简单系统调用过程分析glibc扩展
〇,前言
libc,正是用户程序与Linux内核之间最关键的中间层之一。所谓“中间层”,是指位于高层应用与底层系统之间的一层抽象。它的核心作用是:向上为应用程序提供简洁、易用的接口,向下屏蔽复杂的底层细节。
什么是libc?
libc 是 C 标准库的实现,是用户空间中最基础、最核心的库之一。常见的实现包括 GNU 的 glibc、轻量级的 musl libc、以及嵌入式中常用的 uClibc 等。libc(C 标准库)本质上是应用程序与 Linux 内核之间的中间层。它通过封装系统调用、提供通用功能和抽象硬件差异,实现了应用程序与内核的解耦。
它提供了我们日常开发中耳熟能详的函数,例如:
- 文件操作:
fopen,fclose,fprintf - 字符串处理:
strcpy,strlen,strcat - 内存管理:
malloc,free,calloc - 输入输出:
printf,scanf,puts这些函数并非内核原生提供,而是由libc封装并实现的。可以说,几乎每一个C语言程序都依赖于libc。
Linux内核与libc的关系
与用户空间的 libc 不同,Linux内核是操作系统的核心,运行在特权模式下,直接管理硬件资源,包括CPU调度、内存分配、文件系统、网络协议栈等。
内核通过 系统调用(System Calls) 向用户程序暴露功能接口,例如:
open():打开文件read()/write():读写文件fork()/exec():创建进程mmap():内存映射
这些系统调用是用户程序与内核交互的唯一合法通道。但它们接口原始、使用复杂、性能开销大。
为什么需要中间层?——libc存在的意义
直接使用系统调用编写程序不仅繁琐,而且效率低下。libc 作为中间层,解决了以下几个关键问题:
简化开发: 开发者无需直接调用
write(2)来输出文本,只需使用printf(),即可格式化输出,无需关心底层字节流处理。提高性能:缓冲机制:
libc在标准I/O(如stdio)中引入了缓冲机制,将多次小的写操作合并为一次系统调用,显著减少上下文切换开销。增强可移植性:
libc抽象了不同操作系统和硬件平台的差异。同一份C代码,只要目标平台有兼容的libc实现,就可以编译运行。统一错误处理:
libc封装了系统调用的返回值和错误码(通过errno),提供一致的错误报告机制。
程序执行的调用链
一个典型的C程序从用户代码到硬件的执行路径如下:
1 | 应用程序(如 ls, cat, 用户编写的C程序) |
可以看到,libc 是用户程序通往内核的“必经之路”。
一,简单系统调用过程分析
前言
我写了一段C代码:
1 | int main(){ |
在终端执行:
1 | strace -o printf.log ./print_helloworld |
终端中输出:
1 | hello world! |
printf.log中的内容为:
1 | execve("./print_helloworld", ["./print_helloworld"], 0x7ffddeb59340 /* 37 vars */) = 0 |
涉及到的系统调用
(1)涉及到strace 命令
通过man strace可以看到他是一个跟踪系统调用和信号传递的工具
It intercepts and records the system calls which are called by a process and the signals which are received by a process. The name of each system call, its arguments and its return value are printed on standard error or to the file specified with the -o option.
(2)这个案例中涉及一些系统调用
arch_prctl用于设置架构特定线程状态的系统调用。它允许在 x86 和 x86-64 架构上执行特定的操作,如设置和获取 FS 和 GS 寄存器的基地址,以及启用或禁用 CPUID 指令。
mmapexecve允许进程用一个全新的程序替换当前运行的程序。这个过程中,旧程序的栈、数据和堆段会被新程序所替换。execve函数不会创建新进程,而是在原有进程的基础上执行另一个程序,进程ID保持不变。
accessopenat
用于相对于指定目录文件描述符打开文件,是 open 函数的扩展版本,提供了更灵活的文件打开方式,支持相对路径操作,避免了某些竞态条件。
newfstatatset_tid_addressset_robust_listrseqbrkwritecloseexit_group
具体分析
1. 程序启动 (execve)
1 | execve("./print_helloworld", ["./print_helloworld"], 0x7ffddeb59340 /* 37 vars */) = 0 |
- execve:这是程序启动时的系统调用,用于执行可执行文件。
execve会替换当前进程的映像为指定的程序,这里是./print_helloworld。 - 它传递了参数
["./print_helloworld"],表示没有命令行参数传入。 0x7ffddeb59340是进程环境变量的位置(堆栈中的内存位置)。
2. 分配内存 (brk 和 mmap)
1 | brk(NULL) = 0x55b56c23f000 |
- brk:
brk系统调用是为了调整程序的堆内存边界,分配内存。这里它返回了一个地址,表明堆的开始位置。
1 | mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f16cdaba000 |
- mmap:这个调用分配了一块 8192 字节的内存区域。
PROT_READ|PROT_WRITE表示这块内存可读可写,MAP_PRIVATE|MAP_ANONYMOUS表示这是匿名内存(没有文件映射)。它返回了内存区域的起始地址。
3. 加载动态链接库 (openat 和 read)
1 | openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 |
- openat:打开共享库缓存文件
/etc/ld.so.cache,它包含了动态库路径的信息。O_RDONLY表示只读模式,O_CLOEXEC表示当执行 exec 系统调用时会自动关闭该文件。 - read:从文件中读取数据,这里是读取到 ELF 格式的共享库文件的部分内容。
4. 继续读取共享库信息 (pread64)
1 | pread64(3, "\6\0\0\0\4\0\0\0@", 784, 64) = 784 |
- pread64:从文件描述符中读取共享库的特定偏移位置的数据。这部分通常用于解析 ELF 文件头,提取程序头信息、节头等数据。
5. 加载 libc 库
1 | newfstatat(3, "", {st_mode=S_IFREG|0755, st_size=2220400, ...}, AT_EMPTY_PATH) = 0 |
- mmap:加载共享库
libc.so.6的内容到内存中,大小为 2264656 字节。该内存映射使用了PROT_READ(只读)权限,以防止写入修改库内容。
6. 保护库的只读区域 (mprotect)
1 | mprotect(0x7f16cd8b0000, 2023424, PROT_NONE) = 0 |
- mprotect:将库的一部分内存设置为不可访问(
PROT_NONE),以保护代码区不被修改。
7. 内存映射(执行区域)
1 | mmap(0x7f16cd8b0000, 1658880, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x7f16cd8b0000 |
- mmap:将库的代码部分映射到进程的地址空间,且此区域可执行(
PROT_EXEC)。这使得程序能够执行共享库中的代码。
8. 设置堆栈信息 (arch_prctl 和 set_tid_address)
1 | arch_prctl(ARCH_SET_FS, 0x7f16cd885740) = 0 |
- arch_prctl:设置与体系结构相关的特定寄存器。这通常用于线程的控制。
- set_tid_address:设置当前线程的 ID 地址。
9. 进程的内存保护和资源限制 (mprotect, prlimit64)
1 | mprotect(0x7f16cda9e000, 16384, PROT_READ) = 0 |
- mprotect:对堆栈的内存进行保护,使其只读。
- prlimit64:检查进程的栈内存限制,确保栈内存足够。
10. 写入标准输出 (write)
1 | write(1, "hello world!\n", 13) = 13 |
- write:调用
write系统调用将数据输出到文件描述符 1(即标准输出)。它将hello world!\n输出到终端。
11. 程序退出 (exit_group)
1 | exit_group(0) = ? |
- exit_group:这是进程退出时的系统调用,
0表示程序正常退出。
总结
整个过程可以按以下步骤总结:
- 程序启动:
execve启动./print_helloworld程序。 - 内存分配:程序通过
brk和mmap分配内存。 - 加载共享库:通过
openat和read读取共享库,加载libc.so.6库,映射到进程内存中。 - 设置堆栈和线程:通过
arch_prctl和set_tid_address设置线程和堆栈信息。 - 保护内存:通过
mprotect保护程序的代码和数据区域,防止非法访问。 - 执行代码:通过
mmap映射并执行共享库代码。 - 输出结果:通过
write输出字符串hello world!\n到终端。 - 程序退出:程序调用
exit_group正常退出。
strace 输出展示了系统调用的详细信息,揭示了程序如何通过操作系统接口加载库、分配内存、执行代码和输出结果的过程。
(10 条消息) posix是什么都不知道,还好意思说你懂Linux? - 知乎
(11 条消息) 系统调用与内存管理(sbrk、brk、mmap、munmap) - 知乎
二,glibc扩展分析
案例1:使用 printf(通过libc缓冲)
1 |
|
使用 strace 跟踪系统调用:
1 | strace -e write ./a.out |
结果:只发生了一次 write 系统调用!
案例2:直接调用 write系统调用(绕过libc缓冲)
1 |
|
结果:发生了10次系统调用!
为什么 printf 只触发一次系统调用?——libc的缓冲机制
关键在于 libc 的 标准I/O缓冲策略。printf 属于 stdio 库,其输出默认被缓冲,直到满足刷新条件才真正调用 write。
标准I/O的三种缓冲模式:
| 模式 | 触发条件 | 示例 |
|---|---|---|
| 行缓冲(Line Buffering) | 当输出为终端,比如直接运行程序将结果打印到终端,那么这个时候就是行缓冲。 行缓冲默认的大小为1024,但是当遇到回车换行符(\n)会强制刷新 | printf(“Hello\n”) |
| 全缓冲(Full Buffering) | 输出到文件或管道时启用。缓冲区满(通常4KB)时刷新 | fprintf(file, “…”) |
| 无缓冲No Buffering) | 立即输出,每次调用都触发系统调用。需要显式关闭缓冲 setbuf(stdout, NULL) | stderr 默认无缓冲 |
注意:当输出目标是终端(TTY)时,
stdout默认为行缓冲。因此,如果printf("Hello");没有换行符,数据会暂存于缓冲区,直到程序结束或缓冲区满才刷新。
实验:手动控制缓冲行为
1. 禁用缓冲(无缓冲模式)
1 |
|
2. 手动刷新缓冲区
1 |
|
可以看出,fflush() 强制将缓冲区内容写入内核。
libc 与 内核的本质区别
| 特性 | libc(用户空间库) | Linux 内核 |
|---|---|---|
| 运行空间 | 用户空间 | 内核空间 |
| 权限 | 普通权限 | 特权模式(Ring 0) |
| 功能 | 提供标准C函数、封装系统调用 | 管理硬件、调度资源、提供系统调用 |
| 性能开销 | 低(函数调用) | 高(上下文切换) |
| 可替换性 | 可更换(glibc vs musl) | 不可替换(核心) |
| 缓冲机制 | 有(stdio缓冲) | 无(直接操作硬件) |
总结:libc 是Linux系统的“隐形英雄”
libc是C程序与操作系统之间的关键桥梁。- 它通过封装系统调用、提供通用函数、实现缓冲机制,极大提升了程序的可移植性、易用性和性能。
- 理解
libc的工作原理,尤其是其缓冲机制,有助于编写更高效、更可控的程序。 - 在性能敏感或嵌入式场景中,选择合适的
libc实现(如musl)也能带来显著优势。
一句话总结:
libc不是内核,却比内核更贴近开发者;它不直接操控硬件,却是程序高效运行的幕后功臣。
三,glibc安装
glibc下载地址:https://ftp.gnu.org/gnu/glibc/?C=M;O=A
glibc是GNU发布的libc库,即c运行库。 glibc是linux系统中最底层的api,几乎其它任何运行库都会依赖于glibc。 glibc除了封装linux操作系统所提供的系统服务外,它本身也提供了许多其它一些必要功能服务的实现。
glibc涉及的系统组件多,无法在超算平台上整体部署高版本glibc,如果需要高版本glibc,可通过源码自行安装。
1 | srun -p 64c512g -n 16 --pty /bin/bash |
参考:https://docs.hpc.sjtu.edu.cn/app/compilers_and_languages/glibc.html
glibc编译
1 | cd /home/dk/print |
--disable-werror 很重要,否则很多 warning 会被当成 error 卡住
--prefix 只是“未来如果 install 会装到哪”,不执行 make install 就不会动系统。
–prefix需要配置,否则默认为–prefix=/usr/local,如果你把它装进 /usr/local 可能把系统搞坏”,执行configure时会直接拦住。
只编译(不安装)
1 | make -j"$(nproc)" |
给 clangd 用:生成 compile_commands.json
1 | bear -- make -j"$(nproc)" -k |
然后把它链接到源码根目录:
1 | ln -sf /home/dk/print/glibc-build/compile_commands.json \ |
(或在 VSCode clangd 参数里加:--compile-commands-dir=/home/dk/print/glibc-build)
四,查看glibc版本
1 | ldd --version |
运行结果
1 | dk@DESKTOP:~/print/glibc-2.42$ ldd --version |
GLIBC 的动态库是可以执行运行的, 运行将显示版本号以及版权信息,可以看到版本为:2.35
1 | /lib/x86_64-linux-gnu/libc.so.6 |
运行结果
1 | dk@DESKTOP:~/print/glibc-2.42$ /lib/x86_64-linux-gnu/libc.so.6 |
然后到Index of /pub/gnu/glibc网站上搜索2.35版本进行下载。找到以下版本进行下载
1 | glibc-2.35.tar.gz 2022-02-03 01:35 34M |
解压并进入对应文件夹
1 | tar -zxvf glibc-2.35.tar.gz |
验证下载的glibc就是系统所用的glibc代码
根据echo 'main(){}'| gcc -E -v -查看得到头文件搜索路径,可以得到以下几个头文件搜索路径
1 | dk@DESKTOP:~/print$ echo 'main(){}'| gcc -E -v - |
可以看到搜索路径都在/usr/*路径下,因此使用find搜索一个典型的头文件stdio.h
1 | dk@DESKTOP:~$ find /usr -name stdio.h |
一共搜索出来3个stdio.h,但是可以确定的是对于C代码来说,使用的stdio.h就是/usr/include/stdio.h
在glibc-2.35中搜索stdio.h
1 | dk@DESKTOP:~/print/glibc-2.35$ find -name stdio.h |
一共有4个stdio.h
首先认为/usr/include/stdio.h就是./include/stdio.h,但是很遗憾并不是,猜测这个stdio.h是提供给glibc内部接口使用的,看了下具体代码,全部是extern的一些print声明。
接下来看第二个和第三个文件,发现第二个./include/bits/stdio.h里面只有一行代码#include <libio/bits/stdio.h>,即说明第二个文件和第三个文件是类同的,可以认为是同一个文件。又因为这俩文件都包含了bits目录,所以很自然的将./libio/bits/stdio.h 与 /usr/include/x86_64-linux-gnu/bits/stdio.h对比,发现他俩的内容是一摸一样的。
1 | dk@DESKTOP:~/print/glibc-2.35$ cmp ./libio/bits/stdio.h /usr/include/x86_64-linux-gnu/bits/stdio.h |
最后只剩下./libio/stdio.h 了,将./libio/stdio.h 与 /usr/include/stdio.h使用cmp对比发现两者一模一样。当然也可以在vscode中点击【将已选择项目进行比较】来直观验证
1 | dk@DESKTOP:~/print/glibc-2.35$ cmp /home/dk/print/glibc-2.35/libio/stdio.h /usr/include/stdio.h |
结论就是:glibc-2.3/libio/stdio.h对应 /usr/include/stdio.h
想追踪printf怎么调用到C库的write,最后又如何调到write系统调用,搞了半天也没搞明白,放弃!