抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

C/C++编译与链接

《程序员的自我修养:链接、装载与库》阅读笔记

编译

编译可以被分为四个步骤:

  1. 预编译(Prepressing)
  2. 编译(Compilation)
  3. 汇编(Assembly)
  4. 链接(Linking)

预编译

gcc -E hello.c -o hello.i
cpp hello.c > hello.i
  • 移除并展开所有#define
  • 处理所有条件预编译指令,如#ifdef
  • 递归处理#include指令,将被包含的文件拷贝到该指令位置
  • 删除所有的注释
  • 添加行号和文件名标识,便于产生调试和报错的行号信息
  • 保留#pragma指令

编译

gcc -S hello.i -o hello.s
  1. 词法分析:扫描器使用有限状态机将源代码的字符切分为一系列记号(Token)
  2. 语法分析:分析记号,得到语法树,树的节点是表达式
  3. 语义分析:分析所有的静态语义,为表达式标注类型
  4. 中间语言生成:优化一些代码,比如2+6优化成8
    • 三地址码与P代码
  5. 目标代码的生成与优化

GCC可以将预编译和编译合成一个步骤

gcc -S hello.c -o hello.s
cc1 hello.c

汇编

gcc -c hello.s -o hello.o
as hello.s -o hello.o

链接

ld -static xxxx hello.o -xxxx

链接的本质是替换指令中(对函数和变量)的地址引用

  1. 地址和空间分配
  2. 符号决议
  3. 重定位

main.c文件调用了func.c文件中的函数foo(),所以需要知道foo()的确切地址。但C++不同模块是单独编译的,因此编译main.c时不知道foo()的地址

于是编译器先将foo()的地址搁置,在链接的时候再逐个修正,填入foo()真实的地址

这个地址修复的过程也叫做重定位(Relocation),每一个需要修正的地方叫重定位入口(Relocation Entry)

目标文件

编译器编译后生成的文件是目标文件(.obj.o),结构跟可执行文件相同,只是还未链接

可执行文件、目标文件、动态库、静态库均采用相同的存储格式,在Windows下使用PE-COFF格式存储,在Linux下使用ELF格式存储

  • Windows:PE-COFF(Probable Executable-Common File Format)
  • Linux:ELF(Executable Linkable Format)

目标文件格式

由四个部分组成:

  • 文件头(File Header):文件可否执行、是静态链接还是动态链接、链接入口、目标硬件、目标操作系统、段表(Section Table)
    • 段表描述了文件中各个段的偏移位置和属性,用于找到代码段、数据段
  • 代码段(.text section):编译得到的执行语句
  • 数据段(.data section):已初始化的静态/全局变量
  • .bss section:为未初始化的静态/全局变量预留位置,没有内容
  • .rodata section:只读数据,比如用const修饰的变量、字符串常量
int global_inited_var = 84;
int global_uninit_var;

void func(int var)
{
printf("%d\n", var);
}

int main(void)
{
static int static_inited_var = 85;
static int static_uninit_var;

int local_inited_var = 1;
int loacl_uninit_var;
func(static_inited_var + static_uninit_var + local_inited_var + loacl_uninit_var);
return 0;
}

objdump -h hello.o

查看目标文件的结构

hello.o:     file format elf64-x86-64

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000064 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 000000a4 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000008 0000000000000000 0000000000000000 000000ac 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000ac 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002c 0000000000000000 0000000000000000 000000b0 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000dc 2**0
CONTENTS, READONLY
6 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .eh_frame 00000058 0000000000000000 0000000000000000 00000100 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

objdump -s -d hello.o

将段内容以十六进制的形式打印出来,并将指令段反汇编

hello.o:     file format elf64-x86-64

Contents of section .text:
0000 f30f1efa 554889e5 4883ec10 897dfc8b ....UH..H....}..
0010 45fc89c6 488d0500 00000048 89c7b800 E...H......H....
0020 000000e8 00000000 90c9c3f3 0f1efa55 ...............U
0030 4889e548 83ec10c7 45f80100 00008b15 H..H....E.......
0040 00000000 8b050000 000001c2 8b45f801 .............E..
0050 c28b45fc 01d089c7 e8000000 00b80000 ..E.............
0060 0000c9c3 ....
Contents of section .data:
0000 54000000 55000000 T...U...
Contents of section .rodata:
0000 25640a00 %d..
Contents of section .comment:
0000 00474343 3a202855 62756e74 75203131 .GCC: (Ubuntu 11
0010 2e332e30 2d317562 756e7475 317e3232 .3.0-1ubuntu1~22
0020 2e303429 2031312e 332e3000 .04) 11.3.0.
Contents of section .note.gnu.property:
0000 04000000 10000000 05000000 474e5500 ............GNU.
0010 020000c0 04000000 03000000 00000000 ................
Contents of section .eh_frame:
0000 14000000 00000000 017a5200 01781001 .........zR..x..
0010 1b0c0708 90010000 1c000000 1c000000 ................
0020 00000000 2b000000 00450e10 8602430d ....+....E....C.
0030 06620c07 08000000 1c000000 3c000000 .b..........<...
0040 00000000 39000000 00450e10 8602430d ....9....E....C.
0050 06700c07 08000000 .p......

Disassembly of section .text:

0000000000000000 <func>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 10 sub $0x10,%rsp
c: 89 7d fc mov %edi,-0x4(%rbp)
f: 8b 45 fc mov -0x4(%rbp),%eax
12: 89 c6 mov %eax,%esi
14: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 1b <func+0x1b>
1b: 48 89 c7 mov %rax,%rdi
1e: b8 00 00 00 00 mov $0x0,%eax
23: e8 00 00 00 00 call 28 <func+0x28>
28: 90 nop
29: c9 leave
2a: c3 ret

000000000000002b <main>:
2b: f3 0f 1e fa endbr64
2f: 55 push %rbp
30: 48 89 e5 mov %rsp,%rbp
33: 48 83 ec 10 sub $0x10,%rsp
37: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
3e: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 44 <main+0x19>
44: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 4a <main+0x1f>
4a: 01 c2 add %eax,%edx
4c: 8b 45 f8 mov -0x8(%rbp),%eax
4f: 01 c2 add %eax,%edx
51: 8b 45 fc mov -0x4(%rbp),%eax
54: 01 d0 add %edx,%eax
56: 89 c7 mov %eax,%edi
58: e8 00 00 00 00 call 5d <main+0x32>
5d: b8 00 00 00 00 mov $0x0,%eax
62: c9 leave
63: c3 ret

.data中54000000转化到十进制是84,55000000是85

大小端

注意十六进制的读法,0x54是最低位,后面三个0x00是更高的位,这种低位在前高位在后的字节序是小端序,有点反人类

为什么要区分代码块和数据块呢?

  1. 程序被装载后,数据区域是可读写的,而代码区域是只读的,将段进行分离,可以防止潜在的错误修改
  2. 数据集中存储,可以利用局部性原理提高缓存利用率
  3. 当系统同时运行多个同一文件时,可以共享只读的指令和数据,能大幅节省空间

readelf -h hello.o

查看elf文件头的详细信息

ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 1048 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 14
Section header string table index: 13

魔术(Magic)用于区分ELF文件类型(与Windows里用后缀名区分不同)

符号

符号表

如果目标文件B用到了目标文件A的函数foo(),我们称目标文件A定义(Define)了函数foo(),目标文件B引用(Reference)了函数foo()

我们称函数、变量为符号(Symbol),他们的名字被称为符号名(Symbol Name),符号名独一无二

编译过程中每个目标文件都有一个符号表,每个符号能找到一个对应的符号值,对于变量和函数,符号值就是他们的地址

符号值的类型有:

  • 定义在本文件内的全局符号,比如global_inited_varfunc
  • 定义在其他文件内,但是被本文件引用的全局符号,叫外部符号(External Symbol),比如printf
  • 段名,是段的起始地址
  • 局部符号,比如static_inited_var
  • 行号
readelf -s hello.o

Name Mangling

为了防止符号名冲突,C语言会加上命名空间等方法修饰符号,C++会做符号改编(Name Mangling)

函数签名:由函数的名称、参数数量与类型、所在的类、所在的命名空间等组成

在C++进行编译链接时,会使用函数签名生成一个修饰后名称,使用这个修饰后名称作为符号名

C++允许使用函数重载,但是重载函数的参数不能完全相同

C++允许局部变量和全局变量重名,因为他们修饰后是两个不同的符号

C++的Name Mangling规则取决于编译器版本,没有统一和公开

extern “C”

C++的符号十分复杂,C的符号兼容性会更好,于是我们可以使用extern "C"声明一个C符号,里面的符号不会被Name Mangling

#ifdef __cplusplus
extern "C" {
#endif
int func(int);
int var;
#ifdef __cplusplus
}
#endif

强弱符号

强符号:C++默认函数和初始化的全局变量为强符号,如果多个目标文件拥有相同名字的强符号,链接时会报符号重定义的错误

弱符号:C++未初始化的全局变量是弱符号

extern int ext;	// 不是强、弱符号,因为是外部文件定义的变量
int weak_var; // 弱符号,未初始化的全局变量
int strong_var = 1; // 强符号,初始化的全局变量
__attribute__((weak)) weak_var2; // 弱符号
int main() // main是强符号,函数
{
return 0;
}

规则:

  • 不允许强符号重定义
  • 弱符号会被强符号覆盖
  • 若一个符号在所有文件中都是弱符号,则选择体积最大的符号去链接

强弱引用

强引用:在链接时,如果没有找到强引用的符号,会报符号未定义的错误

弱引用:在链接时,若符号未定义,会给一个默认值0

弱符号、弱引用对于库来说十分有用,可以被用户自己定义的强符号所覆盖,也可以使得程序功能被剪裁组合

静态链接

链接会将多个目标文件加工成一个可执行文件

段合并

相似段合并:将所有具有相同性质的段合并在一起,比如多个文件的.data段合在一个大的.data段中

两步链接

  1. 空间和地址分配
  2. 符号解析和重定位

C++链接步骤

  1. 重复代码消除,一个模板可能在多个编译单元中被实例化,且实例化成相同的代码,将这些重复代码消除可以提高缓存利用率和节约空间
  2. 函数级别链接(选择性开启):每一个函数都单独存储在一个段中,链接时按需添加到目标文件,可以减少包体,但是会降低编译链接速度

程序入口

Linux下程序的入口是_start,这是Glibc库的一部分,会进行程序的初始化,比如全局函数的创建,然后再去main函数执行

ELF有.init和.fini两个段,在这两个段中的代码会在mian前后执行

ABI

两个代码想要链接,需要使用相同的目标文件格式。我们将符号修饰、变量内存布局、函数调用方法等和二进制可执行文件兼容性相关的内容称为ABI(Application Binary Interface)

对于C语言,通过下列内容判断二进制兼容性:

  • 内置类型(int、char)的大小、字节序、对齐方式
  • 组合类型(struct、array、union)的存储方式和内存分布
  • 外部符号的解析方式,比如外部的func被解析为_func
  • 函数调用方式,比如参数入栈顺序,返回值如何保持
  • 堆栈分布方式
  • 寄存器使用约定

对于C++还额外有:

  • 继承相关的内存分布
  • 指向成员函数的指针的内存分布,如何通过成员函数指针调用成员函数,如何传递this指针
  • 如何调用虚函数,虚表的内容及分布
  • 模板如何实例化
  • Name Mangling
  • 全局对象的构造与析构
  • 异常
  • RTTI
  • inline

C++的ABI不稳定,于是DLL建议使用C风格

装载

可执行文件要装载到内存中才能被CPU执行

动态装入:许多情况下程序所需要的内存远超物理内存,为此我们会将一部分数据存储在磁盘里,内存中只保留最常用的部分。目前最常用的方法是页映射

进程的创建

  1. 创建一个独立的虚拟内存空间
    • 分配页目录
    • 虚拟内存区域(VMA)
    • 段(Segment)
  2. 读取可执行文件的头,建立虚拟空间和可执行文件的映射关系
    • 当程序发生缺页时,需要知道当前所需的页在可执行文件的哪一位置,这个位置信息存储在映射关系中
    • 这是装载最核心的步骤,于是很多可执行文件也叫映像文件(Image)
  3. CPU指令寄存器设置为可执行文件的入口,程序运行

页错误

当CPU开始执行一段指令时,发现所在地址的页面是一个空页面,这就是页错误

  1. 此时操作系统会去读映射关系,找到当前页所在的VMA,计算出该页在可执行文件的位置
  2. 去物理内存中读这个页,并建立虚拟页和物理页的映射关系
  3. 回到先前的地址,继续执行指令

VMA

VMA除了可以映射段,还会映射堆(Stack)和栈(Heap)

VMA类型 权限 能否执行
代码VMA 只读 可执行
数据VMA 读写 可执行
堆VMA 读写 可执行
栈VMA 读写 不可执行

段地址对齐

x86处理器默认页的大小为4096字节,于是物理地址和虚拟地址进行映射时,虚拟内存空间的大小应该为4096字节的整数倍

动态链接

静态链接浪费内存空间、磁盘空间、难以更新

动态链接将链接推迟到了运行(装载),能够实现库的复用,减少包体、提高缓存命中率

动态链接升级模块时,理论上只需要重新编译、替换动态库的模块,可以实现插件系统

Linux下动态库是动态共享对象(DSO),以.so结尾

Windows下动态库是动态链接库(DLL),以.dll结尾

评论