链接的这个过程主要解决如何将多个目标文件链接起来得到一个可执行文件
假设分别有如下的两个文件 a.c b.c
注意这里和书上的有点区别在于a.c中添加了 extern swap 的声明
extern int shared;
extern void swap(int *a, int *b);
int main() {
int a = 100;
swap(&a, &shared);
}
int shared = 1;
void swap(int *a, int *b) {
*a ^= *b = *a ^= *b;
}
我们知道可执行文件中的代码段和数据段都是由输入的目标文件合并起来的, 那么对于多目标文件来说, 链接器是如何将各个段合并到输出文件的呢? 或者说, 输出文件中的空间应该如何分配给输入文件?
一个简单的方法就是按次序叠加起来, 如下所示
但是这种方式有很严重的问题, 首先对于多个输入文件的情况会出现很多零散的段, 每一个段都有一定的地址和空间对齐要求, 对于 x86 的硬件来说段的装在地址和空间的对齐单位是 4096 字节, 也就是说即使一个段的长度只有1字节, 他也需要在内存中占据 4096 字节, 因此这并不是一个好的方案
实际上使用的方式是将各个段合并到一起, 相同性质的段组合为一个大段, 如下所示
前文提到了 .bss 段实际上并不占用文件的空间, 在装载时占用地址空间. 那么这里我们可以思考一个问题, 就是所谓的空间分配到底是什么空间?
这样讲起来有点抽象, 不如举一个例子. 对于下面的代码, 全局变量中有一个 x[1000], 由于并未初始化所以它应该分配在 bss 段中
int x[1000];
int main() {
return 0;
}
$ gcc bss.c -o bss
$ nm bss | grep x
w __cxa_finalize@GLIBC_2.2.5
00000000000010e0 t __do_global_dtors_aux
0000000000003df8 d __do_global_dtors_aux_fini_array_entry
0000000000004040 B x
B 对应 .bss, D 对应 .data
可以看到最后一行说明了 x 确实分配在 bss 段中, 通过 du 可以得到这个可执行文件的大小是 16KB
(base) kamilu@LZX:~/miniCRT/notes$ du -h bss
16K bss
但是如果我们稍微修改一下
int x[1000] = {1};
int main() {
return 0;
}
此时可以看到, x 由于已经被初始化了所以被分配在 data 段中, 并且文件的体积也扩大到了 20KB, 多出来的 4KB 显然就是 x 数组的大小
$ nm bss
0000000000004020 D x
$ du -h bss
20K bss
实际上地址和空间有两部分的含义, 一个是指输出在可执行文件中的空间, 第二个是装载后的虚拟地址中的虚拟地址空间
对于有实际数据的段, 比如 .text, .data, 它们在文件中和虚拟地址中都要分配空间, 因为在二者中他们都存在
但是对于 .bss 这样的段来说不需要在可执行文件中分配地址空间, 只需要在虚拟地址空间中分配空间
换而言之, 无论使用 int x[1000]
还是 int x[1000] = {1}
, 都确实在程序中定义了一个大小为1000的int类型的数组x, 只不过由于前面的x没有初始化, 所以不需要在可执行文件中为其开辟一块 4KB 大小的空间来存放这个数组 x 的所有值, 而是用一个记录说明符号 x, 大小 4000, 这样就可以节约可执行文件的大小了
链接器一般采用两步链接
在这一步中链接器能够获取所有的输入目标文件的段长度, 并且将它们合并, 计算出输出文件中各个段合并后的长度和位置, 并建立映射关系
实际上这一步是链接的核心, 特别是重定位
可以尝试使用 ld 将 a.o 和 b.o 链接起来, 但是这里书上有一点小问题, 首先是需要修改一下程序, 手动添加 exit, 不然程序不知道在哪里终止, 运行起来会出现 segment fault 的问题
参考
extern int shared;
extern void swap(int *a, int *b);
int main() {
int a = 100;
swap(&a, &shared);
asm("movq $66,%rdi \n\t"
"movq $60,%rax \n\t"
"syscall \n\t");
}
其次是在编译的时候使用 -fno-stack-protector
关闭栈检查
gcc -fno-stack-protector -c a.c b.c
ld a.o b.o -e main -o ab
-e 表示将 main 函数作为程序的入口, 默认的入口程序是 _start
可以使用 objdump 来查看 a.o b.o ab 的地址分配情况, 如下所示
(base) kamilu@LZX:~/miniCRT/notes$ objdump -h a.o
a.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000040 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000080 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000080 2**0
ALLOC
3 .comment 0000002e 0000000000000000 0000000000000000 00000080 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000ae 2**0
CONTENTS, READONLY
5 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000b0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .eh_frame 00000038 0000000000000000 0000000000000000 000000d0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
(base) kamilu@LZX:~/miniCRT/notes$ objdump -h b.o
b.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000047 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 0000000000000000 0000000000000000 00000088 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 0000008c 2**0
ALLOC
3 .comment 0000002e 0000000000000000 0000000000000000 0000008c 2**0
CONTENTS, READONLY
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000ba 2**0
CONTENTS, READONLY
5 .note.gnu.property 00000020 0000000000000000 0000000000000000 000000c0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .eh_frame 00000038 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
(base) kamilu@LZX:~/miniCRT/notes$ objdump -h ab
ab: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .note.gnu.property 00000020 00000000004001c8 00000000004001c8 000001c8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
1 .text 00000087 0000000000401000 0000000000401000 00001000 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
2 .eh_frame 00000058 0000000000402000 0000000000402000 00002000 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .data 00000004 0000000000404000 0000000000404000 00003000 2**2
CONTENTS, ALLOC, LOAD, DATA
4 .comment 0000002d 0000000000000000 0000000000000000 00003004 2**0
CONTENTS, READONLY
可以发现 a.o 和 b.o 的 .text 段以及 .data 段大小的和刚好是 ab 中的数值, 也印证了合并的方式
.text: 0x40 + 0x47 = 0x87
.data: 0x0 + 0x4 = 0x4
VMA(Virtual Memory Address) 代表虚拟地址, LMA(Load Memory Address)加载地址, 正常情况下这两个值应该是一样的. 但是在有的嵌入式系统中这两个值是不同的
在链接之前目标文件中的所有段的 VMA 都是 0. 而链接后可执行文件中的各个段都被分配到了相应的虚拟地址, 整个程序.text段从 0x401000 开始
当将多个目标文件合并为一个可执行文件之后, 这时候每一个段的虚拟地址已经确定下来了, 比如 .text 段从 0x401000
开始, .data 段从 0x404000
开始. 对于每一个目标文件中的每一个符号, 它们在对应的目标文件的段中有一个固定的偏移量, 比如 X. 那么当最后得到可执行文件之后只需要对应的从 0x401000 + X
即可得到最终的全局符号地址
.text
段从 0x401000 开始而不是 0x400000 是因为开头还有一个.note.gnu.property
段. 但是程序是从 .text 段开始执行的, 也就是说装入之后 PC 的值会被设置为 0x401000, 这个地址可以使用readelf -h
来查看 Entry point address 这一项得到
使用 objdump -d 反汇编 a.o 可以得到如下程序, 其中注意到 0x17 处 48 8d 15 00 00 00 00 和 0x24 处的 e8 00 00 00 00, 分别对应 shared 的地址以及 swap 的地址. 由于在这个阶段编译器并不知道 shared 和 swap 的具体地址, 所以暂时把地址填 0
0000000000000000 <main>:
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: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp)
13: 48 8d 45 fc lea -0x4(%rbp),%rax
17: 48 8d 15 00 00 00 00 lea 0x0(%rip),%rdx # 1e <main+0x1e>
1e: 48 89 d6 mov %rdx,%rsi
21: 48 89 c7 mov %rax,%rdi
24: e8 00 00 00 00 call 29 <main+0x29>
29: 48 c7 c7 42 00 00 00 mov $0x42,%rdi
30: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax
37: 0f 05 syscall
39: b8 00 00 00 00 mov $0x0,%eax
3e: c9 leave
3f: c3 ret
对于这里的地址重定位的计算交给了链接器, 通过前面的空间和地址分配, 链接器就可以确定所有符号的虚拟地址了, 就对这些位置进行修正. 可以通过 objdump -d ab 来查看最后的可执行文件当中的代码段, 如下所示
0000000000401000 <main>:
401000: f3 0f 1e fa endbr64
401004: 55 push %rbp
401005: 48 89 e5 mov %rsp,%rbp
401008: 48 83 ec 10 sub $0x10,%rsp
40100c: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp)
401013: 48 8d 45 fc lea -0x4(%rbp),%rax
401017: 48 8d 15 e2 2f 00 00 lea 0x2fe2(%rip),%rdx # 404000 <shared>
40101e: 48 89 d6 mov %rdx,%rsi
401021: 48 89 c7 mov %rax,%rdi
401024: e8 17 00 00 00 call 401040 <swap>
401029: 48 c7 c7 42 00 00 00 mov $0x42,%rdi
401030: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax
401037: 0f 05 syscall
401039: b8 00 00 00 00 mov $0x0,%eax
40103e: c9 leave
40103f: c3 ret
可以注意到之前填 0 的地址已经被计算完毕, 那么链接器是如何计算并修正这里的地址的呢?
在 ELF 文件当中有一个 重定位表(Relocation Table) 的结构专门用于保存这些与重定位相关的信息, 它在 ELF 文件中往往是一个或多个段. 比如 .text 如果有要被重定位的地方那么就有一个相应的叫做 .rela.text 的段(前面加上 .rela
). 我们可以使用 readelf -S a.o
查看 a.o 目标文件的段表, 其中 [2] .rela.text 和 [9] .rela.eh_frame 分别是对 [1] 和 [8] 的两个重定位表
重定位段的 TYPE 是 RELA, Link 和 Info 分别指向对应符号表 和 需要重定位的段. 需要重定位的段肯定是代码段(.text)
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
0000000000000040 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 000001b0
0000000000000030 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000080
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000080
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .comment PROGBITS 0000000000000000 00000080
000000000000002e 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 000000ae
0000000000000000 0000000000000000 0 0 1
[ 7] .note.gnu.pr[...] NOTE 0000000000000000 000000b0
0000000000000020 0000000000000000 A 0 0 8
[ 8] .eh_frame PROGBITS 0000000000000000 000000d0
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 000001e0
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000108
0000000000000090 0000000000000018 11 3 8
[11] .strtab STRTAB 0000000000000000 00000198
0000000000000016 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 000001f8
000000000000006c 0000000000000000 0 0 1
我们可以直接使用 readelf -r
来查看所有重定位表的信息
(base) kamilu@LZX:~/miniCRT/notes$ readelf -r a.o
Relocation section '.rela.text' at offset 0x1b0 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000001a 000400000002 R_X86_64_PC32 0000000000000000 shared - 4
000000000025 000500000004 R_X86_64_PLT32 0000000000000000 swap - 4
Relocation section '.rela.eh_frame' at offset 0x1e0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
每一个重定位表都是 Elf64_Rela
的数组, 该结构体成员如下所示
typedef struct {
Elf64_Addr r_offset; // 在对应段(比如 .text)中的偏移量
uint64_t r_info; // 低 32 位是符号的重定位类型, 高32位是符号的在符号表中的索引
int64_t r_addend; // 偏移地址长度, 一般是 -4
} Elf64_Rela;
接下来我们具体来看一下链接器是如何根据已有信息完成地址修正的
在编译程序的过程中, undefined reference 是一个非常常见错误
对于一个目标文件, 我们可以看到 GLOBAL 中的三个符号中的 shared swap 都是 UND, 即 undefined 未定义类型.
这种未定义的符号说明了文件中有关于他们的重定位项, 如果链接器扫描了所有输入目标文件之后仍然有未定义符号就会报未定义错误
(base) kamilu@LZX:~/miniCRT/notes$ readelf -s a.o
Symbol table '.symtab' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS a.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 64 FUNC GLOBAL DEFAULT 1 main
4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND shared
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap
接下来详细的讨论一下如何进行指令修正.
对于每一个需要重定位的符号, 其 offset 对应了其重定位的位置(主要是 .text 段), 同时还有两个重要信息分别是他们的重定位类型 R_X86_64_PC32
和 R_X86_64_PLT32
, 以及最后的 Addend 的值. 如下图所示
那么如何在链接得到可执行文件的时候确定这个最终应该跳转的地址位置呢? 这主要取决于选址方式, 目前采取的主流重定位方式都是相对地址寻址
R_X86_64_PC32
: 用于对全局符号进行重定位,以及在指令中引用数据段中的变量时进行重定位.R_X86_64_PLT32
: 用于对函数调用进行重定位.首先观察下图, 前文提到过为了生成可执行文件, 首先需要做的就是取出各个目标文件的 .text, .data , .bss 段并将其分别合并为一个大段, 然后拼接到一起得到一个文件, 也就是第一步. 可以看到最终得到的可执行文件已经确定了各个段的位置, 其中 .text
段的起始地址是 0x401000
, .data
段的起始地址是 0x404000
, 这两个地址比较重要, 因为我们稍后会需要用到它们, 这两个段的索引分别是 2 和 4
在合并所有的目标文件对应的段之后, 实际上每一个符号的位置也就确定了下来, 如下图所示.
其中 swap 类型为 FUNC, 地址位于 0x401040
, 位于 2 号段(也就是 .text 段). shared 类型为 OBJECT, 地址位于 0x404000
(也就是 .data 段的首地址), 位于 4 号段(也就是.data段). 除此之外还可以看到 main 函数, 位于 0x401000
也就是 .text 段的首地址.
不难推测, 对于其他变量和函数, 它们会在 .data 和 .text 段依次往后存放
下图中也可以比较清晰的看到 main 和 swap 函数的地址起点, 就是顺序排列下来
那么接下来要做的事就是如何修改这里的四个字节以找到 shared 变量和 swap 函数, 我们先来整理一下已知信息
.text
段的起始地址为 0x401000; .data
段的起始地址为 0x404000.text
段偏移 0x1a 的位置, 偏移地址长度 -4.text
段偏移 0x25 的位置, 偏移地址长度 -4当执行这条指令的时候, CPU 的 PC(rip) 的位置实际上是下一条指令的开始位置. 因此为了计算实际地址偏移量, 相对偏移量 = 数据地址 - 重定位地址 + 偏移量:
relative_addr = ADDR(shared) - (ADDR(.text) + OFFSET(shared)) + ADDEND(shared)
= 0x404000 - (0x401000 + 0x1a) + (-4)
= 0x404000 - 0x40101e
= 0x2fe2
所以实际上就是做了一个减法 0x404000 - 0x40101e = 0x2fe2
0x40101e
,最后修改这里的值, 使用小端存储也就是 e2 2f 00 00
那么接下来的过程也是同理, 需要修正 swap 的函数跳转地址: 0x401040 - 0x401029 = 0x17
这里的 0x401029 也是通过 (.text 段地址 + 偏移量 - addend) = 0x401000 + 0x25 - (-4) 计算得到的
我们可以使用如下的命令找到 Linux 下的 libc 库
(base) kamilu@LZX:~$ find /usr/lib* -name "libc.a"
/usr/lib/x86_64-linux-gnu/libc.a
/usr/lib32/libc.a
/usr/libx32/libc.a
(base) kamilu@LZX:~$ find /usr/lib* -name "libc.so"
/usr/lib/x86_64-linux-gnu/libc.so
/usr/lib32/libc.so
/usr/libx32/libc.so
/usr/lib/x86_64-linux-gnu/libc.a
: 这是针对64位x86架构的Linux系统的库文件.它包含了glibc库的所有符号,并且在链接时可以将所有的代码静态地链接到目标程序中.这个路径是Debian和Ubuntu系统上的默认位置./usr/lib32/libc.a
: 这是针对32位x86架构的Linux系统的库文件.它包含了glibc库的所有符号,并且在链接时可以将所有的代码静态地链接到目标程序中.这个路径在一些Linux系统上,如Fedora和CentOS上是默认位置./usr/libx32/libc.a
: 这是针对x32 ABI的64位x86架构的Linux系统的库文件.x32 ABI是一种特殊的ABI,它使用32位指针和64位寄存器,旨在提高64位计算机上32位应用程序的性能.这个路径在一些Linux系统上,如Debian和Ubuntu上是默认位置.这些库文件是静态链接库,因此它们可能会增加可执行文件的大小,并且在多个程序使用相同的库时可能会导致重复.因此,通常建议使用动态链接库,例如libglibc.so或libc.so,以减少可执行文件的大小并实现共享.
一个静态库可以简单的看作一组目标文件的集合, 即很多目标文件压缩打包后形成的一个文件. 在一个C语言的运行库中,包含了很多跟系统功能相关的代码,比如输入输出/文件操作/时间日期/内存管理等. glibc本身是用C语言开发的, 它由成百上千个C语言源代码文件组成,也就是说,编译完成以后有相同数量的目标文件,比如
把这些零散的目标文件直接提供给库的使用者,很大程度上会造成文件传输/管理和组织方面的不便,于是通常人们使用"ar"压缩程序将这些目标文件压缩到一起,并且对其进行编号和索引以便于查找和检索, 这样就得到了 libc.a 这个静态库的文件
可以使用 ar 来压缩/解压/查看一个 .a 静态库的信息, 比如查看 glibc.a, 这个文件有 5.8MB, 是大量目标文件的压缩结果
(base) kamilu@LZX:~/miniCRT/notes$ du -h /usr/lib/x86_64-linux-gnu/libc.a
5.8M /usr/lib/x86_64-linux-gnu/libc.a
(base) kamilu@LZX:~/miniCRT/notes$ ar -t /usr/lib/x86_64-linux-gnu/libc.a | less
init-first.o
libc-start.o
sysdep.o
version.o
check_fds.o
libc-tls.o
dso_handle.o
errno.o
errno-loc.o
iconv_open.o
iconv.o
iconv_close.o
gconv_open.o
...
那么我们尝试使用 ld 来手动链接 glibc.a 也很麻烦, 因为除了 C 标准库还需要运行时的一些目标文件和库需要被链接进来. 我们会在 "库与运行库" 这一节单独讲解
Q&A: 为什么静态运行库里面一个目标文件只包含一个函数? 比如 libc.a 里面 printf.o 只有 printf, strlen.o 中只有 strlen
链接器在链接静态库的时候是以目标文件为单位的.比如我们引用了静态库中的
printf
函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中.由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件(函数)就不会被链接到最终的输出文件中.
绝大部分情况下,我们使用链接器提供的默认链接规则对目标文件进行链接.这在一般情况下是没有问题的. 但在一些特殊情况下我们希望控制整个链接过程的细节, 比如: 使用哪些目标文件?使用哪些库文件?是否在最终可执行文件中保留调试信息/输出文件格式(可执行文件还是动态链接库)? 还要考虑是否要导出某些符号以供调试器或程序本身或其他程序使用等.
链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所须要的文件.一般链接器有如下三种方法:
-o
-e
参数就属于这类前面我们在使用ld链接器的时候,没有指定链接脚本,其实 ld 在用户没有指定链接脚本的时候会使用默认链接脚本.我们可以使用下面的命令行来查看ld默认的链接脚本
ld --verbose
默认的链接器脚本位于 /usr/lib/x86_64-linux-gnu/ldscripts
下, 不同的机器平台/输出文件格式都有相应的链接脚本. 可以使用下述命令查看所有链接脚本, 这里使用的是 elf_x86_64.x
(base) kamilu@LZX:~/miniCRT$ ls /usr/lib/x86_64-linux-gnu/ldscripts
(base) kamilu@LZX:~/miniCRT$ vim /usr/lib/x86_64-linux-gnu/ldscripts/elf_x86_64.x
当然,为了更加精确地控制链接过程,我们可以自己写一个脚本,然后指定该脚本为链接控制脚本.比如可以使用-T参数:
ld -T link.script
为了演示链接的具体过程, 我们希望做一个最小的 hello world 程序
printf
函数,该函数是系统C语言库的一部分.为了使用该函数,我们必须在链接时将C语言库与程序的目标文件链接产生最终可执行文件.我们希望"小程序"能够脱离C语言运行库,使得它成为一个独立于任何库的纯正的"程序"._start
,由库负责初始化后调用main 函数来执行程序的主体部分. 因此我们使用 nomain
来作为程序的入口char *str = "Hello World\n";
void print() {
asm("movq $13, %%rdx \n\t"
"movq %0, %%rcx \n\t"
"movq $0, %%rbx \n\t"
"movq $4, %%rax \n\t"
"int $0x80 \n\t" ::"r"(str)
: "rdx", "rcx", "rbx");
}
void exit() {
asm("movq $42, %rbx \n\t"
"movq $1, %rax \n\t"
"int $0x80 \n\t");
}
void nomain() {
print();
exit();
}
将上述代码保存为 TinyHelloWorld.c
, 然后使用如下的指令编译链接, 程序可以正确执行并输出 "Hello World", 最后的返回值为 42
(base) kamilu@LZX:~/miniCRT/notes$ gcc -c -fno-builtin TinyHelloWorld.c
(base) kamilu@LZX:~/miniCRT/notes$ ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o
(base) kamilu@LZX:~/miniCRT/notes$ ./TinyHelloWorld
Hello World
(base) kamilu@LZX:~/miniCRT/notes$ echo $?
42
从源代码我们可以看到,程序入口为nomain()函数,然后该函数调用print()函数,打印"Hello World",接着调用exit()函数,结束进程.
这里的print 函数使用了Linux 的 WRITE
系统调用,exit()函数使用了 EXIT
系统调用, 其中 write 原型如下
int write(int fd, const void *buf, size_t count);
我们可以使用 GCC 提供的内联汇编来调用这个 Linux 内核提供的系统调用, 系统调用通过0x80中断实现,其中 eax为调用号,ebx/ecx/edx等通用寄存器用来传递参数
void print() {
asm("movq $13, %%rdx \n\t"
"movq %0, %%rcx \n\t"
"movq $0, %%rbx \n\t"
"movq $4, %%rax \n\t"
"int $0x80 \n\t" ::"r"(str)
: "rdx", "rcx", "rbx");
}
除了命令行传参, 我们也可以使用 ld 链接脚本, 将如下代码保存在 link.lds 中
ENTRY (nomain)
SECTIONS
{
. = 0x0401000 + SIZEOF_HEADERS;
tinytext : { *(.text) *(.data) *(.rodata) }
/DISCARD/ : { *(.comment) }
}
tinytext: { *(.text)"(.data)"(.rodata)}第二条是个段转换规则,它的意思即为所有输入文件中的名字为".text"/".data"或".rodata"的段依次合并到输出文件的"tinytext".
/DiISCARD/ : { "(.comment)}第三条规则为:将所有输入文件中的名字为".comment"的段丢弃,不保存到输出文件中.
使用 -T 参数来指定 ld 链接脚本, 可以看到可执行文件的体积明显变小了
(base) kamilu@LZX:~/miniCRT/notes$ ld -static -T link.lds -o TinyHelloWorld TinyHelloWorld.o
(base) kamilu@LZX:~/miniCRT/notes$ du -b TinyHelloWorld
1352 TinyHelloWorld
可以看到最终文件的 .text .data .bss .rodata
段都合并到了 .tinytext
段中, 剩余三个段 .symtab
符号表, .strtab
字符串表, .shstrtab
段表字符串表
(base) kamilu@LZX:~/miniCRT/notes$ readelf -S TinyHelloWorld
There are 8 section headers, starting at offset 0x348:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .note.gnu.pr[...] NOTE 0000000000401120 00000120
0000000000000020 0000000000000000 A 0 0 8
[ 2] .eh_frame PROGBITS 0000000000401140 00000140
0000000000000078 0000000000000000 A 0 0 8
[ 3] tinytext PROGBITS 00000000004011b8 000001b8
0000000000000078 0000000000000000 WAX 0 0 1
[ 4] .data.rel.local PROGBITS 0000000000401230 00000230
0000000000000008 0000000000000000 WA 0 0 8
[ 5] .symtab SYMTAB 0000000000000000 00000238
0000000000000090 0000000000000018 6 2 8
[ 6] .strtab STRTAB 0000000000000000 000002c8
0000000000000028 0000000000000000 0 0 1
[ 7] .shstrtab STRTAB 0000000000000000 000002f0
0000000000000051 0000000000000000 0 0 1
其中 .shstrtab
段表字符串表是必须要保留的, 但是剩下两个也是可以删除的, 可以使用 -s
选项去掉, 如下所示, 可以看到可执行文件被进一步压缩了
(base) kamilu@LZX:~/miniCRT/notes$ ld -static -T link.lds -s -o TinyHelloWorld TinyHelloWorld.o
(base) kamilu@LZX:~/miniCRT/notes$ du -b TinyHelloWorld
1024 TinyHelloWorld
ld 链接脚本语法略