附录:按需查阅,不必从头读
这份附录不是主线章节的替代品,也不是要求你在 Chapter 1 之前全部背完的“预科教材”。它真正的作用,是在你已经进入课程以后,遇到某一类非常具体的卡点时,给你一份能立刻拿来补洞的支持材料。
主线章节负责回答“为什么现在要学这个模型部件、它在项目里落在哪个文件、你这章要完成什么实验”。本附录只负责一件事:当你已经知道自己要做什么,却被工具链、C 语言细节、终端操作或调试手段卡住时,帮你尽快回到主线。
所以阅读这份附录的正确方式不是从头看到尾,而是先问自己一句话:
我现在卡住的是“不会装 / 不会跑 / 看不懂 / 不会查”中的哪一种?
一旦问题类型明确了,再跳到对应小节,效率会高很多。
A1.0 这份附录怎么用
很多初学者第一次做项目课时,会把“前置知识”理解成一套必须在开课前全部准备齐的百科全书。这种想法不适合 miniLLM 这门课。因为这门课采用的是“讲义先解释当前问题,再进入对应 lab 完成最小实现”的节奏。你真正需要的,不是一次性把所有工具学透,而是在每一章开始时只补齐当前足够用的那一点点。
换句话说,本附录是一个“回来看”的地方,而不是一个“先读完”的地方。你完全可以在不打开本附录任何一节的情况下走完 Chapter 0;但当你第一次看到 make 报错、第一次怀疑自己把指针写炸了、第一次想知道 ASCII 和 token id 到底怎么对上时,这里应该能马上给出答案。
下面这张表先把“什么时候回来查哪一节”说清楚。
| 你现在的状态 | 优先看哪一节 | 这节帮你解决什么 |
|---|---|---|
| 刚做完 Chapter 0,想确认机器能不能稳定跑后面的 lab | A1.1 环境与版本最低要求 | gcc / make / valgrind 是否够用 |
Chapter 1~3 写 student.c 时,分不清指针、结构体、所有权 |
A1.2 C 指针、结构体与内存配对 | 看懂课程里真正出现的 C 形态 |
| tokenizer 或 BPE 相关任务里,不确定字符和数字怎么对应 | A1.3 ASCII 与字符到 token 的最小背景 | 解释字符、字节、整数 id 的映射 |
| 终端命令经常敲错,分不清自己现在在哪个目录 | A1.4 终端最常用命令 | 补最小命令行操作能力 |
| 程序能编译,但运行崩了或结果明显不对 | A1.5 GDB 最小调试流 | 用断点、单步、栈回溯找错 |
| 程序逻辑像是对的,但怀疑有泄漏、越界、use-after-free | A1.6 valgrind 与 ASAN | 用动态检查工具抓内存问题 |
如果你只想记住一句导航语,那就是下面这句:
能编译但不理解,回 A1.2。能运行但不正确,先看 A1.5,再看 A1.6。还没跑起来,先看 A1.1 和 A1.4。
A1.1 环境与版本最低要求
Chapter 0 已经带你完成了第一次 smoke test,但那一步更多是在验证“这台机器大体能跑”。到了正式 lab 阶段,你会越来越频繁地重新编译、重跑测试、开调试工具。这时最常见的问题不是模型本身,而是工具链版本太旧、平台假设不一致、或者调试工具根本不存在。
miniLLM 当前所有 Makefile 的共同前提很简单:
CFLAGS = -Wall -Wextra -O2 -std=c99
这意味着编译器至少要稳定支持 C99。如果你在一台老 Linux 服务器、老容器、或者某些偏陈旧的教学机房环境里做实验,这个前提不一定自动成立。
A1.1.1 最低版本表
| 工具 | 最低版本 | 推荐版本 | 说明 |
|---|---|---|---|
gcc |
4.7.0 | 9.x 及以上 | 4.7 起能稳定覆盖本课程需要的 C99 特性 |
clang |
3.0 | 12.x 及以上 | 可以替代 gcc,命令行参数基本兼容 |
make |
3.81 | 4.x | 课程脚本和 Makefile 都按 GNU Make 假设编写 |
binutils |
2.20 | 2.30+ | 一般跟系统默认即可,无需单独折腾 |
先别急着升级,先自检:
gcc -dumpfullversion
make --version | head -1
这两行命令的意义不是“填表”,而是让你在后面提问或排错时,能第一时间确认自己是不是跑在一个课程默认支持的环境上。
A1.1.2 三个平台上的现实提醒
Linux
Linux 是这门课默认假设最完整的平台。大多数时候,只要你有 gcc、make、gdb,主线就能顺利推进。真正需要留意的,是某些老发行版自带的 gcc 太旧,虽然能编译,却会带来奇怪的性能差异或调试体验差异。
例如 CentOS 7 常见的 gcc 4.8.5,从“能不能跑”角度看是勉强够的,但从“数值代码调试是否顺手”角度看就不理想。如果你明显处在一个偏老的环境里,优先考虑切到更新的 devtoolset,而不是先怀疑课程代码。
macOS
macOS 可以做这门课,但你要先接受一个事实:系统里的 gcc 常常只是 Apple Clang 的别名。对本课程这种纯 C 项目来说,这通常不是问题;但当你开始查版本、看编译器行为差异时,必须知道自己实际用的是谁。
最稳妥的两种做法只有两种:
- 全程用 Xcode Command Line Tools 自带的 Clang。
- 用 Homebrew 安装真正的 GCC,然后明确调用
gcc-13、gcc-14这类带版本号的命令。
不要来回混用,尤其不要一会儿靠系统 Clang 链接,一会儿又让 Homebrew 的 GCC 接管默认行为。对初学者来说,这种混搭的收益几乎为零,只会放大环境噪声。
Windows
Windows 原生的 PowerShell / cmd 不是本课程承诺支持的实验面。原因不复杂:课程默认使用 make、gcc、grep、gdb 这类 Unix 风格工具,而且很多脚本也按这种环境来写。
如果你必须在 Windows 上做,正确路径是:
Windows -> WSL2 -> Ubuntu/Debian 环境 -> 按 Linux 方式做课程
WSL1 不在支持范围里。不要在 PowerShell 里试图“临时兼容”课程命令,这会浪费很多和模型本身无关的时间。
A1.2 C 指针、结构体与内存配对
很多同学在这门课里真正第一次撞墙的地方,不是 Transformer,而是 student.c 里那些看似普通的 C 代码。因为一旦进入张量、词表、配置结构体、动态字符串这些实现细节,指针指向谁、这块内存谁来释放、这个结构体字段从哪里来 就会立刻变成工程问题,而不是语法问题。
本节不打算把 C 指针讲成一本教材。它只回答本课程里反复出现的三个问题:
- 课程里的指针通常长什么样?
- 结构体在这些 lab 里承担什么角色?
- 怎样判断一次
malloc/free是否配对正确?
A1.2.1 本课程里最常见的三种指针
你在 miniLLM 里看到的指针,绝大多数都属于下面三种。
float *p;
p = malloc(64 * sizeof(float));
p[3] = 1.5f;
free(p);
p = NULL;
第一种是“指向一段连续内存的数组指针”,例如张量数据、embedding 权重、token 序列缓存。你可以把它先理解成“手动管理的一维数组”。
第二种是“结构体里的数据指针”,例如:
typedef struct {
float *data;
int ndim;
int shape[4];
int stride[4];
} Tensor;
这里的 data 不是独立存在的;它是整个 Tensor 对象的一部分。你在调试时不能只盯着 data 本身,还要同时看 ndim、shape、stride 是否自洽。
第三种是“只读输入指针”,常见形态像 const float *in、const char *text。它的重点不是“它也是指针”,而是“这个函数不应该修改它指向的内容”。当你看到 const 时,先把它理解成一条边界声明。
如果你在某个 lab 里突然遇到 void **、函数指针或更复杂的多级指针,不要先怪自己“基础太差”。对于这门课来说,那通常意味着你已经走出了主线难度范围,应当先确认自己是不是读到了不该先读的内部实现。
A1.2.2 结构体不是语法负担,而是边界工具
初学者常把结构体理解成“C 语言里比较麻烦的一种写法”。在项目里,这种理解会妨碍你读代码。更准确的看法是:结构体是在帮你把本来会散落在很多参数里的信息,打包成一个有名字、有边界的对象。
例如张量为什么不只传一个 float *data?因为只传数据地址还不够。模型代码还需要知道:
- 这是几维张量;
- 每一维大小是多少;
- 当前内存布局下,每一维步长是多少。
这些信息如果不和数据放在一起,后面的矩阵乘、reshape、attention 都会失去边界。
访问结构体字段时,最重要的不是 . 和 -> 这两个符号本身,而是养成先检查边界的习惯:
Tensor t;
t.ndim = 2;
Tensor *pt = &t;
pt->ndim = 2;
当你拿到一个结构体指针时,优先检查的通常不是“怎么改它”,而是:
- 它是不是
NULL; - 它的维度信息是否合理;
- 它内部的数据指针是否已经分配。
这比死记 (*pt).ndim 和 pt->ndim 的等价关系更重要。
A1.2.3 malloc / free 的真正关键是所有权
很多内存 bug 表面上看是“忘了 free”或“多 free 一次”,本质上却是没有先说清楚“谁拥有这块内存”。你可以把课程里的资源管理理解成三种常见约定:
| 约定 | 谁负责释放 | 常见样子 |
|---|---|---|
| 调用者拥有 | 调用方 free |
Tensor *t = tensor_create(...); ... tensor_free(t); |
| 函数内部临时 | 函数自己收尾 | tmp = malloc(...); ... free(tmp); |
| 返回值接管 | 新对象自己负责后续生命周期 | char *s = student_extract_response(...); |
只要所有权不清,后面一定会演变成三类错误之一:
- 泄漏:申请了,但没人负责收。
- 二次释放:两个人都以为自己该收。
- 悬空指针:已经收掉了,但别人还在继续用。
如果你怀疑自己在某个 lab 里把所有权搞混了,不要只看当前一行代码。最有效的做法是从分配点开始往后追:
git grep -n "malloc\\|calloc\\|realloc" course/practice/framework course/practice/labs
看见一次分配,就问一句:谁最终会负责把它收回去?
A1.3 ASCII 与字符到 token 的最小背景
Chapter 2 和 Chapter 12 看上去一个在做字符级 tokenizer,一个在做 BPE,难度差很多。但它们有一个共同底层:文本最终都得先落到“数字序列”上。只不过前者更直接,后者多了一层子词学习。
初学者经常在这里混淆三个概念:
- 字符本身,例如
'A'、' '、\n; - 字节表示,例如 UTF-8 中一个字符可能拆成多个字节;
- token id,例如模型内部拿来查 embedding 的整数编号。
本节只覆盖课程主线真正要用到的那一层:字符和整数如何对应。
A1.3.1 课程里常用的 ASCII 范围
| 范围 | 含义 | 例子 |
|---|---|---|
| 0 | 字符串结束符 | '\0' |
| 9, 10, 13 | 制表、换行、回车 | '\t', '\n', '\r' |
| 32 | 空格 | ' ' |
| 48-57 | 数字 | '0' 到 '9' |
| 65-90 | 大写字母 | 'A' 到 'Z' |
| 97-122 | 小写字母 | 'a' 到 'z' |
| 128-255 | 扩展字节范围 | 在 BPE 阶段更常出现 |
在字符级 tokenizer 里,最常见的转换就是:
int id = (unsigned char)s[i];
char c = (char)tokens[i];
第一行表示“把一个字符按字节值解释成整数”;第二行表示“把一个整数 token 当成字符还原出来”。之所以强调 (unsigned char),是因为某些平台上 char 默认有符号,直接转换可能把高位字节解释成负数。
A1.3.2 为什么 Chapter 12 又提到 UTF-8 和 BPE
到了 BPE 阶段,文本不再只是单字符查表,而是先按字节读入,再通过高频合并学出更长的子词。这个时候你不需要马上掌握 UTF-8 的全部细节,但要知道一件事:
BPE 之所以能处理更复杂的文本,是因为它把“最初的离散单位”从人工定义的字符,变成了“可以不断合并的字节序列”。
也就是说,A1.3 不是在给 Chapter 12 补全部背景,而是在帮你建立一个过渡:先接受“文本一定会被映射成整数序列”,后面再理解“这些整数序列如何从单字符升级成子词”。
A1.4 终端最常用命令
这门课的大部分挫败感,其实不来自模型公式,而来自终端里的小失误:人以为自己在 lab08-step7,实际还停在仓库根;以为删掉的是 build/,结果删到了别的目录;以为 make 没生效,其实只是压根没进对目录。
所以这里不做“Linux 命令大全”,只列课程里最常用、最容易立刻派上用场的几条。
| 命令 | 用途 | 课程里的典型时机 |
|---|---|---|
pwd |
看当前路径 | 每次 cd 之后确认位置 |
ls -la |
看目录内容 | 检查 build/、student、模型产物是否存在 |
cd <dir> |
切目录 | 进入某个 labXX-stepY/ |
cat <file> |
看小文件 | 快速检查 Makefile 或配置 |
less <file> |
看大文件 | 阅读较长源码或日志 |
head -n 20 <file> |
看前若干行 | 先观察训练日志开头 |
grep -n "pat" <file> |
查关键词 | 查 malloc、TODO、函数名 |
rm -rf build |
清理构建目录 | 怀疑旧目标文件干扰时 |
make / make test |
编译或编译加验证 | 每章最常用入口 |
man <cmd> |
查手册 | 忘了 malloc、printf 参数含义时 |
真正值得养成的是两个习惯,而不是多背几条命令。
第一,每次切目录后都用 pwd 看一眼。这不是啰嗦,而是成本最低的防错动作。miniLLM 里有很多命名相似的目录,step10、lab10-step9、course/practice/framework 一旦靠记忆切换,很容易弄混。
第二,遇到错误先把第一行完整读完。编译错误最有信息量的地方,经常不是你盯住的 ^ 符号,而是它上面那一整行文件路径和报错类型。
A1.5 GDB 最小调试流
当程序能编译、却在运行时崩掉,或者输出明显不对时,很多初学者第一反应是继续加 printf。printf 确实有用,但它不该是唯一工具。GDB 的价值在于:它让你在程序运行过程中停下来,直接看“这一刻变量到底是什么”。
你不需要成为 GDB 高手。对这门课来说,掌握一条最小流程就够用了。
A1.5.1 启动和下断点
gdb ./step0
gdb --args ./step0 arg1 arg2
进入 (gdb) 提示符后,先做的不是 run,而是设断点:
(gdb) break main
(gdb) break tensor.c:42
(gdb) break tensor.c:42 if ndim == 2
这背后的思路很简单:你先决定“程序应该停在哪个观察点”,再让它跑。否则它只会一路冲到崩溃处,而你错过了中间最关键的状态变化。
A1.5.2 运行、单步和继续
| 命令 | 缩写 | 作用 |
|---|---|---|
run |
r |
跑到第一个断点 |
next |
n |
执行下一行,但不进入被调函数 |
step |
s |
执行下一行,并进入被调函数 |
continue |
c |
继续跑到下一个断点 |
finish |
fin |
跑完当前函数后返回 |
刚开始调试时,一个很实用的保守策略是:优先用 next,少用 step。因为一旦你在复杂函数链里不停 step,很快就会钻进框架内部,忘了自己最初想确认的到底是哪一层逻辑。
A1.5.3 看变量和看调用栈
(gdb) print ndim
(gdb) print t.shape[0]
(gdb) print *t
(gdb) print t.data[0]@10
这些命令的意义不只是“打印一下”。它们让你能把抽象怀疑变成具体判断,例如:
ndim是否真的是你以为的 2 或 3;shape[1]是否在上一层函数里已经被写坏;- 某段张量数据是不是从一开始就全是 0。
如果程序已经崩了,第一件事通常不是乱猜,而是:
(gdb) backtrace
(gdb) bt full
backtrace 会把“是谁调了谁、最后在哪一层崩掉”完整列出来。很多时候,仅仅看这一条栈回溯,你就已经能把排错范围从“整个项目”缩到“一两个函数”。
A1.5.4 一个足够覆盖大多数情况的最小流程
$ gdb ./step0
(gdb) break main
(gdb) run
(gdb) next
(gdb) print t
(gdb) continue
(gdb) quit
如果是段错误,常见流程会变成:
$ gdb ./step0
(gdb) run
# Program received signal SIGSEGV
(gdb) backtrace
(gdb) print *t
(gdb) quit
这已经足够覆盖本课程里大量“数组越界、空指针、维度异常、逻辑分支没走到”的场景。
A1.6 valgrind 与 ASAN
GDB 擅长回答“程序此刻停在什么地方、变量值是什么”;它不总能直接回答“这块内存是不是早就被踩坏了”。当你怀疑问题与泄漏、越界、重复释放、use-after-free 有关时,动态内存检查工具会更直接。
A1.6.1 valgrind 什么时候值得装
make memcheck 依赖的就是 valgrind。它不是主线刚需,但在以下场景非常值:
- 程序偶尔崩,不容易稳定复现;
- GDB 只能看到“已经炸了”,却看不出“在哪里先写坏了内存”;
- 你怀疑有泄漏,但逻辑测试还能过。
常见安装方式:
Debian / Ubuntu
sudo apt update && sudo apt install -y valgrind
Fedora / RHEL
sudo dnf install valgrind
最小用法:
valgrind --leak-check=full --show-leak-kinds=all ./step0
如果你只是想先判断“有没有明显泄漏”,最值得盯住的是最后两行:
definitely lost: 0 bytes in 0 blocks
indirectly lost: 0 bytes in 0 blocks
不是 0,就说明该回去查分配和释放路径了。
A1.6.2 Apple Silicon 上为什么要改用 ASAN
在 macOS,尤其是 Apple Silicon 上,valgrind 的可用性很差,很多同学会在这里白白耗时间。更务实的做法是直接改用 AddressSanitizer。
make CFLAGS="-Wall -Wextra -O1 -g -fsanitize=address -fno-omit-frame-pointer -std=c99"
./step0
它的核心价值和 valgrind 类似:不是帮你“证明逻辑正确”,而是帮你把隐藏的内存踩踏更早暴露出来。
需要注意的是,ASAN 不是套在现有二进制外面跑的工具,而是需要你重新编译。也就是说,当你切换到 ASAN 调试时,默认的 -O2 构建结果已经不再是你要观察的对象。
A1.6.3 调试时为什么建议关优化
无论你用 GDB 还是 ASAN,调试时都建议把优化等级降下来:
make CFLAGS="-Wall -Wextra -O0 -g -std=c99"
理由很直接:-O2 会重排、内联、寄存器化一些变量,导致你在调试器里看到 <optimized out>,或者行号和你脑中的执行顺序不再完全对应。主线跑实验时用 -O2 没问题;定位 bug 时,先追求可观察性。
A1.7 一句话回查索引
如果你已经做完主线某一章,只是临时回来补一个洞,按下面这张“速查索引”跳转即可。
- 想确认机器和工具链是否在支持范围里:看 A1.1
- 指针、结构体、
malloc/free又绕晕了:看 A1.2 - tokenizer / BPE 卡在字符和数字映射上:看 A1.3
- 总在终端里走错目录或忘命令:看 A1.4
- 程序崩了、结果不对、需要停下来观察:看 A1.5
- 怀疑内存问题、泄漏或越界:看 A1.6
本附录的最好使用结果,不是你把它背下来,而是你每次只回来读一小节,然后立刻回到对应 lab 继续做事。