继上一篇探讨了数据表示与浮点数之后,这篇笔记我们将深入 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 内部极速的存储单元,比如
%rax(64位)。 - 内存 (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(): 从标准输入读数据,直到遇到换行符。完全不管缓冲区有多大。C++11 终于忍无可忍把它从标准中移除了。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 字节的数组字段将其按字节读出来,极其高效。
- 应用场景补充: 联合体经常被用来绕过类型系统,特别是在底层协议解析(比如网络编程解析 IP/TCP 头协议栈)时极为常见。你可以往联合体里写入一个 32 位的
