简介:《C语言从头学》汇编是一份面向零基础及初学者的C语言自学PDF资料,由LaoWaiHang整理其CSDN专栏系列文章而成,作者在汇编时修正了原文表述不清之处,并充实完善了案例程序。全书沿用“文字+案例代码”的体例,按从基础语法到复杂结构的顺序编排,涵盖入门程序、变量与数据类型、运算符、流程控制、指针、函数、结构体与联合体、数组与字符串、内存管理、文件操作、预处理指令及常用标准库头文件等知识点,每章配有示例代码,便于对照理解。资源包内共1个PDF文件,大小约17.03MB,配有目录便于按章节查阅,适合作为系统学习C语言的参考材料。目前已有99人学习,适合希望边看讲解边对照代码练习的C语言初学者使用。
1. 从 C 到汇编:为什么每个写 C 的人都该翻一次这份 PDF
很多人写 C 语言写了三五年,指针、结构体、内存对齐都能聊,但一遇到Segmentation fault就只会加printf。问题出在哪?出在从没看过编译器到底把你的代码翻译成了什么。C 语言是高级语言里最贴近硬件的一层,它和汇编之间几乎只隔着一层薄纱。你写的每一行int a = b + c;,背后都是寄存器搬运、栈帧调整、寻址计算。不理解这一层,调优靠猜,排错靠玄学。
《C语言从头学》这类汇编配套 PDF 的价值就在这:它把 C 的语法结构和汇编指令一一对应起来,让你看到for循环怎么变成cmp加jmp,函数调用怎么压栈出栈,局部变量到底住在哪。适合两类人:一是刚学完 C 语法、想搞懂底层的新手;二是写了多年 C、但性能优化和崩溃排查全靠经验的老手。这份材料不是教你手写汇编,而是教你用汇编的眼睛重新读 C。
2. 把 C 代码变成汇编:从编译命令到最小可读片段
2.1 三条命令拿到不同粒度的汇编输出
拿到任何一份 C 源码,第一步不是读 PDF,而是先让它吐出汇编。GCC 和 Clang 都支持-S选项,但不同参数给出的信息量差别很大。我一般分三步走:
# 第一步:生成默认优化级别的汇编,带源码行号注释 gcc -S -fverbose-asm -O0 demo.c -o demo_O0.s # 第二步:生成高优化级别的汇编,对比看编译器做了什么 gcc -S -fverbose-asm -O2 demo.c -o demo_O2.s # 第三步:直接看目标文件的反汇编,最接近机器真实执行 gcc -c -O0 demo.c -o demo.o objdump -d -M intel demo.o-fverbose-asm会在汇编里插入 C 源码的变量名和行号注释,这是新手能读懂汇编的关键。-O0保留所有中间变量和栈操作,适合学习;-O2会做寄存器分配、循环展开、死代码消除,适合观察优化。objdump -d -M intel用 Intel 语法反汇编,比 AT&T 语法更接近大多数教材的写法。
提示:如果你用的是 macOS,
gcc实际指向 Clang,命令基本兼容,但objdump要换成otool -tv或安装 binutils。Windows 下用 MinGW 或 WSL 都可以。
2.2 一个最小例子:加法函数在汇编里长什么样
拿一个最简单的函数开刀,比看任何理论都直观:
// add.c int add(int a, int b) { int c = a + b; // 核心就这一行 return c; }用gcc -S -fverbose-asm -O0 add.c -o add.s生成后,核心片段大致是这样:
add: push rbp ; 保存调用者的栈帧基址 mov rbp, rsp ; 建立当前函数的栈帧 mov DWORD PTR [rbp-20], edi ; 参数 a 从 edi 存入栈 mov DWORD PTR [rbp-24], esi ; 参数 b 从 esi 存入栈 mov edx, DWORD PTR [rbp-20] ; 把 a 读到 edx mov eax, DWORD PTR [rbp-24] ; 把 b 读到 eax add eax, edx ; eax = eax + edx mov DWORD PTR [rbp-4], eax ; 结果存入局部变量 c mov eax, DWORD PTR [rbp-4] ; 返回值放入 eax pop rbp ; 恢复栈帧 ret这段代码信息量很大。第一,x86-64 下前两个整型参数通过edi和esi传递,不是压栈,这是 System V ABI 的规定。第二,-O0下所有局部变量都在栈上,rbp-20、rbp-24、rbp-4是编译器分配的栈偏移。第三,返回值必须放在eax里,这是调用约定。如果你把-O0换成-O2,整个函数会缩成三条指令:lea eax, [rdi+rsi]然后ret,连栈帧都不建。
2.3 对照 PDF 读汇编:建立 C 语法到指令的映射表
看汇编不能一行行死磕,要建立映射。我建议边看 PDF 边整理一张自己的对照表,下面是我常用的几组:
| C 语法结构 | 典型汇编指令 | 关键寄存器/内存位置 |
|---|---|---|
| 局部变量声明 | mov [rbp-N], 值 | 栈帧内偏移 |
| 算术运算 | add/sub/imul/idiv | eax 为主累加器 |
| if 判断 | cmp+jcc | 标志寄存器 ZF/SF/OF |
| for/while 循环 | cmp+jmp回跳 | 循环变量在栈或寄存器 |
| 函数调用 | call+ 参数寄存器 | rdi/rsi/rdx/rcx/r8/r9 |
| 指针解引用 | mov rax, [rax] | 方括号表示内存访问 |
| 数组索引 | [base + index*size] | 比例变址寻址 |
这张表不是背的,是每次看汇编时对照填的。PDF 里通常会按章节拆解这些结构,你跟着它的顺序,每读一章就自己写一个最小 C 文件,编译、反汇编、对照,比光看书记得牢十倍。
3. 栈帧、寄存器与调用约定:读懂汇编的三个硬骨头
3.1 栈帧的建立与销毁:push rbp 到 pop rbp 之间发生了什么
每个函数调用在汇编层面都是一次栈帧的创建和销毁。push rbp把调用者的栈基址压栈保存,mov rbp, rsp把当前栈顶设为新栈基址,之后所有局部变量都通过rbp减偏移来访问。函数返回前pop rbp恢复调用者的栈基址,ret弹出返回地址跳回去。
为什么-O0下局部变量偏移是rbp-4、rbp-8这样递减?因为栈向低地址增长,rbp是栈帧的高地址边界,局部变量往低地址放。rbp-4到rbp-20之间就是当前函数的“地盘”。理解这一点,你就能看懂为什么栈溢出会覆盖返回地址——局部数组越界写到了rbp以上的区域。
注意:
-O2下编译器经常省略rbp,直接用rsp加偏移寻址,这叫“帧指针省略”。此时栈帧布局会变,但逻辑不变。看优化后的汇编时不要死找rbp。
3.2 六个参数寄存器:x86-64 调用约定的硬性规则
x86-64 System V ABI 规定,整型和指针参数按顺序放入rdi、rsi、rdx、rcx、r8、r9,超过六个才压栈。浮点参数走xmm0到xmm7。返回值整型在rax,浮点在xmm0。
这个规则直接决定了你写函数时参数顺序对性能的影响。比如一个频繁调用的小函数,把最常用的参数放前面,能减少压栈操作。下面这个例子能直观看到参数传递:
// args.c int sum6(int a, int b, int c, int d, int e, int f) { return a + b + c + d + e + f; } int sum7(int a, int b, int c, int d, int e, int f, int g) { return a + b + c + d + e + f + g; }sum6的六个参数全在寄存器里,sum7的第七个参数g必须从栈上取。编译后你会看到sum7多了一条mov eax, [rbp+16]之类的指令。这就是调用约定的边界,PDF 里通常会专门用一章讲这个。
3.3 从汇编反推 C 代码:一个逆向练习
读懂汇编的终极检验是反推。给你一段汇编,你能写出对应的 C 代码吗?试试这段:
func: push rbp mov rbp, rsp mov DWORD PTR [rbp-4], edi cmp DWORD PTR [rbp-4], 0 jle .L2 mov eax, DWORD PTR [rbp-4] jmp .L3 .L2: mov eax, 0 .L3: pop rbp ret逐行读:保存栈帧,参数edi存入rbp-4,比较rbp-4和 0,如果小于等于 0 跳到.L2,否则把rbp-4的值放入eax返回,.L2处把 0 放入eax。对应的 C 代码就是一个带条件的函数:
int func(int x) { if (x > 0) { return x; } else { return 0; } }这种练习做上二十个,你看汇编的速度会质变。PDF 里如果有课后题,优先做反推类的,比正向阅读有效得多。
4. 避坑与排查:看汇编时最容易翻车的五个地方
4.1 坑一:AT&T 和 Intel 语法混着看,越看越懵
现象:网上搜到的汇编示例和objdump默认输出对不上,寄存器前面多个%,操作数顺序反着来。原因:GCC 默认输出 AT&T 语法,objdump默认也是 AT&T,但很多教材和 Intel 手册用 Intel 语法。AT&T 是mov src, dst,Intel 是mov dst, src,方向相反。解决:统一用 Intel 语法。objdump加-M intel,GCC 加-masm=intel。看 PDF 时先确认它用哪种语法,不一致就自己转,别混着记。
4.2 坑二:拿 -O2 的汇编去学栈帧,找不到 rbp
现象:按教材说的找push rbp,结果优化后的汇编里根本没有。原因:-O2启用了帧指针省略,编译器用rsp加偏移代替rbp,甚至把局部变量全放进寄存器。解决:学习阶段一律用-O0 -fno-omit-frame-pointer。等-O0看熟了,再对比-O2看编译器怎么优化,这时候你才知道哪些指令是“必要的”,哪些是“可省的”。
4.3 坑三:把编译器生成的临时栈空间当成自己的变量
现象:数局部变量偏移时发现多出几个没声明过的rbp-8、rbp-12。原因:编译器会为表达式求值、函数调用参数准备、对齐填充分配临时栈空间,这些不对应任何 C 变量。解决:以-fverbose-asm的注释为准,有变量名注释的才是你的变量。没有注释的偏移是编译器内部用的,不用管。
4.4 坑四:在不同平台上对比汇编,结论完全对不上
现象:同一份 C 代码,在 x86-64 和 ARM 上编译出的汇编差异巨大,怀疑自己学错了。原因:调用约定、寄存器数量、指令集都不同。x86-64 参数走寄存器,32 位 x86 参数走栈;ARM 有 16 个通用寄存器,参数传递规则也不一样。解决:初学锁定一个平台,推荐 x86-64。PDF 如果没标明架构,看寄存器名就能判断:rax/rbx/rcx是 x86-64,eax/ebx是 32 位 x86,x0/x1是 ARM。
4.5 坑五:只看不写,看完就忘
现象:PDF 翻完了,合上书还是不会看汇编。原因:汇编是技能不是知识,跟学游泳一样,看再多教程不下水照样沉。解决:每看一个 C 语法点,就写一个不超过 10 行的 C 文件,编译、反汇编、逐行注释。我自己的习惯是建一个asm_lab目录,按章节编号存文件,回头复习直接翻自己的注释,比翻书快。
5. 进阶技巧:用汇编视角反哺 C 代码质量
5.1 用编译器探索器快速验证,不装环境也能看汇编
不是每台机器都方便装 GCC,但浏览器里有个编译器探索器可以直接用。把 C 代码贴进去,选 x86-64 GCC 和-O0,右边实时出汇编,还能高亮对应的 C 行。我常用它做两件事:一是快速验证某个语法结构的汇编形态,二是对比不同优化级别的输出差异。比如把-O0切到-O2,看哪些变量被优化进了寄存器,哪些循环被展开了。这个工具不需要安装,适合在没配好环境的机器上临时查证。
5.2 从汇编反推性能瓶颈:三个值得盯的信号
看汇编不只是学习,还能直接指导优化。我盯三个信号:
第一,函数调用过多。如果一个小函数在循环里被反复call,而函数体只有几条指令,考虑加inline或static让编译器内联。汇编里看到call就是一次栈帧开销。
第二,内存访问频繁。如果汇编里反复出现mov rax, [rbp-N]这种从栈上读写,说明变量没被优化进寄存器。检查是不是取了地址、用了volatile、或者编译选项太低。
第三,除法指令。idiv在 x86 上延迟很高,如果除数是常数,编译器会优化成乘法和移位。如果汇编里出现idiv,说明除数是变量,考虑能不能改成常数或预计算倒数。
5.3 一个具体习惯:每次提交前扫一眼关键函数的汇编
我现在养成的习惯是,任何涉及性能敏感的代码,提交前用-O2编译一次,objdump扫一眼关键函数。不逐行读,只看三件事:有没有意外的call,有没有栈上反复读写,有没有idiv。这三样只要出现一个,就回去改 C 代码。这个习惯帮我抓过好几次“看起来没问题但实际很慢”的代码,比如一个被误用成值传递的大结构体,汇编里全是memcpy调用。
血泪经验是,不要等性能测试报告出来才看汇编,那时候改造成本已经高了。把看汇编变成写 C 的常规动作,就像写完代码顺手格式化一样自然。希望帮到你。
本文还有配套的精品资源,点击获取