可执行文件生成:入口点设置,段合并与填充

链接器把各个目标文件拼到一起,这步我习惯叫它“收尾阶段”。你想想看,前面编译、汇编折腾了半天,生成了那么多.o文件,最后一步就是把它们揉成一个能跑起来的可执行文件。这里头有三个关键动作:入口点设置、段合并、段填充。咱们一个一个说。

入口点设置:程序从哪里开始跑?

每个可执行文件都得有个入口点。对Linux下的ELF文件来说,默认入口点是_start,不是main。嗯,这里要注意:main是C语言层面的入口,_start才是真正的程序入口。

我在项目中遇到过一回,自己写了个裸机程序,忘了设置入口点,结果链接器报了个警告,然后默认从0x0开始执行——那当然跑不起来。后来我查了链接脚本,才发现入口点没指定。

入口点设置方式:

  • 通过链接脚本指定:ENTRY(_start)
  • 通过命令行参数:ld -e _start
  • 默认规则:如果没指定,链接器会尝试_startmain等常见符号

说白了,入口点就是一个符号地址。链接器在生成可执行文件时,会把入口点地址写入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段的大小,操作系统加载时再分配。这步如果没处理好,程序运行时就会访问到未初始化的内存。

整体流程:一张图说清楚

下面这张图展示了从目标文件到可执行文件的完整流程:

可执行文件生成流程 目标文件1 (.o) 目标文件2 (.o) 目标文件3 (.o) 链接器 1. 符号解析 2. 重定位 3. 段合并 4. 段填充 5. 入口点设置 可执行文件 ELF头 .text 段 .data 段 .bss 段 入口点: _start 段合并:所有 .text → 一个 .text,所有 .data → 一个 .data 段填充:对齐补齐,确保每个段从正确地址开始 入口点:写入 ELF 头 e_entry 字段 图:可执行文件生成流程

实际案例:看看链接器做了什么

咱们用一个小例子来验证一下。写两个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 -Sobjdump -h查看段信息。这两个工具能帮你快速定位段合并、对齐、填充的问题。

段填充的细节

段填充不只是对齐,还有几个细节要注意:

填充场景 填充内容 说明
段间对齐 0x00 确保下一个段从对齐地址开始
段内对齐 0x00 或 NOP 函数或数据对齐到特定边界
文件末尾 0x00 补齐到页边界
BSS段 不填充 只在内存中分配,文件中不占空间

我曾经遇到过一个坑:某个嵌入式平台要求段间填充必须是0xFF,因为它的Flash编程器会把未编程区域默认读成0xFF。结果我用了默认的0x00填充,程序加载后校验失败。嗯,从那以后,我每次写链接脚本都会检查填充策略。

段填充的注意事项:

  • 填充字节会占用文件空间,但不会占用内存(BSS除外)
  • 某些平台对填充内容有特殊要求
  • 链接脚本中的FILL命令可以自定义填充内容

好了,可执行文件生成的三个核心步骤就这些。入口点设置决定了程序从哪里开始跑,段合并把零散的目标文件拼成整体,段填充确保一切对齐整齐。这三步走完,一个能跑的可执行文件就诞生了。

公众号:蓝海资料掘金营,微信deep3321