前面讲到编译器最后会生成目标文件,但是目标文件到底是什么?我们需要深入理解。
目标文件格式

PC平台的可执行文件格式 ,在Windows下是PE(Portable Executable) ,在Linux下是ELF(Executable Linkable Format) ,它们都是COFF(Common Object File Format) 的变种。
目标文件 就是源代码编译后但未进行进行链接的那些中间文件。在Window下是 .obj 文件对应PE-COFF格式 ,在Linux下是 .o 文件对应ELF格式
从广义上来看,目标文件和可执行文件的区别不是特别大,为了学习考虑,可以把这两种文件看成一种文件格式,即,Windows下的PE ,Linux下的ELF ,又因为这俩格式师出同源,都是COFF 衍生出来的,所以本章重点关注Linux下的ELF即可。
不只是可执行文件按照ELF 格式,包括动态链接库(DLL,Dynamic Linking Library) (Windows下的.dll和linux下的.so)、静态链接库(Static Linking Library)(Windows下的.lib,linux下的.a),这些都是按照可执行文件格式存储的。
静态链接库可以看成多个目标文件捆绑在一起,并加上索引,在编译阶段被链接到每个程序当中,运行时,每个进程中都会有对应的代码副本。
动态链接库只在运行时才链接到程序中,在所有进程中是共享的一段代码。
目标文件和可执行文件的格式跟操作系统和编译器的历史发展密切相关。
查看目标文件格式

可执行文件格式

动态链接库的文件格式
.6后缀就是版本号的区别,不用管

目标文件是什么样的
首先,目标文件,肯定是编译器的产物,那应该有编译器后的机器指令代码、数据。
除了这些基本信息,还要包括,链接时的必要信息,比如符号表,调试信息,字符串等。
为了方便管理,目标文件把这些信息按不同的属性,以段(Segment) 的形式存储。这些段都是一个固定长度的区域。
如果对于目标文件来说,他里面的每个内容,其实都是节,链接之后,很多目标文件会组成一个可执行文件,此时,很多节才会组成一个段,本文为了简单,就全部叫段。
先来看一个C语言代码,如果按照上面的思路,每个函数块本身应该放到一起,叫他函数段,全部的变量定义应该放在一起,叫他变量段。
c
#include <stdio.h>
int global_init_var = 84;
int global_uninit_var;
void func1(int i) {
printf("%d\n", i);
}
int main() {
static int static_var = 85;
static int static_var2; // 函数内的静态局部变量,整个运行时存在,但是仅有函数内部可以调用到
int a = 1;
int b;
func1(static_var + static_var2 + a + b);
return 0;
}
下面我们看看,真实场景ELF格式会怎么存储,如图所示。
.text
段就是保存的函数内容,.data
段保存的全局变量和静态变量。
这里面的局部变量怎么也到text里面了呢?
只是编译后操作这些局部变量的机器指令在栈中,这些局部变量本身不在的。

File Header
文件头,用来描述文件属性,文件是否可执行,目标硬件,目标操作系统等信息。
.text
段,C语言编译后的机器语句都保存在这里。
.data
段,已经初始化的全局变量和局部静态变量都保存这里,因为他们的生命周期都跟程序一致。 .bss
段,没有初始化的全局变量和局部静态变量,在这里,因为默认是0,所以就不占用data
的空间了。这只是标记运行有多少个数据没有初始化而已,他们并不占用ELF文件的空间。
BSS
最初是汇编器中的一个伪指令,用来给符号预留一块内存空间。后来逐渐用来表示要给程序没有初始化的变量预留空间的大小。
本质上ELF
格式是把程序分为两部分,指令(.text
)和数据(.data
和.bss
)。
为什么要这么做呢?
- 当程序装载到内存,运行时,要映射到虚拟内存,这时候要管理不同内存的访问权限,对于
.text
来说,都是只读的,对于.data
和.bss
来说,都是可读可写的。这样子划分内存区域,可以防止指令被恶意改写。 - CPU从内存读数据时,不是一次读一个,而是一次读一行,放在CPU自己的缓存中,CPU是有指令缓存和数据缓存的,如果内存的分布是指令数据混杂,那对于CPU缓存来说很不友好,所以分开可以提高CPU的缓存命中率。
- 当同时运行多个程序副本时,只读区域的
.text
段是可以共享的,极大的提高了内存利用率。
挖掘目标文件内部细节
真正了不起的程序员对自己的程序每一个细节都了如指掌。
还是以刚才的C语言文件为例,我把他命名为SimpleSection.c
,继续分析。
先编译成目标文件, -c
代表只编译不链接
r
gcc -c SimpleSection.c

查看目标文件内部的信息, -h
把ELF每个段的基本信息打印出来。
objdump -h SimpleSection.o

先认识每个字段,这里所有数值都是十六进制的
Size 代表段的大小
VMA Virtual Memory Address 程序运行时,在虚拟内存中的逻辑地址 。
LMA Load Memory Address 存储介质(如磁盘、Flash)中的物理存储地址
File off 代表段的起始地址
Algn 代表对齐参数,都是2的幂,2**0
代表无须对齐, 2**2
就是四字节对齐
因为目标文件是没有被链接器链接过的,所以VMA和LMA都是0,符合预期。
每个段下面一串英文代表的含义。
标志 | 含义 | 常见段示例 |
---|---|---|
CONTENTS |
段在文件中有实际内容 | .text , .data |
ALLOC |
段需要在运行时分配内存 | .text , .data , .bss |
LOAD |
段需从文件加载到内存 | .text , .data |
RELOC |
段包含重定位信息(未链接的目标文件) | .text (未链接时) |
READONLY |
段在内存中只读 | .text , .rodata |
CODE |
段包含可执行代码 | .text |
DATA |
段包含数据 | .data , .rodata |
每个段的含义。
段名 | 作用 | 关键标志 |
---|---|---|
.text |
可执行代码 | CODE , READONLY |
.data |
已初始化全局/静态变量 | DATA , CONTENTS |
.bss |
未初始化全局/静态变量 | ALLOC |
.rodata |
只读数据(如字符串常量) | READONLY , DATA |
.comment |
编译器/链接器版本信息 | READONLY |
.note.GNU-stack |
控制栈的可执行性 | READONLY |
.note.gnu.property |
程序属性和安全策略 | READONLY , DATA |
.eh_frame |
异常处理和栈展开信息 | READONLY , DATA |
明白每个字段含义之后,画个图看看。
从下到上开始分配地址,只画Content的段。只把bss段排除掉,因为他在运行时才分配地址,不占用文件空间。
只关注text data 这些重要的,其他的.note的这些段就省略了~

可以用size
这个命令查看ELF文件的代码段,数据段,BSS段的长度。

在这个命令里面,text比我们刚才输出的size大很多,这是因为他的计算方式如下
.text
(100) + .rodata
(4) + .eh_frame
(88) + .note.gnu.property
(32) = 224
意义是所有标记为 ALLOC
且可加载到内存(LOAD
)的只读段(如代码、只读数据)
代码段
objdump可以显示目标文件的各种信息,用-s
段的内容打印出来,-d
可以把所有包含指令的段反汇编。
yaml
objdump -s -d SimpleSection.o
-------------------------------------------------------------
SimpleSection.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 75203133 .GCC: (Ubuntu 13
0010 2e332e30 2d367562 756e7475 327e3234 .3.0-6ubuntu2~24
0020 2e303429 2031332e 332e3000 .04) 13.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 <func1>:
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 <func1+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 <func1+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
从上到下,慢慢分析,不要慌,ontents of section .text代表.text
段的内容,首先声明,这章里面所有数值都是16进制的。所以下面一大串数字也是16进制的表现形式。
最左边黄框里面,是地址偏移量
中间红框内的是16进制表示的机器码 ,单独拿第一行的第一块来说f30f1efa
,首先这是16进制的表现形式,对应0xf3, 0x0f 0x1e 0xfa
,每一个16进制的数,比如0xf3
对应的二进制是 11110011
是8位(Bit),根据公式 1Byte=8Bit
,所以每一块是四个Byte,就是四字节,相应的红框内的每一行代表16个字节(16Byte),每块是4字节。
这样子计算的话,地址偏移量是0x60 , 加上一块4字节(0x04),正好是0x64,跟前文的ELF文件格式对应上了!
最右边蓝框里面是ASCII码字符,因为大部分机器码用ASCII去表示,都映射成了稀奇古怪的字符上,所以看着像乱码,一般可以用来查看那些本身是字符常量的信息。

那么每个字节的机器码到底是什么意思呢?需要结合下面的反汇编代码<func1>
,一起看。
看图中红色标记的,他们是一一对应的。中间就代表了所有机器指令。


为什么有的是四个字节对应一句汇编指令,有的是一个,有的是六个呢??
因为我这机器是x86架构,他本身是可变长度指令,当CPU读到第一个字节时,也就是第一个操作码,就可以根据对应指令预先确定好的规格,顺序向后读取x个字段。具体的指令和编码方式是由指令集架构设计决定的,也就是常说的
ISA
数据段和只读数据段
.data
用于存放,已经初始化了的全局静态变量和局部静态变量。 我们在代码中一共两个,分别是global_init_var
和 static_var
,他俩都是int定义的,每个int对应四字节,所以他俩在.data
中一共占用8个字节。正好对应上了!

我们源代码里面还有个printf
用到了%d\n
,这是一种只读数据,所以被放在了.rodata
中。因为字符串 "%d\n"
包含3个字符(%
, d
, \n
) + 1个终止符 \0
,共4字节,所以.rodata
段中对应的条大小为 4 字节。这个段是只读数据,在语义上可以支持C++的const关键字,操作系统还可以把它单独映射成只读,保证程序安全。
再观察一下,数据段。两个int数据,第一个0x54000000
代表84
(16进制转换10进制)。
为什么是 0x54000000
而不是 0x00000054
呢?这个计算机看起来是从左到右,也就是从低位往高位读的(看成数组)。
yaml
地址 0000: 54 → 低位字节
地址 0001: 00
地址 0002: 00
地址 0003: 00 → 高位字节
这就是经常说的小端序, 大部分计算机都是小端序,除了网络协议,比如TCP/IP因为历史问题使用大端序。
BSS段
bss段存放的是未初始化的全局变量和局部静态变量 ,如上述代码中 global_unint_var
和 static_var2
。更准确的说法是bss段是为这两个变量预留的空间。
虽然bss段对应的size是8字节。但是这个size表示的是加载到内存时,需要的空间大小,在硬盘保存的时候,只是标记一下,没有直接占用硬盘空间。
打印一下符号表:
objdump -s -d -x SimpleSection.o
最左边是地址偏移量,代表这个符号在ELF文件中的地址
第二个字符代表作用域,l
代表local,局部作用域,g
代表global,全局作用域。
第三个字符代表符号的类型,df
调试符号(debug) + 文件(file)。
第四个字符代表所在段的类型, 这里有个特殊的 ABS
绝对符号,不与任何段关联。
第五个,代表符号的大小,比如 SimpleSection.c 对应的,size就是 0.

从执行结果看, 红框标记的 static_var2
和 global_uninit_var
确实在 bss段中,图中还有个黄色的,其实是用来标识程序中每个段的元数据。
当我们给程序新加两个变量,猜猜他们会在那个段中?

因为x1的0,可以认为是未初始化的,所以被优化掉,存放在bss段中了。

其他段
除了 text data bss 这些,还有一些段。
段名 | 含义 |
---|---|
.shstrtab |
存储所有段的名字(段名字符串表)。 |
.symtab |
符号表,记录全局符号(函数、变量名等)。 |
.strtab |
存储符号名称的字符串表。 |
.rel.dyn / .rela.dyn |
动态重定位表,记录动态链接时需修正的地址偏移。 |
.rel.plt / .rela.plt |
PLT(过程链接表)的重定位信息,用于动态函数调用。 |
.dynamic |
存储动态链接所需的元数据(如依赖库、符号表地址)。 |
.got |
全局偏移表(Global Offset Table),存储动态链接的全局变量地址。 |
.got.plt |
专用于 PLT 的 GOT,存储动态链接的函数地址。 |
.plt |
过程链接表(Procedure Linkage Table),用于动态函数调用的跳转逻辑。 |
.interp |
存储动态链接器的路径(如 /lib64/ld-linux-x86-64.so.2 )。 |
.init |
程序初始化代码(如全局对象的构造函数)。 |
.fini |
程序终止代码(如全局对象的析构函数)。 |
用.开头的是系统保留的段名,我们也可以使用一些自定义的段名,比如mySec
。
使用下面这个gcc编译器的扩展语法。
__attribute__((section(".mySec")))
c
#include <stdio.h>
__attribute__((section(".mySec"))) char my_sec_str[] = "mySec";
int main() {
return 0;
}
先编译成目标文件
shell
gcc -c mySec.c
再用ojbdump看一些。
shell
objdump -s -d -x mySec.o
发现我们自定义的
mySec
段了!。
ELF文件结构描述
从刚才的挖掘目标文件,我们可以看到一个标准的ELF文件轮廓。现在我们把这个轮廓提取出来。

从下往上看。最开始的是ELF文件头。它包含了整个文件的基本属性,比如ELF文件版本,目标机器型号,程序入口地址等。
接下来是各个段,与段有关的重要结构是段表(Section Header Table),这个表描述了ELF文件中包含的所有段的信息,比如段名,段的长度,在文件中的偏移,读写权限等。
文件头
直接用工具看这个文件的文件头吧。
shell
readelf -h SimpleSection.o

可以看到ELF魔数(Magic)
r
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
ELF文件格式的位数ELF64
数据的编码方式2's complement, little endian
低位字节在前(小段序
Version: 1(current),ELF 标准版本,目前始终为 1
。
OS/ABI: UNIX - System V
目标操作系统是Unix,应用二进制接口 System-V。
ABI Version: 0
二进制接口的版本是0。
Type: REL (Relocatable file)
文件类型是可重定位文件。
Machine: Advanced Micro Devices X86-64
目标CPU架构,x86
Version: 0x1
也代表elf的版本,预留的扩展字段,目前没人关心。
Entry point address: 0x0
程序的入口的虚拟地址。
。。。。都是概念,就不写了,有空自己查一下吧。
ELF文件头结构和相关常数被定义在/usr/include/elf.h
中。 因为elf文件有32和64位版本,他们的内容都一样,只有部分成员大小不一样,所以elf在elf.h
中重新定义了一整套变量体系。
typedef x y;
在C语言中,就是把y作为x类型的别名。

在elf.h中定义好之后,编译器会自动根据平台选择不同的字段类型。
查看elf文件头的结构体定义
bash
awk '/typedef struct/,/Elf64_Ehdr/' /usr/include/elf.h

这个定义,就是最开始,用readelf输出的结果。
readelf -h SimpleSection.o
ELF魔数 ,最前面的Magic,16个字节刚好对应结构体定义中的e_ident
这个字段。用来标识ELF文件的平台属性。
r
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
最开始的四个字节,分别是 0x7f
0x45
0x4c
0x46
这是所有ELF文件都必须相同的标识码就是经常说的魔数。第一个字节对应ASCII中的DEL
控制符。后面三个字节,是ASCII里面ELF对应的值。所有的可执行文件,开头都是魔数,java对应的class文件开头也是带着自己的魔数。这种魔数用来确认文件类型,操作系统会在加载时判断魔数是否正确。
接下来的0x02
代表ELF文件类型,0x02
代表64位的,0x01
代表32位的。第六个字节序,第七个是版本号。
文件类型 e_tpye
代表ELF文件类型,有三种,可重定位,可执行,共享目标文件。 在elf.h
中以ET开头。
arduino
#define ET_NONE 0 // 未知类型(无效文件)
#define ET_REL 1 // 可重定位文件(如 .o 文件)
#define ET_EXEC 2 // 可执行文件(如编译后的二进制)
#define ET_DYN 3 // 共享对象文件(如 .so 动态库)
#define ET_CORE 4 // Core 文件(崩溃时生成的调试文件)
#define ET_NUM 5 // 已定义的标准类型数量(非实际类型!)
机器类型 ELF文件格式被设计在多个平台使用。 e_machine
,以EM开头,告诉操作系统这种程序可以在什么CPU架构上跑。
arduino
#define EM_NONE 0 /* No machine */
#define EM_M32 1 /* AT&T WE 32100 */
#define EM_SPARC 2 /* SUN SPARC */
#define EM_386 3 /* Intel 80386 */
#define EM_68K 4 /* Motorola m68k family */
#define EM_88K 5 /* Motorola m88k family */
#define EM_IAMCU 6 /* Intel MCU */
#define EM_860 7 /* Intel 80860 */
#define EM_MIPS 8 /* MIPS R3000 big-endian */
#define EM_S370 9 /* IBM System/370 */
#define EM_MIPS_RS3_LE 10 /* MIPS R3000 little-endian */
#define EM_PARISC 15 /* HPPA */
#define EM_VPP500 17 /* Fujitsu VPP500 */
#define EM_SPARC32PLUS 18 /* Sun's "v8plus" */
#define EM_960 19 /* Intel 80960 */
#define EM_PPC 20 /* PowerPC */
...
段表
段表用来保存ELF文件中,每个段的基本属性。是除了ELF文件头外最重要的结构。编译器,链接器,装载器都是依靠段表来定位和访问各个段的属性。 用objdump读取到只是目标文件中的关键段信息,用readlf可以读取到全部的段信息。
readelf -SW SimpleSection.o

输出的结果就是ELF文件段表的内容,我们根据Elf64_Shdr
来对照着看。

结构体字段的含义正好是readelf输出段表的列名。比如ES 对应 sh_entsize
Lk 对应 sh_link
等等。
在ELF段表的内容中,除了第一行Type为NULL的无效段表描述符,其他都有对应的段表。
可以看到一共14个段表结构体,每个段表大小 sizeof(Elf64_Shdr)=64
字节。
算一下段表占用的总空间。 14*64==896=='0x380'
(10进制转16进制)
readelf再最开头输出了段表的开始地址,我们计算出空间,就能得到他的结束地址:0x410+0x380==0x790

段的类型(sh_type) 用来告诉编译器和链接器,它的类型,常见的有
宏名(sh_type) | 值 | 含义 |
---|---|---|
SHT_NULL |
0 |
无效节头,占位用 |
SHT_PROGBITS |
1 |
程序代码或数据段,比如 .text 、.data 、.rodata |
SHT_SYMTAB |
2 |
符号表(静态) |
SHT_STRTAB |
3 |
字符串表,比如 .strtab 、.shstrtab |
SHT_RELA |
4 |
带 addend 的重定位信息,比如 .rela.text |
SHT_HASH |
5 |
哈希表(用于动态链接) |
段的标志位(sh_flag) 代表在虚拟地址空间中的访问控制属性。
标志位宏名 | 值(十六进制) | 含义 |
---|---|---|
SHF_WRITE |
0x1 |
该段在内存中可写(如 .data 、.bss ) |
SHF_ALLOC |
0x2 |
该段在运行时占用内存(如 .text 、.data ,但不包括 .symtab ) |
SHF_EXECINSTR |
0x4 |
该段包含可执行指令(如 .text ) |
段的链接信息(sh_link, sh_info) 跟链接有关。比如重定位表,符号表等。
段类型 | sh_link 指向 |
sh_info 指向 |
特有字段 |
---|---|---|---|
SHT_DYNAMIC |
动态字符串表 (.dynstr ) |
未使用 (通常为0) | Elf64_Dyn 结构体 |
SHT_HASH |
动态符号表 (.dynsym ) |
未使用 (通常为0) | 哈希桶和链数组 |
SHT_REL |
符号表 (.symtab ) |
目标段 (如 .text ) |
Elf64_Rel (无附加偏移) |
SHT_RELA |
符号表 (.symtab ) |
目标段 (如 .text ) |
Elf64_Rela (含 r_addend ) |
重定位表
在段表中,type类型为RELE
的是重定位表,比如.rele.text
是针对text的重定位表,当链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,也就是text和data中那些对绝对地址引用的地方。例如text
中对printf
的引用。
重定位表本身也是一个段,Lk代表符号表的下标,就是readelf输出中[Nr]的值。 Inf代表作用对象,就是text段,即[1] 。
css
[ 1] .text PROGBITS 0000000000000000 000040 000064 00 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 000328 000078 18 I 11 1 8
字符串表
ELF文件中有很多字符串,比如段名,变量名。 strtab
代表普通的字符串,shstrtab
代表段表名的字符串。
csharp
[12] .strtab STRTAB 0000000000000000 0002c0 000066 00 0 0 1
[13] .shstrtab STRTAB 0000000000000000 0003d0 000074 00 0 0 1
段内部保存的每个字符的地址,字符串结尾用\0
代表,这样子只需要记住开头就行了,一直读到\0
就知道读完了。
链接的接口 -- 符号
链接的目标是要把不同的目标文件链接起来,让他们相互之间可以调用,即目标文件之间的函数和变量可以互相调用。 我们把函数和变量统称为符号。,函数名和变量名就是符号名。
每个目标文件,都有一个符号表,记录着当前文件中每个符号名和对应的符号地址。
使用nm
工具来查看当前文件的符号表
shell
nm SimpleSection.o
左边是符号所在段内的偏移量
中间是符号类型
-
T
: Text segment (代码段) 中的符号,通常是函数。大写表示是全局(external)符号。 -
D
: Data segment (已初始化的数据段) 中的符号,通常是已初始化的全局或静态变量。大写表示是全局符号。 -
B
: BSS segment (未初始化的数据段) 中的符号,通常是未初始化的全局或静态变量。大写表示是全局符号。 -
U
: Undefined symbol (未定义的符号)。该符号在此目标文件中被引用,但在其他地方(如库文件)定义。 -
t
: Text segment (代码段) 中的符号,通常是函数。小写表示是局部(static)符号。 -
d
: Data segment (已初始化的数据段) 中的符号。小写表示是局部静态变量。 -
b
: BSS segment (未初始化的数据段) 中的符号。小写表示是局部静态变量。
最右边是符号名

ELF符号表结构
ELF文件中,符号表所在段的段名是.symtab
。
readelf -SW SimpleSection.o 结果里有。
csharp
[11] .symtab SYMTAB 0000000000000000 000158 000138 18 12 8 8
继续查看.symtab
内部的符号有什么

为了清晰一点,从ELF格式本身开始串起来,一直在符号表,绿色框里是对应的结构体。
最后的Elf64_Sym 就是上面输出的内容,字太小了,直接看上一张图片即可。

具体的定义,同样,还是在elf.h
中

字段 (Field) | 含义 (Meaning) |
---|---|
st_name |
符号名称在字符串表 (.strtab) 中的字节偏移。 |
st_value |
符号的值(通常是地址或偏移量)。 |
st_size |
符号的大小(如函数或数据对象的大小)。 |
st_info |
符号的类型 (低4位) 和绑定 (高4位)。 |
st_other |
符号的可见性属性等其他信息。 |
st_shndx |
符号所在的节 (Section) 的索引(或特殊值如未定义)。 |
readelf -s SimpleSection.o
这个命令的输出结果中,是把st_info
拆开成type(低四位)和 binding(高四位)了。
符号绑定常见字段:
STB_LOCAL
局部符号。只在该目标文件内可见,链接时不会冲突。
STB_GLOBAL
全局符号。在所有目标文件和共享库中可见,定义通常是唯一的。
STB_WEAK
弱符号。类似于全局符号,但优先级较低。如果存在同名的全局符号,链接器会选择全局符号。
符号类型常见字段:
STT_NOTYPE
未指定类型。
STT_OBJECT
数据对象(如变量、数组、结构体等)。
STT_FUNC
函数或其他可执行代码。
STT_SECTION
与某个节相关的符号。其值通常是该节的地址。
STT_FILE
与源文件相关的符号。仅在第一个符号表条目中出现,其名称是源文件名。
符号所在段(st_shndx) 如果符号定义在本目标文件中,那么符号对应段表中的下标。如果在其他文件定义,就是UND
比如printf,如果是绝对地址,比如文件名,就是ABS
符号值(st_value) 代表符号在该符号段内的偏移地址。
再回头看一遍这个图,是不是清晰了。

书上的程序是把global_uninit_var当成了未定义的common符号,并不在bss段中。 我电脑是放在了bss段中。这个跟不同的编译器有关。
第一个Num就是序号,第二个Value就是符号值(st_value),第三列Size,符号所占大小,函数就代表函数体的大小,比如fun1和main,第四列Type(对应st_info低四位),第五列是Biond(st_info高四位),第六列Vis代表符号可见性,这些都是默认可见。第七列Ndx(st_shndx),表示符号所属的段的下标索引,比如Num为1,10,12的,都是text段,对应段表中的是1。

最后一列是符号名。
特殊符号
当我们使用ld作为链接器来链接并生成可执行文件时,它会自动为我们定义很多特殊符号,虽然没有在我们自己的程序中定义,但是可以直接声明并引用它们,这个就是特殊符号。
__executable_start
该符号为程序起始地址,就是程序最开始的地址(不是程序入口地址)。
_etext
代码段结束地址
_edata
数据段结束地址
_end
程序结束地址 以上都是程序被装载时的虚拟地址。
c
#include <stdio.h>
// 声明这些由链接器定义的外部符号
// 使用 char* 是因为它们代表地址,而 char* 是一个字节大小的指针类型,
// 方便直接打印地址值。
extern char __executable_start[];
extern char _etext[];
extern char _edata[];
extern char _end[];
int main() {
printf("--- Program Segment Addresses ---\n");
// 使用 %p 格式化符来打印指针地址
printf("Executable Start : %p\n", __executable_start);
printf("End of Text (.text): %p\n", _etext);
printf("End of Data (.data): %p\n", _edata);
printf("End of BSS (.bss) : %p\n", _end);
printf("---------------------------------\n");
return 0;
}

符号修饰与函数签名
在早期,如果源代码中有一个foo函数,编译成目标文件之后,也叫foo函数,随着项目越来越大,如果引用的库中已有了foo这个函数,我们当前编写的源码就不能写这个foo了,不然就会产生冲突。
为了解决这个问题,C语言在源码中所有符号都加了一个前缀_
简单的绕过了。但是并没有从根本上解决这个问题。 现代C项目开发就使用了两种方法,编译器/链接器提供的可见性属性 (上面提到的VIS:DEFAULT就是默认可见,还可以控制成隐藏)实现 共享库等模块间的符号导出控制和隐藏 ,开发者社区广泛采用的命名约定和前缀 来 降低全局符号命名冲突的风险 。 后来C++设计之初,就使用了命名空间(namespace) 这个概念。
C++符号修饰
强大的C++拥有类,继承,虚机制,重载,名称空间等,这会让符号管理更加复杂。比如同样的func(int),func(double),虽然函数名相关,但是参数列表不同。那么编译器怎么区分呢?
人们通过符号修饰 的机制来解决。
java
void func(double)
void func(int)
C++编译器会根据函数的参数列表,来生成最终的符号名。
void func(int)
可能被改编为类似于 _Z4funci
的符号名。
_Z
: 这是一个前缀,表示这是 C++ 改编后的符号。
4
: 表示后面的函数名 func
有 4 个字符。
func
: 原始函数名。
i
: 表示第一个参数是 int
类型。
void func(double b)
可能被改编为类似于 _Z4funcd
的符号名。
_Z4func
: 同上。
d
: 表示第一个参数是 double
类型。
其他的类重载,命名空间等,都是差不多的,就是多加个修饰字用来区分,这里主要看不同的编译器自身的逻辑,所以,如果想让编译器产生的目标文件能正常链接,必须要同一种编译器,不然会产生符号无法识别的情况。
extern "C"
C++为了与C兼容,在符号管理上,可以用extern "C"
来声明C语言的内容。这样子C++的符号改编和一些专门为C++写的编译逻辑就不会作用到这段代码上,而是用C的逻辑来编译这段代码。
平常直接在C++里面写C看似没有问题,但,这是因为没有通过C++去调用C而已,那种写法只是C++的向前兼容。
如果不使用extern C,例如C语言中的函数 void foo
,直接在C++中调用<mylib.h>中的函数(自定义的一个函数),因为C++的符号修饰,导致函数根本对不上,那就调用不成功啊。
解决办法就是使用extern C。
学一下标准做法,例如<string.h>
,它内部就使用了宏去标记,这样子当我们在C++中调用memset时就无缝衔接了
c
// 在标准的 <string.h> 头文件内部,你会看到类似这样的结构
#ifdef __cplusplus // 如果当前是 C++ 编译器在编译
extern "C" { // 则开启 C 语言符号规则
#endif
// 所有的 C 标准库函数声明都在这里面
extern void *memset(void *__s, int __c, size_t __n);
// ... 其他来自 <string.h> 的函数声明
#ifdef __cplusplus // 如果当前是 C++ 编译器在编译
} // 关闭 C 语言符号规则
#endif
强弱符号、强弱引用
强符号与弱符号
编程中经常会碰到一个错误,就是重复定义。指在多个目标文件中,都定义了一个相同符号名的全局符号。这时链接器就会报错。
这是因为,在C/C++中来说,默认函数和初始化过的全局变量都是强符号 ,没有初始化的变量是弱符号 。
也可以通过__attribute((weak))
来强制定义为弱符号。
强符号之间不允许覆盖,但是弱符号可以,并且都是弱符号的情况,用占用空间最大的那个弱符号作为最终定义。
强引用和弱引用
在目标文件中如果引用了外部符号,但是链接时,没有找到,就会报未定义的错误,这就是强引用 。 与之对应的,如果不报错,就是弱引用。
可以使用__attribute((weakref))
来标记符号为弱引用。
这两种引用和符号的关系,对于库来说十分有用,库中定义的弱符号,可以被用户自定义的符号覆盖,这样,库就可以执行用户自定义的函数了。
如果使用弱引用,程序可以对某些不需要的模块直接剔除,而不会在编译报错,非常方便对程序做裁剪。
调试信息
现代编译器都支持源码调试,原因就是目标文件里面保存了非常详细的调试信息,这些信息比正常release版本的代码庞大很多。
在gcc参数上加-g
r
gcc -c -g SimpleSection.c
可以看到生成了很多debug
相关的段。
本章小结
很难读,必须得一个个字符对着看,慢慢理解,感觉难度上来了。。
从ELF文件格式开始,一直到elf.h
中的结构体定义,把代码段,数据段和bss段都介绍了一遍。还讲了一遍文件头,段表,重定位表这些,算是对目标文件深入的理解了。