先说一个我自己的经历。早些年我做一个网络抓包工具,程序运行十几分钟后稳定崩溃,core 文件里指针指向的地址落在一个看起来完全合理的区间。我调了两天,最后发现问题根源很朴素:我把一个字符串字面量用const修饰后仍试图就地修改,而字符串字面量被编译进了只读的段(.rodata),一写就触发段错误。从那天起我就明白,段(Section)不是编译器内部自嗨的概念,它直接决定你的程序到底能在哪里读、在哪里写、在哪里执行,也决定了链接器最终把你的代码和数据"放"到内存什么位置。
这篇文章就围绕这件事展开:编译器和链接器如何生成段、如何给段定址,以及看懂段结构之后你能拿来做什么——包括排查崩溃、压缩固件体积、定制内存布局。适合写 C/C++、做嵌入式、搞代码体积优化的同学,也适合任何被"程序为什么跑飞了"折磨过的人。
1. 段不是编译器的"内部洁癖",而是程序运行的底层需求
1.1 一个崩溃现场引发的追问
回到开头那个崩溃现场。我用的是一个很老练的调试姿势:先看 core 文件里 RIP 指向的地址,再对应当前进程的/proc/pid/maps映射表,判断这个地址属于哪块区域。结果发现 RIP 指向的页面权限是r-xp,只读可执行,没有写权限。我当时就愣住了:我明明有一个合法的char*指针,怎么会写到只读页面里去?
后来翻汇编才反应过来:编译器把我的字符串常量丢进了.rodata段,链接器又把这个段和.text代码段放在相邻区域,最终页面被映射成只读。程序层面的"合法指针"和系统层面的"页面权限"发生了冲突。这其实是最典型的一类段错误——不是你越界了,而是根本不该写那块地方。
这个例子说明一个很实在的道理:程序不是一堆同质的字节,而是由不同性质的数据拼成的。指令需要被执行,所以页面要可读可执行;全局变量要被修改,所以页面要可写;字符串常量只读不写,放在一起既安全又省事。段就是编译器对这些性质差异做出的第一层归档。
1.2 程序里那些"性格迥异"的数据
如果把一个可执行文件拆开看,里面的内容大致有这几种:
- 机器指令:只读,需要执行权限,体积最大;
- 已初始化的全局变量和静态变量:需要启动时就有值,运行时要读要写;
- 未初始化的全局变量和静态变量:不关心初始值,只要清零即可;
- 字符串字面量和常量:只读,生命周期等于程序运行期。
这些内容如果全塞在一个区域里,会有几个棘手问题。第一,权限没法区分:一个区域要么全部可读可写,要么只读,混在一起就必须按最宽松的权限来,代码段可写会给攻击者留后门;第二,空间浪费:未初始化的变量如果在磁盘上每个都占一个零,大数组会膨胀得没法看;第三,无法共享:多个进程同时跑同一个程序,代码部分其实可以共享物理内存,但如果代码和数据搅在一起就没法共享了。
所以 ELF 这类二进制格式从设计上就把内容切成段。编译阶段由编译器负责把源文件内容装进不同的段,链接阶段由链接器负责把这些段从各个目标文件中取出来、合并、定址,最终交给装载器按段权限映射进内存。名字虽然五花八门,最核心的永远是.text、.data、.bss、.rodata这几个。
段的存在,往小了说是编译器的工作习惯,往大了说,它是现代系统实现权限隔离、进程共享、按需分页这些机制的地基。
2. 编译器手工作坊:从源代码到段指令的完整过程
2.1 先把 C 语言翻译成汇编:段指令第一次现身
很多人以为段是链接器造出来的,其实不是。编译器在生成汇编代码的时候,就已经用.section伪指令给每段内容打了标签。为了看清楚这个过程,拿下面这段极简 C 代码演示:
#include <stdio.h> int global_init = 42; /* 已初始化全局变量 -> .data */ int global_uninit; /* 未初始化全局变量 -> .bss */ const char *message = "Hello, Section!"; /* 字符串常量 -> .rodata */ static int static_val = 7; int add(int a, int b) { return a + b; } int main(void) { printf("%s: %d\n", message, add(global_init, global_uninit)); return 0; }用gcc -S demo.c生成汇编,略去次要指令后,核心结构是这样的:
.section .rodata .LC0: .string "Hello, Section!" .section .data .globl global_init .align 4 global_init: .long 42 .section .bss .globl global_uninit .align 4 global_uninit: .zero 4 .section .text .globl add add: leal (%rdi,%rsi), %eax ret注意.long和.zero的区别:global_init在.data段里实实在在占了 4 个字节,并填入了初始值 42;global_uninit在.bss段里只声明了 4 个字节的空间,文件里并没有真实字节存在,程序启动时由内核或启动代码统一清零。这就是 .bss 的本质:只记账,不占磁盘。
.string指令则把字符串字面量放进了.rodata。static_val没有出现在汇编指令里,是因为编译器发现它从未被使用,直接优化掉了。如果你让它被真正引用,会看到它也放在.data里。
2.2 段的基本盘:.text、.data、.bss、.rodata 各自装着什么
| 段名 | 存放内容 | 典型权限 | 是否占用文件空间 |
|---|---|---|---|
| .text | 编译后的机器指令 | R-X | 是 |
| .rodata | 字符串字面量、const 常量 | R-- | 是 |
| .data | 已初始化的全局/静态变量 | RW- | 是 |
| .bss | 未初始化或零初始化的全局/静态变量 | RW- | 否,仅记录大小 |
除此之外,目标文件里还有一些"辅助段":.symtab是符号表,记录函数名和全局变量名;.strtab/.shstrtab存字符串名字表;.rela.text是重定位表,记录哪些位置的地址要等链接时再填;.comment则记录了编译器版本信息。这些段对运行没有直接作用,但对调试和链接至关重要。
有几个细节特别容易踩坑:
- 函数里的局部变量不在任何段里。它们活在栈上,函数返回就没了,所以段里看不到它们的影子。
- 函数里的 static 局部变量是全局生存期,所以会进
.data或.bss,而不是留在栈上。 - const 全局变量通常进
.rodata,但如果它的初始化值依赖另一个可重定位符号的地址,或者某些平台的链接器策略不同,也可能落进.data。看到 const 变量内存可写时别急着怀疑编译器,先查一下它的初始化方式。 - 不同平台的段名不完全一样。Windows 的 PE 格式里叫
.text、.data的也有,但 macOS 的 Mach-O 用的是__TEXT,__text这种双段名写法。理解思路,名字只是皮囊。
汇编器拿到这些.section指令后,会把同名的内容聚合,生成目标文件里的段头表和段数据。这时的目标文件还只是"半成品":每个段的地址几乎都是空的,等着链接器来填。
3. 链接器定盘:从"各怀心事的.o文件"到"一家人的可执行文件"
3.1 合并同类段:链接器如何把多个目标文件拼在一起
编译单元各自为政时,每个.o文件都有自己的.text、.data、.bss。链接器的第一件事,就是按名字合并同类段——所有.o的.text拼成一个大.text,所有.data拼成一个大.data,依次类推。这一步解释了为什么链接结果的段顺序和你的编译顺序并不直接一致,而是由链接器脚本统一编排。
但合并只是开始。链接器要处理的第二个问题是符号决议:main里调用了add,而这个add在另一个.o文件里定义,必须把声明和定义对上。链接器遍历每个目标文件的符号表,把"未定义符号"逐个匹配到其他文件的"已定义符号"。匹配不上的要么报错,要么保留为动态符号留给动态库在加载时解析。
第三个问题才是重头戏——给每个段分配最终虚拟地址。以 x86-64 Linux 上传统的非 PIE 可执行文件为例,链接器脚本默认把.text放在基地址0x400000之上,各个输入段按对齐要求依次往后排;.data、.bss紧跟其后。现代发行版默认开 PIE,直接链接出来的可执行文件地址会显示得比较小(比如从0x110之类的基础偏移开始),加载时由内核随机化选一个基地,但段与段的相对布局关系不变。
说到这里必须强调一个容易混淆的点:readelf -S里看到的 Address 列,在链接完成的可执行文件里才是"真地址";如果打开的是.o文件,地址几乎全是 0,因为还没链接。定位这件事,只发生在链接阶段,不在编译阶段。这也是标题里"生成与定位"两个词的分工:编译器负责生成段,链接器负责定位段。
3.2 两种符号表:重定位表才是地址分配的真正施工图
给段定址听起来简单,真正麻烦的是段内部的指令怎么跟着改。举例来说,main里有一句call add,编译汇编时,add的地址并不知道,所以汇编器随手写了一个占位地址,同时在.rela.text表里记了一笔:这里有个重定位项,指向符号add,类型是R_X86_64_PLT32。
用readelf -r demo.o能看到类似这样的输出:
Relocation section '.rela.text' at offset 0x2a0 contains 3 entries: Offset Info Type Sym. Value Sym. Name 000000000001 000800000004 R_X86_64_PLT32 000000000000 add 00000000000c 00000000000a R_X86_64_PC32 000000000000 message 000000000013 00000000000b R_X86_64_PC32 000000000000 printf读法很简单:Offset是这条重定位在.text段内的偏移;Type给出地址计算方式;Sym. Name表示要引用哪个符号。链接器在确定所有段地址之后,会拿着这张表逐条回填指令里的地址字段。
这里有一个很重要的分水岭:地址是 PC 相对还是绝对。call指令在 x86-64 上默认是相对寻址,所以重定位类型是R_X86_64_PLT32,最终填进去的值是"目标地址减去当前指令下一条地址的差"。如果改成绝对寻址(比如通过指针加载全局变量地址),则是R_X86_64_64之类的类型,填的就是完整 64 位地址。我之前遇到过一种奇怪现象:把一个 .o 从静态链接改成共享库后,某些全局变量访问全部变成通过 GOT 间接进行,原因就是 PC 相对与绝对寻址在编译模型上的差异彻底改变了。这个知识点如果不从重定位表入手,几乎不可能想明白。
3.3 加载进内存之后:虚拟地址空间里的最终形态
链接器输出的可执行文件在磁盘上是一堆段,但内核装载它时看的不是段头表,而是程序头表(Program Header)。装载器找到几个LOAD类型的程序头,把对应区域映射进虚拟内存。
一个典型 ELF 可执行文件映射到内存后,大致是这个布局:
| 区域 | 内容 | 权限 | 说明 |
|---|---|---|---|
| 代码段 | .text + .rodata + .init_array 等 | r-x(只读可执行) | 进程间可共享物理页面 |
| 数据段 | .data + .bss | rw-(可读写) | .data 有磁盘内容,.bss 没有 |
| 堆区 | 动态分配 | rw- | 从数据段上方往高地址增长 |
| 栈区 | 函数调用栈 | rw- | 从高地址往低地址增长 |
有意思的是,.rodata明明是可读不可写的,却通常被并入第一个LOAD段,也就是代码段。这意味着代码段权限是 r-x 而不是纯 x,读是允许的,只是写不行。很多人以为字符串常量在数据段,实际上它和代码待在一起。
用cat /proc/pid/maps看一个正在运行的程序,会出现这样的条目:
555555554000-555555556000 r-xp 00000000 08:01 1234567 /path/to/demo 555555556000-555555559000 r--p 00001000 08:01 1234567 /path/to/demo 555555559000-55555555a000 rw-p 00002000 08:01 1234567 /path/to/demo第一段 r-x 是代码,第二段 r-- 是只读数据,第三段 rw- 是可写数据。注意 bss 并不单独占一个映射区域,它跟在 .data 后面,靠 memsz 大于 filesz 的那部分空间来体现。这个概念对理解"为什么未初始化全局数组不占二进制体积"至关重要。
4. 拿工具给二进制做体检:readelf 和 objdump 的正确打开方式
4.1 查看段头表:每个段的长宽高都在这里
讲了一堆理论,总要落地验证。最常用的体检工具是readelf -S,它会列出可执行文件的完整段头表:
readelf -S demo输出大致是这样的(字段做了简化):
Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 1] .text PROGBITS 0000000000001040 00001040 00000076 00000000 AX 0 0 16 [ 2] .rodata PROGBITS 0000000000002000 00002000 00000010 00000000 A 0 0 4 [ 3] .data PROGBITS 0000000000004008 00003008 00000008 00000000 WA 0 0 8 [ 4] .bss NOBITS 0000000000004010 00003010 00000004 00000000 WA 0 0 8关键字段逐个说清楚:
- Name / Type:段名和类型。
PROGBITS表示普通程序内容,NOBITS就是 .bss 这种不占文件空间的段。 - Address:链接器分配的虚拟地址。PIE 下是相对基址偏移,非 PIE 下是绝对地址。
- Offset:段内容在文件中的偏移。
- Size:段的大小。注意 .bss 的 Size 是实际运行时要占用的字节数,比如 4,但你在文件中找不到这 4 个字节。
- Flags:运行期权限标志。
A表示可分配(Alloc),X表示可执行,W表示可写。没有A标志的段(比如.comment)不会加载进内存。
.bss的 Type 是NOBITS,这是最直观的一个证据:同样叫段,有的段真实存在,有的段只是一个"未来的承诺"。如果你看到一个段有WA但 Type 是NOBITS,那就是靠启动代码清零的未初始化数据。经验之谈:排查全局变量为什么不是预期初始值时,先确认它进的是.data还是.bss。如果忘了在定义时赋初值,C 标准只保证它清零,不会保证它等于你想要的值。
另外一个我常用的命令是objdump -h,它输出的信息和readelf -S互补,胜在同时显示各段的装载地址(LMA)和虚拟地址(VMA)。嵌入式场景里这俩值经常不一致,靠它一眼就能看出来。
4.2 段与程序头的分界:链接器看 Section,装载器看 Segment
新手最容易把 Section 和 Segment 混为一谈,这俩词的翻译都有点尴尬。我的理解方式非常粗暴:Section 是链接器的语言,Segment 是装载器的语言。
链接器用 Section 来组织和合并内容,它关心段的名字、属性、需要哪些符号;内核装载器用程序头(Program Header)来找"哪块文件内容映射到哪个虚拟地址、权限是什么"。前者是静态视图,后者是运行时视图。一个 Section 不一定对应一个 Segment,多个 Section 可以合并进同一个 Segment,比如 .text 和 .rodata 往往就在同一个 LOAD 段里。
用readelf -l看程序头:
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x00000000 0x00000000 0x000f8 0x000f8 R E 0x1000 LOAD 0x000f8 0x000010f8 0x000010f8 0x00030 0x00050 RW 0x1000看出门道了吗?第一个 LOAD 段的FileSiz和MemSiz相等,第二个 LOAD 段的MemSiz比FileSiz大——多出来的那部分就是 .bss。装载器看到这个差值就知道:文件里只有这么多数据,但映射时要多留出一片清零空间。
有一个特别能说明 Section 与 Segment 关系的实验:给可执行文件执行strip,把符号表和一堆调试相关 Section 删掉,程序照样跑得飞起。为什么?因为装载器只看程序头,不看段头。运行时真正需要的是 Segment,Section 只是链接和调试阶段的产物。想明白这一层,很多关于"为什么我删了段程序还正常"的疑问都会烟消云散。
5. 理解段机制之后,你能做的那些优化和排查
5.1 链接脚本:把段强行安排在指定地址
段机制最硬核的应用,是嵌入式开发里的链接脚本。没有操作系统帮你管理内存布局时,你得告诉链接器:代码放 Flash 哪个地址、数据放 RAM 哪个地址、bss清零段多大。这个任务由ld -T指定的链接脚本完成。
一个典型的链接脚本片段长这样:
ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } SECTIONS { .text : { *(.text) *(.rodata) } > FLASH .data : { _sdata = .; *(.data) _edata = .; } > RAM AT> FLASH .bss : { _sbss = .; *(.bss) _ebss = .; } > RAM }这里要重点解释.data的那一行> RAM AT> FLASH:前面的> RAM指的是运行时虚拟地址(VMA),后面的> FLASH指的是装载地址(LMA)。为什么会有两个地址?因为.data里的全局变量有初始值,初始值必须烧录在 Flash 里,但运行时变量要待在 RAM 里。所以启动代码要多做一步:在main之前把.data从 Flash 拷贝到 RAM,再把.bss清零。链接脚本里出现的_sdata、_edata、_sbss、_ebss这些符号,就是为了让启动代码知道拷贝和清零的边界。
我见过不少新手在这里翻车:链接脚本漏了AT> FLASH,结果.data的 LMA 和 VMA 一样,变量初始值根本没被正确拷贝,运行起来全局变量全是乱值。排查这类问题的标准动作就是readelf -l,对比每个 LOAD 段的VirtAddr和PhysAddr是否一致。
5.2 段级裁剪与常见误区
理解了段之后,二进制瘦身和很多疑难杂症都变得有章可循。
做法一:开启函数段隔离再回收。默认情况下,一个.o文件的所有函数都在同一个.text段里,链接器要么全保留、要么全丢弃。加上-ffunction-sections后,每个函数独立成段,再由链接器--gc-sections回收掉没被引用的段。对于从静态库链接的固件,这招经常能把体积砍掉三分之一以上。数据同理,对应选项是-fdata-sections。
gcc -ffunction-sections -fdata-sections -Wl,--gc-sections -o demo *.c做法二:用自定义段做初始化表。__attribute__((section(".init_array")))这类机制,本质上就是让你手动控制某个变量或函数指针落在哪个段,再把多个段按固定顺序排列,形成一张启动时统一遍历的表。很多框架的自动注册器都是这么干的。理解了段,你就能理解这套"魔法"背后其实就是在摆弄段头表。
常见误区一:认为 .bss 里的变量必然初始化为零。理论上是,但前提是启动代码真的去清了.bss。在嵌入式没走标准启动文件,或者链接脚本里.bss的_sbss/_ebss符号没定义时,它可能就是随机值。排查思路:先用nm查看变量落在哪个段,再决定是去查拷贝代码还是清零代码。
常见误区二:混淆段对齐与缓存行对齐。段头表里有个 Align 字段,它决定的是地址对齐要求,不是性能对齐。如果你为了提高缓存命中率,希望某个热变量对齐到 64 字节,需要的是变量级的aligned属性,而不是修改段对齐。段对齐主要影响内存映射的页对齐和重定位时的地址计算,两者的作用域完全不同。
常见误区三:认为地址列的值就是运行时的最终地址。在 PIE 环境下,readelf -S显示的地址是相对基址,运行时还得加上一个随机基址。所以我在排查问题时,总是以/proc/pid/maps里的实际映射为准,以readelf -S为辅。有一次我在对比两个相似版本的二进制时,发现符号地址整体偏移了一截,差点误判成功能变化,其实就是 PIE 基址不同。
最后说一点个人体会。段这个东西,表面上是二进制格式里的几行表格,实际上是整个程序"从源文件到可寻址内存"这条链路的浓缩。排查崩溃时,我看一眼 core 里的出错地址落在哪个段,心里就已经有三成把握;优化体积时,我对着段大小分布能直接判断该开哪项裁剪;写链接脚本时,段更像是我和硬件地址空间之间的翻译官。多花一个下午把readelf -S的输出从头到尾读一遍,往后省下的可能是一周又一周的调试时间。