目录
[4.1 一切皆是 0/1:真值与机器数](#4.1 一切皆是 0/1:真值与机器数)
[4.2 定点数:补码统治世界](#4.2 定点数:补码统治世界)
[4.3 C 语言里的整数陷阱](#4.3 C 语言里的整数陷阱)
[4.4 字节序:同一个数的两种排法](#4.4 字节序:同一个数的两种排法)
[4.5 非数值数据:字符也是 0/1](#4.5 非数值数据:字符也是 0/1)
[4.6 浮点数:IEEE 754](#4.6 浮点数:IEEE 754)
[5.1 从逻辑门到带标志加法器](#5.1 从逻辑门到带标志加法器)
[5.2 加减运算部件:整个运算世界的核心](#5.2 加减运算部件:整个运算世界的核心)
[5.3 乘法与除法](#5.3 乘法与除法)
[5.4 C 运算符在机器级的真实行为](#5.4 C 运算符在机器级的真实行为)
[5.5 浮点运算:对阶、规格化与舍入](#5.5 浮点运算:对阶、规格化与舍入)
[六、程序的机器级表示:ISA 与汇编](#六、程序的机器级表示:ISA 与汇编)
[6.1 IA-32:CISC 的经典标本](#6.1 IA-32:CISC 的经典标本)
[6.2 一条指令的完整一生](#6.2 一条指令的完整一生)
[6.3 指令家族速览](#6.3 指令家族速览)
[6.4 选择与循环:都是条件转移的排列组合](#6.4 选择与循环:都是条件转移的排列组合)
[十、链接与加载:从 .o 到进程](#十、链接与加载:从 .o 到进程)
[10.1 链接为什么存在](#10.1 链接为什么存在)
[10.2 三类目标文件与 ELF 的两种视图](#10.2 三类目标文件与 ELF 的两种视图)
[10.3 符号与符号解析](#10.3 符号与符号解析)
[10.4 静态链接与符号解析算法](#10.4 静态链接与符号解析算法)
[10.5 重定位:把占位符换成真地址](#10.5 重定位:把占位符换成真地址)
[10.6 共享库与动态链接](#10.6 共享库与动态链接)
[10.7 加载:让程序"活"过来](#10.7 加载:让程序"活"过来)
摘要
一台计算机为什么能把一份 C 源代码变成屏幕上的一行输出?本文沿"表示 → 转换 → 链接"这条主线完整走一遍:从冯·诺依曼结构与指令执行,到补码、IEEE 754 与标志位的运算电路,再到 IA-32/x86-64 的汇编表示、栈帧与过程调用,最后拆开 ELF 目标文件,看清链接器如何做符号解析与重定位。理解了这条链路,那些"反直觉"的程序行为就都有了解释。
一、为什么需要"系统思维":几个反直觉的程序
先看几个真实会在机器上发生的现象。
第一段代码只有一行比较:
cpp
// 在 32 位系统、ISO C90 标准下
-2147483648 < 2147483647 // 结果是 false!
字面上的"负二十多亿小于正二十多亿"竟然不成立。原因藏在编译器处理字面量的规则里:C90 标准下,2147483648 超出了 int 的表示范围,被当作 unsigned int,于是整个表达式按无符号数比较------0x80000000(2147483648)当然大于 0x7FFFFFFF(2147483647),结果为 false。而写成 int i = -2147483648; i < 2147483647 时,两边都是 int,按带符号比较,结果就是 true。同一个数学事实,两种类型视角,两种答案。
再比如这个求和函数:
cpp
float sum(float a[], unsigned len) {
int i;
float sum = 0;
for (i = 0; i <= len - 1; i++)
sum += a[i];
return sum;
}
当调用者传入 len = 0 时,直觉上循环一次都不该执行,返回 0。但实际运行却发生了访存异常。问题出在 len - 1:len 是 unsigned 类型,0 - 1 在模运算下得到 0xFFFFFFFF,也就是无符号数里的最大值,循环条件 i <= 0xFFFFFFFF 几乎永远成立,程序沿着数组一路读下去,直到撞上非法地址。在 Windows 上那个著名的 0xC0000005 访问违例,正是这样一类错误的产物。
类似的还有:x = 65535 时 x * x 的结果是负数;copy_array 里 malloc(count * sizeof(int)) 在 count = 2^30 + 1 时只申请到 4 个字节却按 count 个元素去写;同一段代码把 int a = 0x80000000 除以 -1,写成 a / -1 得到 -2147483648,写成 a / b(b = -1)却触发 "Floating point exception"------反汇编后才发现,前者被编译器优化成了取负指令 neg,而后者走真正的除法指令 IDIV,触发了"除法错"异常 #DE,Linux 对它发送 SIGFPE 信号,shell 把它显示成"浮点异常"。
还有一个我印象很深的例子。下面两个函数功能、算法完全一样,时间复杂度也一样:
cpp
void copyij(int src[2048][2048], int dst[2048][2048]) {
int i, j;
for (i = 0; i < 2048; i++)
for (j = 0; j < 2048; j++)
dst[i][j] = src[i][j]; // 按行访问
}
void copyji(int src[2048][2048], int dst[2048][2048]) {
int i, j;
for (j = 0; j < 2048; j++)
for (i = 0; i < 2048; i++)
dst[i][j] = src[i][j]; // 按列访问
}
在 Pentium 4 上实测,按列访问的版本慢了 21 倍。数组在内存里是按行优先存放的,按行遍历对缓存友好,按列遍历每一步都在跳着访问内存。这背后是 Cache 与访问局部性的故事,属于"程序执行"那条线的主题。
这些例子说明一件事:程序执行结果不仅取决于算法和代码写法,还取决于语言处理系统、操作系统、指令集体系结构(ISA)和微体系结构。整个计算机专业,本质上就是在研究一张抽象层图谱上各层之间的实现与转换关系。

【图1:计算机系统抽象层的转换。从下到上依次是器件、电路、功能部件、微体系结构、指令集体系结构(ISA)、操作系统/虚拟机、编程语言、算法、应用问题。ISA 一层被标红,因为它是软件与硬件的交界面:往上是软件世界(程序员所在的位置),往下是硬件世界(架构师与电路工程师所在的位置)。每一门计算机专业课都落在其中某一层或某几层上,而把各层关联起来解决问题的能力,就是"系统思维"。】
所谓系统思维,就是站在"计算机系统"的角度考虑问题:知道高级语言语句最终会变成什么样的指令,知道指令是 0/1 序列、由机器直接理解执行,知道计算机是一个字长有限的模运算系统------在这里 x² 可能小于 0,(x+y)+z 不一定等于 x+(y+z),访问内存要几十到几百个时钟周期而访问磁盘要几百万个。从机器的角度看,它永远是对的;觉得"反直觉",只是因为还没看到那一层。
二、计算机到底怎么运行:存储程序与指令执行
1946 年诞生的第一台通用电子计算机 ENIAC 用了 18000 多个真空管、占地 170 平方米、重 30 吨,编程方式是手动设置开关和插拔电缆。1945 年,冯·诺依曼在参与 ENIAC 研制的基础上起草了著名的"关于 EDVAC 的报告草案",提出了"存储程序通用电子计算机方案"。这个方案里的结构被称为冯·诺依曼结构,其核心思想只有一句话:任何要计算机完成的工作都先写成程序,程序和原始数据一起送入主存并启动执行,此后计算机在无人干预的情况下自动逐条取指令、执行指令。
围绕这个思想,计算机由五个基本部件组成:运算器(数据运算)、控制器(对指令译码、生成控制信号)、存储器(存放数据和指令)、输入设备和输出设备。有几个细节值得咀嚼:指令和数据在存储器里形式上没有差别,都是 0/1 序列,靠执行的阶段来区分;指令由操作码和地址码组成;每条指令、每个数据都有地址。现代计算机的结构模型可以画成这样:

【图2:现代计算机结构模型。虚线框内是 CPU:控制器负责取指令和译码,PC(程序计数器)存放下一条指令地址,MAR/MDR 分别是访存的地址寄存器和数据寄存器,ALU 负责运算,通用寄存器组 GPRs 存放操作数,标志寄存器记录运算状态。右侧是按地址编址的存储器,通过地址总线、数据总线和控制总线与 CPU 相连,两侧再挂输入输出设备。】
细看图2,几个角色的分工容易被略过:PC 存"下一条指令在哪",IR(指令寄存器)存"当前这条指令是什么"------一个管去处,一个管现状。MAR(存储器地址寄存器)和 MDR(存储器数据寄存器)是 CPU 对外访存仅有的两个"口岸":想去哪个单元先把地址放进 MAR,要读写的数据一律先落 MDR。三条总线各司其职------地址总线送门牌号,数据总线送内容,控制总线送"读还是写"的命令。CPU 内部再快,出门都得过 MAR/MDR 这道关,这个硬件事实埋下了"访存比访问寄存器慢一个量级"的伏笔。
计算机执行一条指令的过程,可以用"妈妈做菜"来类比:存储器是厨房外的架子,架子上按编号放着菜谱(指令)和原材料(数据),PC 就是指定的起始菜谱编号。每道"菜"的制作流程固定为六步:根据 PC 取菜谱(取指令)→ 看菜谱(译码)→ 取原材料(取操作数)→ 洗切炒(执行)→ 装盘(回写结果)→ 算出下一份菜谱的编号(修改 PC)。这个"取指---译码---执行"的循环,就是一切软件运行的底层节奏。
在这套机制之上,计算机系统形成了清晰的层次:最终用户工作在应用层,应用程序员工作在语言处理系统层,系统管理员工作在操作系统层,而系统程序员工作在 ISA 层。ISA(Instruction Set Architecture,指令集体系结构)是一种规约,它规定:指令集合及其格式、操作数类型、寄存器组的结构与编号、存储空间大小与编址方式、大端还是小端、寻址方式、以及指令执行的控制方式(PC、条件码等)。没有 ISA,软件无法使用硬件;同样一套 ISA 可以有截然不同的微体系实现------比如乘法指令既可以用加法器循环实现,也可以用独立乘法器实现。ISA 是抽象,微结构是实现,这一对概念贯穿了后面对 IA-32 和 x86-64 的讨论。
三、从源程序到可执行文件:四步转换
计算机不能直接执行 hello.c。源程序本质上是一串 ASCII 字符------#include 这一行对应的是 35 105 110 99 108 117 100 101 这样的字节序列。要让它跑起来,必须经过四个转换阶段:
cpp
gcc -E hello.c -o hello.i # 预处理(cpp):展开 #include/#define 宏、删除注释
gcc -S hello.i -o hello.s # 编译(cc1):词法/语法/语义分析+优化,生成汇编代码
gcc -c hello.s -o hello.o # 汇编(as):汇编指令→机器指令,生成可重定位目标文件
gcc -static -o hello hello.o printf.o ... # 链接(ld):合并多个 .o 与库,生成可执行文件

【图3:一个典型程序的转换处理过程。左上是 hello.c 源程序及其 ASCII 字节表示;下方是 GCC+Linux 平台的处理流水线:hello.c 经预处理(cpp)得到仍是文本的 hello.i,经编译(cc1)得到汇编语言程序 hello.s,经汇编(as)得到二进制的可重定位目标程序 hello.o,最后由链接器(ld)把它与 printf.o 等库模块链接成可执行目标程序 hello。前三个阶段逐个处理单个源文件,链接阶段才把多个模块合到一起。】
有几个容易忽略的点。首先,gcc 本身只是一个"包装命令",真正干活的是 cpp、cc1、as、ld 这些具体程序。其次,预处理后得到的 .i 和编译后得到的 .s 都还是可读的文本文件,从 .o 开始才是二进制。最后,两个阶段的产物形态不同:test.o 中代码的地址从 0 开始,而链接生成的 test 里同一段 add 函数的代码从 0x80483d4 开始------这个地址差异正是链接器"重定位"工作的直观体现,第三部分会详细展开。
用 objdump -d 可以把二进制目标文件反汇编回汇编形式。对比 objdump -d test.o 和 objdump -d test 的输出,能看到同一条 push %ebp 在前者位于地址 0,在后者位于 80483d4------"链接前地址是占位符,链接后地址才落定",这是理解链接的一条主线索。
四、数据在机器里长什么样
4.1 一切皆是 0/1:真值与机器数
计算机内部所有信息都用二进制编码,原因很朴素:制造两个稳定态的物理器件(电位高/低)容易,二进制的编码与运算规则简单,而且正好与逻辑命题的真/假对应,便于用逻辑电路实现算术运算。
这里要建立一对贯穿全文的概念:真值 是现实中带正负号的数,机器数 是计算机内部的 0/1 序列。同一个机器数,在不同解释规则下对应不同真值------比如 8 位串 11111111,按无符号数解释是 255,按补码解释是 -1。机器级数据分两大类:数值数据(无符号整数、带符号整数、浮点数)和非数值数据(逻辑数、西文字符、汉字、多媒体信息)。
数值数据的表示有三要素:进位计数制、定/浮点表示、编码方式。换句话说,光看到一个数 20137564 是无法确定它的值的------不知道进制、不知道小数点在哪、不知道编码。进制转换的基本方法是"除基取余(整数部分,余数从右往左排)、乘基取整(小数部分,整数从左往右排)",比如 (835.6875)₁₀ = (1101000011.1011)₂,其中 835 = 512+256+64+2+1,0.6875 = 0.5+0.125+0.0625。八进制和十六进制则是二进制的"速记法":3 位二进制对应 1 位八进制,4 位对应 1 位十六进制。
4.2 定点数:补码统治世界
小数点在计算机里没有物理存在,只能靠约定位置:位置固定的叫定点数,浮动的叫浮点数。定点整数表示整数,定点小数则用来表示浮点数的尾数。
带符号整数有三种经典编码:原码(符号位 + 绝对值)、移码(加偏置常数,用于表示阶码)和补码。原码有个致命问题:0 的表示不唯一(0000 和 1000 都是 0),加减运算方式不统一,符号位需要额外处理------这些对硬件设计都是灾难。从 20 世纪 50 年代起,所有计算机的整数都用补码表示,只有浮点数的尾数还保留原码形式。
补码的精髓是模运算。时钟是一个模 12 系统:把 10 点倒拨 4 格和顺拨 8 格结果一样,因为 10 - 4 ≡ 10 + 8 (mod 12),-4 的模 12 补码就是 8。一个只有四档的算盘(模 10⁴)计算 9828 - 1928,可以转化为 9828 + 8072 = 17900,丢掉最高位的"1",得 7900------减法变成了加法。同样地,n 位运算器只有 n 位,运算结果只保留低 n 位,就是一个模 2ⁿ 系统。

【图4:把 4 位运算器看成一个模 2⁴ 的"钟表系统"。16 个机器数 0000~1111 首尾相接排成一个圆圈,真值范围 -8~+7。顺时针加、逆时针减,加法器天然就在这个圆圈上转圈------溢出就是绕过头顶一圈。右下角给出补码定义:X补 = 2ⁿ + X(-2ⁿ⁻¹ ≤ X < 2ⁿ⁻¹,mod 2ⁿ),X 是真值,X补 是机器数。】
补码定义为 [X]补 = 2ⁿ + X (mod 2ⁿ)。它带来三个关键性质:加减运算统一([A-B]补 = [A]补 + [-B]补)、0 的表示唯一、比原码多表示一个最小负数 -2ⁿ⁻¹。求补码的快速方法是"各位取反、末位加一",更快的口诀是"从右向左遇到第一个 1 之前(含这个 1)不变,之后各位取反"。由补码求真值则用按权展开:A = -a(n-1)·2^(n-1) + a(n-2)·2^(n-2) + ... + a0·2⁰,注意最高位的权是负的------这一"负权"正是补码所有神奇性质的代数根源。
4.3 C 语言里的整数陷阱
C 语言声明 (unsigned) char/short/int/long 在 32 位机器上分别是 8/16/32/32 位。当无符号数与带符号数混用时,C 编译器把带符号数强制转换为无符号数再比较。于是:
|------------------------------|-----|----|
| 表达式 | 类型 | 结果 |
| -1 < 0 | 带符号 | 1 |
| -1 < 0U | 无符号 | 0 |
| 2147483647U > -2147483647-1 | 无符号 | 0 |
| (unsigned) -1 > -2 | 无符号 | 1 |
-1 的机器数是全 1(11...1B),按无符号解释就是 2³²-1,所以它比任何普通无符号数都大。表格里带"与常规预期相反"的行,全部源于这条隐式转换规则。
字面量本身也有类型陷阱:C90 下,2147483648 是 unsigned int,2147483648 及以上到 2⁶³-1 才是 long long;C99 则把 2³¹~2⁶³-1 直接定为 long long。这直接决定了开头那个 -2147483648 < 2147483647 悲剧在 C90 为 false、在 C99 为 true。
类型转换时会发生位扩展 和位截断。短转长:无符号数做 0 扩展,带符号数做符号扩展;长转短:强行丢弃高位,可能溢出。一个经典例子:
cpp
short si = -32768; // 机器数 80 00
unsigned short usi = si; // 32768,机器数仍是 80 00
int i = si; // -32768,符号扩展:FF FF 80 00
unsigned ui = usi; // 32768,0 扩展:00 00 80 00
而 int i = 32768; short si = (short)i; int j = si; 中,i 和 j 不相等------32768 超出了 short 的表示范围,截断得到 80 00,再符号扩展回 32 位就是 -32768。
4.4 字节序:同一个数的两种排法
现代计算机按字节编址,一个 int 占 4 个字节,这 4 个字节怎么排?大端方式 (Big Endian)把最高有效字节 MSB 放在最小地址,小端方式 (Little Endian)把最低有效字节 LSB 放在最小地址。IBM 360/370、Motorola 68k 是大端,Intel x86 是小端。int i = -65535(机器数 FFFF0001H)存放在 100~103 号单元:小端机从低到高存 01 00 FF FF,大端机存 FF FF 00 01。
字节序在单机内部完全自洽,一旦跨系统通信或移植就会出问题。利用 union 的所有成员从同一地址开始存放的特性,可以写一个小程序检测当前机器的字节序;而音视频图像文件格式对字节序的规定各不相同(GIF 是小端,JPEG 是大端),格式解析代码必须显式处理。顺带一提,从反汇编文本 89 85 a0 fe ff ff mov %eax, 0xfffffea0(%ebp) 就能看出 IA-32 是小端:指令里的立即数 a0 fe ff ff 按字节读出再按小端拼装,恰好还原出 0xfffffea0。
4.5 非数值数据:字符也是 0/1
逻辑数据用一位表示真/假;西文字符用 7 位 ASCII 码(共 128 个,含数字、字母、专用符号和控制字符);汉字数量巨大,需要一套多层编码:输入码 (键盘按键序列)→ 内码 (系统内存储与处理)→ 字模点阵/轮廓(显示打印)。以 GB2312 为例,94×94 的码表用区位码定位汉字,区号位号各加 32(20H)得到国标码,两个字节的最高位再各置 1 就得到内码------例如"大"字区位码为 20 行 83 列,国标码 3473H,内码 B4F3H。最高位置 1 的目的,是让汉字内码永不与 ASCII 码混淆。图形、图像、音频、视频在机器内部同样最终归约为数值数据或 0/1 编码的序列。
4.6 浮点数:IEEE 754
实数在计算机里用科学计数法的二进制版本表示:X = (-1)^S × M × R^E,只要分别编码符号 S、尾数 M 和阶码 E 即可。1985 年定稿的 IEEE 754 标准统一了浮点数格式,主要起草人是 UC Berkeley 的 William Kahan 教授------在此之前各家机器浮点格式互不兼容,数据交换痛苦不堪。
单精度(float,32 位)的布局是:1 位符号 S + 8 位阶码 E(移码,偏置常数 127)+ 23 位尾数。规格化形式为 (-1)^S × 1.M × 2^(E-127),关键技巧是"隐含的 1":规格化尾数的小数点前总是 1,不必存储,于是 23 位存出了 24 位有效精度。双精度(double)则是 1+11+52,偏置常数 1023。
互换的例子:float 机器数 BEE00000H,展开为 1 01111101 11000...0,阶码 125-127=-2,尾数 1+0.5+0.25=1.75,真值 -1.75×2⁻² = -0.4375。反方向:-12.75 = -1100.11B = -1.10011×2³,阶码 127+3=128=10000010B,机器数 C14C0000H。

【图5:一个 32 位浮点格式的表示范围数轴。规格化数集中在零点两侧远离零的区域,最大正数约 (1-2⁻²⁴)×2¹²⁷,最小正规格化数是 0.1×2⁻¹²⁸;零点附近存在"下溢区"。因为原码尾数关于原点对称,表示范围也对称。图上还标注了正/负上溢与正/负下溢区。值得记住的结论是:浮点数表示范围比定点数大得多,但可表示数的个数并没有变多,所以数与数之间更稀疏、且分布不均匀------越靠近零越密集,越往两端越稀疏。】
阶码用移码表示(加上偏置常数)而不是补码,原因是便于对阶时的阶码比较:移码的大小顺序与真值一致,硬件用一个普通无符号比较器就能比阶。0 的移码表示也唯一。
全 0 和全 1 的阶码被保留下来表示特殊值,这就凑齐了 IEEE 754 的完整分类:

【图6:以单精度为例,按阶码与尾数的取值组合划分全部 2³² 个位串:阶码全 0 且尾数全 0 是 ±0(正负 0 合法并存);阶码全 0 但尾数非 0 是非规格化数(Denorms),此时隐含位不再是 1 而是 0,用于填补最小规格化数与零之间的空隙;阶码 1~254 是规格化数;阶码全 1 且尾数全 0 是 ±∞;阶码全 1 且尾数非 0 是 NaN。浮点数除以 0 得到 ±∞ 而不是异常,整数除 0 才是异常------这正是两者在硬件层面的分工差异。】
非规格化数填补空隙的细节值得专门看一眼:最小规格化数是 1.0×2⁻¹²⁶,如果没非规格化数,0 和它之间会出现一个巨大的"GAP",任何下溢都直接变成 0(突然归零)。有了非规格化数(0.xxx×2⁻¹²⁶),数轴上的过渡变得平滑。另外,float 只能精确表示 7 个十进制有效数位:61.419998 和 61.420002 是两个相邻的可表示数,输入一个不可表示数时,机器把它转换为最邻近的可表示数------这就是浮点"不精确"的根源,不是随机 bug,是表示能力的必然。
五、运算是怎么算出来的
5.1 从逻辑门到带标志加法器
所有运算最终都落在电路上。布尔代数把 0/1 与逻辑真假对应起来,与/或/非三种基本门电路组合出异或门等复杂门;n 位逻辑运算只要重复使用 n 个相同的门按位进行。组合逻辑部件(无存储,输出只依赖当前输入)的构造套路固定:真值表 → 逻辑表达式 → 电路。多路选择器(MUX)在控制端的作用下从多路输入中选一路输出,是后面所有部件里出镜率最高的"开关"。
一位全加器输入两个加数 A、B 和低位进位 Cin,输出和 F 与向高位的进位 Cout;n 个全加器串联成 n 位加法器。在加法器上再加四个标志生成逻辑,就得到带标志加法器:
- 溢出标志 OF = C(n) ⊕ C(n-1)(最高位进位与次高位进位不同,说明带符号溢出)
- 符号标志 SF = F(n-1)(结果最高位)
- 零标志 ZF = 1 当且仅当 F = 0
- 进/借位标志 CF = Cout ⊕ Sub(无符号加减的进/借位)
四个标志各用一句话翻译:ZF 回答"结果是零吗"------相等判断的本质,CMP 只置标志不存差值,je 其实就是 jz;SF 回答"结果的位型看起来是负吗"------最高位直通;OF 回答"按带符号解释算错了吗"------同号相加得异号才算溢出;CF 回答"按无符号解释进/借位了吗"。关键在于:OF 和 CF 是同一次运算的同一个结果按两种解释各自判断的,可能一 1 一 0------硬件不关心符号视角,它把两套答案都算好放着,由跳转指令决定用哪套。带符号比较的条件是 SF≠OF 而非单看 SF,正是为了在"溢出导致符号位翻转"时仍得出正确的大小关系。

【图8:算术逻辑部件 ALU。A、B 两路 n 位输入在 k 位操作控制端 ALUop 的指挥下完成运算,输出 n 位 Result、进位 Cout 以及 OF/SF/ZF/CF 四个标志;k 位 ALUop 最多支持 2^k 种操作,图中给出 3 位控制码的组合:000 加、001 减、010 与、011 或、100 取反、101 异或。ALU 的核心是带标志加法器,乘法器与除法器是可选的独立部件------同一种 ISA 可以有不同的微体系实现(乘法指令既可用加法器循环实现,也可用独立乘法器),在这里有了具体的电路注脚。】
5.2 加减运算部件:整个运算世界的核心
补码的模运算性质让减法可以完全复用加法器:[A-B]补 = [A]补 + [-B]补,而 [-B]补 = [B]补 各位取反加一。于是整数加/减运算部件的构造顺理成章:B 路径上放一个"取反加一"逻辑(MUX 在 Sub=1 时选 B' = B̄,并把 Sub 直接接到加法器的 Cin 上,完成 +1),Sub 同时控制 CF 的生成(CF = Cout ⊕ Sub)。

【图7:所有运算电路的核心------n 位整数加/减运算部件。Sub 为 0 时做加法(B 直通),Sub 为 1 时做减法(B 经非门取反,且 Sub 作为 Cin 送入加法器实现"加一")。加法器输出 n 位 Sum 和四个标志:ZF 零标志、SF 符号标志、OF 溢出标志(带符号溢出)、CF 进/借位标志(= Cout⊕Sub,无符号溢出)。图上方三条"重要认识"值得抄在笔记本扉页:所有运算都基于加法器实现;加法器不知道操作数是带符号数还是无符号数;加法器不判定对错,总是取低 n 位作结果并生成标志信息。】
这三条"重要认识"值得反复咀嚼。同一个加法器算 9 + 6 和 -7 + (-6) 时,硬件动作完全一样。看两个例子(n=8):
cpp
107 + 46: 0110 1011 + 0010 1110 = 1001 1001
OF=1, SF=1, ZF=0, CF=0
带符号解释: 107+46=153 > 127,正溢出,结果 -103 是错的
无符号解释: 153 ≤ 255,CF=0,结果正确
再如 unsigned x=134, y=246 与 int m=134, n=246 用同一部件算减法:得到的机器数都是 1001 0000,但 CF=1 说明无符号解释下 x-y 溢出(144 = 134-246+256),而 OF=0 说明带符号解释下 m-n=-112 没溢出。同一段机器数,标志决定解释------无符号加减看 CF,带符号加减看 OF;比较大小同样靠减法产生的标志(无符号:CF=0 则大于;带符号:OF=SF 则大于)。
有了标志,就能写溢出 检测代码:
cpp
int uadd_ok(unsigned x, unsigned y) { // 无符号加法溢出检测
unsigned sum = x + y;
return sum >= x; // 溢出时 sum 必然小于 x
}
int tadd_ok(int x, int y) { // 带符号加法溢出检测
int sum = x + y;
int neg_over = x < 0 && y < 0 && sum >= 0;
int pos_over = x >= 0 && y >= 0 && sum < 0;
return !neg_over && !pos_over;
}
/* WARNING: This code is buggy. */
int tsub_ok(int x, int y) {
return tadd_ok(x, -y); // 有 bug!
}
tsub_ok 的 bug 很隐蔽:当 y = 0x80000000(INT_MIN)时,-y 本身就溢出------INT_MIN 的相反数超出 int 范围,取负后仍是 INT_MIN,于是 x - INT_MIN 的溢出判断完全失灵。可见"减法转加法"的捷径在边界值上会反咬一口。
5.3 乘法与除法
n 位 × n 位在乘法电路里产生 2n 位乘积,但 C 语言规定结果仍是 n 位------只取低 n 位。高 n 位信息没有丢,它们正好用来判断溢出:无符号乘,高 n 位全 0 则不溢出;带符号乘,高 n 位全 0 或全 1 且等于低 n 位的最高位则不溢出。硬件不判溢出、只保留 2n 位乘积,把判断责任交给软件(MIPS 干脆把 64 位乘积放在 Hi/Lo 两个寄存器里)。带符号乘与无符号乘在"只取低 n 位"的意义下结果相同(低 n 位一致),差别只在高 n 位------布斯乘法(Booth)正是带符号乘的实现算法。
一个血泪案例:
cpp
int copy_array(int *array, int count) {
int i;
int *myarray = (int *) malloc(count * sizeof(int)); // 漏洞!
if (myarray == NULL) return -1;
for (i = 0; i < count; i++)
myarray[i] = array[i];
return count;
}
当 count = 2^30 + 1 时,count * sizeof(int) 在 32 位下溢出为 4,malloc 只分配 4 个字节,循环却照常写入 count 个元素------堆区被大面积破坏。2002 年 Sun 的 RPC XDR 库中 xdr_array 函数的同款整数溢出漏洞,可让攻击者远程获取 root 权限。修法很直接:换用无符号乘并检查高 n 位,或用 count > INT_MAX / sizeof(int) 之类的除法预检。
编译器还会偷偷做优化:变量乘常数会被换成移位加法的组合,x * 20 变成 (x << 4) + (x << 2),因为乘法要多个时钟周期而移位/加法只要一个。有趣的是,即使乘积溢出,这种组合的结果与直接相乘取低 n 位也完全一致------模运算下替代方案是精确等价的。
除法比乘法更慢(30 个以上时钟周期,且不能流水线化)。除 0 无法表示,触发"除法错"异常 #DE 交给操作系统处理;除法溢出只有一种情况 -2^31 / -1(商 2³¹ 超出 int 范围)。不能整除时商按朝 0 方向舍入:正数取地板,负数取天板(7/2=3,-7/2=-3)。除以 2 的幂用右移实现:无符号用逻辑右移,带符号用算术右移;负数不能整除时要先加偏移量 (2^k - 1) 再右移。一个漂亮的综合练习------不用除法/乘法/条件语句实现 x/32:
cpp
int div32(int x) {
int b = (x >> 31) & 0x1F; // x>=0 时 b=0;x<0 时 b=31(偏移量)
return (x + b) >> 5;
}
(x >> 31) 对负数得到全 1,对非负数得到全 0,& 0x1F 后恰好取出偏移量------用符号位当"开关",全程无分支。
5.4 C 运算符在机器级的真实行为
按位运算(& | ~ ^)对位串逐位处理,用于掩码操作,如 y & 0x00FF 提取低字节;逻辑运算(&& || !)把整个操作数当整体,结果只有 0/1,且 &&、|| 具有短路求值。两者在调试时最容易混淆:x && y 永远不会是位串。
移位运算由操作数类型决定逻辑移位还是算术移位:无符号数是逻辑移位(左移高位移出、低位补 0,若移出的是 1 则溢出);带符号数是算术移位(左移可能溢出,判断"移出的位是否等于新符号位";右移高位补符号位,可能丢失有效数据)。8 位机上的例子:x = -81(机器数 10101111),x >> 1 得 11010111 = -41,是精确的除 2 向下取整;x << 1 得 01011110 = 94 而不是 -162,左移溢出了。
一个逆向工程小例子能串起移位与算术:R[ax]=FF80H(即 short 的 -128),执行 salw $2,%ax; addl %dx,%ax; sarw $1,%ax 后,实际计算的是 ((x<<2)+x)>>1 = 5x/2 = -320,最终 AX = FEC0H。从汇编序列反推出 C 表达式,是这门课最锻炼"机器眼"的练习。
5.5 浮点运算:对阶、规格化与舍入
浮点加减法比整数复杂得多,因为要先解决"两数的阶不一致"问题。步骤固定为六步:求阶差 → 对阶(小阶向大阶看齐,尾数右移,隐含的 1 移入小数部分)→ 尾数加减 → 规格化(尾数高位为 0 则左规:左移一次阶码减 1;尾数有进位则右规:右移一次阶码加 1)→ 舍入 → 判溢出。完整走一遍 0.5 + (-0.4375):
cpp
0.5 = 1.000 × 2^-1
-0.4375 = -1.110 × 2^-2
对阶: -1.110×2^-2 → -0.111×2^-1
加减: 1.000×2^-1 + (-0.111×2^-1) = 0.001×2^-1
左规: 0.001×2^-1 → 1.000×2^-4 = 0.0625
这五步里藏着两个"为什么"。对阶永远是小阶向大阶看齐:把小阶的尾数右移,丢的是低位(可用附加位临时保住);反过来把大阶尾数左移,丢的是高位,整数部分都没了,不可恢复。左规连移三次才落位,说明尾数加减的结果常常"不规格化",规格化只是例行收尾。再往深处看一层:所谓"浮点加法器"其实没有专门的加法电路,它就是阶码比较器 + 移位器 + 整数尾数加法器 + 规格化/舍入控制逻辑------浮点的复杂不在算术本身,而在算术前后的对阶、规格化、舍入这三道工序。
IEEE 754 规定中间结果要带两个附加位 :保护位 G(guard)和舍入位 R(round),用来保住对阶时右移出去的位,否则舍入精度大打折扣。舍入有四种模式,默认的"就近舍入"在恰好处于中间值时强制舍入到偶数------可以证明这种方式平均误差最小。这也是为什么把同一个实数赋给 float 和 double 会打印出不同结果:123456.789e4 赋给 float 输出 1234567936.000000,赋给 double 输出 1234567890.000000------float 的 24 位有效精度不足以容纳该数,舍入后的值碰巧偏大。
浮点比较有一张经典的"永真性"清单(x 为 int,f 为 float,d 为 double,均非 NaN):
x == (int)(float) x------ 否(float 只有 24 位有效精度,大整数会舍入)
x == (int)(double) x------ 是(double 的 53 位精度容纳所有 32 位 int)
f == (float)(double) f------ 是(double 能精确表示所有 float)
d == (float) d------ 否(double 转 float 可能丢精度)
f == -(-f)------ 是(除 NaN 外符号翻转可逆)
2/3 == 2/3.0------ 否(左边整数除法得 0,右边浮点除法)
d * d >= 0.0------ 是
(d+f)-d == f------ 否(结合律不成立)
浮点加法结合律同样不成立:x = -1.5×10³⁸,y = 1.5×10³⁸,z = 1.0 时,(x+y)+z = 1.0,而 x+(y+z) = 0.0。
精度问题的代价可能超出想象。爱国者导弹系统的时钟用 0.1 的 24 位定点二进制小数乘以计数器:0.1 的二进制是无限循环小数 0.000110011...,截断为 24 位后产生约 9.54×10⁻⁸ 的单次误差。系统连续工作 100 小时(计数 3.6×10⁶ 次)后,时钟累计偏差约 0.343 秒;飞毛腿导弹速度约 2000 m/s,预测距离偏差约 687 米------1991 年 2 月 25 日,拦截失败,28 名美军士兵丧生。事后分析,若改用 float(24 位有效精度)误差缩小 16 倍,距离偏差约 43 米;若用 32 位定点小数(比 float 有效位多 6 位),偏差仅 0.67 米。1996 年 Ariane 5 火箭首飞 37 秒后爆炸,则是 64 位浮点转 16 位整数时水平速率超出 16 位范围引发溢出异常------复用 Ariane 4 的旧代码而没有重新校验数据范围。两个事故指向同一条工程铁律:类型转换和精度损失要当成显式的风险来管理,不能遇到小数就无脑上浮点。
六、程序的机器级表示:ISA 与汇编
6.1 IA-32:CISC 的经典标本
IA-32(x86-32)是典型的 CISC 风格 ISA:8 个 32 位通用寄存器、变长指令字、变长操作码。它的寄存器组织是一部体系结构进化史:

【图9:IA-32 寄存器全景。8 个通用寄存器 EAX/EBX/ECX/EDX/ESP/EBP/ESI/EDI 各有惯用角色(累加器、基址、计数、数据、栈顶、栈底、源变址、目的变址),且向下兼容 16 位(AX)与 8 位(AH/AL)分段命名------低位能独立访问是兼容 8086 的历史遗产。下方是专用的指令指针 EIP(即 PC)和标志寄存器 EFLAGS,以及 CS/SS/DS/ES/FS/GS 六个段寄存器(间接给出段基址)。】
逐个点名这 8 个通用寄存器的惯用角色:EAX 是累加器------乘除法的隐含操作数、函数返回值的默认出口;EBX 做基址;ECX 是计数器,移位指令的次数只认 CL;EDX 与 EAX 搭档,存放 64 位乘积的高位;ESP 永远指向栈顶,EBP 是栈帧锚点;ESI/EDI 是串操作的源/目的地址。但要点在于:这些角色是指令约定出来的,不是硬件连死的------8 个寄存器在电路上并无区别,只有写在指令里的隐含用法(EAX 放乘积、CL 放移位数、ESP 指栈顶)才是硬约束。两个专用件各管一摊:EIP(指令指针)存放下一条指令地址,且只能被跳转/调用/返回改写,没有 mov eip 这条指令;EFLAGS 就是上一章四个标志的家。六个段寄存器存的是段选择符而非数据,间接给出各内存段基址------取指自动用 CS,栈操作自动用 SS,普通访存默认 DS。
存储器操作数的寻址方式统一成一个通式:段基址 + 基址 + 变址×比例因子 + 位移量 (写法如 8(%ebp,%esi,2))。这个通式与 C 数组的地址计算天然对应:a[i] 是"比例变址+位移",b[i][j] 是"基址+比例变址+位移"。比如 short b[4][4] 中 b[i][j] 的地址是 504+i×8+j×2,取到 AX 的指令就是 movw 504(%ebp,%esi,2), %ax,其中比例因子 2 来自 short 的 2 字节宽度。读括号写法有个口诀:括号外是常数加项,括号内第一项(基址)原样加,第二项(变址)乘上比例因子再加------8(%ebp,%esi,2) 就是 EBP + ESI×2 + 8,乘号只存在于变址与比例因子之间。四槽位全填是最复杂形式,其余寻址方式都是它的省略版:(%eax) 是指针解引用,8(%ebp) 是取栈帧参数,array(,%esi,4) 是 a[i]。地址计算的乘加被寻址电路免费代劳,一条指令顶掉独立的 add/mul------这是 CISC 把常用复合操作固化进硬件的典型设计。
IA-32 的机器指令由操作码、ModRM(寻址方式)字节、SIB(基址变址)字节、位移量和立即数组成,长度可变。AT&T 格式与 Intel 格式的对应关系要熟练:mov edx, DWORD PTR [ebp+8] 就是 movl 8(%ebp), %edx,源/目的操作数顺序相反、长度后缀显式标注、立即数加 $、寄存器加 %。
6.2 一条指令的完整一生
以 push %ebp(机器码 55)为例,把它在数据通路上的六步走完整:S1 取指令(EIP=0x80483d4 → 存储器 → IR,机器码 55 进入指令寄存器);S2 指令译码(控制器识别出 push 操作);S3 指令执行(ESP 减 4 得到新栈顶地址送 MAR);然后访存写回(EBP 的旧值 bfff0020 按"小端方式"拆成 20 00 ff bf 四个字节写入栈顶);最后 EIP 增量指向下一条指令 0x80483d5,循环继续。

【图10:push %ebp 执行到 S3 阶段时的数据通路快照。左下虚线框内是 CPU 内部:控制器已译码,EIP 仍指向 0x80483d4,ALU 正在计算 Resp-4,结果 beeefffc 送 MAR,同时 EBP 的旧值 bfff0020 已送到 MDR 等待写入;右侧存储器中能看到代码段(80483d4 处的 55)与栈区(beeefffc 处即将写入的 bfff0020)并存。一条"简单"的入栈指令,动用了 EIP/ESP/EBP/MAR/MDR/IR 六个寄存器和一次存储器访问。】
读这张图的诀窍是判断"拍点":MAR 已装好新栈顶 beeefffc、MDR 已装好待写的旧 EBP 值 bfff0020,但栈顶单元还是空的,EIP 也仍指着 80483d4------图停在"访存写回前一瞬",取指(IR=55)与译码、执行(ESP 已减 4)都已完成。数据流沿三股走:取指流 从 EIP(指令指针)出发,经 MAR、地址总线到存储器,机器码 55 沿数据总线落在 MDR(数据中转站)再进 IR;执行流 在 CPU 内部闭环,ESP 旧值进 ALU 减 4 后兵分两路------更新 ESP、送 MAR,同时 EBP 旧值提前挪进 MDR"候车";写回流三线并发------地址总线送 beeefffc、数据总线送 bfff0020、控制总线送写命令。一条"最简单"的指令,把读写两个方向都走了一遍。
6.3 指令家族速览
传送指令里最值得单独点名的是 LEA (加载有效地址):leal (%edx,%eax,1), %eax 做的事是 Reax ← Redx+Reax------它不访存,纯粹用地址运算部件做算术!编译器大量用 LEA 顺便完成加法与乘 2/4/8 的组合,这是读汇编时最常遇到的"挂羊头卖狗肉"。比如 leal (%eax,%eax,2), %eax 一条指令完成 ×3,leal (%edx,%eax,4), %eax 完成 x+4y------对照 imul 加 add 的两条指令,这就是它被"滥用"的原因。入栈/出栈(PUSH/POP)配合"栈从高地址向低地址增长"的方向感:pushw %ax 先 Rsp ← Rsp-2 再写入,而 AL 落在栈顶低位,又是小端。
算术指令 ADD/SUB/INC/DEC/NEG/CMP 都影响标志且不区分无/带符号(CMP 就是只改标志不改操作数的 SUB);乘除指令 MUL/IMUL/DIV/IDIV 区分无/带符号且不影响标志。这个"反直觉"的分野原因在数学里:补码下无符号与带符号加法的位级结果完全相同,区分两种解释的工作可以甩给跳转指令(ja 看 CF,jg 看 SF⊕OF);而乘法的位级结果随符号解释真的不同------0xFF 当 -1 乘 2 得 -2,当 255 乘 2 得 510------必须有两套电路。乘法三种形式的差别在"溢出到哪":单操作数把完整 2n 位积放进 EDX-EAX,双/三操作数只留低 n 位、高位直接丢------C 的 int×int 本就只定义低 32 位,编译器日常用的是后两种。按位指令中 TEST 是"只置标志的 AND",移位指令分 SHL/SHR(逻辑)、SAL/SAR(算术)、ROL/ROR(循环)、RCL/RCR(带进位循环,CF 参与移位)。右移才有"逻辑/算术"之分:SHR 高位补 0,适合无符号数除 2;SAR 高位补符号位,负数移完还是负数,对应带符号除 2。RCL/RCR 把 CF 接进循环圈,移出的位先进 CF、CF 的旧值补进空位------这是 64 位数拆成两次 32 位移的多精度标准手段。
x86-64 的演进要点:通用寄存器从 8 个扩到 16 个、宽度到 64 位;新增 movabsq 处理 64 位立即数、movslq/cltq 等符号扩展传送;虚拟地址实际支持 48 位(256TB);对齐更严格(long/double/指针必须 8 字节对齐,long double 16 字节对齐);RBP 不再当帧指针 ,因为参数改从寄存器传递,很多函数根本不需要栈帧。每条演进点背后都有动因:movabsq 是因为 64 位立即数塞不进常规指令编码;只实现 48 位地址是工程折中------全 64 位页表层级爆炸,256TB 对当前应用绰绰有余;对齐收紧是为了避免访存跨缓存行;RBP 退役则是连锁反应的终点------参数走寄存器后简单函数不碰栈,帧锚点失去存在必要,被解放为普通的被调用者保存寄存器。后文读 x86-64 汇编时会发现,函数开头常常直接 subq $8, %rsp,连 push %ebp 都省了。

【图11:x86-64 的 16 个通用寄存器及其使用约定。RAX 存返回值;RDI/RSI/RDX/RCX/R8/R9 依次传递前六个整型或指针参数,超过六个走栈;调用者保存寄存器是 RAX/R10/R11,被调用者保存的是 RBX/RBP/R12~R15;RSP 是栈顶指针。每个 64 位寄存器都可按 32/16/8 位访问(R8D/R8W/R8B),还新增了 %sil/%dil/%bpl/%spl 四个 8 位名字。另有 16 个 128 位 XMM 寄存器用于浮点。】
这张图是读 x86-64 汇编的密码本,核心信息不在寄存器名字,而在右侧的调用约定------它是编译器之间的契约:前六个参数走 RDI~R9,返回值走 RAX,"调用者保存/被调用者保存"的划分让互相调用的函数能安全共用一套寄存器(谁想用被调用者保存寄存器,谁负责先存后还)。与图9对照有三个时代变化:段寄存器淡出应用编程的视野;寄存器角色从历史惯用法(累加器、计数器)变成纯 ABI 约定;参数从全走栈改为优先走寄存器------正是最后这条,让 RBP 的帧锚点角色退役。
参数传递方式的变化带来一个精彩的调试案例:double a = 10; printf("a = %d\n", a); 在 IA-32 上打印 0,在 x86-64 上打印不确定值。IA-32 参数全走栈,a 的 double 机器数 40 24 00 00 00 00 00 00H 被 %d 从栈里读走低 32 位,恰好是全 0;x86-64 中浮点参数走 XMM0,整型参数走 ESI,printf 按 %d 去读 ESI 时拿到的是与 a 无关的残留值。同一个错误代码,两种确定性截然不同的"错误",底层原因全在调用约定的差异里。
6.4 选择与循环:都是条件转移的排列组合
if-else、do-while、while、for 在机器级全都是一个模板:条件比较指令(CMP/TEST)设置标志,条件转移指令(jle/jg/je...)根据标志跳转。do-while 最紧凑(先执行后判断),while 和 for 都要先跳过循环体的"守门"判断。switch-case 的优化手段是跳转表 :case 值连续时(如 10~17),编译器在只读数据节里放一张 4 字节对齐的目标地址表,执行时先做 R[eax] = a - 10,越界走 default,否则"转 .L8 + 4*i 处的地址"------一次间接跳转替代一串比较。
逆向工程练习:给一段汇编填 C 代码。例如循环 32 次、每次执行 result = (result<<1) | (x & 0x01); x >>= 1; 的函数,功能是把 32 位无符号数按位反转 ;另一个用魔数 72340172838076673(即 0x0101010101010101)配合移位折叠的循环,功能是统计 x 中 1 的个数(分治加法:先按字节统计,再高低 32/16/8 位两两相加,最后取低 8 位)。这些题目的共同套路:先从 mov/and/add/shr 序列里认出"位运算惯用法",再还原循环结构。
七、过程调用与栈帧
过程调用要在机器级解决三件事:参数怎么传、控制流怎么切换、现场怎么保存。IA-32 的方案是:参数压栈、CALL 指令压返回地址并跳转、栈帧承载局部变量与保存的寄存器。一次调用(P 调 Q)的完整流程分三段:准备阶段(P 把实参放到 Q 能访问到的地方,CALL 保存返回地址并转入 Q;Q 保存 P 的现场、为局部变量分配空间)、处理阶段(执行 Q 的函数体)、结束阶段(Q 恢复现场、释放空间,RET 取返回地址返回 P)。
"现场"就是通用寄存器的内容------所有过程共享一套寄存器,就像合用一套盘子,谁用谁洗。x86 的约定把寄存器分成两类:调用者保存 (EAX/ECX/EDX,Q 可以随便用,P 若还要用得自己先存)和被调用者保存(EBX/ESI/EDI,Q 用之前必须压栈、返回前恢复)。为减少开销,编译器总是优先用调用者保存寄存器。
看一个完整的例子:
cpp
int add(int x, int y) { return x + y; }
int caller() {
int t1 = 125, t2 = 80;
int sum = add(t1, t2);
return sum;
}
caller 的汇编:先 pushl %ebp; movl %esp,%ebp 建立帧底,subl $24,%esp 开辟栈帧,125 和 80 存入 -12(%ebp) 与 -8(%ebp);调用前把参数倒进栈顶参数区(movl %eax,4(%esp)、movl %eax,(%esp)),call add 自动压入返回地址;add 内部 pushl %ebp; movl %esp,%ebp 后用 8(%ebp)、12(%ebp) 取参数,结果放 EAX 返回;caller 收到 EAX 存入 -4(%ebp),leave; ret 退帧返回。

【图12:caller 调用 add 时的完整栈帧对照。右侧自上而下:caller 帧底(EBP 指向保存的旧 EBP)、sum、t2=80、t1=125(-12(%ebp)~-4(%ebp))、未使用空间、参数区(125、80,由 ESP 相对寻址)、返回地址,然后才是 add 的帧底。左侧逐条标注汇编与栈操作的对应关系:准备阶段(push/mov/sub)、分配局部变量(mov $125,-12(%ebp) 等)、准备入口参数(mov 到 (%esp))、call、返回参数总在 EAX、结束阶段(leave = mov %ebp,%esp + pop %ebp,再 ret)。】
这张图值得带走三个认知。其一,帧是"链"不是"块":每个帧底都保存着上一个函数的旧 EBP,一串 EBP 首尾相接成链------调试器的栈回溯就是沿着这条链走的。其二,EBP 是正负偏移的分界线:-4(%ebp) 是自己的局部变量,8(%ebp)、12(%ebp) 是调用者传来的参数(跳过自己保存的旧 EBP 和返回地址)------一个寄存器同时管"自家抽屉"和"收发窗口"。其三,过程调用三件事在这一帧里各有标准答案:参数压到 ESP 附近的参数区,控制流由 call 压返回地址、ret 弹回,现场由 EBP 链加寄存器保存约定共同维护;leave 是 movl %ebp,%esp 与 popl %ebp 的复合------先把 ESP 拉回锚点一瞬释放整帧,再恢复旧 EBP,最后 ret 收官。
参数传递的按值/按址差异在这里一目了然:
cpp
swap(&a, &b); // 按地址:swap 内通过 8(%ebp)/12(%ebp) 拿到的是
// main 栈帧中 a、b 的地址,解引用后交换的是原值 → 交换成功
swap(a, b); // 按值:8(%ebp)/12(%ebp) 是实参的"复印件",
// 交换只发生在 add 栈帧的参数区里,main 毫无察觉
递归就是过程调用的自我叠加。nn_sum(n) 递归实现中,每层调用增加一个栈帧(保存 EBX、EBP、返回地址、参数 n),n 层递归就是 n 个栈帧叠在栈上;每层还要执行 16 条左右的准备/结束阶段指令。而循环实现只需一个 4 字节(按 16B 对齐也仅 16 字节)的栈帧和 n 次循环体------递归的空间开销是循环的 n 倍,时间也多出 16n 条额外指令。

【图13:nn_sum(n) 的递归执行。左侧汇编中用红蓝箭头标出每条指令对应的栈动作;右侧栈中自底向上是 P 帧、Sum(n) 帧(参数 n、返回地址、EBP/EBX 旧值)、Sum(n-1) 帧......一层层叠上去,直到 n≤0 触发边界条件,再一层层弹回来累加。结论写在图右下:每次递归调用都增加一个栈帧,空间开销很大------深递归会耗尽栈空间导致栈溢出。】
栈帧布局还解释了那个 fun(i) 之谜:
cpp
double fun(int i) {
volatile double d[1] = {3.14};
volatile long int a[2];
a[i] = 1073741824; // 越界写入
return d[0];
}
局部数组 a2 就在栈帧里,C 对数组访问不做边界检查。i=0、1 时正常;i=2、3 时写入的 0x40000000 恰好覆盖了相邻的 d0(3.14 的 double 机器数)的低位或高位字节,返回值变成 3.1399998664856 或 2.00000061035156;i=4 时连保存的 EBP 都被改写,返回 3.14 后立刻触发存储保护错。数组越界不报错,只是安静地改写邻居住户------直到某一次让进程崩溃。
八、复杂数据类型的机器级布局
数组元素的地址公式是 首地址 + i × 元素宽度,对应汇编的"比例变址"寻址:static short A[4] 中取 A[i] 到 AX 就是 movw (%edx,%ecx,2), %ax(EDX 放首址、ECX 放 i、比例因子 2)。静态数组的初始化值直接编进目标文件的 .data 节(如 buf[2]={10,20} 在 0x08049080 处是 0A 00 00 00 14 00 00 00,又是小端);auto 数组则由 movl $10,-8(%ebp) 这样的指令在运行时写入栈帧。指针与数组的关系 a[i] = *(a+i) 落到机器级就是同一个寻址通式,ptr+i 的步长是元素宽度而非 1。
结构体成员的访问是"基址 + 编译期算好的偏移量"。对 struct cont_info {char id[8]; char name[12]; unsigned post; char address[100]; char phone[20];},若变量 x 在 0x8049200,则 &x.post = 0x8049214,读它就是 movl 20(%edx), %eax。结构体作参数时,按值传递要把全部成员复制到栈中参数区(时间空间双开销),按地址传递只传一个指针------这也是为什么工程惯例是结构体参数一律传指针加 const。
联合体让所有成员共享同一块空间,大小按最长成员对齐后计。它有两个经典用途:互斥字段的省内存写法,以及同一位串的多类型重解释:
cpp
unsigned float2unsign(float f) {
union { float f; unsigned u; } tmp_union;
tmp_union.f = f;
return tmp_union.u; // 返回 float 的机器数(IEEE 754 位模式)
}
编译器把整个函数优化成两条 mov------机器级代码根本不区分数据类型,不管声明成 float 还是 unsigned,都是同一个 0/1 序列。这种"位级视角"也是写序列化、哈希和底层测试代码的基本功。
对齐(Alignment)是布局的最后一个变量。主存按字(4/8 字节)为单位存取,数据若横跨字边界,一次访问要拆成两次,访存次数增加。所以编译器默认按自然边界对齐:int/float 按 4 字节,double/long long 按 8 字节(Windows;Linux 宽松些,double 也按 4)。看两个结构体:

【图14:同一组成员(int i; char c; int j;)的两种排列。S1 把 char c 夹在两个 int 中间,c 后面要垫 3 个填充字节才能让 j 对齐到 8,共 12 字节;S2 把 c 挪到最后,单个变量只需 9 字节。但要注意:S2 声明数组 d4 时仍需 12 字节/元素------因为数组中每个元素都必须独立满足对齐要求,元素大小必须是"对齐要求"的整数倍(9 字节无法让第二个元素从 4 的倍数地址开始)。结论:S2 仍比 S1 好,成员按大小降序排列是对齐友好的通用法则。】
对齐行为可以用 #pragma pack(n)(强制 n 字节对齐,牺牲速度换空间)、__attribute__((aligned(m)))(整个结构按 m 对齐且大小为 m 的倍数)、__attribute__((packed))(紧凑排列)调节。一个实测例子:#pragma pack(4) 且整个结构 aligned(1024) 时,uint32/uint8/uint8/uint32/uint64 五个成员的偏移是 0x0/0x4/0x5/0x8/0xc,结构体总大小 1024------总大小也被对齐规则撑大了。
九、越界访问与缓冲区溢出攻击
把第七、八节的知识串起来,就能看懂缓冲区溢出攻击的完整原理。C 对栈上数组的写入不做任何边界检查,而栈帧里恰好躺着敏感数据------保存的 EBP 和返回地址。构造一个有漏洞的程序:
cpp
void outputs(char *str) {
char buffer[16];
strcpy(buffer, str); // 无长度检查!
printf("%s \n", buffer);
}
void hacker(void) { printf("being hacked\n"); }
int main(int argc, char *argv[]) {
outputs(argv[1]);
return 0;
}
outputs 的栈帧共 24 字节局部空间(buffer16 + 对齐 + 保存的寄存器等),加上 4 字节返回地址。当命令行字符串超过 25 个字符(16 字节 buffer + 4 字节保存值 + 4 字节返回地址 + 1 字节 '\0')时,strcpy 会一路写穿 buffer、覆盖保存的 EBP,并把返回地址改写成攻击者指定的值 ------例如 hacker 函数的首地址 0x08048411。outputs 执行 ret 时,CPU 从被污染的栈顶取出"返回地址"跳了过去,程序转而执行 hacker。

【图15:缓冲区溢出攻击现场。左上是被攻击的 outputs 函数(16 字节 buffer + strcpy 无边界检查)与攻击目标 hacker;右侧栈帧图自上而下标出:main 帧的命令行参数首址与返回地址、outputs 的帧底(EBP 旧值)、buffer15~buffer0 四行共 16 字节、以及 strcpy 的两个实参位置。绿色箭头示意 strcpy 从 str 向 buffer 的复制方向------只要源字符串够长,复制就会越过 buffer 的边界,把返回地址所在的 4 字节一并覆盖。]
完整的攻击程序通过 execve 把精心构造的字符串(20 个填充字符 + \x11\x84\x04\x08 的 hacker 地址 + 结束符)作为 argv1 传入,运行输出 "being hacked" 后以 Segmentation fault 结束------因为 hacker 自己 ret 时取到的是不确定地址。这个演示级例子只改写了控制流去执行"已存在"的代码;更危险的变种会把可执行机器码(shellcode)作为输入数据写进栈,再让返回地址指向它。缓冲区溢出是极为普遍且危险的漏洞类别,理解它的第一步,恰恰是理解栈帧的每个字节都在哪。
十、链接与加载:从 .o 到进程
10.1 链接为什么存在
链接的概念比高级语言古老。纸带时代,往程序中间插入一条指令,后面所有跳转地址都要人工重算、重新打孔;汇编语言用符号(标号)代替地址后,"把符号翻译成正确地址"这件事就交给了汇编器和链接器。多模块开发进一步放大了链接的价值:模块化 (一个程序拆成多个源文件、可构建公共函数库)与效率(时间上只需重编被修改的模块再重新链接;空间上无需把库源码放进自己的文件,可执行文件和内存里只包含真正调用的那部分代码)。
10.2 三类目标文件与 ELF 的两种视图
目标文件分三类:可重定位目标文件 (.o,代码和数据地址都从 0 开始,可与其他 .o 合并)、可执行目标文件 (代码和数据已是虚拟地址空间中的地址,可直接复制进内存执行)、共享目标文件(.so,特殊的可重定位文件,装入或运行时动态链接)。ELF 格式为前两类设计了两种视图------链接视图(节的集合)与执行视图(段的集合)。
先看 .o 的内部结构:

【图16:用 readelf -S 列出 test.o 的节头表,右图是按偏移量还原的文件布局。ELF 头(含魔数 7f 45 4c 46、e_shoff=0x120 指向节头表)之后依次是 .text(0x34 起,0x5b 字节,AX 属性:可分配可执行)、.data(已初始化全局变量)、.rodata(只读数据,如 printf 格式串、跳转表)、.bss(未初始化全局变量的占位符)、.comment、.shstrtab、.symtab(符号表,0x120 字节)、.strtab(符号名字符串)和 .rel.text(重定位信息)。红色下划线标出的 4 个节(.text/.data/.bss/.rodata)是将来要分配存储空间的部分;.bss 的 sh_offset 无意义------它根本不占磁盘空间,只在节头表里声明"将来要多少字节"。】
这张图还透露了 ELF 的两级索引设计:ELF 头不逐个记录所有节,只给出 e_shoff 一个指针------解析器先读头,再跳到 0x120 处的节头表(11 个表项),才能拿到每个节的位置与属性;节头表自己也是目录里的一个条目。红色下划线的 4 个节恰好对应程序运行时内存的四种权限形态:.text 可执行、.data 与 .bss 可读写、.rodata 只读;其余节只在链接和调试时有用,加载进内存纯属浪费。还有一个细节值得注意:可重定位文件所有节的"地址"列都是 0------地址要等链接器把所有 .o 合并之后才能确定,这正是"可重定位"这个名字的含义。
.bss 不占磁盘空间是空间效率的点睛之笔:未初始化全局变量反正默认为 0,与其在文件里存一长串 0,不如只在节头表里说明"要预留多少",加载时再由系统清零。ELF 头里的魔数(a.out 是 01 07,PE 是 4D 5A,ELF 是 7F 45 4C 46)用于加载时快速确认文件类型。
可执行文件的视图则换成了程序头表 (段头表):ELF 头的 e_entry 给出第一条指令地址(.o 里是 0),新增 .init 节存放初始化函数 _init,去掉两个 .rel 节(无需再重定位)。程序头表的每个表项(32 字节)描述一个段:把具有相同访问属性的节合并映射。典型的 32 位 Linux 可执行文件有两个可装入段------只读代码段(ELF头+程序头表+.init+.text+.rodata,映射到 0x8048000 起,RE 权限)和读写数据段(.data+.bss,映射到 0x8049f0c 起,RW 权限,.data 按 filesz 初始化、其后 memsz-filesz 的 .bss 部分清零)。

【图17:可执行文件装入 32 位 Linux 虚拟地址空间后的映像。左侧是文件内容,右侧是进程的虚拟地址空间:0x08048000 起装入只读代码段(.init/.text/.rodata),随后是读写数据段(.data/.bss);往上依次是 malloc 动态生长的堆(brk 界限)、共享库区域、从高地址向下生长的用户栈(%esp 指向栈顶),最顶端 1GB 是内核虚存区。注意"存储器映像"指的是虚拟地址空间的布局,不是物理内存------加载器实际建立的只是映射关系,并未真正从磁盘拷贝数据。】
10.3 符号与符号解析
链接的第一步是符号解析 :把每个模块中引用的符号与某个模块中的定义建立关联。"定义"的实质是分配了存储空间 ------函数名对应其代码区,变量名对应其静态数据区。局部变量(如 temp)分配在栈中,不会在过程外被引用,因此根本不是链接器关心的符号。符号分三类:全局符号 (本模块定义、可被其他模块引用,如非 static 函数和全局变量)、外部符号 (其他模块定义、本模块引用)、本地符号(带 static 的函数和全局变量,仅本模块可见)。符号表 .symtab 的每个 16 字节表项记录符号名(strtab 偏移)、地址(节内偏移或虚拟地址)、大小、类型(数据/函数)、绑定属性(全局/局部/弱)和所在节索引 Ndx(UND 表示未定义、COM 表示未初始化的 COMMON 块、ABS 表示不该被重定位)。
全局符号有强弱之分:函数名和已初始化的全局变量是强符号,未初始化的全局变量是弱符号 。链接器的解析三条规则:强符号不能多次定义(否则链接错误);一强多弱按强定义;多弱则任选其一(gcc -fno-common 会对此告警)。规则本身简单,后果却惊人:
cpp
/* main.c */ /* p1.c */
int d = 100; double d;
int x = 200; void p1() { d = 1.0; }
void p1(void);
int main() {
p1();
printf("d=%d, x=%d\n", d, x);
}
d 在两个模块被重复"定义"(一强一弱,链接器不报错,按强符号解析),但 p1.c 以为 d 是 8 字节 double------d = 1.0 会把 1.0 的 64 位机器数 3FF00000 00000000H 整个写进内存。IA-32 是小端,低 32 位(00000000H)落在 main 的 d(int)上,高 32 位(3FF00000H)落到相邻的 x 上。x 的 int 解释是 2³⁰ - 2²⁰ = 1072693248。于是打印 d=0, x=1072693248------没有警告,没有报错,错误在离引发处很远的未来才显现。防御守则:尽量避免全局变量;必须用时全部加 static、赋初值、外部引用加 extern。
10.4 静态链接与符号解析算法
静态库(.a)用 ar rcs mylib.a myproc1.o myproc2.o 把多个 .o 打包。链接器解析时维护三个集合:E (将被合并的目标文件集合)、U (当前未解析引用集合)、D(已定义符号集合)。扫描顺序按命令行:遇到 .o 就加入 E 并把其定义符号并入 D、未定义符号并入 U;遇到 .a 就用其中的模块逐个匹配 U,匹配到的模块加入 E。扫描结束时 U 必须为空,否则报"undefined reference"。
这个算法解释了一个新手必踩的坑:命令行顺序 。gcc -static -o myproc ./mylib.a main.o 会失败------扫描 mylib.a 时 U 还是空的,两个模块都不被选入 E 而被丢弃;之后 main.o 的 myfunc1 永远无法解析。正确写法是把库放最后:gcc -static -o myproc main.o ./mylib.a。库之间有依赖时同理(func.o 依赖 libx.a、libx.a 依赖 libz.a,则 func.o libx.a libz.a;相互依赖则重复列出 func.o libx.a liby.a libx.a)。
10.5 重定位:把占位符换成真地址
符号解析完成后进入重定位,分三步:合并相同的节 (所有 .text 合成一个 .text,所有 .data 合成一个 .data);确定定义符号的地址 (每个符号在虚拟地址空间中落位);修改引用符号处的地址(依据 .rel.text/.rel.data 里的重定位条目)。

【图18:链接过程的本质------合并相同的"节"。左侧是三个可重定位目标文件:main.o(.text 里是 main(),.data 里是 int buf2={1,2})、swap.o(.text 里是 swap(),.data 里是 int *bufp0=&buf0,static 的 bufp1 进 .bss),外加系统代码与系统数据;右侧是合并后的可执行目标文件。彩色箭头标出每个节的重定向去向:所有 .text 合并为一个整体,所有 .data 合并,bufp1 单独落入 .bss,.symtab 与 .debug 不参与加载。"合并相同的节"是链接的第一个物理动作,也是后续地址计算的基础。】
每条重定位条目记录:节内偏移 offset、绑定的符号 symbol、重定位类型 type。IA-32 有两种基本类型:R_386_32 (绝对地址,直接填符号的 32 位地址)和 R_386_PC32(PC 相对地址,填"目标地址 - 下一条指令地址")。
以 call swap 为例:main.o 里这条 call 的机器码是 e8 fc ff ff ff------fcffffff 即 -4,是汇编器留下的占位符,同时 .rel.text 中有条目 7: R_386_PC32 swap(offset=7,指向 call 的操作数)。链接后 main 从 0x8048380 开始占 0x12 字节,swap 对齐到 0x8048394。call 的下一条指令地址(PC)= 0x8048380 + 0x7 - (-4) = 0x804838b,于是重定位值 = 0x8048394 - 0x804838b = 9,最终机器码变成 e8 09 00 00 00。数据引用同理:swap.o 的 int *bufp0 = &buf[0] 在 .rel.data 中有条目 0: R_386_32 buf,链接后 bufp0(0x8049628)的内容被填成 buf 的绝对地址 0x8049620(机器数 20 96 04 08,小端)。swap 函数中共有 6 处这样的重定位,全部由链接器在合并后的地址上一次算清。
10.6 共享库与动态链接
静态库的三大缺点:printf 这类库函数被复制进每个进程的代码段(内存浪费)、被合并进每个可执行文件(磁盘浪费)、库升级要重新编译链接(更新麻烦)。共享库(Linux 的 .so / Windows 的 .dll) 解决之:代码和数据只有一份备份,装入时或运行时动态链接,升级即换文件,还能作为插件扩展程序功能。两种方式:加载时链接 (程序头表的 INTERP 段记录动态链接器 ld-linux.so 的路径,加载器先启动它完成重定位再交还控制权)和运行时链接(dlopen/dlsym/dlerror/dlclose 接口按需装卸)。
动态链接的前提是共享库代码可以被加载到任意地址------位置无关代码 PIC (gcc -fPIC)。PIC 把四种引用分而治之:模块内调用/跳转用 PC 相对偏移(相对距离固定,无需重定位);模块内数据访问通过"call __get_pc 取 PC + 固定偏移"定位(多花几条指令);跨模块数据访问引入 GOT (全局偏移表,.data 起始处的指针数组,GOT 与代码间距离固定,加载时动态链接器把 &b 等外部地址填进 GOT 项);跨模块调用同样可用 GOT 间接调用,或用延迟绑定优化:

【图19:模块间调用 ext() 的 GOT/PLT 方案。左侧汇编:call 先跳到 PLT1(ext 的桩),桩代码先 jmp *GOT[3]------第一次调用时 GOT3 还指向 PLT1 的下一条指令,于是顺势 pushl $0(函数 ID)并跳到 PLT0,由动态链接器解析出 ext 的真实地址填入 GOT3,再转入 ext;此后每次调用只需多执行一条 jmp。右侧示意图标出 .text、GOT(含 &b、&ext 两项)与共享库模块的关系,以及代码到 GOT 的固定偏移 0x1204。延迟绑定的收益:加载时不重定位任何函数地址,第一次调用才解析,之后走捷径。】
10.7 加载:让程序"活"过来
最后一步是加载。shell 收到 ./hello 后:构造 argv/envp → fork() 创建子进程 → 子进程调用 execve("./hello", argv, envp) → execve 调用加载器 。加载器按程序头表把可执行文件的代码和数据"映射"进新进程的虚拟地址空间(只建映射、不真正拷贝),把 PC(EIP)设为入口点 _start,随后依次执行 __libc_init_first、_init 等初始化例程,最终进入 main------int main(int argc, char *argv[], char *envp[]) 的三个参数正是 shell 构造好的那份 argv/envp。至此,从源程序字符流到一条条指令的取指-译码-执行循环,整条链路打通。
十一、写在最后
这篇文章的主线可以用一句话复述:高级语言程序经由预处理、编译、汇编、链接四个阶段变成可执行文件;可执行文件按 ELF 格式组织,由加载器映射进虚拟地址空间;CPU 按"存储程序"方式取指执行;而指令背后,是补码、IEEE 754 和标志位定义的一整套模运算世界。
理解了这条链路,开头那些反直觉现象都有了精确答案,也为后续的操作系统(进程与虚拟存储)、编译原理(中间表示与优化)、体系结构(流水线与存储层次)铺平了地基。程序出错时,能从汇编层面回答"机器到底做了什么",是从"会写代码"到"理解程序"的分水岭。机器永远是对的------让对的东西看起来不那么反直觉,就是系统思维的全部意义。