我们通过终端中输入clang -ccc-print-phases main.m,得到如下打印:
+- 0: input, "main.m", objective-c //输入
+- 1: preprocessor, {0}, objective-c-cpp-output //预处理
+- 2: compiler, {1}, ir //编译
+- 3: backend, {2}, assembler //后端
+- 4: assembler, {3}, object //汇编
+- 5: linker, {4}, image //链接
6: bind-arch, "x86_64", {5}, image //绑定
得到了7步操作的简要说明,分别是:
- 0:导入文件:是为了找到源文件。
- 1:预处理阶段:这个过程处理包括宏的替换,头文件的导入,会生成 main.i 文件,其中 objective-c-cpp-output 中的 cpp 不是指 C++ 语言,而是 c preprocessor 的缩写。
- 2:编译阶段:进行词法分析、语法分析、检测语法是否正确,最终生成IR。
- 3:后端:这里
LLVM
会通过一个一个的Pass
去优化,每个Pass
做一些事情,最终生成汇编代码,生成一个 main.s 文件。 - 4:汇编:生成目标文件,即为 main.o。
- 5:链接:链接需要的动态库和静态库,生成可执行文件,最终生成 image。
- 6:绑定:通过不同的架构,生成对应的可执行文件,默认生成x86_64平台。
- 下面我们一步一步拆解:
1、预处理阶段
是通过预处理器执行一系列的文本转换与文本处理。预处理器是在真正的编译开始之前由编译器调用的独立程序。
在终端中输入:clang -E main.m 会生成预编译结果。
下面我们来把预编译结果生成到文件中,输入命令:clang -E main.m -o main.mi ,会把预编译结果生成一个main.mi文件,里面的内容如下:
# 1 "main.m"
# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 380 "<built-in>" 3
# 1 "<command line>" 1
# 1 "<built-in>" 2
# 1 "main.m" 2
/*
*在 main 函数中输入
int main(int argc, char * argv[]) {
int a = 1;
int b = 2;
return a + b;
}
**/
int main(int argc, char * argv[]) {
int a = 1;
int b = 2;
return a + b;
}
当然,我们不同类型的文件要输出对应的文件类型,具体如下:
- C :.i (clang -E main.c -o main.i)
- C++ :.ii (clang -E main.c++ -o main.ii)
- Objective-C :.mi (clang -E main.m -o main.mi)
- Objective-C++ :.mii(clang -E main.m++ -o main.mii)
细心的小伙伴肯定看到上面打印结果前几行里面包含 1,2,3,4,380这些数字,甚是奇怪。我们继续向下看就明白了
1.直接导入文件解析
首先我们要知道,预处理中我们比较熟悉的就是处理我们的宏定义部分。举个例子:
#import <Foundation/Foundation.h>
预处理器对这行代码的处理是用 Foundation.h 文件中的内容去替换这行代码,如果 Foundation.h 中也使用了类似的宏引入,则会按照同样的处理方式用各个宏对应的真正代码进行逐级替代。这也就是为什么我们主张头文件最好尽量少的去引入其他的类或库,因为引入的东西越多,编译器需要做的处理就越多。例如,我们经常在 .h 文件中写如下的代码:
用
@class QJDClass;
代替:
#import "QJDClass.h"
这么写是告诉编译器 QJDClass 是一个类,并且在 .m 实现文件中可以通过 import "QJDClass.h"
的方式来使用它。
下面我们来看看在 main.m 中引入头文件之后预处理之后的结果。在 main.m 中写上如下代码:
#include <stdio.h>
int main(int argc, char * argv[]) {
int a = 1;
int b = 2;
printf("%d %d", a, b);
return 0;
}
再在终端敲入命令:clang -E main.m -o main.mi
查看生成的文件发现有500多行代码,其中95%都是头文件导入的代码,这还算少的,你再看看
#import <Foundation/Foundation.h>
导入这个之后,能发现有9万多行代码,可见这个乱导头文件带来的危害。不过现在的预处理能够判断没有使用的话不会导入里面的的内容,这个还是很优秀的。
在上面生成的文件中也会看到大量的1,2,3,4等等一系列的数字。下面我们来聊一聊这些数字代表的意思。我们先看看下面这个:
# 1 "main.m"
# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 380 "<built-in>" 3
# 1 "<command line>" 1
# 1 "<built-in>" 2
# 1 "main.m" 2
我们摘抄一下官方要点:
# linenum filename flags
These are called linemarkers. They are inserted as needed into the output (but never within a string or character constant). They mean that the following line originated in file filename at line linenum. filename will never contain any non-printing characters; they are replaced with octal escape sequences.
- 1:This indicates the start of a new file.
- 2:This indicates returning to a file (after having included another file).
- 3:This indicates that the following text comes from a system header file, so certain warnings should be suppressed.
- 4:This indicates that the following text should be treated as being wrapped in an implicit
extern "C"
block.
翻译一下(百度翻译):
这里面表示:内容开始于源文件 main.m 的第几行。
格式为:# linenum filename flags
其中 linenum 为 filename 中的第几行,filename 为原文件名,flags 为零个或者多个,有1、2、3、4。如果有多个 flags 时,彼此使用空格隔开。详细地文档:点击查看🤖🤖🤖
- 1:表示一个新文件的开始
- 2:表示返回文件(包含另一个文件后)
- 3:表示以下文本来自系统头文件,因此应禁止某些警告
- 4:表示应将以下文本视为包装在隐式extern "C" 块中。
举个栗子:比如# 1 "<built-in>" 1,表示一个新文件的在 main.m 文件的第1行。
还比如:# 64 "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/stdio.h" 3 4
表示 stdio.h 导入到 main.m 文件中的文本来自系统头文件,因此应禁止某些警告,并且将 stdio.h 中的文本视为包装在隐式 extern "C" 块中,在 stdio.m 中第64行。
我们发现文档还有这样一句: If multiple flags are given, they must be in ascending order.所以这个 flags 是按照定义顺序排序的。
2.宏的预编译
有时候我们会定义这样的函数:
#define MAX(a,b) a > b ? a : b
int main() {
printf("largest: %d\n", MAX(10,100));
return 0;
}
上面这样写是没有问题的,但是当我们有些参数变的时候,就出现了下面的写法
#define MAX(a,b) a > b ? a : b
int main() {
int i = 200;
printf("largest: %d\n", MAX(i++,100));
printf("i: %d\n", i);
return 0;
}
建个 main.c 文件,把上述代码输进去,然后执行命令:
% clang main.c 会生成 a.out文件。
% ./a.out
largest: 201
i: 202
why?下面我来看看预编译后形成的代码:
首先执行:
% clang -E mian.c
输出很长的内容,我们只看最后几行,如下:
int main() {
int i = 200;
printf("largest: %d\n", i++ > 100 ? i++ : 100);
printf("i: %d\n", i);
return 0;
}
仔细分析上述代码:i++ > 100,显然是成立的,执行完以后 i = 201,然后去执行后面的 i++,在 i++ 执行之前会先打印第一个 printf 打印,结果是 201,然后执行 i++语句,得出 i = 202,所以第二个 printf 打印,i=202。这是典型的宏使用不当,而且通常这类问题非常隐蔽且难以发现 。
你肯定想知道这种问题的解决方式,这个地方最好推荐的就是 static inline ,static inline会以一种类似于宏定义的方式,将调用被 static inline 修饰的函数的语句替换为那个函数体对应的指令,但实际上只是inline的作用,static作用其实是维护代码的健壮性,inline是向编译器建议,将被inline修饰的函数以内联的方式嵌入到调用这个函数的地方。 所以:
- 好处:减少调用函数时的开销减少,传参时可能引起的压栈出栈的开销 ,减少PC跳转时对流水线的破坏。
- 坏处:代码所占体积会更大。
#include <stdio.h>
static const int MyConstant = 200;
static inline int max(int l, int r) {
return l > r ? l : r;
}
int main() {
int i = MyConstant;
printf("largest: %d\n", max(i++,100));
printf("i: %d\n", i);
return 0;
}
上面代码通过同样的预编译处理,发现代码并没有被替换。结果如下:
static const int MyConstant = 200;
static inline int max(int l, int r) {
return l > r ? l : r;
}
int main() {
int i = MyConstant;
printf("largest: %d\n", max(i++,100));
printf("i: %d\n", i);
return 0;
}
整理在调试的时候也很友好,可以设置断点、类型检查以及避免异常行为。基本上,宏的最佳使用场景是日志输出,可以使用 __FILE__
和 __LINE__
和 assert 宏。
这里解释一下:
__LINE__:用以指示本行语句在源文件中的位置信息
__FILE__:用以指示本行语句所在源文件的文件
assert:OC中常见
上面说明了预处理过程中的宏定义和导入头文件的处理过程。下一篇我们在来看一下预处理过程的其他的处理,比较经典的:词法分析、语法分析和语义分析
参考资料:
1.https://zhuanlan.zhihu.com/p/132726037