静态链接

链接的这个过程主要解决如何将多个目标文件链接起来得到一个可执行文件

假设分别有如下的两个文件 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;
}

空间和地址分配

我们知道可执行文件中的代码段和数据段都是由输入的目标文件合并起来的, 那么对于多目标文件来说, 链接器是如何将各个段合并到输出文件的呢? 或者说, 输出文件中的空间应该如何分配给输入文件?

一个简单的方法就是按次序叠加起来, 如下所示

20230515145541

但是这种方式有很严重的问题, 首先对于多个输入文件的情况会出现很多零散的段, 每一个段都有一定的地址和空间对齐要求, 对于 x86 的硬件来说段的装在地址和空间的对齐单位是 4096 字节, 也就是说即使一个段的长度只有1字节, 他也需要在内存中占据 4096 字节, 因此这并不是一个好的方案

实际上使用的方式是将各个段合并到一起, 相同性质的段组合为一个大段, 如下所示

20240111151629

空间地址分配的含义

前文提到了 .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 开始

书上说代码地址总是从0x400000开始,但是查看编译好的elf头起始地址是从0开始的,这是为什么?

当将多个目标文件合并为一个可执行文件之后, 这时候每一个段的虚拟地址已经确定下来了, 比如 .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_PC32R_X86_64_PLT32 , 以及最后的 Addend 的值. 如下图所示

20230516201225

那么如何在链接得到可执行文件的时候确定这个最终应该跳转的地址位置呢? 这主要取决于选址方式, 目前采取的主流重定位方式都是相对地址寻址

首先观察下图, 前文提到过为了生成可执行文件, 首先需要做的就是取出各个目标文件的 .text, .data , .bss 段并将其分别合并为一个大段, 然后拼接到一起得到一个文件, 也就是第一步. 可以看到最终得到的可执行文件已经确定了各个段的位置, 其中 .text 段的起始地址是 0x401000, .data 段的起始地址是 0x404000, 这两个地址比较重要, 因为我们稍后会需要用到它们, 这两个段的索引分别是 2 和 4

20231126175403

在合并所有的目标文件对应的段之后, 实际上每一个符号的位置也就确定了下来, 如下图所示.

20231126175518

其中 swap 类型为 FUNC, 地址位于 0x401040, 位于 2 号段(也就是 .text 段). shared 类型为 OBJECT, 地址位于 0x404000(也就是 .data 段的首地址), 位于 4 号段(也就是.data段). 除此之外还可以看到 main 函数, 位于 0x401000 也就是 .text 段的首地址.

不难推测, 对于其他变量和函数, 它们会在 .data 和 .text 段依次往后存放

下图中也可以比较清晰的看到 main 和 swap 函数的地址起点, 就是顺序排列下来

20230516211039

地址重定位

那么接下来要做的事就是如何修改这里的四个字节以找到 shared 变量和 swap 函数, 我们先来整理一下已知信息

  1. 合并之后的可执行文件中, .text 段的起始地址为 0x401000; .data 段的起始地址为 0x404000
  1. 重定位表有两个表项需要被重定位
    1. shared: 位于 .text 段偏移 0x1a 的位置, 偏移地址长度 -4
    1. swap: 位于 .text 段偏移 0x25 的位置, 偏移地址长度 -4
  1. 符号表有两个表项(暂时忽略 eh_frame)
    1. shared: 位于 .data 段, 数据地址为 0x404000
    1. swap: 位于 .text 段, 数据地址为 0x401040

当执行这条指令的时候, CPU 的 PC(rip) 的位置实际上是下一条指令的开始位置. 因此为了计算实际地址偏移量, 相对偏移量 = 数据地址 - 重定位地址 + 偏移量:

relative_addr = ADDR(shared) - (ADDR(.text) + OFFSET(shared)) + ADDEND(shared)
              = 0x404000     - (0x401000    + 0x1a)           + (-4)
              = 0x404000 - 0x40101e
              = 0x2fe2

所以实际上就是做了一个减法 0x404000 - 0x40101e = 0x2fe2

最后修改这里的值, 使用小端存储也就是 e2 2f 00 00

20230516231522

那么接下来的过程也是同理, 需要修正 swap 的函数跳转地址: 0x401040 - 0x401029 = 0x17

这里的 0x401029 也是通过 (.text 段地址 + 偏移量 - addend) = 0x401000 + 0x25 - (-4) 计算得到的

20230517004015

静态库链接

我们可以使用如下的命令找到 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

这些库文件是静态链接库,因此它们可能会增加可执行文件的大小,并且在多个程序使用相同的库时可能会导致重复.因此,通常建议使用动态链接库,例如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()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中.由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件(函数)就不会被链接到最终的输出文件中.

绝大部分情况下,我们使用链接器提供的默认链接规则对目标文件进行链接.这在一般情况下是没有问题的. 但在一些特殊情况下我们希望控制整个链接过程的细节, 比如: 使用哪些目标文件?使用哪些库文件?是否在最终可执行文件中保留调试信息/输出文件格式(可执行文件还是动态链接库)? 还要考虑是否要导出某些符号以供调试器或程序本身或其他程序使用等.

链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所须要的文件.一般链接器有如下三种方法:

前面我们在使用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

最小的 HelloWorld 程序

为了演示链接的具体过程, 我们希望做一个最小的 hello world 程序

  1. 首先,经典的 helloworld 使用了 printf 函数,该函数是系统C语言库的一部分.为了使用该函数,我们必须在链接时将C语言库与程序的目标文件链接产生最终可执行文件.我们希望"小程序"能够脱离C语言运行库,使得它成为一个独立于任何库的纯正的"程序".
  1. 其次,经典的helloworld由于使用了库,所以必须有main函数.我们知道一般程序的入口在库的 _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 链接脚本语法略

参考

zood