CMU 15-213 CSAPP:机器级编程与内存的暗黑魔法(Machine-Level)

发布时间:2026/7/22 3:58:46
CMU 15-213 CSAPP:机器级编程与内存的暗黑魔法(Machine-Level) 继上一篇探讨了数据表示与浮点数之后这篇笔记我们将深入 CPU 的视角。看看我们用高级语言写的 C/C 代码是如何被翻译成机器指令、如何在寄存器和内存之间穿梭以及稍不留神就会引发毁灭性灾难的“缓冲区溢出”到底是怎么发生的。Lec 05 Machine-Level Programming I: Basics机器指令的诞生一段代码从你敲下到被机器执行要经历C代码 - 汇编代码 (Assembly) - 目标代码 (Object Code)。高级语言里的变量dest、t只是给程序员看的代号在汇编层面它们全都会变成寄存器Registers和内存地址Memory Addresses。汇编代码数据的搬运工汇编中最常见的指令就是搬运数据。移动数据的主要指令是mov它的操作数Operands分为三种立即数 (Immediate):常数比如$0x400。在汇编里以$打头。寄存器 (Register):CPU 内部极速的存储单元比如%rax64位。内存 (Memory):根据寄存器里的地址去内存里找数据比如(%rax)相当于 C 语言里的解引用*rax。注意x86-64 架构不允许直接将数据从一个内存地址mov到另一个内存地址。如果要在内存间倒腾数据必须让寄存器做“中转站”。地址模式与指针魔法利用指针和地址来进行算法操作底层的核心指令是leaq(Load Effective Address加载有效地址)。知识点补充leaq究竟是干嘛的你可以把它看作是并不真正访问内存的mov。它只是利用 CPU 的地址计算硬件算出一个地址值然后塞进寄存器里。编译器特别聪明经常用leaq来做简单的算术运算比如加法和乘法因为它连 ALU算术逻辑单元都不用过直接在地址生成单元就光速算完了。算术表达式分解与寄存器约定理解在 x86-64 架构中函数调用时参数是如何传递的前六个整数或指针参数被严格规定放在寄存器中传递顺序依次是%rdi,%rsi,%rdx,%rcx,%r8,%r9。多余的参数才会放到栈内存里去。返回值统一放在%rax里。Lec 06 Machine-Level Programming II: Control状态码 (Condition Codes)CPU 如何知道if (a b)是否成立答案是条件码寄存器 (Flags Register)。除了普通的寄存器CPU 还有几个单比特的标志位如CF(进位标志),ZF(零标志),SF(符号标志),OF(溢出标志)。set指令可以根据这些条件码的组合把目的寄存器的最低字节设为 0 或 1这正是高级语言中布尔值Boolean的底层实现。分支控制与预测汇编语言实现条件分支的核心是比较指令 (cmp) 跳转指令 (jle,je等) 跳转标签 (Label)。深度拓展条件传送 (Conditional Move) 与分支预测现代 CPU 都有“分支预测”机制如果它猜错了if-else的走向会清空流水线带来严重的性能惩罚。因此编译器有时会使用cmov(条件传送指令)把if和else两个分支的值都算出来然后根据条件选择一个覆盖回去。但这只适用于简单计算以下情况对这种优化极不友好额外计算开销大:如果某一个分支里需要执行复杂的运算全部算出来太浪费 CPU 周期。危险计算 (Risky Computations):比如val p ? *p : 0;。如果p是空指针你强行把*p算出来会导致直接段错误崩溃。副作用 (Side Effects):比如表达式里带有x。如果两个分支都执行最终结果会被错误地累加。跳表 (Jump Table)当函数包含庞大的switch-case语句时如果编译成一堆if-else执行效率就是 O(N)。编译器的魔法是生成一张跳表 (Jump Table)。它实际上是一个存储了代码块地址的数组。通过变量的值作为索引直接访问数组无论有多少个case执行时间都是 O(1)。Stack (栈)系统栈是一块由 CPU 和操作系统自动管理的内存区域。push和pop操作本质上就是移动栈顶指针%rsp并读写数据。注意反直觉的一点在 x86 架构中栈是向低地址生长的。栈顶的内存地址其实是这块区域里最小的。Lec 07 Machine-Level Programming III: Procedures数据流过程与函数调用机制当我们调用一个函数Procedure/Function时底层到底发生了什么本质上是一个严格遵循后进先出 (LIFO)的栈操作过程。准备参数把参数塞进寄存器多余的压入栈。转移控制也就是call指令。它干了两件事把下一条指令的地址返回地址压入栈中然后跳转到目标函数的首地址。分配栈帧目标函数一开始通常会压入老%rbp基址指针分配局部变量需要的内存这一块属于这个函数的私人领地叫栈帧 (Stack Frame)。正是因为每个函数都有自己独立的栈帧**递归Recursion**才成为可能。每次调用自己都会在栈上开辟一块全新的独立空间互不干扰直到触底反弹一层层返回ret指令弹出返回地址并跳转。Lec 08 Machine-Level Programming IV: Procedures矩阵乘法与内存寻址在多维数组如矩阵乘法中计算元素的内存地址非常关键。由于内存是一维线性的二维数组本质上是由行拼接而成的线性数组。访问A[i][j]对应的底层逻辑通常包含imulq(整数乘法) 和leaq。内存对齐 (Memory Alignment)为什么要进行字节对齐CPU 读取内存并不是一个字节一个字节读的而是以“块Chunk”比如 4 字节、8 字节甚至是 Cache Line 的 64 字节为单位。如果一个int(4字节) 恰好跨越了两个内存块的边界CPU 为了取这个int不得不进行两次内存访问然后再拼接起来这极大地拖慢了性能。因此编译器会在结构体中插入一些“填充字节Padding”确保每个数据类型都刚好落在属于它的整数倍地址上。这就解释了为什么有时结构体声明的字节加起来明明是 10用sizeof算出来却是 12 或 16。Lec 09 Machine-Level Programming V: Advanced Topic缓冲区溢出 (Buffer Overflow) - 系统的梦魇前面提到C/C 为了性能没有边界检查。如果你在栈上声明了一个容量为 4 字节的数组buf[4]却往里面塞了 24 个字节的数据会发生什么多出来的数据会直接往高地址蔓延覆盖掉栈里的其他内容更恐怖的是如果它一直蔓延覆盖了栈里保存的“函数返回地址”黑客就可以把这个地址修改为恶意代码的所在地。当函数执行ret准备返回时程序控制权就直接交给了黑客。这就是大名鼎鼎的“堆栈粉碎Stack Smashing”。罪魁祸首危险的 C 语言标准库函数gets(): 从标准输入读数据直到遇到换行符。完全不管缓冲区有多大。C11 终于忍无可忍把它从标准中移除了。strcpy(),strcat(): 如果目标缓冲区太小照样溢出。scanf(%s): 如果没有指定宽度如%10s后果自负。现代替代方案永远使用安全的版本如fgets()或者在 C 中直接使用std::string和std::array把内存管理的脏活交给标准库。异质数据结构 (Compound Types in C)理解内存布局最后一块拼图数组 (Array):连续分配指针指向首元素没有边界检查。结构体 (Struct):按声明顺序分配内存。正如我们在 Lec 08 看到的中间和末尾会有“补齐Padding”以满足对齐需求。联合体 (Union):所有字段共享同一块内存覆盖声明。它的大小就是它最大字段的大小。应用场景补充联合体经常被用来绕过类型系统特别是在底层协议解析比如网络编程解析 IP/TCP 头协议栈时极为常见。你可以往联合体里写入一个 32 位的uint32_t网络地址然后无缝地通过一个 4 字节的数组字段将其按字节读出来极其高效。