Lecture 6: 机器级编程 IV——数组、结构体、联合体与浮点 (Machine Programming IV: Arrays, Structs, Unions, and Floating Point)
Lecture 6: 机器级编程 IV——数组、结构体、联合体与浮点 (Machine Programming IV: Arrays, Structs, Unions, and Floating Point)
讲义对应:CMU 15-213 Lecture 6 — Machine Programming (Part IV)(素材:
F25-06-machine-delta.txt;Fall 2026 对照:06-machine-delta.txt) 教材对应:CS:APP3e 第 3 章 3.8–3.11(数组分配与访问、异质数据结构、对齐、浮点代码) 关联 Lab:L3 Attack Lab(栈布局、缓冲区溢出、ROP 链)
6.1 概述
本讲回答一个核心问题:C 语言里那些”高级”的复合数据类型,在机器一级究竟长什么样? 聚合类型(aggregate type)——数组、结构体、联合体——并没有对应的机器指令,硬件看到的只有连续字节和”基址 + 偏移”的寻址。编译器负责把 A[i][j]、p->next、u.f 全部降解成 leaq / movl 偏移(%rdi,%rsi,4), %eax 这样的地址算术。
承接 Lecture 5 的过程与栈帧知识,本讲先把”数据”摆到内存图谱上(x86-64 Linux 地址空间、栈/堆/数据/代码段),再反过来利用它:栈上的数组一旦越界,就会覆盖相邻成员乃至返回地址,这就是缓冲区溢出(buffer overflow)。系统随后用栈随机化、不可执行内存和栈金丝雀(canary)三层防御应对,攻击者则以返回导向编程(Return-Oriented Programming, ROP)绕过。最后回到数据的位级表示:IEEE 754 浮点格式、%xmm 寄存器与 SSE 指令,以及浮点运算为何不满足结合律。这一讲是 Attack Lab 的直接理论前提。
6.2 核心概念与底层机制图解
6.2.1 数组与指针算术(Array Allocation and Pointer Arithmetic)
- 定义与目的:C 声明
T A[L];分配一块连续区域,大小为L * sizeof(T)字节。数组标识符A在表达式中退化为(decay 为)指向第 0 个元素的指针,类型是T *,值等于首元素地址。 - 直观解释:数组就像电影院里连号的一排座位——座位号(下标)乘以座位宽度(
sizeof(T))就是离排头的距离。数组名不是”一整排座位”,而是”排头座位的位置标签”。 - 底层机制图解:
int val[5]; /* x 为起始地址,每格 4 字节 */
+--------+--------+--------+--------+--------+
val: | val[0] | val[1] | val[2] | val[3] | val[4] |
+--------+--------+--------+--------+--------+
地址: x x+4 x+8 x+12 x+16 x+20
表达式 val val+1 &val[2] *(val+3) val+i
类型 int* int* int* int int*
值 x x+4 x+8 val[3] x + 4*i
- 关键公式:
A[i]编译为*(A + i*sizeof(T));因此p + i的实际地址增量是 $i \times \texttt{sizeof(*}p\texttt{)}$,而不是 $i$ 个字节。&A[i]与A + i完全等价。指针相减q - p得到的是元素个数(两个地址之差除以sizeof(T)),不是字节差。 - 与机器码/硬件的对应:访问
int z[i]只要一条movl (%rdi,%rsi,4), %eax——比例变址寻址(scaled indexed addressing)在地址生成单元(AGU)里用一个周期完成基址 + 变址×比例,这正是为数组访问设计的硬件。sizeof(T)只能取 1、2、4、8,超出范围编译器会退化为乘法 + 加法。
6.2.2 嵌套数组与行优先序(Nested Arrays and Row-Major Order)
- 定义与目的:
T A[R][C];共R*C*sizeof(T)字节,按行优先(row-major)排列:先存第 0 行全部 C 个元素,再存第 1 行…… - 直观解释:像读书——从左到右读完一行,再换到下一行。
A[i]本身是一个”长度为 C 的数组”,所以A的类型是”数组的数组”。 - 底层机制图解:
int A[4][5]; /* R=4 行, C=5 列, sizeof(int)=4, 共 4*5*4 = 80 字节 */
行\列 j=0 j=1 j=2 j=3 j=4
+--------+--------+--------+--------+--------+
A[0] | 1 | 5 | 2 | 0 | 6 | 行起始 = A + 0*20
+--------+--------+--------+--------+--------+
A[1] | 1 | 5 | 2 | 1 | 3 | 行起始 = A + 1*20
+--------+--------+--------+--------+--------+
A[2] | 1 | 5 | 2 | 1 | 7 | 行起始 = A + 2*20
+--------+--------+--------+--------+--------+
A[3] | 1 | 5 | 2 | 2 | 1 | 行起始 = A + 3*20
+--------+--------+--------+--------+--------+
字节: A+0 A+4 A+8 A+12 A+16 A+20 ...(连续递增)
A[i][j] 的地址 = A + (i*C + j)*sizeof(T) = A + (i*5 + j)*4
例: A[2][3] -> A + (2*5 + 3)*4 = A + 52
行向量:A[i] 的起始地址 = A + i*(C*sizeof(T)) = A + i*20
- 与机器码/硬件的对应:
A[i](行访问)就是A + 20*i;A[i][j]则是A + 4*(5*i + j)。编译器在-O1以上会做强度削弱(strength reduction):把”乘 20”写成leaq (%rdi,%rdi,4),%rax得到5*i,再leaq pgh(,%rax,4),%rax乘 4——两次移位加法代替一次乘法,且完全避开imul。关键推论:行优先意味着同一行的元素在内存中相邻(空间局部性好),按行遍历比按列遍历缓存命中率高得多——详见 Lecture 9/10。
6.2.3 结构体与成员偏移(Structures and Member Offsets)
- 定义与目的:结构体把异质(heterogeneous)字段按声明顺序打包成一块内存,大到足以容纳所有字段。编译期就确定了每个成员的偏移(offset),汇编里只出现偏移常量,字段名已经消失。
- 直观解释:像一张固定格式的登记表——第 1 栏姓名、第 2 栏年龄……栏位顺序由填写规则(声明顺序)决定,编译器绝不会为了省纸而重排栏位。
- 与机器码/硬件的对应:访问
r->a[idx]编译成leaq (%rdi,%rsi,4),%rax(r + 4*idx);访问r->next编译成movq 24(%rdi),%rdi——基址 + 编译期常量偏移。成员偏移用<stddef.h>的offsetof(struct S, member)获取,它是编译期常量,甚至在链接期作为重定位量出现(讲义里写作a+8,由链接器解析成绝对地址)。 - 补充说明:
struct rec { int a[4]; size_t i; struct rec *next; };的偏移是 0 / 16 / 24,sizeof为 32——数组a占 16 字节,size_t要求 8 字节对齐所以紧接其后,指针再 8 字节,末尾无需补齐(已是 8 的倍数)。链表遍历因此是一行movq 24(%rdi),%rdi加一次判空跳转。
6.2.4 对齐与填充(Alignment and Padding)
- 定义与目的:x86-64 要求 $K$ 字节的原始类型,其地址必须是 $K$ 的倍数:
char无限制、short(2)地址最低 1 位为 0、int/float(4)最低 2 位为 00、double/long/指针(8)最低 3 位为 000。为满足它,编译器在成员之间插入内部填充(internal padding),在结构体末尾插入外部填充(external padding)。 - 直观解释:像停车场的车位线——4 米长的车必须停在 4 米网格的整格上,中间空出来的位置不能再塞别的车(填充),只为让每辆车都对齐入位。
- 为什么要填充:内存系统按对齐的 4/8 字节块访问。跨越缓存行(cache line,64 字节)的数据需要两次访存;跨页(4 KB)的数据让虚拟内存地址翻译更麻烦。填充是用空间换访存效率。
- 底层机制图解:
struct S1 { char c; int i[2]; double v; }; /* K = 8 (由 double 决定), sizeof = 24 */
偏移 0 1 2 3 4 5 6 7
+----+----+----+----+----+----+----+----+
| c |////|////|////| i[0] | //// = 填充
+----+----+----+----+----+----+----+----+
↑ ↑
p+0 p+4 (4 的倍数)
偏移 8 9 10 11 12 13 14 15
+----+----+----+----+----+----+----+
| i[1] |////|////|////|////|
+----+----+----+----+----+----+----+
↑ ↑
p+8 (8 的倍数) p+16
偏移 16 17 18 19 20 21 22 23
+----+----+----+----+----+----+----+----+
| v (double, 8 字节) |
+----+----+----+----+----+----+----+----+
↑
p+16 (8 的倍数);结构体长度 24 = 3*8,是 K=8 的倍数
=> 注意 K = 8 < sizeof(struct S1) = 24
对照:struct S2 { long v; int i[2]; char c; }; /* sizeof = 24,尾部 7 字节外部填充 */
struct S4 { char c; int i; char d; }; /* sizeof = 12 */
struct S5 { int i; char c; char d; }; /* sizeof = 8 */
把大类型放前面(S5)能省下 4 字节。
- 真实运行数据(本机 GCC 12.2.0 / x86-64 Linux 实测):
sizeof(struct S1) = 24
offsetof c / i / v = 0 / 4 / 16
对齐要求 K(S1) = 8 (= max member alignment)
sizeof(struct S2) = 24 (17 -> 补齐到 24)
offsetof v / i / c = 0 / 8 / 16
sizeof(struct S3) = 12, offsetof j = 8
sizeof(struct S4) = 12
sizeof(struct S5) = 8
- 结构体数组:因为结构体长度已是 K 的倍数、起始地址也是 K 的倍数,所以
struct S A[L]的元素之间不需要额外填充,A[i]的地址就是A + i*sizeof(struct S)。讲义中struct S3 { short i; float v; short j; } a[10];的sizeof是 12(含 2 字节尾部填充),a[idx].j的汇编为leaq (%rdi,%rdi,2),%rax(3*idx)再movzwl a+8(,%rax,4),%eax——先算3*idx再乘 4 得12*idx,把两次乘法都变成了lea。 - 补充说明:Fall 2026 的同一页把
struct S1/S2里的double v换成了long v。二者都是 8 字节、对齐要求都是 8,所以 K 与所有偏移完全不变——这正说明结构体布局只取决于大小和对齐,与类型的语义无关。
6.2.5 联合体(Unions)
- 定义与目的:
union U { ... };的所有成员共享同一块内存起始地址,大小 = 最大成员的大小,对齐 = 最大成员的对齐。同一时刻只能用其中一个字段(否则读到的是别的字段的位模式)。 - 直观解释:像一块可擦写白板——你写”浮点数”时它是浮点数,擦掉写”无符号整数”时它是整数,白板本身只有一块、大小固定。
- 三大用途:
- 节省空间:多个互斥的字段共用一块存储,如
value_t { bool is_float; union {float f; unsigned u;} val; }。 - 类型双关(type punning):把同一串位按不同类型解读。
bit_float_t { float f; unsigned u; }里的bit2float(u)返回的是按位重解释的浮点值,与(float) u(整数转浮点,会做数值转换)完全不同——这是讲义明确点出的、学生最容易搞错的地方。 - 和类型(sum type / tagged union):用一个标签字段记录当前哪个成员有效。
- 节省空间:多个互斥的字段共用一块存储,如
- 字节序影响:小端序下,联合体的
unsigned char c[8]与unsigned int i[2]会给出完全不同的读数。讲义示例把c[0..7]设为0xf0..0xf7后:
Characters 0-7 == [0xf0,0xf1,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7]
Shorts 0-3 == [0xf1f0,0xf3f2,0xf5f4,0xf7f6]
Ints 0-1 == [0xf3f2f1f0,0xf7f6f5f4]
Long 0 == [0xf7f6f5f4f3f2f1f0]
最低有效字节(LSB)在最低地址,这正是小端序(Little Endian,x86-64 采用)的定义;而在大端序的 Sun/Sparc 上同一段代码会打印 [0xf0f1f2f3, ...]。做网络协议或读写二进制文件时,”字节序不匹配”就是从这里来的。
- 与机器码/硬件的对应:联合体的成员访问不需要任何指令——所有成员偏移都是 0,联合体本身只是一块内存的别名。
6.2.6 IEEE 754 浮点表示(Floating Point Representation)
- 定义与目的:用有限位数近似表示实数。CS:APP3e 3.11 的浮点格式把位域切成三部分:符号位 $s$、阶码(exponent)
exp、尾数(fraction)frac。 - 直观解释:科学计数法 $1.0101_2 \times 2^{5}$ 的二进制版本——尾数保存”有效数字”,阶码保存”小数点该往哪挪”。阶码用偏置(bias)编码,好处是能让浮点数在不做符号处理的整数比较电路上直接比较大小。
- 底层机制图解:
IEEE 754 双精度 double —— 64 位
63 62 52 51 0
+----+------------------------+----------------------------------------+
| s | exp | frac |
| 1 | 11 | 52 |
+----+------------------------+----------------------------------------+
MSB LSB
IEEE 754 单精度 float —— 32 位
31 30 23 22 0
+----+-----------------+---------------------------------------------+
| s | exp | frac |
| 1 | 8 | 23 |
+----+-----------------+---------------------------------------------+
- 四类数的判定与求值公式(bias = 127 / 1023):
exp | frac | 类别 | 值 $V$ |
|---|---|---|---|
| $0$ | $0$ | $\pm 0$ | $V = (-1)^s \cdot 0$(有 +0 与 -0 两个零) |
| $0$ | $\neq 0$ | 非规格化数(denormalized) | $V = (-1)^s \times 0.\texttt{frac} \times 2^{1-\text{bias}}$ |
| $1 \sim 2^{k}-2$ | 任意 | 规格化数(normalized) | $V = (-1)^s \times M \times 2^{E}$,$M = 1.\texttt{frac}$,$E = \texttt{exp} - \text{bias}$ |
| 全 1 | $0$ | $\pm\infty$ | $V = \pm\infty$(如 $1.0/0.0$) |
| 全 1 | $\neq 0$ | NaN(Not a Number) | 如 $0.0/0.0$、$\infty - \infty$ |
规格化数的尾数隐含前导 1($M = 1.\texttt{frac}$),这一位不存储,白赚一个有效位;代价是 0 无法表示,于是让 exp=0 专门表示非规格化数,它用 $M = 0.\texttt{frac}$ 且阶码固定为最小值——这样 0 与最小规格化数之间的间距是均匀的,称为渐进下溢(gradual underflow)。
- 与机器码/硬件的对应:浮点数放在
%xmm0–%xmm15这 16 个 128 位 SSE 寄存器里;标量float用低 32 位、double用低 64 位,高位置零。
6.2.7 浮点寄存器与指令(Floating-Point Registers and Instructions)
- 寄存器约定:整型参数用
%rdi, %rsi, %rdx, %rcx, %r8, %r9,浮点参数一律用%xmm0–%xmm7(各自独立计数),浮点返回值放在%xmm0。 - 指令族:
- 数据传送:
movss(单精度标量,move scalar single)、movsd(双精度标量)、movaps/movapd(对齐的打包传送,要求 16 字节对齐)、movups/movupd(非对齐的打包传送,unaligned)。 - 算术:
addss/addsd、subss/subsd、mulss/mulsd、divss/divsd——助记符后缀ss= scalar single,sd= scalar double。 - 类型转换:
cvtss2sd(float→double)、cvtsd2ss(double→float)、cvtsi2sd(int→double)、cvttsd2si(double→int,截断,truncate)。
- 数据传送:
- 真实
gcc -Og -S输出(本机实测):
poly: # double poly(double a, float x, double b)
cvtss2sd %xmm1, %xmm1 # float 参数先扩成 double
mulsd %xmm0, %xmm1 # a * (double)x
movapd %xmm1, %xmm0
addsd %xmm2, %xmm0 # + b
ret # 返回值已在 %xmm0
d2f: cvtsd2ss %xmm0, %xmm0 ; ret # (float)d
f2d: cvtss2sd %xmm0, %xmm0 ; ret # (double)f
i2d: pxor %xmm0,%xmm0 ; cvtsi2sdl %edi,%xmm0 ; ret # (double)i
d2i: cvttsd2sil %xmm0, %eax ; ret # (int)d(向零截断)
注意 i2d 里那句 pxor %xmm0,%xmm0:cvtsi2sd 只写目标寄存器的低 64 位,必须先把高位清零,否则会留下上一次运算的垃圾位(伪依赖)。
6.2.8 进程内存布局与缓冲区溢出(Memory Layout and Buffer Overflow)
- x86-64 Linux 地址空间(讲义图,非按比例):从高地址到低地址依次是栈(runtime stack,8 MB 上限,存局部变量)、共享库、堆(
malloc/calloc/new动态分配,向高地址增长)、数据段(全局变量、static变量、字符串常量)、代码段 Text(只读的可执行指令)。栈基址和共享库位置都做了随机化。讲义给出的真实量级:局部变量0x00007ffe4d3be87c、malloc(2^28)得到0x00007f7262a1e010、全局big_array在0x0000000080601060、main()代码在0x000000000040060c。 - 漏洞原型:
char buf[4]; gets(buf);中gets完全没有长度限制,用户输入直接从buf开始向高地址写,越界后依次覆盖未使用空间、金丝雀、保存的寄存器、返回地址。 - 底层机制图解:
+------------------------+ 高地址
| call_echo 的栈帧 ... |
+------------------------+
| 返回地址 (8 字节) | echo 的 ret 从这里弹地址
| 00 40 06 c3 00 00 00 00|
+------------------------+ <- %rsp + 0x18
| 20 字节未使用(或金丝雀) |
+------------------------+
| buf[4] '3''2''1''0' | <- %rsp(gets 从这里开始写)
+------------------------+ 低地址
gets 的写入方向 ───────────────► (向高地址增长)
不越界: 24 字节内写完,程序正常返回
越界: '0'..'2' 共 24 字节刚好填满;再多 4 字节覆盖返回地址低 4 字节
-> 程序"返回"到 0x0400600 这类非法地址 -> Segmentation fault
- 三层防御:① 写安全的代码(用
fgets代替gets、strncpy代替strcpy、别用%s格式符);② 系统级保护——栈随机化(每次运行栈地址都不同,攻击者猜不到注入代码的位置)与不可执行内存(x86-64 加 NX 位,栈标记为不可执行,跳进去立即崩溃);③ 编译器插入栈金丝雀(-fstack-protector,现为默认):函数入口把%fs:0x28处的哨兵值存到缓冲区与返回地址之间,返回前xor比较,被改动就调用__stack_chk_fail终止进程。 - ROP 绕过:金丝雀保护不了”不越过缓冲区就无法写入”的攻击者,但栈不可执行挡住了代码注入。ROP 转而复用已有代码:在可执行段中寻找”一串指令 +
ret(编码为单字节0xc3)”的小工具(gadget),把它们在栈上串成链。讲义的两个例子:ab_plus_c中0x4004d4处的lea (%rdi,%rdx,1),%rax; ret实现%rax ← %rdi + %rdx;setval的立即数字节里恰好嵌着48 89 c7(movq %rax,%rdi),所以0x4004dc处存在一个mov %rax,%rdi; ret的小工具——x86-64 变长指令集允许从指令中间开始解码,这是 ROP 的物理基础。
ROP 执行模型(每个 gadget 以 c3 = ret 结尾)
gadget 1 code ... c3 ─┐
gadget 2 code ... c3 ─┼─► 每条 ret 从栈上弹出下一个 gadget 地址
gadget n code ... c3 ─┘
栈: [g1 地址][g2 地址][g3 地址]... %rsp 依次向高地址推进
6.3 代码示例与底层机制分析
6.3.1 示例一:数组寻址与行优先二维数组
代码(/tmp/c06/arr.c,gcc -g -Wall -std=c11 arr.c -o arr 编译通过):
/* c06_arrays.c — 数组寻址、指针算术与行优先二维数组 */
#include <stdio.h>
#include <stddef.h>
#define ZLEN 5
typedef int zip_dig[ZLEN];
#define R 4
#define C 5
static int A[R][C] = {
{1, 5, 2, 0, 6}, {1, 5, 2, 1, 3},
{1, 5, 2, 1, 7}, {1, 5, 2, 2, 1}
};
static zip_dig pgh[R] = {
{1, 5, 2, 0, 6}, {1, 5, 2, 1, 3},
{1, 5, 2, 1, 7}, {1, 5, 2, 2, 1}
};
int get_digit(zip_dig z, int digit) { return z[digit]; }
int *get_pgh_row(int index) { return pgh[index]; }
int get_pgh_digit(int index, int d) { return pgh[index][d]; }
int main(void)
{
printf("sizeof(zip_dig) = %zu (= 5 * sizeof(int))\n", sizeof(zip_dig));
printf("sizeof(A) = %zu (= R * C * sizeof(int))\n", sizeof(A));
printf("&A[2][3] = %p (+%zu bytes from A[0][0])\n",
(void *)&A[2][3], (size_t)((char *)&A[2][3] - (char *)&A[0][0]));
printf("公式预测偏移 = (2*C+3)*4 = %zu bytes\n", (size_t)((2 * C + 3) * 4));
int *p = &A[0][0]; /* 指针算术:增量 = sizeof(*p) */
printf("p=%p p+1=%p 差值=%zu bytes\n",
(void *)p, (void *)(p + 1), (size_t)((char *)(p + 1) - (char *)p));
printf("&A[2][3]==A[2]+3 ? %d\n", &A[2][3] == A[2] + 3);
printf("A[2][3]==*(*(A+2)+3) ? %d\n", A[2][3] == *(*(A + 2) + 3));
printf("get_pgh_digit(1,2) = %d\n", get_pgh_digit(1, 2));
int (*rowp)[C] = &A[3]; /* 指向整行的指针 */
printf("(*rowp)[4] = %d\n", (*rowp)[4]);
return 0;
}
【代码做什么?】
zip_dig是int[5]的类型别名,sizeof(zip_dig)得 20;sizeof(A)得 80。- 打印
&A[2][3]与首地址之差,验证它等于公式(i*C+j)*4 = 52。 - 打印
p与p+1的差,证明指针加 1 走的是 4 字节而非 1 字节。 - 用两条恒等式
&A[i][j] == A[i]+j、A[i][j] == *(*(A+i)+j)验证”数组的数组”语义。 - 用
int (*rowp)[C]说明”指向整行的指针”与”指向 int 的指针”类型不同、步长不同。
【底层机制透视】 A[i] 不是运行时计算出来的对象,而是编译期类型推导出的表达式:A[i] 的类型是 int[5],进一步退化成 int *,值为 A + i*20。整条链最后塌缩成一条比例变址寻址指令,没有循环、没有函数调用。
【内存布局 / 数据结构图解】(本机实测地址,栈上数组,每次运行可能不同):
sizeof(A) = 80
row 0: A[0]=0x404040 A[0]+C=0x404054 (20 bytes/row)
row 1: A[1]=0x404054 A[1]+C=0x404068 (20 bytes/row)
row 2: A[2]=0x404068 A[2]+C=0x40407c (20 bytes/row)
row 3: A[3]=0x40407c A[3]+C=0x404090 (20 bytes/row)
&A[2][3] = 0x404074 ( +52 bytes = 13*4 )
【与汇编 / 硬件的对应】 gcc -Og -S 生成的真实片段:
get_digit: # int get_digit(int z[], int digit)
movslq %esi, %rsi
movl (%rdi,%rsi,4), %eax # z[digit]:一条比例变址指令
ret
get_pgh_row: # int *get_pgh_row(int index)
movslq %edi, %rdi
leaq (%rdi,%rdi,4), %rax # 5 * index (强度削弱)
leaq pgh(,%rax,4), %rax # pgh + 20*index
ret
get_pgh_digit: # int get_pgh_digit(int index, int dig)
movslq %esi, %rsi
movslq %edi, %rdi
leaq (%rdi,%rdi,4), %rax # 5*index
addq %rsi, %rax # 5*index + dig
movl pgh(,%rax,4), %eax # M[pgh + 4*(5*index+dig)]
ret
三处细节值得记住:movslq 把 32 位 int 参数符号扩展到 64 位才能当变址寄存器使用;leaq (%rdi,%rdi,4) 是”乘 5”的标准写法(x + x*4);全局符号 pgh 的地址由链接器在重定位时填入,汇编器只留下 pgh(,%rax,4) 这样的待填坑。
6.3.2 示例二:结构体填充与 offsetof
代码(/tmp/c06/strct.c,gcc -g -Wall -std=c11 strct.c -o strct 编译通过):
/* c06_struct.c — 结构体填充、offsetof 与结构体数组 */
#include <stdio.h>
#include <stddef.h>
struct S1 { char c; int i[2]; double v; }; /* 讲义 S1, K = 8 */
struct S2 { long v; int i[2]; char c; }; /* 讲义 S2, 尾部外部填充 */
struct S3 { short i; float v; short j; }; /* 讲义 S3, sizeof = 12 */
struct S4 { char c; int i; char d; }; /* 12 字节 */
struct S5 { int i; char c; char d; }; /* 8 字节 */
struct rec { int a[4]; size_t i; struct rec *next; };
int *get_ap(struct rec *r, size_t idx) { return &r->a[idx]; }
long length(struct rec *r)
{
long len = 0;
while (r) { len++; r = r->next; }
return len;
}
int main(void)
{
printf("sizeof(S1)=%zu offsets c/i/v = %zu/%zu/%zu K=%zu\n",
sizeof(struct S1), offsetof(struct S1, c),
offsetof(struct S1, i), offsetof(struct S1, v),
_Alignof(struct S1));
printf("sizeof(S2)=%zu offsets v/i/c = %zu/%zu/%zu\n",
sizeof(struct S2), offsetof(struct S2, v),
offsetof(struct S2, i), offsetof(struct S2, c));
printf("sizeof(S3)=%zu offsetof j = %zu\n",
sizeof(struct S3), offsetof(struct S3, j));
printf("sizeof(S4)=%zu sizeof(S5)=%zu\n",
sizeof(struct S4), sizeof(struct S5));
printf("sizeof(rec)=%zu offsets a/i/next = %zu/%zu/%zu\n",
sizeof(struct rec), offsetof(struct rec, a),
offsetof(struct rec, i), offsetof(struct rec, next));
struct S3 arr[10];
printf("sizeof arr[10]=%zu &arr[3]-&arr[0]=%zu bytes\n",
sizeof(arr), (size_t)((char *)&arr[3] - (char *)&arr[0]));
printf("arr[3].j 地址偏移 = %zu\n",
(size_t)((char *)&arr[3].j - (char *)&arr[0]));
struct rec n1 = {{1, 2, 3, 4}, 0, NULL};
struct rec n0 = {{9, 9, 9, 9}, 1, &n1};
printf("get_ap(&n0,2)==&n0.a[2] ? %d length(&n0)=%ld\n",
get_ap(&n0, 2) == &n0.a[2], length(&n0));
return 0;
}
【代码做什么?】
- 用
offsetof把编译器暗中插入的填充”挖出来”:S1的i在偏移 4 而不是 1(前面 3 字节填充),v在偏移 16 而不是 12(又 4 字节填充)。 - 打印
_Alignof(S1) = 8,印证 K = 最大成员对齐值。 - 对比
S4(12 字节)与S5(8 字节)——同样的成员、不同的顺序,大小差 4 字节。 - 对结构体数组
arr[10]验证元素间距恰为sizeof(S3) = 12,没有额外填充。 get_ap/length演示成员访问就是”基址 + 常量偏移”。
【底层机制透视】 offsetof 是一个宏,展开后通常等价于 ((size_t)&((struct S *)0)->member)——把空指针当基址取成员地址,得到的整数就是偏移量。它必须是编译期常量,因为链接器和优化器都要用它。填充字节的内容是未定义的:memcmp 两个”字段相同”的结构体可能返回非零,用结构体做哈希键或直接 write 到文件都会踩这个坑。
【内存布局 / 数据结构图解】(struct S1 真实字节转储,c='A', i[0]=0x11223344, i[1]=0x55667788, v=1.0;小端序):
+0 0x41 <- c ('A')
+1 0x00 <- padding
+2 0x00 <- padding
+3 0x00 <- padding
+4 0x44 <- i[0] 低字节
+5 0x33 <- i[0]
+6 0x22 <- i[0]
+7 0x11 <- i[0] 高字节
+8 0x88 <- i[1] 低字节
+9 0x77 <- i[1]
+10 0x66 <- i[1]
+11 0x55 <- i[1] 高字节
+12 0x00 <- padding
+13 0x00 <- padding
+14 0x00 <- padding
+15 0x00 <- padding
+16 0x00 \
+17 0x00 |
+18 0x00 | v = 1.0 的 IEEE 754 双精度位模式
+19 0x00 | 0x3FF0000000000000,小端序存储
+20 0x00 |
+21 0x00 |
+22 0xf0 |
+23 0x3f /
【与汇编 / 硬件的对应】 gcc -Og -S 的真实输出,印证”偏移在编译期确定、汇编里看不到字段名”:
get_ap: # &r->a[idx]
leaq (%rdi,%rsi,4), %rax # r + 4*idx,偏移 0 直接省略
ret
length: # while (r) { len++; r = r->next; }
movl $0, %eax
jmp .L3
.L4:
addq $1, %rax
movq 24(%rdi), %rdi # r = Mem[r+24]:next 的偏移是常量 24
.L3:
testq %rdi, %rdi
jne .L4
ret
【实测验证】 两条命令就能把编译器插入的填充”照”出来。第一条是 -Wpadded(本机实测输出):
$ gcc -Wall -Wpadded -c strct.c
strct.c:5:25: warning: padding struct to align 'i' [-Wpadded]
5 | struct S1 { char c; int i[2]; double v; };
| ^
strct.c:5:38: warning: padding struct to align 'v' [-Wpadded]
strct.c:6:8: warning: padding struct size to alignment boundary [-Wpadded]
第二条是在 GDB 里让调试信息自己画出空洞(ptype /o = 显示偏移):
$ gcc -g -std=c11 gdbt.c -o gdbt
$ gdb -q -batch -ex 'break main' -ex run -ex next \
-ex 'p sizeof(struct S1)' -ex 'p/x (long)((char*)&s.v-(char*)&s)' \
-ex 'ptype /o struct S1' -ex 'x/24xb &s' ./gdbt
$1 = 24
$2 = 0x10 # v 的偏移 = 16
/* offset | size */ type = struct S1 {
/* 0 | 1 */ char c;
/* XXX 3-byte hole */
/* 4 | 8 */ int i[2];
/* XXX 4-byte hole */
/* 16 | 8 */ double v;
/* total size (bytes): 24 */
}
0x7fffffffb6b0: 0x01 0x00 0x00 0x00 0x40 0x00 0x00 0x00
0x7fffffffb6b8: 0x00 0x00 0x00 0x10 0x00 0x00 0x00 0x00
0x7fffffffb6c0: 0x00 0x08 0x00 0x00 0x00 0x00 0x00 0x00
pahole(显示每个成员偏移与空洞)或 ptype /o 是两条最省事的核对途径。注意 GDB 只有在函数体内出现过该结构体变量时才会保留其完整类型信息,所以 sizeof(struct S1) 要在 struct S1 s; 已被执行的位置求值。
6.3.3 示例三:IEEE 754 位域与浮点精度丢失
代码(/tmp/c06/flt.c,gcc -g -Wall -std=c11 flt.c -o flt -lm 编译通过):
/* c06_float.c — IEEE 754 位域、规格化/非规格化、精度丢失与结合律失效 */
#include <stdio.h>
#include <string.h>
#include <math.h>
#include <stdint.h>
typedef union { float f; unsigned u; } bit_float_t; /* 类型双关 */
typedef union { double d; unsigned long long u; } bit_double_t;
float bit2float(unsigned u) { bit_float_t a; a.u = u; return a.f; }
unsigned float2bit(float f) { bit_float_t a; a.f = f; return a.u; }
double bits2double(unsigned long long u) { bit_double_t a; a.u = u; return a.d; }
unsigned long long double2bits(double d) { bit_double_t a; a.d = d; return a.u; }
/* 由 s / exp / frac 手工拼出 IEEE 754 单精度值 */
static float build_float(unsigned s, unsigned exp, unsigned frac)
{
return bit2float((s << 31) | ((exp & 0xff) << 23) | (frac & 0x7fffff));
}
static void show_double(const char *n, double x)
{
unsigned long long u = double2bits(x);
printf("%-8s d=%-8g bits=0x%016llx s=%llu exp=0x%03llx frac=0x%013llx\n",
n, x, u, u >> 63, (u >> 52) & 0x7ff, u & 0xfffffffffffffULL);
}
int main(void)
{
printf("sizeof(float)=%zu sizeof(double)=%zu\n", sizeof(float), sizeof(double));
printf("FLT_EPSILON=%.10g DBL_EPSILON=%.17g\n\n",
1.19209290e-7, 2.2204460492503131e-16);
show_double("3.14", 3.14);
show_double("1.0", 1.0);
show_double("-0.0", -0.0);
show_double("1.0/0.0", 1.0 / 0.0);
show_double("0.0/0.0", 0.0 / 0.0);
printf("\n-- 手工拼装 V = (-1)^s * M * 2^E --\n");
printf("s=0 exp=127 frac=0 -> %g\n", build_float(0, 127, 0));
printf("s=0 exp=127 frac=0x400000 -> %g (M=1.5)\n", build_float(0, 127, 0x400000));
printf("s=0 exp=128 frac=0 -> %g (E=+1)\n", build_float(0, 128, 0));
printf("s=0 exp=126 frac=0x400000 -> %g (E=-1, M=1.5)\n", build_float(0, 126, 0x400000));
printf("s=0 exp=0 frac=1 -> %g (denorm = 2^-149)\n", build_float(0, 0, 1));
printf("s=0 exp=0 frac=0x7fffff -> %g (最大非规格化数)\n", build_float(0, 0, 0x7fffff));
printf("s=0 exp=1 frac=0 -> %g (最小规格化数 = 2^-126)\n", build_float(0, 1, 0));
printf("\n-- 十进制有效位与精度丢失 --\n");
printf("float 1/3 = %.10f\n", (double)(1.0f / 3.0f));
printf("double 1/3 = %.20f\n", 1.0 / 3.0);
printf("(int)16777217 -> float, 再转回 int = %d (2^24+1 被舍入)\n",
(int)(float)16777217);
printf("(float)0.1 = %.20f\n", (double)0.1f);
printf("0.1+0.2 == 0.3 ? %d\n", (0.1 + 0.2 == 0.3));
printf("0.1+0.2 = %.20f\n", 0.1 + 0.2);
printf("0.3 = %.20f\n", 0.3);
printf("fabs((0.1+0.2)-0.3) < 1e-9 ? %d\n", fabs((0.1 + 0.2) - 0.3) < 1e-9);
printf("\n-- 结合律失效 --\n");
double a = 1e20, b = -1e20, c = 3.14;
printf("(a+b)+c = %.10f\n", (a + b) + c);
printf("a+(b+c) = %.10f 相等? %d\n", a + (b + c),
((a + b) + c) == (a + (b + c)));
return 0;
}
【代码做什么?】 先用 union 做类型双关把 float/double 的位模式打印出来,再用 build_float 手工拼 s/exp/frac 验证求值公式,最后用 2^24+1、0.1+0.2、1e20+(-1e20+3.14) 三个经典例子演示精度丢失。
【实测输出(本机 GCC 12.2.0 / x86-64)】:
sizeof(float)=4 sizeof(double)=8
---- 双精度位域划分 ----
3.14 d=3.14 bits=0x40091eb851eb851f s=0 exp=0x400 frac=0x91eb851eb851f
1.0 d=1 bits=0x3ff0000000000000 s=0 exp=0x3ff frac=0x0000000000000
-0.0 d=-0 bits=0x8000000000000000 s=1 exp=0x000 frac=0x0000000000000
1.0/0.0 d=inf bits=0x7ff0000000000000 s=0 exp=0x7ff frac=0x0000000000000
0.0/0.0 d=-nan bits=0xfff8000000000000 s=1 exp=0x7ff frac=0x8000000000000
---- 单精度位域划分 ----
3.14 f=3.14 bits=0x4048f5c3 s=0 exp=0x80 frac=0x48f5c3
0.1f f=0.1 bits=0x3dcccccd s=0 exp=0x7b frac=0x4ccccd
+0.0 f=0 bits=0x00000000 s=0 exp=0x00 frac=0x000000
-0.0 f=-0 bits=0x80000000 s=1 exp=0x00 frac=0x000000
inf f=inf bits=0x7f800000 s=0 exp=0xff frac=0x000000
---- 手工拼装: V = (-1)^s * M * 2^E ----
s=0 exp=127 frac=0 -> 1
s=0 exp=127 frac=0x400000 -> 1.5
s=0 exp=128 frac=0 -> 2
s=0 exp=126 frac=0x400000 -> 0.75
s=0 exp=0 frac=1 -> 1.4013e-45 (denorm, 2^-149)
s=0 exp=0 frac=0x7fffff -> 1.17549e-38 (最大非规格化数)
s=0 exp=1 frac=0 -> 1.17549e-38 (最小规格化数 2^-126)
---- 十进制有效位与精度丢失 ----
float 1/3 = 0.3333333433
double 1/3 = 0.33333333333333331483
(int)16777217 -> float, 再转回 int = 16777216 (2^24+1 被舍入)
(float)0.1 = 0.10000000149011611938
0.1+0.2 == 0.3 ? 0
0.1+0.2 = 0.30000000000000004441
0.3 = 0.29999999999999998890
fabs((0.1+0.2)-0.3) < 1e-9 ? 1
---- 结合律失效 ----
(a+b)+c = 3.1400000000
a+(b+c) = 0.0000000000 相等? 0
【底层机制透视】 三条硬结论:
float只有 24 位有效位(隐含前导 1 + 23 位frac),对应约 $\log_{10} 2^{24} \approx 7.22$ 位十进制有效数字,所以float约 7 位十进制有效数字;double有 53 位有效位,对应 $\log_{10} 2^{53} \approx 15.95$,即约 15–16 位十进制有效数字。上面的float 1/3 = 0.3333333433从第 8 位开始就是噪声。int→float会丢精度:$2^{24}+1 = 16777217$ 需要 25 位有效位,float只能四舍五入到 $2^{24} = 16777216$。因此”用float存整数计数器”在数值超过 $2^{24} \approx 1.68 \times 10^7$ 后就会静默出错。- 浮点加法不满足结合律:$1.0 \times 10^{20}$ 与 $3.14$ 的指数相差约 66,$3.14$ 对齐后完全落在
double的 53 位有效位之外,被舍成 0。这就是 Lecture 1 的 “Ints are not Integers, Floats are not Reals” 的具体机制。浮点只满足有序性(单调性、符号),不满足结合律和分配律——把x+y+z改写成x+(y+z)是编译器不允许做的变换。
【内存布局 / 数据结构图解】 double 3.14 的位域分解:
0x40091EB851EB851F
│└────┬─────┘└──────────────────┬──────────────────┘
s=0 exp=0x400=1024 frac=0x91EB851EB851F
E = 1024 - 1023 = 1
M = 1.91EB851EB851F(16) = 1.57
V = (+1) * 1.57 * 2^1 = 3.14
按字节(小端序): 1F 85 EB 51 B8 1E 09 40
↑ 最低地址存最低有效字节
LSB MSB
【与汇编 / 硬件的对应】 类型转换的真实指令序列,见 6.2.7 的 gcc -Og -S 输出。特别提醒 d2i: cvttsd2sil——向零截断(truncate),(int)3.9 = 3、(int)(-3.9) = -3;C 语言里唯一的四舍五入是 round(),而 cvtsd2si(不带 t)才使用当前舍入模式。
6.3.4 示例四:结构体越界的安全含义(⚠️ 仅供演示,请勿模仿)
代码(/tmp/c06/oob.c,gcc -g -Wall -std=c11 -fno-stack-protector oob.c -o oob):
/* ⚠️ 仅供演示,请勿模仿:故意制造结构体数组越界写 */
#include <stdio.h>
#include <stddef.h>
typedef struct { int a[2]; double d; } struct_t;
double fun(int i) {
volatile struct_t s;
s.d = 3.14;
s.a[i] = 1073741824; /* UB:i >= 2 时越界,实际结果依编译器/机器而异 */
return s.d;
}
int main(void) {
printf("offsetof(a)=%zu offsetof(d)=%zu sizeof=%zu\n",
offsetof(struct_t, a), offsetof(struct_t, d), sizeof(struct_t));
printf("fun(0) -> %.10f\n", fun(0));
printf("fun(1) -> %.10f\n", fun(1));
printf("fun(2) -> %.10f (a[2] 踩到 d 的低 4 字节)\n", fun(2));
printf("fun(3) -> %.10f (a[3] 踩到 d 的高 4 字节)\n", fun(3));
return 0;
}
【代码做什么?】 struct_t 里 a 占偏移 0–7、d 占偏移 8–15。写 a[2] 就是写偏移 8,正好是 d 的低 4 字节;写 a[3] 就是偏移 12,即 d 的高 4 字节。再往后 (a[4]、a[5]…) 就冲出结构体,进入返回地址和保存寄存器。
【实测输出(本机)】:
offsetof(a)=0 offsetof(d)=8 sizeof=16
fun(0) -> 3.1400000000
fun(1) -> 3.1400000000
fun(2) -> 3.1399998665 (a[2] 踩到 d 的低 4 字节)
fun(3) -> 2.0000006104 (a[3] 踩到 d 的高 4 字节)
【底层机制透视】 讲义给出的完整表是 fun(0)(1) -> 3.14、fun(2) -> 3.1399998665、fun(3) -> 2.0000006104、fun(4) -> Segmentation fault、fun(6) -> Stack smashing detected、fun(8) -> Segmentation fault(不同版本略有差异)。结果随系统而异,因为越界后踩到的是栈帧里的其他东西——可能是编译器留的空隙、可能是金丝雀、可能是返回地址。这正是 C 的”未定义行为(Undefined Behavior)”的典型形态:编译器可以假设你没有越界,从而做出任何优化决策。
为什么这是”大问题”:缓冲区溢出曾是技术层面第一号安全漏洞来源(总体上第一号原因是社会工程学与用户疏忽)。最常见形态是字符串输入没有长度检查,尤其是栈上的定长字符数组——俗称栈击穿(stack smashing)。讲义列出的历史案例包括:1988 年 Morris 蠕虫通过 fingerd 的 gets() 在几小时内感染了约 6000 台机器(当时互联网的 10%),并直接促成了 CERT 在 CMU 成立;1999 年 AOL 与微软的”IM 战争”中,AOL 甚至利用自家 AIM 客户端的缓冲区溢出漏洞把 4 字节签名回传给服务器以识别并封堵 MSN 客户端。这些漏洞的代价是真实的,Attack Lab 的用意就是让你亲手打穿一次,从而再也不会写出这种代码。
【内存布局 / 数据结构图解】 越界位置与后果:
struct_t 在栈上(16 字节),%rsp 指向 a[0]
偏移 +0 +1 +2 +3 +4 +5 +6 +7 | +8 +9 +10 +11 +12 +13 +14 +15
+-------------------------------+-------------------------------+
| a[0] | a[1] | ← 合法范围
+-------------------------------+-------------------------------+
↑ a[0] ↑ a[1] ↑ d 开始(偏移8)
偏移 +8 +9 +10 +11 +12 +13 +14 +15
+-------------------------------+-------------------------------+
| d 的低 4 字节 | d 的高 4 字节 |
+-------------------------------+-------------------------------+
↑ a[2] 落点(污染 d 低位) ↑ a[3] 落点(污染 d 高位)
越界下标 → 写入位置
a[2] -> 偏移 8 -> d 低位 -> d 变成 3.1399998665
a[3] -> 偏移 12 -> d 高位 -> d 变成 2.0000006104
a[4] -> 偏移 16 -> 栈帧空隙 -> 可能无事发生
a[6] -> 偏移 24 -> 金丝雀 -> *** stack smashing detected ***
a[8] -> 偏移 32 -> 返回地址附近 -> Segmentation fault
6.3.5 示例五:联合体的字节序可视化
代码(/tmp/c06/dw.c,讲义原始示例,gcc -g -Wall -std=c11 dw.c -o dw 编译通过):
#include <stdio.h>
int main(void) {
union { unsigned char c[8]; unsigned short s[4];
unsigned int i[2]; unsigned long l[1]; } dw;
for (int j = 0; j < 8; j++) dw.c[j] = 0xf0 + j;
printf("Characters 0-7 == [0x%x,0x%x,0x%x,0x%x,0x%x,0x%x,0x%x,0x%x]\n",
dw.c[0], dw.c[1], dw.c[2], dw.c[3], dw.c[4], dw.c[5], dw.c[6], dw.c[7]);
printf("Shorts 0-3 == [0x%x,0x%x,0x%x,0x%x]\n",
dw.s[0], dw.s[1], dw.s[2], dw.s[3]);
printf("Ints 0-1 == [0x%x,0x%x]\n", dw.i[0], dw.i[1]);
printf("Long 0 == [0x%lx]\n", dw.l[0]);
printf("sizeof(dw)=%zu\n", sizeof dw);
return 0;
}
【实测输出(x86-64,小端序)】:
Characters 0-7 == [0xf0,0xf1,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7]
Shorts 0-3 == [0xf1f0,0xf3f2,0xf5f4,0xf7f6]
Ints 0-1 == [0xf3f2f1f0,0xf7f6f5f4]
Long 0 == [0xf7f6f5f4f3f2f1f0]
sizeof(dw)=8
【底层机制透视】 联合体的 8 字节始终是 f0 f1 f2 f3 f4 f5 f6 f7(c[] 视图永远这么读)。但按 short 视图,每个 2 字节组被解释成”高地址字节当高位”,于是 0xf1f0;按 long 视图,最高地址的 0xf7 成了最高有效字节。若换到大端序机器(Sparc/Sun),Shorts 会打印 [0xf0f1,...]、Long 会打印 [0xf0f1f2f3...]。结论:在多机通信或读写二进制文件时,必须在协议层显式约定字节序(Internet 就是大端序),不要依赖本机的 union 行为。
6.4 实验关联
L3 Attack Lab 是本讲的直接落地,共 5 关、100 分:前三关对 ctarget 做代码注入(Code Injection, CI),后两关对 rtarget 做 ROP。讲义在 F25-06 中把漏洞原型、跳转机理和小工具构造都讲到了,实验只是把它做一遍(本讲无独立 new lab;Attack Lab 与 Lecture 5 共同构成本章实验基础)。
- Phase 1(touch1,10 分):只改返回地址。要先用
objdump -d ctarget找到getbuf的sub $0xNN,%rsp,算出buf起始到返回地址的字节数,再把touch1的地址按小端序写在对应位置。本讲 6.2.8 的栈图就是解题模板。坑:BUFFER_SIZE是编译期常量,buf的位置随 GCC 分配策略变化,必须看反汇编而不是猜。 - Phase 2(touch2,25 分):要”像”把 cookie 当第一个参数传进去,即
%rdi = cookie。注入的机器码应先把 cookie 装进%rdi,再用ret跳到touch2。讲义明确警告:不要用jmp/call,因为它们的相对偏移难以手算,一切控制转移都用ret。本讲的movq/ret编码知识在这里第一次用上。 - Phase 3(touch3,25 分):参数是 cookie 的字符串表示(8 位十六进制、无
0x前缀、以\0结尾)。难点在于hexmatch会在栈上开char cbuf[110]并调用sprintf/strncmp,把你的缓冲区踩掉,所以字符串必须放在不会被覆盖的位置(通常放在返回地址之后的高地址处)。 - Phase 4(rtarget L2,35 分):
rtarget开了栈随机化 + 不可执行栈,只能从start_farm到mid_farm区间取小工具。可用指令只有movq、popq、ret、nop(0x90),只允许用前 8 个寄存器(%rax–%rdi)。两个小工具就够:一个popq %rdi(从栈上取值),一个地址处含movq %rax,%rdi的字节序列。坑:popq会消耗栈上的数据,所以攻击串里小工具地址和数据必须交替排布,而且要逐字节核对 gadget 的反汇编,objdump从指令中间解码出来的才是真身。 - Phase 5(rtarget L3,5 分):用 ROP 调用
touch3并传入 cookie 字符串指针,还需要lea/算术 gadget 来算字符串地址。分值只有 5 分但难度最高,讲义自己都建议”有别的正事就停在这里”。 - 通用坑:(1)
hex2raw只认十六进制字节对,脚本里的注释和空行要小心处理;(2)所有地址都要按小端序”反过来”写,0x004006c8写成c8 06 40 00 00 00 00 00;(3)串里不能出现0x0a(换行),否则会被getbuf的读入逻辑提前截断——这正是 Phase 4/5 里选择 gadget 地址时的一个隐藏约束;(4)用 GDB 在getbuf的ret处下断点、x/8xb $rsp检查栈顶,比反复试错高效得多。
6.5 常见错误与调试技巧
- 误以为结构体大小等于成员大小之和:
struct S1 {char; int[2]; double;}直觉算 1+8+8=17,实际 24。调试:gcc -Wall -Wpadded -c x.c让编译器报出每一处填充;或 GDB 里ptype /o struct S1。 - 用
memcmp/write/ 哈希比较结构体:填充字节是未定义内容,字段相同也可能比较失败。调试:改用逐字段比较;valgrind --track-origins=yes ./prog能报出”使用了未初始化字节”。 - 混淆
p + 1与”地址 + 1”:int *p加 1 走 4 字节,char *p加 1 走 1 字节,struct S *p加 1 走sizeof(struct S)字节。调试:gdb里p/x p、p/x p+1、p sizeof(*p)三条命令对照看。 - 把联合体当成
(float)u那样的数值转换:bit2float(u)是位重解释,(float)u是数值转换,结果天差地别。调试:两边都打印位模式(p/x *(unsigned*)&f),对比0x4048f5c3之类。 - 数组名与指针的
sizeof混淆:函数参数里的int z[]就是int *z,sizeof(z) == 8而不是数组长度。调试:gcc -Wall会报warning: 'sizeof' on array function parameter will return size of 'int *';始终显式传长度。 - 浮点直接用
==比较:0.1+0.2 == 0.3恒为假。调试:改用fabs(a-b) < 1e-9 * fmax(1.0, fmax(fabs(a), fabs(b)));GCC 的-Wfloat-equal能静态发现这类比较。 -O0与-O2下”同一段代码”行为不同:未定义行为给了优化器任意假设的自由,越界代码在-O2下可能被整体优化掉。调试:同时用gcc -O0 -fsanitize=address,undefined跑一遍,这是抓越界与 UB 最有效的组合拳。- 忽略栈金丝雀对调试的干扰:
*** stack smashing detected ***说明溢出已经发生,但报错点不是溢出点。调试:gcc -fno-stack-protector重编译以便观察真实行为,再用x/24xb $rsp比对溢出前后的栈内容。
6.6 关键要点
- 聚合类型没有机器指令:数组、结构体、联合体最终都降解为”基址 + 偏移”的地址算术,字段名在汇编里彻底消失,只剩编译期常量偏移。
p + i的增量是i * sizeof(*p):这是 C 指针算术的定义,也是二维数组A[i][j]地址公式A + (i*C + j)*sizeof(T)的来源;sizeof(T)只能是 1/2/4/8 才享受比例变址寻址。- 结构体布局 = 声明顺序 + 对齐填充:K 等于最大成员的对齐值,结构体大小必须是 K 的倍数;把大类型放前面能省空间,但编译器不会替你重排。
- 对齐是硬件效率换空间:4/8 字节的访存粒度决定了不对齐要跨缓存行、跨页;填充字节内容未定义,不可用于比较或哈希。
- 浮点 = 位域解释 + 有限精度:
float24 位有效位(约 7 位十进制数字)、double53 位(约 15–16 位);$2^{24}+1$ 转float会丢,浮点加法不满足结合律,所以(a+b)+c不能重写为a+(b+c)。 - 栈上的数组 + 无界输入 = 可被执行的攻击串:理解栈布局与
mov/ret是 Attack Lab 的全部前提,也是”永远不要写这种代码”的最好理由。
6.7 思考题(带答案)
题 1(计算题:算 sizeof 与元素偏移) 设有
struct P { char a; double b; short c; int d; };
struct Q { double b; int d; char a; short c; };
struct P arr[6];
(1)分别求 sizeof(struct P) 与 sizeof(struct Q),并给出 c 在两个结构体中的偏移。(2)求 arr[4].d 相对 arr 的字节偏移。(3)若把 arr 的起始地址记为 x,&arr[3].c 等于什么?
答:(1)先定 K = 8(double)。struct P:a 在 0,b 需要 8 字节对齐所以跳到 8,c 在 16,d 需要 4 对齐、16+2=18 不是 4 的倍数所以跳到 20,末尾 24 已是 8 的倍数,故 sizeof(struct P) = 24,offsetof(c) = 16。struct Q:b 在 0,d 在 8,a 在 12,c 需要 2 对齐、13 不是 2 的倍数所以跳到 14,占 14–15,总 16 已是 8 的倍数,故 sizeof(struct Q) = 16,offsetof(c) = 14。同样的成员、不同的声明顺序,大小差 8 字节。(2)sizeof(struct P) = 24,arr[4] 起始偏移 $4 \times 24 = 96$,d 的成员偏移 20,故 arr[4].d 在 偏移 116($= 4\times24+20$)。(3)arr[3] 起始为 $x + 72$,c 偏移 16,故 &arr[3].c = x + 88。
题 2(计算题:算二维数组元素地址与下标) 有 short M[7][9];,M 起始地址为 0x1000。(1)M 总共占多少字节?(2)&M[3][5] 的地址是多少?(3)若 int *p = (int *)&M[2][0];,p[7] 对应 M 的哪个元素?(4)为什么按列遍历(外层循环 j、内层循环 i)比按行遍历慢?
答:(1)sizeof(short) = 2,总大小 $7 \times 9 \times 2 = 126$ 字节。(2)偏移 $= (i \times C + j) \times 2 = (3\times 9 + 5)\times 2 = 32\times2 = 64$,因此地址为 0x1000 + 0x40 = 0x1040。(3)M[2][0] 的偏移是 $2\times9\times2 = 36$ 字节;p 是 int *(4 字节),p[7] 的字节偏移是 $36 + 7\times4 = 64$,即 $64/2 = 32$ 个 short,而 $32 = 3\times9 + 5$,所以 p[7] 正好覆盖 M[3][5]。(4)行优先下同一行的元素在内存中连续;按行遍历是顺序访问,一个 64 字节缓存行能装下 $64/2 = 32$ 个 short,即 3 行多,命中率高。按列遍历每次跳 $9\times2 = 18$ 字节,几乎每次都踩在新的缓存行上,造成”每次访问都必然缺失”的最坏模式——详见 Lecture 9/10 关于局部性的讨论。
题 3(找错题:”直觉但错误”的想法错在哪) 有同学说:”union { float f; unsigned u; } x; x.u = 0x4048F5C3; return x.f; 和 return (float)0x4048F5C3; 应该一样吧?反正都是把整数变成 float。” 请指出错误,并算出两个结果分别是多少。
答:错在混淆了位重解释(type punning)与数值转换。x.f 把 0x4048F5C3 这 32 位按 IEEE 754 单精度解读:s=0、exp=0x80=128,$E = 128-127 = 1$,frac=0x48F5C3,$M = 1 + 0x48F5C3/2^{23} \approx 1.5700001$,所以 $V \approx 1.57 \times 2 = \mathbf{3.14}$。而 (float)0x4048F5C3 是数值转换:$0x4048F5C3 = 1078523331$,转成 float 时保留 24 位有效位,结果约为 $\mathbf{1.07852333 \times 10^{9}}$。两者相差九个数量级。正确结论:union 双关读位模式,强制类型转换做数值映射,绝不能互相替代。
题 4(判断与证明题) 下面这段代码输出什么?为什么编译器不允许把它优化成 printf("%d\n", 1)?
float x = 1e20f, y = -1e20f, z = 3.14f;
printf("%d\n", (x + y) + z == x + (y + z));
答:输出 0(假)。x + y 精确得 0,0 + z = 3.14;而 y + z 先算:y = -1e20f 的量级是 $10^{20}$,float 只有 24 位有效位,其相邻可表示数之间的间隔已达约 $10^{13}$,3.14 对齐后完全落在有效位之外被舍成 0,于是 y + z = -1e20f,x + (-1e20f) = 0。两式结果分别是 3.14 与 0。编译器不允许优化成 1,是因为 IEEE 754 浮点不满足结合律:把 (x+y)+z 重排成 x+(y+z) 会改变可观测结果,而 C 标准要求浮点运算按规定的结合顺序求值(除非程序员显式使用 -ffast-math 这类放弃 IEEE 语义的选项)。这也解释了为什么需要 -ffast-math 或 #pragma STDC FP_CONTRACT 才能让编译器做浮点重排——它们是在明确放弃可复现性。