【Linux指南】动静态库系列(二):从源码复用到目标文件复用:为什么需要把 .o 打包成库

文章目录

    • 一、先写一个可以复用的小模块
    • 二、第一种复用方式:给源码和头文件
      • [1. 源码完全暴露](#1. 源码完全暴露)
      • [2. 文件越来越多后使用麻烦](#2. 文件越来越多后使用麻烦)
      • [3. 不利于模块独立维护](#3. 不利于模块独立维护)
    • [三、第二种复用方式:给 .o 和 .h](#三、第二种复用方式:给 .o 和 .h)
    • 四、头文件和目标文件分别负责什么
      • [1. 头文件负责告诉编译器"函数长什么样"](#1. 头文件负责告诉编译器“函数长什么样”)
      • [2. .o 文件负责提供真正的函数实现](#2. .o 文件负责提供真正的函数实现)
    • [五、只给 .o + .h 的优点](#五、只给 .o + .h 的优点)
      • [1. 可以隐藏源码实现](#1. 可以隐藏源码实现)
      • [2. 编译速度更快](#2. 编译速度更快)
      • [3. 接口和实现分离](#3. 接口和实现分离)
    • [六、但 .o 文件多了以后仍然麻烦](#六、但 .o 文件多了以后仍然麻烦)
    • [七、静态库的出现:把多个 .o 打成一个包](#七、静态库的出现:把多个 .o 打成一个包)
    • [八、为什么 .a 不需要手动解包](#八、为什么 .a 不需要手动解包)
    • 九、接口发布时应该给什么
    • [十、不要在 include 中写死路径](#十、不要在 include 中写死路径)
    • 十一、从源码到静态库的完整演进
    • 十二、总结

上一篇我们讲了库的本质:库是已经写好、成熟可复用的二进制代码。静态库和动态库虽然使用方式不同,但它们都离不开一个共同基础:目标文件 .o。

这篇文章不急着直接讲 ar -rc libxxx.a *.o。我们先从最朴素的代码复用方式开始,看一个库是如何从"发源码"一步步演进到"发 .o + .h",最终发展到"把多个 .o 打包成 .a"的。

一、先写一个可以复用的小模块

假设我们写了一个简单的字符串工具模块,提供两个函数:

  • my_strlen:计算字符串长度;
  • my_strcmp:比较两个字符串。

头文件 my_string.h:

c 复制代码
#pragma once

int my_strlen(const char *s);
int my_strcmp(const char *s1, const char *s2);

实现文件 my_string.c:

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

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

int my_strcmp(const char *s1, const char *s2)
{
    while (*s1 && *s2 && *s1 == *s2) {
        s1++;
        s2++;
    }
    return *s1 - *s2;
}

使用者写一个 main.c:

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

int main()
{
    const char *s1 = "hello";
    const char *s2 = "hello";

    printf("len=%d\n", my_strlen(s1));
    printf("cmp=%d\n", my_strcmp(s1, s2));
    return 0;
}

这时最直接的编译方式是:

bash 复制代码
gcc main.c my_string.c -o main
./main

这就是最原始的复用:把源码文件直接给别人。

二、第一种复用方式:给源码和头文件

如果别人想使用我们的字符串工具,我们可以把下面两个文件发给他:

text 复制代码
my_string.h
my_string.c

使用者把自己的 main.c 和 my_string.c 一起编译即可:

bash 复制代码
gcc main.c my_string.c -o main

这种方式简单直观,但问题也很明显。

1. 源码完全暴露

如果这是你写的一个商业库,或者你不希望别人直接看到内部实现,发 .c 文件就不合适。

头文件本来就是给别人看的,因为它描述"怎么调用"。但实现文件不一定要给别人看。

2. 文件越来越多后使用麻烦

真实项目不会只有一个 my_string.c,可能还有:

text 复制代码
my_stdio.c
my_math.c
my_log.c
my_time.c
my_net.c

使用者每次编译时都要把这些 .c 文件全部写进命令里:

bash 复制代码
gcc main.c my_string.c my_stdio.c my_math.c my_log.c my_time.c -o main

这很容易漏掉某个文件,导致链接失败。

3. 不利于模块独立维护

如果库开发者和库使用者是两拨人,使用者其实只关心:

text 复制代码
有哪些函数可以调用?
参数是什么?
返回值是什么?

至于函数内部怎么实现,不应该成为使用者必须关心的问题。

这就引出了第二种方式:只给头文件和目标文件。

三、第二种复用方式:给 .o 和 .h

.c 文件经过编译后,会生成 .o 目标文件:

bash 复制代码
gcc -c my_string.c -o my_string.o

这里的 -c 表示只编译,不链接。

生成的 my_string.o 已经包含了 my_strlen、my_strcmp 的机器码实现。此时我们可以只把下面两个文件给使用者:

text 复制代码
my_string.h
my_string.o

使用者编译自己的代码:

bash 复制代码
gcc -c main.c -o main.o

再把自己的目标文件和我们的目标文件链接起来:

bash 复制代码
gcc main.o my_string.o -o main

这样程序照样能运行。

四、头文件和目标文件分别负责什么

这一步非常关键。很多链接错误,本质上都是没分清头文件和库文件的职责。

1. 头文件负责告诉编译器"函数长什么样"

当 main.c 中写:

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

编译器能看到:

c 复制代码
int my_strlen(const char *s);

于是编译器知道:

text 复制代码
my_strlen 是一个函数;
它接收 const char *;
它返回 int。

所以 main.c 可以通过编译。

但是,头文件里只有声明,没有函数实现。

2. .o 文件负责提供真正的函数实现

my_string.o 中才有 my_strlen 的机器码。

链接阶段,链接器会发现:

text 复制代码
main.o 里引用了 my_strlen;
my_string.o 里定义了 my_strlen;
两者可以匹配。

于是最终可执行程序就能生成。

可以总结成一句话:

text 复制代码
.h 解决编译阶段"能不能认识这个函数"的问题;
.o/.a/.so 解决链接阶段"能不能找到这个函数实现"的问题。

五、只给 .o + .h 的优点

相比直接发源码,只发 .o + .h 有几个好处。

1. 可以隐藏源码实现

使用者只能看到头文件里的接口,看不到 .c 里的具体实现。

2. 编译速度更快

库开发者提前把库源码编译成 .o。使用者不需要再编译库源码,只需要链接 .o。

3. 接口和实现分离

使用者只依赖头文件描述的接口,库开发者可以在不改变接口的情况下优化内部实现。

六、但 .o 文件多了以后仍然麻烦

如果一个库只有一个 .o 文件,直接给 .o + .h 还可以接受。

但真实库通常包含多个模块:

text 复制代码
my_stdio.o
my_string.o
my_math.o
my_log.o
my_time.o

使用者链接时就要写:

bash 复制代码
gcc main.o my_stdio.o my_string.o my_math.o my_log.o my_time.o -o main

这带来三个问题:

  1. 命令太长。
  2. 容易漏掉某个目标文件。
  3. 发布和传输不方便。

于是就需要一个"打包工具",把多个 .o 组织成一个文件。

这就是静态库 .a。

七、静态库的出现:把多个 .o 打成一个包

静态库的本质可以非常直白地理解:

text 复制代码
静态库 = 多个 .o 文件的归档包

例如:

bash 复制代码
ar -rc libmyc.a my_stdio.o my_string.o my_math.o

这条命令会把多个目标文件归档到 libmyc.a 中。

之后使用者不需要关心里面有多少 .o,只需要链接这个库:

bash 复制代码
gcc main.c -L. -lmyc -o main

这里的 -lmyc 会让链接器寻找:

text 复制代码
libmyc.a

也就是自动补上:

text 复制代码
lib + myc + .a

这就是为什么库文件命名要遵守 libxxx.a 的规则。

八、为什么 .a 不需要手动解包

.a 是归档文件,但它不是普通压缩包。使用者不需要先把它解开,再一个个链接里面的 .o。

链接器认识 .a 格式。它会自动扫描静态库,找到程序需要的目标文件,并把相关代码合并进最终可执行程序。

例如:

text 复制代码
libmyc.a 中有:
my_stdio.o
my_string.o
my_math.o

如果你的 main.c 只调用了 my_strlen,链接器会从库中找到包含 my_strlen 的目标文件,并把需要的部分纳入最终程序。

所以,静态库的价值不只是"少传几个文件",而是把一组目标文件变成一个可被链接器直接管理的整体。

九、接口发布时应该给什么

如果你要把一个库发布给别人,最少需要给两类文件:

text 复制代码
头文件:告诉别人怎么调用
库文件:提供真正实现

典型目录结构可以这样组织:

text 复制代码
myc/
├── include/
│   ├── my_stdio.h
│   └── my_string.h
└── lib/
    └── libmyc.a

使用者编译时写:

bash 复制代码
gcc main.c -I./myc/include -L./myc/lib -lmyc -o main

这里三个参数分别是:

text 复制代码
-I:告诉编译器去哪里找头文件
-L:告诉链接器去哪里找库文件
-l:告诉链接器要链接哪个库

这三个参数会在后续文章反复出现。

十、不要在 include 中写死路径

有些初学者可能会这样写:

c 复制代码
#include "./myc/include/my_string.h"

这样虽然可能暂时能编译,但不推荐。

原因是:

  1. 源码和目录结构强绑定,可移植性差。
  2. 换一个项目路径就可能失效。
  3. 正规做法应该通过 -I 指定头文件搜索路径。

推荐写法是:

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

编译时指定:

bash 复制代码
gcc main.c -I./myc/include -L./myc/lib -lmyc -o main

这样项目结构更清晰,也更符合工程习惯。

十一、从源码到静态库的完整演进

我们把这篇文章的演进路线总结一下:

text 复制代码
阶段 1:给 .c + .h
优点:简单
缺点:暴露源码,文件多

阶段 2:给 .o + .h
优点:隐藏源码,使用者不必编译库源码
缺点:.o 多了以后链接麻烦

阶段 3:给 .a + .h
优点:多个 .o 打成一个库,方便发布和链接
缺点:库更新后程序需要重新链接

这就是静态库出现的真实背景。

十二、总结

.o 是理解库的关键。源文件先被编译成目标文件,目标文件再被链接成可执行程序或库文件。

头文件负责声明接口,目标文件负责提供实现。当目标文件越来越多时,就需要用 ar 把多个 .o 打包成静态库 .a。

下一篇,我们正式进入静态库制作实战,手写 libmyc.a,并用 Makefile 完成从编译、归档到打包发布的完整流程。

相关推荐
@兽血沸腾16 分钟前
Windows系统安装Jenkins部署应用至远程Windows服务器操作指南
运维·服务器·jenkins
且从容.3 小时前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道4 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
讯捷蓝达6 小时前
戴尔R720xd服务器卡死在 “Configuring Memory...Done“,换了内存还是卡?问题根本不在内存
运维·服务器
且从容.7 小时前
ubuntu安装flink
linux·ubuntu·flink
数字新视界8 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
爱和冰阔落8 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨8 小时前
WSL2的安装与使用
linux·wsl
微小的鱼8 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng9 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器