可执行文件生成:入口点设置,段合并与填充
链接器把各个目标文件拼到一起,这步我习惯叫它“收尾阶段”。你想想看,前面编译、汇编折腾了半天,生成了那么多.o文件,最后一步就是把它们揉成一个能跑起来的可执行文件。这里头有三个关键动作:入口点设置、段合并、段填充。咱们一个一个说。
入口点设置:程序从哪里开始跑?
每个可执行文件都得有个入口点。对Linux下的ELF文件来说,默认入口点是_start,不是main。嗯,这里要注意:main是C语言层面的入口,_start才是真正的程序入口。
我在项目中遇到过一回,自己写了个裸机程序,忘了设置入口点,结果链接器报了个警告,然后默认从0x0开始执行——那当然跑不起来。后来我查了链接脚本,才发现入口点没指定。
入口点设置方式:
- 通过链接脚本指定:
ENTRY(_start) - 通过命令行参数:
ld -e _start - 默认规则:如果没指定,链接器会尝试
_start、main等常见符号
说白了,入口点就是一个符号地址。链接器在生成可执行文件时,会把入口点地址写入ELF文件头的e_entry字段。操作系统加载程序时,直接跳转到这个地址开始执行。
段合并:把零散的东西拼起来
各个目标文件都有自己的.text、.data、.bss段。链接器要做的,就是把所有.text合并成一个大的.text段,所有.data合并成一个大的.data段,以此类推。
这个过程不是简单的拼接。链接器会按照链接脚本中的布局规则,把同类型的段放到连续的内存区域。举个例子:
// 链接脚本片段
SECTIONS {
.text : {
*(.text) // 所有目标文件的.text段
}
.data : {
*(.data) // 所有目标文件的.data段
}
.bss : {
*(.bss) // 所有目标文件的.bss段
}
}
你想想看,如果两个目标文件都有.text段,链接器会按照它们在命令行中出现的顺序,依次合并。我建议你在写链接脚本时,明确指定段的排列顺序,这样能避免一些奇怪的内存布局问题。
段合并的注意事项:
- 同类型段合并时,段内符号的相对顺序保持不变
- 不同目标文件的同名段,按链接顺序合并
- 段对齐要求:合并后的段起始地址必须满足最大对齐要求
段填充:对齐和补齐
段合并之后,还有个麻烦事——对齐。每个段都有自己的对齐要求,比如.text段可能要求4字节对齐,.data段可能要求8字节对齐。链接器需要在段之间插入填充字节,确保每个段都从正确的地址开始。
我曾经调试过一个性能问题,发现程序在某个函数调用时总是慢一拍。查了半天,原来是段填充导致指令缓存命中率下降。嗯,从那以后,我对段对齐就特别上心。
段填充的坑:
- 填充字节默认是0,但有些平台要求特定值(比如0x90是NOP指令)
- 填充过多会导致可执行文件膨胀
- 某些安全机制(如ASLR)会受填充影响
段填充还有一个作用:补齐。比如.bss段在文件中不占空间,但加载时需要分配内存。链接器会在文件头中记录.bss段的大小,操作系统加载时再分配。这步如果没处理好,程序运行时就会访问到未初始化的内存。
整体流程:一张图说清楚
下面这张图展示了从目标文件到可执行文件的完整流程:
实际案例:看看链接器做了什么
咱们用一个小例子来验证一下。写两个C文件:
// a.c
int global_var = 42;
void func_a() {
global_var++;
}
// b.c
extern int global_var;
void func_b() {
global_var--;
}
编译成目标文件后,用readelf -S看看段信息:
$ gcc -c a.c b.c
$ readelf -S a.o
[Nr] Name Type Address Offset Size
[ 1] .text PROGBITS 00000000 00000034 00000014
[ 2] .data PROGBITS 00000000 00000048 00000004
$ readelf -S b.o
[Nr] Name Type Address Offset Size
[ 1] .text PROGBITS 00000000 00000034 00000014
[ 2] .data PROGBITS 00000000 00000048 00000000 // 注意:b.o没有.data
链接之后:
$ gcc a.o b.o -o prog
$ readelf -S prog
[Nr] Name Type Address Offset Size
[ 1] .text PROGBITS 08048094 00000094 00000028 // 两个.text合并了
[ 2] .data PROGBITS 080490bc 000000bc 00000004 // 只有a.o的.data
看到了吗?.text段大小从0x14+0x14=0x28,正好合并。地址也从0变成了0x08048094,这是链接器分配的实际加载地址。
个人经验:我建议你在调试链接问题时,多用readelf -S和objdump -h查看段信息。这两个工具能帮你快速定位段合并、对齐、填充的问题。
段填充的细节
段填充不只是对齐,还有几个细节要注意:
| 填充场景 | 填充内容 | 说明 |
|---|---|---|
| 段间对齐 | 0x00 | 确保下一个段从对齐地址开始 |
| 段内对齐 | 0x00 或 NOP | 函数或数据对齐到特定边界 |
| 文件末尾 | 0x00 | 补齐到页边界 |
| BSS段 | 不填充 | 只在内存中分配,文件中不占空间 |
我曾经遇到过一个坑:某个嵌入式平台要求段间填充必须是0xFF,因为它的Flash编程器会把未编程区域默认读成0xFF。结果我用了默认的0x00填充,程序加载后校验失败。嗯,从那以后,我每次写链接脚本都会检查填充策略。
段填充的注意事项:
- 填充字节会占用文件空间,但不会占用内存(BSS除外)
- 某些平台对填充内容有特殊要求
- 链接脚本中的
FILL命令可以自定义填充内容
好了,可执行文件生成的三个核心步骤就这些。入口点设置决定了程序从哪里开始跑,段合并把零散的目标文件拼成整体,段填充确保一切对齐整齐。这三步走完,一个能跑的可执行文件就诞生了。