阶段 2 课后练习参考答案(Homework)
所有命令与输出在 WSL Arch(gcc 16.1.1,个别题 clang 22.1.8)下真实运行得到。UB 类题目的输出「只是这台机器这次的选择」,换编译器/优化级别可能不同——每道题里凡是出现这种输出,都会就地说明它属于哪一类。
2.1-A
难度 L1 · 题面见 homework
思路:&score 和 p 是同一个地址(p 里存的就是 score 的地址);&p 是 p 这个指针变量自己的地址,是另一回事;四种指针 sizeof 全 8 说明「指针大小和指向类型无关」。
int* p = &score;之后&score与p相等;*p是score的别名,*p加 10 扣 1,score跟着变。→ 知识点:第 1 章:指针是什么「&取地址,*解引用」一节(*p是别名)int** pp = &p;顺着走两层,**pp = 99改的还是score;&p与p的值不同——p存的是score的地址,&p是p自己占的那块内存的地址。→ 知识点:第 1 章「指针自己也是个变量,有自己的地址」一节- 三个
sizeof都是 8:64 位机上任何指针装的都只是一份地址,宽度由地址空间决定、与指向类型无关。→ 知识点:第 1 章「指针的类型与大小」一节
验证输出:
$ gcc -std=c11 -Wall -Wextra hw21a.c -o hw21a && ./hw21a
&score = 0x...
p = 0x... ← 和 &score 同一个地址
加 10 分后: score = 71, *p = 71
扣 1 分后: score = 70, *p = 70
**pp 改后: score = 99
&p = 0x... (p 自己的地址,和 p 的值不同)
sizeof(int*) = 8
sizeof(char*) = 8
sizeof(double*) = 82.1-B
难度 L2 · 题面见 homework
思路:(a) 解引用 NULL 必崩在地址 0;(b) 野指针的值是垃圾——本机这次恰好是 0,所以「看起来」和 (a) 一样,但这纯属运气;(c) 是合法对照,全程无警告无崩溃。
- (a) 普通构建段错误、退出码 139;ASan 报
SEGV on unknown address 0x000000000000——地址 0 那页被操作系统映射成不可访问。→ 知识点:第 1 章「两个必崩的坑」一节(NULL 解引用) - (b) 编译期
-Wuninitialized就响了;本机普通构建这次退出码 139,因为q那个栈槽里的垃圾恰好是 0。但换成 ASan 的-O0构建,垃圾值又恰好落在合法栈地址——写入「成功」、静默退出 0,ASan 也拦不住(它管地址合不合法,而这回地址恰好合法)。这就是教材强调的:野指针可能崩、可能不崩、可能「成功」把 2 写进别人内存,哪种结果都不保证;换一次运行、换-O级别、换编译器,垃圾值都可能不同。→ 知识点:第 1 章(野指针的 UB 本质) - (c) 合法程序无警告、正常打印 3。→ 知识点:第 1 章(对照:指针指向已知对象才安全)
验证输出:
展开代码 (共 21 行)收起代码
$ gcc -std=c11 -Wall -Wextra hw21b1.c -o hw21b1 && ./hw21b1; echo "exit=$?"
Segmentation fault
exit=139
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw21b1.c -o hw21b1a && ./hw21b1a
==278==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
==278==The signal is caused by a WRITE memory access.
#0 0x... in main /tmp/cj-ex2-hw/hw21b1.c:5
SUMMARY: AddressSanitizer: SEGV /tmp/cj-ex2-hw/hw21b1.c:5 in main
$ gcc -std=c11 -Wall -Wextra hw21b2.c -o hw21b2
hw21b2.c:5:8: warning: 'q' is used uninitialized [-Wuninitialized]
5 | *q = 2;
| ~~~^~~
$ ./hw21b2; echo "exit=$?"
Segmentation fault
exit=139 ← 普通构建这次垃圾值恰好是 0,崩了
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw21b2.c -o hw21b2a && ./hw21b2a
q = 0x7ffdb92bc538 ← ASan 构建这次垃圾值落在合法栈地址
exit=0 ← 写入「成功」,静默退出,ASan 也拦不住
← UB 崩不崩纯看运气:换 -O1 又可能崩在地址 0
$ gcc -std=c11 -Wall -Wextra hw21b3.c -o hw21b3 && ./hw21b3
x = 32.2-A
难度 L2 · 题面见 homework
思路:指针遍历就是 for (p = a; p < a + n; p++);p - a 是元素个数差;long* 加 1 跨 sizeof(long) = 8 字节。
- 一个循环同时干求和、找最大值,循环结束
p停在 past-the-last,p - a得 6。→ 知识点:第 2 章:指针算术「用指针遍历数组」「两个指针相减」两节 lp与lp + 1差 8 字节:步长 =sizeof(指向类型)。→ 知识点:第 2 章「指针加减整数」一节
验证输出:
$ gcc -std=c11 -Wall -Wextra hw22a.c -o hw22a && ./hw22a
sum = 54, max = 19
走过的元素数 p - a = 6
lp = 0x...
lp + 1 = 0x... (long* 加 1 跨 8 字节)2.2-B
难度 L3 · 题面见 homework
思路:双指针从两头往中间走,交换、逼近;终止条件 lo < hi 保证奇数个元素时中间那个不被自己和自己交换(那也没错,但多此一举),更重要的是 lo 永远碰不到「超过 hi」的位置——两个指针只在同一个数组里移动,这正是指针比较 lo < hi 有定义的前提(§6.5.8)。
int* lo = a; int* hi = a + n - 1;——a + n是合法的 past-the-last(可以「算」出来),但解引用只能到a + n - 1,所以末元素指针取a + n - 1。→ 知识点:第 2 章「越界与 past-the-last」一节- 每轮交换
*lo与*hi,lo++/hi--各自走一步;偶数个元素两两相遇后交错(lo > hi)退出,奇数个元素在中间元素相遇(lo == hi)退出。→ 知识点:第 2 章(步长与比较)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw22b.c -o hw22b && ./hw22b
偶数个: 6 5 4 3 2 1
奇数个: 5 4 3 2 12.3-A
难度 L2 · 题面见 homework
思路:C 没有 reference,想让函数改调用者的变量只能传指针;square_wrong 改的是副本。
square(&x)里*p是x的别名,改*p就是改x;square_wrong(y)传的是副本,y纹丝不动。→ 知识点:第 3 章:用指针改调用者的变量「值传递为什么改不了」「传指针」两节toggle里*flag = !*flag先读别名再写回,两次调用 0→1→0。→ 知识点:第 3 章(指针参数的输入/输出之分)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw23a.c -o hw23a && ./hw23a
square 后 x = 25
square_wrong 后 y = 6 (没变)
第一次 toggle: 1
第二次 toggle: 02.3-B
难度 L3 · 题面见 homework
思路:C 只能 return 一个值,「多返回值」的套路是一个用 return、其余用指针参数带出;h/m/s 是纯输出参数(调用者事先声明变量、传地址进去等着被写)。
*h = (total_sec % 86400) / 3600;等三行先算各字段再写回;天数return total_sec / 86400;。→ 知识点:第 3 章「用指针模拟『多返回值』」一节- 调用点声明
int h, m, s;传&h, &m, &s——不初始化没关系,函数会写;读代码的人看到int* h且无const,就该判断它是「输出」。→ 知识点:第 3 章(区分输入/输出参数)、第 4 章:const 限定(下一章用 const 标记输入)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw23b.c -o hw23b && ./hw23b
90061 秒 = 1 天 1 时 1 分 1 秒
61 秒 = 0 天 0 时 1 分 1 秒2.4-A
难度 L2 · 题面见 homework
思路:const 三态的分工——const 在 * 前锁对象、在 * 后锁指针;两个失败用例各报一种「只读」。
- ①通过:
p = &m改指向合法,*p读的是新目标 9。②失败:assignment of read-only location '*p'——const int*锁的是「指的对象」。→ 知识点:第 4 章:const 限定「指针的 const 三态」一节 - ③失败:
assignment of read-only variable 'q'——int* const锁的是指针本身(顺带一个-Wunused-but-set-variable杂音,因为q只赋值没读过);④通过:*q = 9合法,n变成 9。→ 知识点:第 4 章(两种报错措辞的分工) const int*≡int const*:口诀「const 修饰左边紧挨的东西、在最左则修饰右边的类型」。→ 知识点:第 4 章(读法口诀)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw24a1.c -o hw24a1 && ./hw24a1
*p = 9
$ gcc -std=c11 -Wall -Wextra hw24a2.c -o hw24a2
hw24a2.c:4:8: error: assignment of read-only location '*p'
4 | *p = 9;
| ^
$ gcc -std=c11 -Wall -Wextra hw24a3.c -o hw24a3
hw24a3.c:4:7: error: assignment of read-only variable 'q'
4 | q = &m;
| ^
$ gcc -std=c11 -Wall -Wextra hw24a4.c -o hw24a4 && ./hw24a4
n = 92.4-B
难度 L3 · 题面见 homework
思路:同一个「丢掉 const 再改」的动作,三个版本三种结局——这正是 UB 的教具:-O0 的局部 const 在栈上、恰好可写;-O2 下编译器按「const 对象不会被改」的假定直接常量折叠;全局 const 落在只读段,写它就段错误。
-O0局部版打印x = 20——栈上的x被绕过 const 改了。这是 UB,但本机这次「成功」了。→ 知识点:第 4 章「const 正确性」一节(丢 const 改对象是 UB)- 全局版段错误、退出码 139:全局 const 住在
.rodata,那页只读,一写就 SIGSEGV。→ 知识点:第 12 章:内存布局与生命周期「程序的六大内存区」一节(.rodata只读) -O2局部版打印x = 10:编译器有权假定const int x永远不变,直接把printf里的x替换成常量 10——你改的栈内存它看都不看。→ 知识点:第 4 章(UB 与优化,教材「哪种结果都不保证」的原话兑现)-Wdiscarded-qualifiers就是第 4 章那个警告:隐式把const int*赋给int*时提醒你「const 被丢了」;本题用显式(int*)强转压掉警告,等价于对编译器说「我知道、后果自负」——后果你三个版本都看见了。→ 知识点:第 4 章(const 正确性:const 要一路传递)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw24b1.c -o hw24b1 && ./hw24b1
x = 20 ← -O0:局部 const 在栈上,恰好可写
$ gcc -std=c11 -Wall -Wextra hw24b2.c -o hw24b2 && ./hw24b2; echo "exit=$?"
Segmentation fault
exit=139 ← 全局 const:.rodata 只读,写它段错
$ gcc -std=c11 -Wall -Wextra -O2 hw24b1.c -o hw24b1o2 && ./hw24b1o2
x = 10 ← -O2:编译器假定 const 不变,常量折叠2.5-A
难度 L2 · 题面见 homework
思路:手写 strchr 就是教材那套「char* 遍历法」加一个提前返回;pos - s 是第 2 章的指针减法。
while (*s)走到\0停,途中撞见c就return s——返回的是指向s内部的指针;参数和返回值都是const char*:函数只读不改,返回值也不给调用者改的口子,const 从声明一路传下去。→ 知识点:第 5 章:指针、数组、字符串的统一视角「用 char* 遍历字符串」一节、第 4 章(const 正确性)'o'出现在下标 4,pos - s得 4;'w'得 6;'z'找不到返回NULL。→ 知识点:第 2 章(指针减法)、第 5 章(NULL表示「查无结果」,第 1 章见过)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw25a.c -o hw25a && ./hw25a
找 'o': pos - s = 4, 指向 "o world"
找 'w': pos - s = 6, 指向 "world"
找 'z': NULL2.5-B
难度 L3 · 题面见 homework
思路:strcat = 「走到 dst 的 \0」+「strcpy 的经典一行」两段;它不检查 dst 还剩多少空间,装不下就写越界——这正是 strcat 不安全的原因,ASan 报告里的 WRITE of size 1 会点出越界发生在哪。
- 第一段循环
while (*p) p++;走到\0;第二段while ((*p++ = *src++) != '\0')把「取字符、赋字符、两指针后移、判\0」压进一个表达式,拷到\0为止(\0已被带过去)。→ 知识点:第 5 章「手写 strcpy」一节(经典一行)、「手写 strlen」一节(走到\0) - 32 字节缓冲正常得
pointer and memory;16 字节缓冲装不下("pointer" 7 字节 + " and memory" 11 字节 +\0共 19 字节),ASan 报stack-buffer-overflow、WRITE of size 1,点名small这个变量在偏移 48 处被写穿——16 字节small恰好从偏移 32 开始,写到偏移 48 就是第 17 个字节。→ 知识点:第 7 章:动态内存的坑(越界的 ASan 报法,栈版stack-buffer-overflow)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw25b.c -o hw25b && ./hw25b
buf = pointer and memory
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw25b_small.c -o hw25bs && ./hw25bs
==385==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
WRITE of size 1 at 0x... thread T0
#0 0x... in my_strcat /tmp/cj-ex2-hw/hw25b_small.c:8
#1 0x... in main /tmp/cj-ex2-hw/hw25b_small.c:15
This frame has 1 object(s):
[32, 48) 'small' (line 14) <== Memory access at offset 48 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow /tmp/cj-ex2-hw/hw25b_small.c:8 in my_strcat2.6-A
难度 L2 · 题面见 homework
思路:malloc 返回 void*,C 里隐式转成任意对象指针(§6.3.2.3p1),所以不写 (double*);强转在 C++ 里才必需,在 C 里反而会掩盖「忘了 #include <stdlib.h> 导致的隐式声明」这类老 bug。
double* a = malloc((size_t) n * sizeof(double));——(size_t)是第 6 章教的防溢出纪律;拿到后先查NULL,然后a[i]像普通数组一样用(p[i] ≡ *(p+i)对malloc来的指针同样成立)。→ 知识点:第 6 章:动态内存入门「malloc:要一块堆内存」一节- 前 8 项倒数序列之和 2.718——和自然常数 e 的前几位(2.71828…)撞上了,纯属巧合:e 的真实级数是 Σ1/k!(阶乘倒数),而 1 + 1/2 + 1/3 + … 是发散的调和级数、与 e 毫无关系。用完
free(a); a = NULL;,ASan 构建零报告、退出码 0。→ 知识点:第 6 章「free 与内存泄漏」一节
验证输出:
$ gcc -std=c11 -Wall -Wextra hw26a.c -o hw26a && ./hw26a
倒数序列: 1.000 0.500 0.333 0.250 0.200 0.167 0.143 0.125
sum = 2.718
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw26a.c -o hw26aa && ./hw26aa
倒数序列: 1.000 0.500 0.333 0.250 0.200 0.167 0.143 0.125
sum = 2.718 ← ASan 零报告,退出码 02.6-B
难度 L3 · 题面见 homework
思路:动态数组的标配——「长度 len + 容量 cap」分开记,放满就 tmp 模式翻倍。realloc 失败时返回 NULL 而原指针仍有效,直接 a = realloc(a, ...) 一旦失败就把原地址弄丢、既泄漏又丢数据,所以必须 tmp 接、判成功、再赋回。
- 容量 2→4→8→16 三次翻倍(10 个元素最终容量 16),每次扩容打印一行,全程数据保留。→ 知识点:第 6 章「realloc:调整一块已有内存的大小」一节(tmp 模式)
- 失败分支
free(a); return 1;是教材强调的细节:失败时原a还活着,要么继续用、要么手动释放。→ 知识点:第 6 章(realloc 失败处理)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw26b.c -o hw26b && ./hw26b
扩容: 容量 -> 4
扩容: 容量 -> 8
扩容: 容量 -> 16
最终容量 = 16, 元素 10 个:
3 1 4 1 5 9 2 6 5 3
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw26b.c -o hw26ba && ./hw26ba
... (同样输出,零报告)2.7-A
难度 L2 · 题面见 homework
思路:三个程序各踩一种坑,ASan 各报一种类型——这题的关键动作是别加 -O1:本机真跑过,-O1 下 gcc 会把 double-free 的第二次 free 和越界写 a[3] = 99 直接优化折叠掉(UB 让编译器「有权」这么干),ASan 什么都抓不到、退出码 0。默认 -O0 才还原三个坑的原貌。
- (a) 编译期
-Wuse-after-free先响(free和后续*p在同一个函数里编译器看得见),ASan 报heap-use-after-free、READ of size 4,还贴心地列出「在哪 free、在哪 malloc」。→ 知识点:第 7 章:动态内存的坑「use-after-free」一节 - (b) ASan 报
attempting double-free,并指出第一次free的位置。→ 知识点:第 7 章「double-free」一节 - (c) ASan 报
heap-buffer-overflow、WRITE of size 4,报告里写清「0 bytes after 12-byte region」——第 13 个字节就是a[3]。→ 知识点:第 7 章「堆越界」一节 free(p); p = NULL;一举两得:置 NULL 后误用*p是解引用空指针、立刻崩在明处(而不是悄悄读别人数据);再free(p)时free(NULL)是合法空操作。→ 知识点:第 7 章「小结」一节
验证输出(关键行,地址/进程号每次不同):
展开代码 (共 23 行)收起代码
$ gcc -std=c11 -Wall -Wextra hw27a1.c -o hw27a1
hw27a1.c:8:5: warning: pointer 'p' used after 'free' [-Wuse-after-free=]
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw27a1.c -o hw27a1a && ./hw27a1a
==429==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
#0 0x... in main /tmp/cj-ex2-hw/hw27a1.c:8
SUMMARY: AddressSanitizer: heap-use-after-free /tmp/cj-ex2-hw/hw27a1.c:8 in main
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw27a2.c -o hw27a2a && ./hw27a2a
==435==ERROR: AddressSanitizer: attempting double-free on 0x... in thread T0:
#1 0x... in main /tmp/cj-ex2-hw/hw27a2.c:6
SUMMARY: AddressSanitizer: double-free /tmp/cj-ex2-hw/hw27a2.c:6 in main
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw27a3.c -o hw27a3a && ./hw27a3a
==441==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
WRITE of size 4 at 0x... thread T0
0x... is located 0 bytes after 12-byte region
#0 0x... in main /tmp/cj-ex2-hw/hw27a3.c:9
SUMMARY: AddressSanitizer: heap-buffer-overflow /tmp/cj-ex2-hw/hw27a3.c:9 in main
$ # 对照:-O1 把 UB 折叠掉了,ASan 抓不到
$ gcc -std=c11 -Wall -Wextra -O1 -g -fsanitize=address hw27a2.c -o hw27a2o1 && ./hw27a2o1; echo "exit=$?"
exit=0
$ gcc -std=c11 -Wall -Wextra -O1 -g -fsanitize=address hw27a3.c -o hw27a3o1 && ./hw27a3o1
a[3] = 99
exit=0 ← UB 被优化折叠:这就是「可能崩可能不崩」的又一面2.7-B
难度 L3 · 题面见 homework
思路:make_label 分配、调用者释放——「谁分配谁释放」必须配对,不写清楚就会两边都以为对方会 free、结果谁都没 free。
- 泄漏版:LeakSanitizer 在程序退出时报
192 byte(s) leaked in 3 allocation(s)——64 × 3 正好对账。注意一个真实细节:泄漏版的printf输出在报告里根本没出现——ASan 的退出路径不走 stdio 缓冲刷新,label-1/2/3还憋在 stdout 缓冲区里。修复版正常退出才打印出来。→ 知识点:第 7 章「内存泄漏」一节(LeakSanitizer 在退出时检查) - 修复版补上三个
free(并置 NULL)后零报告、退出码 0。→ 知识点:第 6 章「free 与内存泄漏」一节(谁 malloc 谁 free)
验证输出:
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw27b.c -o hw27ba && ./hw27ba
==465==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 192 byte(s) in 3 object(s) allocated from:
#1 0x... in make_label /tmp/cj-ex2-hw/hw27b.c:6
SUMMARY: AddressSanitizer: 192 byte(s) leaked in 3 allocation(s).
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw27b2.c -o hw27b2a && ./hw27b2a
label-1
label-2
label-3 ← 修复版零报告2.8-A
难度 L2 · 题面见 homework
思路:argv 是 char**——「指针数组 char* argv[] 在函数参数里退化成的指针」;argv[argc] 按约定是 NULL 哨兵,所以指针遍历版拿它当循环条件。
- 下标版
for (i = 0; i < argc; i++)与指针版for (char** p = argv; *p != NULL; p++)输出一致;argv[argc] == NULL打印 1。→ 知识点:第 8 章:多级指针与指针数组「命令行参数」一节 argv[0]是程序名./hw28a——argc = 4表示程序名 + 3 个参数。→ 知识点:第 8 章(argv[argc]是 NULL 哨兵)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw28a.c -o hw28a && ./hw28a alpha beta gamma
argc = 4
--- 下标版 ---
argv[0] = ./hw28a
argv[1] = alpha
argv[2] = beta
argv[3] = gamma
--- 指针遍历版 ---
./hw28a alpha beta gamma
argv[argc] == NULL ? 12.8-B
难度 L3 · 题面见 homework
思路:行指针 int (*row)[4] 指向「4 个 int 的数组」,row++ 的步长是 4 × 4 = 16 字节;int* p = m 的编译诊断实锤了 m 退化成的是 int (*)[4] 而不是 int*。
(*row)[j]先解引用拿到一整行(一个 4 元素数组),再下标取元素;每行求完row++跨行。→ 知识点:第 8 章「数组指针」一节(void*) m与(void*) (m + 1)差 0x10(16 字节)。→ 知识点:第 2 章(步长由指向类型决定)int* p = m;报initialization of 'int *' from incompatible pointer type 'int (*)[4]'——这是约束违反,gcc/clang 默认就报 hard error(连-Werror都不用加);报错本身就证明m的类型。→ 知识点:第 10 章:复杂声明与 typedef([]优先级高于*,两者区别全在括号)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw28b.c -o hw28b && ./hw28b
第 0 行和 = 10
第 1 行和 = 26
第 2 行和 = 42
总和 = 78
行指针步长: m = 0x..., m + 1 = 0x... ← 差 0x10 = 16 字节
$ gcc -std=c11 -Wall -Wextra hw28b_bad.c -o hw28b_bad
hw28b_bad.c:3:14: error: initialization of 'int *' from incompatible pointer type 'int (*)[4]'2.9-A
难度 L2 · 题面见 homework
思路:函数指针作参数就是「回调」——apply1 只负责「把一个数喂给某个函数」,喂哪个由调用方定;neg 与 &neg 同地址是「函数名退化」的实锤。
- 三次调用得 -6/12/36,
apply1一行没改。→ 知识点:第 9 章:函数指针「函数指针作参数」一节 (void*) neg == (void*) &neg:函数名在表达式里自动退化成函数指针(§6.7.6.3p8,和数组名退化同理)。→ 知识点:第 9 章「赋值与调用」一节fp(x)与(*fp)(x)等价(§6.5.2.2 函数调用语义允许两种写法),工程里现在写前者更干净。→ 知识点:第 9 章(两种调用等价)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw29a.c -o hw29a && ./hw29a
apply1(neg, 6) = -6
apply1(dbl, 6) = 12
apply1(sq, 6) = 36
neg = 0x..., &neg = 0x... (函数名退化) ← 两个地址相同2.9-B
难度 L4 · 题面见 homework
思路:qsort 排结构体,比较函数里 void* 先转回 const struct Item*;double 相减转 int 是双重错误——既可能丢掉小数(差小于 1 时截成 0,两个不同的价格被判「相等」)、也可能溢出,教材的「别写 return ia - ib」在这里变成「别写 return (int)(a->price - b->price)」。
- 正确版显式
>/<返回 ±1/0,降序得 tea 29.90 / milk 12.00 / rice 8.70 / bread 8.50 / egg 1.20。→ 知识点:第 9 章「标准库 qsort」一节(显式返回 -1/0/1 最稳妥) - 错误版(降序意图的
(int)(b->price - a->price))对bread(8.50) 与rice(8.70) 算出(int)(-0.2) = 0,比较函数声称「相等」——两个元素相对顺序保持原样,输出里bread 8.50排在rice 8.70前面,降序被破坏。注意两点:错序能否看到,取决于初始顺序和 qsort 实现(glibc 对小数组走插入排序、通常保持原序,所以本机恰好可见;但「被判相等后谁在前」没有保证,换数据、换 libc 就可能不同)——所以错误比较器的坑是「相对顺序不可依赖」,不是「必然在这个位置排错」。→ 知识点:第 9 章(qsort 比较约定:0 表示相等)
验证输出:
$ gcc -std=c11 -Wall -Wextra q.c -o q && ./q
正确比较函数(降序):
tea 29.90
milk 12.00
rice 8.70
bread 8.50
egg 1.20
错误比较函数:
tea 29.90
milk 12.00
bread 8.50 ← 8.50 排在 8.70 前面:差 -0.2 被截成 0,降序被破坏
rice 8.70
egg 1.202.10-A
难度 L2 · 题面见 homework
思路:右左法则逐个拆:char* months[2] 先与 [2] 结合([] 优先级高于 *)是数组、元素是 char*;int (*row)[3] 括号让 * 先结合是指针、指向 int[3];int* first(int*) 先与 () 结合是函数、返回 int*。
sizeof(months)= 16:2 个指针 × 8 字节——指针数组是一整块装指针的内存。→ 知识点:第 10 章:复杂声明与 typedef「右左法则」一节(*row)[2]第 0 行得 3,row++后第 1 行得 6。→ 知识点:第 10 章(数组指针)、第 8 章(行指针)first(a)返回a的首元素地址(数组退化成指针传进去),解引用得 10;它不是函数指针——函数指针必须(*f)(...)用括号把*和名字包住,int* first(int*)里的first先和参数列表结合成了「函数」。→ 知识点:第 10 章(int *f(void)与int (*f)(void)风马牛不相及)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw210a.c -o hw210a && ./hw210a
char* months[2]: sizeof = 16 (2 个指针)
第 0 行 (*row)[2] = 3
第 1 行 (*row)[2] = 6
first(a) 返回首元素 = 102.10-B
难度 L4 · 题面见 homework
思路:①#define 是预处理文本替换,STR_PTR a, b; 展开成 char* a, b;——* 只跟 a,b 落成纯 char;typedef 把「指向 char 的指针」封装成完整类型,对每个声明符都生效。②int (*pipeline[2])(int) 按右左法则:pipeline 是 2 元素数组、元素是指针、指针指向 int(int) 函数。
- 宏版
sizeof8 vs 1,typedef 版 8 vs 8。→ 知识点:第 10 章「typedef vs #define」一节 typedef int (*Unary)(int);之后Unary pipeline[2] = {dbl, neg};清爽如初;pipeline[0](5)得 10、pipeline[1](pipeline[0](5))是neg(dbl(5))得 -10——先翻倍再取负。→ 知识点:第 10 章「typedef 拯救声明」一节、第 9 章(函数指针数组)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw210b.c -o hw210b && ./hw210b
宏版: sizeof(a) = 8, sizeof(b) = 1
typedef 版: sizeof(x) = 8, sizeof(y) = 8
pipeline[0](5) = 10 (dbl)
pipeline[1](pipeline[0](5)) = -10 (neg(dbl(5)))2.11-A
难度 L2 · 题面见 homework
思路:unsigned char* 是 C 的天生字节指针(sizeof(char) == 1 钉死),加 1 跨 1 字节;0x1234 打在内存里是 34 12 说明本机小端(最低有效字节在最低地址)。
- 逐字节打印
0x34 0x12——x86_64 小端序;字节序是实现定义的,跨平台代码不能假设。→ 知识点:第 11 章:void* 与字节操作「unsigned char*:当字节指针用」一节 void*round-trip:&x→void*→short*地址一个比特不变,*back读回0x1234(§6.3.2.3p1/p7)。→ 知识点:第 11 章「void* 的两条规矩」一节
验证输出:
$ gcc -std=c11 -Wall -Wextra hw211a.c -o hw211a && ./hw211a
x = 0x1234, 占 2 字节
逐字节(低地址到高地址): 0x34 0x12 ← 小端:最低有效字节在最低地址
&x = 0x..., back = 0x..., *back = 0x1234 (转换无损)2.11-B
难度 L3 · 题面见 homework
思路:①右移一位时源和目的区间重叠,从前往后手抄会先把自己写进去的值又读出来(1 1 1 1 1),memmove 内部先处理重叠方向所以结果正确;②字节序反转是「字节视角」的经典应用——memcpy 进字节数组、反向、memcpy 回,全程不碰移位。
memmove(a + 1, a, 4 * sizeof(int))得1 1 2 3 4;手抄版b[i] = b[i - 1]把 1 一路复制过去得1 1 1 1 1。→ 知识点:第 11 章「memcpy 与 memset」一节(重叠请换 memmove)、第 6 章(重叠的根源:同一块连续内存)swap_endian(0x12345678)=0x78563412,再换一次还原——两次反转回到原点。→ 知识点:第 11 章(字节序、htonl/ntohl 处理的就是这个差异)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw211b.c -o hw211b && ./hw211b
memmove 右移: 1 1 2 3 4
手抄错误版: 1 1 1 1 1
swap_endian(0x12345678) = 0x78563412
再换一次还原 = 0x123456782.12-A
难度 L2 · 题面见 homework
思路:六类地址分成三层——栈最高、堆居中、程序映像(.rodata/.data/.bss)在低地址块;nm 的类型字母(D/B/T)与段一一对应。
- 地址从高到低:栈
0x7ffe...> 堆0x5770a4...>.bss/.data(0x57707e...里.bss紧挨.data之后)>.rodata(最低)。global_uninit = 0是.bss启动清 0 的招牌。→ 知识点:第 12 章:内存布局与生命周期「程序的六大内存区」一节 nm:D global_init(已初始化→.data)、B global_uninit(未初始化→.bss)、T main(代码→.text)。→ 知识点:第 12 章「用 nm 看符号落哪个段」一节
验证输出:
$ gcc -std=c11 -Wall -Wextra hw212a.c -o hw212a && ./hw212a
栈 &stack_local 0x7ffe... ← 最高
堆 heap 0x5770a4...
.data &global_init 0x57707e...
.data &static_local 0x57707e...
.bss &global_uninit 0x57707e... ← 紧挨 .data
.rodata literal 0x57707e... ← 程序映像里最低
global_uninit = 0 (.bss 启动清 0)
$ nm hw212a | grep -E 'global_(init|uninit)| main$'
0000000000004030 D global_init
000000000000403c B global_uninit
0000000000001169 T main2.12-B
难度 L4 · 题面见 homework
思路:①每深一层函数调用,新帧落在更低的地址——栈向低地址增长;②爆栈时进程被 SIGSEGV 直接打死,stdout 的块缓冲来不及刷,所以用 fflush(stdout) 逼出每五万层的记录;③程序映像内 .rodata 在 .data 之下(上题输出已实锤)。
walk五层地址从0x...3454一路递减到0x...3394,每层帧约 0x30 字节。→ 知识点:第 12 章「栈向低地址增长」一节- 深递归:gcc 编译期就报
-Winfinite-recursion(没有基线的递归它认得出来);本机跑到约 50 万层附近爆栈(8 MB 栈 ÷ 每帧 16 字节,数量级对得上),段错误退出码 139。这个层数是这台机器这次的数——栈上限随系统配置不同、每帧大小随编译器与优化级别不同(16~48 字节都正常),重要的是「越深越往下、终有尽头」。→ 知识点:第 12 章(爆栈的机制)、第 7 章(段错误退出码 139)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw212b.c -o hw212b && ./hw212b
depth 0: &frame = 0x...
depth 1: &frame = 0x...
depth 2: &frame = 0x...
depth 3: &frame = 0x...
depth 4: &frame = 0x... ← 地址一路变小
$ gcc -std=c11 -Wall -Wextra hw212b2.c -o hw212b2
hw212b2.c:5:13: warning: infinite recursion detected [-Winfinite-recursion]
$ ./hw212b2; echo "exit=$?"
depth = 50000
depth = 100000
depth = 150000
depth = 200000
depth = 250000
Segmentation fault
exit=1392.C-1
难度 L3 · 题面见 homework
思路:argv(第 8 章)+ malloc 动态数组(第 6 章)+ qsort 回调(第 9 章)三件套拼成一个命令行排序器;nums 的分配和释放都在 main 里配对,包括解析失败的错误分支也要先 free 再退出。
int* nums = malloc((size_t) n * sizeof(int));查NULL;循环里sscanf(argv[i + 1], "%d", &nums[i])——&nums[i]是「指针改调用者变量」的变体(sscanf把解析结果写进你给的地址)。→ 知识点:第 3 章(scanf 家族靠指针写回)、第 6 章(malloc 必查 NULL)cmp_asc显式返回 -1/0/1(不用a - b,防有符号溢出);qsort(nums, n, sizeof(int), cmp_asc)原地排序。→ 知识点:第 9 章「标准库 qsort」一节free(nums); nums = NULL;结束——分配与释放同处main,「谁分配谁释放」配对清晰;ASan 构建零报告。→ 知识点:第 7 章(谁分配谁释放)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw2c1.c -o hw2c1 && ./hw2c1 42 7 19 3 88
排序结果: 3 7 19 42 88
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw2c1.c -o hw2c1a && ./hw2c1a 42 7 19 3 88
排序结果: 3 7 19 42 88 ← 零报告2.C-2
难度 L4 · 题面见 homework
思路:qsort 同款签名 = void* 兜底任意类型 + 函数指针接收比较逻辑 + size 算偏移;void* 不能算术,所以一进来就 (unsigned char*) base 转字节指针,第 j 个元素是 p + j * size。冒泡排序本身是教材外补充(阶段 3 才细讲),这里只需要「相邻两两比较交换 n 轮」这个直白骨架。
my_sort用cmp(left, right) > 0决定是否交换,交换按字节逐个搬(unsigned char tmp)。→ 知识点:第 11 章(void* 转字节指针)、第 9 章(qsort 的比较约定)- 三个比较函数各司其职;字符串版的元素是
char*(8 字节一个),比较函数里先转const char* const*再解一层拿到真正的char*,交给strcmp比字典序。→ 知识点:第 8 章(指针数组)、第 5 章(strcmp)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw2c2.c -o hw2c2 && ./hw2c2
int 升序: 3 7 19 42 88
int 降序: 88 42 19 7 3
字符串字典序: apple berry fig grape pear2.C-3
难度 L5 · 题面见 homework
思路:三次反转是原地轮转的经典构造(改编自 LeetCode 189「Rotate Array」的 O(1) 空间解,字节级泛化到任意内存块则贴近 CSAPP 的 reverse/rotate 练习)。左移 k 字节 = 反转 [0, k) + 反转 [k, n) + 反转 [0, n):前两步把两段各自掉头,最后一步整体掉头后两段就回到了正确顺序。
reverse_bytes用字节指针走半开区间[lo, hi):hi从lo + n起、先减后交换,lo < hi相遇即停。→ 知识点:第 2 章(双指针相遇条件)、第 11 章(unsigned char* 字节指针)rotate_left先k %= n(n == 0返回、k == 0返回),然后三次反转。int数组要左移 3 个元素,就是左移3 * sizeof(int)字节——「元素」到「字节」的换算全在调用点完成,函数本身只认字节。→ 知识点:第 11 章(字节视角)、第 3 章(void* 参数把「类型」留给调用者)- 对拍 4 组边界(k=0、k=1、k=n、k<n)与朴素实现逐字节一致;k>n 时
9 % 7 = 2等价左移 2。gcc、clang、ASan 三种构建结果全同。→ 知识点:第 6 章(朴素版 malloc 临时缓冲 + free,对照用)
验证输出:
$ gcc -std=c11 -Wall -Wextra hw2c3.c -o hw2c3 && ./hw2c3
字符串左移 2: cdefgab
int 数组左移 3 元素: 4 5 1 2 3
对拍 4 个用例,不一致 0 个
k>n (左移 9): cdefgab
$ clang -std=c11 -Wall -Wextra hw2c3.c -o hw2c3c && ./hw2c3c
(输出同上,全对)
$ gcc -std=c11 -Wall -Wextra -O0 -g -fsanitize=address hw2c3.c -o hw2c3a && ./hw2c3a
(输出同上,ASan 零报告)