开篇
最近闲暇,在看《程序员的自我修养》这本书,发现真是一本宝藏书籍,很多地方之前并没有理解额很深入,看完豁然开朗。但是由于书籍本身是基于 Linux 或者 Windows 来书写的,在 Mac 上会有很多不一样的地方,同时也容易看完即忘,就写下来记录自己的总结和在 Mac 上的实践。
从源码到可执行文件,编译器分成了哪几步?
源文件
hello.c
#include <stdio.h>
int main() {
printf("Hello World\n");
return 0;
}
第一步:预编译
执行 gcc -E 预编译命令,得到预编译后的中间文件 hello.i
gcc -E hello.c -o hello.i
这里预编译做了什么事情呢?
- #define 展开;
- 处理所有 #if、#ifdef 等;
- 处理 #include ,插入引入代码;
- 删除注释;
- 添加行号、文件名标识;
- 这里特殊的是,#pragma 相关指令会被保留下来;
第二步:编译代码
将预编后的文件,从源码,生成汇编文件。
执行 gcc 命令,得到汇编文件 hello.s
gcc -S hello.c -o hello.s
打开 hello.s 文件可以看到代码都被转换成了汇编:
第三步:汇编
将汇编文件,转换成机器可以执行的命令。
执行 as 汇编命令,得到目标文件 hello.o
as hello.s -o hello.o
第四步:链接
链接的目的是把程序多个相关的目标文件(.o),编译到最终的可执行文件中,其中涉及到了未定义的符号确定、Section 段偏移纠正等等,比较复杂,具体在下一篇再详细展开。
这里我们执行 ld 命令进行链接操作(书籍原指令)
ld -static /usr/lib/crt1.o /usr/lib/crti.o /user/lib/gcc/i486-linux-gnu/4.1.3/crtbeginT.o -L /usr/lib/gcc/i486-linux-gnu/4.1.3 -L /usr/lib -L /lib hello.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/libgcc/i486-linux-gnu/4.1.3/crtend.o /usr/lib/crtn.o
我们知道,命令的主要目的是将相关依赖的库在链接时绑定在一起,这里因为代码里包含了 stdio.h,所以文件都来源于 **Glibc 库,**但由于 MacOS 并没有移植 Glibc 库,这里就直接利用了 -v 这个命令在 Mac 下生成对应链接后的可执行文件,具体如下:
gcc hello.o -o hello -v
-v: gccto reveal all the commands executed in the compilation stage.
(Ref:stackoverflow.com/questions/2…)
第五步:执行
我们得到hello文件后,就可以通过 ./hello 来进行程序的执行, 从而程序运行结果。
编译器做了什么
这里主要讲了,编译器在实际执行时,用到了哪些基本的技术,更贴近于我们上学时,了解的《编译原理》相关的内容。
因为不涉及到实践部分,这里就简单的总结下:
编译过程
截取了书上的示意图:
大致分为:扫描、语法分析、语义分析、源代码优化、代码生成、目标代码优化几部分。
词法分析(扫描)
这里目的就是通过词法分析,将代码快速的进行扫描,分割成各种 Token,方便后续进行代码的处理。
语法分析
通过上下文无关语法,将所有的 Token 根据表达式,生成完整的语法树,常见的工具有 yacc。
(书本上的语法树示意)
语义分析
语法分析,只解决了表达式的拆解,但是并没有去理解具体的代码含义,而语义分析就是为了解决这个问题而被引入,常见的包括对于类型匹配,类型转换等静态语义分析。经过语义分析,语法树所有表达式都被标识上了类型。
源代码优化(中间语言)
因为语法树并不好做代码优化,所以引入了中间代码,将语法树按照顺序转换成对应的中间代码格式,再针对中间代码进行编译层面的优化(常见的中间代码包括:Three-address Code、P-Code等)。
目标代码生成+优化
这里通过代码生成器和代码优化器,来分别进行代码的生成和优化工作。具体的例子这里就不详细描述了。
静态链接
因为不同的源码文件是独立编译,并生成对应的目标文件,而我们知道,一个程序包含了几百上千个文件,他们之间存在互相引用的关系,那这些文件之间,是如何能正确的在一起工作呢?这里就引入了 “静态链接“ 的概念。
在生成程序的最后一步,编译器会通过 “链接器”, 将相关的文件之间进行静态链接,包含了以下几个部分:
- 地址和空间分配
- 符号决议
- 重定位
具体如何进行的,会在下一篇详细介绍。
目标文件
上一节,我们分析了代码如何从源码转换到目标文件的过程,那么目标文件长什么样子呢?
格式
目前主流的可执行文件,都是基于 COFF 格式而产生的变种,所以可以理解,大家都基本遵循了 COFF 的标准。例如 LInux 下为 ELF, Windows 下为 PE,而Mac下,则为 Mach-O 格式。
(图一)
按照书里描述,常见的 ELF格式,会细分为四种不同类型的文件:可重定位文件(.o)、可执行文件、共享目标文件(so、dll)、Core Dump文件。
那么Mac OS下,是什么呢?
我们可以看到 “可重定位文件” 在 Mac 上对应为 object 类型(如图一);
“可执行文件” 在 Mac 上对应为 excutable 类型(如图二);
“共享目标文件” 对应为 bundle 类型(如图三);
“Core Dump” 文件对应 core 类型(如图四)。
(图二)
(我们平时的bash文件,在 M1 机器上实际上是包含了 x86 和 arm64 两个混合的结构)
(图三)
(图四)
注:
Mac 如何开启 Coredump:
sudo sysctl kern.coredump=1
具体参考:stackoverflow.com/questions/9…
M1下需要额外参考:developer.apple.com/forums/thre…
目标文件具体是什么
包含哪些内容呢?
- 指令
- 数据
- 符号表
- 调试信息
- 字符串
- …
通过 Section(段) 的形式存储(在 Mac 下又额外的引入了 Segement 结构)
常见的段,大部分我们都听过了:
- 代码段:用于存放代码,常见名:”.code”、”.text”;
- 数据段:用于存放全局变量和局部变量,常见名:”.data”;
- bss段:未初始化的全局变量和静态变量预留的位置(预留的原因在于,在静态链接前,无法最终确定变量的结构,也就无法申请空间,详情在下一篇文章中会涉及)
通过示意图会更清楚一些(来源书本):
分析目标文件
按照书里的示例,我们创建 SimpleSection.c 文件
然后我们通过 gcc 编译,生成对应的 .o 文件
gcc -c SimpleSection.c
之后通 objdump 获取 .o 文件的结构来分析(-h 会只显示最基本的段信息)
objdump -h SimpleSection.o
得到对应的文件格式如下:
这里,我们可以看到 SimpleSection.o 文件包含了代码段(TEXT)、数据段(DATA)、BSS段 几种类型。
但是对于 mach-o 文件,objdump 会比较有限,很多功能并不能很好的支持,Apple 提供了一个工具 otool 专门用于查看 mach-o 文件,类似于 objdump 对于 ELF 文件。具体的命令可以通过 man 查看。
man otool
这里我们重新用 otool 来分析 SimpleSection.o 文件:
(-l: Display the load commands,能够详细的打印出具体的 Section 信息)
otool -lV SimpleSection.o
我们可以得到详细的 Section 信息:
内容比较长,只截取了部分,但基本上和 objdump 是一致的。
接下来,我们用 size 命令,可以查看每个段的长度(dec 和 hex 用于表示所有段总和的大小):
size SimpleSection.o
现在,我们来看看每种类型的段具体都是什么内容呢。
代码段
由于 objdump 相对通用,所以接下来尽可能依然使用 objdump 来查看,而不是 otool。
我们先通过 -d 来查看指令段的内容:
objdump -s -d SimpleSection.o
可以看到,Text 段和 DATA 段的内容,以十六进制的方式打印出来,最左侧一列是偏移量,中间是十六进制内容,最后一列是 ASCII 码的形式。
而下半部分的反汇编结果,我们可以看到这部分内容正是我们代码里的 func 1() 和 main() 的指令内容。例如:0x55 对应了 func1 方法的第一条内容:push %ebp, 而0xc3 对应了 retq 指令(类似于ret函数返回指令)
数据段
同理,我们可以通过同样的命令,查看数据段的内容:
objdump -x -s -d SimpleSection.o
由于 Mac 下这条指令不能识别 mach-o 文件,可以使用 otool:
otool -dV SimpleSection.o
这里可以看到 data的前四个字节为 0x54、0x00、ox00、ox00。转换为十进制就是: 84, 正是源码里的 global_init_varabal 变量对应的值(先后顺序由大端小端决定)。这里最后四个字节是0x55、0x00、0x00、0x00,对应的就是85, 也就是源码里的 static_init_var。
BSS段
仍然使用 otool 命令查看所有 Section
otool -lV SimpleSection.o
我们只关注其中的BSS段:
其中可以看到 BSS 段只有 4字节,而不是两个变量占用的 8 个字节(global_uninit_var、static_var2),是因为global_uninit_var是全局变量,并且未定义的,无法确认清楚到底是什么类型,所以只能在链接后才会定义和分配空间,所以不在 bss 段内。
(static int x1 = 0; 这段代码虽然是已定义的 static 类型,但是由于值为 0,在优化时,会作为未定义放到 bss 段,而不是 data 段)
其他段
例子相对简单,并未包含太多其他段,所以按照书中,只列出一些常见的段:
- rodata1 只读段
- .comment 编译器版本信息
- .debug 调试信息
- …
系统中默认段名以 “.” 开头,但是允许我们自定义的段,参考代码:
__attribute__((section("TEST"))) int global = 42;
但实际 mac-o 对于 section 的 gcc 定义并非如书上所示,实际的代码应该更新为:
int global __attribute__((section("__DATA, TEST"))) = 42;
然后如之前的方法,我们查看目标文件的段信息:
可以看到已经包含了我们定义的 TEST DATA段了,其中占用空间为4字节,对应了 global 变量。
(自定义段的好处:我们可以把一些信息写入到自定义段中,因为段加载时在 main 函数执行之前,就可以解决比如某些模块加载太靠后,导致一些基础工具无法发现模块存在的问题)
Mach-O 文件
参考:geneblue.github.io/2021/01/04/…
文件头
基本和 ELF 大体类似,会多了一些 Load commands 信息。这里我们主要看 Header 和 Data 两部分,而 Data 相比于 ELF 文件多了 Segment 来组织 Section 信息。
注:Load Command 指定了文件逻辑结构还有文件在虚拟内存中的布局,每个 Load Command 结构都会以命令类型和当前 Load Command 结构大小开始,这里没有过多研究不在此展开。
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};
我们使用 SimpleSection.o 文件分析,通过 otool 查看目标文件的头部:
(Mach-O 文件)
( ELF 文件)
可以看到相比于 ELF 的头部,mach-o 会简化不少。这里MACHO-64 的魔数值为 0xfeedfacf,CPU 类型为 x86_64, 文件类型为 OBJECT (目标文件)。
ncmds和sizeofcmds表示 LOAD_COMMAND结构数量,和所有 LOAD_COMMAND 结构大小总和,flags字段标识文件的各种属性,0x200000 代表标志subsections_via_symbols,意思是可以将汇编中整块代码根据符号拆分成不同的区块,具体可以根据参考 Mach-O 的头文件结构(github.com/apple/darwi…)
段表
同样,我们可以查看 load.h 的源码,Section 段的定义如下:
基本结构和 ELF 的段表类似:
- sectname: 段的名称;
- addr: 段虚拟地址;
- size: section的长度
- offset: 段偏移,段在文件内的偏移地址;
- align: 段地址对齐;
- flags: 段的类型和其他属性;
- …
重定位表
我们知道,多个目标文件,在链接的时候需要进行重定位,而定位信息都需要有一个表来进行存储,书里面展示了”.rel.text” 段,是针对 “.text” 段的重定位表,我们通过 otool 可以查看SimpleSection 中的重定向表信息:
可以看到 TEXT 的重定向表,其中相关信息定义:
- address: 重定位的地址;
- pcrel: 是否使用相对地址寻址;
- length: 地址长度;
- extern: true为符号表的索引,false为 section;
- type: 指令操作;
- scrattered: 底层数据结构;
文件包含了 4 个相关的定义,例如 printf 的定义就需要依赖重定向表。
字符串表
我们知道字符串在编译过程中,会有一个专门存储的表;常见做法是通过一个字符串集中存储的表,在使用时通过指针位移来达到查询的目的,我们只需要指明起始位置,不需要关心字符长度,字符串表通过”\0” 来自动进行分割。
(书本里的例子)
按照惯例,我们看下 SimpleSection 的字符串表是什么,这里我们先改造 SimpleSection,在 func1 方法中增加以下内容:
printf( "string Test") ;
接着我们去看下符号表有什么:
可以看到,字符串表按照地址,存储了所有包含的字符串内容(%d\n 和我们后面加的 string Test)。
符号
函数和变量统称为符号,函数名和变量名就是符号名,在编译过程中,针对每一个目标文件,都有一个对应的符号表,本质上就是地址和符号之间的映射关系,在 iOS 我们常见的符号表开关也是类似的逻辑。我们可以看下 SimpleSection 哪些被存入了符号表中:
- 这里我们发现 printf 被标注为 undefined 是因为还未进行相关的链接,编译器并不知道具体的实现;
- 对于 func1 和 main 都是定义在代码内部的所以都存在于代码段;
- global_init_var 是初始化了的全局变量,所以被存放在 DATA 段;
- global_uninit_var 因为是未初始化的全局变量,所以作为 COMMON 类型,并没有存在 BSS 段;
- main.static_var2 和 *main.static*var 因为是静态变量,所以符号名做了修改,这里 Mach-O 文件会在变量名前用方法名(main )作为前缀,这里和 ELF 有所不同,ELF会对变量增加随机符号(如static_var2 会变成 static_var2.1534),感觉 Mach-O 的方式更清晰。这里有雨static_var2并没有定义值,所以被放在了 BSS 段内。
- global是我们特意写入Section 段 TEST 的变量;
符号修饰
不同的语言为了解决符号冲突的问题,发明了类似于命名空间等方法,但是由于历史原因,GCC还是会在符号前默认增加”_”。
为了解决命名空间不同的情况,增加了“函数签名”来进行区分,常见的修饰方法如图所示:
C++基本都以_Z开头,如果有命名空间就增加 N 跟在 Z 后面,然后是空间名和方法名,每个都以长度开头(例如C的长度为1,所以就是1C),最后 E 结束。参数列表紧跟 E, 这里的 i 就代表了 int。
“extern C” 也采用了类似的方式来定义,例如 extern “C” int var; 就会被表示为:_ZN3varE。
但是这里注意,同样是C++,在 Visual C++,又产生了另一套标准,这里就不展开了。
弱符号/强符号
我们可以通过 GCC 定义符号为弱符号:
__attribute__((weak))
强弱符号我理解主要是为了规避多个文件之间重复定义的冲突问题。基本的规则:
- 同名强符号多次被定义会报错;
- 如果一个符号在目标文件是强符号,在其他地方是弱符号,会按照强符号的定义;
- 如果多次定义都是弱符号,选择空间最大的。
同样对于引用,我们可以定义弱引用或者强引用:
__attribute__ ((weakref)) void foo();
弱引用的好处是,可以在编译时,即使foo方法没有定义,也依然不会报错,但是运行时会产生非法访问的问题。
调试信息
使用 GCC 时,我们可以通过 -g 参数增加调试信息,会产生一些 debug 段信息用来保存调试信息,现在调试信息的主流标准为 DWARF(Debug With Arbitrary Record Format),这也是 iOS XCode 中默认使用的方式。