我想研究TinyCC,最主要的原因是它小,感觉自己能读得下去。打开源码以后,还是会遇到一个很大的结构体:TCCState。里面有头文件路径、库路径、用户选项,也有编译过程中留下的信息。
如果从第一个字段开始逐个看,很容易知道名字,却不知道它们怎样一起工作。我先挑一个自己用过的选项:-I。它告诉编译器去哪里找头文件,这个目录到底怎样留到后面使用?
同名头文件,两个目录都能找到
我准备了两个目录,都放一个叫blog_choice.h的文件。A目录定义HEADER_VALUE=11,B目录定义HEADER_VALUE=21。程序把它与命令行宏提供的3、库函数返回的5相加。
只交换两个目录的顺序,实际结果是这样:
| 目录顺序 | 编译 | 运行输出 |
|---|---|---|
| -Iinclude-a -Iinclude-b | 成功 | value=19 |
| -Iinclude-b -Iinclude-a | 成功 | value=29 |
19是11+3+5,29是21+3+5。其他输入没变,差别来自选中了不同的头文件。完整程序和可执行命令放在附录,可以自己换一下顺序。
这次用的是#include <blog_choice.h>。我们先跟着这一种包含方式走,不把双引号、绝对路径和include_next一起塞进来。
-I读进来以后,先放在哪里?
参数解析遇到-I,会调用tcc_add_include_path。它的函数体很短:
{ tcc_split_path(s, &s->include_paths, &s->nb_include_paths, pathname); return 0; }这儿没有打开blog_choice.h,连文件名都没有传进来。传的是目录pathname,以及状态结构体中两个成员的地址。对应源码
include_paths指向一组路径指针,nb_include_paths表示其中有效的路径数量。先加A、再加B,和这次查找有关的内容就像下面这样:
include_paths[0] -> "include-a" include_paths[1] -> "include-b" nb_include_paths = 2这里的2是有效数量,不是结构体大小,也不是数组容量。两条路径的字符没有直接塞进TCCState,而是通过指针接到外面分配的存储。
现在还要确认:A、B真的按这个顺序放进去了吗?第二次添加时,哪里发生了变化?
加第二条路径,不会覆盖第一条吗?
继续进tcc_split_path,能看到它把解析出的路径交给追加函数:
dynarray_add(p_ary, p_nb_ary, tcc_strdup(str.data));再看dynarray_add末尾,动作是先在下标nb处放入新指针,nb加1,再把数量写回。追加函数、路径拆分
对于这两条用户目录,第一次nb是0,A放在[0],数量变成1;第二次nb是1,B放在[1],数量变成2。不是每次都写[0],所以前面的路径不会被后面的覆盖。
先看只有A的状态。include_paths保存的是数组的地址,[0]里另存着字符串的地址。这是两次沿指针找东西:先到数组,再从[0]找到字符。
再加B时,[0]仍然能找到A,新路径放到[1],计数变成2。两条字符串分别有自己的存储,不是把A那份字符改成B。
图只展开这两条用户目录的有效元素,不画备用容量,也不是实际内存地址快照。两个图里把数组放在同一位置便于对照,真实追加时重分配可能让数组地址改变;不能把图上的位置当成地址不会变。
到这里,也能回答前面的函数为什么传两个成员的地址。追加时,计数要改;空间不够时还要重新分配指针数组,数组地址也可能变。辅助函数需要把这两种变化写回TCCState,不能只改一份局部副本。
那句tcc_strdup先留着,稍后看。先去找谁会读取刚放好的A、B。
到了include,怎样从A走到B?
处理包含文件的代码在tccpp.c。搜索循环中,目录列表这一支是:
int j = i - 2, k = j - s1->nb_include_paths; path = k < 0 ? s1->include_paths[j] : s1->sysinclude_paths[k]; pstrcpy(buf1, sizeof(buf1), path); pstrcat(buf1, sizeof(buf1), "/");这两个减法为什么要这样写?看外围循环才知道,i的前两个位置另有用途:i=0检查绝对路径;i=1处理双引号包含时的当前文件目录。我们的文件名不是绝对路径,用的又是尖括号,这两处会跳过,到了i=2才开始读目录列表。循环与打开文件的完整上下文
所以减2,是把循环位置转换成目录下标。再减用户目录数量,是为了判断是否已经越过用户目录、进入系统目录。
先按A在前、B在后,算前三个目录位置:
| i | j=i-2 | k=j-2 | 读哪一项 |
|---|---|---|---|
| 2 | 0 | -2 | 用户目录[0]:A |
| 3 | 1 | -1 | 用户目录[1]:B |
| 4 | 2 | 0 | 系统目录[0] |
最后一行还要求系统目录列表非空,循环才会走到这个位置。我们这次找到A就结束,不会真的走遍这三行。
代码把取出的目录复制到buf1,接上斜杠,再接文件名。第一次尝试的就是:
include-a/blog_choice.h后面tcc_open若失败,会continue去试下一项;成功则进入这个头文件的处理,跳出当前搜索,不会再打开B把A的定义替换掉。这个区别很重要:不是把两个同名头文件都读完,再挑其中一个。
现在把参数换成B在前,追加后的[0]变成B。相同的i=2、j=0,这次拼出来的是include-b/blog_choice.h,于是用到21,运行结果变成29。
命令行里的先后顺序,就这样经过“追加到列表末尾”和“从列表前面查找”留到了最终结果里。两次都能编译成功并不奇怪,因为两份头文件都满足程序的需要;成功退出本身区分不了选的是哪一份。
回头看:为什么还要复制路径字符串?
刚才追加时用了tcc_strdup(str.data),没有直接把str.data放进数组。
str是拆分路径时的临时字符串,添加完这一项就会被清理。数组如果只留下这个临时缓冲区的地址,等后面处理include再来读取,就不能指望那份字符还有效。
因此要复制一份。数组元素保存副本的地址,临时字符串按自己的流程清理,预处理器以后仍然能读到路径。
不过,复制出来的字符串不会凭空消失。继续找状态清理函数tcc_delete,会看到:
dynarray_reset(&s1->include_paths, &s1->nb_include_paths);dynarray_reset先逐项释放路径字符串,再释放装这些地址的数组,并把数组指针置空,计数降到0。状态清理、数组清理实现
对应前面的图,就是先释放右边的A、B字符串,再释放左边的指针数组。只释放数组,会丢掉找到那两份字符串的入口,并没有释放字符串本身。
再去看那个大结构体
现在认识include_paths,不只是知道“这里放头文件路径”。我们已经知道谁追加它,数量怎样变,搜索时从哪一项开始,以及路径为什么能留到搜索阶段、最后由谁释放。
这也是我想从TinyCC开始读编译器的原因:能从熟悉的小选项出发,沿几段函数把一个过程走完,再扩大范围,而不是先背下整个结构体。TCCState定义
下一项可以看库路径,但别直接把刚才的行为照搬过去:-L目录增加搜索位置,-l名字要求加入一个库,library_paths不是把库文件内容存进去的数组。-D又会涉及宏与符号处理,不是改一个整数成员就结束。
本文源码固定在release_0_9_27对应提交,CLI实验使用已有的Windows版TinyCC 0.9.27,没有从这个提交重建二进制。前面字段和函数关系来自源码,19/29来自实际运行,不把CLI输出说成直接观察到了状态内存。完整命令见下面,版本和其他边界放在最后。
附录一:完整源码与成功对照
不需要拿一个大项目猜。我准备一个程序,让三项输入都影响最终结果:头文件提供一个值,命令行宏提供一个值,静态库函数提供一个值。
在一个新建、可丢弃的实验目录中准备四个源文件。只运行下面的自有固定程序,不使用不可信C代码。需要已安装的TinyCC 0.9.27 x86_64 Windows与PowerShell;以下命令块在同一个窗口按顺序执行。
目录结构:
实验目录/ include-a/blog_choice.h include-b/blog_choice.h main.c answer.c lib/ 命令会创建include-a/blog_choice.h:
#define HEADER_VALUE 11include-b/blog_choice.h:
#define HEADER_VALUE 21answer.c:
int blog_answer(void) { return 5; }main.c:
#include <stdio.h> #include <blog_choice.h> #ifndef USER_VALUE #error USER_VALUE must be supplied #endif int blog_answer(void); int main(void) { printf("value=%d\n", HEADER_VALUE + USER_VALUE + blog_answer()); return 0; }在上述实验目录执行,确保tcc已在PATH;不要在现有项目根目录运行。本实验会创建同名对象、库和exe文件,不改系统路径或编译器全局配置。
$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false $tcc = (Get-Command tcc -CommandType Application | Select-Object -First 1).Source function T { & $tcc @args; if ($LASTEXITCODE -ne 0) { throw "tcc failed: $args" } } New-Item -ItemType Directory lib | Out-Null T -c answer.c -o answer.o T -ar rcs lib/libblogstate.a answer.o T -Iinclude-a -DUSER_VALUE=3 -Llib main.c -lblogstate -o baseline.exe $output = & ./baseline.exe if ($LASTEXITCODE -ne 0 -or $output -ne 'value=19') { throw 'baseline mismatch' } $output这一轮的结果是value=19:11+3+5。我们同时检查输出和进程退出码,不能仅凭“成功生成exe”认定程序行为正确。
接着一次只改一个选项:
T -Iinclude-b -DUSER_VALUE=3 -Llib main.c -lblogstate -o header.exe $output = & ./header.exe if ($LASTEXITCODE -ne 0 -or $output -ne 'value=29') { throw 'header path mismatch' } $output T -Iinclude-a -DUSER_VALUE=7 -Llib main.c -lblogstate -o macro.exe $output = & ./macro.exe if ($LASTEXITCODE -ne 0 -or $output -ne 'value=23') { throw 'macro mismatch' } $output| 对照 | 只改变什么 | 本轮输出 |
|---|---|---|
| 基准 | 头文件11、宏3、库函数5 | value=19 |
| 更换-I | 头文件由11变21 | value=29 |
| 更换-D | 宏由3变7 | value=23 |
这说明选项改变了可观察的编译结果。但它不是直接观察TCCState内存的实验;具体经过哪些字段和函数,需要前面的源码阅读来解释。尤其不能把-D简化成“给结构体某个整数成员赋值”:宏定义处理还有自己的符号和预处理逻辑。
两个-I都有同名头文件,会选哪个?
这次不换文件内容,只交换两个搜索目录的顺序:
T -Iinclude-a -Iinclude-b -DUSER_VALUE=3 -Llib main.c -lblogstate -o ab.exe $output = & ./ab.exe if ($LASTEXITCODE -ne 0 -or $output -ne 'value=19') { throw 'A before B mismatch' } "A before B: $output" T -Iinclude-b -Iinclude-a -DUSER_VALUE=3 -Llib main.c -lblogstate -o ba.exe $output = & ./ba.exe if ($LASTEXITCODE -ne 0 -or $output -ne 'value=29') { throw 'B before A mismatch' } "B before A: $output"A在前得到19,B在前得到29。在这组使用尖括号、自有头文件且两个目录都有同名文件的实验中,先搜索的目录决定选中哪一份头文件。路径列表不仅保存“有哪些目录”,还保留影响查找的顺序。
两次编译都成功,却用了不同的文件。这也解释了为什么“能编译”不是路径配置正确的充分证据:还要检查实际选中的内容。这里用输出区分内容,不把这个小对照扩大成所有include搜索规则的证明。
附录二:缺少选项时,失败也要符合预期
五个成功对照之外,再分别去掉-I、-D、-L。它们不能都只记成“编译失败”,需要核对对应诊断。
| 去掉什么 | 本轮应出现的诊断 | 为什么检查它 |
|---|---|---|
| -Iinclude-a | blog_choice.h不存在 | 缺的是自有头文件搜索路径 |
| -DUSER_VALUE=3 | USER_VALUE must be supplied | 命中程序主动设置的#error |
| -Llib | blogstate库不存在 | 缺的是实验静态库搜索路径 |
三个对照还必须返回非零退出码。否则,错误文字碰巧出现,也不能算验证通过。下面的失败检查程序依赖前面生成的实验库,需要在同一窗口、同一目录继续执行。
为了避免环境变量暗中补上路径,本轮自动验证移除了子进程环境中的CPATH、C_INCLUDE_PATH和LIBRARY_PATH,在独立目录执行,库使用实验专属名字。它不修改用户的系统环境变量。自行复现若诊断不同,应先记录环境,不把其他失败当作预设失败通过。
前面的表格已分别列出成功结果和失败诊断,不再用一张缩小后的图片重复它们。两个-I的顺序对照见前面的命令和说明;自动验证的完整命令、退出码和stdout/stderr保留在本地证据中。
在前面成功对照的同一个PowerShell窗口、同一个实验目录执行下面的程序。它复用$tcc,并依赖已经生成的实验库。
function ExpectFailure { param([string]$diagnostic, [string[]]$compilerArgs) $saved = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' $messages = & $tcc @compilerArgs 2>&1 $code = $LASTEXITCODE } finally { $ErrorActionPreference = $saved } if ($code -eq 0 -or (($messages | Out-String) -notmatch $diagnostic)) { throw 'expected failure or diagnostic missing' } $messages | Out-String } ExpectFailure "blog_choice\.h.*not found" @('-DUSER_VALUE=3','-Llib','main.c','-lblogstate','-o','no-include.exe') ExpectFailure 'USER_VALUE must be supplied' @('-Iinclude-a','-Llib','main.c','-lblogstate','-o','no-macro.exe') ExpectFailure "library.*blogstate.*not found" @('-Iinclude-a','-DUSER_VALUE=3','main.c','-lblogstate','-o','no-library.exe')失败调用不用成功实验的T包装:我们需要保存非零退出并检查错误,而不是遇到预期失败就中断整个实验。临时调整PowerShell错误偏好是为了收集原生命令的stderr,结束后立即恢复;不是把所有错误忽略。退出码和对应诊断有一个不符合,就让验证程序报错。
版本、验证边界与相关阅读
本文源码固定为TinyCC的release_0_9_27,提交d348a9a51d32cece842b7885d27a411436d7887b;实验使用已有的TinyCC 0.9.27 x86_64 Windows,没有从该提交重建二进制。因此源码阅读与CLI实测分开陈述,不声称构建逐字节对应。
自动验证从正文提取源文件和命令,检查五个成功结果、三个预期失败;还故意改错基准预期值、交换搜索顺序但保留原预期,确认检查程序会在对应断言处报错,而不是只会输出“通过”。
CLI实验没有直接观察TCCState内存,也没有测试libtcc宿主、并发或其他后端。这个版本仍有全局tcc_state,不能凭API接收上下文指针便断言线程安全;它也保留inline函数token列表,不能把单趟编译理解成任何内容都只访问一次。这些问题值得另写一篇,不在这里扩成未经验证的结论。
已有《Tinyc编译器(翻译)》承担手册查询;《tcc -run不是解释C》展开内存编译执行。这篇的重点,是从一个结果差异追到配置的保存、使用和释放。