阶段 4 课后练习参考答案(Homework)
所有命令与输出在 WSL Arch(gcc 16.1.1 / clang 22.1.8)下真实运行得到。UB 类题目的输出「只是这台机器这次的选择」,换编译器/优化级别可能不同——这正是每道题要你体会的东西。三处真实结果和「想当然」不一致的地方,正文里都当场交代了。
4.1-A
难度 L1 · 题面见 homework
思路:纸面判断题,全是第 1 章三条契约的「知不知道」。
- 三行:
#ifndef MYLIB_X_H、#define MYLIB_X_H、正文、#endif。→ 知识点:第 1 章:头文件契约「契约一:include guard」一节 #pragma once不在 ISO C 标准里,是编译器扩展;追求可移植用#ifndef/#define/#endif。→ 知识点:同上- 头文件里放带初始化的变量定义,谁
#include谁多一份定义,n 个翻译单元就 n 重定义,链接器报multiple definition。→ 知识点:第 1 章「契约二:ODR」一节(头文件只放声明) - ODR:一个非
inline的全局变量或函数,整个程序里只能有一处定义;声明可以到处放(头文件),定义只能一份(某个.c)。→ 知识点:同上 - 填
static;裸inline链接期报undefined reference to 'xxx'(C99 的裸 inline 不提供外部符号)。→ 知识点:第 1 章「契约三:裸 C99 inline」一节 - 不一样——C++ 的
inline自动提供外部定义,C99 的裸inline没有;这是 C++ 转 C 的人最容易踩的坑。→ 知识点:同上
4.1-B
难度 L3 · 题面见 homework
思路:a.h 先被包含时定义了 COMMON_H,b.h 的 #ifndef 判定为「已定义」、整段跳过——struct Color 从未被声明过,报错和「没写这个头」几乎一样。
main.c同时包含两头的完整版本编译,struct Color变成不完整类型。→ 知识点:第 1 章「契约一」一节(guard 宏名要全工程唯一)- 把两个 guard 宏改成各自唯一的
A_H/B_H,重编跑出正确结果。→ 知识点:同上(撞名的修法)
验证输出:
$ gcc -std=c11 -Wall -Wextra -I. main.c -o collide
main.c: In function 'main':
main.c:8:12: error: variable 'c' has initializer but incomplete type
8 | struct Color c = {255, 0, 128};
| ^~~~~
main.c:8:18: error: storage size of 'c' isn't known
8 | struct Color c = {255, 0, 128};
| ^
---- 修复后 ----
$ gcc -std=c11 -Wall -Wextra -I. main_fix.c -o collide_fix && ./collide_fix
v=(3,4) c=(255,0,128)
exit=0关键代码(撞名版本):
/* a.h 和 b.h 的 guard 宏名都是 COMMON_H —— 撞名! */
#ifndef COMMON_H
#define COMMON_H
struct Vec2 {
int x;
int y;
};
#endif为什么「整段被跳过」:#include 是纯文本插入,a.h 插入时 COMMON_H 还没定义,于是 #define 了它、struct Vec2 生效;轮到 b.h 插入时 COMMON_H 已存在,#ifndef 判假、整个 b.h 正文被预处理器删掉,struct Color 就从来没在这个翻译单元里出现过。诊断时记住这个特征:报错说类型「incomplete / isn't known」,而不是「redefinition」——那多半不是没包含,而是被撞名的 guard 吞了。
4.2-A
难度 L2 · 题面见 homework
思路:头里 typedef struct IntStack IntStack_t; 制造不完整类型,字段藏进 .c;消费者只能走 API。栈和 ringbuffer 同构:数组 + 一个游标,push/pop 都在 sp 上。
- 头文件只有前向声明 + 五个原型;
.c里struct IntStack藏着data/cap/sp,create两次 malloc + 失败回滚、destroy先free(data)再free(s)。→ 知识点:第 2 章:API 设计与不透明类型「不透明类型是什么」一节 - 消费者
s->sp会撞invalid use of incomplete typedef;IntStack local;会撞storage size of 'local' isn't known——编译器不知道类型多大,既不能解引用也不能在栈上分配。→ 知识点:第 2 章「消费者想偷摸字段」与「代价」两节
验证输出:
$ gcc -std=c11 -Wall -Wextra -I. main_ok.c intstack.c -o ok_gcc && ./ok_gcc
size = 3
pop 30
pop 20
pop 10
exit=0
$ clang -std=c11 -Wall -Wextra -I. main_ok.c intstack.c -o ok_clang && ./ok_clang
size = 3
pop 30
pop 20
pop 10
$ gcc -std=c11 -Wall -Wextra -I. -c main_illegal.c -o illegal.o
main_illegal.c:5:6: error: invalid use of incomplete typedef 'IntStack_t' {aka 'struct IntStack'}
5 | s->sp = 99; /* 想偷摸字段 —— 不完整类型 */
| ^~
main_illegal.c:6:16: error: storage size of 'local' isn't known
6 | IntStack_t local; /* 想栈上分配 —— 不知道多大 */
| ^~~~~注意栈弹出来的顺序是 30 20 10——LIFO,和教材 ringbuffer 的 FIFO 10 20 30 正好相反,这就是「换方向」的题眼。
4.2-B
难度 L4 · 题面见 homework
思路:旧 client.o 里编进去的是「cfg.port 在偏移 0、cfg.verbose 在偏移 4」,新实现却按「version 在 0、port 在 4、verbose 在 8」写 12 字节。结果不是读错值,而是——如实交代:本机 Arch 的 gcc 默认开栈保护,新实现往旧消费者那个只有 8 字节的栈对象里写了 12 字节,直接触发 stack smashing detected,比「读到错值」更狠。
- 版本 1 全量编译跑出
port=8080 verbose=1;把消费者单独编成client.o(对着 v1 头)。→ 知识点:第 2 章「收益二:ABI 稳定」一节(旧.o里编进的是字段偏移) - 头改成 v2(
port前插version)、只重编实现、链旧client.o:栈对象 8 字节、config_init写 12 字节 → 栈金丝雀被踩 →stack smashing detected。重编消费者后一切正常。→ 知识点:同上(不透明类型改字段不破消费者,摊开的 struct 一改就炸)、第 1 章(编译器只看单个翻译单元,链接期不拦这种错)
验证输出:
$ gcc -std=c11 -Wall -Wextra -I. naive.c client.c -o v1 && ./v1
port=8080 verbose=1
$ gcc -std=c11 -Wall -Wextra -I. -c client.c -o client.o # 消费者:旧头
$ gcc -std=c11 -Wall -Wextra -I. -c naive2.c -o naive2.o # 实现:v2 头
$ gcc client.o naive2.o -o stale && ./stale
*** stack smashing detected ***: terminated
Aborted
$ gcc -std=c11 -Wall -Wextra -I. naive2.c client.c -o fresh && ./fresh
port=9090 verbose=0「旧的 .o 里编进去的是什么」:编译 client.c 时,cfg.port 被翻译成「栈偏移 0 读 4 字节」、cfg.verbose 是「偏移 4 读 4 字节」——偏移是编译期算死的。新实现按新布局写 version@0 port@4 verbose@8,其中偏移 8 的写入越过了旧对象的边界。链接器只管符号对得上(config_init 这个名字两边一致),根本不知道也不管两个翻译单元对 struct 布局的理解不一致——所以这种错链接期不拦、运行期才炸。而 opaque 那套打法里消费者 .o 压根没有字段偏移,第 2 章演示过加字段照样链、照样跑。
4.3-A
难度 L2 · 题面见 homework
思路:errno 铁律——只在「确认失败之后」查它问为什么,永远不拿它判断成功;成功调用不动 errno,残留值是常态。
- 正确姿势:失败返回 -1、调用方先看
rc,失败才读 errno(EINVAL=22)。→ 知识点:第 3 章:错误处理三件套「风格②:errno 约定」一节 - 错误姿势:成功调用后查
errno == 0——先清零时「碰巧成立」,但这是运气;不预先清零时,上一次失败的 EINVAL 残留着,成功调用被判成失败。→ 知识点:同上(成功调用后 errno 的值是「未指定」的)
验证输出:
$ gcc -std=c11 -Wall -Wextra errno_kelvin.c -o ek_gcc && ./ek_gcc
[正确姿势] rc=-1 => 失败, errno=22 (Invalid argument)
[错误姿势] 清零后 errno==0,判定成功: k=298.15 (这次碰巧对)
[错误姿势] errno=22 (Invalid argument)——明明成功了却判定失败!
[正确姿势] rc=0 => 成功
exit=0第三行就是教材「残留坑」在温度场景的复现:to_kelvin(-300) 失败设了 EINVAL,紧接着的成功调用没动 errno,if (errno == 0) 就把成功判成了失败。正确姿势永远是 rc 定成败、errno 只补充「为什么」。
4.3-B
难度 L3 · 题面见 homework
思路:context 那套(堆上 desc + __func__ 记位置 + 两次 vsnprintf 探长再拷贝)直接从教材思路复刻;解析本身用 strtoll + errno == ERANGE + int 范围双保险。
error_ctx四个函数(new/set/clear/free)和教材context_demo.c同构,set里va_copy留给第二次vsnprintf。→ 知识点:第 3 章「风格③:context 对象」一节parse_int区分两种错:非法字符(end == s || *end != '\0',码 1)与越界(errno == ERANGE || v > INT_MAX || v < INT_MIN,码 2);这里errno的用法符合铁律——调用前清零、只在strtoll失败后信它。→ 知识点:第 3 章(errno 铁律 + context 的组合拳)
验证输出:
$ gcc -std=c11 -Wall -Wextra errctx.c parse_int.c -o pi_gcc && ./pi_gcc
[成功] '123' -> 123
[失败] code=1, 位置=parse_int, 说明=非法字符:'12a3'
[失败] code=2, 位置=parse_int, 说明=越界:'99999999999999' -> 99999999999999
exit=0
$ clang -std=c11 -Wall -Wextra errctx.c parse_int.c -o pi_clang && ./pi_clang
[成功] '123' -> 123
[失败] code=1, 位置=parse_int, 说明=非法字符:'12a3'
[失败] code=2, 位置=parse_int, 说明=越界:'99999999999999' -> 99999999999999
$ gcc -std=c11 -Wall -Wextra -fsanitize=address,undefined errctx.c parse_int.c -o pi_asan && ./pi_asan
[成功] '123' -> 123
[失败] code=1, 位置=parse_int, 说明=非法字符:'12a3'
[失败] code=2, 位置=parse_int, 说明=越界:'99999999999999' -> 99999999999999
exit=0核心解析函数:
static int parse_int(const char* s, int* out, error_ctx* err) {
char* end = NULL;
errno = 0; /* 铁律:调用前清零,只信「失败之后」的 errno */
long long v = strtoll(s, &end, 10);
if (end == s || *end != '\0') {
error_ctx_set(err, 1, __func__, "非法字符:'%s'", s);
return -1;
}
if (errno == ERANGE || v > INT_MAX || v < INT_MIN) {
error_ctx_set(err, 2, __func__, "越界:'%s' -> %lld", s, v);
return -1;
}
*out = (int) v;
return 0;
}99999999999999 没让 strtoll 报 ERANGE(它在 long long 范围内),是 v > INT_MAX 那道防线抓的——所以「双保险」不是重复代码,是两层不同的判断。ASan/UBSan 全程干净,说明反复 malloc/free 的 desc 堆管理没漏没越界。
4.4-A
难度 L1 · 题面见 homework
思路:纸面判断题,第 4 章的「知不知道」。
- 修改时间(mtime),不是内容。→ 知识点:第 4 章:make 深处「第一道坑」一节
- 三个词:
'xxx' is up to date.(make 觉得啥都不用干,程序却还是旧行为)。→ 知识点:同上 main.o: main.c mod.h(目标.o依赖它自己的.c加它#include的所有项目头)。→ 知识点:第 4 章「-MMD」一节- 头文件被删/改名时,make 不会因为「找不到这个 prerequisite」罢工——
-MP给每个头补一条空 phantom 规则。→ 知识点:第 4 章「-MP 的 phantom 规则」一节 - 对,TAB 是语法;空格会报
missing separator. Stop.。→ 知识点:阶段 0 第 11 章 fatal error: genhdr.h: No such file or directory(编译那条比生成器先跑)。→ 知识点:第 4 章「-jN:并行构建与那道阴险的竞态」一节
4.4-B
难度 L3 · 题面见 homework
思路:多目录的关键全在教材那两句话——「.d 跟着 .o 走」「.d 里的路径是编译命令里 .o 的路径」。build/%.o: src/%.c 配上 | build(order-only,教材提过竖线语法)自动建目录,$(wildcard build/*.d) 收集依赖。
- Makefile 全文(见下):
-Iinclude放进CFLAGS,build/%.o模式规则带-MMD -MP,-include build/*.d。→ 知识点:第 4 章(-MMD -MP一对写死;| build是教材提过的 order-only prerequisite) touch util.h触发两个.o全重编——因为build/main.d里写着build/main.o: src/main.c include/util.h。→ 知识点:第 4 章「-MMD」一节- 删掉
util.h后报的是编译器的fatal error: util.h: No such file or directory,不是 make 的No rule to make target——-MP的空规则让 make 不因「prerequisite 消失」罢工,把战场交给了真正的消费者(编译器)。→ 知识点:第 4 章「-MP」一节
验证输出:
展开代码 (共 21 行)收起代码
$ make clean && make && ./build/app
mkdir -p build
gcc -std=c11 -Wall -Wextra -MMD -MP -Iinclude -c src/main.c -o build/main.o
gcc -std=c11 -Wall -Wextra -MMD -MP -Iinclude -c src/util.c -o build/util.o
gcc -std=c11 -Wall -Wextra -MMD -MP -Iinclude -o build/app build/main.o build/util.o
util says: v1
$ cat build/main.d
build/main.o: src/main.c include/util.h
include/util.h:
$ touch include/util.h && make
gcc ... -c src/main.c -o build/main.o
gcc ... -c src/util.c -o build/util.o
gcc ... -o build/app build/main.o build/util.o
$ # 改 GREETING v2 再 make → 同样重编 → ./build/app 输出 v2
$ rm include/util.h && make
gcc -std=c11 -Wall -Wextra -MMD -MP -Iinclude -c src/main.c -o build/main.o
src/main.c:1:10: fatal error: util.h: No such file or directory
1 | #include "util.h"
| ^~~~~~~~~~~~
compilation terminated.
make: *** [Makefile:11: build/main.o] Error 1Makefile 全文:
展开代码 (共 21 行)收起代码
CC = gcc
CFLAGS = -std=c11 -Wall -Wextra -MMD -MP -Iinclude
BIN = build/app
OBJS = build/main.o build/util.o
$(BIN): $(OBJS)
$(CC) $(CFLAGS) -o $@ $(OBJS)
build/%.o: src/%.c | build
$(CC) $(CFLAGS) -c $< -o $@
build:
mkdir -p build
DEPS := $(wildcard build/*.d)
-include $(DEPS)
.PHONY: clean
clean:
rm -rf build4.5-A
难度 L2 · 题面见 homework
思路:判断标准就一条(教材原话)——消费者的编译/链接会不会因为缺了这个依赖而失败。会,就 PUBLIC;不会,就 PRIVATE;只给消费者、自己不用,INTERFACE。
- ① 公开头目录 → PUBLIC(消费者要
#include得到);② 私有头目录 → PRIVATE(只有自己.c用);③ 警告旗标 → PRIVATE(警告是开发期自己的事,传染给消费者会让别人一链接你就被你的 warning 烦死);④ 公开头露出pthread_mutex_t→ 依赖挂 PUBLIC(消费者#include你的头就必须能链 pthread,否则链接失败——对照第 2 章CCMutex.h用void*把类型藏住,正是因为藏住了才不用传染);⑤ 库自己不用、只给消费者的宏 → INTERFACE。→ 知识点:第 5 章:CMake 工程化「传播三态」一节 - 消费者侧
target_link_libraries(demo PRIVATE greeter)里的 PRIVATE 是「demo 把 greeter 当内部实现细节、没有第三方再链接 demo」;库侧的 PRIVATE 是「这个属性只给我自己编用」。同一个词、两处位置、管的是两个不同 target 的传播方向。→ 知识点:同上
4.5-B
难度 L3 · 题面见 homework
思路:① GLOB 只在 configure 那一次扫目录,加文件不 reconfigure 就不会进构建系统;② CMAKE_BUILD_TYPE 替单配置生成器选旗标,NDEBUG 把 assert 关成空操作。
beta.c躺在磁盘上、main.c也调了它,但生成的 Makefile 里根本没有beta.c.o→undefined reference to 'beta';重新 configure 后新文件进源表、构建通过。→ 知识点:第 5 章「GLOB_RECURSE 的坑」一节- Debug 的
C_FLAGS = -g、Release 的C_FLAGS = -O3 -DNDEBUG;Debug 版assert触发退出 134(128+SIGABRT),Release 版静默通过退出 0。→ 知识点:第 5 章「多配置」一节、阶段 0 第 9 章 - 多配置生成器(VS/Xcode/Ninja Multi-Config)不认
CMAKE_BUILD_TYPE,要用cmake --build build --config Release在构建期切。→ 知识点:同上(单配置 vs 多配置生成器)
验证输出:
$ cmake --build build # 只 build,没 reconfigure
[ 33%] Building C object CMakeFiles/demo.dir/main.c.o
[ 66%] Linking C executable demo
/usr/bin/ld: CMakeFiles/demo.dir/main.c.o: in function `main':
main.c:(.text+0xa): undefined reference to `beta'
collect2: error: ld returned 1 exit status
$ cmake -B build && cmake --build build && ./build/demo
alpha=1 beta=99
$ grep '^C_FLAGS' build-dbg/CMakeFiles/demo.dir/flags.make
C_FLAGS = -g
$ grep '^C_FLAGS' build-rel/CMakeFiles/demo.dir/flags.make
C_FLAGS = -O3 -DNDEBUG
$ ./build-dbg/demo 7; echo "exit=$?"
demo: .../demo.c:10: main: Assertion `x % 2 == 0 && "奇数不该进来"' failed.
exit=134
$ ./build-rel/demo 7; echo "exit=$?"
x=7 ran to completion
exit=04.6-A
难度 L2 · 题面见 homework
思路:链接器从左到右单趟扫描,扫过的不回头;对象在前、库在后、被依赖的靠右。三库链 octa→quad→twice 的正确顺序是 -locta -lquad -ltwice。
- 正确顺序:driver.o 累积缺
octa→libocta.a供上并登记缺quad→libquad.a供上并登记缺twice→libtwice.a收尾。→ 知识点:第 6 章:静态库、动态库与链接顺序「静态库的顺序陷阱」一节 - 错误顺序
-ltwice -lquad -locta:扫libtwice.a时还没人缺twice,一个成员都不抽;扫到libocta.a抽出octa.o时发现缺quad,可libquad.a已经扫过了——报undefined reference to 'quad',注意它点名的是libocta.a(octa.o),不是「quad 没定义」。→ 知识点:同上(报错字面和「漏链」一模一样,先看命令行顺序) --start-group让组内反复扫,救回来但链接变慢——兜底不是正解。→ 知识点:同上
验证输出:
$ gcc -std=c11 -Wall -Wextra driver.c -L. -locta -lquad -ltwice -o ok && ./ok
octa(5) = 40
exit=0
$ gcc -std=c11 -Wall -Wextra driver.c -L. -ltwice -lquad -locta -o bad
/usr/bin/ld: ./libocta.a(octa.o): in function `octa':
octa.c:(.text+0x11): undefined reference to `quad'
collect2: error: ld returned 1 exit status
$ gcc -std=c11 -Wall -Wextra driver.c -L. -Wl,--start-group -ltwice -lquad -locta -Wl,--end-group -o grouped && ./grouped
octa(5) = 40顺带交代一个本人在出题时亲手踩的真坑:第一版我把底层库起名叫 libc.a,结果 -lc 先匹配到自己的库、系统 libc 被顶掉,printf/__libc_start_main 全部 undefined reference——别把库起成 libc.a,这是「起名撞系统库」的活案例。
4.6-B
难度 L3 · 题面见 homework
思路:① $ORIGIN 展开成「可执行文件自己所在目录」,.so 装在 ./libs/ 就必须写 $ORIGIN/libs,写 $ORIGIN 等于没设;② -fvisibility=hidden 把默认可见性改成隐藏,只有标了 default 的导出。
- 漏
/libs的版本:readelf -d里 RUNPATH 是[$ORIGIN]、字面值看起来没毛病,运行照样cannot open shared object file(退出 127)——报错和「完全没设 RPATH」一模一样;改成\$ORIGIN/libs后正常跑出 42。→ 知识点:第 6 章「RPATH/RUNPATH/$ORIGIN」一节($ORIGIN指可执行所在目录、路径要拼对) - 默认编译
nm -D导出core_pub+core_secret两个T;加-fvisibility=hidden后只剩core_pub,普通nm显示core_secret降级成小写t(local,不导出),static的 helper 本来就是t。→ 知识点:第 6 章「-fvisibility=hidden」一节
验证输出:
$ readelf -d use_bad | grep -Ei 'rpath|runpath|NEEDED'
0x0000000000000001 (NEEDED) Shared library: [libcore.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN]
$ ./use_bad; echo "exit=$?"
./use_bad: error while loading shared libraries: libcore.so: cannot open shared object file: No such file or directory
exit=127
$ gcc ... -L./libs -Wl,-rpath,\$ORIGIN/libs use_core.c -lcore -o use_ok && ./use_ok
core_value() = 42
$ nm -D libvis_default.so | grep ' T ' | sort
00000000000010e9 T core_pub
00000000000010ff T core_secret
$ nm -D libvis_hidden.so | grep ' T ' | sort
00000000000010e9 T core_pub
$ nm libvis_hidden.so | grep -E 'core_pub|core_helper|core_secret'
00000000000010f4 t core_helper
00000000000010e9 T core_pub
00000000000010ff t core_secret4.7-A
难度 L2 · 题面见 homework
思路:重定向后 stdout 变全缓冲,abort() 一棒子把没刷掉的缓冲全陪葬;setvbuf 强制无缓冲后输出顺序才等于执行顺序。退出码 134 = 128 + SIGABRT(6)。
- 不设缓冲 + 重定向:
out.log是空的(连=== assert 测试 ===都丢了),只有 stderr 上那条Assertion ... failed活着。→ 知识点:第 7 章:测试不再是 printf「第一级:裸 assert」一节(stdout 全缓冲遇 abort 丢日志的坑) setvbuf(stdout, NULL, _IONBF, 0)后直接跑:前两条[OK]全打出来,第三条断言失败abort,第四条和「全部通过」永远到不了,退出 134。→ 知识点:同上(一条失败拖死全家、没夹具——两个死穴各对应哪一行)
验证输出:
$ ./t1_gcc > out.log 2> err.log; echo "exit=$?"
exit=134
$ cat out.log ← 空的!缓冲全丢
$ cat err.log
t1_gcc: test_assert.c:23: test_sum_wrong: Assertion `got == 99' failed.
$ ./t2_gcc; echo "exit=$?"
=== assert 测试 ===
[OK] sum(10,20,30) = 60
[OK] sum(empty) = -1
t2_gcc: test_assert2.c:23: test_sum_wrong: Assertion `got == 99' failed.
exit=1344.7-B
难度 L3 · 题面见 homework
思路:add_test 写在 enable_testing() 之前会被静默忽略——不报错、不生效,ctest 只给你一句「No tests were found」。CTest 判 pass/fail 的唯一依据是退出码:0 过、非 0 挂。
- 先不写
enable_testing():ctest 报No tests were found!!!(注意它不报错,退出码还是 0——这就是「静默坑」)。→ 知识点:第 7 章「第三级:CTest」一节(enable_testing()必须写在所有add_test之前) - 补上
enable_testing()并重新 configure(改了 CMakeLists 必须 reconfigure):good绿、bad红(t_fail返回 1),ctest 退出码 8;ctest -R good只跑绿的那条;修好t_fail后 2/2 全绿退出 0。→ 知识点:同上(退出码契约 +-R过滤)
验证输出:
展开代码 (共 21 行)收起代码
$ ctest --test-dir build --output-on-failure
Test project /tmp/cj-ex4-hw4/47b/build
No tests were found!!!
ctest exit=0
$ # 补上 enable_testing() 再 configure + build
$ ctest --test-dir build --output-on-failure
Start 1: good
1/2 Test #1: good ............................. Passed 0.00 sec
Start 2: bad
2/2 Test #2: bad ..............................***Failed 0.00 sec
50% tests passed, 1 tests failed out of 2
The following tests FAILED:
2 - bad (Failed)
ctest exit=8
$ ctest --test-dir build -R good
1/1 Test #1: good ............................. Passed 0.00 sec
100% tests passed out of 1
$ # 修好 t_fail 再 build
$ ctest --test-dir build --output-on-failure
100% tests passed out of 2
ctest exit=04.8-A
难度 L2 · 题面见 homework
思路:依赖抽成函数指针 g_sensor_fn(默认指真实实现),测试时换成带脚本的 mock——记调用次数、按序返回 20/30/40。产品代码为了可测性让了一步:多了全局指针和 setter。
avg_temp(3)三次采样拿到 20/30/40,平均 30;call_count == 3;再调一次avg_temp(1)计数涨到 4(脚本取模循环)。→ 知识点:第 8 章:Mock 与隔离「第一招:函数指针表 mock」一节sensor_set_fn(NULL)恢复真实实现,读数回到 25——测试卫生,别让 mock 残留污染下一个测试。→ 知识点:同上
验证输出:
$ gcc -std=c11 -Wall -Wextra -I. sensor.c test_sensor.c -o ts_gcc && ./ts_gcc
avg = 30, rc = 0
call_count = 3
after avg_temp(1): call_count = 4
restored: avg = 25
OK
$ clang -std=c11 -Wall -Wextra -I. sensor.c test_sensor.c -o ts_clang && ./ts_clang
avg = 30, rc = 0
call_count = 3
after avg_temp(1): call_count = 4
restored: avg = 25
OK
$ gcc ... -fsanitize=address,undefined ... && ./ts_asan
avg = 30, rc = 0
call_count = 3
after avg_temp(1): call_count = 4
restored: avg = 25
OKmock 桩核心:
static int mock_sensor(int* out) {
int v = script[call_count % script_len];
call_count++;
if (out) {
*out = v;
}
return v;
}4.8-B
难度 L3 · 题面见 homework
思路:产品代码一行不改(照题面往 fd 1 打日志),链接期 -Wl,--wrap,write 只改写本工程目标文件里对 write 的引用;动态链接的 printf 在 libc 内部调用 write,根本不经过 --wrap 这个链接期符号——所以测试自己照常 printf、与截获的 write 互不干扰(实测 last_fd=1、last_len=12,断言全过,printf 照常打屏)。
__wrap_write签名必须和真实write完全一致(ssize_t (int, const void*, size_t)):记下被调次数、最后一次的len,返回 count 假装成功、不真写。→ 知识点:第 8 章「第二招:链接期 --wrap」一节objdump显示logger_emit里call 11a7 <__wrap_write>——调用目标被链接器改写,不是 libc 的write;nm佐证可执行文件里只有__wrap_write没有write。→ 知识点:第 8 章(--wrap 是链接期魔法)、阶段 0 第 5 章(nm 看符号)
验证输出:
$ gcc -std=c11 -Wall -Wextra -I. logger.c test_logger.c -Wl,--wrap,write -o tw_gcc && ./tw_gcc
test output ← 测试自己的 printf 照常工作(libc 内部调用不经过 --wrap)
got = 12
wrap_call_count = 1, last_fd = 1, last_len = 12
OK
$ clang -std=c11 -Wall -Wextra -I. logger.c test_logger.c -Wl,--wrap,write -o tw_clang && ./tw_clang
got = 12
wrap_call_count = 1, last_fd = 1, last_len = 12
OK
$ objdump -d tw_gcc | sed -n '/<logger_emit>:/,/ret/p'
...
mov $0x1,%edi ← 产品代码原样的 write(1, ...)
call ... <__wrap_write> ← 调用目标被链接器改写
$ nm tw_gcc | grep -E 'logger_emit|wrap'
000000000000... T __wrap_write
000000000000... T logger_emit
000000000000... b wrap_call_count--wrap 的代价:读 logger.c 的人看不出 write 被换过,只有去翻链接选项才知道——显式(函数指针)vs 隐式(--wrap)的取舍,就是教材「三种手段怎么选」那张表。
4.9-A
难度 L2 · 题面见 homework
思路:gdb batch 模式(-q -batch -ex ...)把交互式命令脚本化,适合做题和复现。崩溃后第一件事 thread apply all bt 看全线程栈,因为多线程 bug 的根因常在别的线程。
worker_good纯忙循环不碰缓冲 → 崩溃唯一确定在worker_bad:Thread 3 命中 SIGSEGV、停在shared_buf[i] = i * round;;gdb 自动切到 Thread 3(info threads里带*那行)。→ 知识点:第 9 章:gdb 实战「多线程栈」一节thread apply all bt三个线程各居其位:Thread 3 事故现场、Thread 2 忙循环、Thread 1 卡在pthread_join;info locals给i=0, round=3,print shared_buf是0x0——free 后置 NULL 再写,链条完整。→ 知识点:同上(thread apply all bt是多线程崩溃后第一件事)
验证输出:
展开代码 (共 26 行)收起代码
$ ./mtcrash; echo "exit=$?"
Segmentation fault
exit=139
$ gdb -q -batch -ex run -ex "thread apply all bt" -ex "info threads" \
-ex "info locals" -ex "print shared_buf" --args ./mtcrash
[New Thread 0x7ffff7bff6c0 (LWP 1660)]
[New Thread 0x7ffff73fe6c0 (LWP 1661)]
Thread 3 "mtcrash" received signal SIGSEGV, Segmentation fault.
[Switching to Thread 0x7ffff73fe6c0 (LWP 1661)]
worker_bad (arg=0x0) at mtcrash.c:32
32 shared_buf[i] = i * round;
Thread 3 (Thread 0x7ffff73fe6c0 (LWP 1661) "mtcrash"):
#0 worker_bad (arg=0x0) at mtcrash.c:32
Thread 2 (Thread 0x7ffff7bff6c0 (LWP 1660) "mtcrash"):
#0 0x00005555555551c7 in worker_good (arg=0x0) at mtcrash.c:16
Thread 1 (Thread 0x7ffff7fa8740 (LWP 1657) "mtcrash"):
#4 0x0000555555555334 in main () at mtcrash.c:53
Id Target Id Frame
1 Thread 0x7ffff7fa8740 (LWP 1657) "mtcrash" 0x00007ffff7ca07f2 in ?? () from /usr/lib/libc.so.6
2 Thread 0x7ffff7bff6c0 (LWP 1660) "mtcrash" 0x00005555555551c7 in worker_good (arg=0x0) at mtcrash.c:16
* 3 Thread 0x7ffff73fe6c0 (LWP 1661) "mtcrash" worker_bad (arg=0x0) at mtcrash.c:32
i = 0
round = 3
$1 = (int *) 0x0直接跑只有一句 Segmentation fault + 139,batch 一趟就把「哪个线程、哪一行、指针什么值」全挖出来了。
4.9-B
难度 L4 · 题面见 homework
思路:-O2 把 start=12 整条链在编译期折叠:collatz_steps 被内联、steps/doubled/result 都被算出常量,运行时内存里根本没留位置,gdb 只能回 <optimized out>。
-O0版:断点停稳,steps=9 doubled=18、info locals五个变量全在。→ 知识点:第 9 章「-O2 的变量失踪」一节、阶段 0 第 9 章-O2版:同一断点print steps→<optimized out>(本机这次doubled/result被优化掉、start反而幸存——见info locals里的start = 12);nm对比:-O0有t collatz_steps,-O2符号整个消失(被内联)。→ 知识点:同上volatile兜底要如实读输出:标了volatile的steps回来了($1 = 9),但doubled/result依然<optimized out>、collatz_steps仍被内联——volatile 只保住被标的那一个变量,不是「保住全场」,这是本题相对教材结论的一处诚实细化。→ 知识点:同上(volatile 的代价与边界)
验证输出:
展开代码 (共 34 行)收起代码
$ gdb -q -batch -ex "break collatz.c:18" -ex run -ex "print steps" \
-ex "print doubled" -ex "info locals" --args ./collatz_O0
Breakpoint 1, main () at collatz.c:18
18 return 0;
$1 = 9
$2 = 18
start = 12
steps = 9
doubled = 18
result = 19
$ gdb -q -batch -ex "break collatz.c:18" -ex run -ex "print steps" \
-ex "print doubled" -ex "info locals" --args ./collatz_O2
Breakpoint 1, main () at collatz.c:18
18 return 0;
$1 = <optimized out>
$2 = <optimized out>
start = 12
steps = <optimized out>
doubled = <optimized out>
result = <optimized out>
$ nm collatz_O0 | grep collatz
0000000000001139 t collatz_steps
$ nm collatz_O2 | grep collatz || echo "(O2 无符号:被内联)"
(O2 无符号:被内联)
$ gdb -q -batch -ex "break collatz_v.c:18" -ex run -ex "print steps" \
-ex "print doubled" -ex "info locals" --args ./collatz_v_O2
Breakpoint 1, main () at collatz_v.c:18
18 return 0;
$1 = 9
$2 = <optimized out>
start = 12
steps = 9
doubled = <optimized out>
result = <optimized out>4.10-A
难度 L2 · 题面见 homework
思路:同一套 CI flags 下,UBSan 默认 recover(报完继续跑、退出 0),ASan 报完即停(退出非 0);clang 和 gcc 对「越界」这种双管辖区错误的表现不一样——这就是 CI 教训的来源。
- clang 编
oob2:UBSan 的 bounds 检查截胡,报index 8 out of bounds for type 'int[4]',报完继续、退出 0;uaf2归 ASan 管:heap-use-after-free三段栈(非法读在 :9、free 在 :8、malloc 在 :6),退出 1;ovf2两条 UBSan(有符号溢出 + 移位越界)都报、退出 0。→ 知识点:第 10 章:ASan+UBSan 深入「用 CI 的那套 flags」一节 - gcc 编同一个
oob2:UBSan 报完没有短路,ASan 的stack-buffer-overflow照样登场、ABORTING、退出 1——同样一套 flags,两个编译器的输出不一样。→ 知识点:同上(sanitizer 行为依赖编译器实现;别拿「我本地 gcc 跑过没报」当「CI 也一定过」)
验证输出(clang 三个 + gcc 对照):
展开代码 (共 38 行)收起代码
$ clang -std=c11 -fsanitize=address,undefined -fno-omit-frame-pointer -g oob2.c -o oob_c
$ ./oob_c
oob2.c:6:5: runtime error: index 8 out of bounds for type 'int[4]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior oob2.c:6:5
a[0] = 0
exit=0
$ ./uaf_c
=================================================================
==1781==ERROR: AddressSanitizer: heap-use-after-free on address 0x... thread T0
READ of size 4 at 0x... thread T0
#0 ... in main /tmp/cj-ex4-hw5/410a/uaf2.c:9
freed by thread T0 here:
#1 ... in main /tmp/cj-ex4-hw5/410a/uaf2.c:8
previously allocated by thread T0 here:
#1 ... in main /tmp/cj-ex4-hw5/410a/uaf2.c:6
SUMMARY: AddressSanitizer: heap-use-after-free /tmp/cj-ex4-hw5/410a/uaf2.c:9 in main
exit=1
$ ./ovf_c
ovf2.c:7:15: warning: shift count >= width of type [-Wshift-count-overflow]
ovf2.c:6:15: runtime error: signed integer overflow: 2147483647 + 100 cannot be represented in type 'int'
ovf2.c:7:15: runtime error: shift exponent 32 is too large for 32-bit type 'int'
-2147483549 -300736192
exit=0
$ gcc -std=c11 -fsanitize=address,undefined -fno-omit-frame-pointer -g oob2.c -o oob_g
$ ./oob_g
oob2.c:6:6: runtime error: index 8 out of bounds for type 'int [4]'
oob2.c:6:10: runtime error: store to address 0x... with insufficient space for an object of type 'int'
=================================================================
==1798==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x... thread T0
WRITE of size 4 at 0x... thread T0
#0 ... in main /tmp/cj-ex4-hw5/410a/oob2.c:6
Address 0x... is located in stack of thread T0 at offset 96 in frame
This frame has 2 object(s):
[48, 52) 'i' (line 5)
[64, 80) 'a' (line 4) <== Memory access at offset 96 overflows this variable
Shadow bytes around the buggy address:
=>0x...: f1 f1 f1 f1 f1 f1 04 f2 00 00 f3 f3[f3]f3 f3 f3
exit=14.10-B
难度 L4 · 题面见 homework
思路:use-after-scope 是新版 gcc/clang 上 -fsanitize=address 的默认行为,标志是 shadow byte f8;三个旋钮用 ASAN_OPTIONS=help=1 让 ASan 自报;LSan 报泄漏会让退出码非 0。
- 出块后读
local:stack-use-after-scope,shadow 行f1 f1 f1 f1[f8]f8 f3 f3——f8= Stack use after scope。→ 知识点:第 10 章「-fsanitize-address-use-after-scope」一节 help=1自报:halt_on_error当前true、abort_on_error当前false(严格abort(3)默认关着)、detect_leaks当前true。→ 知识点:同上「ASAN_OPTIONS 三件套」一节detect_leaks=1:LSan 报Direct leak of 64 byte(s)、退出 1;=0:一声不吭退出 0。LSan 靠ptrace扫内存,seccomp 严的容器里会启动即挂(Unable to scan process memory),那种环境必须detect_leaks=0——本机无受限容器,这条据教材与 LSan 文档作答。→ 知识点:同上「LSan 的容器/seccomp 坑」一节
验证输出:
展开代码 (共 27 行)收起代码
$ ./s3_g
=================================================================
==1807==ERROR: AddressSanitizer: stack-use-after-scope on address 0x... thread T0
READ of size 4 at 0x... thread T0
#0 ... in main /tmp/cj-ex4-hw5/410b/scope3.c:9
This frame has 1 object(s):
[32, 48) 'local' (line 6) <== Memory access at offset 32 is inside this variable
SUMMARY: AddressSanitizer: stack-use-after-scope /tmp/cj-ex4-hw5/410b/scope3.c:9 in main
Shadow bytes around the buggy address:
=>0x...: f1 f1 f1 f1[f8]f8 f3 f3 00 00 00 00 00 00 00 00
exit=1
$ ASAN_OPTIONS=help=1 ./s3_g 2>&1 | grep -A1 -E "halt_on_error|abort_on_error|detect_leaks"
halt_on_error
- Crash the program after printing the first error report ... (Current Value: true)
detect_leaks
- Enable memory leak detection. (Current Value: true)
abort_on_error
- If set, the tool calls abort() instead of _exit() ... (Current Value: false)
$ ASAN_OPTIONS=detect_leaks=1 ./l3_g
=================================================================
==1817==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 64 byte(s) in 1 object(s) allocated from:
#1 ... in main /tmp/cj-ex4-hw5/410b/leak3.c:4
SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).
exit=1
$ ASAN_OPTIONS=detect_leaks=0 ./l3_g >/dev/null 2>&1; echo "exit=$?"
04.11-A
难度 L2 · 题面见 homework
思路:能力矩阵纸面题,核心是分清「地址能不能访问」(ASan)和「值从哪来」(valgrind)。
- ① 读未初始化 → valgrind memcheck(ASan 的影子内存只管地址合法性,不初始化读在 C 里多为未指定行为,UBSan 也不管);② free 后再读 → ASan(heap-use-after-free 是它的主场,报完三段栈);③ 无锁自增 → TSan(valgrind 侧对应 helgrind/drd);④
INT_MAX + 1→ UBSan(有符号溢出 UB)。→ 知识点:第 11 章:valgrind 与 sanitizer 的分工「一张能力矩阵」一节 - 形态差别:sanitizer 是编译期插桩(要
-fsanitize重编重链、ASan 约慢 2 倍、能挂 CI 天天跑),valgrind 是运行期动态二进制翻译(不用重编、不要源码、慢 10-50 倍、只能定向复核)。→ 知识点:第 11 章「引言」一节 - MSan 要求程序里每一个库(包括 glibc)都用 MSan 重编,否则未重编的库传出的未初始化字节会一路误报——工程上几乎没法全栈启用,所以「不重编就能查未初始化读」这个生态位归 valgrind。→ 知识点:同上(MSan 落地困难户)
4.11-B
难度 L3 · 题面见 homework
思路:① ASan 对未初始化读一声不吭(退出 0),valgrind 靠「字节有没有被写过」的元数据当场抓;本机 Arch 的 valgrind 动态链接启动即死(教材的环境坑),按 -static 绕法后 memcheck 正常工作。② TSan 抓的是「竞争存在与否」,不是「结果对错」。
- ASan 跑 uninit:
decide returned 0、退出 0,一个字不报。valgrind 动态链接:Fatal error at startup: a function redirection which is mandatory ...(本机 Arch 把 ld-linux 符号 strip 了,教材「环境坑」一节就是这个);-static绕过后:Conditional jump or move depends on uninitialised value(s)精确到uninit.c:5,--error-exitcode=99退出 99。→ 知识点:第 11 章「实验一」与「环境坑」两节 - TSan:
WARNING: ThreadSanitizer: data race,直指race.c:9、Location is global 'counter',退出 66——哪怕这次 counter 恰好打出了期望值,竞争存在就是存在。→ 知识点:第 11 章「实验三」一节
验证输出:
展开代码 (共 35 行)收起代码
$ gcc -g -std=c11 -Wall -Wextra -fsanitize=address,undefined \
-fno-omit-frame-pointer uninit.c -o uninit_asan
$ ./uninit_asan; echo "exit=$?"
decide returned 0
exit=0
$ gcc -g -std=c11 -Wall -Wextra uninit.c -o uninit_dyn
$ valgrind --tool=memcheck ./uninit_dyn
==2059== Memcheck, a memory error detector
valgrind: Fatal error at startup: a function redirection
valgrind: which is mandatory for this platform-tool combination
exit=1
$ gcc -g -std=c11 -Wall -Wextra -static uninit.c -o uninit_static
$ valgrind --tool=memcheck --leak-check=no --error-exitcode=99 ./uninit_static
==2066== Conditional jump or move depends on uninitialised value(s)
==2066== at 0x402F86: decide (uninit.c:5)
==2066== by 0x402FB7: main (uninit.c:13)
==2066== Conditional jump or move depends on uninitialised value(s)
==2066== at 0x41197A: free (in /tmp/cj-ex4-hw6/411b/uninit_static)
==2066== by 0x402FDF: main (uninit.c:15)
decide returned 0
exit=99
$ gcc -g -std=c11 -Wall -Wextra -pthread -fsanitize=thread \
-fno-omit-frame-pointer race.c -o race_tsan
$ ./race_tsan; echo "exit=$?"
==================
WARNING: ThreadSanitizer: data race (pid=2075)
Read of size 4 at 0x... by thread T2:
#0 worker /tmp/cj-ex4-hw6/411c/race.c:9 (race_tsan+0x11f9)
Previous write of size 4 at 0x... by thread T1:
#0 worker /tmp/cj-ex4-hw6/411c/race.c:9 (race_tsan+0x1211)
Location is global 'counter' of size 4 at 0x... (race_tsan+0x4064)
SUMMARY: ThreadSanitizer: data race /tmp/cj-ex4-hw6/411c/race.c:9 in worker
==================
counter = 1000000 (期望 1000000)
exit=66注意最后一行:counter 这次恰好打出了 1000000,但 TSan 照样报 race、退出 66——「结果对是运气,竞争存在是事实」。
4.12-A
难度 L1 · 题面见 homework
思路:纸面判断题,静态分析的定位。
- 编译时静态分析,不运行程序。→ 知识点:第 12 章:静态分析门「引言」一节
- 不抓——编译器完全不管;违反 ISO C11 §7.1.3(Reserved identifiers)。→ 知识点:第 12 章「第三类:reserved identifier」一节
implementation-defined(ISO §6.3.1.3:有符号整数窄化的结果由实现定义,不是 UB)。→ 知识点:同上「第一类:narrowing conversion」一节- clang-tidy 强在风格与标准符合性(reserved-identifier、缺括号、narrowing),cppcheck 强在内存/指针类真 bug(空指针解引用、泄漏、越界)、卖点是少误报。→ 知识点:第 12 章「cppcheck」一节
- 每个源文件的编译命令(include 路径、
-std、宏定义),一般用 CMake 的-DCMAKE_EXPORT_COMPILE_COMMANDS=ON生成。→ 知识点:同上「clang_tidy_check.py」一节
4.12-B
难度 L3 · 题面见 homework
思路:先让编译器说话,再让 clang-tidy 说话,两边错位互补正是这道题要看的。注意题面的诚实预告:gcc 对 dangling else 是会报警的(-Wdangling-else 收在 -Wall),对另两类一声不吭。
- gcc
-Wall -Wextra -Wpedantic只报一条suggest explicit braces to avoid ambiguous 'else'——reserved identifier 和 narrowing 全静默。→ 知识点:第 12 章「三类 finding 的对照」一节 - clang-tidy(
--checks用本仓三族配置)报 6 条:bugprone-reserved-identifier×2、readability-braces-around-statements×3(含 dangling else 那处)、bugprone-narrowing-conversions×1(措辞是implementation-defined)。→ 知识点:同上 - 修法:
__HiddenCfg改名HiddenCfg、if/else 补花括号、int small = (int) big;显式窄化。重跑 clang-tidy 只剩 14 条全被系统头抑制的输出,编译运行0 1 1。三处修法里,显式(int)窄化对应教材说的「legacy 妥协」——最彻底是把字段类型也改对,但 ABI 影响面大,先用 cast 兜住、留下 TODO。→ 知识点:同上「clib-utilities 的活样板」一节
验证输出:
$ gcc -std=c11 -Wall -Wextra -Wpedantic -c findings.c -o f.o
findings.c: In function 'pick':
findings.c:9:8: warning: suggest explicit braces to avoid ambiguous 'else' [-Wdangling-else]
9 | if (flag)
| ^
$ clang-tidy --checks="-*,bugprone-*,performance-*,readability-*,..." findings.c -- -std=c11
findings.c:4:16: warning: declaration uses identifier '__HiddenCfg', which is a reserved identifier [bugprone-reserved-identifier]
findings.c:6:3: warning: declaration uses identifier '__HiddenCfg', which is a reserved identifier [bugprone-reserved-identifier]
findings.c:9:14: warning: statement should be inside braces [readability-braces-around-statements]
findings.c:10:19: warning: statement should be inside braces [readability-braces-around-statements]
findings.c:12:13: warning: statement should be inside braces [readability-braces-around-statements]
findings.c:19:17: warning: narrowing conversion from 'long' to signed type 'int' is implementation-defined [bugprone-narrowing-conversions]
$ clang-tidy --checks="..." findings_fixed.c -- -std=c11
14 warnings generated.
Suppressed 14 warnings (14 in non-user code).
$ gcc -std=c11 -Wall -Wextra -Wpedantic findings_fixed.c -o ff && ./ff
0 1 14.13-A
难度 L2 · 题面见 homework
思路:只测 grade_level(95) 时,两个 if 的 false 路、return "B" 和 return "C" 全是死的;补 70/30 两条用例后全活。字符串内容比较用 strcmp——直接 == 比的是指针地址(那条 -Waddress 警告就是编译器在提醒)。
- 第一版:
Lines executed:50.00% of 6、Branches executed:50.00% of 4、Taken at least once:25.00% of 4——.gcov里第 8/9/11 行顶着#####。→ 知识点:第 13 章:覆盖率门「gcov」与「行覆盖 vs 分支覆盖」两节 - 补两条用例后:三行数字全 100%,
grade_level called 3。→ 知识点:同上(补用例→覆盖率涨的闭环)
验证输出:
展开代码 (共 22 行)收起代码
$ gcc -std=c11 -Wall -Wextra --coverage -g -O0 grade.c test_grade1.c -o t1 && ./t1
$ gcov -b grade.c
File 'grade.c'
Lines executed:50.00% of 6
Branches executed:50.00% of 4
Taken at least once:25.00% of 4
$ cat grade.c.gcov
function grade_level called 1 returned 100% blocks executed 50%
1: 4:const char* grade_level(int score) {
branch 0 taken 100% (fallthrough)
branch 1 taken 0%
1: 6: return "A";
#####: 8: if (score >= 60) {
branch 0 never executed (fallthrough)
branch 1 never executed
#####: 9: return "B";
#####: 11: return "C";
$ # 补上 grade_level(70)=="B" 与 grade_level(30)=="C" 后
$ gcov -b grade.c
Lines executed:100.00% of 6
Branches executed:100.00% of 4
Taken at least once:100.00% of 4「行覆盖 100% 也不代表分支覆盖 100%」在哪亲眼看到:第一版的行覆盖是 50%,看上去已经「测了一半」;分支那边 Taken at least once 只有 25%——四个分支走向只走过一路,另外三路是死的。行覆盖会粉饰「happy path 一跑到底」的测试,分支覆盖把这层粉饰揭掉。
4.13-B
难度 L3 · 题面见 homework
思路:三轮测试分别命中 || 短路的三条路:a==NULL 单独为真(轮 2)、a!=NULL && n<=0(轮 3)——教材留的那个练习,答案就是「补一条非 NULL 但 n=0」。
- 轮 1(只测正常路径):
Taken at least once:66.67% of 6;轮 2(加 NULL):83.33%;轮 3(加 n=0):100.00%。→ 知识点:第 13 章「分支覆盖」一节 - 第三轮
.gcov里 branch 明细:branch 0 taken 75% (fallthrough)是a==NULL假、branch 1 taken 25%是真(4 次调用里 1 次 NULL);branch 2 taken 33%是n<=0真(那条「非 NULL 但 n=0」)、branch 3 taken 67%是假——两条新用例各命中一路。→ 知识点:同上(读 .gcov 的 branch 行)
验证输出:
展开代码 (共 24 行)收起代码
===== 轮次 1(只测 {10,20,30} 两次) =====
Lines executed:85.71% of 7
Branches executed:100.00% of 6
Taken at least once:66.67% of 6
===== 轮次 2(+stats_average(NULL, 0) == -1) =====
Lines executed:100.00% of 7
Branches executed:100.00% of 6
Taken at least once:83.33% of 6
===== 轮次 3(+stats_average(a, 0) == -1,非 NULL 但 n=0) =====
Lines executed:100.00% of 7
Branches executed:100.00% of 6
Taken at least once:100.00% of 6
$ cat stats.c.gcov # 轮次 3
function stats_average called 4 returned 100% blocks executed 100%
4: 6: if (a == NULL || n <= 0) {
branch 0 taken 75% (fallthrough)
branch 1 taken 25%
branch 2 taken 33% (fallthrough)
branch 3 taken 67%
2: 7: return -1;
8: 10: for (int i = 0; i < n; i++) {
branch 0 taken 75%
branch 1 taken 25% (fallthrough)
2: 13: return (int) (sum / n);4.14-A
难度 L2 · 题面见 homework
思路:clock() 量的是 CPU 时间不是墙钟——sleep 时几乎不走,纯烧 CPU 时和墙钟趋同。量化第一性选择:先分清你要量的是墙钟还是 CPU。
- sleep 组:
clock()=0.000027、monotonic=1.000183、cpu=0.000023——clock()和进程 CPU 时间几乎都是 0,墙钟老老实实 1 秒。→ 知识点:第 14 章:性能剖析「为什么别用 clock()」一节 - burn 组:三个读数都是 0.59 秒——纯 CPU-bound 时三者趋同。由此:「我的程序慢」要先量化是哪一种慢(CPU-bound 还是 I/O-bound),方向完全不同。→ 知识点:第 14 章「第一件:慢,要量化哪一种时间」一节
验证输出:
$ gcc -std=c11 -Wall -Wextra -O2 clocks.c -o clocks && ./clocks
sleep(1) : clock()=0.000027 s monotonic=1.000183 s cpu=0.000023 s
burn(300M): clock()=0.591606 s monotonic=0.591606 s cpu=0.591606 s4.14-B
难度 L4 · 题面见 homework
思路:迭代数必须 argv 驱动——写死成常量 -O2 会整段折叠、机器码里没循环可测;volatile 累加防死代码消除。50:5 的比例在 call graph 里就是 90.9% : 9.1%(50/55 与 5/55)。
-O2 -fno-inline -pg:flat 里leaf_accumulateself 100%、calls 55(插桩精确计数);call graph 里 worker_heavy children 7.78s 占 90.9%、worker_light 0.78s 占 9.1%——比例 ≈ 50:5。→ 知识点:第 14 章「gprof 是怎么工作的」与「真跑」两节- 去掉
-fno-inline:flat profile 只剩一行100.00% ... main,三个函数被内联、符号消失,profile 假报「main 慢」。-fno-inline只用于剖析看清结构,发布版必须还给它内联。→ 知识点:第 14 章「真坑:gprof 被 inline 骗了」一节
验证输出:
展开代码 (共 31 行)收起代码
$ gcc -std=c11 -Wall -Wextra -O2 -fno-inline -pg work_tree.c -o wt_pg
$ ./wt_pg 80000000
heavy=479999994000000000 light=47999999400000000 (iters=80000000)
$ gprof -b ./wt_pg gmon.out
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls s/call s/call name
100.00 8.56 8.56 55 0.16 0.16 leaf_accumulate
0.00 8.56 0.00 1 0.00 7.78 worker_heavy
0.00 8.56 0.00 1 0.00 0.78 worker_light
Call graph
0.78 0.00 5/55 worker_light [4]
7.78 0.00 50/55 worker_heavy [3]
[1] 100.0 8.56 0.00 55 leaf_accumulate [1]
-----------------------------------------------
0.00 7.78 1/1 main [2]
[3] 90.9 0.00 7.78 1 worker_heavy [3]
7.78 0.00 50/55 leaf_accumulate [1]
-----------------------------------------------
0.00 0.78 1/1 main [2]
[4] 9.1 0.00 0.78 1 worker_light [4]
0.78 0.00 5/55 leaf_accumulate [1]
$ gcc -std=c11 -Wall -Wextra -O2 -pg work_tree.c -o wt_inline && ./wt_inline 80000000
$ gprof -b ./wt_inline gmon.out
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls Ts/call Ts/call name
100.00 8.69 8.69 main4.15-A
难度 L2 · 题面见 homework
思路:硬门 = 脚本退出码非 0 就断 CI;报告 = 只生数字不挡合并。六行表加三个追问。
- 六道门表:
build-examples→build_examples.py退出码 → 硬门(gcc/clang 矩阵双跑);sanitize→ 同一个脚本 + sanitizer flags 再编 → 硬门;docs→validate_frontmatter.py+ markdownlint action 的退出码 → 硬门;format-check→clang-format --dry-run --Werror退出码 → 硬门;static-analysis→clang_tidy_check.py(stdout 有warning:即失败)→ 硬门;coverage→ lcov 只打摘要 → 报告。→ 知识点:第 15 章:把质量门拼成流水线「逐 job 概览」一节 coverage是报告:最后一步lcov --summary只打印覆盖率、不做阈值断言,哪怕 5% 也退出 0;但它里面「编译+ctest --output-on-failure」那一步是硬的(测试挂了&&链断、CI 红)。→ 知识点:同上「coverage」一节KNOWN_LEGACY:build_examples.py给明知编不过的老工程(Keil/bochs/VS/mplayer)留报告出口子,失败只打日志不断 CI;新代码和整改完的走硬门。concurrency.cancel-in-progress:同分支新推送取消上一轮在跑的旧 CI,省排队、状态始终对应最新一次。→ 知识点:同上「KNOWN_LEGACY 双模式」与「concurrency」两节- 加新门三步:写
scripts/xxx_check.py(裁决逻辑写脚本里、sys.exit(0/1))→ ci.yml 抄一个 job(checkout + 装工具 + 跑脚本)→ 想清楚硬门还是报告。→ 知识点:同上「怎么加一道新门」一节
4.15-B
难度 L3 · 题面见 homework
思路:三道门各跑各的命令、各自 rc 判红绿、汇总 fail 由脚本 exit 交出去——「什么叫失败」全写在脚本里,这正是教材说的「裁决逻辑写在脚本里」。
- 第一轮
test1.c故意断言mod_double(21) == 44:门 1 编译绿,门 2 测试test exit=134(abort)红,门 3 sanitizer 同样红,汇总 fail=1、脚本退出 1。→ 知识点:第 15 章「本地复现三道硬门」一节、第 7 章(退出码是 CI 判红绿的唯一依据) - 第二轮换
test2.c:三道门全绿、汇总 fail=0、退出 0。→ 知识点:同上
验证输出:
展开代码 (共 26 行)收起代码
$ ./gate.sh
==> gate 1: 编译门 (gcc -Wall -Wextra -Werror)
compile exit=0
==> gate 2: 测试门 (跑测试可执行, 看退出码)
link exit=0
testbin: test1.c:7: main: Assertion `mod_double(21) == 44' failed.
test exit=134
==> gate 3: sanitizer 门 (ASan+UBSan 重编再跑)
sanitize build exit=0
testbin_asan: test1.c:7: main: Assertion `mod_double(21) == 44' failed.
sanitize run exit=134
==== 汇总 fail=1 ====
gate exit=1
$ TEST_C=test2.c ./gate.sh
==> gate 1: 编译门 (gcc -Wall -Wextra -Werror)
compile exit=0
==> gate 2: 测试门 (跑测试可执行, 看退出码)
link exit=0
all pass
test exit=0
==> gate 3: sanitizer 门 (ASan+UBSan 重编再跑)
sanitize build exit=0
all pass
sanitize run exit=0
==== 汇总 fail=0 ====
gate exit=04.16-A
难度 L1 · 题面见 homework
思路:收官章的「知不知道」,全是第 16 章真跑过的事实。
- UBSan 抓的是
CCDynamicArray.c:203函数指针类型不匹配的调用(compareInt强转签名不符,ISO §6.5.2.2 UB);ASan 抓的是eraseSingle的 heap-buffer-overflow(缩容后读到0 bytes after 20-byte region)。→ 知识点:第 16 章:工程化毕业项目「③ Sanitizer」一节 - 不能——ctest 那五条用例全绿,ASan 一开当场抓到两个真 bug。「测试全绿」和「没有内存 bug」是两回事,中间那道缝就是 sanitizer 门存在的意义。→ 知识点:同上
- 行覆盖 42.45%,139 行里约跑了 59 行。→ 知识点:第 13 章
- 没有——CI 的 sanitize 与 clang-tidy 目前只覆盖
examples/一级子项目,clib 在硬门外、legacy 整改中(修齐再纳入)。→ 知识点:第 16 章(诚实交代「六道门里有一道还没合上」) - 绝对路径(CMake 默认埋的 build-tree RPATH 指向安装前缀);分发时换成相对的
$ORIGIN写法、设INSTALL_RPATH。→ 知识点:第 6 章(build-tree RPATH 是开发期便利)
4.16-B
难度 L3 · 题面见 homework
思路:arr_erase 挪元素循环写 i < a->len,最后一轮 i == len-1 读 data[len]——容量 4、元素 4 时正好读到分配区外一字节。普通构建下读的是邻接内存的脏字节、写入 data[3] 后被 len-- 藏掉,测试全绿;ASan 埋的红区当场点名。
- 第一阶段普通构建:ctest
100% tests passed、Unity4 Tests 0 Failures。→ 知识点:第 7 章(ctest 全绿 ≠ 没 bug,这句话先记着) - 第二阶段 CI sanitizer flags 重编:
heap-buffer-overflow、READ of size 4、0 bytes after 16-byte region、点名arr_erase——和教材 16 章 clibEraseSingle是同一种病:缩容时越界读。→ 知识点:第 16 章「③ Sanitizer」一节、第 10 章 - 第三阶段把循环改成
i + 1 < a->len,sanitizer 下 ctest 全绿退出 0。→ 知识点:同上
验证输出:
展开代码 (共 23 行)收起代码
$ ctest --test-dir build --output-on-failure
1/1 Test #1: arr_unity ........................ Passed 0.00 sec
100% tests passed out of 1
$ CC=clang CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" \
LDFLAGS="-fsanitize=address,undefined" cmake -B build-asan && cmake --build build-asan
$ ctest --test-dir build-asan --output-on-failure
1/1 Test #1: arr_unity ........................***Failed 0.08 sec
test_push_three ... PASS
test_erase_middle ... =================================================================
==2716==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x... thread T0
READ of size 4 at 0x... thread T0
#0 ... (test_arr+0x194c6a)
0x... is located 0 bytes after 16-byte region [0x...,0x...)
allocated by thread T0 here:
#1 ... (test_arr+0x1941ae)
SUMMARY: AddressSanitizer: heap-buffer-overflow (test_arr+0x194c6a)
50% tests passed, 1 tests failed out of 1
ctest exit=8
$ # 修掉循环边界再 build
$ ctest --test-dir build-asan --output-on-failure
1/1 Test #1: arr_unity ........................ Passed 0.09 sec
100% tests passed out of 1
ctest exit=0为什么普通构建「看起来没事」:data[4] 那 4 字节仍在进程的堆区里(不是保护区),读得动、值不崩;读来的脏字节被写进 data[3]、随后 len-- 把 data[3] 移出视线。UB 最阴险的地方就在这里——它能静默运行到某天布局一变才炸,ASan 的红区就是把「某天」提前到今天。
4.C-1
难度 L3 · 题面见 homework
思路:不透明句柄(第 2 章)+ context 错误(第 3 章)+ mini_unity(第 7 章)+ gcov(第 13 章)四章拧一股绳。队列用环形容器(head/tail 模 cap),元素是堆上 dup 的字符串。
strqueue.h只有typedef struct StrQueue StrQueue_t;加五个原型,字段藏.c;sq_push满返 0 并往 ctx 写「队列已满:cap=%zu」,sq_pop空返 0 写「队列为空」。→ 知识点:第 2 章、第 3 章- 四条 Unity 用例 FIFO 顺序/满报错/空报错/出队缩容,
4 Tests 0 Failures,ASan 干净。→ 知识点:第 7 章 - gcov:
Lines 78.79% of 66、Branches 100% of 30、Taken 60.00% of 30、Calls 42.86% of 7——死的全是「参数非法(码 1)/分配失败(码 3)/出队缓冲不够(码 5)」这些错误注入分支。工程权衡:这些分支要么靠故障注入(Lab 的 L5 就是干这个的)、要么留给「防御性代码可测性低」的旧账,硬补普通用例性价比低。→ 知识点:第 13 章
验证输出:
展开代码 (共 24 行)收起代码
$ gcc -std=c11 -Wall -Wextra strqueue.c errctx.c mini_unity.c test_strqueue.c -o t && ./t
test_fifo_order ... PASS
test_push_full_reports ... PASS
test_pop_empty_reports ... PASS
test_pop_shrinks ... PASS
4 Tests 0 Failures
exit=0
$ gcc ... -fsanitize=address,undefined ... -o t_asan && ./t_asan
test_fifo_order ... PASS
test_push_full_reports ... PASS
test_pop_empty_reports ... PASS
test_pop_shrinks ... PASS
4 Tests 0 Failures
$ gcov -b strqueue.c
Lines executed:78.79% of 66
Branches executed:100.00% of 30
Taken at least once:60.00% of 30
Calls executed:42.86% of 7
function dup_str called 8 returned 100% blocks executed 75%
function sq_create called 4 returned 100% blocks executed 62%
function sq_push called 9 returned 100% blocks executed 69%
function sq_pop called 4 returned 100% blocks executed 67%sq_pop 的实现要点(「空/不够返 0、错误进 ctx」):
展开代码 (共 22 行)收起代码
int sq_pop(StrQueue_t* q, char* out, size_t out_cap, ErrCtx_t* err) {
if (!q || !out) {
errctx_set(err, 1, __func__, "参数非法");
return 0;
}
if (q->count == 0) {
errctx_set(err, 4, __func__, "队列为空");
return 0;
}
char* s = q->slots[q->head];
size_t n = strlen(s);
if (n + 1 > out_cap) {
errctx_set(err, 5, __func__, "出队缓冲不够:需要 %zu,给了 %zu", n + 1,
out_cap);
return 0;
}
memcpy(out, s, n + 1);
q->head = (q->head + 1) % q->cap;
q->count--;
free(s);
return 1;
}4.C-2
难度 L4 · 题面见 homework
思路:第 5 章 target 三态 + 第 6 章 install/export/find_package + 第 15 章质量门拼装。踩过的真坑写在这里:include(GNUInstallDirs) 必须写在 target_include_directories 之前——否则 ${CMAKE_INSTALL_INCLUDEDIR} 还是空串,导出的 IMPORTED target 里 INTERFACE_INCLUDE_DIRECTORIES 被空值顶掉,消费者就 fatal error: libcalc.h: No such file or directory。
add_library(calc SHARED ...)+SOVERSION 1+BUILD_INTERFACE/INSTALL_INTERFACE头路径 +install(EXPORT ... NAMESPACE Calc::)+configure_package_config_file/write_basic_package_version_file。→ 知识点:第 5 章(生成器表达式)、第 6 章「全链路活教材」一节DESTDIR装出 7 类文件:libcalc.so.1、软链libcalc.so、头、CalcTargets.cmake、CalcTargets-noconfig.cmake、CalcConfig.cmake、CalcConfigVersion.cmake。消费者find_package(Calc 1.0 REQUIRED)+ 链Calc::calc,跑出calc_add(2,3)=5 calc_mul(4,5)=20。→ 知识点:同上readelf -d:RUNPATH 是绝对路径(CMake 默认 build-tree RPATH);分发时用$ORIGIN相对写法设INSTALL_RPATH才可移植。→ 知识点:第 6 章(build-tree RPATH 的诚实交代)
验证输出:
展开代码 (共 21 行)收起代码
$ cmake --build libcalc/build
[ 50%] Building C object CMakeFiles/calc.dir/src/libcalc.c.o
[100%] Linking C shared library libcalc.so
$ DESTDIR=$PWD/prefix cmake --install libcalc/build
-- Installing: .../prefix/usr/local/lib/libcalc.so.1
-- Installing: .../prefix/usr/local/lib/libcalc.so
-- Installing: .../prefix/usr/local/include/libcalc.h
-- Installing: .../prefix/usr/local/lib/cmake/Calc/CalcTargets.cmake
-- Installing: .../prefix/usr/local/lib/cmake/Calc/CalcTargets-noconfig.cmake
-- Installing: .../prefix/usr/local/lib/cmake/Calc/CalcConfig.cmake
-- Installing: .../prefix/usr/local/lib/cmake/Calc/CalcConfigVersion.cmake
$ cmake -S consumer -B consumer/build -DCALC_PREFIX=$PWD/prefix && cmake --build consumer/build
[ 50%] Building C object CMakeFiles/consumer.dir/main.c.o
[100%] Linking C executable consumer
$ ./consumer/build/consumer
calc_add(2,3) = 5
calc_mul(4,5) = 20
$ readelf -d consumer/build/consumer | grep -Ei 'rpath|runpath|NEEDED'
0x0000000000000001 (NEEDED) Shared library: [libcalc.so.1]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000001d (RUNPATH) Library runpath: [/tmp/cj-ex4-hw9e/prefix/usr/local/lib]4.C-3
难度 L5 · 题面见 homework
思路:改编自 cmocka 的 will_return/check_expected 脚本队列语义(如实标注)。骨架复用第 7 章 setjmp/longjmp 隔离,队列用两个环形 FIFO(返回值脚本 + 期望参数脚本),队列空或参数不符就打印 FAIL、longjmp 回框架、继续下一条用例。
mockfw.c两套队列 +mock_take_return/mock_check_param;mock_run_test每例先mock_reset()(新脚本)、再setjmp跑用例。→ 知识点:第 7 章(setjmp/longjmp 隔离)、第 8 章(函数指针 mock 的框架化)- 用例①脚本
-1,-1,7+ 三个期望参数 42 →poll_until_ok(42,3)==7、被调 3 次;用例②三次-1→ 返 -1;用例③期望写成 99 → 框架抓「参数不符」,这条 FAIL 且不拖死下一条。→ 知识点:第 8 章(mock 只按脚本返回、还能验参数)
验证输出:
展开代码 (共 23 行)收起代码
$ gcc -std=c11 -Wall -Wextra -I. mockfw.c sensor.c test_sensor_mock.c -o tm_gcc && ./tm_gcc
test_retry_succeeds ... PASS
test_all_fail ... PASS
test_param_mismatch_caught ... FAIL: sensor_read 参数不符:期望 99, 实际 42
test_still_runs_after_fail ... PASS
4 Tests 1 Failures
exit=1
$ clang -std=c11 -Wall -Wextra -I. mockfw.c sensor.c test_sensor_mock.c -o tm_clang && ./tm_clang
test_retry_succeeds ... PASS
test_all_fail ... PASS
test_param_mismatch_caught ... FAIL: sensor_read 参数不符:期望 99, 实际 42
test_still_runs_after_fail ... PASS
4 Tests 1 Failures
exit=1
$ gcc ... -fsanitize=address,undefined ... -o tm_asan && ./tm_asan
test_retry_succeeds ... PASS
test_all_fail ... PASS
test_param_mismatch_caught ... FAIL: sensor_read 参数不符:期望 99, 实际 42
test_still_runs_after_fail ... PASS
4 Tests 1 Failures框架核心(两套队列 + 长跳隔离):
展开代码 (共 39 行)收起代码
static mval_t ret_q[Q_CAP];
static int ret_head = 0;
static int ret_len = 0;
static mval_t exp_q[Q_CAP];
static int exp_head = 0;
static int exp_len = 0;
mval_t mock_take_return(const char* mock_name) {
if (ret_len <= 0) {
printf(" FAIL: %s 的返回值脚本用完了\n", mock_name);
longjmp(MockJmp, 1);
}
return q_pop(ret_q, &ret_head, &ret_len);
}
void mock_check_param(const char* mock_name, mval_t got) {
if (exp_len <= 0) {
printf(" FAIL: %s 的期望参数脚本用完了(got=%lld)\n", mock_name, got);
longjmp(MockJmp, 1);
}
mval_t want = q_pop(exp_q, &exp_head, &exp_len);
if (want != got) {
printf(" FAIL: %s 参数不符:期望 %lld, 实际 %lld\n", mock_name, want, got);
longjmp(MockJmp, 1);
}
}
void mock_run_test(void (*test)(void), const char* name) {
printf(" %s ... ", name);
fflush(stdout);
mock_reset(); /* 每条用例拿到全新的脚本队列 */
if (setjmp(MockJmp) == 0) {
test();
printf("PASS\n");
MockPassCount++;
} else {
MockFailCount++;
}
}业务侧 poll_until_ok 一行没改 mock 的账,全靠函数指针 + 脚本队列:这就是 cmocka 把「桩生成 + 队列语义 + 断言 API」自动化之前的手写全貌——看懂了它,再看 cmocka 就是「省去手写的脚手架」。