大多数程序员的第一行代码可能都是从输出“Hello,World!开始的吧。如果请你写一个c程序,在屏幕上打印“Hello,World!”,下面的代码对拥有扎实编程基本功的你而言肯定so easy:
#include
int main()
{
printf("Hello, World!\n");
return 0;
}
使用gcc编译运行:
(base) ~/Downloads/exmaples$ gcc main.c -o main
(base) ~/Downloads/exmaples$ ./main
Hello, World!
(base) ~/Downloads/exmaples$
按照一般的编程套路,写了一个main()函数作为程序的入口函数。这个main()函数是梦(可能是噩梦,前方有难缠的八阿哥)开始的地方吗?
问题一 这个可执行文件大概依赖于哪些动态库?
假如你刚开始接触编程,只是浅浅的知道一个程序的运行需要依赖一些运行环境,但不太清楚究竟依赖哪些环境,这些环境又由哪些部门负责建立?为什么调用了一个printf函数,编译时没有链接任何库却可以编译通过并正常运行?
这些疑问,千锤百炼的编程大师们都想到了,并提供了一系列的工具来帮助你解开这些疑惑。
一般而言,动态链接的可执行程序需要依赖一些动态链接库。这些动态链接库或初始化程序运行的一些基础环境(比如堆栈),或辅助程序实现特定的功能(比如提供你需要的printf函数)。
对一个可执行程序而言,其运行所需要的库可以静态链接,也可以动态链接。
经常逛盒马的同学大概都可以看到一些事先为你烹饪好的美味佳肴,这些美味佳肴还有个响亮的名字--预制菜。制作美味佳肴需要的葱姜蒜、调料等已经和食材本身融为一体。即使是做菜小白,拿到预制菜放进微波炉加热一下也能无脑输出一道史诗级别的国民美食。
也有很多民间美食家喜欢自己动手,别人准备好的总归不一定百分百符合自己的口味。大厨们备菜一般不亲自出马,只负责烹饪的部分。拿到食材烹饪的过程中,需要什么配菜,加什么调料,都由大厨择机投放并严格控制用量,最后也能用脑输出一顿令人口口相传的家庭私房菜。
静态链接好比盒马的预制菜,烹饪所需的食材、配菜及调料作为一个整体被一次性打包。你中有我,我中有你,不能分离。
静态链接的程序在编译的时候被编译器将其依赖的模块和程序本身组装为一个整体,运行时被整体加载到内存中,如同做菜小白将预制菜放进微波炉加热一样。
动态链接好比民间美食家的烹饪。美味佳肴的烹饪手法、需要的配菜、调料等已经事先确定。烹饪过程中需要配菜、调料时再择机加入。食材本身、配菜、调料等是分离的。你是你,我是我。
动态链接的可执行程序所依赖的模块一般只有在真正需要的时候才由动态链接器加载至内存运行。
ldd命令可以用来查看一个程序依赖于哪些动态链接库。我们可以使用这个命令来一探究竟:
(base) ~/Downloads/exmaples$ ldd main
linux-vdso.so.1 (0x00007fffb99f1000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fa470f55000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa471168000)
libc.so.6
程序中调用printf向标准输出输出“Hello,World!”,printf是c语言标准库中的函数。
什么是c语言标准库呢?一个编程语言要建立自己的生态圈就要提高语言的易用性,其中一个很重要的部分,是把常用的功能封装成函数,以库的形式提供给用户使用。不然,用户无论使用什么功能都需要亲自实现一遍,不仅效率低下,而且在重复造轮子。
这时,权威组织(c语言标准委员会,由美国国家标准协会成立)就站出来了。它规定了哪些功能需要封装成库函数,并规定了这些库函数的具体形式。无论什么平台,什么操作系统,都需要支持这些函数形式。
c语言标准库可以看做c用户程序和不同操作系统平台之间的软件抽象层,它将不同的操作系统平台的API抽象成相同的库函数。这样,程序在各个平台和操作系统之间迁移就简单多了。
gcc编译的时候默认为程序动态链接了libc.so.6,即glibc。glibc是c语言标准库的一个超集,除了包含c语言标准库,还有几个辅助程序运行的运行库。这些运行库的功能包括初始化程序运行时环境(如堆的初始化)、调用用户入口函数等。
/lib64/ld-linux-x86-64.so.2
既然程序运行时需要动态加载glibc,那么肯定需要一个动态链接器将动态库加载到内存并与程序进行链接方能运行,/lib64/ld-linux-x86-64.so.2即是动态链接器。聪明的你又有疑问了,动态连接器本身又是被谁加载链接的呢?陷入了鸡生蛋蛋生鸡的死循环。
答案是动态链接器具有自举功能。
一个人不借助任何工具,自己把自己提起来,双脚腾空,这就是自举。
物理世界的自举(不借助外力提起自己)是不可能的(如果可以发生,请迅速广而告之!),但程序的自举是可以通过精心设计的层次结构实现的:
硬件/固件提供初始执行环境
引导程序通过固定入口点(如bios的0x7c00)获得控制权
逐级加载,比如引导程序→内核→用户空间
自引用构建:用简单版本构建复杂版本(如编译器自编译)
这种自举不是“凭空启动”,而是通过分层接力,让系统“拉起自己的鞋带”。自举在程序设计中比较常见,比如Go语言最初(Go 1.4及之前版本)的编译器是用c语言编写的。从Go 1.5版本开始,Go团队成功实现了编译器的自举。这意味着Go 1.5的编译器完全由Go语言本身完成。
实现动态链接器自举有什么挑战呢?
如果我们的程序静态链接了静态库a,调用了库a中的函数func_a,汇编伪代码可以是这样:jump func_a。这条指令可以理解为找到func_a在内存中的地址(这个地址存储了func_a实现的指令序列),跳到这个地址开始执行实现func_a的指令序列。
假设func_a的地址为1000,jump func_a被改写为jump 1000。
一个函数或变量的地址一开始不确定,某个阶段条件成熟后,才能准确确认它们的实际位置,这就叫做符号的重定位。
一个程序的编译大致可以分为编译、汇编、链接等过程。那么问题来了,编译器在编译用户的程序时,还没有把a链接进来,怎么处理调用func_a的代码呢,即jump后面的地址该怎么填?
对于静态链接,编译器的做法是任意填一个地址,链接时将用户程序和库a打包为一个整体。此时,func_a的地址就可以确定了,再由链接器将jump后面的地址改写为func_a实际的地址。
如果我们的程序动态链接了动态库b,调用了库b中的函数func_b。做法是否一样呢?
动态链接是被动态链接器在程序运行时动态加载到到内存中的,可以借鉴静态链接中更新函数地址的做法,在库b被加载到内存后由动态链接器最终确定func_b的实际位置。
如果这个动态链接库只服务一个进程,这种做法没问题。如果库b的函数func_b又调用了动态库c的函数func_c,且库b被多个进程同时使用,会有问题吗?
进程A将库b加载到自己的地址空间,将func_b中的jump func_c代码改写成jump 2000。进程B同样将库b加载到自己的地址空间。在进程B的地址空间发生func_b到func_c的调用时,拿到的代码是jump 2000。2000是进程A地址空间中的地址,该地址在进程B中可能无效,函数调用出错!
不仅如此,进程竟然可以修改代码段中的代码,这是不允许的。
动态链接库的代码段可以同时被多个进程共享,但数据段会被单独拷贝一份到各进程的地址空间。数据段是可以被进程修改的,如果它同时被多个进程修改,且这些进程之间无关联性,数据段中的数据就乱套啦!
一个动态链接库一般包含代码段和数据段。一段代码被编译成动态库后,代码段和数据段的相对位置就确定了。
利用动态链接库数据的独立性、代码段和数据段的相对位置不变性这两个特性,可以重新设计动态链接库中符号地址的重定位算法。
库b的数据段存有一张表格,这个表格的每一项存储的是需要重定位符号的实际地址,这个实际地址在动态链接器将库加载到进程的地址空间就可以确定下来了。
假如库b的数据段和jump func_c这条指令的偏移量为500,地址表格的位置在数据段的偏移量为50,func_c是地址表格中的第5项,那么jump func_c可以转变成jump 500 + 50 + 5 * 8(地址长度在64位机器上是8字节),找到这个地址后,取出该地址的存储内容就可以获取func_c在本进程的实际地址了。
jump 500 + 50 + 5 * 8这条指令就是地址无关指令,因为指令跳转的位置是相对位置。
动态链接库一般被多个进程同时使用,所以往往被编译成地址无关指令。
动态链接器自举代码的设计不可以使用任何全局变量,也不可以调用函数,因为没人帮它填充地址表格中符号的实际地址。
linux-vdso.so.1
用户空间的程序如果要操控比较底层的功能(比如通过文件系统访问磁盘)需要经过操作系统这道坎。
苹果的apple watch一直没有复制门禁卡的功能是因为没有nfc硬件吗?不是,是watch os没有开放这个功能给应用开发者而已!
我们去图书馆还书,先要去柜台找管理员,管理员拿到书后再将书放回到原来的位置。为什么管理员有这个特权而我们没有?
如果把这个特权交给普通读者呢?张三去还书,李四去还书,王五也去还书。张三素质比较高,知道严格按照要求将书放回原来的位置。李四和王五就差点意思,他们还书的位置随心情而定。久而久之,图书馆里书的放置位置就乱套了。
特权,只能掌握在专业可靠的人手中,为了安全,为了稳定。
为了安全和稳定人民群众是识大体的,但效率也确实低下。还书要麻烦图书管理员,图书馆的其它功能难道都需要请出专业的特权人员才能搞定吗?
有些涉及到系统安全稳定的功能不能妥协可以理解,但有些功能对系统的影响有限,采用一刀切的流程就没那个必要了。比如图书馆的某本书现在还剩多少本,最早归还的日期是多少等查询功能就可以直接向读者开放,读者在大厅的自动查询机上直接查询即可,没必要再去劳烦图书管理员饶一道圈圈。
传统的系统调用(申请运行内核空间的代码)需要从用户态切换到内核态。当发生系统调用时,程序需要进行上下文切换,保存用户态的寄存器、程序计数器等信息,加载内核态的寄存器、堆栈指针等。从内核态返回时,同样也会进行寄存器切换等操作。
linux-vdso.so.1是内核镜像的一部分,内核把一部分功能直接暴露给普通用户,普通用户可以直接在用户空间执行指令,不用陷入内核空间获取特权后才能运行这段代码,效率提升明显。
linux-vdso.so.1在文件系统中没有与之对应的具体文件,它是内核镜像的一部分,是内核虚拟出来的一个文件。为了看看这个文件究竟有些什么内容,需要采取一些手段。
首先,我们对main.c的内容做一些改造,让它在后台小睡300秒:
#include
int main()
{
sleep(300);
return 0;
}
编译,后台运行:
(base) ~/Downloads/exmaples$ gcc main.c -o main
(base) ~/Downloads/exmaples$ ./main &
[1] 555649
main进程的进程号为555649,看看它的虚拟地址分布:
(base) ~/Downloads/exmaples$ cat /proc/555649/maps
558e701ae000-558e701af000 r--p 00000000 103:02 3324120 /home/solora/Downloads/exmaples/main
558e701af000-558e701b0000 r-xp 00001000 103:02 3324120 /home/solora/Downloads/exmaples/main
558e701b0000-558e701b1000 r--p 00002000 103:02 3324120 /home/solora/Downloads/exmaples/main
558e701b1000-558e701b2000 r--p 00002000 103:02 3324120 /home/solora/Downloads/exmaples/main
558e701b2000-558e701b3000 rw-p 00003000 103:02 3324120 /home/solora/Downloads/exmaples/main
7f740282a000-7f740282c000 rw-p 00000000 00:00 0
7f740282c000-7f740284e000 r--p 00000000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f740284e000-7f74029c6000 r-xp 00022000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f74029c6000-7f7402a14000 r--p 0019a000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7402a14000-7f7402a18000 r--p 001e7000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7402a18000-7f7402a1a000 rw-p 001eb000 103:02 14944449 /usr/lib/x86_64-linux-gnu/libc-2.31.so
7f7402a1a000-7f7402a1e000 rw-p 00000000 00:00 0
7f7402a38000-7f7402a3a000 rw-p 00000000 00:00 0
7f7402a3a000-7f7402a3b000 r--p 00000000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a3b000-7f7402a5e000 r-xp 00001000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a5e000-7f7402a66000 r--p 00024000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a67000-7f7402a68000 r--p 0002c000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a68000-7f7402a69000 rw-p 0002d000 103:02 14943910 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7f7402a69000-7f7402a6a000 rw-p 00000000 00:00 0
7ffccfc88000-7ffccfcaa000 rw-p 00000000 00:00 0 [stack]
7ffccfcd5000-7ffccfcd9000 r--p 00000000 00:00 0 [vvar]
7ffccfcd9000-7ffccfcdb000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
vdso的地址范围为7ffccfcd9000-7ffccfcdb000,使用gdb命令将该地址范围的内容拷贝到一个名为vdso.dso的文件:
(base) ~/Downloads/exmaples$ sudo gdb -p 555649 -batch -ex "dump memory vdso.dso 0x7ffccfcd9000 0x7ffccfcdb000" -ex "detach" -ex "quit"
0x00007f74029091b4 in __GI___clock_nanosleep (clock_id=, clock_id@entry=0, flags=flags@entry=0, req=req@entry=0x7ffccfca7ea0, rem=rem@entry=0x7ffccfca7ea0) at ../sysdeps/unix/sysv/linux/clock_nanosleep.c:78
78 ../sysdeps/unix/sysv/linux/clock_nanosleep.c: No such file or directory.
[Inferior 1 (process 555649) detached]
上面找不到的文件用于调试,不影响我们要讨论的内容,可以忽略。走到这里,就可以使用objdump查看linux-vdso.so.1的内容了:
(base) ~/Downloads/exmaples$ objdump -T vdso.dso
vdso.dso: file format elf64-x86-64
DYNAMIC SYMBOL TABLE:
0000000000000a10 w DF .text 0000000000000413 LINUX_2.6 clock_gettime
0000000000000690 g DF .text 0000000000000348 LINUX_2.6 __vdso_gettimeofday
0000000000000e30 w DF .text 0000000000000060 LINUX_2.6 clock_getres
0000000000000e30 g DF .text 0000000000000060 LINUX_2.6 __vdso_clock_getres
0000000000000690 w DF .text 0000000000000348 LINUX_2.6 gettimeofday
00000000000009e0 g DF .text 0000000000000029 LINUX_2.6 __vdso_time
0000000000000ec0 g DF .text 000000000000009c LINUX_2.6 __vdso_sgx_enter_enclave
00000000000009e0 w DF .text 0000000000000029 LINUX_2.6 time
0000000000000a10 g DF .text 0000000000000413 LINUX_2.6 __vdso_clock_gettime
0000000000000000 g DO *ABS* 0000000000000000 LINUX_2.6 LINUX_2.6
0000000000000e90 g DF .text 0000000000000025 LINUX_2.6 __vdso_getcpu
0000000000000e90 w DF .text 0000000000000025 LINUX_2.6 getcpu
文件包含了五个系统调用及对应的vdso实现,比如__vdso_gettimeofday对应gettimeofday,两个文件的地址完全一样。也就是说,内核开放了这五个系统调用的实现,这些实现可以直接在用户空间执行。
vdso 在技术上是"存在"于大多数进程中,但它不是传统意义上的库依赖,而是内核提供的优化机制。对于普通开发者来说,完全不需要关心它的存在。
问题二 不依赖printf打印Hello,World!行不行?
既然printf最终会调用write系统调用,直接使用write不行吗?
#include
int main()
{
const char msg[] = "Hello, World!\n";
size_t len = sizeof(msg) - 1;
write(0, msg, len);
return 0;
}
0表示标准输出,编译运行:
(base) ~/Downloads/exmaples$ gcc main.c -o main
(base) ~/Downloads/exmaples$ ./main
Hello, World!
可见,直接调用write也是可行的,既然write能够实现同样的功能,c语言标准库为什么还需要引入printf这个函数呢?
引入printf函数能够满足复杂的数据格式化输出需求,提高代码的可读性和可维护性。在实际开发中,可以根据具体的应用场景和需求来选择使用printf或write。如果只是简单地输出数据,write系统调用可能更高效;但如果需要进行复杂的格式化输出,printf函数则更为合适。
能否既不使用printf也不使用write来实现相同的功能?
系统调用本质是通过cpu的陷阱机制(trap)主动触发从用户态到内核态的受控切换。可以直接在c程序中使用内联汇编指令调用系统调用:
void print()
{
const char msg[] = "Hello, World!\n";
size_t len = sizeof(msg) - 1;
asm volatile (
"movq $1, %%rax\n"
"movq $1, %%rdi\n"
"movq %0, %%rsi\n"
"movq %1, %%rdx\n"
"syscall"
:
: "r"(msg), "r"(len)
: "rax", "rdi", "rsi", "rdx", "memory"
);
}
每个系统调用都有对应的编号,write的系统调用号为1,再通过寄存器传递write所需的参数,最后调用syscall触发trap就可以进入到内核态处理write系统调用的流程了。
用同样的方法可以实现一个类似exit()的函数:
void my_exit()
{
asm volatile (
"movq $60, %%rax\n"
"movq $42, %%rdi\n"
"syscall"
:
:
: "rax", "rdi", "rcx", "r11"
);
}
然后在main函数中调用这两个函数:
void main() {
print();
my_exit();
}
编译运行:
(base) ~/Downloads/exmaples$ gcc main.c -o main
(base) ~/Downloads/exmaples$ ./main
Hello, World!
(base) ~/Downloads/exmaples$ echo $?
42
达到了同样的目的。
问题三 程序一定要从main()函数开始执行吗?
将main函数的名字改一下,换成别的名字,比如nomain:
#include
int nomain()
{
printf("Hello, World!\n");
return 0;
}
编译:
(base) ~/Downloads/exmaples$ gcc main.c -o main
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
(.text+0x24): undefined reference to `main'
collect2: error: ld returned 1 exit status
编译出错啦,错误提示包含了两个信息:
- 当运行这个程序的时候,首先调用的是_start,再由_start调用main
- _start是由Scrt1.o提供的函数
使用gcc -v输出编译时的更多信息:
(base) ~/Downloads/exmaples$ gcc -v main.c -o nomain
/usr/lib/gcc/x86_64-linux-gnu/9/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/9/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/9/lto-wrapper -plugin-opt=-fresolution=/tmp/ccN6Ktn2.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro -o nomain /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/9/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/9 -L/usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/9/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L. -L/usr/lib/gcc/x86_64-linux-gnu/9/../../.. /tmp/cctSwJdZ.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/9/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/crtn.o
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
gcc默认的编译行为链接了Scrt1.o这个目标文件(/usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o)
使用nm查看Scrt1.o包含的符号:
(base) ~/Downloads/exmaples$ nm /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o
0000000000000000 D __data_start
0000000000000000 W data_start
U _GLOBAL_OFFSET_TABLE_
0000000000000000 R _IO_stdin_used
U __libc_csu_fini
U __libc_csu_init
U __libc_start_main
U main
0000000000000000 T _start
也确实包含了_start函数。该入口点由内核在加载程序后跳转执行。_start负责初始化环境,设置栈,处理全局构造(对于 c++ 程序),然后调用main函数。
从git://sourceware.org/git/glibc.git下载glibc源码,Intel/AMD 64 位平台上_start的汇编实现在glibc/sysdeps/x86_64/start.S可以找到:
ENTRY (_start)
|
|
|
call *__libc_start_main@GOTPCREL(%rip)
|
|
|
END (_start)
最终调用__libc_start_main,而传给该函数的参数为(main, argc, argv, init, fini, rtld_fini, stack_end)。至此,熟悉的main函数终于现身了。所有独立式程序(可执行文件)必须包含全局命名空间的main函数作为入口点,因此只能有一个main符号,不然会给_start造成困扰。
一个可执行程序的入口函数可以由链接脚本来控制,链接的默认脚本在哪里呢?ld是gcc编译时使用的链接器,可以利用--verbose参数打印链接的更多信息:
(base) ~/Downloads/glibc/sysdeps/x86_64$ ld --verbose
GNU ld (GNU Binutils for Ubuntu) 2.34
Supported emulations:
elf_x86_64
elf32_x86_64
elf_i386
elf_iamcu
elf_l1om
elf_k1om
i386pep
i386pe
using internal linker script:
==================================================
/* Script for -z combreloc -z separate-code */
/* Copyright (C) 2014-2020 Free Software Foundation, Inc.
Copying and distribution of this script, with or without modification,
are permitted in any medium without royalty provided the copyright
notice and this notice are preserved. */
OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64",
"elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start)
------------------
输出的内容包含默认的链接脚本(using internal linker script:),其中ENTRY(_start)指定了程序的入口函数。
由此可见,如果采用gcc的默认编译行为,程序的入口必然是_start,_start必然会调用main函数,如果不提供main函数,编译必然报错。
既然链接脚本可以控制程序的入口函数,我们也可以依葫芦画瓢,自己写一个链接脚本控制程序的入口点:
ENTRY(nomain)
SECTIONS
{
. = 0x400000 + SIZEOF_HEADERS;
}
链接脚本的内容参考ld默认链接脚本的写法:
(base) ~/Downloads/exmaples$ ld --verbose | grep -A5 "SIZEOF_HEADERS"
PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x400000)); . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
.interp : { *(.interp) }
.note.gnu.build-id : { *(.note.gnu.build-id) }
.hash : { *(.hash) }
.gnu.hash : { *(.gnu.hash) }
.dynsym : { *(.dynsym) }
0x400000是linux x86_64 架构的 ABI(应用程序二进制接口)规范 中定义的标准入口地址。可执行文件默认加载到虚拟地址空间的0x400000处。. = 0x400000 + SIZEOF_HEADERS表示将当前虚拟地址设置成0x400000 + SIZEOF_HEADERS,文件头与代码段物理连续,可单次内存映射完成,提高了装载时页映射的效率。
编译运行:
(base) ~/Downloads/exmaples$ gcc -c -fno-builtin -fno-stack-protector nomain.c
(base) ~/Downloads/exmaples$ ld -T nomain.lds -o nomain nomain.o
(base) ~/Downloads/exmaples$ ./nomain
Hello, World!
现在,即使代码中没有main函数,程序也能正常运行输出结果。
问题四 怎样减小可执行文件的大小?
先看下目前可执行文件nomain的大小(1520字节):
(base) ~/Downloads/exmaples$ ls -l nomain
-rwxrwxr-x 1 solora solora 1520 6月 16 16:52 nomain
nomain中有哪些段呢?
(base) ~/Downloads/exmaples$ objdump -h nomain
nomain: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000008a 0000000000400158 0000000000400158 00000158 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .eh_frame 00000078 00000000004001e8 00000000004001e8 000001e8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .note.gnu.property 00000020 0000000000400260 0000000000400260 00000260 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .comment 0000002b 0000000000000000 0000000000000000 00000280 2**0
CONTENTS, READONLY
通过链接脚本把除了代码段(.text)以外的三个段全部去掉看看程序运行是否正常:
nomain.lds:
ENTRY(nomain)
SECTIONS
{
. = 0x400000 + SIZEOF_HEADERS;
/DISCARD/ : { *(.comment) *(.eh_frame) *(.note.gnu.property) }
}
(base) ~/Downloads/exmaples$ ld -T nomain.lds -o nomain nomain.o
(base) ~/Downloads/exmaples$ ./nomain
Hello, World!
(base) ~/Downloads/exmaples$ objdump -h nomain
nomain: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000008a 00000000004000e8 00000000004000e8 000000e8 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
现在nomain看起来只剩下.text段且能正常运行,那么现在的大小是多少呢?
(base) ~/Downloads/exmaples$ ls -l nomain
-rwxrwxr-x 1 solora solora 904 6月 16 17:09 nomain
从1520字节降到了904字节。
问题五 nomain还能进一步减小吗?
nomain真的只剩下.text段了吗?用另外一个工具readelf确认下:
(base) ~/Downloads/exmaples$ readelf -S nomain
There are 5 section headers, starting at offset 0x248:
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 00000000004000e8 000000e8
000000000000008a 0000000000000000 AX 0 0 1
[ 2] .symtab SYMTAB 0000000000000000 00000178
0000000000000090 0000000000000018 3 3 8
[ 3] .strtab STRTAB 0000000000000000 00000208
000000000000001d 0000000000000000 0 0 1
[ 4] .shstrtab STRTAB 0000000000000000 00000225
0000000000000021 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
竟然还有三个顽固分子:.shstrtab、.symtab以及.strtab。它们分别是段名字符串表、符号表和字符串表。
什么是符号表呢?一个变量或函数总得有类型、名称、作用域等信息吧。符号名称给我们调试程序带来很大的便利,应该没人想对着一串数字来调试程序吧?这些名称又由字符串表负责存放。
在默认情况下,ld链接器在产生可执行文件时会产生这三个段。对于可执行文件来说,符号表和字符串表是可选的,但是段名字符串表用以保存段名,所以它是必不可少的。
可以使用strip命令去除nomain中的符号表:
(base) ~/Downloads/exmaples$ strip nomain
(base) ~/Downloads/exmaples$ ./nomain
Hello, World!
(base) ~/Downloads/exmaples$ ls -l nomain
-rwxrwxr-x 1 solora solora 584 6月 16 17:25 nomain
nomain仍然能够正常运行输出结果,但此时nomain的大小从904字节降到了584字节。
现在nomain还剩下哪些段呢?
(base) ~/Downloads/exmaples$ readelf -S nomain
There are 3 section headers, starting at offset 0x188:
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 00000000004000e8 000000e8
000000000000008a 0000000000000000 AX 0 0 1
[ 2] .shstrtab STRTAB 0000000000000000 00000172
0000000000000011 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
没错,只剩下代码段(.text)和段名字符串表(.shstrtab)了。