编译、链接、加载与运行¶
约 1908 个字 预计阅读时间 6 分钟
编译¶
-
编译的主要目的是将人类可读的源代码,翻译成计算机能理解的、但是尚未封装好的半成品——目标文件(
.o文件) -
这个阶段通常分为3个小步骤:
-
预处理(Preprocessing)
-
拷贝标准库里的文件代码
-
宏展开
-
条件判断保留或删除代码块
- 输入:
hello.c,输出:hello.i
-
-
编译(Compilation proper)
-
编译器进行词法分析、语法分析、语义分析和优化,最终将C代码转换成特定CPU架构的汇编代码
-
输入:
hello.i,输出:hello.s
-
-
汇编(Assembly)
-
汇编器将汇编指令翻译器二进制的机器码
-
输入:
hello.s,输出:hello.o
-
- 此时已经是二进制文件了,包含了所有函数的机器码,但是仍是一个半成品。比如,在调用
printf()的时候,hello.o文件里其实printf的地址是未知的,只有一个占位符,表示需要用到这个printf函数,要将地址告诉它
链接¶
-
将多个
.o文件和所需的库文件组合在一起,得到完整的可执行文件 -
链接器主要执行两个核心任务:
-
符号解析(Symbol Resolution)
- 检查所有
.o文件,确保每一个占位符(比如对printf的引用)都能在其他的.o文件里找到对应的实体,如果找不到,就会报错undefined reference to...
- 检查所有
-
重定位(Relocation)
-
计算出所有函数和变量在最终可执行文件里确切的虚拟地址,然后回去修改那些占位符(
.text中的指令),把计算出的正确地址(不是物理地址)填进去 -
链接器会将所有
.o文件中性质相同的部分合并在一起,形成最终可执行文件的不同段(Segments/Sections)
-
段的划分
text段(代码段)
-
内容:程序的二进制机器码
-
特点:只读且可执行,这是重要的安全机制,防止程序意外或恶意修改自己的指令
.rdata段(只读数据段)
-
内容:只读数据,比如字符串常量、
const型全局变量 -
特点:只读
.data段(已初始化数据段)
-
内容:已经被初始化的全局变量和静态变量(e.g.
int global_var = 100;) -
特点:可读可写
.bss段(未初始化数据段)
-
内容:未被初始化的全局变量和静态变量(e.g.
static int global_array[1024];) -
特点:可读可写。一个巧妙的优化是,在可执行文件中,
.bss段几乎不占用实际空间,只记录需要多少字节大小的内存。当程序加载时,由加载器分配一块填满0的内存给它,大大减小了可执行文件在磁盘上的体积
链接方式¶
-
把静态库(如
libc.a)的内容完整地复制一份,装订在可执行文件中 -
优点:得到的可执行文件是完全独立的,不依赖任何的外部库文件,可以随便拷贝到兼容的操作系统上执行
-
缺点:体积大,更新难
-
链接器在可执行文件里会记录一个信息:“我需要一个名为
libc.so的共享库” -
优点:体积小,易更新,节省内存(多个程序在运行时可以共享内存中同一份库的副本)
-
缺点:有依赖性,如果没有对应的
.so文件,或者版本不对,程序就无法运行(所谓的依赖地狱)
加载与运行¶
-
当我们从shell中敲下
./hello并回车时,操作系统内核的加载器开始工作 -
不过我们首先要知道,通过编译、链接得到的可执行文件里包含了什么?
可执行文件的组成
- 可执行里不是只包含了机器码,而是一个高度结构化的容器
- 文件头(ELF Header)
- 这标识了文件的各种信息:文件的类型(可执行文件、库文件),目标硬件架构(x86,ARM等),入口点地址(程序从哪里开始第一行执行)等关键信息
- 程序头表(Program Header Table)
- 这是给操作系统加载器看的蓝图。它表述了文件中哪些部分(如
.text节)应该被加载到内存的什么位置,以及加载后这块内存区域应该设置成什么权限(读、写、执行),它定义了内存“段”的布局
- 节头表(Section Header Table)
- 这是给链接器和调试器看的材料清单。它详细描述了文件里所有的Section(这与内存中的段 Segment 密切相关),比如
.text,.data,.bss,.symbol(符号表),.debug_info(调试信息)等,以及它们在文件中的位置和大小
- 其他信息
-
符号表:记录了函数名、变量名和他们地址的对应关系,是调试和动态链接的基础
-
重定位信息:在生成动态链接库(
.so)或者位置无关代码时需要 -
调试信息:如果编译时开启了
-g选项,会包含源代码行号、变量类型等丰富的调试信息
- 创建虚拟空间
- 加载器做的第一件事,不是把整个文件读进内存,而是为新程序创建一个独立的、全新的虚拟地址空间(Virtual Address Space)。这块地址从
0x0000...到0xFFFF...都属于这个新进程
- 内存映射(Memory Mapping)
-
接下来是最关键的一步,加载器会读取ELF文件的程序头表 (Program Header Table),这里面记录了哪些段需要被加载到内存,以及它们的权限。
-
加载器使用
mmap系统调用,将文件的不同段映射到虚拟地址空间中,而不是真的“复制”过去。-
将
.text段 映射到一块内存,并设置权限为 Read-Execute。 -
将
.rodata段 映射到一块内存,并设置权限为 Read-Only。 -
将
.data段 映射到一块内存,并设置权限为 Read-Write。 -
为
.bss段 申请一块匿名的、全为0的内存,并设置权限为 Read-Write。
-
-
这种“映射”是一种延迟加载技术。只有当程序真正访问到某一块代码或数据时,内核才会触发一个缺页中断(Page Fault),然后才去从磁盘文件中读取那一页的数据到物理内存中。这大大加快了程序的启动速度。
- 处理动态链接
-
如果程序是动态链接的(绝大多数程序都是),加载器会看到ELF头中指定了一个程序解释器,通常是
/lib/ld-linux-x86-64.so.2(在x86上) 或/lib/ld-linux-riscv64-lp64d.so.1(在RISC-V上)。 -
此时,内核会先加载并启动这个动态链接器。然后,动态链接器会接管后续工作:
-
读取程序所需的共享库列表(如
libc.so.6)。 -
在系统中查找并加载这些
.so文件到进程的地址空间。 -
进行运行时的重定位,修复所有对共享库函数的调用地址(这通常通过修改GOT/PLT表来完成)。
- 移交控制权
-
一切准备就绪后,内核会为程序设置好堆栈(Stack),将命令行参数(argc, argv)和环境变量压栈。
-
最后,加载器将CPU的指令指针 (Instruction Pointer / Program Counter) 设置为ELF文件中记录的入口点地址 (_start 符号),然后执行一条跳转指令。
-
至此,CPU的控制权正式从内核空间移交到了用户空间的程序手中。程序开始从
_start执行,然后调用main,一个鲜活的进程就此诞生。