我想研究TinyCC最主要的原因是它小感觉自己能读得下去。打开源码以后还是会遇到一个很大的结构体TCCState。里面有头文件路径、库路径、用户选项也有编译过程中留下的信息。如果从第一个字段开始逐个看很容易知道名字却不知道它们怎样一起工作。我先挑一个自己用过的选项-I。它告诉编译器去哪里找头文件这个目录到底怎样留到后面使用同名头文件两个目录都能找到我准备了两个目录都放一个叫blog_choice.h的文件。A目录定义HEADER_VALUE11B目录定义HEADER_VALUE21。程序把它与命令行宏提供的3、库函数返回的5相加。只交换两个目录的顺序实际结果是这样目录顺序编译运行输出-Iinclude-a -Iinclude-b成功value19-Iinclude-b -Iinclude-a成功value2919是113529是2135。其他输入没变差别来自选中了不同的头文件。完整程序和可执行命令放在附录可以自己换一下顺序。这次用的是#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是0A放在[0]数量变成1第二次nb是1B放在[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的前两个位置另有用途i0检查绝对路径i1处理双引号包含时的当前文件目录。我们的文件名不是绝对路径用的又是尖括号这两处会跳过到了i2才开始读目录列表。循环与打开文件的完整上下文所以减2是把循环位置转换成目录下标。再减用户目录数量是为了判断是否已经越过用户目录、进入系统目录。先按A在前、B在后算前三个目录位置iji-2kj-2读哪一项20-2用户目录[0]A31-1用户目录[1]B420系统目录[0]最后一行还要求系统目录列表非空循环才会走到这个位置。我们这次找到A就结束不会真的走遍这三行。代码把取出的目录复制到buf1接上斜杠再接文件名。第一次尝试的就是include-a/blog_choice.h后面tcc_open若失败会continue去试下一项成功则进入这个头文件的处理跳出当前搜索不会再打开B把A的定义替换掉。这个区别很重要不是把两个同名头文件都读完再挑其中一个。现在把参数换成B在前追加后的[0]变成B。相同的i2、j0这次拼出来的是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.cint 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_VALUE3 -Llib main.c -lblogstate -o baseline.exe $output ./baseline.exe if ($LASTEXITCODE -ne 0 -or $output -ne value19) { throw baseline mismatch } $output这一轮的结果是value191135。我们同时检查输出和进程退出码不能仅凭“成功生成exe”认定程序行为正确。接着一次只改一个选项T -Iinclude-b -DUSER_VALUE3 -Llib main.c -lblogstate -o header.exe $output ./header.exe if ($LASTEXITCODE -ne 0 -or $output -ne value29) { throw header path mismatch } $output T -Iinclude-a -DUSER_VALUE7 -Llib main.c -lblogstate -o macro.exe $output ./macro.exe if ($LASTEXITCODE -ne 0 -or $output -ne value23) { throw macro mismatch } $output对照只改变什么本轮输出基准头文件11、宏3、库函数5value19更换-I头文件由11变21value29更换-D宏由3变7value23这说明选项改变了可观察的编译结果。但它不是直接观察TCCState内存的实验具体经过哪些字段和函数需要前面的源码阅读来解释。尤其不能把-D简化成“给结构体某个整数成员赋值”宏定义处理还有自己的符号和预处理逻辑。两个-I都有同名头文件会选哪个这次不换文件内容只交换两个搜索目录的顺序T -Iinclude-a -Iinclude-b -DUSER_VALUE3 -Llib main.c -lblogstate -o ab.exe $output ./ab.exe if ($LASTEXITCODE -ne 0 -or $output -ne value19) { throw A before B mismatch } A before B: $output T -Iinclude-b -Iinclude-a -DUSER_VALUE3 -Llib main.c -lblogstate -o ba.exe $output ./ba.exe if ($LASTEXITCODE -ne 0 -or $output -ne value29) { throw B before A mismatch } B before A: $outputA在前得到19B在前得到29。在这组使用尖括号、自有头文件且两个目录都有同名文件的实验中先搜索的目录决定选中哪一份头文件。路径列表不仅保存“有哪些目录”还保留影响查找的顺序。两次编译都成功却用了不同的文件。这也解释了为什么“能编译”不是路径配置正确的充分证据还要检查实际选中的内容。这里用输出区分内容不把这个小对照扩大成所有include搜索规则的证明。附录二缺少选项时失败也要符合预期五个成功对照之外再分别去掉-I、-D、-L。它们不能都只记成“编译失败”需要核对对应诊断。去掉什么本轮应出现的诊断为什么检查它-Iinclude-ablog_choice.h不存在缺的是自有头文件搜索路径-DUSER_VALUE3USER_VALUE must be supplied命中程序主动设置的#error-Llibblogstate库不存在缺的是实验静态库搜索路径三个对照还必须返回非零退出码。否则错误文字碰巧出现也不能算验证通过。下面的失败检查程序依赖前面生成的实验库需要在同一窗口、同一目录继续执行。为了避免环境变量暗中补上路径本轮自动验证移除了子进程环境中的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 21 $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_VALUE3,-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_VALUE3,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》展开内存编译执行。这篇的重点是从一个结果差异追到配置的保存、使用和释放。