如何用Rust写一个C预处理器:claudes-c-compiler宏展开与#include处理完整解析
【免费下载链接】claudes-c-compilerClaude Opus 4.6 wrote a dependency-free C compiler in Rust, with backends targeting x86 (64- and 32-bit), ARM, and RISC-V, capable of compiling a booting Linux kernel.项目地址: https://gitcode.com/gh_mirrors/cl/claudes-c-compiler
你是否好奇一个零依赖的 C 编译器是如何实现预处理器的?claudes-c-compiler(CCC)是一个完全用 Rust 编写的 C 编译器,由 Claude Opus 4.6 从零实现,目标覆盖 x86-64、i686、AArch64 和 RISC-V 64,甚至能编译启动 Linux 内核。它的前端第一步就是预处理器——负责宏展开、#include文件包含与条件编译。本文带你拆解它的核心设计:为什么选择"纯文本"处理、函数式宏的五步展开算法、如何防止宏无限递归,以及 GCC 兼容的#include搜索策略。
一、预处理器在编译流水线中的位置
CCC 的前端是一条清晰的四级流水线(详见 src/frontend/README.md):
.c 源码 → 1.预处理器 → 2.词法分析 → 3.语法解析 → 4.语义分析 → IR (本文主角)预处理器是一个**文本到文本(text-to-text)**的转换阶段:输入原始 C 源码,输出已完成宏展开、并嵌入了行标记(# 行号 "文件名")的字符串,供后续词法分析使用。整个模块只有 11 个文件、约 6000 行 Rust 代码,但行为对标 GCC/Clang,这是它最"硬核"的部分。
💡 一个冷知识:它对外宣称自己是 GCC 14.2.0,这样 Linux 内核、glibc 等项目的版本检测宏都能顺利通过。
二、设计哲学:操作原始文本,而不是词法 Token
大多数编译器教程会教你"先做词法分析,再展开宏",但 CCC 反其道而行:预处理器直接操作原始字节切片(&[u8]),根本不生成 Token。
这个看似"偷懒"的选择有三个实际好处:
- 简单——不需要同时处理展开前/展开后两套 Token 文法;
- 快——C 预处理 Token 都是 ASCII,字节扫描避免了大量内存分配;
- 兼容——输出格式与 GCC/Clang 的
-E输出一致,是一个带行标记的扁平字符串。
入口函数preprocess()位于 pipeline.rs,其内部按 C11 标准规定的翻译阶段依次执行:
| 阶段 | 做什么 | 实现位置 |
|---|---|---|
| 阶段 2 | 行拼接(反斜杠+换行合并) | text_processing.rs |
| 阶段 3 | 块注释替换为单个空格、删除行注释 | text_processing.rs |
| 阶段 4 | 逐行处理指令 + 宏展开 | pipeline.rs |
注意顺序:必须先拼接续行、再去注释,否则跨行的注释会破坏行号映射。
三、宏展开:一个MacroDef搞定对象式与函数式宏
所有宏定义统一存储在一个FxHashMap<String, MacroDef>宏表中(定义见 macro_defs.rs)。每个MacroDef记录:宏名、是否函数式、参数列表、是否变参、替换体文本。
3.1 对象式宏:替换 + 重扫描
最简单的情况,比如#define VERSION 42:每次在指令外文本中出现VERSION就替换成42,然后对结果重新扫描继续展开——因为替换体里可能还有别的宏。
3.2 函数式宏:C11 五步展开算法
函数式宏的展开严格遵循 C11 §6.10.3 的算法,核心流程在expand_function_macro()(macro_defs.rs):
- 收集实参——
parse_macro_args()从左括号匹配到平衡的右括号,只在深度 0处按逗号切分;字符串和字符字面量内的逗号会被跳过; - 实参预展开——每个实参先完整展开,但紧邻
#或##操作符的实参保持原始文本; - 字符串化与拼接——处理
#param(加引号并转义)和a ## b(Token 粘贴); - 参数替换——把替换体中的参数名换成实参文本;
- 重扫描——再次展开替换体,防止嵌套宏。
举个例子,这个常见模式就能正确处理:
#define DBG(fmt, ...) fprintf(stderr, fmt, ## __VA_ARGS__) DBG("hello") // 展开为: fprintf(stderr, "hello")这里的, ## __VA_ARGS__是 GNU 扩展:当变参为空时自动吞掉逗号,__VA_ARGS__收集最后一个具名参数之后的所有实参。
3.3 两个隐藏难点:防递归与防误拼接
蓝色涂漆(blue-paint):C11 规定宏展开过程中不能再展开它自己。CCC 用一个"正在展开"的集合追踪当前栈上的宏,命中者被加上一个不可见于合法 C 源码的单字节哨兵前缀(0x01),重扫描时就不会再被识别为宏,输出前统一剥除(macro_defs.rs)。
防误拼接(anti-paste guard):##可能把两个/粘成//注释、两个+粘成++。would_paste_tokens会在相邻的危险 Token 之间插入保护性空格。
还有两个贴心的细节:
- 多行实参累积——当一行括号不配对(
(多于)),后续行会累积到pending_line缓冲,直到括号平衡,支持实参跨多行的宏调用; - 行内状态复用——"正在展开"哈希集只分配一次,每行清空复用(
expand_line_reuse),避免大文件中逐行分配的开销。
四、条件编译:#if表达式是怎么求值的
#if/#ifdef/#elif/#else/#endif由 conditionals.rs 中的ConditionalStack状态机管理。每条入栈记录跟踪三件事:是否有分支已被选中、当前分支是否激活、父上下文是否激活(处理嵌套)。不激活分支内的行被替换为空行而非删除,这样行号始终与源码一致——错误信息才能准确定位。
#if后的表达式走一条五步流水线:
defined(X)、__has_builtin(X)、__has_include(X)等运算符先被替换成1或0(expr_eval.rs);- 表达式整体做宏展开;
- 再跑一遍第 1 步——因为宏展开可能新引入
__has_*()调用; - 剩余标识符按 C 标准替换为
0(true/false除外); - 递归下降求值,支持完整的运算符优先级:位运算、移位、关系、逻辑、三元。
按 C99 §6.10.1,整型求值自动在intmax_t/uintmax_t之间切换:只要有操作数带U后缀就走无符号路径。
五、#include处理:GCC 兼容的搜索与防递归
#include的完整实现约 800 行,核心是handle_include()(includes.rs)。
5.1 搜索顺序
引号包含和尖括号包含的搜索路径不同,与 GCC 完全一致:
| 顺序 | #include "file.h" | #include <file.h> |
|---|---|---|
| 1 | 当前文件所在目录 | -I路径 |
| 2 | 原始源文件目录 | -isystem路径 |
| 3 | -iquote路径 | 默认系统路径 |
| 4 | -I路径 | -idirafter路径 |
| 5+ | -isystem、系统默认路径 | — |
几个值得注意的工程细节:
- 结果缓存——以
(包含路径, 是否系统头, 当前目录)为键缓存解析结果,避免对同一文件反复stat(); - 不解析符号链接——路径绝对化刻意不用
canonicalize而用make_absolute,因为 GCC 的"..."包含是相对于符号链接所在位置查找的; - 计算型包含——
#include HEADER_MACRO这种不带引号/尖括号的写法会先做宏展开,再剥掉展开产物中可能混入的空格。
5.2 防重复与防递归
头文件被重复包含是性能杀手,CCC 用了三层防护:
#pragma once——标记过的文件后续包含直接返回空;- Include guard 探测——预处理前扫描原始源码的经典保护模式(
#ifndef GUARD+#define GUARD+#endif),若守卫宏仍被定义则整文件跳过,这是 GCC/Clang 同款优化; - 递归深度上限 200——只限制同一文件的过度嵌套,没有 pragma/guard 的文件仍可被有意重入。
5.3 没有系统头也能编译?
这是 CCC 最巧妙的设计之一:builtin_macros.rs 内置了<limits.h>、<stdint.h>、<stddef.h>、<stdbool.h>等标准头对应的内置宏(INT_MAX、NULL、offsetof、true/false……)。当磁盘上找不到真实头文件(交叉编译、无 sysroot 场景)时注入最小回退声明,让编译继续;若项目自带头文件(如 musl、dietlibc),回退则自动让位。这就是"零外部工具链"承诺的一部分。
六、性能与兼容性:小细节见真章
想写一个能编译 Linux 内核的预处理器,光正确还不够,还要快、还要兼容。CCC 的几个关键手法:
Cow<str>短路——#字符串化和注释剥离等函数在无工作可做时直接返回借用切片,零分配;__LINE__/__COUNTER__不进宏表——存在Cell<usize>里按需展开,避免逐行创建宏定义对象;__FILE__原地更新——#include切换文件时只改现有条目的值,不重新插入;- 预定义宏静态表——predefined_macros.rs 提供
__SIZEOF_*__、__INT_MAX__、__FLT_MAX__等数百个平台宏,set_target()可一键切换 aarch64/riscv64/i686/i386 架构。
七、代码导航:按功能找到对应文件
预处理器模块结构一目了然(模块声明见 mod.rs):
| 文件 | 职责 |
|---|---|
| pipeline.rs | 主循环:preprocess()、指令分发、多行累积 |
| macro_defs.rs | 宏表、五步展开、字符串化/粘贴、防递归 |
| conditionals.rs | 条件栈状态机 +#if递归下降求值器 |
| expr_eval.rs | defined()/__has_include()解析、未定义标识符归零 |
| includes.rs | 路径解析、guard 探测、深度限制、内置头注入 |
| predefined_macros.rs | 平台/架构预定义宏表与set_target() |
| builtin_macros.rs | 标准头替代宏(limits.h/stdint.h等) |
| pragmas.rs | #pragma once/pack/weak等分发 |
| text_processing.rs | 续行拼接、块注释剥离(含行号重映射) |
| utils.rs | 标识符字节分类、字面量跳过等共享工具 |
想深入了解整体架构(IR、优化 pass、四套后端),可以继续读 DESIGN_DOC.md 和 README.md,预处理器的完整设计文档在 src/frontend/preprocessor/README.md。
写在最后
这个预处理器展示了"文本到文本"路线的可行性:没有复杂的 Token 基础设施,仅靠字节切片扫描、Cow短路和精心设计的哨兵字节,就实现了 C11 完整的宏语义与 GCC 级别的包含文件行为——而这正是它能吞下 Linux 内核这种"宏宇宙"的关键。如果你想自己写一个 C 预处理器,建议的实现顺序就是本文的脉络:先文本清洗(续行+注释),再做宏表与重扫描展开,然后是条件栈,最后才是包含解析。掌握这四步,你就拥有了一个真正能跑的 C 预处理器。
【免费下载链接】claudes-c-compilerClaude Opus 4.6 wrote a dependency-free C compiler in Rust, with backends targeting x86 (64- and 32-bit), ARM, and RISC-V, capable of compiling a booting Linux kernel.项目地址: https://gitcode.com/gh_mirrors/cl/claudes-c-compiler
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考