【Linux】库制作与原理:从源码到静态库,搞懂 ar、-I/-L/-l 与打包交付

封面

🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》C++
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

在这里插入图片描述


🏠博主简介
在这里插入图片描述

前言

写 C/C++ 项目时,我们经常会直接调用现成接口,却很少停下来观察:头文件负责什么,.o 文件从哪里来,静态库又是怎样被链接进程序的。IDE 把这些步骤藏在“生成”按钮后面,方便是方便了,但真正遇到 undefined reference、找不到头文件或找不到库时,就容易分不清问题出在哪个阶段。

这篇文章从一套可以实际编译的源码开始,先实现一个简化的文件流模型和字符串函数,再完成静态库的制作、链接、目录整理、安装与打包。整篇只围绕一条主线展开:

源文件先编译成 .o 目标文件,多个 .o 再由 ar 归档成静态库;调用方通过头文件完成编译,通过库文件完成链接。

本文主要解决下面几个问题:

  • 头文件、目标文件和静态库分别承担什么工作?
  • 为什么静态库使用 ar 制作?
  • -I-L-l 分别在哪个阶段生效?
  • 为什么只写 -l 仍然可能找不到库?
  • 一个库交付给别人时,目录应该怎样整理?
  • 静态链接完成后,为什么 ldd 看不到自定义静态库?

一、什么是库?

库可以理解为一组已经写好、经过测试、可以重复使用的代码。大型程序不可能所有功能都从零开始实现,字符串处理、输入输出、网络通信、图形显示等基础能力,通常都由库提供。

从文件形式上看,Linux 常见的库分为两类:

类型LinuxWindows主要特点
静态库.a.lib链接时把需要的目标代码合并进可执行程序
动态库/共享库.so.dll运行时加载,多个程序可以共享同一份库代码

日常使用 Windows 软件时,偶尔会遇到“缺少某某 DLL”的提示,这就是程序依赖的动态库没有被找到、版本不匹配,或者被误删导致的。

动态库缺失示例

1.1 在系统中查看 C/C++ 动静态库

不同发行版安装位置可能不同,下面分别看 Ubuntu 和 CentOS 中常见的 C/C++ 标准库文件。

# Ubuntu:C 动态库与静态库
ls -l /lib/x86_64-linux-gnu/libc-2.31.so
ls -l /lib/x86_64-linux-gnu/libc.a

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

# CentOS:C 动态库与静态库
ls -l /lib64/libc-2.17.so
ls -l /lib64/libc.a

# CentOS:C++ 动态库与静态库
ls -l /lib64/libstdc++.so.6
ls -l /usr/lib/gcc/x86_64-redhat-linux/4.8.2/libstdc++.a

这里先记住:

.a 通常是多个可重定位目标文件的归档;.so 是可以被动态装载的共享目标文件。二者都来自编译后的目标代码,但组织方式、链接方式和加载方式不同。

1.2 库里为什么通常不写 main?

main 是用户程序的入口。库的职责是提供函数、类型或数据,供不同程序调用。

假设库里已经定义了一个 main,用户程序里又有自己的 main,链接器就会发现两个同名的全局入口,产生重复定义错误。因此,一般的函数库不包含 main

库通常向使用者提供两部分内容:

  • 头文件 .h:声明接口,告诉编译器函数名、参数类型、返回值和类型定义。
  • 库文件 .a.so:保存编译后的函数实现,供链接器或动态加载器使用。

更准确地说,头文件不是库文件本体,但它是库对外发布的接口说明。使用者需要头文件完成编译,需要库文件完成链接或运行时加载。


二、准备一套可以制作成库的源码

为了让后面的实验有完整上下文,我们先实现两组函数:

  1. 简化版标准 I/O:mfopenmfwritemfflushmfclose
  2. 字符串长度函数:my_strlen

下面的 mFILE 是为了理解 FILE* + 用户态缓冲区 + fd 之间关系而设计的教学模型,不是 glibc 的完整实现。

2.1 my_stdio.h

#pragma once

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

typedef struct IO_FILE
{
    int flag;               // 刷新策略
    int fileno;             // 底层文件描述符
    char outbuffer[SIZE];   // 用户态输出缓冲区
    int cap;                // 缓冲区容量
    int size;               // 当前已经使用的字节数
} 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);

2.2 my_stdio.c

下面沿用这套实现思路,并补上边界检查、部分写入和内存释放,让示例可以直接编译运行。

#include "my_stdio.h"

#include <errno.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

static int write_all(int fd, const char *buffer, int size)
{
    int written = 0;

    while (written < size)
    {
        ssize_t n = write(fd, buffer + written, (size_t)(size - written));

        if (n > 0)
        {
            written += (int)n;
            continue;
        }

        if (n < 0 && errno == EINTR)
        {
            continue;
        }

        return -1;
    }

    return 0;
}

mFILE *mfopen(const char *filename, const char *mode)
{
    if (filename == NULL || mode == NULL)
    {
        return NULL;
    }

    int fd = -1;

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

    if (fd < 0)
    {
        return NULL;
    }

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

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

    return stream;
}

void mfflush(mFILE *stream)
{
    if (stream == NULL || stream->size <= 0)
    {
        return;
    }

    if (write_all(stream->fileno, stream->outbuffer, stream->size) == 0)
    {
        stream->size = 0;
    }
}

int mfwrite(const void *ptr, int num, mFILE *stream)
{
    if (ptr == NULL || stream == NULL || num < 0)
    {
        return -1;
    }

    const char *data = (const char *)ptr;
    int copied = 0;

    while (copied < num)
    {
        int free_space = stream->cap - stream->size;

        if (free_space == 0)
        {
            mfflush(stream);
            free_space = stream->cap - stream->size;
            if (free_space == 0)
            {
                return copied;
            }
        }

        int current = num - copied;
        if (current > free_space)
        {
            current = free_space;
        }

        memcpy(stream->outbuffer + stream->size, data + copied, (size_t)current);
        stream->size += current;
        copied += current;

        if (stream->flag == FLUSH_LINE &&
            stream->size > 0 &&
            stream->outbuffer[stream->size - 1] == '\n')
        {
            mfflush(stream);
        }
        else if (stream->flag == FLUSH_FULL && stream->size == stream->cap)
        {
            mfflush(stream);
        }
    }

    return copied;
}

void mfclose(mFILE *stream)
{
    if (stream == NULL)
    {
        return;
    }

    mfflush(stream);
    close(stream->fileno);
    free(stream);
}

有些教学实现还会在 mfflush 中调用 fsync。这里需要区分两件事:

  • write:把用户态数据交给内核。
  • fsync:要求内核尽量把对应文件的数据同步到持久化设备。

普通标准库的 fflush 主要解决用户态缓冲区刷新,不等于每次都调用 fsync。如果每次刷新都强制落盘,性能会明显下降。因此,上面的教学实现没有默认调用 fsync,需要强持久化时可以由调用方单独执行。

实验说明: 为了方便观察刷新过程,这里把 flag 统一初始化为 FLUSH_LINE。真实标准库会结合输出对象和运行环境选择缓冲策略,普通文件通常采用全缓冲,终端输出通常采用行缓冲。另外,mfopen 虽然保留了 "r" 分支来展示打开模式与 open 标志位的对应关系,但本文没有实现 mfread,所以后面的实际调用以 "w""a" 为主。

自定义 FILE 模型源码与结构

2.3 my_string.h 与 my_string.c

// my_string.h
#pragma once

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

int my_strlen(const char *s)
{
    const char *end = s;

    while (*end != '\0')
    {
        end++;
    }

    return (int)(end - s);
}

完成这些源文件后,我们就有了可以制作成库的目标代码。


三、静态库的制作与使用

3.1 静态库到底是什么?

源文件不能直接被链接器使用,需要先经过编译和汇编,形成 .o 目标文件:

gcc -c my_stdio.c
gcc -c my_string.c

此时目录里会得到:

my_stdio.o
my_string.o

静态库的核心,就是把多个 .o 目标文件组织成一个归档文件。它不是普通的 tar 压缩包,而是链接器能够直接识别的目标文件归档。

可以把多个 .o 想象成零散纸张,静态库就是把这些纸张装订成一本册子。使用者不需要手动拆开,链接器会从归档中挑出需要的目标模块。

3.2 使用 ar 生成静态库

# 先生成所有目标文件
gcc -c *.c

# 将目标文件归档成静态库
ar -rcs libmystdio.a my_stdio.o my_string.o

选项含义:

选项含义
r将目标文件插入归档;同名成员已经存在时替换
c归档不存在时创建,同时不输出创建提示
s建立或更新符号索引,方便链接器快速查找符号
t列出归档中的成员
v显示详细信息

查看静态库中包含哪些目标文件:

ar -tv libmystdio.a

使用 ar 打包静态库

静态库的命名规则可以直接记成:

lib + 库名 + .a

例如 libmystdio.a,传给 -l 的库名就是 mystdio,需要去掉开头的 lib 和结尾的 .a

3.3 第一次链接:为什么只写 -l 还不够?

假设调用方有一个 main.c

#include "my_stdio.h"
#include "my_string.h"

#include <stdio.h>

int main(void)
{
    const char *text = "abcdefg\n";

    printf("length = %d\n", my_strlen(text));

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

    mfwrite(text, my_strlen(text), fp);
    mfwrite(text, my_strlen(text), fp);
    mfwrite(text, my_strlen(text), fp);
    mfclose(fp);

    return 0;
}

先只编译调用方:

gcc -c main.c

如果直接链接:

gcc -o main main.o

链接器会提示 my_strlenmfopen 等符号未定义,因为它只看到了函数声明,没有找到函数实现。

未链接库时产生未定义符号

告诉链接器需要 mystdio 库:

gcc -o main main.o -lmystdio

-l 的意思是指定库名,但此时仍可能提示找不到 libmystdio.a

只写 -l 仍然找不到库

原因是链接器默认不会把所有当前目录都当成库目录。还需要用 -L 指定库文件所在路径:

gcc -o main main.o -L. -lmystdio

通过 -L 指定库文件目录

3.4 -I、-L、-l 分别负责什么?

这三个选项经常一起出现,但作用阶段不同:

选项作用面向阶段示例
-I增加头文件搜索目录预处理/编译-I./lib/include
-L增加库文件搜索目录链接-L./lib
-l指定要链接的库名链接-lmystdio

完整命令:

gcc main.c \
    -I./lib/include \
    -L./lib/mylib \
    -lmystdio \
    -o main

-I 解决“头文件在哪里”,-L 解决“库文件去哪里找”,-l 解决“具体找哪个库”。

如果代码直接写相对路径:

#include "./lib/include/my_stdio.h"
#include "./lib/include/my_string.h"

确实可以不写 -I,但这样会把项目目录结构硬编码到源代码里。更规范的做法通常是保持简洁的 #include,把搜索路径交给构建系统管理。

3.5 设计一个可以交付给别人的目录结构

库设计者一般不会只丢一个 .a,而是把头文件、库文件和构建说明整理好:

推荐把交付目录整理成下面三层:

  • lib/
    • include/
      • my_stdio.h
      • my_string.h
    • mylib/
      • libmystdio.a

这样一眼就能分清:头文件放在 include,库文件放在 mylib

整理头文件与库文件目录

打包:

tar czf lib.tgz lib

使用者解压:

cp ../lib.tgz .
tar xzf lib.tgz

使用者解压库安装包

编译时告诉 GCC 头文件和库文件位置:

gcc main.c \
    -I./lib/include \
    -L./lib/mylib \
    -lmystdio \
    -o main

指定头文件和库搜索路径

3.6 把库“安装到系统”是什么意思?

头文件常见搜索位置包括:

ls /usr/include
ls /usr/local/include

系统头文件目录

库文件常见位置包括:

ls /lib64
ls /usr/lib64
ls /usr/local/lib64

系统库文件目录

最直接的安装方式,是把头文件和库文件复制到系统默认搜索目录:

cp lib/include/* /usr/include/
cp lib/mylib/libmystdio.a /lib64/

安装后可以省略 -I-L

gcc main.c -lmystdio -o main

安装后直接通过 -l 链接

不过,实际项目不建议随意往 /usr/include/lib64 复制自定义文件,因为容易污染系统目录、覆盖同名文件,也不方便卸载。更常见的选择是:

  • 安装到 /usr/local/include/usr/local/lib
  • 使用项目私有目录,通过 -I-L 或 CMake 管理。
  • 制作 RPM、DEB 等安装包,让包管理器记录文件。

3.7 静态链接后为什么 ldd 看不到自定义库?

查看可执行程序依赖:

ldd ./main

如果 libmystdio.a 是静态链接进去的,ldd 不会显示它。

静态链接后 ldd 不显示自定义静态库

原因是链接器已经从静态库中取出需要的目标模块,把代码合并进了可执行文件。程序运行时不再需要原来的 .a 文件。

静态库并不是整个文件无条件复制进去。链接器通常只提取解决当前未定义符号所需要的归档成员。

3.8 用 Makefile 自动完成静态库制作与打包

TARGET := libmystdio.a
OBJECTS := my_stdio.o my_string.o

$(TARGET): $(OBJECTS)
	ar -rcs $@ $^

%.o: %.c
	gcc -Wall -Wextra -O2 -c $< -o $@

.PHONY: output
output: $(TARGET)
	mkdir -p lib/include
	mkdir -p lib/mylib
	cp -f *.h lib/include
	cp -f $(TARGET) lib/mylib
	tar czf lib.tgz lib

.PHONY: clean
clean:
	rm -rf *.o $(TARGET) lib lib.tgz

执行:

make
make output
make clean

Makefile 自动生成静态库安装包



总结

这篇文章把静态库的完整流程走了一遍:先编写头文件和源文件,再使用 gcc -c 生成 .o,最后通过 ar -rcs 把多个目标文件归档成 .a。调用方在编译和链接时,需要分别解决三个问题:

  • -I:头文件在哪里;
  • -L:库文件去哪里找;
  • -l:具体要链接哪个库。

静态链接完成后,链接器会从归档中取出当前程序真正需要的目标模块,并把代码组织进可执行文件。因此程序运行时不再依赖原来的 .aldd 也不会显示这个自定义静态库。

静态库的核心不是“把源码压缩起来”,而是把已经编译好的目标文件整理成链接器可以识别的归档。

到这里,静态库这条线已经走通。下一篇继续处理动态库、运行时查找、Visual Studio 配置,以及目标文件和 ELF 加载之间的关系。

资源分享
【Linux】Ext 文件系统篇:从 inode 到路径解析,搞懂挂载与软硬链接
【Linux】Ext 文件系统:从机械磁盘到 CHS/LBA,搞懂扇区、块与分区
【Linux】文件描述符背后的底层逻辑:重定向、VFS 与用户态缓冲区

评论 24
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值