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

继上一篇探讨了数据表示与浮点数之后,这篇笔记我们将深入 CPU 的视角。看看我们用高级语言写的 C/C++ 代码,是如何被翻译成机器指令、如何在寄存器和内存之间穿梭,以及稍不留神就会引发毁灭性灾难的"缓冲区溢出"到底是怎么发生的。

Lec 05 Machine-Level Programming I: Basics

机器指令的诞生

一段代码从你敲下到被机器执行,要经历:C代码 -> 汇编代码 (Assembly) -> 目标代码 (Object Code)

高级语言里的变量 destt 只是给程序员看的代号,在汇编层面,它们全都会变成寄存器(Registers)内存地址(Memory Addresses)

汇编代码:数据的搬运工

汇编中最常见的指令就是搬运数据。

移动数据的主要指令是 mov,它的操作数(Operands)分为三种:

  1. 立即数 (Immediate): 常数,比如 $0x400。在汇编里以 $ 打头。
  2. 寄存器 (Register): CPU 内部极速的存储单元,比如 %rax(64位)。
  3. 内存 (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 (条件传送指令):ifelse 两个分支的值都算出来,然后根据条件选择一个覆盖回去

但这只适用于简单计算,以下情况对这种优化极不友好:

  1. 额外计算开销大: 如果某一个分支里需要执行复杂的运算,全部算出来太浪费 CPU 周期。
  2. 危险计算 (Risky Computations): 比如 val = p ? *p : 0;。如果 p 是空指针,你强行把 *p 算出来,会导致直接段错误崩溃。
  3. 副作用 (Side Effects): 比如表达式里带有 x++。如果两个分支都执行,最终结果会被错误地累加。

跳表 (Jump Table)

当函数包含庞大的 switch-case 语句时,如果编译成一堆 if-else,执行效率就是 O(N)。

编译器的魔法是生成一张跳表 (Jump Table) 。它实际上是一个存储了代码块地址的数组。通过变量的值作为索引直接访问数组,无论有多少个 case,执行时间都是 O(1)。

Stack (栈)

系统栈是一块由 CPU 和操作系统自动管理的内存区域。

  • pushpop 操作本质上就是移动栈顶指针 %rsp 并读写数据。
  • 注意反直觉的一点: 在 x86 架构中,栈是向低地址生长的。栈顶的内存地址其实是这块区域里最小的。

Lec 07 Machine-Level Programming III: Procedures

数据流过程与函数调用机制

当我们调用一个函数(Procedure/Function)时,底层到底发生了什么?

本质上是一个严格遵循后进先出 (LIFO) 的栈操作过程。

  1. 准备参数: 把参数塞进寄存器(多余的压入栈)。
  2. 转移控制: 也就是 call 指令。它干了两件事:把下一条指令的地址(返回地址)压入栈中,然后跳转到目标函数的首地址。
  3. 分配栈帧: 目标函数一开始,通常会压入老 %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(): 从标准输入读数据,直到遇到换行符。完全不管缓冲区有多大。C++11 终于忍无可忍把它从标准中移除了。
  • strcpy(), strcat(): 如果目标缓冲区太小,照样溢出。
  • scanf("%s"): 如果没有指定宽度(如 %10s),后果自负。

现代替代方案: 永远使用安全的版本,如 fgets(),或者在 C++ 中直接使用 std::stringstd::array,把内存管理的脏活交给标准库。

异质数据结构 (Compound Types in C)

理解内存布局最后一块拼图:

  • 数组 (Array): 连续分配,指针指向首元素,没有边界检查。
  • 结构体 (Struct): 按声明顺序分配内存。正如我们在 Lec 08 看到的,中间和末尾会有"补齐(Padding)"以满足对齐需求。
  • 联合体 (Union): 所有字段共享同一块内存(覆盖声明)。它的大小就是它最大字段的大小。
    • 应用场景补充: 联合体经常被用来绕过类型系统,特别是在底层协议解析(比如网络编程解析 IP/TCP 头协议栈)时极为常见。你可以往联合体里写入一个 32 位的 uint32_t 网络地址,然后无缝地通过一个 4 字节的数组字段将其按字节读出来,极其高效。
相关推荐
正经人_x2 小时前
学习日记43:FINO
学习
Ztt6666666662 小时前
六方壶不只看棱角,转得温和才耐看
经验分享·笔记·其他·百度·微信公众平台
网络工程小王4 小时前
【HCIE-AI】11.模型 昇腾迁移适配-精度调试-性能调优
人工智能·学习·华为·迁移学习·昇腾
RainCity4 小时前
Java Swing 自定义组件库分享(十五)
java·笔记·后端
ysa0510306 小时前
【板子】二分答案(最大最小?)
c++·笔记·算法·板子
吃好睡好便好7 小时前
近期几点体会
学习·生活
xlrqx7 小时前
商丘家电清洗培训零基础学习需掌握哪些要点零基础到底能不能学
python·学习
编程圈子8 小时前
电机驱动开发学习19. 霍尔 BLDC 三段式 FOC 启动算法
驱动开发·学习·算法
MartinYeung58 小时前
[论文学习]LLM 中的个性化安全:一个基准测试与基于规划的智能体方法
人工智能·学习·安全