《程序员的自我修养》(一)

689 阅读14分钟

开篇

最近闲暇,在看《程序员的自我修养》这本书,发现真是一本宝藏书籍,很多地方之前并没有理解额很深入,看完豁然开朗。但是由于书籍本身是基于 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 文件可以看到代码都被转换成了汇编:

Untitled.png

第三步:汇编

将汇编文件,转换成机器可以执行的命令。

执行 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 来进行程序的执行, 从而程序运行结果。

Untitled 1.png

编译器做了什么

这里主要讲了,编译器在实际执行时,用到了哪些基本的技术,更贴近于我们上学时,了解的《编译原理》相关的内容。

因为不涉及到实践部分,这里就简单的总结下:

编译过程

截取了书上的示意图:

Untitled 2.png 大致分为:扫描、语法分析、语义分析、源代码优化、代码生成、目标代码优化几部分。

词法分析(扫描)

这里目的就是通过词法分析,将代码快速的进行扫描,分割成各种 Token,方便后续进行代码的处理。

语法分析

通过上下文无关语法,将所有的 Token 根据表达式,生成完整的语法树,常见的工具有 yacc。

(书本上的语法树示意)

Untitled 3.png

语义分析

语法分析,只解决了表达式的拆解,但是并没有去理解具体的代码含义,而语义分析就是为了解决这个问题而被引入,常见的包括对于类型匹配,类型转换等静态语义分析。经过语义分析,语法树所有表达式都被标识上了类型。

源代码优化(中间语言)

因为语法树并不好做代码优化,所以引入了中间代码,将语法树按照顺序转换成对应的中间代码格式,再针对中间代码进行编译层面的优化(常见的中间代码包括:Three-address Code、P-Code等)。

目标代码生成+优化

这里通过代码生成器和代码优化器,来分别进行代码的生成和优化工作。具体的例子这里就不详细描述了。

静态链接

因为不同的源码文件是独立编译,并生成对应的目标文件,而我们知道,一个程序包含了几百上千个文件,他们之间存在互相引用的关系,那这些文件之间,是如何能正确的在一起工作呢?这里就引入了 “静态链接“ 的概念。

在生成程序的最后一步,编译器会通过 “链接器”, 将相关的文件之间进行静态链接,包含了以下几个部分:

  • 地址和空间分配
  • 符号决议
  • 重定位

具体如何进行的,会在下一篇详细介绍。

目标文件

上一节,我们分析了代码如何从源码转换到目标文件的过程,那么目标文件长什么样子呢?

格式

目前主流的可执行文件,都是基于 COFF 格式而产生的变种,所以可以理解,大家都基本遵循了 COFF 的标准。例如 LInux 下为 ELF, Windows 下为 PE,而Mac下,则为 Mach-O 格式。

(图一) Untitled 4.png 按照书里描述,常见的 ELF格式,会细分为四种不同类型的文件:可重定位文件(.o)、可执行文件、共享目标文件(so、dll)、Core Dump文件。

那么Mac OS下,是什么呢?

我们可以看到 “可重定位文件” 在 Mac 上对应为 object 类型(如图一);

“可执行文件” 在 Mac 上对应为 excutable 类型(如图二);

“共享目标文件” 对应为 bundle 类型(如图三);

“Core Dump” 文件对应 core 类型(如图四)。

(图二) Untitled 5.png (我们平时的bash文件,在 M1 机器上实际上是包含了 x86 和 arm64 两个混合的结构)

(图三) Untitled 6.png

(图四) Untitled 7.png 注: Mac 如何开启 Coredump:

sudo sysctl kern.coredump=1

具体参考:stackoverflow.com/questions/9…

M1下需要额外参考:developer.apple.com/forums/thre…

目标文件具体是什么

包含哪些内容呢?

  • 指令
  • 数据
  • 符号表
  • 调试信息
  • 字符串

通过 Section(段) 的形式存储(在 Mac 下又额外的引入了 Segement 结构)

常见的段,大部分我们都听过了:

  • 代码段:用于存放代码,常见名:”.code”、”.text”;
  • 数据段:用于存放全局变量和局部变量,常见名:”.data”;
  • bss段:未初始化的全局变量和静态变量预留的位置(预留的原因在于,在静态链接前,无法最终确定变量的结构,也就无法申请空间,详情在下一篇文章中会涉及)

通过示意图会更清楚一些(来源书本): Untitled 8.png

分析目标文件

按照书里的示例,我们创建 SimpleSection.c 文件 Untitled 9.png

然后我们通过 gcc 编译,生成对应的 .o 文件

gcc -c SimpleSection.c

之后通 objdump 获取 .o 文件的结构来分析(-h 会只显示最基本的段信息)

objdump -h SimpleSection.o

得到对应的文件格式如下: Untitled 10.png 这里,我们可以看到 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 信息: Untitled 11.png 内容比较长,只截取了部分,但基本上和 objdump 是一致的。

接下来,我们用 size 命令,可以查看每个段的长度(dec 和 hex 用于表示所有段总和的大小):

size SimpleSection.o

Untitled 12.png 现在,我们来看看每种类型的段具体都是什么内容呢。

代码段

由于 objdump 相对通用,所以接下来尽可能依然使用 objdump 来查看,而不是 otool。

我们先通过 -d 来查看指令段的内容:

objdump -s -d SimpleSection.o

Untitled 13.png Untitled 14.png 可以看到,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

Untitled 15.png 这里可以看到 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段:

Untitled 16.png

其中可以看到 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;

然后如之前的方法,我们查看目标文件的段信息: Untitled 17.png 可以看到已经包含了我们定义的 TEST DATA段了,其中占用空间为4字节,对应了 global 变量。

(自定义段的好处:我们可以把一些信息写入到自定义段中,因为段加载时在 main 函数执行之前,就可以解决比如某些模块加载太靠后,导致一些基础工具无法发现模块存在的问题)

Mach-O 文件

参考:geneblue.github.io/2021/01/04/…

文件头

Untitled 18.png 基本和 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 文件) Untitled 19.png Untitled 20.png ( ELF 文件)

Untitled 21.png Untitled 22.png 可以看到相比于 ELF 的头部,mach-o 会简化不少。这里MACHO-64 的魔数值为 0xfeedfacf,CPU 类型为 x86_64, 文件类型为 OBJECT (目标文件)。

  • ncmdssizeofcmds表示 LOAD_COMMAND结构数量,和所有 LOAD_COMMAND 结构大小总和,
  • flags字段标识文件的各种属性,0x200000 代表标志 subsections_via_symbols,意思是可以将汇编中整块代码根据符号拆分成不同的区块,具体可以根据参考 Mach-O 的头文件结构(github.com/apple/darwi…

Untitled 23.png

段表

同样,我们可以查看 load.h 的源码,Section 段的定义如下: Untitled 24.png 基本结构和 ELF 的段表类似:

  • sectname: 段的名称;
  • addr: 段虚拟地址;
  • size: section的长度
  • offset: 段偏移,段在文件内的偏移地址;
  • align: 段地址对齐;
  • flags: 段的类型和其他属性;

重定位表

我们知道,多个目标文件,在链接的时候需要进行重定位,而定位信息都需要有一个表来进行存储,书里面展示了”.rel.text” 段,是针对 “.text” 段的重定位表,我们通过 otool 可以查看SimpleSection 中的重定向表信息: Untitled 25.png 可以看到 TEXT 的重定向表,其中相关信息定义:

blog.csdn.net/Px01Ih8/art…

  • address: 重定位的地址;
  • pcrel: 是否使用相对地址寻址;
  • length: 地址长度;
  • extern: true为符号表的索引,false为 section;
  • type: 指令操作;
  • scrattered: 底层数据结构;

文件包含了 4 个相关的定义,例如 printf 的定义就需要依赖重定向表。

字符串表

我们知道字符串在编译过程中,会有一个专门存储的表;常见做法是通过一个字符串集中存储的表,在使用时通过指针位移来达到查询的目的,我们只需要指明起始位置,不需要关心字符长度,字符串表通过”\0” 来自动进行分割。

(书本里的例子) Untitled 26.png 按照惯例,我们看下 SimpleSection 的字符串表是什么,这里我们先改造 SimpleSection,在 func1 方法中增加以下内容:

printf( "string Test") ;

接着我们去看下符号表有什么: Untitled 27.png 可以看到,字符串表按照地址,存储了所有包含的字符串内容(%d\n 和我们后面加的 string Test)。

符号

函数和变量统称为符号,函数名和变量名就是符号名,在编译过程中,针对每一个目标文件,都有一个对应的符号表,本质上就是地址和符号之间的映射关系,在 iOS 我们常见的符号表开关也是类似的逻辑。我们可以看下 SimpleSection 哪些被存入了符号表中: Untitled 28.png

  • 这里我们发现 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还是会在符号前默认增加”_”。

为了解决命名空间不同的情况,增加了“函数签名”来进行区分,常见的修饰方法如图所示: Untitled 29.png 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 中默认使用的方式。