Linux 动静态库制作与原理:从 .a、.so 到 ELF 加载一次讲透


文章目录

  • 引言
  • [1. 什么是库:不是神秘工具,本质就是可复用代码](#1. 什么是库:不是神秘工具,本质就是可复用代码)
    • [1.1 我对库的第一层理解](#1.1 我对库的第一层理解)
    • [1.2 在系统里观察真实的库](#1.2 在系统里观察真实的库)
  • [2. 自己封装一个简单的库:从代码开始理解库](#2. 自己封装一个简单的库:从代码开始理解库)
    • [2.1 为什么要先自己写一份库代码](#2.1 为什么要先自己写一份库代码)
    • [2.2 头文件 my_stdio.h](#2.2 头文件 my_stdio.h)
    • [2.3 实现 my_stdio.c](#2.3 实现 my_stdio.c)
    • [2.4 这段代码背后的 Linux 原理](#2.4 这段代码背后的 Linux 原理)
    • [2.5 字符串函数 my_string](#2.5 字符串函数 my_string)
  • [3. 静态库:把代码"拷贝"进可执行程序](#3. 静态库:把代码“拷贝”进可执行程序)
    • [3.1 静态库的核心理解](#3.1 静态库的核心理解)
    • [3.2 制作静态库 Makefile](#3.2 制作静态库 Makefile)
    • [3.3 使用静态库](#3.3 使用静态库)
    • [3.4 静态库容易踩的坑](#3.4 静态库容易踩的坑)
  • [4. 动态库:运行时再加载,共享更灵活](#4. 动态库:运行时再加载,共享更灵活)
    • [4.1 动态库的核心理解](#4.1 动态库的核心理解)
    • [4.2 制作动态库 Makefile](#4.2 制作动态库 Makefile)
    • [4.3 为什么动态库要用 -fPIC](#4.3 为什么动态库要用 -fPIC)
  • [5. 动态库的使用与查找路径](#5. 动态库的使用与查找路径)
    • [5.1 编译时能找到,不代表运行时能找到](#5.1 编译时能找到,不代表运行时能找到)
    • [5.2 解决动态库 not found 的几种方式](#5.2 解决动态库 not found 的几种方式)
      • [5.2.1 放到系统库路径下](#5.2.1 放到系统库路径下)
      • [5.2.2 使用 LD_LIBRARY_PATH](#5.2.2 使用 LD_LIBRARY_PATH)
      • [5.2.3 使用 ldconfig](#5.2.3 使用 ldconfig)
      • [5.2.4 使用 rpath](#5.2.4 使用 rpath)
    • [5.3 编译期搜索路径和运行期搜索路径的区别](#5.3 编译期搜索路径和运行期搜索路径的区别)
  • [6. 使用外部库 ncurses:体验链接第三方库](#6. 使用外部库 ncurses:体验链接第三方库)
    • [6.1 安装 ncurses](#6.1 安装 ncurses)
    • [6.2 一个进度条小程序](#6.2 一个进度条小程序)
    • [6.3 代码拆解](#6.3 代码拆解)
  • [7. 目标文件:.o 不是中间垃圾,而是链接的关键](#7. 目标文件:.o 不是中间垃圾,而是链接的关键)
    • [7.1 从源码到 .o](#7.1 从源码到 .o)
    • [7.2 为什么修改一个源文件只需要重新编译它](#7.2 为什么修改一个源文件只需要重新编译它)
  • [8. ELF 文件:Linux 可执行程序的"身份证"](#8. ELF 文件:Linux 可执行程序的“身份证”)
    • [8.1 哪些东西是 ELF](#8.1 哪些东西是 ELF)
    • [8.2 ELF 的核心组成](#8.2 ELF 的核心组成)
    • [8.3 readelf 查看 ELF 头](#8.3 readelf 查看 ELF 头)
    • [8.4 Section 和 Segment 的区别](#8.4 Section 和 Segment 的区别)
  • [9. 静态链接:把彼此"不认识"的 .o 连接起来](#9. 静态链接:把彼此“不认识”的 .o 连接起来)
    • [9.1 .o 文件之间一开始并不认识](#9.1 .o 文件之间一开始并不认识)
    • [9.2 链接器负责修正地址](#9.2 链接器负责修正地址)
    • [9.3 符号表的作用](#9.3 符号表的作用)
  • [10. ELF 加载与进程地址空间](#10. ELF 加载与进程地址空间)
    • [10.1 ELF 没运行之前有没有地址](#10.1 ELF 没运行之前有没有地址)
    • [10.2 进程虚拟地址空间从哪里来](#10.2 进程虚拟地址空间从哪里来)
    • [10.3 程序头表为什么重要](#10.3 程序头表为什么重要)
  • [11. 动态链接:把链接推迟到运行时](#11. 动态链接:把链接推迟到运行时)
    • [11.1 动态库如何进入进程地址空间](#11.1 动态库如何进入进程地址空间)
    • [11.2 动态库为什么能被多个进程共享](#11.2 动态库为什么能被多个进程共享)
    • [11.3 PIC、GOT 和相对寻址](#11.3 PIC、GOT 和相对寻址)
    • [11.4 PLT:延迟绑定,第一次用到再解析](#11.4 PLT:延迟绑定,第一次用到再解析)
  • [12. 从编译到运行的完整流程总结](#12. 从编译到运行的完整流程总结)
    • [12.1 静态链接流程](#12.1 静态链接流程)
    • [12.2 动态链接流程](#12.2 动态链接流程)
    • [12.3 我对库原理的最终理解](#12.3 我对库原理的最终理解)
  • [高频技术题 / 面试题](#高频技术题 / 面试题)
    • [1. 静态库和动态库的区别是什么?](#1. 静态库和动态库的区别是什么?)
    • [2. 为什么动态库在当前目录下,运行时还是 not found?](#2. 为什么动态库在当前目录下,运行时还是 not found?)
    • [3. `-I`、`-L`、`-l` 分别是什么意思?](#3. -I-L-l 分别是什么意思?)
    • [4. `.o` 文件是什么?为什么它还不能直接运行?](#4. .o 文件是什么?为什么它还不能直接运行?)
    • [5. 什么是 ELF?](#5. 什么是 ELF?)
    • [6. Section 和 Segment 有什么区别?](#6. Section 和 Segment 有什么区别?)
    • [7. 什么是重定位?](#7. 什么是重定位?)
    • [8. 为什么动态库需要 -fPIC?](#8. 为什么动态库需要 -fPIC?)
    • [9. GOT 和 PLT 分别解决什么问题?](#9. GOT 和 PLT 分别解决什么问题?)
    • [10. 静态链接和动态链接的本质区别是什么?](#10. 静态链接和动态链接的本质区别是什么?)
  • 结语

引言

刚开始学 Linux 的时候,我对"库"的理解其实非常浅:我只知道写 C 语言要 #include <stdio.h>,编译的时候可能要加 -lm-lpthread 之类的选项,但我一直没想过一个问题:库到底是什么?它为什么能被我的程序调用?静态库和动态库到底差在哪里?

以前我以为库就是"别人写好的代码",这个说法当然没错,但后来越学越发现,库背后其实牵扯到了非常多底层知识:编译、链接、ELF 格式、符号表、重定位、虚拟地址空间、动态加载、GOT、PLT、PIC......这些概念单独看都挺抽象,但如果从"一个程序如何调用库函数"这条线串起来,就会清晰很多。

这篇博客就是我学习 Linux 库制作与链接加载原理时整理出来的一篇学习总结。重点不是单纯记命令,而是把这些问题想明白:

  • 静态库和动态库到底是什么?
  • .a.so 是怎么制作出来的?
  • gcc -I -L -l 这些参数到底在干什么?
  • 为什么动态库明明在当前目录下,运行时还会 not found
  • .o、可执行程序、.so 为什么都和 ELF 有关系?
  • 程序运行前,库函数地址是怎么被修正的?
  • GOT、PLT、PIC 这些看起来很"底层"的东西到底解决了什么问题?

💡 我刚开始理解库的时候,就像只会去食堂买饭,但不知道后厨怎么备菜、怎么分工、怎么出餐。后来学到编译链接后才发现,库就像后厨提前准备好的"半成品模块",程序运行时能不能顺利吃到这顿饭,取决于编译器、链接器、加载器和操作系统之间的一整套配合。


1. 什么是库:不是神秘工具,本质就是可复用代码

1.1 我对库的第一层理解

库可以先简单理解成:写好的、成熟的、可以复用的代码集合

比如我们平时写 C 语言的时候,经常使用:

c 复制代码
printf("hello world\n");
strlen("abcdef");
malloc(1024);

这些函数并不是我们自己实现的,而是来自 C 标准库。我们只需要包含头文件,然后在编译链接阶段让程序找到对应的实现即可。

但是继续往下想,会出现一个更底层的问题:这些函数的机器码到底在哪里?

答案就是:在库里面。

Linux 下常见的库主要有两类:

类型 Linux 后缀 Windows 后缀 特点
静态库 .a .lib 编译链接时拷贝进可执行程序
动态库 .so .dll 程序运行时加载,共享使用

💡 库有点像大学里大家都会用的公共实验设备。静态库像是你把设备买回宿舍自己用,方便但占空间;动态库像是学校实验室统一放一台,大家都去共享使用,省空间但运行时必须能找到它。

1.2 在系统里观察真实的库

Linux 系统中本身就有大量库,例如 C 标准库、C++ 标准库。可以用 ls 去查看:

bash 复制代码
# Ubuntu 下查看 C 动态库
ls -l /lib/x86_64-linux-gnu/libc-2.31.so

# Ubuntu 下查看 C 静态库
ls -l /lib/x86_64-linux-gnu/libc.a

# C++ 动态库
ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -l

# C++ 静态库
ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a

这里有一个很重要的命名规律:

bash 复制代码
libc.so      -> 使用时写 -lc
libmystdio.a -> 使用时写 -lmystdio
libpthread.so -> 使用时写 -lpthread

也就是说,-l 后面写的库名要去掉:

  • 前缀 lib
  • 后缀 .so.a

所以:

bash 复制代码
-lmystdio

实际找的是:

bash 复制代码
libmystdio.so
libmystdio.a

这个规则非常重要,很多刚学 Linux 编译的人一开始都会写错。


2. 自己封装一个简单的库:从代码开始理解库

2.1 为什么要先自己写一份库代码

如果只是背静态库和动态库的定义,其实很容易忘。但如果我们自己写一组函数,再把它打包成库,就会明显感觉到:库不是虚的,它就是一堆 .o 文件按照一定规则组织起来。

这里我用一个简单的 my_stdiomy_string 来模拟一部分标准库能力。

2.2 头文件 my_stdio.h

c 复制代码
#pragma once

#define SIZE 1024
#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2

struct IO_FILE
{
    int flag;              // 刷新方式
    int fileno;            // 文件描述符
    char outbuffer[SIZE];  // 用户级缓冲区
    int cap;               // 缓冲区容量
    int size;              // 当前缓冲区已有数据大小
};

typedef struct IO_FILE mFILE;

mFILE *mfopen(const char *filename, const char *mode);
int mfwrite(const void *ptr, int num, mFILE *stream);
void mfflush(mFILE *stream);
void mfclose(mFILE *stream);

这里我一开始最容易忽略的是:mFILE 里面并不是直接保存"文件本身",而是保存了一个 fileno

这个 fileno 本质上就是文件描述符,它是用户层访问内核打开文件表的入口。

💡 文件描述符就像酒店房间号。房间号本身不是房间,但你拿着房间号就能找到对应的房间。fileno 也一样,它不是文件本身,而是进程访问文件资源的索引。

2.3 实现 my_stdio.c

c 复制代码
#include "my_stdio.h"
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>

mFILE *mfopen(const char *filename, const char *mode)
{
    int fd = -1;

    if(strcmp(mode, "r") == 0)
    {
        fd = open(filename, O_RDONLY);
    }
    else if(strcmp(mode, "w") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
    }
    else if(strcmp(mode, "a") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
    }

    if(fd < 0) return NULL;

    mFILE *mf = (mFILE*)malloc(sizeof(mFILE));
    if(!mf)
    {
        close(fd);
        return NULL;
    }

    mf->fileno = fd;
    mf->flag = FLUSH_LINE;
    mf->size = 0;
    mf->cap = SIZE;

    return mf;
}

void mfflush(mFILE *stream)
{
    if(stream->size > 0)
    {
        // 写到内核文件缓冲区中
        write(stream->fileno, stream->outbuffer, stream->size);

        // 尝试刷新到外设
        fsync(stream->fileno);

        stream->size = 0;
    }
}

int mfwrite(const void *ptr, int num, mFILE *stream)
{
    // 1. 拷贝到用户级缓冲区
    memcpy(stream->outbuffer + stream->size, ptr, num);
    stream->size += num;

    // 2. 行刷新:如果最后一个字符是 '\n',就刷新
    if(stream->flag == FLUSH_LINE &&
       stream->size > 0 &&
       stream->outbuffer[stream->size - 1] == '\n')
    {
        mfflush(stream);
    }

    return num;
}

void mfclose(mFILE *stream)
{
    if(stream->size > 0)
    {
        mfflush(stream);
    }

    close(stream->fileno);
}

2.4 这段代码背后的 Linux 原理

这段代码看起来像是在模仿 fopenfwritefflushfclose,但它背后其实刚好能串起 Linux 基础 IO 的很多知识。

mfopen 里面真正打开文件的是:

c 复制代码
open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);

这里几个参数要理解:

  • O_CREAT:文件不存在就创建。
  • O_WRONLY:只写方式打开。
  • O_TRUNC:打开时清空原文件内容。
  • 0666:文件默认权限,最终还会受到 umask 影响。

mfwrite 并没有直接调用 write 写入磁盘,而是先写入 outbuffer

这就是缓冲区思想。

💡 缓冲区就像我去超市买水。如果我每喝一口水就跑一趟超市,效率非常低;更合理的做法是一次买一箱水放宿舍,想喝的时候直接拿。用户级缓冲区就是这箱水,减少频繁系统调用带来的开销。

mfflush 里面做了两件事:

c 复制代码
write(stream->fileno, stream->outbuffer, stream->size);
fsync(stream->fileno);

write 是把数据写到内核中,fsync 尝试把数据进一步同步到外设。

这里需要注意一个细节:真实的标准库会比这个复杂很多,比如会处理:

  • 缓冲区溢出
  • 部分写入
  • 错误码
  • 不同刷新策略
  • 多线程安全
  • free(stream)

所以这份代码更适合理解原理,而不是直接当工业代码使用。

2.5 字符串函数 my_string

c 复制代码
// my_string.h
#pragma once

int my_strlen(const char *s);
c 复制代码
// my_string.c
#include "my_string.h"

int my_strlen(const char *s)
{
    const char *end = s;
    while(*end != '\0') end++;
    return end - s;
}

这个 my_strlen 的实现很经典:让一个指针从字符串开头一直走到 '\0',最后两个指针相减,得到字符串长度。

这个函数未来也会被打包进我们的库中。


3. 静态库:把代码"拷贝"进可执行程序

3.1 静态库的核心理解

静态库的后缀是 .a

它的特点是:程序在编译链接时,会把静态库中需要的代码链接到最终可执行程序里。程序运行时,不再依赖这个静态库文件。

所以静态库的优点是:

  • 发布简单,可执行程序依赖少。
  • 运行时不需要再查找 .a 文件。
  • 部署环境比较干净。

缺点也很明显:

  • 可执行程序体积可能变大。
  • 多个程序使用同一份库代码时,每个程序都要带一份,浪费磁盘和内存。
  • 库升级后,需要重新链接生成可执行程序。

💡 静态库就像考试前把资料全部打印出来塞进书包。考试时你不需要联网,也不需要再找资料,但代价是书包会变重,而且资料更新了你还得重新打印。

3.2 制作静态库 Makefile

makefile 复制代码
libmystdio.a: my_stdio.o my_string.o
	@ar -rc $@ $^
	@echo "build $^ to $@ ... done"

%.o: %.c
	@gcc -c $<
	@echo "compiling $< to $@ ... done"

.PHONY: clean
clean:
	@rm -rf *.a *.o stdc*
	@echo "clean ... done"

.PHONY: output
output:
	@mkdir -p stdc/include
	@mkdir -p stdc/lib
	@cp -f *.h stdc/include
	@cp -f *.a stdc/lib
	@tar -czf stdc.tgz stdc
	@echo "output stdc ... done"

这里最关键的是:

makefile 复制代码
ar -rc libmystdio.a my_stdio.o my_string.o

ar 是 GNU 的归档工具,静态库本质上可以理解成多个 .o 文件的打包集合。

参数含义:

  • r:replace,如果库中已有同名目标文件,就替换。
  • c:create,如果库不存在,就创建。

可以查看静态库里面到底打包了什么:

bash 复制代码
ar -tv libmystdio.a

输出类似:

bash 复制代码
rw-rw-r-- 1000/1000 2848 Oct 29 14:35 2024 my_stdio.o
rw-rw-r-- 1000/1000 1272 Oct 29 14:35 2024 my_string.o

其中:

  • t:列出库中的文件。
  • v:显示详细信息。

3.3 使用静态库

假设我们写一个测试程序:

c 复制代码
#include "my_stdio.h"
#include "my_string.h"
#include <stdio.h>

int main()
{
    const char *s = "abcdefg";

    printf("%s: %d\n", s, my_strlen(s));

    mFILE *fp = mfopen("./log.txt", "a");
    if(fp == NULL) return 1;

    mfwrite(s, my_strlen(s), fp);
    mfwrite(s, my_strlen(s), fp);
    mfwrite(s, my_strlen(s), fp);

    mfclose(fp);
    return 0;
}

如果头文件和库文件都在当前目录下,可以这样编译:

bash 复制代码
gcc main.c -I. -L. -lmystdio

三个参数分别是:

bash 复制代码
-I.        # 指定头文件搜索路径
-L.        # 指定库文件搜索路径
-lmystdio  # 指定链接 libmystdio.a 或 libmystdio.so

如果只想强制使用静态链接,可以加:

bash 复制代码
gcc main.c -I. -L. -lmystdio -static

不过实际使用时要注意,-static 会让程序尽可能使用静态库,最终可执行程序会明显变大,而且有些系统环境可能缺少对应的静态库。

3.4 静态库容易踩的坑

第一个坑:库名写错。

bash 复制代码
gcc main.c -L. -llibmystdio

这是错误的,因为 -l 后面不需要写 lib 前缀,也不需要写 .a 后缀。

正确写法:

bash 复制代码
gcc main.c -L. -lmystdio

第二个坑:链接顺序问题。

一般来说,被依赖的库要放在后面:

bash 复制代码
gcc main.c -L. -lmystdio

因为链接器通常是从左到右解析符号的。

第三个坑:只包含头文件不代表链接成功。

c 复制代码
#include "my_stdio.h"

这只是让编译器知道函数声明。真正的函数实现还要在链接阶段找到。

💡 头文件像菜单,库文件像后厨。你拿到菜单只能知道有哪些菜,但真正能不能吃到,还要看后厨有没有准备好。


4. 动态库:运行时再加载,共享更灵活

4.1 动态库的核心理解

动态库的后缀是 .so

它的特点是:程序在运行时才去链接动态库中的代码,多个程序可以共享同一份库代码。

动态库的优点:

  • 可执行程序体积更小。
  • 多个进程可以共享同一份库代码。
  • 库升级更灵活。
  • 更适合大型系统。

缺点:

  • 运行时必须能找到 .so
  • 加载和重定位会带来一定开销。
  • 部署时要处理库搜索路径问题。

💡 动态库像共享单车。大家不需要每个人都买一辆车,只要城市里有车,扫码就能用。但前提是:你得能找到车。如果找不到,就会出现运行时报错。

4.2 制作动态库 Makefile

makefile 复制代码
libmystdio.so: my_stdio.o my_string.o
	gcc -o $@ $^ -shared

%.o: %.c
	gcc -fPIC -c $<

.PHONY: clean
clean:
	@rm -rf *.so *.o stdc*
	@echo "clean ... done"

.PHONY: output
output:
	@mkdir -p stdc/include
	@mkdir -p stdc/lib
	@cp -f *.h stdc/include
	@cp -f *.so stdc/lib
	@tar -czf stdc.tgz stdc
	@echo "output stdc ... done"

这里有两个重点参数:

bash 复制代码
-shared

表示生成共享库格式。

bash 复制代码
-fPIC

表示生成位置无关码,也就是 Position Independent Code。

动态库的命名规则一般是:

bash 复制代码
libxxx.so

例如:

bash 复制代码
libmystdio.so

使用时写:

bash 复制代码
-lmystdio

4.3 为什么动态库要用 -fPIC

这个参数刚开始看很抽象,但它解决的是一个非常实际的问题:动态库可能被加载到不同进程地址空间中的不同位置。

如果动态库内部写死了绝对地址,那么它只能放在固定位置,一旦换个加载地址,代码就可能失效。

-fPIC 生成的位置无关代码,尽量通过相对寻址、GOT 等机制,让动态库无论被映射到哪里,都能正常运行。

💡 -fPIC 就像一个人不靠"固定门牌号"找地方,而是靠"我当前位置往东走 100 米"这种相对路线。这样即使起点变了,只要相对关系不变,依然能找到目标。


5. 动态库的使用与查找路径

5.1 编译时能找到,不代表运行时能找到

使用动态库时,编译可以这样:

bash 复制代码
gcc main.c -I. -L. -lmystdio

这一步如果成功,只能说明:链接器在编译链接阶段找到了库。

但是运行时还可能报错:

bash 复制代码
./a.out: error while loading shared libraries: libmystdio.so: cannot open shared object file: No such file or directory

ldd 查看:

bash 复制代码
ldd a.out

可能看到:

bash 复制代码
linux-vdso.so.1 =>  (0x00007fff4d396000)
libmystdio.so => not found
libc.so.6 => /lib64/libc.so.6 (0x00007fa2aef30000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa2af2fe000)

这里最容易误解的一点是:动态库就在当前目录下,为什么还找不到?

原因是:运行时动态链接器默认不会随便搜索当前目录。编译时的 -L. 只影响链接阶段,不一定影响运行阶段。

5.2 解决动态库 not found 的几种方式

5.2.1 放到系统库路径下

可以把 .so 拷贝到系统共享库路径,例如:

bash 复制代码
/usr/lib
/usr/local/lib
/lib64

不过这种方式需要权限,而且不太适合随便测试。

5.2.2 使用 LD_LIBRARY_PATH

临时指定动态库搜索路径:

bash 复制代码
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.
./a.out

也可以指定绝对路径:

bash 复制代码
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/user/mylib

这种方式适合调试和学习。

5.2.3 使用 ldconfig

创建配置文件:

bash 复制代码
sudo vim /etc/ld.so.conf.d/mystdio.conf

写入动态库所在路径:

bash 复制代码
/home/user/mylib

然后执行:

bash 复制代码
sudo ldconfig

ldconfig 会重新加载动态库缓存。

5.2.4 使用 rpath

编译时把运行库路径写进可执行程序:

bash 复制代码
gcc main.c -I. -L. -lmystdio -Wl,-rpath=.

这种方式也很常见,尤其适合项目发布时指定相对路径。

5.3 编译期搜索路径和运行期搜索路径的区别

这个地方非常适合总结成一句话:

-L 解决的是链接时找库,LD_LIBRARY_PATH / ldconfig / rpath 解决的是运行时找库。

💡 编译阶段像你写论文时引用了一本书,说明你知道这本书存在;运行阶段像你真正要打开这本书复习,如果书不在你能找到的地方,还是会失败。


6. 使用外部库 ncurses:体验链接第三方库

6.1 安装 ncurses

Linux 里有很多第三方库,例如 ncurses 可以用来在终端里做字符界面。

CentOS:

bash 复制代码
sudo yum install -y ncurses-devel

Ubuntu:

bash 复制代码
sudo apt install -y libncurses-dev

6.2 一个进度条小程序

c 复制代码
#include <stdio.h>
#include <string.h>
#include <ncurses.h>
#include <unistd.h>

#define PROGRESS_BAR_WIDTH 30
#define BORDER_PADDING 2
#define WINDOW_WIDTH (PROGRESS_BAR_WIDTH + 2 * BORDER_PADDING + 2)
#define WINDOW_HEIGHT 5
#define PROGRESS_INCREMENT 3
#define DELAY 300000

int main()
{
    initscr();
    start_color();

    init_pair(1, COLOR_GREEN, COLOR_BLACK);
    init_pair(2, COLOR_RED, COLOR_BLACK);

    cbreak();
    noecho();
    curs_set(FALSE);

    int max_y, max_x;
    getmaxyx(stdscr, max_y, max_x);

    int start_y = (max_y - WINDOW_HEIGHT) / 2;
    int start_x = (max_x - WINDOW_WIDTH) / 2;

    WINDOW *win = newwin(WINDOW_HEIGHT, WINDOW_WIDTH, start_y, start_x);
    box(win, 0, 0);
    wrefresh(win);

    int progress = 0;
    int max_progress = PROGRESS_BAR_WIDTH;

    while(progress <= max_progress)
    {
        werase(win);

        int completed = progress;
        int remaining = max_progress - progress;

        int bar_x = BORDER_PADDING + 1;
        int bar_y = 1;

        attron(COLOR_PAIR(1));
        for(int i = 0; i < completed; i++)
        {
            mvwprintw(win, bar_y, bar_x + i, "#");
        }
        attroff(COLOR_PAIR(1));

        attron(A_BOLD | COLOR_PAIR(2));
        for(int i = completed; i < max_progress; i++)
        {
            mvwprintw(win, bar_y, bar_x + i, " ");
        }
        attroff(A_BOLD | COLOR_PAIR(2));

        char percent_str[10];
        snprintf(percent_str, sizeof(percent_str), "%d%%",
                 (progress * 100) / max_progress);

        int percent_x = (WINDOW_WIDTH - strlen(percent_str)) / 2;
        mvwprintw(win, WINDOW_HEIGHT - 1, percent_x, percent_str);

        wrefresh(win);

        progress += PROGRESS_INCREMENT;
        usleep(DELAY);
    }

    delwin(win);
    endwin();

    return 0;
}

编译时要链接 ncurses:

bash 复制代码
gcc progress.c -o progress -lncurses

如果忘记 -lncurses,就很可能出现 undefined reference 错误。

6.3 代码拆解

c 复制代码
initscr();

初始化 ncurses 模式。

c 复制代码
start_color();
init_pair(1, COLOR_GREEN, COLOR_BLACK);

启用颜色,并设置颜色对。

c 复制代码
WINDOW *win = newwin(WINDOW_HEIGHT, WINDOW_WIDTH, start_y, start_x);

创建一个窗口。

c 复制代码
mvwprintw(win, bar_y, bar_x + i, "#");

在窗口指定位置输出字符。

c 复制代码
wrefresh(win);

刷新窗口,把内容显示出来。

c 复制代码
endwin();

退出 ncurses 模式,恢复终端状态。

这个例子让我更直观地感受到:库就是一组已经封装好的能力,我们通过头文件知道怎么调用,通过链接选项找到具体实现。


7. 目标文件:.o 不是中间垃圾,而是链接的关键

7.1 从源码到 .o

假设有两个源文件:

c 复制代码
// hello.c
#include <stdio.h>

void run();

int main()
{
    printf("hello world!\n");
    run();
    return 0;
}
c 复制代码
// code.c
#include <stdio.h>

void run()
{
    printf("running...\n");
}

分别编译:

bash 复制代码
gcc -c hello.c
gcc -c code.c
ls

会得到:

bash 复制代码
hello.o
code.o

.o 文件叫目标文件,它已经是二进制文件,但还不是最终可执行程序。

可以用 file 查看:

bash 复制代码
file hello.o

输出类似:

bash 复制代码
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

这里出现了一个关键词:ELF

7.2 为什么修改一个源文件只需要重新编译它

如果工程很大,有很多 .c 文件:

bash 复制代码
add.c
sub.c
mul.c
div.c
main.c

每个源文件可以先单独编译成 .o

bash 复制代码
add.o
sub.o
mul.o
div.o
main.o

如果只修改了 add.c,理论上只需要重新生成 add.o,然后重新链接即可,不需要把所有源文件都重新编译一遍。

这也是 Makefile 能提高编译效率的原因之一。

💡 .o 文件有点像组装电脑时已经装好的 CPU、内存、显卡模块。最终电脑还没装好,但每个模块已经可以参与最后组装了。链接器做的事情,就是把这些模块拼成一台能启动的机器。


8. ELF 文件:Linux 可执行程序的"身份证"

8.1 哪些东西是 ELF

ELF 是 Executable and Linkable Format,也就是可执行可链接格式。

常见的 ELF 文件包括:

  • .o:可重定位目标文件。
  • 可执行程序:例如 a.out
  • .so:共享目标文件,也就是动态库。
  • core dump:进程崩溃时保存的上下文信息。

所以 .o、可执行程序、动态库其实都属于 ELF 体系。

8.2 ELF 的核心组成

一个 ELF 文件主要由几部分组成:

组成部分 作用
ELF Header 描述 ELF 基本信息,并定位其他部分
Program Header Table 描述运行加载视角下的 segment
Section Header Table 描述链接视角下的 section
Section 保存不同类型的数据,如代码、数据、符号表等

常见 section:

section 作用
.text 保存机器指令
.data 保存已初始化的全局变量、静态变量
.bss 为未初始化的全局变量、静态变量预留空间
.rodata 保存只读数据,比如字符串常量
.symtab 符号表,保存函数名、变量名和地址关系
.got / .plt 动态链接相关结构

8.3 readelf 查看 ELF 头

查看目标文件:

bash 复制代码
readelf -h hello.o

可以看到类似信息:

bash 复制代码
ELF Header:
  Magic:   7f 45 4c 46 ...
  Class:                             ELF64
  Data:                              2's complement, little endian
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x0
  Start of program headers:          0
  Start of section headers:          728

几个字段非常重要:

  • Magic:ELF 魔数,用来识别这是 ELF 格式。
  • Class:32 位还是 64 位。
  • Data:大小端。
  • Type:文件类型,比如 REL 表示可重定位文件。
  • Entry point address:入口地址。目标文件还不能直接运行,所以通常是 0。
  • Start of section headers:节头表偏移。

查看可执行程序:

bash 复制代码
gcc *.o
readelf -h a.out

可能看到:

bash 复制代码
Type:                              DYN (Shared object file)
Entry point address:               0x1060
Start of program headers:          64
Start of section headers:          14768
Number of program headers:         13
Number of section headers:         31

这里说明可执行程序已经有入口地址,也有程序头表。操作系统加载程序时,就需要依赖这些信息。

8.4 Section 和 Segment 的区别

刚开始我很容易把 section 和 segment 混在一起。后来我发现可以这样理解:

  • Section 是链接视角:给链接器看的,粒度更细。
  • Segment 是执行视角:给操作系统加载器看的,粒度更适合加载。

比如很多 section:

bash 复制代码
.text
.rodata
.init
.plt

它们可能都具有只读、可执行等相似属性,加载时可以合并到同一个 segment。

为什么要合并?

因为内存管理通常以页为单位,比如 4KB。如果每个小 section 都单独加载,可能造成大量内存碎片。把相同权限的 section 合并成 segment,可以提高内存利用率,也方便设置访问权限。

💡 section 像快递仓库里一件件小包裹,分类很细;segment 像装车运输时的大箱子,会把目的地和属性相近的包裹合并装车。链接器关心小包裹怎么分类,操作系统更关心大箱子怎么装进内存。

查看 section:

bash 复制代码
readelf -S a.out

查看 segment:

bash 复制代码
readelf -l a.out

readelf -l 中还能看到:

bash 复制代码
Section to Segment mapping:
  Segment Sections...
   02 .interp .note.ABI-tag .gnu.hash .dynsym .dynstr .text .rodata
   03 .init_array .fini_array .dynamic .got .data .bss

这就说明,多个 section 最终会映射到 segment 中。


9. 静态链接:把彼此"不认识"的 .o 连接起来

9.1 .o 文件之间一开始并不认识

继续看前面的例子:

c 复制代码
// hello.c
#include <stdio.h>

void run();

int main()
{
    printf("hello world!\n");
    run();
    return 0;
}
c 复制代码
// code.c
#include <stdio.h>

void run()
{
    printf("running...\n");
}

分别编译:

bash 复制代码
gcc -c *.c

然后反汇编:

bash 复制代码
objdump -d hello.o

可以看到 main 里面调用 printfrun 的位置,call 指令附近可能是类似这样的形式:

bash 复制代码
e8 00 00 00 00    callq ...

为什么这里会有很多 00

因为在编译 hello.c 的时候,编译器只知道:

c 复制代码
void run();

它知道有一个 run 函数,但不知道 run 的实现最终会放在哪个地址。

同理,printf 的真正实现也不在 hello.c 里面。

所以编译器只能先留下一个"待修正位置"。

9.2 链接器负责修正地址

最终执行:

bash 复制代码
gcc hello.o code.o -o main.exe

链接器会做几件事:

  1. 合并多个 .o 的 section。
  2. 解析符号表。
  3. 找到外部符号对应的定义。
  4. 根据重定位表修正指令中的地址。
  5. 生成最终可执行程序。

这里的关键是:重定位。

比如 hello.o 中有一个对 run 的调用,但地址暂时不知道。链接器发现 run 定义在 code.o 中,就会把对应 call 指令的地址修正为正确位置。

💡 重定位就像拼图。一开始每块拼图背面只写着"这里将来要接 A 区域",但具体位置还没定。等所有拼图都摆上桌,整体布局确定后,才知道每一块到底该接到哪里。

9.3 符号表的作用

可以查看符号表:

bash 复制代码
readelf -s code.o

符号表里会记录函数、变量等符号信息。

比如:

bash 复制代码
run
printf
main

其中有些符号是本文件定义的,有些符号是未定义的,需要链接时去其他目标文件或库中找。

这也是为什么链接错误经常会出现:

bash 复制代码
undefined reference to `xxx'

意思就是:编译器已经通过了,但链接器找不到 xxx 的实现。


10. ELF 加载与进程地址空间

10.1 ELF 没运行之前有没有地址

这个问题刚开始我觉得很奇怪:程序还没加载到内存里,怎么会有地址?

后来理解到:ELF 在形成时,里面已经记录了未来运行时的虚拟地址布局。

可以用:

bash 复制代码
objdump -S a.out

查看反汇编结果,最左边通常就是地址。

从严格角度讲,这些地址更接近逻辑地址或虚拟地址。程序还没真正放进物理内存,但它已经有了自己的统一编址方案。

10.2 进程虚拟地址空间从哪里来

当程序被加载成进程时,操作系统需要给它建立虚拟地址空间。

进程地址空间里的很多区域,比如:

  • 代码区
  • 数据区
  • 共享库映射区

都不是凭空来的。

ELF 中的 program header 会告诉操作系统:

  • 哪些 segment 需要加载。
  • 加载到哪个虚拟地址范围。
  • 这段内存是可读、可写还是可执行。
  • 文件中的偏移是多少。
  • 占用多大空间。

Linux 内核就可以根据这些信息初始化 mm_structvm_area_struct 等结构,并建立虚拟地址范围。

💡 ELF 像一张"装修图纸",进程地址空间像真正装修出来的房子。房子还没建之前,图纸上已经规划好了卧室、厨房、客厅的位置。操作系统加载 ELF 的过程,就像按照图纸把房子的空间划分出来。

10.3 程序头表为什么重要

对操作系统来说,真正加载可执行程序时主要关心 program header table。

因为它描述的是 segment,而 segment 才是适合加载到内存的单位。

所以可以简单总结:

  • Section Header Table:链接器更关心。
  • Program Header Table:加载器和操作系统更关心。

11. 动态链接:把链接推迟到运行时

11.1 动态库如何进入进程地址空间

动态库也是文件,也要被打开、读取、映射。

程序运行时,动态链接器会把需要的 .so 映射到当前进程的虚拟地址空间中。

也就是说,动态库代码最终也在进程地址空间里,只不过通常位于共享库映射区域。

当程序调用库函数时,本质上就是从当前代码区跳转到共享库映射区域,执行完后再返回。

💡 动态库不是"远程调用"。它不是程序运行到一半去磁盘上临时翻代码,而是先把库映射进自己的地址空间。调用库函数时,就像在同一栋教学楼里从一个教室走到另一个教室。

11.2 动态库为什么能被多个进程共享

动态库的代码段通常是只读的。

既然只读,就意味着多个进程可以映射同一份物理内存页,而不需要每个进程都复制一份。

但每个进程的虚拟地址空间是独立的,所以不同进程看到的动态库虚拟地址可能不同。

这就引出一个问题:如果动态库加载地址不同,代码怎么还能正常运行?

答案就是:位置无关代码 PIC。

11.3 PIC、GOT 和相对寻址

动态库使用 -fPIC 生成位置无关代码。

PIC 的核心思路是:

  • 尽量不在代码中写死绝对地址。
  • 通过相对寻址找到 GOT。
  • GOT 中保存运行时修正后的真实地址。
  • 调用函数时先查表,再跳转。

GOT,全称 Global Offset Table,全局偏移表。

它的作用可以理解成:保存外部符号真实地址的一张表。

由于不同进程中动态库加载位置可能不同,所以每个进程都需要自己的 GOT 表。代码段可以共享,但 GOT 这种可修改数据不能随便共享。

💡 GOT 就像手机通讯录。你不需要记住每个人现在在哪里,只需要查通讯录里的号码。号码可能会更新,但你打电话的方式不变。

11.4 PLT:延迟绑定,第一次用到再解析

动态链接如果在程序一启动时就解析所有函数地址,可能很浪费。

因为很多动态库函数,程序运行过程中可能根本用不到。

所以系统引入了 PLT,也就是 Procedure Linkage Table,过程链接表。

PLT 的思想是:第一次调用函数时再真正解析地址,之后就直接跳转。

大致流程:

  1. 第一次调用外部函数。
  2. 先进入 PLT 桩代码。
  3. 动态链接器查找真实函数地址。
  4. 更新 GOT 表。
  5. 后续再次调用时,直接通过 GOT 跳转到真实函数。

这叫延迟绑定。

💡 PLT 像第一次去一个教室上课时需要问路,问到之后把路线记下来。以后再去同一个教室,就不用再问了,直接走过去。


12. 从编译到运行的完整流程总结

12.1 静态链接流程

静态链接可以总结成:

bash 复制代码
.c -> .o -> .a -> 可执行程序

核心动作:

  1. 编译器把 .c 编译成 .o
  2. ar 把多个 .o 打包成 .a
  3. 链接器把 .o.a 中需要的代码合并进可执行程序。
  4. 程序运行时不再依赖 .a

12.2 动态链接流程

动态链接可以总结成:

bash 复制代码
.c -> .o -> .so -> 可执行程序记录动态依赖 -> 运行时加载 .so

核心动作:

  1. 编译器用 -fPIC 生成位置无关目标文件。
  2. 链接器用 -shared 生成 .so
  3. 可执行程序记录对动态库的依赖。
  4. 运行时动态链接器查找 .so
  5. .so 被映射到进程地址空间。
  6. 通过 GOT/PLT 完成函数调用。

12.3 我对库原理的最终理解

学完这一块之后,我对"库"的理解从"别人写好的代码"变成了:

库是一种可复用的二进制代码组织形式,它通过编译器、链接器、加载器和操作系统的配合,最终参与到程序的运行过程中。

静态库更偏向"提前合并",动态库更偏向"运行时共享"。

这也是 Linux 系统设计里很典型的一种思想:
用更复杂的底层机制,换来更灵活、更高效的上层使用方式。


高频技术题 / 面试题

1. 静态库和动态库的区别是什么?

静态库 .a 是在编译链接阶段被链接进可执行程序的。程序运行时不再依赖静态库本身。

动态库 .so 是在程序运行时被加载进进程地址空间的。多个程序可以共享同一份动态库代码。

静态库优点是部署简单,运行时依赖少;缺点是可执行程序体积大,库升级需要重新链接。动态库优点是节省空间、方便升级、支持共享;缺点是运行时必须能找到对应 .so,并且加载和动态链接有一定成本。

2. 为什么动态库在当前目录下,运行时还是 not found?

因为编译时的 -L. 只告诉链接器去当前目录找库,它解决的是链接阶段问题。

程序运行时由动态链接器负责查找 .so,默认搜索路径通常不包括当前目录。所以即使 .so 在当前目录下,也可能运行失败。

常见解决方式有:

bash 复制代码
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.

或者配置:

bash 复制代码
/etc/ld.so.conf.d/
ldconfig

也可以使用:

bash 复制代码
-Wl,-rpath=.

3. -I-L-l 分别是什么意思?

-I 指定头文件搜索路径。

bash 复制代码
-I./include

-L 指定库文件搜索路径。

bash 复制代码
-L./lib

-l 指定要链接的库名。

bash 复制代码
-lmystdio

注意 -l 后面的名字要去掉 lib 前缀和 .a / .so 后缀。

例如:

bash 复制代码
libmystdio.so

使用时写:

bash 复制代码
-lmystdio

4. .o 文件是什么?为什么它还不能直接运行?

.o 文件是目标文件,已经包含机器指令,但它通常还没有完成符号解析和地址重定位。

比如 hello.o 里调用了 run 函数,但 run 的实现可能在 code.o 中。单个 .o 并不知道所有外部符号的最终地址,所以不能直接运行。

链接器会把多个 .o 和库连接起来,修正地址,最终生成可执行程序。

5. 什么是 ELF?

ELF 是 Linux 下常见的可执行可链接文件格式。

常见 ELF 文件包括:

  • .o 目标文件
  • 可执行程序
  • .so 动态库
  • core dump 文件

ELF 中有 ELF Header、Program Header Table、Section Header Table 和各种 section。链接器更关注 section,操作系统加载程序时更关注 segment。

6. Section 和 Segment 有什么区别?

Section 是链接视角,主要给链接器使用,比如 .text.data.bss.rodata.symtab

Segment 是执行视角,主要给操作系统加载器使用。多个属性相近的 section 会合并成 segment,方便加载进内存并设置权限。

简单说:

  • Section:链接时看。
  • Segment:运行加载时看。

7. 什么是重定位?

重定位就是链接器修正地址的过程。

编译单个源文件时,编译器可能不知道外部函数或变量的最终地址,只能先留下占位信息。链接时,链接器根据符号表和重定位表,把这些占位地址修正成正确地址。

所以如果链接器找不到某个符号实现,就会报:

bash 复制代码
undefined reference to `xxx'

8. 为什么动态库需要 -fPIC?

动态库会被映射到不同进程的虚拟地址空间中,而且加载地址不一定固定。

如果动态库代码依赖绝对地址,就会导致加载位置变化后无法正常运行。

-fPIC 会生成位置无关代码,让动态库通过相对寻址、GOT 等机制访问函数和变量,从而可以被加载到不同位置。

9. GOT 和 PLT 分别解决什么问题?

GOT 是全局偏移表,用来保存动态链接过程中外部符号的真实地址。

PLT 是过程链接表,用来实现函数调用的跳转入口,并支持延迟绑定。

第一次调用动态库函数时,可能先进入 PLT,由动态链接器解析真实地址并更新 GOT。以后再次调用,就可以直接通过 GOT 跳转到真实函数。

10. 静态链接和动态链接的本质区别是什么?

静态链接是在程序运行前,把库代码合并进可执行程序。

动态链接是把符号解析和地址绑定的一部分工作推迟到程序运行时完成。

所以动态链接牺牲了一点加载和调用复杂度,但换来了更小的程序体积、更好的共享能力和更灵活的库升级能力。


结语

学完 Linux 库制作与原理之后,我最大的感受是:很多我们平时随手敲的命令,背后其实都有完整的系统设计。

以前我看到:

bash 复制代码
gcc main.c -L. -lmystdio

只觉得这是一个编译命令。现在再看,它背后其实包含了头文件搜索、库文件搜索、符号解析、重定位、ELF 生成等一整套过程。

以前我看到:

bash 复制代码
libmystdio.so => not found

只觉得是路径问题。现在再看,它其实是在提醒我:编译期链接和运行期加载是两件事。

库这一块知识,表面上是在学 .a.so,但真正往下挖,其实是在学 Linux 程序从源码变成进程的整个过程。理解了库,编译链接、ELF、进程地址空间、动态加载这些知识也就不再是孤立的概念了。

相关推荐
luyu007_0076 小时前
鲁渝能源全功率无线充电为巡检机器人筑牢能源底座
人工智能·安全·机器人·能源
轻刀快马6 小时前
讲透分布式系统的演进史与核心架构
开发语言·架构·php
一切皆是因缘际会6 小时前
从概率拟合到内生心智:七层投影架构重构AGI数字生命新范式
大数据·数据结构·人工智能·重构·架构·agi
Jump 不二6 小时前
AI Agent Skill 系统架构全解析:SKILL 规范与框架实现
人工智能·语言模型·系统架构
元宵大师6 小时前
[题材&选股] 5.21长鑫产业链集体退潮,电力、面板接棒主升浪!QTYX-V3.4.8量化复盘
人工智能
梦想的颜色6 小时前
LangGraph实战指南:从零到一构建生产级多智能体系统
人工智能·claude code
ALINX技术博客6 小时前
【黑金云课堂】FPGA技术教程Linux开发:电压温度检测/USB/eMMC
linux·fpga开发
kels88996 小时前
加密货币实时api的订单簿快照多久更新一次?
开发语言·笔记·python·金融·区块链
Byte Wizard6 小时前
C语言数据在内存中的存储
c语言·开发语言