Linux程序调试--静态链接

注意:本文中的大部分是阅读 《程序员的自我修养》 作 者:俞甲子,石凡,潘爱民 的读书笔记。推荐大家看看这本书。

一,空间与地址分配

/* a.c */

extern int shared;

int main()

{

int a=100;

swap (&a.&shared);

}

/*b.c */

int shared=1;

void swap(int *a,int *b)

{

*a^=*b^=*a^=*b;

}

产生.o文件分别为a.o和b.o:

gcc -c a.c b.c

从代码中可见,a.c定义了的符号为main,b.c里则有全局变量shared和函数swap。

下面将a.o和b.o链接形成可执行文件ab。

如果在形成可执行文件的时候,采用的是输入文件顺序叠加的方式,由于可能规模稍大的应用程序有数百个目标文件,每个目标文件都有

.text,.data,.bss段,则产生的输出文件可能有成百上千个零散的段。这样浪费空间,因为每个段都有一定的地址和空间对齐要求。比如x86

硬件,段的装载地址和空间对齐单位是页,即4096字节。这样就会造成大量内存空间的内部碎片。

一个好办法是相似段合并。因此链接器将会为目标文件分配地址和空间,其有两个含义:第一是输出的可执行文件中的空间,第二是装载后的虚拟地址中的虚拟地址空间。对于有实际数据的段,需要在文件和虚拟地址中分配空间,对于.bss这样的段,只需要虚拟地址空间。

上述链接方式称为两部链接(Two-pass Linking)。即整个链接步骤分为两步:

1,空间地址分配 扫描输入目标文件,获取它们各个段的长度、属性、位置,并将输入目标文件的符号表中所有符号定义和引用收集,放置于全局符号表。这一步,链接器将输入目标文件的段进行合并,计算段合并后的长度与位置,并建立映射关系。

2,符号解析和重定位 使用上一步的信息,读取输入文件段的数据、重定位信息,进行符号解析和重定位、调整代码中的地址等。重定位是链接的核心。

使用ld链接器进行链接:

ld a.o b.o -e main -o ab

-e 表示以mian函数为程序入口,ld默认的入口函数为_start。

-o ab表示输出文件名,默认是a.out。

查看 a.o:

objdump -h a.o

a.o: file format elf32-i386

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000034 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 00000000 00000000 00000068 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 00000068 2**2
ALLOC
3 .comment 0000002e 00000000 00000000 00000068 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 00000000 00000000 00000096 2**0
CONTENTS, READONLY

查看b.o:

objdump -h b.o

b.o: file format elf32-i386

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000003e 00000000 00000000 00000034 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 00000000 00000000 00000074 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 00000078 2**2
ALLOC
3 .comment 0000002e 00000000 00000000 00000078 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 00000000 00000000 000000a6 2**0
CONTENTS, READONLY

查看ab:

objdump -h ab

ab: file format elf32-i386

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000072 08048094 08048094 00000094 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 08049108 08049108 00000108 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .comment 0000005c 00000000 00000000 0000010c 2**0
CONTENTS, READONLY

可见:

链接后已经给可执行文件分配了VMA。我们关心VMA和Size,忽略文件偏移(File off)。

Linux下,ELF可执行文件默认从地址0x08048000开始分配,而不是从0开始。

二,符号地址的确定

分配虚拟地址后,链接器需要计算各个符号的虚拟地址,因为各个符号在段内的相对位置固定,因而main、shared、swap的地址在这个时候也是确定的了,只不过链接器需要给符号加上偏移量,调整到正确的虚拟地址。比如main相对于a.o的.text段的偏移是X,合并后的a.o的.text位于虚拟地址0x08048094,则main的地址是0x08048094+X。这里X=0,因为这里,main是a.o的.text的开始。其他计算方法一致。

三,符号解析与重定位

完成空间和地址分配后,链接器需要对可执行文件中的“代码”中的符号进行修正。

使用objdump -d a.o查看a.o的反汇编结果:

0: 8d 4c 24 04 lea 0x4(%esp),%ecx
4: 83 e4 f0 and $0xfffffff0,%esp
7: ff 71 fc pushl 0xfffffffc(%ecx)
a: 55 push %ebp
b: 89 e5 mov %esp,%ebp
d: 51 push %ecx
e: 83 ec 24 sub $0x24,%esp
11: c7 45 f8 64 00 00 00 movl $0x64,0xfffffff8(%ebp)
18: c7 44 24 04 00 00 00 movl $0x0,0x4(%esp)
1f: 00
20: 8d 45 f8 lea 0xfffffff8(%ebp),%eax
23: 89 04 24 mov %eax,(%esp)
26: e8 fc ff ff ff call 27 <main+0x27>
2b: 83 c4 24 add $0x24,%esp
2e: 59 pop %ecx
2f: 5d pop %ebp
30: 8d 61 fc lea 0xfffffffc(%ecx),%esp
33: c3 ret

可以看到这个时候的main的地址是0x00000000,汇编中的call指令都是使用的当前的地址,call 27 <main+0x27>的地址是27。call指令的指令码为 E8 FCFFFFFF,采用近址相对位移调用指令,后边四字节是被调用函数相对调用函数下一个指令的偏移。没有重定位时,其是小端方式存储的,值是-4的补码形式。这样计算的swap应该在0x27,这实际上不是swap,可见0xFCFFFFFF只是临时的代替值而已。

对全局变量shared的指针的使用在movl $0x0,0x4(%esp),该语句是将shared地址放入0x4(%esp)位置,后边四个字节即00000000是shared的地址,此时也是未初始化的。因为编译器不知道这些符号的虚拟地址,所以暂时写为0。

objdump -d ab发现,地址已经被修正到正确位置:


ab: file format elf32-i386

Disassembly of section .text:

08048094 <main>:
8048094: 8d 4c 24 04 lea 0x4(%esp),%ecx
8048098: 83 e4 f0 and $0xfffffff0,%esp
804809b: ff 71 fc pushl 0xfffffffc(%ecx)
804809e: 55 push %ebp
804809f: 89 e5 mov %esp,%ebp
80480a1: 51 push %ecx
80480a2: 83 ec 24 sub $0x24,%esp
80480a5: c7 45 f8 64 00 00 00 movl $0x64,0xfffffff8(%ebp)
80480ac: c7 44 24 04 08 91 04 movl $0x8049108,0x4(%esp)
80480b3: 08
80480b4: 8d 45 f8 lea 0xfffffff8(%ebp),%eax
80480b7: 89 04 24 mov %eax,(%esp)
80480ba: e8 09 00 00 00 call 80480c8 <swap>
80480bf: 83 c4 24 add $0x24,%esp
80480c2: 59 pop %ecx
80480c3: 5d pop %ebp
80480c4: 8d 61 fc lea 0xfffffffc(%ecx),%esp
80480c7: c3 ret

080480c8 <swap>:
80480c8: 55 push %ebp
80480c9: 89 e5 mov %esp,%ebp
80480cb: 53 push %ebx
80480cc: 8b 45 08 mov 0x8(%ebp),%eax
80480cf: 8b 18 mov (%eax),%ebx
80480d1: 8b 45 0c mov 0xc(%ebp),%eax
80480d4: 8b 08 mov (%eax),%ecx
80480d6: 8b 45 08 mov 0x8(%ebp),%eax
80480d9: 8b 10 mov (%eax),%edx
80480db: 8b 45 0c mov 0xc(%ebp),%eax
80480de: 8b 00 mov (%eax),%eax
80480e0: 31 c2 xor %eax,%edx
80480e2: 8b 45 08 mov 0x8(%ebp),%eax
80480e5: 89 10 mov %edx,(%eax)
80480e7: 8b 45 08 mov 0x8(%ebp),%eax
80480ea: 8b 00 mov (%eax),%eax
80480ec: 89 ca mov %ecx,%edx
80480ee: 31 c2 xor %eax,%edx
80480f0: 8b 45 0c mov 0xc(%ebp),%eax
80480f3: 89 10 mov %edx,(%eax)
80480f5: 8b 45 0c mov 0xc(%ebp),%eax
80480f8: 8b 00 mov (%eax),%eax
80480fa: 89 da mov %ebx,%edx
80480fc: 31 c2 xor %eax,%edx
80480fe: 8b 45 08 mov 0x8(%ebp),%eax
8048101: 89 10 mov %edx,(%eax)
8048103: 5b pop %ebx
8048104: 5d pop %ebp
8048105: c3 ret

三,重定位表

链接器通过重定位表知道哪些指令需要进行调整。对于可重定位的ELF文件,必须包含重定位表。

objdump -r a.o查看重定位表

objdump -r a.o

a.o: file format elf32-i386

RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000001c R_386_32 shared
00000027 R_386_PC32 swap

每个要重定位的地方称为Relocation Entry。OFFSET为其在段内的偏移 对照汇编代码发现 0000001c、00000027分别是

shared变量地址、swap函数地址,恰恰是需要修正的值。

重定位表的数据结构如下:

/* Relocation table entry without addend (in section of type SHT_REL). */

typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;

第一个元素对于可重定位文件,是要修正位置的第一个字节相对段起始的偏移;对于可执行或者共享对象文件,是要修正位置的第一个字节的虚拟地址。

第二个元素是重定位入口类型和符号。低8位是类型,高24位是符号在符号表的下标。对于可执行文件和共享目标文件,其类型是动态链接类型。不同处理器,有自己的一套重定位入口类型的定义。

四,符号解析

在我们进行链接的时候,有时候会遇到 undefined referenc to '******'的错误。这最常见原因是链接时缺少了某个库,或者输入目标文件的路径不正确或符号的声明与定义不一致。重定位过程伴随着符号解析。对重定位表中需要重定位的符号,链接器需要查找全局符号表,找到相应符号,查看其符号的地址,然后进行修正。

对于 a.o:

readelf -s a.o

Symbol table '.symtab' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00000000 0 FILE LOCAL DEFAULT ABS a.c
2: 00000000 0 SECTION LOCAL DEFAULT 1
3: 00000000 0 SECTION LOCAL DEFAULT 3
4: 00000000 0 SECTION LOCAL DEFAULT 4
5: 00000000 0 SECTION LOCAL DEFAULT 6
6: 00000000 0 SECTION LOCAL DEFAULT 5
7: 00000000 52 FUNC GLOBAL DEFAULT 1 main
8: 00000004 4 OBJECT GLOBAL DEFAULT COM shared
9: 00000000 0 NOTYPE GLOBAL DEFAULT UND swap

可见,除了main定义在代码段,shared、swap都是UND,即为定义类型的。这是因为该目标文件有对他们的重定位项。链接器扫描后,

所有这些未定义的符号都应该能够在全局符号表中找到,否则就会报符号未定义错误。

五,指令修正方式

不同处理器指令的格式和方式都不一样。修正的方式有:

1,绝对地址修正,比如对shared的修正,即R_386_32方式。

2,相对寻址修正,比如对swap的修正,即R_386_PC32方式,这样指令的修正后应该把0xFFFFFFFC修正为0x2000+(-4)-(0x1000+0x27)=0xFD5。因而指令变成e8 d5 0f 00 00则变成了call 0xfd5。加上起始地址:0xfd5+0x102b=0x2000,恰好为swap函数。

上述方式不同即在于一个是实际地址,另外是符号距离被修正位置的地址差(符号位置-被修正位置)。

六,common块

弱符号机制允许同一个符号的定义存在多个文件中。弱符号如果定义在多个目标文件,他们的类型又不同,应该如何?变量类型对链接器来说是不知道的。当定义的多个符号定义类型不一致,链接器处理如下:

两个/两个以上强符号不一致 ---->非法,报错

一个强符号,其他弱符号

两个/两个以上弱符号类型不一致

链接器处理的就是后两种情况。事实上,现在的编译器、链接器都支持一种叫COMMON块的机制。当不同目标文件需要的COMMON块大小不一致,则以大的为准进行分配。

现代的链接机制在处理弱符号的时候,采用的就是与COMMON块一样的机制。例如符号SimpleSection.o中的global_uninit_var,它在符号表的值如下(使用readelf -s查看):

st_name="global_uninit_var"

st_value=4

st_size=4

st_info =0x11 STB_GLOBAL STT_OBJECT

  st_other=0

st_shndx=0xfff2 SHN_COMMON

可以看到它是一个全局的数据对象,它的类型是SHN_COMMON类型。是一个典型弱符号。如果另外一个文件中,也定义这个弱符号,

是double,则一般链接后其大小为double的大小,即8个字节。如果有强符号,则以强符号为准,但是如果弱符号比该强符号大,则ld会出现警告。这种处理机制本质上,是因为链接器无法判断各个符号的类型一致问题。

弱符号和强符号机制 就是为什么未初始化的全局变量不是分配到.bss而是标记成COMMON的原因--弱符号占用空间大小未知,需要链接时确定。链接后,这些弱符号可以在链接的输出文件的.bss中分配空间了。总体看,最终被放到了BSS段。

GCC -fno-common选项允许我们把所有未初始化的全局变量不以common块存储,也可用 __attribute__(()nocommon)来限制。

int global __attribute__((nocommon));一旦这样,它就被当作强符号了。

七,C++相关问题

C++的语言特性很多需要编译器、链接器共同支持。比如C++的重复代码消除和全局构造、析构。由于虚函数、重载、继承、异常机制,使得C++背后的数据结构复杂,导致不同编译器和链接器之间不能通用,C++程序二进制兼容性造成一个很大问题。

C++编译器在很多时候会产生重复的代码,比如模板、外部内联函数、虚函数表都可能在不同编译单元内生成相同代码。最简单的以模板来说,模板本质上很像宏。模板在一个单元里实例化后,它并不知道自己是否在别的编译单元也被实例化。所以一个模板在多个编译单元实例化成相同类型的时候,必然产生重复代码。这样导致空间浪费,地址交易出错(有可能两个指向同一个函数的指针会不同),指令运行效率低,因为一份指令有多个副本导致cache的命中率会下降。

有效的方法是对每个模板的实例代码都单独存放在一个段,每个段包含一个模板实例。例如模板函数add<T>(),某个编译单元以int和 float实例化该函数,则编译单元的目标文件包含了两个该模板实例的段。假设分别为.temp.add<int> 和.temp.add<float>。这样,别的编译单元也以int和float实例化后,产生的名字相同,最终链接器链接的时候,可以区分这些相同代码段,将它们合并成最后的代码段。

对于内联和虚函数表做法类似。比如对于一个虚函数的类,其相应的虚函数表vtbl,编译器会在用到该类的多个编译单元生成虚函数表,造成重复,外部内联函数、默认构造函数、默认拷贝构造函数、赋值操作都类似,解决方法与模板类似。

当然这种方法存在相同名字有不同内容的情况,比如编译器优化选项、编译器版本不同导致的。这样链接器必须做出选择哪个副本的选择,然后提出警告。

函数级别链接

Visual C++编译器提供了一个编译选项叫函数级别链接 Functional-Level Linking /Gy。作用是让所有函数都像前边的模板函数一样,单独保存到段,链接器需要某个函数,就合并到输出文件,不需要则不合并。该优化选项会减慢编译链接过程,因为链接器需要计算函数的依赖关系,目标函数的段的数量也变大,重定位会变慢。函数较多的时候,目标文件由于段数目增加而变得相对较大。

gcc类似选项则是-ffunction-sections和-fdata-sections将每个函数或变量保持到独立的段。

全局构造和析构

main函数之前,需要初始化进程环境,比如堆分配、线程子系统等。C++全局对象构造函数也是该时期执行的。析构则在main执行后执行。

Linux系统下,程序入口一般是_start,是Linux系统库(Glibc)的一部分。当我们的程序与Glibc库链接一起形成可执行文件后,该函数即程序的初始化部分的入口。初始化部分执行后,调用main函数。main函数执行完返回初始化部分做清理工作,然后结束进程。

ELF提供了.init和.fini对C++的全局对象的构造和析构提供支持。Glibc的初始化部分在main函数调用前会执行.init的代码,而main返回后,Glibc安排.fini的代码执行。

八,C++与ABI

不同编译器编译的目标文件链接在一起,不仅仅链接器需要识别不同的二进制文件格式,比如ELF和PE/COFF格式,还要满足:

采用相同目标文件格式、拥有同样的符号修饰标准、变量内存分布相同、函数调用方式相同,等等。

其中,符号修饰、内存布局、函数调用方式这些与可执行代码二进制兼容性相关内容称为ABI(Application Binary Interface)。

ABI不同于源码级的API,API关注源码层面,而ABI是二进制层面的,兼容程度比API更要严格。比如压栈顺序等不同、指令集不同,是二进制层面的。

影响ABI的因素很多。对于C语言的目标代码,以下会决定二进制是否兼容:

内置类型,如int 、float大小和布局方式,对齐、大小端等。

组合类型 struct、union的存储方式、内存分布

外部符号的命名方式、解析方式

函数调用方式,如压栈、返回值保存等

堆栈分布方式

寄存器使用约定

其他很多因素,不一一列举。C++的则又有了更多额外的内容:

继承类体系的内存分布,比如基类、虚基类在继承类的位置等

指向成员函数的指针的内存分布,如何传递this指针等。

虚函数调用的方法,vtable的内容和分布形式,vtable在object中的位置等。

template如何实例化

外部符号的修饰

全局对象的构造和析构

异常的产生和捕获机制

标准库的细节问题,如RTTI如何实现

内嵌函数访问细节

C++有的时候同一个编译器不同版本之间兼容性也很不好。

所以人们一直期待有统一的C++二进制兼容标准(C++ ABI)。*nix系统的ABI到了LSB(Linux Standard Base)和Interl的Itaniurn C++ ABI标准出来后才有所改善,但是并不能彻底解决。目前形成了VISUAL C++和GNU阵营的GCC(Itaniurn C++ ABI标准)的两大派系。

九,静态库链接

一个语言的开发环境带有的语言库(Language Library)就是对系统API的封装,比如C标准库的printf,在linux下是系统调用write的封装,windows下则是writeconsole系统API。

对于strlen()则没有调用系统API。很大部分的库函数会调用操作系统API。

静态库是一组目标文件的集合。比如Linux下的C语言静态库位于/user/lib/libc.a/,其是glibc项目的一部分,windows上常用的c语言库是IDE自带的运行库。VC++附带了多个版本的C/C++运行库,比如多线程静态库和多线程静态调试库。一般存放与VC安装目录下的lib\目录。

人们使用ar压缩程序将目标文件压缩到一起,并进程标号和索引,这样printf.o、scanf.o、fread.o、fwrite.o等目标文件,就很好查找和检索了,这样形成了libc.a静态库文件。

ar -t libc.a查看包含的目标文件。

VC++的称为lib.exe,用以创建或提取.lib文件内容。参见MSDN。

查看printf所在目标文件:

objdump -t libc.a |grep printf

可以使用ar -x libc.a解压出printf.o,然后使用printf.o与hello,world!程序进行链接。但是在编译hello,world的时候,要使用-fno-buildin,防止编译器对其优化,使用puts代替printf函数。

但是printf.o依赖与其他目标文件stdio.o中的stdout和vfprintf.o中的vfprintf。但是这两个文件又依赖其他目标文件。只有找齐它们才能顺利链接。

ld链接程序会处理这个复杂繁琐的过程,将.o文件从libc.a中解压缩,最终链接成可执行文件。

使用gcc 的-verbose表示将编译链接过程打印出来:

gcc -static --verbose -fno-buildin hello.c 过程很复杂。使用的链接程序是collect2,其是ld的包装,但会对链接结果进程进一步处理。

printf.o中只有一个函数的这种设计,正如之前讨论的函数级别链接,有利于链接代码占用空间的减少,因为一般链接会将整个文件合并到输出文件中。

九,链接过程控制

对于内核驱动程序等程序可能需要使用,请参考《程序员的自我修养》第四章,或者其他书籍。

十,BFD库

由于ELF文件在不同平台的变种很多而且差异很大,BFD库(Binary File Descriptor Library)就是为了通过统一接口处理不同的目标文件格式的一个GNU项目。这个项目本身是binutils项目中的子项目。GCC、GDB、binutils的其他工具都使用BFD库来处理目标文件。我们也可使用BFD库处理二进制文件。

参考http://sources.redhat.com/binutils/ 的binutils文档。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值