程序员的自我修养学习笔记——第四章
对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件? 或者说,输出文件中的空间如何分配给输入文件?
/*假设有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;
}
“链接器为目标文件分配地址和空间”中的“地址和空间”其实有两个含义:第一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。
这个链接过程分两步:
第一步 : 空间与地址分配
扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一到全句符号表。
第二步 : 符号解析与重定位
使用第一步收集到的所有信息,读取输入文件的中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。
对上面的两个文件编译:
$gcc -c a.c b.c //只编译不连接
$ld a.o b.o -e main -o ab
-e main 表示将main函数作为程序入口,ld连接器默认的程序入口为_start
-o ab 表示连接输出文件名为ab,默认为a.out
可以通过objdump来查看链接前后地址的分配情况。
$ objdump -h a.o
a.o: file format pe-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000084 00000000 00000000 0000008c 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000004 00000000 00000000 00000110 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 00000000 2**2
ALLOC
VMA表示Virtual Memory Address, LMA表示 Load Memory Address 即加载地址
root@ubuntu:~/Desktop/ezCode# objdump -h ab
ab: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000062 08048094 08048094 00000094 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 080490f8 080490f8 000000f8 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .comment 0000002b 00000000 00000000 000000fc 2**0
CONTENTS, READONLY
链接之后,可执行文件“ab”中的各个段都被分配到了相应的虚拟地址,VMA值已经不再是00000000。Linux 下,ELF可执行文件默认从地址0x08048000开始分配。
看反汇编:
root@ubuntu:~/Desktop/ezCode# objdump -d a.o
a.o: file format elf32-i386
Disassembly of section .text:
00000000 <main>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 e4 f0 and $0xfffffff0,%esp
6: 83 ec 20 sub $0x20,%esp
9: c7 44 24 1c 64 00 00 movl $0x64,0x1c(%esp)
10: 00
11: c7 44 24 04 00 00 00 movl $0x0,0x4(%esp)
18: 00
19: 8d 44 24 1c lea 0x1c(%esp),%eax
1d: 89 04 24 mov %eax,(%esp)
20: e8 fc ff ff ff call 21 <main+0x21>
25: c9 leave
26: c3 ret
代码段“.text”的重定位表为“.rel.data”
查看重定位表:
$objdump -r a.o
a.o: file format elf32-i386
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
00000015 R_386_32 shared
00000021 R_386_PC32 swap
重定位表是一个Elf32_Rel结构的数组,每个数组元素对应一个重定位入口。
typedef struct
{
Elf32_Addr r_offset;
Elf32_Word r_info;
}Elf32_Rel;
r_offset: 重定位入口的偏移地址
r_info : 重定位入口的类型和符号。低八位表示重定位入口的类型,高24位表示重定位入口的符号在符号表中的下标。
符号解析:
重定位过程中,每个重定位的入口都是对一个符号的引用,那么当连接器须要对某个符号的引用最近重定位时,它就要确定这个符号的目标地址。这时连接器就会去查找由所有目标文件的符号组成的全局符号表,找到相应的符号后进行重定位。
root@ubuntu:~/Desktop/ezCode# 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 39 FUNC GLOBAL DEFAULT 1 main
8: 00000000 0 NOTYPE GLOBAL DEFAULT UND shared
9: 00000000 0 NOTYPE GLOBAL DEFAULT UND swap
注意上面Ndx项中UND表示这两项须要重定位。
全局构造与析构:
我们知道C++全局对象的构造函数在main之前被执行,C++全局对象的析构函数在main之后被执行。
Linux系统下一般程序的入口是“_start”,这个函数是Linux系统库(Glibc)的一部分。当我们的程序与Glibc链接在一起形成最终可执行文件以后,这个函数就是程序的初始化部分的入口,程序初始化部分完成一系列初始化过程之后,会调用main函数来执行程序主体。
在main函数前后执行的一般放在ELF文件中的两个特殊段:
.init 该段里面保存的是可执行指令,它构成了进程的初始化代码。
.fini 该段保存着进程终止代码指令。
如果一个函数放在.init段,在main函数执行前系统就会执行它。同理,假如一个函数放到.fint段,在main函数返回后该函数就会被执行。
不同编译器编译出来的目标文件是否可以链接在一起呢?
如果能,须要:采用相同的目标文件格式,拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同等等。其中我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容相关的内容成为ABI。
ABI:application binary interface,应用程序二进制接口
影响ABI的因素非常多,硬件、编程语言、编译器、连接器、操作系统等都会有影响到ABI。
对于C语言的目标代码来说,一下几个方面会决定目标文件之间是否二进制兼容:
·内置类型(int,float,char等)的大小和在存储器中的防止位置(大小端、对齐方式等)
·组合类型(struct、union、数组等)的存储方式和内存布局
·外部符号与用户定义的符号之间的命名方式和解析方式,如函数func在C语言的mubi9ao文件中是否被解析成为外部符号_func
·函数的调用方式,比如参数入栈顺序、返回值如何保持等
·堆栈的分布方式,比如参数和局部变量在堆栈里面的位置,参数传递方法等。
·寄存器使用约定,函数调用时那些寄存器可以修改,哪些需要保存等。
C++要做到二进制兼容比C更不容易:
·继承类体系的内存分布,如基类,虚基类在继承类中的位置等
·指向成员函数的指针的内存分布,如何通过指向成员函数的指针来调用成员函数,如何传递this指针
·如何调用虚函数,vtable的内容和分布形式,vtable指针在object中的位置等
·template如何实例化
·外部符号的修饰
·全句对象的构造和析构
·一次的产生和捕获机制
·标准库的细节问题,RTTI如何实现等
·内嵌函数的访问细节
C++一直为人诟病的一大原因是它的二进制兼容性不好。
二进制兼容目的: 针对动态链接库,一个程序连接到动态链接库,当发布新版本的动态链接库的时候, 这个程序依然能够正常运行。
二进制不兼容的例子:
- 给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
- 增加虚函数,会造成 vtbl 里的排列变化。(不要考虑“只在末尾增加”这种取巧行为,因为你的 class 可能已被继承。)
- 增加默认模板类型参数,比方说 Foo 改为 Foo >,这会改变 name mangling
- 改变 enum 的值,把 enum Color { Red = 3 }; 改为 Red = 4。这会造成错位。当然,由于 enum 自动排列取值,添加 enum 项也是不安全的,除非是在末尾添加。
源文档 <http://blog.csdn.net/Solstice/article/details/6233478>
下面的操作一般是二进制兼容的:
增加新的 class
增加 non-virtual 成员函数
修改数据成员的名称,因为生产的二进制代码是按偏移量来访问的,当然,这会造成源码级的不兼容。
Policies/Binary Compatibility Issues With C++
源文档 <http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B>
静态链接:
静态链接库可以简单看成一组目标文件的集合。
比如Linux中最常用的C语言静态库libc位于 /usr/lib/libc.a
使用“ar”查看libc.a中的内容:
$ar -t libc.a
init-first.o
libc-start.o
sysdep.o
version.o
check_fds.o
libc-tls.o
elf-init.o
dso_handle.o
errno.o
init-arch.o
…
总共有1500多项
$gcc -o helloworld helloword.c
在链接的时候至少有下面几个库和目标文件被链接入了最终的可执行文件:
crt1.o crti.o crtbeginT.o ligcc.a libgcc_eh.a libc. crtend.o crtn.o
链接过程控制:
在大部分情况下,我们使用链接器提供的默认链接规则对目标文件进行链接。这一版没有问题,但对于特殊要求的程序,比如操作系统内核,BIOS或一些没有操作系统的情况下运行的程序(Boot Loader或者嵌入式系统程序),以及另外的一些需要特殊的链接过程的程序,如内核驱动程序等。
连接器提供的控制整个链接过程的方法:
·使用命令行来给连接器指定参数
·将链接指令存放在目标文件里面,编译通常通过这种方法想连接器传递指令。VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段。
·使用连接控制脚本
/TinyHelloWorld.c/
char *str = "Hello world!\n";
void print()
{
asm("movl $13, %%edx \n\t"
"movl %0, %%ecx \n\t"
"movl $0, %%ebx \n\t"
"movl $4, %%eax \n\t"
"int $0x80 \n\t"
::"r"(str):"edx","ecx","ebx");
}
void exit()
{
asm("movl $42, %ebx \n\t"
"movl $1, %eax \n\t"
"int $0x80 \n\t");
}
void nomain()
{
print();
exit();
}
程序中使用了GCC内联汇编,我会专门介绍一下(其实我也不懂汇编)
先简单介绍系统调用:系统调用通过0x80中断实现。其中eax为调用号,ebx,ecx,edx等通用寄存器来传递参数。
比如WRITE调用是往一个文件句柄写入数据,如果用C语言来描述它的原型就是:
int write(int filedesc, char *buffer, int size);
·WRITE调用的调用号为4, 则eax = 4
·filedesc表示被写入的文件句柄,使用ebx寄存器传递,我们这里是要往标准输出,它的文件句柄为0,即ebx = 0
·buffer表示要写入的缓冲区地址,使用ecx寄存器传递,我们这要输出字符串str,所以ecx = str.
·size表示要写入的字节数,使用edx寄存器传递,字符串“Hello world!\n”长度为13字节,所以edx = 13
编译和链接TinyHelloWorld.c
$gcc -c -fno-builtin TinyHelloWorld.c
$ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o
GCC和ld参数的意义:
·-fno-builtin GCC编译器提供了很多内置函数来来替换C库函数,以达到优化目的,使用-fno-builtin参数来关闭GCC内置函数功能。
·-static 表示ld将使用静态链接的方式来链接程序
·-e nomain 表示该程序的入口函数为nomain
·-o TinyHelloWorld 表示指定输出可执行文件名TinyHelloWorld。
这里提到了很多关于readelf 的使用,下面给一个总结:
-a --all 全部 Equivalent to: -h -l -S -s -r -d -V -A -I
-h --file-header 文件头 Display the ELF file header
-l --program-headers 程序 Display the program headers
--segments An alias for --program-headers
-S --section-headers 段头 Display the sections' header
--sections An alias for --section-headers
-e --headers 全部头 Equivalent to: -h -l -S
-s --syms 符号表 Display the symbol table
--symbols An alias for --syms
-n --notes 内核注释 Display the core notes (if present)
-r --relocs 重定位 Display the relocations (if present)
-u --unwind Display the unwind info (if present)
-d --dynamic 动态段 Display the dynamic segment (if present)
-V --version-info 版本 Display the version sections (if present)
-A --arch-specific CPU构架 Display architecture specific information (if any).
-D --use-dynamic 动态段 Use the dynamic section info when displaying symbols
-x --hex-dump=<number> 显示 段内内容Dump the contents of section <number>
-w[liaprmfFso] or
--debug-dump[=line,=info,=abbrev,=pubnames,=ranges,=macro,=frames,=str,=loc]
显示DWARF2调试段内容 Display the contents of DWARF2 debug sections
-I --histogram Display histogram of bucket list lengths
-W --wide 宽行输出 Allow output width to exceed 80 characters
-H --help Display this information
-v --version Display the version number of readelf
Objdump 命令选项:
SYNOPSIS
objdump [-a|--archive-headers]
[-b bfdname|--target=bfdname]
[-C|--demangle[=style] ]
[-d|--disassemble]
[-D|--disassemble-all]
[-z|--disassemble-zeroes]
[-EB|-EL|--endian={big | little }]
[-f|--file-headers]
[-F|--file-offsets]
[--file-start-context]
[-g|--debugging]
[-e|--debugging-tags]
[-h|--section-headers|--headers]
[-i|--info]
[-j section|--section=section]
[-l|--line-numbers]
[-S|--source]
[-m machine|--architecture=machine]
[-M options|--disassembler-options=options]