Skip to content

阶段 2 Lab:指针解剖台——从 &x 到内存地图

实验目标

Homework 是一道道独立的题,这个 Lab 则是一条贯穿的线:我们从「&x*p 这一对运算符」出发,一路解剖到「整张内存地图」——先看指针怎么隔着地址动变量,再让它走数组、进函数、管堆内存,然后亲手用 ASan 把动态内存的四个坑一个个抓现行,最后站到高处把 .text/.rodata/.data/.bss/堆/栈六大区分层画出来。做完这个 Lab,阶段 2 的 12 章就不是 12 个孤立知识点,而是一条你亲手摸过的「地址流水线」。

所有实验都在 /tmp/cj-ex2-lab/ 下做(这个目录每个程序各自一个 .c,别混在一个文件里)。每步有验收标准,输出对得上才算过;卡住先回题面标注的章节链接读教材,再不行就看实验参考

步骤 1:指针的基本功

难度 L1 · 涉及第 1 章:指针是什么

目标:把「指针 = 装着地址的变量」这件事钉死——&xp 相等、&p 是另一回事、*p 是别名、指针大小与类型无关。

  1. lab1.cint x = 42; int* p = &x;,打印 &xp&p 三个地址。
  2. 通过 *px 改成 100,再打印 x
  3. 打印 sizeof(int*)sizeof(char*)sizeof(double*)sizeof(void*)

验收标准:贴出输出;说清三件事——①&xp 为什么相等、②&pp 为什么不同、③四个 sizeof 全一样说明了什么(对应第 1 章:指针是什么)。

实验参考 →

步骤 2:指针算术与 past-the-last 哨兵

难度 L2 · 涉及第 2 章:指针算术

目标:亲手验证「步长 = sizeof(指向类型)」与「数组末尾之后那位(past-the-last)可以指向、不能解引用」。

  1. lab2.c:对 int a[5] = {4, 8, 15, 16, 23}; 用指针遍历(不许用下标)求 sum,循环结束后打印 p - a
  2. char s[] = "lab";char[4],含 \0)用 char* end = s + 3;(正好指向终止符 \0 那个真实元素)加指针比较遍历,逐个打印字符。

验收标准:贴出输出;说清三件事:p - a 为什么是 5(不是 20 字节);s + 3 指向的元素为什么合法可读(读出来是 \0);这个数组真正的 past-the-last 是 s + 4——一位才是「可指向、绝不能解引用」的对象(对应第 2 章:指针算术)。

实验参考 →

步骤 3:指针参数三件套

难度 L2 · 涉及第 3 章:用指针改调用者的变量

目标:对照值传递与传指针,并跑一遍「多返回值」套路。

  1. lab3.cswap_bad(int a, int b)(值传递)与 swap(int* a, int* b)(指针版),各调用一次,对照输出。
  2. inc_ptr(int* p) 通过指针加一,调用后打印原变量。
  3. divmod(int a, int b, int* rem):商 return、余数用指针带出,调用 divmod(20, 6, &rem)

验收标准:贴出输出;说清 swap_bad 为什么「换了等于没换」、divmod 的第三个参数为什么必须传 &rem(对应第 3 章:用指针改调用者的变量)。

实验参考 →

步骤 4:动态数组与 realloc 的 tmp 模式

难度 L3 · 涉及第 6 章:动态内存入门第 7 章:动态内存的坑

目标:把 10 个输入元素塞进「容量从 4 起步」的动态数组,亲手写对 realloc 的 tmp 模式,再用 ASan 确认全程干净。

  1. lab4.cint input[] = {7, 3, 9, 1, 5, 2, 8, 4, 6, 0};,容量 cap = 4 起步,放满就 tmp 模式翻倍扩容,每次扩容打印新容量,最后打印全部元素与最终容量。
  2. -O0 -g -fsanitize=address 重编译运行一遍,确认零报告。

验收标准:贴出两次扩容的容量(4→8→16)、最终输出与 ASan 零报告;说清 int* tmp = realloc(a, ...) 为什么不能简化成 a = realloc(a, ...)(对应第 6 章:动态内存入门)。

实验参考 →

步骤 5:ASan 四坑一条龙

难度 L4 · 涉及第 7 章:动态内存的坑

目标:把 UAF、double-free、堆越界、泄漏四个坑亲手埋一遍、亲手抓一遍,各写一句修复。

  1. 写四个小文件:lab5uaf.cfree 后还 printf("%d", *p))、lab5df.c(同一块 free 两次)、lab5ovf.cmalloc(2 * sizeof(int)) 后写 a[2])、lab5leak.cmalloc(50 * sizeof(int)) 后忘了 free)。
  2. 全部用 -O0 -g -fsanitize=address 编译运行,收集四份报告。
  3. 给每个坑写一句修复(注意:堆越界那一句要落到「边界写成 < len」,泄漏那一句要落到「谁分配谁释放」)。

验收标准:贴出四份报告的关键行(错误类型、READ/WRITE of sizeSUMMARY、泄漏字节数);注意 ASan 构建不要加 -O1——做完想一想为什么(本机真跑过:-O1 会把 double-free 和越界写直接优化折叠、ASan 抓个空,对应第 7 章:动态内存的坑)。

实验参考 →

步骤 6:内存地图

难度 L4 · 涉及第 12 章:内存布局与生命周期

目标:把阶段 2 的终点站画出来——六大区地址分层 + nm 符号落段 + 栈向低地址。

  1. lab6.c:打印栈局部、堆(malloc)、.data 全局、.data static 局部、.bss 全局、.rodata 字面量六类地址,再打印未初始化全局的值。
  2. 加一个递归 walk(int depth) 打印每层局部变量地址(递归到 depth 2)。
  3. 对可执行文件跑 nm,把两个全局符号与 main 的类型字母贴出来。

验收标准:贴出输出;按地址从高到低说出六类各落在哪个段、nmD/B/T 各代表什么、递归地址一路变小说明什么(对应第 12 章:内存布局与生命周期)。

实验参考 →

附加挑战(L5):徒手 memmove

目标:徒手实现重叠安全的 memmove,并与标准库对拍。改编自 CSAPP/K&R 的经典练习(如实标注来源):拷贝方向由「目标相对源的位置」决定。

  1. void* my_memmove(void* dst, const void* src, size_t n):只用 unsigned char* 字节指针;n == 0dst == src 直接返回;dst < src 时从头往尾拷,否则从尾往头拷(想清楚为什么这个分支保证重叠也安全——源区间是 [src, src+n))。
  2. 验证:int overlap[8] = {1..8}my_memmove(overlap + 2, overlap, 4 * sizeof(int)) 后打印数组(应为 1 2 1 2 3 4 7 8)。
  3. 对拍:五组不同 dst 偏移 × 不同长度的用例,my_memmove 与标准库 memmovememcmp 必须全等。

验收标准:贴出重叠搬移与普通搬移的输出、对拍结果(0 组不一致);一句话说清「从尾往头拷」为什么在 dst > src 时安全、在 dst < src 时却会翻车。

实验参考 →

提交物清单

一个目录装下全部源码、每步终端记录(stepN.log)、以及 200 字以内的小结——用你自己的话说清「指针隔着一层地址动内存」这件事你在哪一步看得最真切。