Linux 动静态库与加载原理
本节我们总体上分为两大块,一是明确动静态库的制作,二是明确动态库和静态库的加载原理,这与先前学习过的虚拟地址空间也有关联,最终我们了解完动静态库的使用和原理后,就终于能把我们脑中这块“程序”从磁盘到CPU上运行起来了。
一.库的制作原理
首先我们来聊聊库存在的意义。之前的章节我们已经聊过这个话题,可以得出一个简单的结论:库的存在是方便各程序员各司其职,减少了广大程序员在常见接口上的调试与编写(如printf等),便于程序员在库的基础上进行二次开发。
本质上来说库是⼀种可执⾏代码的⼆进制形式,可以被操作系统载⼊内存执⾏。库有两种:
• 静态库 .a[Linux]、.lib[windows]
• 动态库 .so[Linux]、.dll[windows]
我们也可以直接查看我们自己主机上的动静态库。
查看动态库:
wujiahao@VM-12-14-ubuntu:~$ ls -l /lib/x86_64-linux-gnu
lrwxrwxrwx 1 root root 18 Aug 29 2021 libacl.so.1 -> libacl.so.1.1.2301
-rw-r--r-- 1 root root 34888 Aug 29 2021 libacl.so.1.1.2301
lrwxrwxrwx 1 root root 15 Mar 24 2022 libaio.so.1 -> libaio.so.1.0.1
-rw-r--r-- 1 root root 14456 Mar 24 2022 libaio.so.1.0.1
lrwxrwxrwx 1 root root 33 May 26 15:25 libanl.so -> /lib/x86_64-linux-gnu/libanl.so.1
....
查看静态库:
ls -l /lib/x86_64-linux-gnu
-rw-r--r-- 1 root root 8 May 26 15:25 libanl.a
1.静态库的制作
1.接着我们来对之前实现的glibc封装接口进行静态库的制作。
在当前目录下创建my_string的头文件和实现文件。
mystring.h+ 1 #pragma once2 3 int my_strlen(const char* s); mystring.c+ 1 #include "mystring.h"2 #include <stdio.h> 3 int my_strlen(const char *s)4 {5 const char *start = s;6 while(*s)7 {8 s++;9 }10 return s - start;11 }
动静态库中,需要包含main函数吗?不需要,因为我们要将动静态库和可执行程序链接,如果有main的话会发生程序入口冲突。
然后测试mystring的接口。
usercode.c+ buffers 1 #include "mystdio.h"2 #include <string.h>3 #include <unistd.h>4 5 int main()6 {7 MyFile *filep = MyFopen("./log.txt", "a");8 if(!filep)9 {10 printf("fopen error!\n");11 return 1;12 }13 14 int cnt = 10;15 while(cnt--)16 {17 char *msg = (char*)"hello myfile!!!";18 MyFwrite(filep, msg, strlen(msg));19 MyFFlush(filep);20 printf("buffer: %s\n", filep->outbuffer);21 sleep(1);22 }23 MyFclose(filep); // FILE *fp24 const char* str="hello world!\n";25 printf("strlen:%d\n",mystrlen(str)); 26 return 0;27 }
我们此时对源文件通过-c选项预处理成同名.o文件
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -c mystdio.c
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -c mystring.c
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls
Makefile mystdio.c mystdio.h mystdio.o mystring.c mystring.h mystring.o usercode.c
这时候,.o是人类看不懂的乱码。但是给我们的头文件我们能明白接口的使用方法,参数,返回值。这样用户就能只包头文件进行使用接口,而无法看到.o的源文件内容。
我们在编译文件时,各个文件编译自己的,互相没有关系,只有到链接的步骤才有关系。把所有的.o文件链接,就形成了可执行程序。那么我们发现,只要把头文件和.o文件交给其他人,就能实现库的作用。
当.o文件到达一定量,就很容易丢失。于是可以把.o整体打包,形成一个“静态库”。linux提供了一个工具,打包之后再解包依然会生成很多文件很麻烦,怎么办?ar归档工具可以把所有.o文件打包成一个文件,gcc无需解压可以直接识别。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ar -rc libmyc.a *.o
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls
libmyc.a mystdio.c mystdio.o mystring.h usercode.c
Makefile mystdio.h mystring.c mystring.o
.a静态库,本质是一种归档文件,不需要使用者解包,而是用编译器直接连接。
静态库的命名:lib开头,.a结尾,中间的才是真正的库名称。例如:这个库的名称叫anl。
-rw-r--r-- 1 root root 8 May 26 15:25 libanl.a
这时候具体如何使用静态库呢?使用库真正的名字!
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -o usercode usercode.o -L . -lmyc
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls
libmyc.a mystdio.c mystdio.o mystring.h usercode usercode.o
Makefile mystdio.h mystring.c mystring.o usercode.c
发现正确的运行起来了。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ./usercode
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
strlen:13
2.我们可以使用一种更优雅的方式使用静态库:创建一个lib目录,include存放头文件.h,mylib存放库.a。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ cp *.h lib/include
wujiahao@VM-12-14-ubuntu:~/glibc_test$ cp *.a lib/mylib
wujiahao@VM-12-14-ubuntu:~/glibc_test$ tree lib
lib
├── include
│ ├── mystdio.h
│ └── mystring.h
└── mylib└── libmyc.a
然后可以直接把lib文件夹tar打包,供给更多人使用。
注意:
使用时我们需要注意,gcc在找头文件时默认不会去我们可执行程序的目录下去找,尤其是像现在我们解压了别人的库来用的情况,因此我们在编译链接时需要使用新的选项。那么同样的,为了防止库找不到,我们也需要把上面的选项复用。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -o usercode usercode.c -I ./lib/include/ -L ./lib/mylib/ -lmyc
现在我们可以得出一个结论:库也是需要被安装到系统中的。即使上面地操作可以让我们正常使用,但是太麻烦。我们需要gcc默认的头文件搜索路径和库搜索路径。
库的安装就是把它拷贝到这些默认路径下,将头文件也拷贝到对应路径下,就可以实现以下的编译方式。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls /usr/include
aio.h expat_external.h lastlog.h netipx scsi threads.h
aliases.h expat.h libdmmp netiucv search.h time.h
...wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls /usr/lib64
ld-linux-x86-64.so.2
为了后续操作方便,我们写好以下的Makfile文件。
Makefile+ buffers 1 libmyc.so:mystdio.o mystring.o2 gcc -shared -o $@ $^3 mystdio.o:mystdio.c4 gcc -fPIC -c $<5 mystring.o:mystring.c6 gcc -fPIC -c $<7 8 .PHONY:output9 output:10 mkdir -p lib/include11 mkdir -p lib/mylib12 cp -f *.h lib/include13 cp -f *.so lib/mylib14 tar czf lib.tgz lib15 16 .PHONY:clean17 clean:18 rm -rf *.o libmyc.so lib lib.tgz
2.动态库的制作
1.这里的步骤实际上跟静态库的制作差不多,有一些细节需要我们注意。
将源文件编译成同名.o文件。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -fPIC -c *.c
然后对所有的.o文件进行打包
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -shared -o libmyc.so *.o
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls
libmyc.so mystdio.c mystdio.o mystring.h usercode usercode.o
Makefile mystdio.h mystring.c mystring.o usercode.c
动静态库最直接的差别,我们可以通过查看这两个打包的文件信息了解。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ file libmyc.a
libmyc.a: current ar archive
wujiahao@VM-12-14-ubuntu:~/glibc_test$ file libmyc.so
libmyc.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=c1b65dba60731a4eef226233ef2cd53750a0728a, not stripped
2.如果我们要使用动态库,应该如何编译?
wujiahao@VM-12-14-ubuntu:~/glibc_test$ make
gcc -fPIC -c mystdio.c
gcc -fPIC -c mystring.c
gcc -shared -o libmyc.so mystdio.o mystring.o
wujiahao@VM-12-14-ubuntu:~/glibc_test$ make output
mkdir -p lib/include
mkdir -p lib/mylib
cp -f *.h lib/include
cp -f *.so lib/mylib
tar czf lib.tgz lib
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -o usercode usercode.c -I lib/include -L lib/mylib/ -lmyc
usercode.c: In function ‘main’:
usercode.c:25:26: warning: implicit declaration of function ‘my_strlen’; did you mean ‘strlen’? [-Wimplicit-function-declaration]25 | printf("strlen:%d\n",my_strlen(str));| ^~~~~~~~~| strlen
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls
lib libmyc.so Makefile mystdio.h mystring.c mystring.o usercode.c
libmyc.a lib.tgz mystdio.c mystdio.o mystring.h usercode
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ./usercode
./usercode: error while loading shared libraries: libmyc.so: cannot open shared object file: No such file or directory
结果我们发现还是无法使用,这是为啥?我们可以查看usercode的依赖信息,发现我们自己的库libmyc.so有依赖,但是却找不到,这又是为啥?
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ldd usercodelinux-vdso.so.1 (0x00007ffd7351b000)libmyc.so => not foundlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff36942a000)/lib64/ld-linux-x86-64.so.2 (0x00007ff369660000)
问题是,我们只告诉了编译器gcc库在哪,但准备运行时,库需要让系统知道(这也是动态库不同于静态库的地方)。我们需要让系统知道动态库在哪。
关键在于,为什么静态库没有这个问题?
因为静态库在链接时,是直接把库拷贝给可执行程序,运行时就不再需要他了,所以不存在运行时找不到的原因。而动态库是形成可执行,需要动态地查找动态库在哪。
接着我们就来讨论讨论,怎样“让系统知道动态库”。
3.最暴力的方法是把动态库拷贝到系统的lib64下(但这样会污染我们的系统库最好不要做)。
还有一个解决办法,就是:这是一个环境变量,用于指定动态链接器运行时搜索动态库的路径。
echo $LD_LIBRARY_PATH
这个环境变量通常是空的,我们可以将这个环境变量定位到我们自己的库的位置。
export LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:/home/wujiahao/glibc_test/lib/mylib
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ./usercode
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
buffer: hello myfile!!!
strlen:13
第三个方法,我们可以创建一个配置文件:
touch /etc/ld.so.conf.d/my.conf
然后往这个文件中写入我们自己动态库的路径,执行ldconfig命令即可。
4.结论:动态库和静态库同时在一个目录下,默认使用动态库。如果要指定使用静态库要加static,并且静态库必须存在。
当只存在静态库时,只能使用静态库。
Linux系统下,默认情况安装的大部分库都是动态库。
库:应用程序=1:n
二.动静态库原理
编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们⼀般都是⼀键构建⾮常⽅便,但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多⼈就束⼿⽆策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这⼀系列操作。
接下来我们深⼊探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使⽤原理。
动静态库地使用原理:可以看到,在编译之后会⽣成两个扩展名为 .o 的⽂件,它们被称作⽬标⽂件。要注意的是如果我们修改了⼀个原⽂件,那么只需要单独编译它这⼀个,⽽不需要浪费时间重新编译整个⼯程。⽬标⽂件是⼀个⼆进制的⽂件,⽂件的格式是 ELF ,是对⼆进制代码的⼀种封装。
1.ELF文件
通常情况下,以下四种文件都是ELF文件。我们需要通过ELF文件了解编译链接的细节。
• 可重定位⽂件(Relocatable File) :即 xxx.o ⽂件。包含适合于与其他⽬标⽂件链接来创建可执⾏⽂件或者共享⽬标⽂件的代码和数据。
• 可执⾏⽂件(Executable File) :即可执⾏程序。
• 共享⽬标⽂件(Shared Object File) :即 xxx.so⽂件。
• 内核转储(core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。
ELF文件的结构如下:如何?.o文件事实上并不像我们想象的是一坨乱码,它是有着严格的分区结构的。
ELF各个部分的概览如下:
• ELF头(ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分。
• 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥
记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。
• 节头表(Section header table) :包含对节(sections)的描述。
• 节(Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。
除了一些描述信息的信息头,section主要存储着.o文件的代码和数据。section也有不同的类别,它们是分类进行存储的。我们不妨看看我们自己的可执行程序的section信息。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ size usercodetext data bss dec hex filename2395 680 8 3083 c0b usercode
多个ELF格式的.o文件链接形成可执行,会把相同权限,相同功能,相同属性的section合并为一个新的更大的segment。
接下来我们通过解决四个问题,深入了解ELF和编译链接的细节。
1.静态库是如何形成可执行程序的
2.ELF程序如何加载到内存的(路径+文件名),ELF是如何转换成进程的[逻辑地址,物理地址,虚拟地址],虚拟地址空间
3.动态库是如何与我们的可执行程序关联的
4.动态库是如何加载的
2.静态库是如何形成可执行程序的
• ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
• 合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等.
• 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
• 很显然,这个合并⼯作也已经在形成 ELF 的时候,合并⽅式已经确定了,具体合并原则被记录在了 ELF 的 程序头表(Program header table) 中
我们可以通过指令readelf -S name来查找可执行程序的section(读取section header table内容)。这类似于一个数组,sectionheader中以数组方式存储section,并给出了偏移量和s起始地址便于定址。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ readelf -S usercode
There are 31 section headers, starting at offset 0x37b8:Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .interp PROGBITS 0000000000000318 00000318000000000000001c 0000000000000000 A 0 0 1[ 2] .note.gnu.pr[...] NOTE 0000000000000338 000003380000000000000030 0000000000000000 A 0 0 8[ 3] .note.gnu.bu[...] NOTE 0000000000000368 000003680000000000000024 0000000000000000 A 0 0 4[ 4] .note.ABI-tag NOTE 000000000000038c 0000038c0000000000000020 0000000000000000 A 0 0 4[ 5] .gnu.hash GNU_HASH 00000000000003b0 000003b00000000000000024 0000000000000000 A 6 0 8[ 6] .dynsym DYNSYM 00000000000003d8 000003d80000000000000168 0000000000000018 A 7 1 8[ 7] .dynstr STRTAB 0000000000000540 0000054000000000000000d1 0000000000000000 A 0 0 1[ 8] .gnu.version VERSYM 0000000000000612 00000612000000000000001e 0000000000000002 A 6 0 2[ 9] .gnu.version_r VERNEED 0000000000000630 000006300000000000000030 0000000000000000 A 7 1 8[10] .rela.dyn RELA 0000000000000660 0000066000000000000000c0 0000000000000018 A 6 0 8[11] .rela.plt RELA 0000000000000720 0000072000000000000000d8 0000000000000018 AI 6 24 8[12] .init PROGBITS 0000000000001000 00001000000000000000001b 0000000000000000 AX 0 0 4[13] .plt PROGBITS 0000000000001020 0000102000000000000000a0 0000000000000010 AX 0 0 16[14] .plt.got PROGBITS 00000000000010c0 000010c00000000000000010 0000000000000010 AX 0 0 16[15] .plt.sec PROGBITS 00000000000010d0 000010d00000000000000090 0000000000000010 AX 0 0 16[16] .text PROGBITS 0000000000001160 0000116000000000000001ee 0000000000000000 AX 0 0 16[17] .fini PROGBITS 0000000000001350 00001350000000000000000d 0000000000000000 AX 0 0 4[18] .rodata PROGBITS 0000000000002000 000020000000000000000052 0000000000000000 A 0 0 4[19] .eh_frame_hdr PROGBITS 0000000000002054 000020540000000000000034 0000000000000000 A 0 0 4[20] .eh_frame PROGBITS 0000000000002088 0000208800000000000000ac 0000000000000000 A 0 0 8[21] .init_array INIT_ARRAY 0000000000003d68 00002d680000000000000008 0000000000000008 WA 0 0 8[22] .fini_array FINI_ARRAY 0000000000003d70 00002d700000000000000008 0000000000000008 WA 0 0 8[23] .dynamic DYNAMIC 0000000000003d78 00002d780000000000000200 0000000000000010 WA 7 0 8[24] .got PROGBITS 0000000000003f78 00002f780000000000000088 0000000000000008 WA 0 0 8[25] .data PROGBITS 0000000000004000 000030000000000000000010 0000000000000000 WA 0 0 8[26] .bss NOBITS 0000000000004010 000030100000000000000008 0000000000000000 WA 0 0 1[27] .comment PROGBITS 0000000000000000 000030100000000000000026 0000000000000001 MS 0 0 1[28] .symtab SYMTAB 0000000000000000 000030380000000000000420 0000000000000018 29 18 8[29] .strtab STRTAB 0000000000000000 000034580000000000000243 0000000000000000 0 0 1[30] .shstrtab STRTAB 0000000000000000 0000369b000000000000011a 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),D (mbind), l (large), p (processor specific)
其中有些字段值得我们研究。比如这里的第16下标的text,就是源程序的代码部分。data就是全局变量。bss就相当于在磁盘中描述一些未初始化的全局变量,在程序运行时再动态加载到内存,并默认为0.
我们可以通过readelf -l 查看多个section合并之后的segment.我们还可以看到具体是哪些section进行了合并。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ readelf -l usercodeElf file type is DYN (Position-Independent Executable file)
Entry point 0x1160
There are 13 program headers, starting at offset 64Program Headers:Type Offset VirtAddr PhysAddrFileSiz MemSiz Flags AlignPHDR 0x0000000000000040 0x0000000000000040 0x00000000000000400x00000000000002d8 0x00000000000002d8 R 0x8INTERP 0x0000000000000318 0x0000000000000318 0x00000000000003180x000000000000001c 0x000000000000001c R 0x1[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]LOAD 0x0000000000000000 0x0000000000000000 0x00000000000000000x00000000000007f8 0x00000000000007f8 R 0x1000LOAD 0x0000000000001000 0x0000000000001000 0x00000000000010000x000000000000035d 0x000000000000035d R E 0x1000LOAD 0x0000000000002000 0x0000000000002000 0x00000000000020000x0000000000000134 0x0000000000000134 R 0x1000LOAD 0x0000000000002d68 0x0000000000003d68 0x0000000000003d680x00000000000002a8 0x00000000000002b0 RW 0x1000DYNAMIC 0x0000000000002d78 0x0000000000003d78 0x0000000000003d780x0000000000000200 0x0000000000000200 RW 0x8NOTE 0x0000000000000338 0x0000000000000338 0x00000000000003380x0000000000000030 0x0000000000000030 R 0x8NOTE 0x0000000000000368 0x0000000000000368 0x00000000000003680x0000000000000044 0x0000000000000044 R 0x4GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x00000000000003380x0000000000000030 0x0000000000000030 R 0x8GNU_EH_FRAME 0x0000000000002054 0x0000000000002054 0x00000000000020540x0000000000000034 0x0000000000000034 R 0x4GNU_STACK 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000000 0x0000000000000000 RW 0x10GNU_RELRO 0x0000000000002d68 0x0000000000003d68 0x0000000000003d680x0000000000000298 0x0000000000000298 R 0x1Section to Segment mapping:Segment Sections...00 01 .interp 02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 03 .init .plt .plt.got .plt.sec .text .fini 04 .rodata .eh_frame_hdr .eh_frame 05 .init_array .fini_array .dynamic .got .data .bss 06 .dynamic 07 .note.gnu.property 08 .note.gnu.build-id .note.ABI-tag 09 .note.gnu.property 10 .eh_frame_hdr 11 12 .init_array .fini_array .dynamic .got
原本31个section,在合并之后只有12个segment了。将bss和data合并为一个数据节,将text和rodata(只读)合并,这样更能证明在合并数据节时是按照相同属性相同权限相同功能合并了
操作系统在加载ELF时就会读取program header找到各个数据段的地址起始位置和偏移量,将数据节连续依次加载到内存。
所以我们的ELF,其实在链接时就已经把怎么加载的问题定好了,当我们的可执行程序在加载之前,将来的可执行程序在系统中如何加载,哪些数据节需要合并也已经确定了。
之前我们了解到文件系统在进行IO时的基本单位为4kb,实际上操作系统把内存也看做一个大数组,每一个基本单位也是4kb。也就是说,磁盘的4kb内容是完全可以直接载入到一个内存块中的,磁盘内存以4kb为基本单位进行交互。我们进行的new/malloc,写时拷贝,定义全局变量,实际上操作系统是直接把这些数据所在的4kb直接分配出来的,这考虑到一些时间和空间的就近原则,能在一定程度上提高效率。
可执行程序也是文件,在文件系统角度,ELF文件的所有数据节和Header信息都是按4kb为单位存储的,一个4kb可能存不完某个数据节,我们来想想为什么要把section合并为segment:
• Section合并的主要原因是为了减少⻚⾯碎⽚,提⾼内存使⽤效率。如果不进⾏合并,
假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分
为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个
⻚⾯。
• 此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制。
对于program header和section header表头,我们可以从两个视角理解它的作用:
1.链接视图:
⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的
section header
是链接视图,能够理解 ELF ⽂件中包含的各个部分的信息。
◦ 为了空间布局上的效率,将来在链接⽬标⽂件时,链接器会把很多节(section)合并,规整成可执⾏的段(segment)、可读写的段、只读段等。合并了后,空间利⽤率就⾼了,否
则,很⼩的很⼩的⼀段,未来物理内存⻚浪费太⼤(物理内存⻚分配⼀般都是整数倍⼀块给你,⽐如4k),所以,链接器趁着链接就把⼩块们都合并了。
2.执行角度——program header作用。一个进程启动时,如何知道代码区是只读的?怎么知道有些区域是可读可写的?页表也是kv结构(映射以及权限),它如何知道这些权限的?就是由操作系统读取Program Header表,发现不同的字段有不同的权限,构建页表时往里面一写就可以了——就这么简单。
3.理解链接与加载
加载器和链接器完成的不同工作:将两个表头展现给不同的对象。
symbol符号表:就是源码中函数名,变量名和代码的对应关系,例如:符号表相当于把函数名,变量名和代码以及动静态库等等都记录在类似char类型的数组中,而只需要每个字段的起始地址和偏移量即可找到对应的字段。之后每隔字段在我们源代码中出现的可能只有数字而没有这些字符了。
ELF header:整个elf格式的相关管理信息,比如每个区域的大小,位置,数量等等。我们可以用readelf -h查看这个字段。magic字段首要的作用就是:判断当前的这个文件是ELF格式的。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ readelf -h /usr/bin/ls
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64Data: 2's complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: DYN (Position-Independent Executable file)Machine: Advanced Micro Devices X86-64Version: 0x1Entry point address: 0x6ab0Start of program headers: 64 (bytes into file)Start of section headers: 136224 (bytes into file)Flags: 0x0Size of this header: 64 (bytes)Size of program headers: 56 (bytes)Number of program headers: 13Size of section headers: 64 (bytes)Number of section headers: 31Section header string table index: 30
我们上面介绍每个字段时,都有意无意的强调了ELF区域和文件偏移量的关系。
无论是我们自己编译形成的.o还是静态库,本质都是把.o文件链接的过程。所以研究静态链接,就是研究.o文件如何连接。下面我们做个实验。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ cat hello.c
#include<stdio.h>
void run();
int main() {printf("hello world!\n");run();return 0;
}
wujiahao@VM-12-14-ubuntu:~/glibc_test$ cat code.c
#include<stdio.h>void run(){printf("running...\n");
}
我们将hello与code编译形成同名.o
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -c *.c
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls
code.c hello.c libmyc.a Makefile mystdio.h mystring.c mystring.o usercode.c
code.o hello.o log.txt mystdio.c mystdio.o mystring.h usercode usercode.o
接着对这两个.o文件进行链接:
wujiahao@VM-12-14-ubuntu:~/glibc_test$ gcc -o main.exe *.o
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ls
code.c hello.c libmyc.a main.exe mystdio.c mystring.c usercode
code.o hello.o log.txt Makefile mystdio.h mystring.h usercode.c
虽然这两个文件和c标准库是动态链接,但这两个文件一定是合并的。
objdump -d code.o反汇编.注意这时我们对.o文件进行反汇编,还没有进行连接.
我们会发现一个奇怪的现象,查看code.s,我们发现它在call指令时,函数地址居然是0!
code.s buffers 1 2 code.o: file format elf64-x86-643 4 5 Disassembly of section .text:6 7 0000000000000000 <run>:8 0: f3 0f 1e fa endbr649 4: 55 push %rbp10 5: 48 89 e5 mov %rsp,%rbp11 8: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # f <run+0xf>12 f: 48 89 c7 mov %rax,%rdi13 12: e8 00 00 00 00 call 17 <run+0x17>14 17: 90 nop15 18: 5d pop %rbp16 19: c3 ret
因为模块之间还没有合并,这两个函数一个是库提供,一个是hello.c提供.也就是说此时编译器也不知道这些方法在哪,多个.o此时不知道对方的存在,编译器只能临时把它的地址设为0。
这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的地址将其修正。
我们查看code.o的符号表:在这里编译器是不认识printf的(undefined)。hello.o此时也一样,同样不认识run和printf是什么。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ readelf -s code.oSymbol table '.symtab' contains 6 entries:Num: Value Size Type Bind Vis Ndx Name0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS code.c2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text3: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .rodata4: 0000000000000000 26 FUNC GLOBAL DEFAULT 1 run5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
在链接时:就会相互交流,哪些方法是定义了的run,哪些是不认识的printf再跟c静态库链接.
再查看可执行程序main.exe的符号表,上面的方法就认识了(可能会出现当前我们的系统默认为动态链接此时还不认识printf的情况)。
24: 0000000000001149 26 FUNC GLOBAL DEFAULT 16 run25: 000000000000118c 0 FUNC GLOBAL HIDDEN 17 _fini26: 0000000000004000 0 NOTYPE GLOBAL DEFAULT 25 __data_start27: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__28: 0000000000004008 0 OBJECT GLOBAL HIDDEN 25 __dso_handle29: 0000000000002000 4 OBJECT GLOBAL DEFAULT 18 _IO_stdin_used30: 0000000000004018 0 NOTYPE GLOBAL DEFAULT 26 _end31: 0000000000001060 38 FUNC GLOBAL DEFAULT 16 _start32: 0000000000004010 0 NOTYPE GLOBAL DEFAULT 26 __bss_start33: 0000000000001163 40 FUNC GLOBAL DEFAULT 16 main
要记得我们之间在查看未链接时的两个文件(hello.o,code.o)的反汇编.方法的地址都是0.此时我们把可执行程序反汇编看看方法的地址:此时run和printf的地址都已经进行了修正。
0000000000001163 <main>:
127 1163: f3 0f 1e fa endbr64
128 1167: 55 push %rbp
129 1168: 48 89 e5 mov %rsp,%rbp
130 116b: 48 8d 05 9d 0e 00 00 lea 0xe9d(%rip),%rax # 200f <_IO_stdin_used+0xf>
131 1172: 48 89 c7 mov %rax,%rdi
132 1175: e8 d6 fe ff ff call 1050 <puts@plt>
133 117a: b8 00 00 00 00 mov $0x0,%eax
134 117f: e8 c5 ff ff ff call 1149 <run>
135 1184: b8 00 00 00 00 mov $0x0,%eax
136 1189: 5d pop %rbp
137 118a: c3 ret
结论:
1.两个.o的文件合并到了一起,并且进行了统一编址
2.链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进行相关call地址完成代码调用
静态链接:链接过程会涉及对.o中外部符号进行地址重定位.因为在连接时它的地址会被修改,所以.o文件又被称为可重定位目标文件.
4.ELF加载与进程地址空间
首先我们先抛出一个问题:
一个可执行程序,如果没有被加载到内存中,该可执行程序有没有地址?
是有地址的!
由刚才所学,数据是分节的.起始地址+偏移量,找到任意函数或变量.磁盘上的地址-逻辑地址.各个segment之间没有交集。
实际上,操作系统并不会采用这种编址方式,而是和编译器一起进行了下一阶段的演化:将每个segment的起始地址都设为0,从0开始统一编址——称为平坦模式编址。这样一来对于所有的可执行程序,所有的segment,所有的函数,变量编址的起始偏移量都从0开始。
让我们回忆一下,上次讲从0开始连续编址,是什么时候?虚拟地址空间!对,你的直觉没错,操作系统对可执行程序的这种编址方式与虚拟地址空间的编址是有着紧密联系的!
实际上,虚拟地址空间不仅是操作系统内进程看待内存的方式,并且磁盘上的可执行程序,代码和数据编制也是虚拟地址的统一编址。
逻辑地址?虚拟地址?在编译好之后,函数之间互相调用的地址已经是虚拟地址了.
重新理解进程虚拟地址空间:也就是说,在加载程序时,操作系统会读取Program header中的相关字段,然后用可执行程序的虚拟地址,直接用来初识化进程的mm_struct等结构。
将多个segment合并在干什么?对可执行程序进行统一编址,都从起始地址0开始.有了统一的地址后,再去修改.o文件中的call,将call改为我们此时真实的地址,由此各个程序之间的调用关系就产生了.
我们的可执行程序在加载到物理内存之前,地址就已经作为代码的一部分存在了.加载代码,还会创建进程pcb。
接下来,我们用可执行程序做例子解释这个过程。
mm_struct中有一块代码区,就会有起始地址和偏移量的概念.偏移量就是我们可执行程序的大小.假设100字节.起始地址就是磁盘中已经分配好的代码段的起始地址.(确定好start和end)
因为可执行程序被加载到了物理内存中,所以每一行代码也必定有自己的物理地址.此时页表的虚拟地址和物理地址就产生了映射.
当我们查看某个可执行文件的ELFheader时,有一个重要的字段:Entry point_address
它就是这个程序的入口地址!!他是被单独记录下来的!
操作系统会把这个入口地址,直接load到CPU的EIP寄存器中!!这样,CPU就知道了我们可执行程序的起始地址!
cpu读到的每一条指令的地址,全部都是虚拟地址.在CPU内部,通过MMU寄存器和页表的作用,转化出来的全是物理地址!由此cpu再也不关心物理地址,cpu只认为我们在访问时从0到全f编址,和磁盘的视角是一摸一样的.
来整体回答一下可执行程序是如何加载到内存的:这是一个文件系统,操作系统内核和磁盘的协作,例如此时我们要执行mian.exe程序,先通过文件系统找到文件,然后创建进程和mm_struct,接着再加载,最后再建立映射.
5.动态库如何与我们的可执行程序关联
1.进程如何看待动态库:从代码区跳转到共享区执行库函数,执行完在跳转回来.这就是库函数调用.要被进程看到,就要将动态库映射到进程的地址空间,被进程调用就是在进程地址空间跳转。
动态库最大的优势,就是即使有再多的代码(进程)需要同一个动态库,只需要在内存中加载一份,就可以被多个进程所共享。
动态库链接,动态库如何加载?
在linux中,动态链接远比静态链接常用。
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ldd main.exelinux-vdso.so.1 (0x00007ffc18bfd000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4cf6f80000)/lib64/ld-linux-x86-64.so.2 (0x00007f4cf71b6000)
这⾥的 libc.so 是C语⾔的运⾏时库,⾥⾯提供了常⽤的标准输⼊输出⽂件字符串处理等等这些功能。
那为什么编译器默认不使⽤静态链接呢?静态链接会将编译产⽣的所有⽬标⽂件,连同⽤到的各种库,合并形成⼀个独⽴的可执⾏⽂件,它不需要额外的依赖就可以运⾏。照理来说应该更加⽅便才对是吧?
静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。
动态链接到底是如何⼯作的?
⾸先要交代⼀个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了
2.我们常说,我们的程序被动过手脚,是什么意思?就拿这段代码来说:除了依赖c标准库,还有一个ld…会提供一个特殊的入口函数start,动态库的加载依赖start调用系统提供的ld加载器.把动态库加载.在这里我们就能理解上面我们在LD_LIBRARY_PATH中的配置文件中指定动态库搜索路径的作用了,ld加载器会在加载动态库时搜索这个路径.
wujiahao@VM-12-14-ubuntu:~/glibc_test$ ldd main.exelinux-vdso.so.1 (0x00007ffc18bfd000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4cf6f80000)/lib64/ld-linux-x86-64.so.2 (0x00007f4cf71b6000)
_start函数的初始化操作:
在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:
1. 设置堆栈:为程序创建⼀个初始的堆栈环境。
2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段
3. 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址。 动态链接器: ◦ 动态链接器(如ld-linux.so)负责在程序运⾏时加载动态库。 ◦ 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。 环境变量和配置⽂件: ◦ Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置⽂件(如/etc/ld.so.conf及其⼦配置⽂件)来指定动态库的搜索路径。 ◦ 这些路径会被动态链接器在加载动态库时搜索。 缓存⽂件: ◦ 为了提⾼动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存⽂件。 ◦ 该⽂件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会⾸先 搜索这个缓存⽂件。 4. 调⽤ __libc_start_main :⼀旦动态链接完成, _start 函数会调⽤
__libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执⾏
⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。
5. 调⽤ main 函数:最后, __libc_start_main 函数会调⽤程序的 main 函数,此时程序的执
⾏控制权才正式交给⽤⼾编写的代码。
6.处理main函数的返回值:当main函数返回时,__libc_start_main会负责处理这个返回值,并最终调用_exit函数终止程序。
3.动态库中的相对地址:动态库也是elf,我们也理解成起始地址(0)+偏移量的统一编址(平坦模式编址)。
我们的程序和库时如何映射起来的:• 动态库也是⼀个⽂件,要访问也是要被先加载,要加载也是要被打开的
• 让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,通过虚拟地址进⾏跳转访问的,所以需要把动态库映射到进程的地址空间中
ELF符号表会记录下来依赖库的路径
起始地址+偏移量
这里加载的原理和可执行程序加载的原理基本上没太大差别
4.我们的程序如何进行库调用?
• 库已经被我们映射到了当前进程的地址空间中
• 库的虚拟起始地址我们也已经知道了
• 库中每⼀个⽅法的偏移量地址我们也知道
• 所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅
法
• ⽽且:整个调⽤过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程完全在进程地址空间中进⾏的.
例如这里我们自己的程序要调用fputs,假设puts在库中的偏移地址是0x112233.偏移量在编译时就确定了(要进行编址),假设动态库映射之后,其实的虚拟地址也确定,这样fputs的虚拟地址就确定了(吗?).
跟库关联,是会改变我们源代码中的函数调用地址的.修改的是代码区?代码区不是只读的吗?
不能改,也改不了!那我们上面的结论要如何实现?
全局偏移量表GOT:
动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址
因为.data区域是可读写的,所以可以⽀持动态进⾏修改
readelf -S main.exe[24] .got PROGBITS 0000000000003fb8 00002fb80000000000000048 0000000000000008 WA 0 0 8
got在加载时,回和.data合并成同一个segment,然后加载在一起。
readelf -l main.exe
05 .init_array .fini_array .dynamic .got .data .bss
因此通过got表我们能忽略对代码区的修改,而是对数据区的修改
1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表。
2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。 3. 在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。 4. 这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
6.库间依赖原理
不仅可执行程序可以调用库,库之间也可以互相调用,也就是说库之间也会存在依赖关系。那么是如何做到库与库之间互相调用是地址无关的呢?
库中也有.GOT,和可执⾏⼀样!这也就是为什么⼤家为什么都是ELF的格式!
由于GOT表中的映射地址会在运⾏时去修改,我们可以通过gdb调试去观察GOT表的地址变化。
我们可以参照这个文档进行实践:使用gdb调试GOT
• 由于动态链接在程序加载的时候需要对⼤量函数进⾏重定位,这⼀步显然是⾮常耗时的。为了进⼀步降低开销,我们的操作系统还做了⼀些其他的优化,⽐如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序⼀开始就对所有函数进⾏重定位,不如将这个过程推迟到函数第⼀次被调⽤的时候,因为绝⼤多数动态库中的函数可能在程序运⾏期间⼀次都不会被使⽤到。
思路是:GOT中的跳转地址默认会指向⼀段辅助代码,它也被叫做桩代码/stup。在我们第⼀次调⽤函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调⽤函数的时候,就会直接跳转到动态库中真正的函数实现。
总⽽⾔之,动态链接实际上将链接的整个过程,⽐如符号查询、地址的重定位从编译时推迟到了程序的运⾏时,它虽然牺牲了⼀定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利⽤磁盘空间和内存资源,以极⼤⽅便了代码的更新和维护,更关键的是,它实现了⼆进制级别的代码复⽤。
7.总结
• 静态链接的出现,提⾼了程序的模块化⽔平。对于⼀个⼤的项⽬,不同的⼈可以独⽴地测试和开发⾃⼰的模块。通过静态链接,⽣成最终的可执⾏⽂件。
• 我们知道静态链接会将编译产⽣的所有⽬标⽂件,和⽤到的各种库合并成⼀个独⽴的可执⾏⽂件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
• ⽽动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是⽆论加载到什么地⽅,都要映射到进程对应的地址空间,然后通过.GOT⽅式进⾏调⽤(运⾏重定位,也叫做动态地址重定位)。