(WWDC) Xcode 构建过程的幕后 —— Clang 编译器

 

我们将会了解到和 Clang 相关的两个特性:

  • 如何使用头文件映射(header map)来将 Xcode 构建系统产生的信息传递到 Clang 编译器;
  • 如何使用 Clang 模块(modules)来加速构建;

 

什么是 Clang ?

苹果公司的 C 语言家族的官方编译器:

  • C
  • C++
  • Objective-C
  • Objective-C++

其实,Swift 也需要使用 Clang。

 

编译器会为每一个输入文件生成一个之后用于链接的输出文件。

如果你需要访问一个 iOS API 或者调用你自己的实现,你通常需要在代码中包含一个头文件。

头文件就是一个承诺,承诺实现部分存在而且与声明内容相符。

如果你只更新了头文件但是忘了更新相应的实现,在编译阶段是不会发生错误,但是在链接阶段会出现问题。

  

现在,让我们结合实例程序 PetWall 来理解如何处理头文件。

实例程序 PetWall 内部混合使用了多种语言。

程序主体部分采用了 Swift 编写。

使用了一个用 Objective-C 编写的库 PetKit。

还使用了一个用 C++ 编写的库 PetSupport。

现在,这个应用的体积随着时间开始膨胀。
我们决定把 Cat 相关的文件移到另一个文件夹中,但是不改动任何实现文件。

然后,项目还是能正常编译和运行。

 
 

你可能会好奇 Clang 如何找到头文件呢?让我们来看一个例子。

 

这是其中一个包含了 Cat.h 头文件的实现文件。
但是,我们如何查明 Clang 做了什么?

打开构建日志,查看 Xcode 构建系统如何编译这个文件。
复制这个指令,然后粘贴到命令行终端中,并在结尾添加 -v 参数。 -v (verbose) 的意思是冗长的。
然后 Clang 就会告诉你很多信息。

不过,我们只需要关注 搜索路径(search paths)
搜索路径指向了你的源代码,但这并不是你猜想的答案。

Xcode 构建系统使用 headermap 来记录头文件的位置。

使用 headermap 是为了指向源代码,当 Clang 需要生成警告或者错误时,它可以给出具体的源代码位置。

因为很多人不知道 headermap 的用处,所以遭遇了很多问题。

一个常见的问题就是 忘记将头文件添加到项目中,头文件只在目录中而不在项目中。
所以,记得把头文件添加到项目中。

另一个是 头文件重名问题,尽可能地为头文件添加唯一的文件名。
如果你有一个头文件的名称和系统的头文件同名,就会屏蔽掉系统的头文件。

  

说到系统头文件,现在就以 PetWall 项目为实例讲解如何找到系统头文件 Fundation.h

忽略项目中的头文件,只看 $(SDKROOT) 相关的路径:

默认情况下,我们会在 SDK 的这两个路径中查找头文件。

对于第一个路径,我们无法找到头文件,因为头文件不在那里。

 
 

对于第二个路径,因为这是一个框架路径,所以 Clang 的行为略有不同。

首先,它需要知道这是什么框架以及框架是否存在。

然后,它需要在头文件目录找到头文件。
对于这个例子,它确实可以找到头文件。

 
 

如果找不到呢?Clang 会去查找私有头文件。
苹果发布的SDK中不会包含私有头文件,但是你的项目中包含公开和私有头文件,所以 Clang 会去查找。

如果这个头文件确实不存在,那么 Clang 就会停止查找。

 
 

如果你好奇生成的实现文件(所有相关的头文件被导入而且被预处理之后)是什么样,你可以让 Xcode 为你预处理源代码文件。这个操作会生成一个庞大的输出文件。

这个文件会有多大?

Foundation.h 是系统中最最基础的头文件,所以你常常会直接或者间接地导入它。
这也就意味着,每一次编译调用(compiler invocation)都会去查找这个头文件。

目前为止,每一次导入 Foundation.h, Clang 都需要处理超过 800 个头文件。
在每一次编译调用中,差不多有 9 MB 的源代码文件需要被处理和验证。

 
 

这么大量的重复工作,应该想方设法避免。那么我们该怎么做?

 

其中一个你可能已经知道的解决方案是预处理头文件(precompiled header files)。

但是,我们还有更好的解决方案:Clang 模块 (Clang modules)。
Clang 模块允许我们为每个框架只进行一次查找和解析,然后缓存在磁盘上以便后续的重用。
这可以有效地优化你的构建时间。

为了实现这个目的,Clang 必须包含某些属性。其中一个最重要的属性就是上下文无关。

如果像上面这样使用传统的宏定义来导入头文件,我们将无法重用模块。
相反,模块会忽视这些上下文相关的信息,以此来实现模块的重用。

另一个要求是模块需要制定所有的依赖(自包含)。
这样做有一个优点,当你在导入一个模块时就可以开始使用这个模块,你不用担心是否需要添加其他的头文件才能使模块正常工作。

 

那么,Clang 怎么知道是否应该构建一个模块呢?

 

让我们来看一个与 NSString.h 相关的简单例子。

首先,Clang 不得不在框架中先找到相应的头文件,我们已经知道需要在 Foundation.framework 目录中查找。
接下来,Clang 编译器会查找模块目录和一个与头文件目录相关的模块映射

 

什么是模块映射?

 

一个模块映射用于描述一个特定集合的头文件如何转换到模块上。

你会发现,模块映射中只有一个头文件 Foundation.h
但是,这其实是一个特殊的头文件,它被 umbrella 关键字标记。(umbrella header 直译就是伞头,这样的表述简直生动形象)
umbrella 关键字说明,Clang 需要在这个特殊的头文件中去查明 NSString.h 是否是这个模块的一部分。

Clang 可以找到 NSString.h,说明它是模块的一部分。
现在,Clang 可以将文本式的导入升级为模块导入,不过我们需要先构建 Foundation 模块。

 

那么,我们如何构建 Foundation 模块?

 

首先,我们为它创建一个单独的 Clang 位置。
这个 Clang 位置包含了来自 Foundation 模块的所有头文件。
我们不从原始编译器调用中传输任何现有的上下文。 因此,它是上下文无关的。

我们传输的只是命令行传递给 Clang 的参数
当我们构建 Foundation 模块时,Foundation 模块也包含了其他框架,所以我们也需要构建其他的模块。

我们也会发现,有些导入是相同的,所以我们可以开始重用这些模块。

所有的这些模块都可以被缓存在磁盘中的模块缓存中。
当创建模块时,所有命令行参数都会被传递过来,而这些参数可以影响模块的最终内容。
所以,我们需要对这些参数进行哈希,然后针对这一次编译调用,把模块存储到与这个哈希值对应的文件夹中。

如果你之后你改变了编译器的一些参数,这时就会有一个新的哈希值产生。
这时就需要 Clang 重新构建所有输入,并存储到与新的哈希值对应的文件夹中。

所以,为了尽可能地重用这些模块缓存,你要尽可能地保持参数相同。

以上就是我们如何为系统库构建模块。

 

那么,我们如何为开发者的框架构建模块呢?

 

让我们回到 Cat 头文件的例子,这一次我们会启用模块。

如果我们使用头文件映射,头文件映射会指向源代码目录。
但是,这就产生了问题,因为源代码目录不是模块目录。

这时候,Clang 不知道该如何处理这种情况。
为了解决这个问题,我们引入了一个新的概念—— Clang 的虚拟文件系统(Clang's Virtual File System)

它会创建一个框架的虚拟抽象,然后让 Clang 可以构建这个模块。
而这个抽象基本上又指向源代码目录的文件。

因此,Clang 就可以为你的源代码生成警告和错误。
这就是我们如何为开发者的框架构建模块的相关内容。

 

最后还有一个提醒,如果你不指定框架的名称,你将会遭遇一些坑!

 

让我们来看一个简单的例子。

这里有两个导入操作,第一个导入 PetKit 模块。
对于第二个导入,虽然我们知道这是 PetKit 模块的一部分,但是 Clang 并不知道,因为你没有指定框架的名称。

在这种情况下,你可能会遭遇重复定义的错误,这种错误基本上是在你重复导入同一个头文件时发生。
只需要做一个微小的调整,Clang 就可以成功为你的框架构建模块。

所以,建议在导入框架时指定框架的名称,无论这个框架是公开的还是你自己私有的。

 
 

继续阅读 (WWDC) Xcode 构建过程的幕后 —— Swift

  


参考内容:
Behind the Scenes of the Xcode Build Process




转载请注明出处,谢谢~

推荐阅读更多精彩内容