☰
CSAPP hello生命周期:编译链路、地址翻译与进程运行复习地图
2026/11/6 5:52:07 网站建设 项目流程

简介:这份文档面向计算机系统课程学习者与准备CSAPP大作业的学生,以Hello程序从源码到可执行文件、再到运行与终止的生命周期为主线,系统串联计算机系统概述、预处理、编译、汇编、链接、进程管理、信号与异常、内存管理及输入输出管理等核心知识。压缩包仅含一个文档文件,类型为doc,大小约6.44MB,正文按章节组织,涉及Ubuntu环境下的命令操作、预处理后的头文件展开与宏替换、编译汇编中间结果、可重定位目标文件结构、进程与异常分析等内容,便于对照课程要求梳理实验流程。资源已有358人学习或下载,适合用作大作业报告框架参考、底层知识复习和实验步骤查漏补缺;读者可借此理解Linux下程序生成、加载与运行的关键环节,并积累报告撰写与过程分析思路。

1. 一张知识地图:hello 从 hello.c 到进程退出要穿过哪几层

CSAPP 的知识点散在教材十几个章节里,但一份 hello 程序的生命周期作业就能把它们串成一条完整主线。把这条线摊开看,是一层层向下穿透的过程:源文件层做预处理与编译,目标文件层做汇编与链接,机器指令层落到寄存器与栈帧,内核层走 fork 与 execve,硬件层做地址翻译与缓存,设备层收在 I/O 接口上。每一层都留下一个可以被验证的实物——hello.i、hello.s、hello.o、hello、进程号、页表项、文件描述符,复习时能指着产物说话,比背概念靠谱得多。

这份笔记适合已经学过一轮、但知识点在脑子里还是零散状态的人。先看清自己现在停在哪一层,再按层往下补细节,比从头重读一遍教材省时间。中间几章按“是什么、重点在哪、怎么验证”的顺序排,最后一章给出可照着走的复习清单。

2. 构建链路四步:四个中间文件与它们各自留下的证据

一个 hello.c 走到可执行文件 hello,编译系统把它切成四个阶段,每个阶段都留下一个能打开看的文件。很多人复习时记住了四个阶段的名字,却说不出每个阶段的输入输出边界在哪、产物里多了什么少了什么、报错时该回头看哪个文件。真正吃透这条链路的标志是:拿到任意一个中间文件,你能说出它是文本还是二进制、里面有没有符号表、能不能被加载器直接执行。下面按四个阶段的顺序拆,每段配一条命令、一份产物结构、一个验证动作。

2.1 预处理:hello.i 里多出来的三千行是什么

预处理器是整套工具链里最容易被低估的一环。它做的事听着简单——把以 # 开头的行解释成预处理指令并执行——但它直接决定了编译器前端看到的源文件长什么样。ISO C 要求支持的指令包括条件编译族 #if、#ifdef、#ifndef、#else、#elif、#endif,宏定义 #define,源文件包含 #include,行控制 #line,错误指令 #error,实现相关杂注 #pragma,以及单独一个 # 构成的空指令。这些指令的共同点是:在词法分析之前就已经被处理干净,编译器根本看不到它们的存在。

指令作用处理时机
#include把指定文件递归复制到当前位置预处理最开始
#define宏名替换为实际值,可带参数文本扫描时逐个替换
#if / #ifdef 系列按条件裁剪代码块宏替换之后判定
#line改写后续行号与文件名标记影响报错定位
#error条件不满足时直接中断编译判定为真时触发
#pragma传给具体实现的杂注由实现自行解释

实际操作只要一条命令,-E 的含义是“停在预处理阶段,不往下走”:

# 只做预处理,-E 表示到此为止,产物仍是 C 文本 gcc -E hello.c -o hello.i # 等价的独立预处理器调用,用重定向写入文件 cpp hello.c > hello.i # 对比行数,直观看到展开规模 wc -l hello.c hello.i

参数上,-E 是阶段开关,-o 指定输出文件名;如果省略 -o,结果会直接打到标准输出,容易刷屏。cpp 是预处理器本体,gcc -E 内部调用的就是它,两者结果一致。作业里观察到的现象是 hello.c 展开成 3065 行,而开发者写的源程序出现在第 3046 行及之后——前面三千行几乎全是展开内容。这些内容归为三类:stdio.h、unistd.h、stdlib.h 三个头文件被逐层递归复制进来,头文件里还套着头文件,展开量迅速放大;宏定义被替换成实际值,宏名在 hello.i 里已经不存在;条件编译把不满足条件的分支整段裁掉,同时所有注释被清除。

提示:在 hello.i 里搜索以 # 开头的行,会看到大量形如# 1 "hello.c"的标记。那是 #line 留下的行号信息,用来让后续阶段的报错指回原始文件,不是注释。

2.2 编译:五个阶段与 hello.s 里的伪指令

编译是整个链路里理论密度最高的一段。它的任务是把预处理后的 C 文本翻译成汇编语言程序,工作过程分成五个阶段:词法分析把字符流切成记号并标注非法记号;语法分析把记号序列按文法归约成中间表示并建立符号表;语义检查与中间代码生成做静态语义核对;代码优化把中间表示换成功能等价但更省时间或空间的版本;目标代码生成输出汇编。前两个阶段合称源程序分析,语法错误就报在这一步。

# 从预处理结果生成汇编,-S 停在编译阶段 gcc -S hello.i -o hello.s # 也可以从 .c 一步到 .s,gcc 会自动先跑一遍预处理 gcc -S hello.c -o hello.s

-S 是阶段开关,产物 hello.s 依然是纯文本,可以直接用编辑器打开。这一步的关键认知是:hello.s 里以点开头的名字不是指令助记符,不会被翻译成机器码,它们是给汇编器的指示,称为汇编指示或伪操作。把它们认全,读汇编才不会卡壳。

伪指令含义
.file声明源文件名,供调试信息使用
.text其后内容归入代码段
.globl声明符号为全局,链接时对其他模块可见
.data已初始化的全局变量与静态变量区
.rodata只读数据区,常放字符串常量与 #define 常量
.align指定后续数据的地址对齐方式
.section显式切换到某个段
.long / .string声明 long 型数据 / 声明字符串
.size / .type声明符号大小 / 声明是函数还是对象

2.3 汇编:hello.s 到 hello.o 的 ELF 可重定位格式

汇编器把 hello.s 翻译成机器语言指令,并把这些指令打包成可重定位目标程序的格式,结果写进 hello.o。注意这里的“可重定位”三个字:hello.o 里的地址还不是最终地址,凡是引用了外部符号或全局数据的地方都留着空位和重定位条目,等链接器来填。这也是它不能直接执行的原因。

# 汇编:文本汇编文件 -> 二进制可重定位目标文件 gcc -c hello.s -o hello.o # 把 ELF 的全部结构导出成可读文本,方便逐节对照 readelf -a hello.o > hello.elf # 只看节头表,快速确认有哪些节、各自多大、文件偏移在哪 readelf -S hello.o

-c 表示“编译或汇编到目标文件为止,不链接”。readelf -a 会把 ELF 头、节头表、符号表、重定位表、反汇编统统打出来,量很大,重定向到文件再慢慢看更实际。下面的节区表是读 hello.o 时必须能对号入座的:

节内容特点
.text已编译的机器代码只读可执行
.rodata只读数据,如格式串、跳转表只读
.data已初始化的全局与静态变量可读写,占文件空间
.bss未初始化或初始化为 0 的全局与静态变量只占内存不占文件空间
.symtab符号表,记录函数与全局变量链接时被读取
.rel.text / .rel.data代码段与数据段的重定位条目链接器据此改地址
.debug调试符号,需 -g 生成可剥离
.strtab符号名对应的字符串表与 .symtab 配合

.bss 那一行是高频考点:它不在文件里占实际字节,只记录“需要多少字节、起始在哪”,加载时由内核把对应内存清零。所以在 hello.o 的节头表里能看到 .bss 有大小,却没有对应的文件内容。

2.4 链接:符号解析、重定位与虚拟地址空间的定型

链接器做两件事:符号解析,把所有目标模块里引用的符号和定义的符号对上号;重定位,把每个符号的引用改写成最终运行时的地址。做完这两步,输出的 hello 才有确定的入口地址和段布局,能被加载器直接送进内存执行。

# 链接:把 hello.o 与所需库合并成可执行文件 gcc -o hello hello.o # 查看 ELF 头,重点是入口地址与文件类型 readelf -h hello # 查看程序头表,也就是加载器眼里的段视图 readelf -l hello # 查看依赖的共享库,验证动态链接是否生效 ldd hello

这里有个容易混淆的点:节头表是给链接器看的,程序头表是给加载器看的,两者不是一一对应。链接之后再看 hello 的虚拟地址空间,布局大致如下:

区域位置内容
只读代码段低地址,典型从 0x400000 起.init、.text、.rodata
读写数据段紧接代码段之后.data、.bss
运行时堆数据段之后,向高地址增长malloc 分配的内存
共享库映射区地址空间中上部libc.so 等共享对象
用户栈高地址,向低地址增长局部变量、返回地址、参数
内核区最高一段用户态不可访问

hello 用到了 printf、exit、sleep、getchar,这些都在标准 C 库里,因此它是动态链接的:可执行文件里只保留 PLT 桩和 GOT 表项,真正的函数体在 libc.so 中,运行时由动态链接器把地址填进 GOT。这也解释了为什么反汇编 hello 时看到的调用目标是printf@plt而不是 printf 的实体。

3. 机器级代码:hello.s 里数据、跳转与调用的翻译规则

从 C 到汇编,编译器其实只做三件事:给每个变量找位置、把每种控制结构翻译成比较加跳转、把每次函数调用翻译成寄存器传参加栈帧切换。把这三件事的规则记牢,任意一段 hello 级别的代码都能读下来。这一层是整份作业里最容易“看得懂但写不出”的部分,原因是教材给的规则是正向的,而读汇编需要反向推理——看到一条 cmpl,要能倒推出它对应源码里的哪个 if。

3.1 数据落位:字符串、整型与数组分别放在哪

hello 里出现的字符串有两条:"Usage: Hello 学号 姓名!\n"和"Hello %s %s\n"。中文部分按 UTF-8 编码,本质仍是一串字节。按作业里的观察,这两条格式串出现在 .data 段,而伪指令表里 .rodata 才是字符串常量的常规去处——这个差异恰好说明字符串放哪个段并不是语言规定的,而是编译器按优化级别和读写属性自行决定的,复习时不要把它当成铁律背。

整型的处理更能看出编译器的取舍。int sleepsecs = 2.5;是一次隐式强制类型转换,浮点常量被截断成整数 2,sleepsecs 作为全局 int 占 4 字节,初值由 .data 段直接给出。argc是 main 的第一个参数,通过 %edi 传进来,第 28 行和第 30 行把它与 3 作比较来决定走哪条分支。循环变量i是局部变量,落在栈上 (%rbp-4) 的位置,第 37 行赋初值 0,第 53 行自增,第 55 行与 10 比较。

数组方面,char *argv[]是指针数组,x86-64 下每个指针 8 字节,所以 argv[i] 的访问就是首地址加减 8 的整数倍。argv 作为第二个参数放在 %rsi 中,首地址是 (%rbp-32);第 41 行把 argv[2] 送进 %rdx,第 44 行把 argv[1] 送进 %rsi,正好对应 printf 的第三、第二个可变参数。

3.2 控制转移:cmp 搭配 je 与 jle 的条件码逻辑

C 里的 if 和 for 在汇编里没有对应指令,编译器一律翻译成“比较 + 条件跳转”。比较指令不产生结果,只设置条件码:ZF 表示结果为零,SF 表示结果为负,OF 表示有符号溢出。跳转指令再根据这些标志位决定走向。

if(argc != 3)的翻译是先用 cmpl 把 argc 与 3 相减,用差值设置条件码,再用 je 判断 ZF。若 ZF 为 0 说明 argc 减 3 不为零,即 argc 确实不等于 3,就顺序执行 if 体内的代码;若 ZF 为 1 则直接跳到 .L2 跳过整块。for(i=0; i<10; i++)的翻译稍有不同:第 55 行用 cmpl 比较 i 与 9,第 56 行用 jle 在“小于等于”时跳回循环体入口 .L4,否则顺序执行到 getchar()。注意这里是拿 i 和 9 比而不是和 10 比,因为“i 小于 10”等价于“i 小于等于 9”,编译器顺手省掉一次减法。

/* 源码语义:两个分支,一个循环 */ if (argc != 3) { /* 参数个数不对,打印用法后退出 */ } for (i = 0; i < 10; i++) { /* 循环体里调用 getchar */ }

对应到汇编,关键是记住条件跳转是“满足条件就跳”,不是“满足条件就执行”。读代码时先看跳转目标在哪,再倒推源码结构,比逐条翻译快得多。

3.3 函数调用:栈帧、寄存器传参与 PLT 跳转

main 函数的原型是int main(int argc, char *argv[]),它本身不会被谁“主动”调用,而是由系统启动函数__libc_start_main通过 call 指令拉起来的。call 指令做两件事:把下一条指令的地址压栈作为返回地址,然后跳转到目标。函数返回时执行 ret,等价于把返回地址弹出到 %rip。栈帧的建立靠 %rbp:进入函数后先把旧的 %rbp 压栈,再把当前 %rsp 赋给 %rbp,之后通过对 %rbp 做减法来开辟局部空间。函数结束时执行 leave,等价于mov %rbp,%rsp加pop %rbp,把栈恢复到调用前状态。

x86-64 的参数传递走寄存器,顺序是 %rdi、%rsi、%rdx、%rcx、%r8、%r9,超出的部分才走栈;返回值放 %eax。hello 里的几处调用正好覆盖了这些规则:第一次 printf 把"Usage: Hello 学号 姓名!\n"的首地址送进 %rdi,编译器发现没有格式说明符,直接把它降级成puts@PLT;第二次带%s %s,必须用printf@PLT。exit(1)把 1 送进 %edi 再调exit@PLT;sleep(sleepsecs)把全局变量送进 %edi 再调sleep@PLT;循环里的getchar()直接调getchar@PLT。@PLT 后缀说明这是动态链接的延迟绑定入口,第一次调用时才会真正解析到 libc.so 里的地址。

4. 运行期:fork、execve、地址翻译与 I/O 落点

构建完成只说明磁盘上多了个文件,真正让 hello 动起来的是内核。在终端敲下./hello,shell 要 fork 出子进程、execve 换掉子进程的地址空间、内核给它分配时间片、MMU 把每条指令的虚拟地址翻成物理地址、printf 最终落成一次 write 系统调用。这一章把运行期拆成五段,每段都对应一个能被观察到的现象,比如进程号变化、信号处理函数的返回值、缺页次数的跳动。

4.1 fork 与 execve:shell 把 hello 换进当前进程的两步

shell 是一个常驻的用户态程序,它的处理流程是循环读命令行、解析参数、判断是不是内建命令,若不是就 fork 出子进程,由子进程 execve 加载目标程序,父进程则 waitpid 等待子进程结束。fork 调用一次返回两次:父进程拿到子进程的 PID,子进程拿到 0,靠这个返回值区分两条执行路径。fork 出来的子进程几乎完全复制了父进程的地址空间,但采用的是写时复制,页表项被标记为只读,谁写谁触发缺页再复制,所以开销远小于全量拷贝。

execve 则是在当前进程里加载并运行新程序,用 hello 替换掉子进程里原本的 shell 代码。它要完成四步:删除当前进程虚拟地址用户部分里已存在的区域结构;为新程序的代码、数据、bss 和栈创建新的区域结构,其中代码和数据映射到 hello 文件里的 .text 与 .data,bss 和栈是请求二进制零的,映射到匿名文件,初始长度为零;如果程序与共享对象链接,比如标准 C 库 libc.so,就把这些共享对象映射到用户地址空间的共享区域;最后设置当前进程上下文里的程序计数器,让它指向代码区域的入口点。

注意:作业里把调用简写成execve("hello", NULL, NULL)是为了突出“加载”这个动作。标准签名是execve(const char *filename, char *const argv[], char *const envp[]),真实调用会传入完整的参数数组和环境变量数组。

4.2 异常与信号:Ctrl-C、Ctrl-Z 与 SIGCHLD 的回收链

异常是控制流的突变,按原因和处理方式分四类。中断是异步的,来自外部设备,比如键盘敲击和定时器;陷阱是有意的,比如系统调用,执行完返回下一条指令;故障是出错但可修复,比如缺页,处理完重新执行引发故障的那条指令;终止是不可恢复的硬件错误,直接结束进程。这条分类线很重要,因为缺页、除零、非法内存访问分别对应不同的后续动作,混在一起记就会在分析现象时判断失误。

信号是内核发给进程的软中断通知。Ctrl-C 让终端驱动给前台进程组发 SIGINT,默认动作是终止;Ctrl-Z 发 SIGTSTP,默认动作是挂起;子进程结束时内核给父进程发 SIGCHLD,父进程靠它知道该去回收了。进程对信号的处置有三种:默认动作、忽略、捕获。捕获就是用 signal 或 sigaction 注册一个处理函数,函数执行完返回到被中断的那条指令继续跑。这里有个血泪经验:sleep 被信号打断会提前返回,返回值是剩余未睡完的秒数,如果代码里写的是while(sleep(n))之外的简单判断,就会误以为睡够了。

回收这一步也容易断链。子进程结束后并不会立刻消失,它变成僵尸进程等待父进程读取退出状态,直到 waitpid 取走状态码,内核才把它占用的所有痕迹清干净。shell 的整个生命周期就是 fork、execve、waitpid 三个动作循环,这也是 hello 的 0 到 0 里最后一个 0 的落点。

4.3 地址翻译:段式、页式、TLB 与三级 Cache 的四道关

程序里的地址是逻辑地址,由段选择符加段内偏移构成。段式管理先用段选择符查段描述符表拿到段基址,加上偏移得到线性地址;在平坦模型下段基址通常为零,于是线性地址等于虚拟地址。页式管理再把虚拟地址切成两部分:高位是虚拟页号,低位是页内偏移。x86-64 的四级页表把 48 位虚拟地址中的 36 位页号再切成四段各 9 位,加上 12 位页内偏移,逐级索引 PGD、PUD、PMD、PTE,最终从页表项里取出物理页框号,拼上偏移就是物理地址。

这条路上有两级加速。TLB 是 MMU 内部的一张翻译缓存,存的是虚拟页号到物理页框号的映射,命中就不用走四级页表;未命中才查页表,查到后回填 TLB。拿到物理地址后再查 Cache:物理地址被切成标记、组索引、块偏移,用组索引定位到组,用标记逐一比对,命中直接取数据,未命中才去下一级或主存取整个块。TLB 命中不代表 Cache 命中,这两件事经常被混为一谈——前者省的是地址翻译,后者省的是访存。

4.4 缺页与动态分配:写时复制的页面和 malloc 的取舍

缺页故障发生在页表项有效位为 0 的时候,说明这个虚拟页还没装进物理内存。处理流程是:处理器把虚拟地址和控制信息存进寄存器,触发缺页异常,内核的缺页处理程序据此判断是合法访问还是越界,合法就选一个牺牲页、必要时写回磁盘、把目标页调入并更新页表项,然后重新执行引发故障的那条指令。fork 的写时复制正是靠这套机制实现的:父子进程共享只读页面,任何一方写入都会触发缺页,内核这时才真正复制一份。

动态存储分配解决的是编译期无法确定大小的问题。malloc 从堆里划出一块,free 归还,中间不移动已分配的块。分配器的设计是一场取舍:吞吐率高意味着分配回收快,利用率高意味着碎片少,两者很难兼得。实现方式从隐式空闲链表到显式空闲链表再到分离空闲链表,本质上都是拿查找时间换空间利用率。碎片分两种,内部碎片是分配块大于请求造成的浪费,外部碎片是空闲块总量够但没有单块够大。

4.5 I/O 落点:Unix I/O 接口与 printf、getchar 的实现

Linux 的设备管理遵循“一切皆文件”,每个打开的文件对应一个文件描述符,进程通过描述符表找到打开文件表项,再找到 v-node 或 inode。最底层的接口是 Unix I/O 那一组:open 打开、read 读、write 写、lseek 定位、stat 取元数据、close 关闭。上层所有带缓冲的库函数最终都要落到这组调用上。

printf 的路径是:先按格式串解析出各个参数,把结果拼进 FILE 结构里的用户态缓冲区,等缓冲区满、遇到换行(行缓冲)或显式 fflush 时,才调用 write 把整块内容交给内核。getchar 走反方向:它不一次读一个字节,而是调用 read 一次性读入一整块到缓冲区,之后每次调用只是从缓冲区里取下一个字符,取空才再触发一次 read。理解了这层缓冲,才能解释为什么 printf 之后紧接一个崩溃,输出可能一行都没出来——内容还躺在用户态缓冲区里没来得及 write。

5. 易漏易错点:五处最容易被跳过的地方

复习到这一层,概念基本都见过,真正拉开差距的是那些“看课本时觉得理所当然、动手时才发现对不上”的细节。下面五条是按翻车频率排的,每一条都给出漏在哪、为什么漏、怎么补。

易漏点一:预处理产物的行号标记。打开 hello.i 看到三千多行,只注意到前面是头文件内容,忽略了大量以 # 开头的行。为什么漏:习惯性把这些行当注释,扫一眼就翻过去了。怎么补:单独 grep 出所有以 # 开头的行,对照原始文件确认每一条 #line 记录的是哪个文件的第几行,这样能看懂后续阶段报错为什么能指回 hello.c 而不是 hello.i。

易漏点二:.bss 只占内存不占文件。为什么漏:readelf -S 里 .bss 明明有 size,容易误以为文件里也有对应数据。怎么补:对比节头表里的文件偏移和大小两栏,.bss 的文件大小为 0,只在装载时按大小清零分配。顺带记住 .data 和 .bss 的分界就是“初值是否为零”,显式初始化为 0 的变量同样进 .bss。

易漏点三:字符串到底在 .data 还是 .rodata。为什么漏:伪指令表里写着 .rodata 放字符串,于是当成定论,遇到实际产物在 .data 就怀疑自己看错。怎么补:用 -O0 和 -O2 各生成一份 hello.s 做 diff,观察同一段代码在不同优化级别下数据放置和指令选择的差异,结论自然从“背规则”变成“看上下文”。

易漏点四:printf 与 puts 的选择。为什么漏:以为源码写 printf 就一定调 printf。怎么补:回到 hello.s 里核对两条输出语句的调用目标,带格式说明符的走 printf@PLT,不带说明符、只输出一个字符串的会被编译器替换成 puts@PLT,两者行为在自动补换行这一点上还有差别。

易漏点五:TLB、页表、Cache 的层级混淆。为什么漏:三者都跟“地址”打交道,复述流程时容易串成一条线上依次经过的关。怎么补:画一张顺序表,明确每关的输入是什么、输出是什么、命中就不用往下走——TLB 输入虚拟页号输出物理页框号,页表输入虚拟页号输出物理页框号,Cache 输入物理地址输出数据块。三者是两段串联关系,不是三段并列关系。

6. 复习清单:按产物过一遍比按章节过一遍快

这份作业的知识点虽然横跨大半本教材,但落到复习上其实有一条很省力的路径:不按章节顺序看,而是按产物顺序看。每拿到一个文件,先问自己四个问题——它是什么格式、由哪条命令生成、里面有哪些节或字段、下一步谁消费它。四个问题答不上来,说明这一层还没通,翻回对应章节补,比从头通读效率高得多。

顺序产物生成命令验证动作对应知识
1hello.igcc -Ewc -l 对比行数,grep '^#'头文件展开、宏替换、条件编译
2hello.sgcc -S认全所有点开头的伪指令五阶段、数据落位、控制转移
3hello.ogcc -creadelf -S 看节头表与 .bssELF 可重定位格式、符号表
4hellogcc -oreadelf -l、ldd、反汇编找 @plt符号解析、重定位、动态链接
5进程./hellops 看进程状态变化fork、execve、waitpid
6地址无需命令手推一遍 48 位虚址的拆分段式、四级页表、TLB、Cache

几个能立刻用上的小技巧:用readelf -S hello.o的输出对照节区表逐行核对,比看教材图示记得牢;用gcc -O0 -S和gcc -O2 -S各存一份汇编,diff 一下就能看清优化到底动了什么,比如循环展开、函数内联、printf 降级为 puts 都是在这一步发生的;遇到信号相关的问题,用 strace 跟一遍系统调用,能看到 fork、execve、write、wait4 的实际发生顺序,比对着书想象直观。

我自己的教训是,第一遍做这份作业时把所有精力花在“写出正确结果”上,预处理、汇编、链接都是一条命令跑完就往下走,中间的 .i 和 .s 文件根本没打开看过。等到复习时才发现,能背出四个阶段的名字,却说不清 hello.i 里第 3046 行之前到底是什么,也说不出 hello.o 的 .bss 为什么文件大小是零。从那以后我每次碰编译链路,都强制走一遍“生成产物、打开产物、说出结构、再做下一步”的流程,慢是慢一点,但每一层都是自己看过的,不是听来的。希望这份清单帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询