附录 A1:工具与前置知识

附录:按需查阅,不必从头读

这份附录不是主线章节的替代品,也不是要求你在 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 是这门课默认假设最完整的平台。大多数时候,只要你有 gccmakegdb,主线就能顺利推进。真正需要留意的,是某些老发行版自带的 gcc 太旧,虽然能编译,却会带来奇怪的性能差异或调试体验差异。

例如 CentOS 7 常见的 gcc 4.8.5,从“能不能跑”角度看是勉强够的,但从“数值代码调试是否顺手”角度看就不理想。如果你明显处在一个偏老的环境里,优先考虑切到更新的 devtoolset,而不是先怀疑课程代码。

macOS

macOS 可以做这门课,但你要先接受一个事实:系统里的 gcc 常常只是 Apple Clang 的别名。对本课程这种纯 C 项目来说,这通常不是问题;但当你开始查版本、看编译器行为差异时,必须知道自己实际用的是谁。

最稳妥的两种做法只有两种:

  1. 全程用 Xcode Command Line Tools 自带的 Clang。
  2. 用 Homebrew 安装真正的 GCC,然后明确调用 gcc-13gcc-14 这类带版本号的命令。

不要来回混用,尤其不要一会儿靠系统 Clang 链接,一会儿又让 Homebrew 的 GCC 接管默认行为。对初学者来说,这种混搭的收益几乎为零,只会放大环境噪声。

Windows

Windows 原生的 PowerShell / cmd 不是本课程承诺支持的实验面。原因不复杂:课程默认使用 makegccgrepgdb 这类 Unix 风格工具,而且很多脚本也按这种环境来写。

如果你必须在 Windows 上做,正确路径是:

Windows -> WSL2 -> Ubuntu/Debian 环境 -> 按 Linux 方式做课程

WSL1 不在支持范围里。不要在 PowerShell 里试图“临时兼容”课程命令,这会浪费很多和模型本身无关的时间。

A1.2 C 指针、结构体与内存配对

很多同学在这门课里真正第一次撞墙的地方,不是 Transformer,而是 student.c 里那些看似普通的 C 代码。因为一旦进入张量、词表、配置结构体、动态字符串这些实现细节,指针指向谁这块内存谁来释放这个结构体字段从哪里来 就会立刻变成工程问题,而不是语法问题。

本节不打算把 C 指针讲成一本教材。它只回答本课程里反复出现的三个问题:

  1. 课程里的指针通常长什么样?
  2. 结构体在这些 lab 里承担什么角色?
  3. 怎样判断一次 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 本身,还要同时看 ndimshapestride 是否自洽。

第三种是“只读输入指针”,常见形态像 const float *inconst 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).ndimpt->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(...);

只要所有权不清,后面一定会演变成三类错误之一:

  1. 泄漏:申请了,但没人负责收。
  2. 二次释放:两个人都以为自己该收。
  3. 悬空指针:已经收掉了,但别人还在继续用。

如果你怀疑自己在某个 lab 里把所有权搞混了,不要只看当前一行代码。最有效的做法是从分配点开始往后追:

git grep -n "malloc\\|calloc\\|realloc" course/practice/framework course/practice/labs

看见一次分配,就问一句:谁最终会负责把它收回去?

A1.3 ASCII 与字符到 token 的最小背景

Chapter 2 和 Chapter 12 看上去一个在做字符级 tokenizer,一个在做 BPE,难度差很多。但它们有一个共同底层:文本最终都得先落到“数字序列”上。只不过前者更直接,后者多了一层子词学习。

初学者经常在这里混淆三个概念:

  1. 字符本身,例如 'A'' '\n
  2. 字节表示,例如 UTF-8 中一个字符可能拆成多个字节;
  3. 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> 查关键词 mallocTODO、函数名
rm -rf build 清理构建目录 怀疑旧目标文件干扰时
make / make test 编译或编译加验证 每章最常用入口
man <cmd> 查手册 忘了 mallocprintf 参数含义时

真正值得养成的是两个习惯,而不是多背几条命令。

第一,每次切目录后都用 pwd 看一眼。这不是啰嗦,而是成本最低的防错动作。miniLLM 里有很多命名相似的目录,step10lab10-step9course/practice/framework 一旦靠记忆切换,很容易弄混。

第二,遇到错误先把第一行完整读完。编译错误最有信息量的地方,经常不是你盯住的 ^ 符号,而是它上面那一整行文件路径和报错类型。

A1.5 GDB 最小调试流

当程序能编译、却在运行时崩掉,或者输出明显不对时,很多初学者第一反应是继续加 printfprintf 确实有用,但它不该是唯一工具。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 继续做事。