欢迎光临
我们一直在努力

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

封面

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

在这里插入图片描述


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

文章目录

  • 前言
  • 一、什么是库?
    • 1.1 在系统中查看 C/C++ 动静态库
    • 1.2 库里为什么通常不写 main?
  • 二、准备一套可以制作成库的源码
    • 2.1 my_stdio.h
    • 2.2 my_stdio.c
    • 2.3 my_string.h 与 my_string.c
  • 三、静态库的制作与使用
    • 3.1 静态库到底是什么?
    • 3.2 使用 ar 生成静态库
    • 3.3 第一次链接:为什么只写 -l 还不够?
    • 3.4 -I、-L、-l 分别负责什么?
    • 3.5 设计一个可以交付给别人的目录结构
    • 3.6 把库“安装到系统”是什么意思?
    • 3.7 静态链接后为什么 ldd 看不到自定义库?
    • 3.8 用 Makefile 自动完成静态库制作与打包
  • 总结

前言

写 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:保存编译后的函数实现,供链接器或动态加载器使用。

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


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

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

  • 简化版标准 I/O:mfopen、mfwrite、mfflush、mfclose。
  • 字符串长度函数: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_strlen、mfopen 等符号未定义,因为它只看到了函数声明,没有找到函数实现。

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

    告诉链接器需要 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:具体要链接哪个库。

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

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

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

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

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux】库制作与原理:从源码到静态库,搞懂 ar、-I/-L/-l 与打包交付
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址