程序员自我修养阅读笔记——静态编译

Posted grayondream

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了程序员自我修养阅读笔记——静态编译相关的知识,希望对你有一定的参考价值。

测试环境:

➜  tmp uname --version
uname (GNU coreutils) 8.25
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by David MacKenzie.
➜  tmp gcc --version
gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

  本章描述为什么需要链接以及链接的大致过程。

1 链接

  对于链接的过程输入的多个编译单元,每个编译单元都包含一些列section,在链接中如何将多个section合并成一个文件的section?可能的方式如下图的两种方式:

  • 将各个模块的section按序排放,缺点很明显相同功能的段散步过于零散,有悖缓存的思想并且容易造成内部碎片;
  • 将不同模块的section按名字合并,这也是大多数编译器采用的方式。

  链接器在链接时为目标文件分配地址和空间:

  1. 在输出的可执行文件中分配空间,也就是实际存在于磁盘的;
  2. 在装载后的虚拟地址中虚拟地址空间。

  链接器在链接时一般采用两步链接,分别为空间地址分配和符号解析与重定位。

  • 空间地址分配:扫描输入的文件检查各自的段,将输入文件的符号表中的符号等信息收集放到全局的符号表中,并将各个输入文件的段合并建立映射关系;
  • 符号解析与重定位:利用第一步得到的信息读取文件的数据重定位等信息进行符号解析和重定位、调整代码中的符号地址等。

  下面尝试使用两个文件展示链接的过程,链接前使用gcc -c a.c -fno-stack-protector -o a.o将他们编译为.o文件,然后使用ld a.o b.o -e main -o main连接成可执行文件:

➜  tmp cat a.c
extern int shared;

int main(){
        int a = 100;
        swap(&a, &shared);
}
➜  tmp cat b.c
int shared = 1;

void swap(int *a, int *b){
        int tmp = *a;
        *a = *b;
        *b = tmp;
}

  然后通过objdump -h <file>查看目标文件中各个文件的段内容,这里只关注data,bss,text三个段,因此删除了一些额外的信息。

➜  tmp objdump -h a.o
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000002e  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  0000006e  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  0000006e  2**0
➜  tmp objdump -h b.o
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000002d  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  0000000000000000  0000000000000000  00000070  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000074  2**0
➜  tmp objdump -h main
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000005b  00000000004000e8  00000000004000e8  000000e8  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .data         00000004  0000000000601000  0000000000601000  00001000  2**2
                  CONTENTS, ALLOC, LOAD, DATA

  从上面的输出中能够看到最终的text和data进行了合并,大小也是两个子文件的大小之和,并且在未链接之前段的VMA(虚拟地址)和LMA(装载地址)都是0,链接之后对应的地址值才确立。

2 符号解析和重定位

2.1 重定位

  继续使用上面的例子,反汇编objdump -d a.o查看swap和shared的地址。从下面的代码中能狗看到由于并未分配VMA因此main的地址为0x00,而其中调用的swap和shared皆为临时的特殊值。源代码中a的值为100即0x64,可以确定标号为8的这一行为涉及a变量的代码,可以推断出标号8和f为将变量存储到寄存器中,而上面的汇编为调整栈不可能为操作shared,那么在call和f之间的三行基本确定时操作shared的代码。因为函数调用需要的是一个地址因此13应该为操作shared的实际代码。可以看到shared的地址填充的是0x00+rip,即0x1a,同时call调用的函数地址使用的是call下一条指令mov的地址。因为编译器并不知道对应的函数的地址,暂时赋予一个临时值,在链接阶段再进行调整。

➜  tmp objdump -d a.o
0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 83 ec 10             sub    $0x10,%rsp
   8:   c7 45 fc 64 00 00 00    movl   $0x64,-0x4(%rbp)
   f:   48 8d 45 fc             lea    -0x4(%rbp),%rax
  13:   48 8d 35 00 00 00 00    lea    0x0(%rip),%rsi        # 1a <main+0x1a>
  1a:   48 89 c7                mov    %rax,%rdi
  1d:   b8 00 00 00 00          mov    $0x0,%eax
  22:   e8 00 00 00 00          callq  27 <main+0x27>
  27:   b8 00 00 00 00          mov    $0x0,%eax
  2c:   c9                      leaveq
  2d:   c3                      retq

  再查看main反汇编的部分结果能够看到swap调用地址已经调整为0x400116,并且shared地址已经调整到0x200efe+rip=0x200efe+0x400102=0x601000

00000000004000e8 <main>:
  4000e8:       55                      push   %rbp
  4000e9:       48 89 e5                mov    %rsp,%rbp
  4000ec:       48 83 ec 10             sub    $0x10,%rsp
  4000f0:       c7 45 fc 64 00 00 00    movl   $0x64,-0x4(%rbp)
  4000f7:       48 8d 45 fc             lea    -0x4(%rbp),%rax
  4000fb:       48 8d 35 fe 0e 20 00    lea    0x200efe(%rip),%rsi        # 601000 <shared>
  400102:       48 89 c7                mov    %rax,%rdi
  400105:       b8 00 00 00 00          mov    $0x0,%eax
  40010a:       e8 07 00 00 00          callq  400116 <swap>
  40010f:       b8 00 00 00 00          mov    $0x0,%eax
  400114:       c9                      leaveq
  400115:       c3                      retq

0000000000400116 <swap>:

重定位表:
  重定位表就是描述哪些内容需要进行重定位的表,其在elf中往往是一个或者几个段组成,可以使用objdump -r查看重定位表中的内容,offset表示符号在输入文件中的偏移。从下面的内容中能够看到a.o中需要重定位的有shared,swap,.text

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE
0000000000000016 R_X86_64_PC32     shared-0x0000000000000004
0000000000000023 R_X86_64_PLT32    swap-0x0000000000000004

RELOCATION RECORDS FOR [.eh_frame]:
OFFSET           TYPE              VALUE
0000000000000020 R_X86_64_PC32     .text

2.2 符号解析

  符号解析将每个模块中引用的符号与某个目标模块中的定义符号建立关联。
  前面提到程序为了确定哪些符号需要进行重定位会在符号表中标记相关的符号,比如通过objdump -t a.o查看符号表能够看到swap,shared等被标记为UND如果最终连接完成仍然无法找到对应的符号地址则汇报undefined reference错误。

0000000000000000 l    df *ABS*  0000000000000000 a.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .data  0000000000000000 .data
0000000000000000 l    d  .bss   0000000000000000 .bss
0000000000000000 l    d  .note.GNU-stack        0000000000000000 .note.GNU-stack
0000000000000000 l    d  .eh_frame      0000000000000000 .eh_frame
0000000000000000 l    d  .comment       0000000000000000 .comment
0000000000000000 g     F .text  000000000000002e main
0000000000000000         *UND*  0000000000000000 shared
0000000000000000         *UND*  0000000000000000 _GLOBAL_OFFSET_TABLE_
0000000000000000         *UND*  0000000000000000 swap

  链接阶段对地址进行修饰会使用下面两种地址修正方式:

  • R_X86_64_PC32,重定位一个使用32位PC相对地址的引用。
  • R_X86_64_PLT32,过程链接表延迟绑定。

3 COMMON块

  弱符号机制允许同一个符号的定义存在于多个文件中。链接器本身不支持符号类型,只知道符号的名字。但当一个弱符号以不同的类型定义在多个目标文件中时,使用COMMON块进行区分。现在的编译器和链接器支持COMMON块(Common Block)机制。COMMON类型的链接规则是针对符号都是弱符号的情况。如果其中有一个强符号,最终符号所占空间欲强符号相同。如果有弱符号大小大于强符号,链接器报警告。
  为什么编译器把未初始化的全局变量标记为一个COMMON类型的变量,而不直接把它当作未初始化的局部静态变量,为其在BSS段分配空间?

  1. 编译时,若目标文件中含有弱符号(比如未定义的全局变量),则该弱符号最终所占内存空间大小无法确定,因为有可能其他目标文件中该弱符号所占内存空间比本单元弱符号所占内存空间大,所以此时无法在.BSS段为该弱符号分配空间。
  2. 链接时,读取了所有目标文件,确定了任意一个弱符号的大小。这时才在最终输出文件的.bss段中为其分配空间。
  3. 总体看来,未初始化的全局变量最终还是被放在.bss段。

4 C++涉及的问题

4.1 重复代码消除

  C++外部内联函数、模板和虚函数表都可能导致不同编译单元中存在相同代码,这些额外的重复代码,如果简单的将重复代码保留容易造成:

  • 空间浪费;
  • 地址容易出错,相同调用可能两个不同的函数地址;
  • 指令运行效率低。

  当今主流编译器进行重复代码消除采用的主流方式是将模板的每个实例都包含于一个段中,比如add的两个实例化版本add,add分别存储于单独的段中。然后在链接时将段进行合并。针对虚函数和外部内联函数的做法类似。但是这种方式也存在问题,比如可能因为不同编译器版本或者优化选项等的影响导致同一个函数编译出实际代码有所不同,则需要链接器进行选择,可能的选择时随机挑选给出警告信息。

template<class T>
int add(T a, T b){
        return a + b;
}

void test(){
        add(1, 2);
        add(3.3, 2.2);
}

  上面的代码中会出现额外的两个段:

5 .text._Z3addIiEiT_S0_ 00000014  0000000000000000  0000000000000000  0000008b  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  6 .text._Z3addIdEiT_S0_ 0000001e  0000000000000000  0000000000000000  0000009f  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE

**函数级别链接:**vc和gcc都提供类似函数级别链接(vs /Gy选项,gcc -ffunction-sections,-fdata-sections选项):即只是链接使用到的函数和变量,其他的并不链接到目标文件,实现原理是将每个函数或者变量添加到单独的段中进行有选择的链接。

4.2 全局构造与析构

  程序在执行之前会进行一些初始化工作,结束之后会进行一些清理工作,而这些工作都是在main之外进行的。一般而言main只是我们写程序的入口,linux c的入口为_start,windows为mainCRTStartup
  程序为了执行初始化或者清理工作比如C++的析构,需要额外的执行指令,ELF就定义了两个特殊的段:

  • .init:该段中保存的可执行指令构成了进程的初始化代码,顾名思义会在main之前调用;
  • .fini:该段保存着进程终止清理工作相关的指令,会在程序真正退出前调用。

4.3 ABI

  ABI(Application Binary Interface),包含符号修饰标准、变量内存布局、函数调用方式等跟可执行代码二进制兼容性相关的内容。如果两个平台编译出的文件的ABI相同则该二进制文件有可能在两个平台都能使用。
  影响ABI的因素很多,比如OS、硬件、编译器、链接器等等。对于C代码,以下几个方面决定目标文件之间是否二进制兼容:

  • 内置类型的大小和存储器中存储方式(大小端);
  • 组合类型的存储方式及内存布局;
  • 外部符号与用户定义的符号之间的命名及解析方式;
  • 函数调用方式,比如入栈顺序,谁负责清栈之类;
  • 堆栈分布方式;
  • 寄存器使用约定。

  由于C++的特性繁多,C++要做到ABI兼容的难度比C更大:

  • 继承体系内存布局;
  • 成员指针的内存布局;
  • 虚函数调用方式,虚函数表内存布局;
  • 模板实例化方式;
  • 外部符号修饰方式;
  • 全局对象的构造和析构;
  • 异常的产生和捕获;
  • RTTI实现方式;
  • 内联函数等等。

5 静态库链接

5.1 静态链接

  我们写程序时通常除了自己的代码之外还要使用语言相关的库,语言相关的库屏蔽了系统实现提供了统一的接口。C使用的库时已经静态库libc.a该库提供了类似标准输入输出等API。
  通常静态库是一系列目标文件的集合,这些目标文件在linux上时.o,windows上是.obj。我们使用file查看静态库能够发现linux上该文件是一个ar打包文件。下面是是不同平台使用对应的命令查看的库的内容,下面只列举了一部分不是全部。

~ ar -t /usr/lib/x86_64-linux-gnu/libc.a
init-first.o
libc-start.o
...
D:\\Microsoft Visual Studio\\2017\\Community>lib /LIST "D:\\Microsoft Visual Studio\\2017\\Community\\VC\\Tools\\MSVC\\14.16.27023\\lib\\onecore\\x64\\libcmt.lib"
Microsoft (R) Library Manager Version 14.16.27045.0
Copyright (C) Microsoft Corporation.  All rights reserved.

d:\\agent\\_work\\2\\s\\Intermediate\\vctools\\libcmtnativeproj_1714333426\\objr\\amd64\\amdsecgs.obj
d:\\agent\\_work\\2\\s\\Intermediate\\vctools\\libcmt.nativeproj_1714333426\\objr\\amd64\\cfgcheckthunk.obj
...

  按理说,我们可以自己把该打包文件解压然后使用ld链接编译成目标文件,至于如何找出当前实现中使用到的api所在的目标文件,可以使用objdump -t libc.a配合grep查找。虽然理论上和实际上都可行,但是一般由于太多的依赖关系要把所有的文件链接很麻烦。要了解编译时发生了什么可以使用g++ -verbose main.cpp查看编译时具体发生了什么:

➜  tmp g++ --verbose main.cpp
...
 /usr/lib/gcc/x86_64-linux-gnu/7/cc1plus -quiet -v -imultiarch x86_64-linux-gnu -D_GNU_SOURCE main.cpp -quiet -dumpbase main.cpp -mtune=generic -march=x86-64 -auxbase main -version -fstack-protector-strong -Wformat -Wformat-security -o /tmp/cc8nrLfI.s
...
COLLECT_GCC_OPTIONS='-v' '-shared-libgcc' '-mtune=generic' '-march=x86-64'
 as -v --64 -o /tmp/ccX3pUYj.o /tmp/cc8nrLfI.s
...
 /usr/lib/gcc/x86_64-linux-gnu/7/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/7/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper -plugin-opt=-fresolution=/tmp/ccJrexLV.res -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/7/../../.. /tmp/ccX3pUYj.o -lstdc++ -lm -lgcc_s -lgcc -lc -lgcc_s -lgcc /usr/lib/gcc/x86_64-linux-gnu/7/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o

  上面的输出中过滤了其他设置,可以看到主要分为三个步骤:

  • cc1plus:将源程序编译成.s汇编代码;
  • as:汇编器将汇编编译成二进制码;
  • collect2:将目标文件链接成最终的可执行文件,collect2可以看作ld的加强版,会做一些额外的工作,详情见gnu-collect2

上面的输出中为什么每个api都对应一个.o或者.obj文件?
答案:节省空间,减少不必要的链入。

5.2 链接控制

  有些时候,我们需要针对特定的环境比如操作系统内核等的编译指定程序中虚拟内存地址、段名称、存放顺序等设置。为了满足这种需求连接器一般都提供了多种控制整个链接过程的方法,以满足用户生成满足需求的文件:

  • 使用命令行参数指定;
  • 将链接指令存放在目标文件里面,编译器会将对应的指令传递给链接器;
  • 使用链接控制脚本。

  用户在链接时并未指定链接脚本,但是链接的时候会使用默认的链接脚本,可以使用ld -verbose查看。如果需要使用自定义的脚本可以使用ld -T user.script命令。
  书籍最后介绍了ld脚本的语法和进行链接控制的方式,下面简单描述不多说。下面的程序将使用nomain为入口,并将str输出,输出的方式时直接调用0x80中断实现。

char * str = "Hello world!\\n";
void print(void)
{
    asm("movl $13,%%edx \\n"
        "movl %0,%%ecx \\n"
        "movl $0,%%ebx \\n"
        "movl $4,%%eax \\n"
        "int $0x80 \\n"
        ::"r"(str):"edx","ecx","ebx");
}
void exit(void)
{
    asm("movl $42,%ebx \\n"
        "movl $1, %eax \\n"
        "int $0x80 \\n");
}
void nomain(void)
{
    print();
    exit();
}

  因为代码时使用IA32写的因此需要使用32bit编译,生成的目标文件也是32bit可能在64bit上无法运行:

➜  tmp gcc -m32 -c -fno-builtin main.c
➜  tmp ld -melf_i386 -static -e nomain -o main main.o

二进制文件描述符库 (BFD) 是 GNU 项目的主要机制,用于对各种格式的目标文件进行可移植操作,即为了屏蔽硬件差异的一个中间抽象,方便移植。BFD

6 reference

以上是关于程序员自我修养阅读笔记——静态编译的主要内容,如果未能解决你的问题,请参考以下文章

程序员自我修养阅读笔记——动态链接

《程序员自我修养》阅读笔记-静态链接

《程序员自我修养》阅读笔记-目标文件里有什么

《程序员自我修养》阅读笔记-动态链接

程序员自我修养阅读笔记——运行库

程序员自我修养阅读笔记——运行库