
1. 事情的起因Modules 代码在编辑器里全面失灵大概一年前我开始用 C20 Modules 重写一个内部的算法库。当时被各种宣传打动模块化编译可以显著缩短构建时间、告别头文件顺序依赖、把接口和实现之间的逻辑边界变成真正语法层面的边界。最初几个星期确实很爽export module calc;一写import calc;一引编译速度肉眼可见地提升构建系统也比以前干净了许多。但很快我遇到了一个让整个开发体验倒退十年的问题代码编辑器彻底不认识我的项目了。我用的是 VS Code clangd 组合对传统 C 项目来说这套组合基本是无敌的跳转、补全、引用查找都非常准。可一旦源文件顶部出现export module或import xxx;编辑器就开始胡言乱语。定义跳转直接失效补全基本靠猜semantic highlight消失了甚至偶尔还会崩掉几个语言服务器插件。那段日子我几乎是被迫回到了看系统日志找错字的状态。更麻烦的是这跟写不写注释、项目组织得好不好完全无关是工具链在语义层面已经跟不上了。于是我做了一个比较冲动的决定自己写一个支持 C Modules 的 language server。这个项目断断续续做了大半年期间踩了无数坑也把 Modules 在编译语义层面对编辑器的要求摸了个遍。这篇文章想把整个设计思路和踩坑过程完整记录下来对于想做类似工具、或者正在用 Modules 但嫌弃编辑器不给力的朋友应该会有不小帮助。先说清楚一件事我做的不是一个能跑就行的玩具目标很明确——在编辑器中实现这四种能力跨模块的符号跳转、模块范围内准确的补全、支持模块可见性语义的引用查找、以及基于模块依赖图的增量式索引更新。后面所有的设计都是围绕这四点展开的。2. 模块语义为什么能让现有语言工具链集体翻车2.1 从文本与预处理到语义与编译产物的范式迁移传统 C 的编辑体验建立在一条很古老的地基上#include本质上是文本拼接。一个.h文件被 include 多少次它的内容就会以文本形式被复制多少份。语言服务器之所以能对传统代码做到近乎完美的补全和跳转正是因为它可以模拟这条文本拼接链路——把 include 展开、把宏替换掉、把预处理后的 token 流塞给解析器拿到的是一个完整翻译单元的 AST。对这个 AST 做分析就能回答编辑器的大部分问题。C Modules 把这条链路彻底改掉了。import foo;不是文本导入而是语义导入。编译器不会把foo的源码内容粘贴到当前位置而是要求预先存在一个已经编译好的模块语义缓存——Clang 里叫.pcmGCC 的模块缓存体系叫 GCMMSVC 则生成.ifc。语言服务器如果还想像以前那样靠解析文本来理解代码它就必须自己把整个项目的模块依赖图找出来、给每个模块接口文件生成对应的语义缓存然后在这个缓存上做查询。这一步直接就把大多数语言服务器挡在了门外。现在很多人提到 C20 Modules 还是停留在新语法的层面实际上它真正颠覆的是工具链的架构分工**编译器不再是读进文本、吐出目标文件的简单管道语义在编译过程中会先被物化成一个中间产物之后的翻译单元直接消费这个产物。**语言服务器想高效工作就必须成为这个语义产物的生产者和消费者而不再只是文本解析器。2.2 Visible 和 Reachable语言服务器必须理解的两套可见性模块引入了一堆传统#include完全没有的概念其中对编辑器影响最大的是 visible可见和 reachable可达的区分。简单说当你import a;时模块a中用export修饰的实体对你是可见visible的可以直接用名字调用但模块a中没有导出的实体只是可达reachable而非可见。所谓可达指的是它们在模板实例化等编译底层机制中仍然参与名字查找但你如果直接在代码里写这些未导出名字编译器会报错。这个设计对实现者有个很微妙的好处内部实现细节不会污染外部接口但又不会破坏模板特化和重载决议需要的副作用。这个区分对语言服务器来说简直是灾难。做查找所有引用功能时如果一个符号属于某个模块的未导出部分它在模块外部只能以可达的方式影响代码而不能被显式引用。到底该不该把那些模板特化中的使用算作引用如果算跳过去的代码读者根本看不懂如果不算又可能漏掉真正优化模板特化逻辑时需要的依据。最终我选择的是对可见实体统计所有真实引用对不可见实体只统计模块内部引用以及模板实例化上下文中的隐式使用并在 UI 上做明确标识。2.3 模块分区和重新导出带来的寻名复杂度模块的另一个核心语法是 partition分区和 re-export重新导出。一个模块可以用export module mylib:math;声明数学分区然后在主模块接口里写export import :math;把分区重新导出。此外还有export import other_module;这样把别人引入再导出的写法。这套机制导致一个符号的真实所属模块和用户看到的模块名可以完全不一致用户写的是import mylib;但符号定义在mylib:math分区里甚至可能在other_module里被mylib间接带出来。任何想支持 Modules 的语言服务器都必须维护一张模块名到物理文件以及模块分区名到模块接口文件的映射表并在每一个编译单元里跟踪整个导入闭包。做跳转时要从当前符号往模块图里回溯找到真正定义它的那个模块接口文件做补全时要把当前模块的所有导出实体、被 re-export 的实体、以及可达模块中的可发现实体都合并进候选集。细节数量级比之前 include 时代膨胀了至少一个维度。3. 具体实现怎么让 Language Server 真正理解模块3.1 不自己造编译器前端而是做一个编译器前端的客户端我最早犯过的错是试图自己解析模块声明、索引模块结构。只要稍微深入一点就会发现Modules 的语义远比 AST 长得复杂——判断一个名字是否可见依赖模块的导出声明集合判断一个模板实例化是否会依赖某个不可见实体需要模拟编译器的查找规则。这些逻辑如果自己实现等于重新写一个 C 编译器前端。所以项目从第二周开始就被我重构为**直接复用真实编译器前端来生成语义缓存语言服务器自己只负责管理依赖图、缓存生命周期、以及把语义缓存里的数据翻译成 LSP 响应。**我选择了 Clang 作为前端因为 Clang 的 libclang 和 Clangd 基础设施相对更容易嵌入到自定义工具里而且.pcm模块缓存可以直接通过编译命令生成。具体流程如下读取项目的compile_commands.json找到所有编译单元和它们的编译参数。扫描其中用于模块接口编译的命令-x c-module或等价参数提取模块名。为每个模块接口文件执行一次预编译操作生成.pcm语义缓存同时记录缓存与源文件、编译选项的对应关系。用户在编辑器中打开或修改文件时根据 LSP 请求决定是直接用当前文件近似的 AST 快速响应还是先确保相关.pcm缓存是最新版本再返回语义级别的结果。这套架构最大的好处是语义正确性完全由 Clang 保证我不需要去猜某个符号到底可见不可见。坏处也有就是每次模块接口变动后缓存重建可能需要数百毫秒甚至更久必须仔细设计后台任务优先级和缓存失效策略。# 典型的模块接口编译命令Clang 前端 clang -stdc20 -x c-module --precompile calc.cppm -o calc.pcm # 使用该模块的普通翻译单元 clang -stdc20 -fmodule-filecalc.pcm main.cpp -c -o main.o3.2 模块依赖图是语言服务器的主干数据结构我实现里最重要的数据结构不是 AST也不是符号表而是一张模块依赖图Module Dependency Graph。图中每个节点对应一个模块接口文件或模块分区每条边表示一个import依赖关系。整个项目的所有语义缓存都挂在这张图上节点版本号每个.pcm内容哈希一旦变化依赖它的下游节点版本全部失效。为什么必须靠这个图而不是传统的文件时间戳因为一个模块接口文件即使在文本层面没变但如果它依赖的另一个模块变了它的语义缓存其实也已经过期了。举个例子calc.cppm写了import helper;helper.cppm里改了一个函数模板的实现并重新编译生成新的helper.pcm。此时calc.cppm虽然内容一个字没改但它的.pcm语义缓存里已经蕴含了旧helper.pcm的信息必须重建。这种传递失效如果不靠依赖图只靠 mtime 判断一定会漏掉大量更新然后编辑器里就会出现各种改了代码但补全还是旧的诡异现象。我在实际实现里给每个缓存记录两个内容源文件的SHA256哈希以及所有直接依赖模块的缓存版本号。计算出新哈希后先跟源文件比较再沿依赖图逐层比较。只要有任何一层的版本号不一致就从该节点往下重算并同时更新所有下游节点。这套机制跟构建系统里的增量编译逻辑很像区别在于它专门服务于编辑器的语义查询。3.3 LSP 层面对模块语义的翻译标准 LSP 本身并不认识模块它只有textDocument/definition、textDocument/completion、textDocument/references这些通用能力。所以我需要在标准协议的基础上做一层语义翻译。比如补全传统 C 的补全可以基于当前文件作用域 include 头文件的类型声明来暴力收集候选符号。但模块化代码的候选符号必须遵循可见性规则。我的实现里补全请求会先定位当前文件所属的模块节点然后在节点对应的模块语义缓存里展开完整的导出实体集合再叠加 import 进来的其他模块的可见实体。对于每个候选还会额外返回一个到达路径——比如mylib:math分区里的sin到达路径是mylib - mylib:math的 re-export 链。再比如跳转定义当用户对一个从模块导入的符号请求定义时我会先查这个符号属于哪个模块分区再找到那个分区的模块接口文件定位到其源码位置。如果符号是export import二次引入的这个定位过程还要跨层追溯。听起来不复杂但真正写到实现里每一步都要处理大量边界情况符号是模板特化吗是依赖模板参数的隐式实例化吗模块文件有增量编译导致的源码区间偏移吗这些细节我用一个专门的ModuleLocator组件统一处理避免把逻辑散落在各种 handler 里。4. 踩坑记录四个几乎劝退我的难题4.1 BMI 缓存的间接失效文字没变语义已旧前面提到了缓存失效策略但在现实的编辑器场景里这个问题比理论上更恶毒。我记得有一次我在constants.cppm里改了一个常量值这个模块被十几个下游模块直接或间接引用。改动后VS Code 里的大多数补全都更新了唯独某个深层的函数模板补全结果还是旧的。原因是那个函数模板所在的模块并不直接 importconstants而是通过另一个中间模块间接依赖的。我的早期版本只检查了直接的导入关系导致中间模块的缓存版本没有传递刷新深层模块的语义索引就会继续引用旧缓存。解法其实也简单每次缓存重建完成后除了更新节点自身还要立刻把所有下游可达节点的版本号标记为 stale让编辑器在下一个请求到来前优先重建。这一步说起来轻松但它要求语言服务器必须真正理解整张模块依赖图的拓扑排序而不能像 clangd 处理传统 include 那样按文件粒度去碰运气。另外一个隐藏点.pcm文件跟编译器参数强关联。如果某一天用户改了编译标准比如从-stdc20改成-stdc23前面生成的.pcm全部作废但编译命令文件的 mtime 可能没有变化。我把编译器主要选项也纳入缓存 key 的哈希计算才彻底解决这个问题。4.2 标准模块的适配import std;在不同编译器的同词不同义C23 开始有import std;这种标准库模块的一键导入语法但在 2024 年的现实世界不同编译器对它的支持程度和底层映射完全不同。Clang 需要额外的标准库模块映射文件GCC 需要-fmodules-ts加上实验性的头文件单元扫描MSVC 则是把.ifc打包在安装目录里。同一个import std;在不同工具链下面是完全不同的产物这给语言服务器的通用性带来了巨大麻烦。我给项目做了一层标准模块适配层针对每种编译器前端单独配置一份标准模块清单核心逻辑判断当前compile_commands.json里的编译器类型再决定去哪个路径下查找标准模块的语义缓存。如果找得到就把这些缓存登记进模块依赖图的根部如果找不到我会向 LSP 客户端返回自定义的诊断消息提示用户先配置标准模块位置而不是让编辑器静默崩溃。这层适配让我意识到一个问题C Modules 的标准化还远未完成工具链层面的割裂比标准文档里写得更痛。语言服务器如果想真正面向普通用户这个适配层就不能是临时补丁而必须是一等公民。4.3 编辑器交互的延迟构建语义缓存不能阻塞 UI最初几版我把textDocument/completion请求直接挂在模块缓存生成后面结果就是每次补全都要等一两秒。在小型测试项目里还能接受但到了几百个模块的真实项目里这个延迟完全没法用。后来我改成异步流水线用户输入时先立刻用一个快速的词法级别的近似索引返回一部分候选符号保证编辑器不会空白。同时把当前文件关联的模块节点标记为 pending触发后台线程重建它以及所有下游依赖的语义缓存。重建完成后再主动向 LSP 客户端推送一次semantic tokens 刷新让补全结果自动更新。这套机制的难点在于管理后台任务的优先级。我设了一个简单的调度策略当前编辑文件所在模块节点的重建优先级最高直接依赖它的节点第二其余按依赖图深度依次降低。由于模块图天然是 DAG这个调度可以用拓扑排序轻松实现。实测下来编辑器首屏补全响应可以压到 100 毫秒以内完整语义补全则能在 0.5 到 1 秒内追上来。4.4 和构建系统的集成CMake 的 P1689 扫描是个老坑这个语言服务器的最后一块拼图是让用户不需要手动配置模块映射。因为目标群体基本都用 CMake我最初直接读取compile_commands.json来推断模块关系。但 CMake 在开启CXX_MODULE_UNITS特性的项目里编译命令本身并不直接包含模块到.pcm的映射关系而是依赖 CMake 生成的 P1689 扫描文件.p1689.json来告知构建系统哪个文件是哪个模块。构建时编译器会看着这份扫描文件来决定模块编译顺序。我在集成这个文件格式时被一个细节折磨了很久早期某些 CMake 版本生成的 P1689 文件格式跟后来的 4.x 版本略有差异字段从粗分类改成更细的类型。特别是当我试图把一个老项目的compile_commands.json跟新生成的 P1689 合并时经常触发类似网上经常看到的/usr/share/cmake-4.2/modules/CMakeDetermineCompilerID.cmake:9那一串报错——本质上就是编译器探测阶段没找到可用的 C 编译器路径而后续的模块扫描又依赖这个探测结果。排查到最后发现不是我的语言服务器逻辑有问题而是 CMake 版本升级后在模块编译器探测顺序上发生了变化。解决办法是在构建系统集成代码里加了一层兼容逻辑P1689 文件缺失时回退到直接扫描源文件一级的export module声明同时把模块名映射结果输出成一份简易映射文件给下游语义索引使用。这样即便构建系统配置不对语言服务器也不会完全停摆。5. 实测表现除补全和跳转之外真正受益的场景5.1 测试项目的性能数字我拿这个语言服务器跑了两个测试项目。第一个是内部算法库包含 46 个模块接口文件、120 多个普通翻译单元总代码量约 8 万行第二个是一个用import std;写的小型演示项目只有 10 个模块但依赖了标准库模块和两个第三方模块。在本地这台 Linux 工作站上冷启动清空全部语义缓存后首次打开项目完成全量模块图构建大约 5.4 秒之后每次打开文件的语义索引命中时间小于 150 毫秒textDocument/definition的平均响应在 40 毫秒左右。把编辑器从 clangd 切到我的 server 前相同项目里 clangd 在传统 include 场景下这些指标大约是 20 到 60 毫秒差距已经缩小到了模块化代码可以接受的程度。但更有意义的指标是崩溃率。之前用 clangd 打开模块项目几乎每半小时就会触发一次客户端里的断言错误要么是索引过期要么是补全结果列表里混入非法符号。换了新 server 的测试周期里我只遇到过一次崩溃还被我复现出来定位到是 P1689 文件解析的越界 bug属于我自身代码的问题而不是模块语义导致的系统性错误。5.2 跨模块安全重构终于变得现实传统重命名在 include 时代其实很脆弱你执行一个符号的全局重命名工具会把所有出现该符号的文本位置全改掉——包括刚刚好同名的局部变量、模板参数甚至注释。模块化代码反而给了工具一个救命优势每个符号都有明确的模块归属和可见性边界。我的 server 在重命名时会把候选引用集合限制在模块语义的合法可见范围内。比如某个符号只在模块mylib:internal里可见那么重命名时绝不会去改动模块外部那些碰巧同名的局部符号。这个能力在传统 C 工具里几乎不可用但在模块化代码里可以做到非常精确。我用测试项目做了一次ExternalAPI类名的跨模块重命名总共 23 处引用工具指示的结果全部命中无一误伤。5.3 对用户排查模块依赖循环的实际帮助Modules 对依赖循环的约束比 include 严格得多但因为模块图可以很复杂用户经常在 import 链的某个暗处绕了一圈自己都没发现。我的语言服务器在生成依赖图时顺便做了环检测一旦发现依赖环路就会通过自定义 LSP diagnostics 给用户返回一条环路链提示比如mylib - mylib:math - mylib:utils - mylib。这个功能后来被很多测试用户反馈为最意外的实用功能因为编译器通常会在一堆底层报错之后才暴露循环而语言服务器能在编辑阶段就把这个信息干净地呈现出来。做一个支持 C Modules 的 language server最核心的认知转变是**它本质上不是文本编辑器插件而是编译器语义缓存的管理系统。**那些漂亮的补全和跳转只是把编译过程中早已生成的语义信息拿出来做可视化而已。这个项目让我重新理解了 C 工具链的演变方向——当语言本身开始把语义物化成编译产物编辑器和编译器之间的边界就注定要被重塑。希望这篇记录能给你一些启发如果你也在设计类似的工具至少知道那些看起来简单、做起来崩溃的坑都在哪里。