Lecture 6: 机器级编程 IV——数组、结构体、联合体与浮点 (Machine Programming IV: Arrays, Structs, Unions, and Floating Point)

目录 · ← l5 · l7 →

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(数组分配与访问、异质数据结构、对齐、浮点代码) 关联 LabL3 Attack Lab(栈布局、缓冲区溢出、ROP 链)

6.1 概述

本讲回答一个核心问题:C 语言里那些”高级”的复合数据类型,在机器一级究竟长什么样? 聚合类型(aggregate type)——数组、结构体、联合体——并没有对应的机器指令,硬件看到的只有连续字节和”基址 + 偏移”的寻址。编译器负责把 A[i][j]p->nextu.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*iA[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),%raxr + 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 { ... }; 的所有成员共享同一块内存起始地址,大小 = 最大成员的大小,对齐 = 最大成员的对齐。同一时刻只能用其中一个字段(否则读到的是别的字段的位模式)。
  • 直观解释:像一块可擦写白板——你写”浮点数”时它是浮点数,擦掉写”无符号整数”时它是整数,白板本身只有一块、大小固定。
  • 三大用途
    1. 节省空间:多个互斥的字段共用一块存储,如 value_t { bool is_float; union {float f; unsigned u;} val; }
    2. 类型双关(type punning):把同一串位按不同类型解读。bit_float_t { float f; unsigned u; } 里的 bit2float(u) 返回的是按位重解释的浮点值,与 (float) u(整数转浮点,会做数值转换)完全不同——这是讲义明确点出的、学生最容易搞错的地方。
    3. 和类型(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):
expfrac类别值 $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/addsdsubss/subsdmulss/mulsddivss/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,%xmm0cvtsi2sd 只写目标寄存器的低 64 位,必须先把高位清零,否则会留下上一次运算的垃圾位(伪依赖)。

6.2.8 进程内存布局与缓冲区溢出(Memory Layout and Buffer Overflow)

  • x86-64 Linux 地址空间(讲义图,非按比例):从高地址到低地址依次是(runtime stack,8 MB 上限,存局部变量)、共享库malloc/calloc/new 动态分配,向高地址增长)、数据段(全局变量、static 变量、字符串常量)、代码段 Text(只读的可执行指令)。栈基址和共享库位置都做了随机化。讲义给出的真实量级:局部变量 0x00007ffe4d3be87cmalloc(2^28) 得到 0x00007f7262a1e010、全局 big_array0x0000000080601060main() 代码在 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 代替 getsstrncpy 代替 strcpy、别用 %s 格式符);② 系统级保护——栈随机化(每次运行栈地址都不同,攻击者猜不到注入代码的位置)与不可执行内存(x86-64 加 NX 位,栈标记为不可执行,跳进去立即崩溃);③ 编译器插入栈金丝雀-fstack-protector,现为默认):函数入口把 %fs:0x28 处的哨兵值存到缓冲区与返回地址之间,返回前 xor 比较,被改动就调用 __stack_chk_fail 终止进程。
  • ROP 绕过:金丝雀保护不了”不越过缓冲区就无法写入”的攻击者,但栈不可执行挡住了代码注入。ROP 转而复用已有代码:在可执行段中寻找”一串指令 + ret(编码为单字节 0xc3)”的小工具(gadget),把它们在栈上串成链。讲义的两个例子:ab_plus_c0x4004d4 处的 lea (%rdi,%rdx,1),%rax; ret 实现 %rax ← %rdi + %rdxsetval 的立即数字节里恰好嵌着 48 89 c7movq %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.cgcc -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;
}

【代码做什么?】

  1. zip_digint[5] 的类型别名,sizeof(zip_dig) 得 20;sizeof(A) 得 80。
  2. 打印 &A[2][3] 与首地址之差,验证它等于公式 (i*C+j)*4 = 52
  3. 打印 pp+1 的差,证明指针加 1 走的是 4 字节而非 1 字节。
  4. 用两条恒等式 &A[i][j] == A[i]+jA[i][j] == *(*(A+i)+j) 验证”数组的数组”语义。
  5. 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.cgcc -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;
}

【代码做什么?】

  1. offsetof 把编译器暗中插入的填充”挖出来”:S1i 在偏移 4 而不是 1(前面 3 字节填充),v 在偏移 16 而不是 12(又 4 字节填充)。
  2. 打印 _Alignof(S1) = 8,印证 K = 最大成员对齐值。
  3. 对比 S4(12 字节)与 S5(8 字节)——同样的成员、不同的顺序,大小差 4 字节
  4. 对结构体数组 arr[10] 验证元素间距恰为 sizeof(S3) = 12,没有额外填充。
  5. 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.cgcc -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+10.1+0.21e20+(-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

【底层机制透视】 三条硬结论:

  1. float 只有 24 位有效位(隐含前导 1 + 23 位 frac),对应约 $\log_{10} 2^{24} \approx 7.22$ 位十进制有效数字,所以 float7 位十进制有效数字double 有 53 位有效位,对应 $\log_{10} 2^{53} \approx 15.95$,即约 15–16 位十进制有效数字。上面的 float 1/3 = 0.3333333433 从第 8 位开始就是噪声。
  2. intfloat 会丢精度:$2^{24}+1 = 16777217$ 需要 25 位有效位,float 只能四舍五入到 $2^{24} = 16777216$。因此”用 float 存整数计数器”在数值超过 $2^{24} \approx 1.68 \times 10^7$ 后就会静默出错。
  3. 浮点加法不满足结合律:$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.cgcc -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_ta 占偏移 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.14fun(2) -> 3.1399998665fun(3) -> 2.0000006104fun(4) -> Segmentation faultfun(6) -> Stack smashing detectedfun(8) -> Segmentation fault(不同版本略有差异)。结果随系统而异,因为越界后踩到的是栈帧里的其他东西——可能是编译器留的空隙、可能是金丝雀、可能是返回地址。这正是 C 的”未定义行为(Undefined Behavior)”的典型形态:编译器可以假设你没有越界,从而做出任何优化决策。

为什么这是”大问题”:缓冲区溢出曾是技术层面第一号安全漏洞来源(总体上第一号原因是社会工程学与用户疏忽)。最常见形态是字符串输入没有长度检查,尤其是栈上的定长字符数组——俗称栈击穿(stack smashing)。讲义列出的历史案例包括:1988 年 Morris 蠕虫通过 fingerdgets() 在几小时内感染了约 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 f7c[] 视图永远这么读)。但按 short 视图,每个 2 字节组被解释成”高地址字节当高位”,于是 0xf1f0;按 long 视图,最高地址的 0xf7 成了最高有效字节。若换到大端序机器(Sparc/Sun),Shorts 会打印 [0xf0f1,...]Long 会打印 [0xf0f1f2f3...]结论:在多机通信或读写二进制文件时,必须在协议层显式约定字节序(Internet 就是大端序),不要依赖本机的 union 行为。

6.4 实验关联

L3 Attack Lab 是本讲的直接落地,共 5 关、100 分:前三关对 ctarget代码注入(Code Injection, CI),后两关对 rtargetROP。讲义在 F25-06 中把漏洞原型、跳转机理和小工具构造都讲到了,实验只是把它做一遍(本讲无独立 new lab;Attack Lab 与 Lecture 5 共同构成本章实验基础)。

  • Phase 1(touch1,10 分):只改返回地址。要先用 objdump -d ctarget 找到 getbufsub $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_farmmid_farm 区间取小工具。可用指令只有 movqpopqretnop0x90),只允许用前 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 在 getbufret 处下断点、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) 字节。调试gdbp/x pp/x p+1p sizeof(*p) 三条命令对照看。
  • 把联合体当成 (float)u 那样的数值转换bit2float(u) 是位重解释,(float)u 是数值转换,结果天差地别。调试:两边都打印位模式(p/x *(unsigned*)&f),对比 0x4048f5c3 之类。
  • 数组名与指针的 sizeof 混淆:函数参数里的 int z[] 就是 int *zsizeof(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 字节的访存粒度决定了不对齐要跨缓存行、跨页;填充字节内容未定义,不可用于比较或哈希。
  • 浮点 = 位域解释 + 有限精度float 24 位有效位(约 7 位十进制数字)、double 53 位(约 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 Pa 在 0,b 需要 8 字节对齐所以跳到 8,c 在 16,d 需要 4 对齐、16+2=18 不是 4 的倍数所以跳到 20,末尾 24 已是 8 的倍数,故 sizeof(struct P) = 24offsetof(c) = 16struct Qb 在 0,d 在 8,a 在 12,c 需要 2 对齐、13 不是 2 的倍数所以跳到 14,占 14–15,总 16 已是 8 的倍数,故 sizeof(struct Q) = 16offsetof(c) = 14同样的成员、不同的声明顺序,大小差 8 字节。(2)sizeof(struct P) = 24arr[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$ 字节;pint *(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.f0x4048F5C3 这 32 位按 IEEE 754 单精度解读:s=0exp=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 = -1e20fx + (-1e20f) = 0。两式结果分别是 3.14 与 0。编译器不允许优化成 1,是因为 IEEE 754 浮点不满足结合律:把 (x+y)+z 重排成 x+(y+z) 会改变可观测结果,而 C 标准要求浮点运算按规定的结合顺序求值(除非程序员显式使用 -ffast-math 这类放弃 IEEE 语义的选项)。这也解释了为什么需要 -ffast-math#pragma STDC FP_CONTRACT 才能让编译器做浮点重排——它们是在明确放弃可复现性。