认识目标文件结构

时间:2022-06-24
本文章向大家介绍认识目标文件结构,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

1.目标文件简介

目标文件是源代码编译但未链接的中间文件(Windows的.obj和Linux的.o),Windows的.obj采用 PE 格式,Linux 采用 ELF 格式,两种格式均是基于通用目标文件格式(COFF,Common Object File Format)变化而来,所以二者大致相同。本文以 Linux 的 ELF 格式的目标文件为例,进行介绍。

目标文件一般包含编译后的机器指令代码、数据、调试信息,还有链接时所需要的一些信息,比如重定位信息和符号表等,而且一般目标文件会将这些不同的信息按照不同的属性,以“节(section)”也叫“段(segment)”的形式进行存储,本文统称为“段”。

首先将如下具有代表性又不会过于复杂的 C 源码通过 gcc 只编译不链接生成目标文件 test.o,然后对目标文件 test.o 进行分析。

//
//@file: test.c
//

int printf(const char* format, ...);

int gInitVar = 84; 
int gUninitVar;

void foo(int i)
{
    printf("%dn", i); 
}

int main()
{
    static int staVar = 85; 
    static int staVar1;

    int a = 1;
    int b;

    foo(staVar + staVar1 + a + b); 

    return 0;
}

通过命令 gcc -c test.c -o test.o 编译生成目标文件 test.o。

2.ELF 目标文件的结构

通过 readelf -S 命令可以查看目标文件test.o的所有段的段头信息,实际上是读取段表的内容。

readelf -S test.o 
There are 13 section headers, starting at offset 0x198:

Section Headers:
  [Nr] Name              Type             Address           Offset		Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000	0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040	0000000000000056  0000000000000000  AX       0     0     4
  [ 2] .rela.text        RELA             0000000000000000  000006a0	0000000000000078  0000000000000018          11     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000098	0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000a0	0000000000000004  0000000000000000  WA       0     0     4
  [ 5] .rodata           PROGBITS         0000000000000000  000000a0	0000000000000004  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000a4	000000000000002d  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000d1	0000000000000000  0000000000000000           0     0     1
  [ 8] .eh_frame         PROGBITS         0000000000000000  000000d8	0000000000000058  0000000000000000   A       0     0     8
  [ 9] .rela.eh_frame    RELA             0000000000000000  00000718	0000000000000030  0000000000000018          11     8     8
  [10] .shstrtab         STRTAB           0000000000000000  00000130	0000000000000061  0000000000000000           0     0     1
  [11] .symtab           SYMTAB           0000000000000000  000004d8	0000000000000180  0000000000000018          12    11     8
  [12] .strtab           STRTAB           0000000000000000  00000658	0000000000000045  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), l (large), I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

从上面的输出我们可以各个段在文件中的偏移位置,可以推断出ELF目标文件的结构大致如下。

ELF Header

.text

.data

.bss

.rodata

.comment

.shstrtab

section header table

.symtab

.strtab

.rela.text

other sections

从上至下主要包含: (1)ELF Header,ELF文件头描述目标文件整体信息,包含 ELF 文件版本,目标机器型号、程序入口地址等; (2).text,代码段存放程序的机器指令; (3).data,初始化数据段存放已初始化的全局变量与局部静态变量; (4).bss,未初始化数据段存放未初始化的全局变量与局部静态变量; (5).rodata,只读数据段存放程序中只读变量,如const修饰的常量和字符串常量; (6).comment,注释信息段存放编译器版本信息,比如字符串"GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-4)" (7).shstrtab,段表字符串表,用于存放段的名称字符串; (8)section header table,段表存放所有段的基本信息,表中的每一项为段头,即段的基本信息; (9).symtab,符号表记录了目标文件中使用的所有符号,比如变量和函数名,对于变量和函数而言,符号对应的值为它们所在的地址。符号用于链接器链接时找到符号地址; (10).strtab,字符串表用于存放目标文件中用到的字符串,比如变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示比较困难。常见的做法就是把字符串集中起来存放到一个表。然后使用字符串在表中的偏移来引用字符串; (11).rela.text,代码段重定位表存放目标文件未定义的指令在链接时所需的重定位信息。

除了上面提到的段外,ELF文件也有可能包含其他的段,用来保存与程序相关的其他信息。

段名

说明

.hash

符号哈希表

.line

调试时的行号表,即源代码行号与编译后指令的对应表

.dynamic

动态链接信息

.debug

调试信息

.comment

存放编译器版本信息,比如 “GCC:(GNU)4.2.0”

.plt和.got

动态链接的跳转表和全局入口表

.init 和 .fini

程序初始化和终结代码段

.rodata1

Read Only Data,只读数据段,存放字符串常量,全局 const 变量,该段和 .rodata 一样

下面以目标文件 test.o 为例,讲解 Linux 下 ELF 目标文件的具体组成部分。

3.ELF 文件头(ELF Header)

通过命令 readelf -h 可以查看 ELF 目标文件的头信息。

readelf -h test.o
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          408 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         13
  Section header string table index: 10

通过输出结果可以看出,ELF目标文件头包含了如下信息: (1)魔数(Magic)。前四个字节7f、45、4c、46分别对应ASCII字符的Del(删除)、字母E、L、F。这四个字节被称为ELF文件的魔数,操作系统在加载可执行文件时会确认魔数是否正确,如果不正确则拒绝加载。 第五个字节标识ELF文件是32位(01)还是64位(02)的。第六个字节表示字节序是小端(01)还是大端(02)。第七个字节指示ELF文件的版本号,一般是01。 后九个字节ELF标准未做定义。一般为00。 (2)类别(Class),为ELF64,如果是32位的目标文件,则类别为ELF32。我们可以使用编译命令gcc -m32 -c test.c -o test32.o生成32位的目标文件。 (3)数据存储方式(Data),为小端字节序。 (4)版本(Version),当前版本为1。 (5)运行平台与应用程序二进制接口(OS/ABI),为UNIX - System V。其它的还有 UNIX - Linux 与 UNIX - GNU。其中 ABI 为 GNU 和 Linux 两种是相同的,只是使用不同版本的 readelf 会现实不同的结果。而 System V 则是最古老的,也是兼容性最好的。 (6)应用程序二进制接口版本(ABI Version),为0。 (7)类型(Type),为可重定位文件(REL,Relocatable file),包括目标文件.o与静态链接库.a。其它的还有DYN(共享目标文件,.so文件)和 EXEC(可执行文件)。 (8)硬件平台(Machine),为 Intel 80386。 (9)硬件平台版本(Version),为 1。 (10)入口地址(Entry point address),为 0。 (11)程序头起始地址(Start of program headers),为0字节。 (12)段表起始地址(Start of section headers),为408字节。 (13)标志(Flags),为0。 (14)文件头大小(Size of this header),为52字节。 (15)程序头大小( Size of program headers),为0字节。 (16)程序头数量( Number of program headers),为0。 (17)段头大小(Size of section headers),64字节。 (18)段头数量(Number of section headers),段头数量,表示有13个段。 (19)字符串表段头索引(Section header string table index),表示字符串表段头在段表中的偏移为10。

ELF文件头结构及相关常数的定义在/usr/include/elf.h里,因为ELF文件有32位和64位版本,所以头文件中对应也有两种结构,分别是 Elf32_Ehdr 和 Elf64_Ehdr。其成员与上面输出的头信息对应关系如下: |成员|readelf输出结果| |e_ident|Magic,Class,Data,Version,OS/ABI,ABI Version| |e_type|类型| |e_machine|硬件平台| |e_version|硬件平台版本| |e_entry|入口点地址| |e_phoff|程序头起始地址| |e_shoff|段表起始地址| |e_flags|标志| |e_ehsize|文件头的大小| |e_phentsize|程序头大小| |e_phnum|程序头数量| |e_shentsize|段头大小,一般等于sizeof(Elf64_Ehdr)| |e_shnum|段头数量,也表示有多少个段| |e_shstrndx|字符串表段头索引|

4.代码段(.text)

代码段存放程序的机器指令,我们通过命令 objdump -S 可以反汇编代码段的内容。

objdump -S test.o

test.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <foo>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	48 83 ec 10          	sub    $0x10,%rsp
   8:	89 7d fc             	mov    %edi,-0x4(%rbp)
   b:	8b 45 fc             	mov    -0x4(%rbp),%eax
   e:	89 c6                	mov    %eax,%esi
  10:	bf 00 00 00 00       	mov    $0x0,%edi
  15:	b8 00 00 00 00       	mov    $0x0,%eax
  1a:	e8 00 00 00 00       	callq  1f <foo+0x1f>
  1f:	c9                   	leaveq 
  20:	c3                   	retq   

0000000000000021 <main>:
  21:	55                   	push   %rbp
  22:	48 89 e5             	mov    %rsp,%rbp
  25:	48 83 ec 10          	sub    $0x10,%rsp
  29:	c7 45 fc 01 00 00 00 	movl   $0x1,-0x4(%rbp)
  30:	8b 15 00 00 00 00    	mov    0x0(%rip),%edx        # 36 <foo+0x36>
  36:	8b 05 00 00 00 00    	mov    0x0(%rip),%eax        # 3c <foo+0x3c>
  3c:	01 c2                	add    %eax,%edx
  3e:	8b 45 fc             	mov    -0x4(%rbp),%eax
  41:	01 c2                	add    %eax,%edx
  43:	8b 45 f8             	mov    -0x8(%rbp),%eax
  46:	01 d0                	add    %edx,%eax
  48:	89 c7                	mov    %eax,%edi
  4a:	e8 00 00 00 00       	callq  4f <foo+0x4f>
  4f:	b8 00 00 00 00       	mov    $0x0,%eax
  54:	c9                   	leaveq 
  55:	c3                   	retq   

从上面可以看到,代码段包含的是test.c中两个函数foo()与main()的指令。代码段.text的第一个字节0x55就是函数foo()的第一条"push %rbp"指令,即帧指针的压栈操作。最后一个字节0xc3是main()函数的最后一条指令"ret"。

5.初始化数据段(.data)

.data段保存了已经初始化的全局变量与局部静态变量。源码 test.c 中有初始化的全局变量 int gInitVar和局部静态变量static int staVar,所以这两个变量的值存放在.data段,因为是两个int变量,所以.data段的大小是4字节。使用命令 objdump -s 可以查看目标文件所有非空段的内容。

objdump -s test.o

test.o:     file format elf64-x86-64

Contents of section .text:
 0000 554889e5 4883ec10 897dfc8b 45fc89c6  UH..H....}..E...
 0010 bf000000 00b80000 0000e800 000000c9  ................
 0020 c3554889 e54883ec 10c745fc 01000000  .UH..H....E.....
 0030 8b150000 00008b05 00000000 01c28b45  ...............E
 0040 fc01c28b 45f801d0 89c7e800 000000b8  ....E...........
 0050 00000000 c9c3                        ......          
Contents of section .data:
 0000 54000000 55000000                    T...U...        
Contents of section .rodata:
 0000 25640a00                             %d..            
Contents of section .comment:
 0000 00474343 3a202847 4e552920 342e382e  .GCC: (GNU) 4.8.
 0010 35203230 31353036 32332028 52656420  5 20150623 (Red 
 0020 48617420 342e382e 352d3429 00        Hat 4.8.5-4).   
Contents of section .eh_frame:
 0000 14000000 00000000 017a5200 01781001  .........zR..x..
 0010 1b0c0708 90010000 1c000000 1c000000  ................
 0020 00000000 21000000 00410e10 8602430d  ....!....A....C.
 0030 065c0c07 08000000 1c000000 3c000000  ...........<...
 0040 00000000 35000000 00410e10 8602430d  ....5....A....C.
 0050 06700c07 08000000                    .p...... 

从输出结果可以看到,段.data的内容分别是0x54与0x55,刚好是两个初始化变量的值84 与 85。

6.未初始化数据段(.bss)

.bss段存放的是未初始化全局变量与局部静态变量,如 test.c 中的未初始化的全局变量 int gUninitVar 与局部静态变量 static int staVar1,其实更准确的说是.bss段为它们预留了空间。

从命令 readelf -S test.o 的输出结果可以看到,.bss段的大小是4个字节,这与 gUninitVar 和 staVar1的8字节大小不符。其实通过符号表(Symbol Table)(下面会详细介绍)可以看到,只有 staVar1 被放在了.bss段,而 gUninitVar并没有被放在任何段,只是一个未定义的 COMMON 符号。这其实和不同语言与编译器的实现有关,有些编译器会将全局未初始化变量放在.bss段,有些则不放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间。

为什么编译器把未初始化的全局变量标记为一个COMMON符号,而不直接把它当作未初始化的局部静态变量,为其在.bss段分配空间呢?原因如下: (1)编译时,若目标文件中含有弱符号(比如未定义的全局变量),则该弱符号最终所占内存空间大小无法确定,因为有可能其他目标文件中该弱符号所占内存空间比本单元弱符号所占内存空间大,所以此时无法在.BSS段为该弱符号分配空间。 (2)链接时,读取了所有目标文件,确定了任意一个弱符号的大小。这时才在最终输出文件的.BSS段中为其分配空间。 (3)总体看来,未初始化的全局变量最终还是被放在.BSS段。

7.只读数据段.rodata

.rodata段存放的是只读数据,一般是程序里面的只读变量(比如const修饰的变量)和字符串常量。比如源码文件test.c中在调用printf()时,用到了格式化字符串常量"%dn",存放在.rodata段。从命令 objdump -s 的输出结果,可以看到.rodata段的内容为0x25640a00,占用4个字节,分别表示字符%、d、n与空字符。

单独设立.rodata段的好处有很多,比如语义上支持了C的const常量,而且操作系统在加载的时候可以将.rodata段的内容映射为只读区,这样对于这个段的任何修改都会被判为非法,保证了程序的安全性。

8.段表(Section Header Table)

ELF 文件中有各种各样的段,段表保存了这些段的基本属性。段表是 ELF 文件中除了文件头以外最重要的结构,它描述了 ELF 各个段的信息,比如每个段的段名、类型、长度、在文件中的偏移等,编译器、链接器和装载器都是通过访问段表来获取各个段的属性。段表在 ELF 文件中的位置由 ELF 文件头的 e_shoff 成员决定,比如 test.o 中,段表偏移为408字节。我们可以使用命令 readelf -S 查看段表内容,前文已经使用过并输出其结果。

段表的实际结构比较简单,它是一个以结构体 Elf32_Shdr 或 Elf64_Shdr 为元素的数组,每个元素对应一个段,数组元素个数等于段的数量。Elf32_Shdr 与 Elf64_Shdr 又被称为段描述符(Section Descriptor)。对于 test.o,段表有13个 Elf64_Shdr 元素,第一个为无效的段描述符,它的类型为 NULL,所以 test.o 共有12个有效的段。

Elf32_Shdr 或 Elf64_Shdr 被定义在 /usr/include/elf.h,以 Elf64_Shdr 为例,其定义如下:

typedef struct
{
  Elf64_Word    sh_name;                /* Section name (string tbl index) */
  Elf64_Word    sh_type;                /* Section type */
  Elf64_Xword   sh_flags;               /* Section flags */
  Elf64_Addr    sh_addr;                /* Section virtual addr at execution */
  Elf64_Off     sh_offset;              /* Section file offset */
  Elf64_Xword   sh_size;                /* Section size in bytes */
  Elf64_Word    sh_link;                /* Link to another section */
  Elf64_Word    sh_info;                /* Additional section information */
  Elf64_Xword   sh_addralign;           /* Section alignment */
  Elf64_Xword   sh_entsize;             /* Entry size if section holds table */
} Elf64_Shdr;

中文释义如下:

成员

含义

sh_name

段名,是一个字符串,它位于名叫.shstrtab的段表字符串表中,sh_name是段名字符串在.shstrtab的偏移

sh_type

段的类型,详见下文“段的类型”

sh_flags

段的标志位,详见下文“段的标志位”

sh_addr

段虚拟地址。如果该段可以被加载,则sh_addr 为该段被加载后在进程地址空间中的虚拟地址,否则 sh_addr 为 0

sh_offset

段的偏移,如果该段存在于文件中,则表示该段在文件中的偏移

sh_size

段的长度

sh_link 与 sh_info

段链接信息。详见下文“段的链接信息”

sh_addralign

段地址对齐。有些段要求地址对齐,比如段起始位置包含一个double变量,因为 Intel x86_64 系统要求浮点数的存储地址必须是本身的整数倍,那么该段的 sh_addr 必须是8的整数倍。由于地址对齐均是2的整数,所以 sh_addralign 为 2 的指数,比如段地址对齐是8,那么sh_addralign取值3。如果 sh_addralign 为 0,表示不需要对齐

sh_entsize

有些段包含固定大小的项,比如符号表,它包含的每个符号所占的大小都是一样的,对于这种段,sh_entsize表示每个项的大小。如果为0,则表示该段不包含固定大小的项

从文件头中可以看到,段表元素大小等于 64B=sizeof(Elf64_Shdr),元素个数等于 13,所以段表大小等于 64*13=832B,这个数值刚好等于 .symtab 在文件中的偏移 0x4d8 减去段表的偏移 0x198。

段的类型(sh_type),段的名字只是在编译和链接过程中有意义,但它不能真正地表示段的类型。我们也可以将一个数据段命名为“.text”,对于编译器和链接器来说,主要决定段的属性的是段的类型(sh_type)和段的标志位(sh_flags)。段的类型相关常量以SHT_开头,定义在 /usr/include/elf.h,列举如下:

常量

含义

SHT_NULL

0

无效段

SHT_PROGBITS

1

程序数据。代码段和数据段都是这种类型

SHT_SYMTAB

2

符号表

SHT_STRTAB

3

字符串表

SHT_RELA

4

重定位表。该段包含了重定位信息

SHT_HASH

5

符号表的哈希表

SHT_DYNAMIC

6

动态链接信息

SHT_NOTE

7

提示性信息

SHT_NOBITS

8

表示该段在文件中没有内容,比如.bss段

SHT_REL

9

该段包含了重定位信息

SHT_SHLIB

10

保留

SHT_DYNSYM

11

动态链接的符号表

段的标志位(sh_flag)表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。相关常量以SHF_开头,定义在 /usr/include/elf.h,如下表:

常量

含义

SHF_WRITE

(1 << 0)

表示该段在进程空间中可写

SHF_ALLOC

(1 << 1)

表示该段在进程空间中须要分配空间。有些包含指示或控制信息的段不须要在进程空间中为其分配空间,它们一般不会有这个标识。像代码段、数据段和.bss段都会有这个标志位

SHF_EXECINSTR

(1 << 2)

表示该段在进程空间可以被执行,一般指代码段

段的链接信息(sh_link、sh_info) 如果段的类型是与链接相关的(不论是动态链接或静态链接),比如重定位表、符号表等,那么 sh_link 和 sh_info 这两个成员所包含的意义如下表所示。对于其他类型的段,这两个成员没有意义。

sh_type

sh_link

sh_info

SHT_DYNAMIC

该段所使用的字符串表在段表中的下标

0

SHT_HASH

该段所使用的符号表在段表中的下标

0

SHT_RELA、SHT_REL

该段所使用的相应符号表在段表中的下标

该重定位表所作用的段在段表中的下标

SHT_SYMTAB、SHT_DYNSYM

操作系统相关的

操作系统相关的

other

SHN_UNDEF

0

9.符号表(.symtab)

9.1 符号简介

链接过程的本质就是要把多个不同的目标文件之间像拼图一样拼起来,相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B用到了目标文件A中的函数foo,那么称目标文件A定义了函数foo,目标文件B引用了函数foo。定义与引用这两个概念同样适用于变量。每个函数和变量都有自己独一的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数或变量名就是符号名(Symbol Name)。

符号是链接的粘合剂,没有符号无法完成链接。每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。

除了函数和变量之外,还存在其它几种不常用到的符号。符号表中所有符号可以分为如下几种: (1)全局符号。定义在本目标文件,可以被其它目标文件引用。比如 test.o 中的 gInitVar、gUninitVar 与 foo; (2)外部符号(External Symbol)。在本目标文件中引用的全局符号,却没有定义在本目标文件。比如 test.o 中的 printf; (3)段名。其值为该段的起始地址。比如 test.o 的 .text、.data等; (4)局部符号。这类符号只在编译单元内部可见,链接器往往会忽略它们,因为没用。比如 test.o 中的 staVar 与 staVar1; (5)行号信息。即目标文件指令与源代码中代码行的对应关系,它是可选的。

对于链接而言,只关心全局符号,局部符号、段名、行号等都是次要的,它们对于其它目标文件来说是不可见的。我们可以使用很多工具查看 ELF 文件的符号表,比如 readelf、objdump 和 nm 等,比如使用 nm 查看 test.o 的结果如下:

nm test.o
0000000000000000 T foo
0000000000000000 D gInitVar
0000000000000004 C gUninitVar
0000000000000021 T main
                 U printf
0000000000000000 b staVar1.1731
0000000000000004 d staVar.1730

9.2符号表结构

ELF 文件中的符号表往往是一个段,段名一般叫 .symtab。它是一个 Elf64_Sym 结构的数组,每个 Elf64_Sym 结构对应一个符号,Elf64_Sym 定义在 /usr/include/elf.h。

typedef struct
{
  Elf64_Word    st_name;                //符号名,是一个下标值,表示该符号名在字符串表中的下标
  unsigned char st_info;                //符号类型与绑定信息
  unsigned char st_other;               //符号可见性
  Elf64_Section st_shndx;               //符号所在段
  Elf64_Addr    st_value;               //符号值
  Elf64_Xword   st_size;                //符号大小
} Elf64_Sym;

(1)符号类型和绑定信息(st_info) 该成员低4位表示符号的类型(Symbol Type),高28位表示符号绑定信息(Symbol Binding)。

符号类型有:

宏定义名

说明

STT_NOTYPE

0

未知类型符号

STT_OBJECT

1

该符号是个数据对象,比如变量、数组等

STT_FUNC

2

该符号是个函数或其他可执行代码

STT_SECTION

3

该符号表示一个段,这种符号必须是STB_LOCAL的

STT_FILE

4

该符号表示文件名,一般都是该目标文件所对应的源文件名,它一定是 STB_LOCAL 类型的,并且它的 st_shndx 一定是SHN_ABS

符号绑定信息取值如下:

宏定义名

说明

STB_LOCAL

0

局部符号,其它目标文件不可见

STB_GLOBAL

1

全局符号,外部可见

STB_WEAK

2

弱引用

(2)符号所在段(st_shndx) 如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标。但是如果符号不是定义在本目标文件中,或者对于有些特殊符号,sh_shndx的值有些特殊。

宏定义名

说明

SHN_ABS

0xfff1

表示该符号包含了一个绝对的值,比如表示文件名的符号就属于这种类型的

SHN_COMMON

0xfff2

表示该符号是一个 COMMON 块类型的符号,一般来说,未初始化的全局符号就是这种类型的

SHN_UNDEF

0

表示该符号未定义。这个符号表示该符号在本目标文件被引用到,但是定义在其他目标文件中

(3)符号值(st_value) 在目标文件中,每一个符号都有一个对应的值,不同类型的符号其值具有不同的意义。主要分为如下几种: (a)在目标文件中,如果有符号的定义并且该符号不是 COMMON 块类型的,则st_value表示该符号在其所属段中的偏移。比如 test.o 中全局变量 gInitVar 在其所属.data段中的偏移; (b)在目标文件中,如果符号是 COMMON 块类型的,则 st_value 表示该符号的对齐属性。比如 test.o 中全局未初始化变量 gUninitVar; (c)在可执行文件中,st_value 表示符号的虚拟地址,这个虚拟地址对动态链接器十分有用。

9.3符号表实例剖析

使用 readelf -s 可以查看符号表的内容。

readelf -s test.o
Symbol table '.symtab' contains 16 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS test.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 staVar.1730
     7: 0000000000000000     4 OBJECT  LOCAL  DEFAULT    4 staVar1.1731
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 
    10: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
    11: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 gInitVar
    12: 0000000000000004     4 OBJECT  GLOBAL DEFAULT  COM gUninitVar
    13: 0000000000000000    33 FUNC    GLOBAL DEFAULT    1 foo
    14: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
    15: 0000000000000021    53 FUNC    GLOBAL DEFAULT    1 main

输出格式与 Elf64_Sym 成员基本一一对应。第一列 Num 表示符号表数组的下标,共有16个符号;第二列 Value 表示符号值,ji st_value;第三列Size为符号大小,即st_size;第四列和第五列,分别为符号类型与绑定信息,即对应 st_info 的低4位和高28位;第六列 Vis 在C/C++未使用,可忽略;第七列 Ndx 即 st_shndx,表示该符号所属段的头在段表中的偏移。最后一列为符号名称。

(1)foo 和 main 函数都是定义在 test.c 中,它们都属于代码段,所以 Ndx 为 1,即 test.o 里面,.text段头在段表中的下标是 1 ,从命令 readelf -S 的输出结果可以看出。他们是函数,所以类型是 STT_FUNC;它们是全局可见的,所以是 STB_GLOBAL;Size 表示函数指令所占的字节数;Value 表示函数相对于代码段起始位置的偏移量。 (2)printf这个符号只在 test.c 中被引用,未被定义,所以它的 Ndx 是 SHN_UNDEF。 (3)gInitVar 是已初始化的全局变量,它被定义在 .bss 段,即下标为 3。 (4)gUninitVar 是未初始化的全局变量,它是一个 SHN_COMMON 类型的符号,它本身并没有存在于 .bss 段。 (5)staVar.1730 和 staVar1.1731 是两个局部静态变量,它们的绑定属性是 STB_LOCAL,即只是编译单元内部可见。 (6)对于类型是 STT_SECTION 类型的符号,它们表示下标为 Ndx 的段的段名。它们的符号名没有显示,其它它们的符号名就是它们的段名。比如2号符号的 Ndx 为 1,那么它即表示 .text 段的段名,我们可以使用 objdump -t来查看。 (7)test.c 表示编译单元的源文件名。

10.字符串表(.strtab)

ELF文件中用到了很多字符串,比如段名、变量名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难。一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。假设有下面这个字符串表。

偏移

0

1

2

3

4

5

6

7

8

9

0

h

e

l

l

o

w

o

r

l

10

d

n

M

y

v

a

r

i

a

b

20

l

e

那么偏移与它们对应的字符串如下表所示:

偏移

字符串

0

空字符串

1

helloworld

6

world

12

Myvariable

通过这种方法,在ELF文件中引用字符串只须给出一个数字下标即可,不用考虑字符串长度的问题。一般字符串表在ELF文件中也以段的形式保存,常见的段名为“.strtab”或“.shstrtab”。这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。顾名思义,字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见的就是段名(sh_name)。

接着我们再回头看这个ELF文件头中的“e_shstrndx”的含义,它是"Section Header String Table Index"的缩写。我们知道段表字符串表本身也是ELF文件中的一个普通的段,它的名字往往叫做.shstrtab。那么这个e_shstrndx就表示.shstrtab在段表中的下标,即段表字符串表在段表中的下标。前面的 test.o 中,e_shstrndx的值为10,我们再对照 readelf -S 的输出结果,可以看到.shstrtab这个段刚好位于段表中的下标为8的位置上。由此可见,分析ELF文件头,可以得到段表和段表字符串表的位置,从而解析整个ELF文件。

11.代码段重定位表(.rela.text)

通过命令 readelf -S的输出可以看到,test.o有一个段 .rela.text,其类型为 RELA,也就是说它是一个重定位表,用于链接器在处理目标文件时,重定位代码段对外部模块的引用。比如 .text 段中对外部 printf() 函数的调用。

重定位表也是 ELF 的一个段,这个段的类型(Type)就是 RELA,它的 Link 表示它在段表中的下标,Info 表示它作用于哪个段。比如 .rela.text 的 Info 等于 1 ,表示作用于 .text 段,因为 .text 段的下标是1。


参考文献

[1]俞甲子,石凡,等.程序员的自我修养——链接、装载与库[M].北京:电子工业出版社,2009-04.C3.1目标文件格式.P61-95