1. 库
库是指一组预编译的、可复用的代码集合,这些代码实现了一些特定的功能或算法,供其他程序调用使用,而无需开发者每次都从头编写这些代码。库一般可以分为静态库和动态库。
静态库在Linux系统上以.a为后缀,在Windows上以.lib为后缀。静态库会在编译时将库代码直接嵌入到生成的可执行文件中,因此生成的可执行文件体积较大,但是在运行时可以无需依赖外部库文件。
动态库在Linux系统上以.so为后缀,在Windows上以.dll为后缀。动态库则无需嵌入文件,而只在程序运行时被加载到内存,多个程序可以共享这一份动态库代码。
1.1 静态库
1.1.1 创建静态库
我们通过编写一个makefile文件来完成编译和打包静态库的工作。
bash
libmstdio.a : mstdio.o mstring.o
@ar -rc $@ $^
@echo "build $^ to $@ done successfully"
%.o:%.c
@gcc -c $<
@echo "compiling $< to $@ done successfully"
.PHONY:clean
clean:
@rm -rf *.a *.o pack*
@echo "clean done"
.PHONY:output
output:
@mkdir -p pack/include
@mkdir -p pack/lib
@cp -f *.h pack/include
@cp -f *.a pack/lib
@tar -czf pack.tgz pack
@echo "output pack successfully"
bashar -rc libmystdio.a my_stdio.o my_string.o
ar是一个用于创建、修改和提取归档文件(通常是静态库)的工具,我们使用ar指令来打包目标文件(.o)来创建静态库。
-r选项:将文件插入归档文件中。如果归档中已经存在同名的文件,则替换之。可以确保当.o文件更新时,生成的静态库文件对应位置会被新的内容覆盖。
-c选项:创建归档文件时不显示警告信息(即使归档文件之前不存在,也不会提示)。
-s选项:创建或更新归档的索引(符号表)。对于静态库来说,这个操作会使得链接器更快地查找符号。通常在插入、删除文件后,需要调用此选项来更新索引。
-t选项:针对生成的.a静态库,列出其中的文件。
-v选项:在-t选项的基础上给出静态库中文件的详细信息。
cpp%.o: %.c @gcc -c $< @echo "compiling $< to $@ done successfully"
%.o:%.c是一个规则头部,其中%是通配符,表示对于任意的.c文件,都可以生成对应的.o文件。即如果需要生成某个.o文件,可以用对应的.c文件作为依赖。
接下来就是gcc的编译指令,-c选项表示使用GCC编译器将C源文件编译成目标文件。
其中$<表示规则头部的第一个依赖文件,在此处就是%.c文件。
bash.PHONY:clean clean: @rm -rf *.a *.o pack* @echo "clean done" .PHONY:output output: @mkdir -p pack/include @mkdir -p pack/lib @cp -f *.h pack/include @cp -f *.a pack/lib @tar -czf pack.tgz pack @echo "output pack successfully"
.PHONY用于声明为目标(phony target),表示强制执行下面的命令。以上两步完成了清除与输出的任务。


可以看到我们的目录中生成了一个libmstdio.a的静态库文件。
1.1.2 使用静态库
我们需要正常编译main.c肯定需要我们另外的两个c文件和h文件,而现在.c文件被整合进了静态库.a文件当中,所以使用静态库的前提就是找到静态库和头文件的位置。
①给出静态库和头文件全部路径
bashgcc main.c -I/path/to/headers -L/path/to/libs -lmystdio
-I选项后跟指定头文件所在目录。
-L选项后跟指定库文件所在目录。
-l选项后跟静态库名称。需要注意的是搜索静态库是在-L选项指定的目录中搜索,而且静态库的命名规范是 lib库名称.a(so) 。真正的库名是去掉前后缀的结果,如c标准库libc.so.6的真正名称是c。
②不 给出头文件路径
gcc会默认在当前路径和系统默认路径/usr/include中寻找头文件。
③不给出静态库路径
gcc会默认在/lib64路径下查找静态库。
小结一下,当编译时gcc会从当前路径、系统默认路径/usr/include、用户-I给出的路径下寻找头文件;会从用户-L给出的路径、系统默认路径/lib64下寻找静态库。
所以使用静态库时需要指明头文件和静态库文件的位置,当然为了可以方便使用,也可以将我们自己的文件放入系统对应路径中。一般来说对于除了c标准库和c++标准库以外的第三方静态库,都需要通过-l选项来指出使用的库名。
1.2 动态库
1.2.1 创建动态库
bash
libmstdio.so : mstdio.o mstring.o
@gcc -o $@ $^ -shared
@echo "build $^ to $@ done successfully"
%.o:%.c
@gcc -fPIC -c $<
@echo "compiling $< to $@ done successfully"
.PHONY:clean
clean:
@rm -rf *.so *.o pack*
@echo "clean done"
.PHONY:output
output:
@mkdir -p pack/include
@mkdir -p pack/lib
@cp -f *.h pack/include
@cp -f *.so pack/lib
@tar -czf pack.tgz pack
@echo "output pack successfully"
bashgcc -o libmstdio.so mstdio.o mstring.o -shared
gcc的-shared选项可以链接多个.o目标文件,生成一个共享库(.so)。
bashgcc -fPIC -c <source_file>.c
-c表示只编译不链接。
-fPIC指生成位置无关代码(Position Independent Code),这是一种特殊的代码生成方式,它的核心特点是代码可以在内存中的任意位置运行,而不需要修改代码本身。
动态库在运行时被加载到内存中,并且可能被多个进程共同使用。实际上动态库只被实际加载到了物理内存一次,但是由于各个进程都通过页表映射到了这个动态库,所以在每个进程的地址空间视角下,同一份动态库有着不同的逻辑地址。所以动态库必须采取和位置无关的代码,即使用偏移量的方法来寻址访问。

1.2.2 使用动态库
对于动态库的使用和静态库基本相似,也需要通过gcc的选项指出头文件和动态库所在目录。
①给出静态库和头文件全部路径
bashgcc main.c -I/path/to/headers -L/path/to/libs -lmystdio
-I选项后跟指定头文件所在目录。
-L选项后跟指定库文件所在目录。
-l选项后跟库名称。
②不 给出头文件路径
gcc会默认在当前路径和系统默认路径/usr/include中寻找头文件。
③不给出静态库路径
gcc会默认在/lib64路径下查找动态库。
和静态库相同,当编译时gcc会从当前路径、系统默认路径/usr/include、用户-I给出的路径下寻找头文件;会从用户-L给出的路径、系统默认路径/lib64下寻找动态库。
在编译成功生成可执行文件后,如果动态库文件没有位于系统目录下,那么执行可执行程序有可能会出现新的问题。

通过ldd分析可执行程序的依赖库,可以发现mstdio动态库未找到。这是因为动态库是程序执行时才被载入内存的,当使用gcc进行编译时给出了动态库所在路径因此编译成功。但是当执行时,shell并不知道需要的动态库在哪里,因此出现了错误。

解决这个问题也不难,只需要告诉shell动态库的位置即可,操作系统默认搜索路径包括lib、lib64等系统标准库路径,然后再去一个名叫LD_LIBRARY_PATH的环境变量的指定路径。
所以可行的解决方案包括:将动态库拷贝到系统默认路径下、在系统默认路径下建立软连接、修改环境变量LD_LIBRARY_PATH增加动态库所在路径、新增配置文件。其中新增配置文件一般在/etc/ld.so.conf.d/目录下新建一个自己的xxx.conf,然后在其中设置新的动态库链接路径,最后ldconfig使配置重新加载生效即可。
不难发现静态库和动态库的链接命令都是一样的,当同时存在静态库和动态库时,gcc和g++会优先选择动态库,而如果想要强制静态链接则需要使用-static选项。
2. ELF文件
ELF(Executable and Linkable Format)是一种文件类型,主要在Linux环境下使用,是一种用于可执行文件、目标文件、共享库和核心转储的标准文件格式。
在Linux中使用ELF文件格式的文件包括:
①可执行目标文件(Executable):编译链接后生成的可执行文件,是可以直接运行的程序。
②可重定位目标文件(Relocatable):编译生成的中间文件(.o 文件),需要进一步链接。
③共享库(Shared Object):动态链接库(.so 文件),在程序运行时被加载。
④核心转储文件(Core Dump):在程序崩溃时生成的内存转储文件,用于调试。
2.1 ELF文件结构
2.1.1 ELF文件基本框架
图片来源: doc.embedfire.com/linux/imx6/base/zh/latest/linux_driver/module.html
2.1.1.1 ELF 头(ELF Header)
ELF头用于描述文件的基本信息。

其中包括:
魔数(Magic Number):标识文件为 ELF 格式。
文件类型:可执行文件、目标文件、共享库等。
目标架构:如 x86、ARM。
入口点地址:程序的起始地址。
程序头表和节头表的偏移量和大小。
2.1.1.2 程序头表(Program Header Table)
程序头表描述段的信息,用于加载和执行程序。

其中包括:
每个段在文件中的位置和大小。
段在内存中的加载地址。
段的权限(读、写、执行)。
2.1.1.3 节头表(Section Header Table)
节头表描述节的信息,用于链接和调试。

其中包括:
每个节在文件中的位置和大小。
节的类型(代码、数据、符号表等)。
节的属性(可读、可写、可执行)。
2.1.1.4 节(Sections)和段(Segments)
节是 ELF 文件中的最小组织单位,用于存储特定类型的数据。保存了代码、数据、符号表、字符串表、重定位信息等,是链接和调试的基本单位。每个节的信息在节头表中给出。
常见的节:
.text:存储程序的指令。
.data:存储已初始化的全局变量。
.bss:存储未初始化的全局变量。
.rodata:存储只读数据(如字符串常量)。
.symtab:存储符号表(函数和变量的名称和地址)。
.strtab:存储字符串表(符号名称和节名称)。
.rel.*:存储重定位信息(用于链接目标文件)。
段是 ELF 文件在内存中的加载单位,用于描述如何将文件内容加载到内存中。一个段可以包含一个或多个节,是加载和执行的基本单位。每个段的信息在程序头表中给出。
常见段:
LOAD:需要加载到内存的段(如代码段和数据段)。
DYNAMIC:动态链接信息。
INTERP:指定动态链接器的路径。
编译器在生成目标文件时,会将代码、数据等内容组织成不同的节,链接器在生成可执行文件或动态库时,也会使用节中的信息进行符号解析和重定位。但是到了操作系统在加载 ELF 文件时,会根据段的信息将文件内容映射到内存中。所以一个段可以包含多个节,这些节在内存中通常是连续的。
当多个.o目标文件被链接时,他们的所有相同属性的section会进行合并,生成最后的可执行文件。
2.1.2 ELF文件示例
2.1.2.1 可重定位目标文件(Relocatable Object File)

文件类型:ET_REL(可重定位文件)。
用途:编译生成的中间文件(.o 文件),需要进一步链接。
特点:
包含代码、数据和符号表,但未分配最终的内存地址。
包含重定位信息(.rel.text 和 .rel.data),用于链接时修正地址。
不包含程序头表(Program Header Table),因为不需要加载到内存。
2.1.2.2 可执行目标文件(Executable Object File)

文件类型:ET_EXEC(可执行文件)。
用途:可以直接运行的程序。
特点:
包含代码、数据和符号表,且已分配最终的内存地址。
包含程序头表(Program Header Table),用于加载到内存。
不包含重定位信息,因为地址已经确定。
入口点地址(Entry Point)指向程序的起始地址。
2.1.2.3 动态库(Shared Object File)

文件类型:ET_DYN(共享对象文件)。
用途:动态链接库(.so 文件),可以在运行时加载。
特点:
包含代码、数据和符号表,但地址是位置无关的(PIC)。
包含程序头表(Program Header Table),用于加载到内存。
包含动态链接信息(如 .dynamic 节和 .got 节)。
可以被多个程序共享,节省内存空间。
2.2 ELF文件加载
2.2.1 虚拟空间的内存结构
我们在进程部分曾经介绍过,对于每一个进程而言,他们有自己独立的task_struct结构体来管理自己进程的信息。在task_struct结构体中有一个字段叫做mm_struct,它是进程的内存描述符,用于描述一个进程的整个虚拟内存空间。
稍微回顾一下,在mm_struct中有这样一些字段:
mmap(vm_area_struct):指向进程的虚拟内存区域链表。
pgd:指向进程的页全局目录(Page Global Directory,PGD),用于页表管理。
start_code、end_code:代码段的起始和结束地址。
start_data、end_data:数据段的起始和结束地址。
start_brk、brk:堆的起始和当前结束地址。
start_stack:栈的起始地址。

其中vm_area_struct是虚拟内存区域描述符,用于描述进程虚拟地址空间中的一个连续区域。其字段包括:
vm_start:区域的起始地址。
vm_end:区域的结束地址。
vm_flags:区域的权限标志(如可读、可写、可执行)。
vm_file:如果区域映射了文件,指向对应的文件对象。
vm_next:指向下一个 vm_area_struct,形成链表。
这时会有一个疑问,似乎mm_struct和vm_area_struct的字段重复了。这是因为mm_struct目的是提供进程内存管理的全局视图,适合快速访问和整体管理;而vm_area_struct提供进程内存管理的局部视图,适合精细化的内存管理,其中包含着区域的权限、文件等信息。
以一个例子具体化理解:
假设一个进程的虚拟内存布局如下:
代码段:0x08048000 - 0x08049000
数据段:0x08049000 - 0x0804a000
堆:0x0804a000 - 0x0804b000
栈:0xbf800000 - 0xbf801000
mm_struct:
start_code = 0x08048000
end_code = 0x08049000
vm_area_struct 链表:
节点 1:代码段(0x08048000 - 0x08049000)。
节点 2:数据段(0x08049000 - 0x0804a000)。
节点 3:堆(0x0804a000 - 0x0804b000)。
节点 4:栈(0xbf800000 - 0xbf801000)。
2.2.2 可执行程序加载
对于一个ELF可执行程序而言,它的文件中自带地址,我们可以通过objdump -S 指令来查看其内容,会发现其中已经存在了了各个指令的地址信息。

针对这个地址,我们称之为逻辑地址 ,是程序视角中的地址,通常是相对于某个段基址的偏移量。与之相似的是虚拟地址,是进程视角中的地址,也就是进程虚拟地址空间中的地址。另外还有物理地址,是实际内存硬件中的地址,虚拟地址通过页表可以映射到物理地址。
当把程序置于平坦模式下时,由于内存地址空间是连续的、线性的,没有分段机制,所以这个时候程序整体不做分割加载到内存中,逻辑地址也就和虚拟地址在数值上一样了。
2.2.2.1 加载执行过程举例
假设我们有一个简单的 ELF 可执行文件a.out,其内存布局如下:
代码段:0x08048000 - 0x08049000
数据段:0x08049000 - 0x0804a000
堆:0x0804a000 - 0x0804b000
栈:0xbf800000 - 0xbf801000
①首先操作系统会读取这个ELF文件的ELF头,明确这个文件的类型是可执行文件,读取程序头表的位置和大小,并且找到文件入口地址(即0x08048000)。
②接着操作系统解析程序头表,获取各个段的信息,如:
代码段:文件偏移 0x000000,大小 0x1000,虚拟地址 0x08048000,权限 R-X。
数据段:文件偏移 0x001000,大小 0x1000,虚拟地址 0x08049000,权限 RW-。
③然后操作系统为进程分配虚拟地址空间:
代码段:0x08048000 - 0x08049000。
数据段:0x08049000 - 0x0804a000。
④加载段到内存并设置页表映射
代码段:将文件偏移 0x000000 处的 0x1000 字节加载到虚拟地址 0x08048000。
数据段:将文件偏移 0x001000 处的 0x1000 字节加载到虚拟地址 0x08049000。
假设物理内存的可用区域从 0x10000000 开始。
代码段:虚拟地址 0x08048000 映射到物理地址 0x10000000。
数据段:虚拟地址 0x08049000 映射到物理地址 0x10001000。
⑤开始执行后,CPU会完成对虚拟地址到物理地址的计算
当cpu试图访问某一内存地址的内容时,就需要用已知的虚拟地址映射出真正的物理地址,这其中就会用到页表。
⑥取得物理地址后,cpu就可以到指定的地址处读取数据了。
2.2.3 动态库加载
静态链接的情况下,因为静态库本身作为了可执行程序代码的一部分,所以与正常的可执行文件加载没有什么不同。
对于动态链接而言,则并不是这样,因为动态库时独立于可执行程序之外的。
①当程序启动时,操作系统会加载程序的 ELF 文件,并解析其依赖的动态库。在解析到程序需要的动态库之后,使用动态链接器(如 ld-linux.so)来加载和链接动态库。此时动态库被加载到了物理内存之中。为了使得程序可以调用动态库,于是将动态库的地址映射到程序虚拟地址空间的共享区位置。
经过这一步操作,动态库只被在进程中加载了一份,而多个程序都可以通过映射的方法找到这一份动态库。

②接下来动态链接器会解析动态库中的符号(如函数和变量)。然后动态链接器将符号的地址更新到 GOT 中;将 PLT 中的条目指向 GOT 中的地址。
GOT(Global Offset Table,全局偏移表)
GOT 通常位于动态库的数据段(.data 或 .got 节),每个动态库有自己的 GOT。这个表中存储了动态库中的全局变量和函数的实际地址。因为动态库被加载的位置未知,所以GOT 中的地址是运行时确定的。在动态库加载时,动态链接器会更新 GOT 中的地址。
PLT(Procedure Linkage Table,过程链接表)
PLT 通常位于动态库的代码段(.plt 节),用于实现延迟绑定(Lazy Binding)。PLT 中的条目指向 GOT 中的地址,在首次调用函数时,PLT 会跳转到动态链接器,解析函数的实际地址并更新 GOT。
简而言之GOT给出了自己动态库中各符号的地址信息,在动态库加载时相当于地址信息被确定,所以会更新表。而PLT则是如果在程序一开始就对所以函数进行地址解析,那么会导致启动时间很长。所以延迟绑定只有在函数第一次被调用时才解析其地址。