前言:平时写程序,我们经常直接调用库里的函数。这篇就谈谈制作一个简单的库和介绍下ELF文件的宏观认识
1.库和目标文件
库可以理解成把一些能够重复使用的功能整理好,交给其他程序调用。比如输入输出、字符串处理等功能,不需要每写一个程序就重新实现一遍。
在 Linux 下,常见的两种库是:
- 静态库 :通常命名为
lib名字.a,在链接时把需要的目标文件内容放进可执行文件中。 - 动态库 :通常命名为
lib名字.so,程序运行时还需要找到并使用这个库。
一个库可以供多个程序使用,一个程序也可以同时使用多个库。

先把 .c 文件编译为 .o,再由链接器把需要的部分组织起来,形成可执行程序。这里的 .o 就是可重定位目标文件 要参与后面的链接。
制作普通的功能库不需要写 main,提供可以被调用的函数就行。程序的 main 由使用这个库的一方提供。
先准备一个很简单的函数。librarydir 是制作库的目录,其中 head.h 提供声明:
c
#pragma once
#include <stdio.h>
const char* my_string();
code.c 提供实现:
c
#include "head.h"
const char* my_string()
{
return "hello Linux!\0";
}
调用方放在另一个目录 tmpdir 中,main.c 的内容如下:
c
#include "head.h"
int main()
{
printf("%s\n", my_string());
return 0;
}
这个程序要做的事情很简单:调用 my_string(),再把得到的字符串打印出来。接下来静态库和动态库都使用这套代码。
2.静态库的制作与使用
2.1生成静态库并打包
静态库的制作可以先记成两步:把源文件编译成 .o,再把 .o 归档成 .a。
这里是制作过程:
makefile
TAEGET=libmydio.a
SRC=$(wildcard *.c)
OBJ=$(SRC:.c=.o)
$(TAEGET):$(OBJ)
@ar -cr $@ $^
%.o:%.c
@gcc -c $<
.PHONY:clean
clean:
@rm -rf $(OBJ) $(TAEGET) librarydir*
.PHONY:output
output:
@mkdir -p librarydir/include
@mkdir -p librarydir/lib
@cp -p *.h librarydir/include
@cp -p *.a librarydir/lib
@tar -czf librarydir.tgz librarydir
@echo "创建静态库并压缩完成"

SRC 找到当前目录中的 .c 文件,OBJ 把对应的文件名换成 .o。随后 gcc -c 生成目标文件,ar -cr 再把这些目标文件归档到 libmydio.a 中。
ar 是归档工具,其中 c 表示创建归档时不额外提示,r 表示添加或替换其中的成员。这里生成的 .a 可以直接参与链接,不需要使用者先手动解包。
在包含 code.c、head.h 和两份 Makefile 的制作目录中执行:
bash
make -f Makefile-bak clean
make -f Makefile-bak
make -f Makefile-bak output
因为静态版的文件名叫 Makefile-bak,所以用 -f 指定它。先清理旧的构建结果,再生成库,最后执行 output 打包。
打包后会得到 librarydir.tgz,里面的发布目录分成 include 和 lib:

include/head.h 告诉使用者有哪些函数、怎样调用;lib/libmydio.a 中保存编译后的实现。交给别人使用时,把头文件和库一起提供即可,不需要再交出 code.c。
想看静态库里放了哪些目标文件,可以使用 ar -tv libmydio.a。其中 t 用于列出成员,v 显示详细信息。
2.2在其他程序中使用静态库
把静态版发布目录放到 tmpdir 中,与 main.c 放在一起,就形成下面这样的布局:

在 tmpdir 中,可以直接指定静态库文件进行链接:
bash
gcc main.c -I librarydir/include librarydir/lib/libmydio.a -o app_static
./app_static
这里明确传入了 libmydio.a,使用的就是这个静态库。
如果库目录中只有静态版,也可以按照库名来查找:
bash
gcc main.c -I librarydir/include -L librarydir/lib -lmydio -o app_static
下面是相关选项介绍:
| 选项 | 作用 |
|---|---|
-I librarydir/include |
到这个目录查找头文件,注意是大写字母 I |
-L librarydir/lib |
链接时到这个目录查找库文件 |
-lmydio |
指定要链接的库名,这里是小写字母 l |
使用 -l 时,把库文件名的 lib 前缀和 .a、.so 后缀去掉即可。因此 libmydio.a 对应的是 -lmydio 当然我上面手误了多了个 io
静态链接完成后,运行 app_static 就不再依赖这份 libmydio.a,因为用到的内容已经进入可执行文件中了。不过,程序仍可能动态依赖其他库,比如这里使用的 C 标准库。
通常同一搜索目录中同时存在同名 .so 和 .a 时,默认会优先选择动态库。若希望整个程序使用静态链接,可以加 -static,但环境中也要有各项依赖对应的静态库。
3.动态库的制作与使用
3.1生成动态库
动态库仍然使用前面的 head.h 和 code.c,主要变化在构建方式上。制作目录中的 Makefile 如下:
makefile
TAEGET=libmydio.so
SRC=$(wildcard *.c)
OBJ=$(SRC:.c=.o)
$(TAEGET):$(OBJ)
@gcc -o $@ $^ -shared
%.o:%.c
@gcc -fPIC -c $<
.PHONY:clean
clean:
@rm -rf $(OBJ) $(TAEGET) librarydir*
.PHONY:output
output:
@mkdir -p librarydir/include
@mkdir -p librarydir/lib
@cp -p *.h librarydir/include
@cp -p *.so librarydir/lib
@tar -czf librarydir.tgz librarydir
@echo "创建动态库并压缩完成"

和静态版相比,主要看两处:
- 编译目标文件时增加了
-fPIC,生成位置无关代码,方便动态库在不同进程中使用。 - 生成库时使用
-shared,把目标文件链接为共享库libmydio.so。
静态库通过 ar 归档,动态库这里则通过 gcc 调用链接工具生成。
在制作目录中执行:
bash
make clean
make
make output
就可以成生了
3.2链接成功,为什么还可能运行不了
在 tmpdir 中执行:
bash
gcc main.c -I librarydir/include -L librarydir/lib -lmydio
./a.out
链接命令和前面基本相同,但运行时可能出现下面的情况:

a.out 已经生成了,可运行时仍然提示找不到 libmydio.so。使用 ldd a.out 查看依赖,也能看到它对应的是 not found。
问题在于:链接时找到了库,不代表运行时也知道库在哪里。
-L 指定的是链接时的搜索位置。到了运行阶段,需要动态链接器另外找到程序依赖的 .so,前面的 -L 不会自动变成它的运行搜索路径。
3.3 解决方法
我们自己测试时,可以先通过 LD_LIBRARY_PATH 告诉它库在哪里。在 tmpdir 中执行:
bash
LD_LIBRARY_PATH=./librarydir/lib ./a.out
这条命令临时把当前目录下的 librarydir/lib 设为本次运行的库搜索路径。动态链接器能找到 libmydio.so 后,程序就可以调用其中的函数,打印出 hello Linux!。这里的环境变量设置只对这一次运行生效。因为我们用户每次登上机器时系统会重新从配置文件中加载环境变量,如果不修改配置文件的话,那登上去又找不到了。
如果希望系统长期能够找到这个库,还可以采用下面这些方式:
- 把动态库安装到系统配置的库目录中。
- 在系统库目录中建立指向它的软链接。
- 在
/etc/ld.so.conf.d/下的配置文件中写入库所在目录的绝对路径,再执行ldconfig更新缓存。
系统库目录会随发行版和架构不同而变化,比如常见的 /usr/lib、/lib64 等。我这里就不作演示了我怕给我的环境给污染了(当然是我懒得删了)
静态库和动态库的区别就是:静态链接把需要的内容带进程序中;动态链接让库单独保留,程序运行时再使用它。多个程序使用同一个动态库时,可以共享其中的代码,减少重复存放。
因此,发布动态链接的程序时,也要一起考虑它依赖的动态库以及这些库的查找位置。
4.从整体上认识 ELF
4.1目标文件与 ELF 的基本结构
前面生成了 .o、.a、.so 和可执行程序,它们都是二进制文件,但内部也需要按一定格式组织,工具才能知道代码、数据等内容分别放在哪里。
ELF 就是 Linux 下常见的一种目标文件格式。 常见的 ELF 文件有可重定位文件 .o、可执行文件、共享目标文件 .so,以及记录进程异常现场的 core 文件。
静态库 .a 则是一个归档文件,里面通常装着多个 ELF 格式的 .o。可以把 .a 理解成装这些目标文件的包。

主要有四部分:
| 部分 | 可以先这样理解 |
|---|---|
| ELF Header,ELF 头 | 文件开头的说明信息,记录类型、入口和其他表的位置等 |
| Section,各个节 | 按内容分类保存代码、数据、符号等信息 |
| Section Header Table,节头表 | 描述各个节的位置、大小和属性 |
| Program Header Table,程序头表 | 描述程序加载时使用的段,包括位置、大小和权限等 |
普通 .o 主要用于后续链接,通常没有程序头表。可执行文件需要告诉系统怎样加载,就会用到程序头表。
使用 readelf -h a.out 可以查看 ELF 头。下面是相关的文档ELF 头说明
文件类型中,.o 常见的是 REL,共享库是 DYN;可执行文件可能显示 EXEC,采用位置无关方式构建的可执行文件也可能显示 DYN。反正就是起到一个区分文件格式的作用
4.2节中保存的东西
程序的内容并不是混在一起存放的,而是分成不同的节:
| 常见的节 | 保存的内容 |
|---|---|
.text |
编译后的机器指令 |
.data |
通常保存有非零初值的全局变量、静态变量 |
.rodata |
只读数据,比如字符串常量 |
.bss |
为未初始化或零初始化的全局变量、静态变量记录所需空间 |
.symtab |
符号表,记录函数、变量等符号的信息 |
.strtab |
字符串表,用来保存符号名字等字符串 |
比如前面的 my_string(),它编译后的指令可以放在代码节中,返回的字符串常量则放在只读数据区域。
.bss 比较特别:它记录运行时需要多少空间,这也是为什么未初始全局变量的值为零的原因,因为我只需要找到要开出大多的空间,展开时默认都为0, 这样就可以启动一个节省空间的作用
使用 readelf -S a.out,就能看到各个节的说明:

知道文件起点,再加上这个偏移,就能找到保存在文件里的对应内容。
Address 则描述节的虚拟地址,具体后面再介绍。
再看符号表。我们写代码时使用 my_string 这个函数名,但 main.c 中只有对它的调用,具体实现位于另外的文件中。
符号表就帮助链接器找到这些名字对应的定义。可以先把它理解为一份记录:哪些函数或变量在这里定义,哪些还需要到其他目标文件或库中寻找。
名字本身可以集中保存在字符串表中,用偏移找到对应字符串。这样,符号信息和名字就能联系起来。查看符号表可以使用 readelf -s。
4.3从多个目标文件到程序加载
静态库中装的是目标文件,使用静态库参与链接时,链接器会取出需要的成员,与程序自己的目标文件一起处理。

到了程序加载时,还会遇到另一个名字:段,Segment。

节是按照内容用途细分的,比如代码、只读数据、可写数据;加载时则更关心哪些区域要进入内存、放到什么位置,以及应当具有哪些访问权限。
所以,链接器会把适合放在一起的节安排进同一个可加载段,并把安排记录在程序头表里。程序加载时,系统按这份信息建立对应的内存区域。
一个段可以包含多个节。这样既方便设置读、写、执行权限,也可以减少把许多很小的节分别安排空间所带来的浪费。
使用 readelf -l a.out 可以查看程序头表:

因此,两张表可以这样分工:**节头表帮助链接工具认识文件里的各部分,程序头表帮助系统把程序加载起来。
完