Clang Module 内部实现原理及源码分析

语言: CN / TW / HK

背景

钉钉工程开始支持Swift,在适配clang module的过程中,遇到了各种各样的编译问题,为了弄清楚这些编译失败的真正原因,以及clang module的最佳实践,决定通过深入阅读clang module的实现代码,来解开这些谜团。

分析

编译参数

Xcode的Build Settings针对Clang Module有专门的设置分组,如下图:

针对这几个设置参数,下面分别解释一下其作用。

Enable Modules (C and Objective-C)

是否开启Clang Module特性。

当设置为YES的时候,会设置编译器参数-fmodules,开启clang module特性。当设置为NO的时候,其它4个选项也会随之失效,不会设置编译器参数-fmodules。

Enable Clang Module Debugging

对引用的外部clang module或者预编译头文件生成调试信息

当设置为YES的时候,会设置编译器参数 -gmodules

举例说明一下这个参数,我们自己模块的 Objective-C 源代码中如果有 #import <Foundation/Foundation.h> ,那Foundation模块就属于被引用的外部clang module。当开启Clang Module特性的时候,会根据Foundation模块提供的modulemap生成clang module编译缓存,其缓存的目录是通过编译器参数 -fmodules-cache-path 来设定的。

默认Xcode会设定编译缓存目录为的 ModuleCache.noindex

-fmodules-cache-path=/Users/baozhifei/Library/Developer/Xcode/DerivedData/ModuleCache.noindex

如下图红框中所示, ModuleCache.noindex 为clang module缓存目录, Foundation-3DFYNEBRQSXST.pcm 为Foundation的缓存文件

当Enable Clang Module Debugging为YES的时候,这个缓存文件为Mach-O格式的文件,其中 __CLANG,__clangast 节为缓存内容,这个文件还携带 __DWARF,__debug_info 等一些调试信息。

其中缓存内容的头4个字节签名是CPCH,应该是Compiled PCH的缩写。

当Enable Clang Module Debugging为NO的时候,缓存文件直接就是CPCH文件,不会生成Mach-O格式且携带调试信息。

建议正常开发的时候关闭这个设置,当出现clang module编译问题的时候,可以开启这个调试选项,有了DWARF的调试信息,可以精确定位的错误代码的行号和列号。

开启这个选项后,编译时会有性能损失,因为缓存变成了Mach-O格式,需要完整加载整个文件,读取 __clangast 节,才能获取真正的缓存内容。

Apple的官方文档《Precompiled Header and Modules Internals》中原文描述如下:

Clang’s AST files are loaded “lazily” from disk. When an AST file is initially loaded, Clang reads only a small amount of data from the AST file to establish where certain important data structures are stored. The amount of data read in this initial load is independent of the size of the AST file, such that a larger AST file does not lead to longer AST load times.

从下面代码中,就可以看出,CPCH文件内容其实就是AST的bitcode,所以,clang module的实现机制是和预编译头文件一致的,clang module可以认为是更通用的预编译头文件。

Disable Private Modules Warnings

对于Private Module概念还不了解,后面再展开

Allow Non-modular Includes In Framework Modules

允许framework模块中有非clang module的include

当设置为NO的时候,会设置编译器参数 -Wnon-modular-include-in-framework-module 。如果在引用的模块中,遇到非clang module的头文件,例如 #import "XXX.h"  这样格式的import指令,就会报错。

Link Frameworks Automatically

对于开启clang module后,import clang module会自动对链接器ld64增加链接参数,如下图红框所示:

因为我们都是使用CocoaPod来管理依赖,所以,最好关闭此选项,统一在podspec中声明依赖的frameworks和weak_frameworks

关闭后,会增加编译器参数 -fno-autolink

Defines Module

当开启的时候,会生成modulemap文件,如果当前编译模块有和模块同名的头文件,则modulemap编译器会帮忙合成一个,也支持自定义设置modulemap文件,如下图:

会增加两个编译器参数,一个是设置clang module name

-fmodule-name=ClangModuleTest

一个是形成一个虚拟的clang module层,让我们当前源码编译的模块也可以伪装成clang module格式

-ivfsoverlay /Users/baozhifei/Library/Developer/Xcode/DerivedData/ClangModuleTest-frvkjzzwryjshkeuimnrjtpuowxm/Build/Intermediates.noindex/ClangModuleTest.build/Debug-iphonesimulator/ClangModuleTest.build/all-product-headers.yaml

这个文件格式有点问题,说是yaml格式,实际上内容是json

源代码

llvm项目地址:https://github.com/llvm/llvm-project

我们编译调试的版本是14.0.6,是目前最新的tag

调试的代码,用Xcode创建一个新的framework工程叫ClangModuleTest,新增Test类,我们分析Test.m的编译流程。

构建参数可以从Xcode的构建日志中获取。

Xcode内置的clang版本应该是有一些功能没有开源,我们开源的clang不认识 -index-unit-output-path-index-store-path ,调试的时候这两个参数删除即可。

最新版本的clang的编译参数,都统一定义在Options.td文件中,通过 clang-tblgen 来统一生成,这样生成出来的rst文档和Options.inc是一致的,在Options.td中没有找到上述两个参数。

我这里调试的环境是Qt Creator,设置运行参数如下图

如果只调试不写代码,可以使用Xcode来调试,如果还要编译代码,最好使用vscode,clion,qt creator这种支持cmake的IDE,可以使用ninja来构建,编译会非常快。Qt Creator界面丑一点,但是稳定,cmake自带一个GUI配置界面,很方便。

如果是vscode,要配置settings.json和launch.json文件,内容大致如下:

预处理

clang::Preprocessor 是负责预处理的类,预处理主要是处理编译单元中的一些#号开头的预处理指令,比如, #import 导入头文件预编译指令,这些指令定义在TokenKinds.def文件中。

所以,Proprocessor是需要分词的Lex,在开始预处理之前EnterSourceFile中,就会创建Lexer对象。

我们的测试代码,第一行就是import指令

会在HandleDirective方法中分发调用各自预处理指令的处理函数

因为预处理指令是 #import <Foundation/Foundation.h> ,可以推断出是Foundation模块。从HeaderSearch中,找到对应的Foundation的module.modulemap文件

解析完ModuleMap文件以后,就可以实例 clang::Module 对象

CompilerInstance会缓存目录中查找缓存是否已经存在,如果不存在会再new一个CompilerInstance去单独编译Foundation模块,并且把编译的AST文件读入到内存中。

clang会开启另外一个线程来编译Foundation模块,并把编译结果写入到pcm文件中。这个CompilerInstance执行的FrontendAction就是GenerateModuleFromModuleMapAction,我们之前编译.o文件实际上是EmitObjAction。

把解析好的AST文件写入到pcm文件中去

就是下面红框中的文件。有了编译缓存,当再另外一个编译单元在编译的时候,会直接读取之前的AST缓存文件。

总结

通过阅读Clang Module的实现代码,大致清楚了Clang Module的实现原理,当我们导入一个模块头文件时,如果这个模块是Clang Module,则会直接读取其pcm缓存文件,如果没有缓存,则会开启另外一个编译器来生成pcm文件。pcm文件内容就是AST文件,这样多个编译单元可以最大程度复用,减少编译时间。

参考

  1. Clang Module Spec : https://clang.llvm.org/docs/Modules.html

  2. Precompiled Header and Modules Internals : https://clang.llvm.org/docs/PCHInternals.html