第 3 章 系统编程概念 (System Programming Concepts)
核心结论
-
系统调用机制:系统调用是「用户态→内核态」受控入口;通过 glibc 包装函数触发;CPU 状态从用户态切换到内核态;返回时把结果(含可能的 errno)传回用户态。
-
系统调用 vs 库函数:系统调用是内核服务入口(如
read、write、open);库函数是 C 标准库提供的函数(部分基于系统调用,如fopen基于open,部分不基于,如strcpy)。 -
错误处理约定:系统调用失败时返回
-1并设置全局errno;成功调用不重置errno;检查错误必须「先看返回值,再看 errno」。 -
errno 与诊断函数:
<errno.h>定义错误常量(E 开头的常量);perror()和strerror()把 errno 翻译成可读字符串。 -
glibc:Linux 上最常用的 C 库实现是 GNU C Library (glibc);嵌入式替代品有 uClibc、diet libc;查询版本用
gnu_get_libc_version()或confstr(_CS_GNU_LIBC_VERSION)。 -
可移植编程:feature test macros(
_POSIX_C_SOURCE、_XOPEN_SOURCE、_GNU_SOURCE)控制可见的函数声明与类型;SUSv3 定义了标准系统数据类型(如pid_t、uid_t)。 -
可重入函数:
getpwnam、ctime、localtime等返回静态分配结构的函数不可重入;多线程程序必须用_r版本(如getpwnam_r、ctime_r)。
|
本章主旨
本章为后续所有系统编程章节奠定基础:(1) 系统调用是如何工作的(从 glibc 包装到内核服务例程);(2) 库函数与系统调用的区别;(3) 错误处理的统一约定(errno 与 perror/strerror);(4) 可移植编程的工具(feature test macros、SUSv3 数据类型)。本书的每个示例程序都会用 |
一、核心概念
本章围绕 6 个核心概念展开:从「系统调用如何工作」到「错误处理约定」,再到「可移植编程」。
| 概念 | 定义 + 重要性 | 实现提示 |
|---|---|---|
系统调用机制 |
用户态→内核态的受控入口;通过 glibc 包装函数触发;每个系统调用有唯一编号;返回非负值表示成功,负值(取反后即 errno)表示失败。 |
§3.1;调用 |
库函数 vs 系统调用 |
库函数属于 C 标准库;部分基于系统调用(如 |
§3.2;不要混淆系统调用(man 2)与库函数(man 3)。 |
errno 与错误处理 |
|
§3.4; |
glibc (GNU C Library) |
Linux 上最常用的 C 标准库实现;包含系统调用包装、POSIX 扩展、线程支持等;版本号查询用 |
§3.3;嵌入式替代品:uClibc、diet libc、musl。 |
Feature Test Macros |
在 |
§3.6;man page 的「Feature Test Macro Requirements」章节会说明需要哪个宏。 |
可重入性 (Reentrancy) |
函数能否在「同一进程被同一信号的 handler 中断后再调用」时仍正确;返回静态分配结构的函数( |
§3.5;多线程/异步信号处理必须用 |
二、详细笔记
3.1 系统调用机制
What:系统调用是「进程请求内核服务」的受控入口;通过 glibc 包装函数触发;执行期间 CPU 切换到内核态;完成后返回用户态。
Why:理解系统调用机制能解释「为什么系统调用有性能开销」「为什么 errno 是全局变量」「为什么 getpriority() 等函数需要特殊处理」。
How:以 execve() 为例的系统调用执行步骤(x86-32,§3.1):
-
应用调用 glibc 包装函数
execve(path, argv, envp)。 -
包装函数把参数复制到寄存器(内核期望的位置)。
-
包装函数把系统调用编号(
__NR_execve = 11)存入%eax。 -
包装函数执行
int 0x80或sysenter指令——CPU 切换到内核态。 -
内核的
system_call()例程检查编号合法性,索引sys_call_table,调用sys_execve()。 -
服务例程执行,返回结果状态。
-
内核恢复寄存器,把返回值放在栈上。
-
CPU 切回用户态;包装函数把返回值给应用;如果返回负数,把它取反写入
errno,并返回-1给应用。
Linux 系统调用服务例程的命名约定:sys_xyz()(如 sys_execve、sys_open);C 库包装函数同名(execve、open)。
When:
-
需要直接与内核交互时(如绕过库函数缓冲)。
-
调试性能问题时——系统调用开销约 0.3μs,比普通函数调用高一个数量级。
-
理解
strace(附录 A)的输出——它能列出程序执行的所有系统调用。
Example:
// 摘自《The Linux Programming Interface》第 3 章
// 用户态调用 execve() —— 触发系统调用
execve(path, argv, envp);
// ↓ glibc 包装
// sys_execve() 在内核执行
// ↓ 成功:返回 0;失败:返回 -ENOENT 等
if (execve(path, argv, envp) == -1) {
if (errno == ENOENT)
fprintf(stderr, "file not found: %s\n", path);
}
3.2 库函数与 glibc
What:库函数属于 C 标准库;可能基于系统调用(如 fopen→open),也可能不基于(如 strlen、memcpy);glibc 是 Linux 上最常用的实现。
Why:区分系统调用(man 2)与库函数(man 3)能避免「找不到手册页」「混淆两层错误处理」等问题。
How:
| 类别 | 基于系统调用 | 纯用户态 |
|---|---|---|
文件 I/O |
|
— |
字符串 |
— |
|
内存 |
|
|
时间 |
|
|
数学 |
— |
|
glibc 是 Linux 默认 C 库;提供:
-
C 标准库(
stdio.h、stdlib.h等)。 -
POSIX/SUS 扩展。
-
Linux 特有扩展。
-
线程支持(NPTL)。
-
国际化(locale)。
When:
-
查询 glibc 版本——
/lib/libc.so.6(直接运行);或ldd myprog | grep libc;或程序内调用gnu_get_libc_version()。 -
写嵌入式代码——考虑 uClibc、musl(更小)。
-
在 man page 第 2 节看「系统调用」,第 3 节看「库函数」。
Example:
// 摘自《The Linux Programming Interface》第 3 章
// 查询 glibc 版本
#include <gnu/libc-version.h>
printf("glibc version: %s\n", gnu_get_libc_version());
// 输出: "2.35"
3.3 错误处理:errno 与诊断函数
What:系统调用失败时设置全局 errno;perror() 和 strerror() 把 errno 翻译为可读字符串;成功调用不重置 errno。
Why:错误处理是系统编程的「卫生习惯」——跳过它会让 bug 难以诊断。
How:
-
errno:
<errno.h>定义的int;常见常量:EACCES(权限)、EBADF(坏 fd)、EFAULT(坏地址)、EINTR(被信号中断)、ENOENT(文件不存在)、ENOMEM(内存不足)。 -
检查约定:调用返回
-1→ 看errno;调用返回 0 → 成功;少数函数(如getpriority)合法返回-1——必须先errno = 0再调用。 -
perror(msg):打印
msg: errno 对应字符串到 stderr。 -
strerror(e):返回 errno 对应字符串的指针。
-
strerror_r(e, buf, n):可重入版本。
// 摘自《The Linux Programming Interface》第 3 章
// 典型错误处理模式
fd = open(pathname, O_RDONLY);
if (fd == -1) {
// 必须先看返回值!errno 在成功调用后可能仍然非零
if (errno == ENOENT)
fprintf(stderr, "file not found: %s\n", pathname);
else
errExit("open"); // 打印 msg + errno 描述 + exit(EXIT_FAILURE)
}
When:
-
几乎所有系统调用都应检查返回值。
-
不要把
errno当作「最后错误」——成功调用可能不改它。 -
在多线程程序中——
errno是线程局部的(thread-local),可放心使用。
Example:perror("open") 输出形如 open: No such file or directory。
3.4 Feature Test Macros
What:Feature test macros(特性测试宏)在 #include 之前定义,控制 <unistd.h> 等头文件暴露哪些声明与类型。
Why:不同 UNIX 标准(POSIX、SUS、XPG)定义了不同的函数子集;通过宏告诉编译器「我要遵循哪个标准」。
How:常见宏:
| 宏 | 暴露的标准 |
|---|---|
|
POSIX;200809L = POSIX.1-2008 |
|
X/Open + SUS;600 = SUSv3 |
|
GNU 扩展(Linux 特有)= |
|
默认行为(包含 BSD/SVID) |
|
BSD 风格(已废弃,用 |
When:
-
写可移植 UNIX 代码——
_POSIX_C_SOURCE=200809L。 -
用 Linux 特有扩展——
_GNU_SOURCE。 -
man page 第 2/3 节通常会列出「Feature Test Macro Requirements」。
Example:
// 摘自《The Linux Programming Interface》第 3 章
#define _POSIX_C_SOURCE 200809L // 启用 POSIX.1-2008
#define _GNU_SOURCE // 启用 GNU 扩展
#include <unistd.h>
#include <fcntl.h>
// 现在可以使用 O_CLOEXEC 等 GNU 扩展
int fd = open(path, O_RDONLY | O_CLOEXEC);
3.5 可重入性 (Reentrancy)
What:函数在「同一进程被信号 handler 中断,handler 又调用同一函数」时仍能正确工作——即可重入。
Why:多线程程序 + 异步信号处理要求函数可重入;返回静态分配结构的函数不满足。
How:不可重入函数通常返回指向静态缓冲区的指针——第二次调用会覆盖第一次的结果。
-
不可重入:
ctime、localtime、gmtime、asctime、getpwnam、getpwuid、getgrnam、getgrgid、strerror。 -
可重入版本:以上函数加
_r后缀,如ctime_r(buf, size, t)、getpwnam_r(name, pwd, buf, buflen, &result)。 -
errno本身在 glibc 中是线程局部的(#define errno (*__errno_location()))。
When:
-
写多线程程序——避免不可重入函数。
-
信号 handler 内——只能用
async-signal-safe函数(man 7 signal-safety);其余包括 printf、malloc 等都不可用。 -
替代方案——拷贝返回值到本地缓冲区。
Example:
// 摘自《The Linux Programming Interface》第 3 章
// 不可重入版本(线程不安全)
char *s = ctime(&t); // 返回静态缓冲区指针
// ... 第二次调用后 s 内容被覆盖
// 可重入版本(线程安全)
char buf[26];
ctime_r(&t, buf); // 结果写入调用者提供的 buf
3.6 SUSv3 标准数据类型
What:SUSv3 定义了一组标准系统数据类型(<sys/types.h>),如 pid_t、uid_t、gid_t、mode_t、off_t;目的是屏蔽不同实现的位宽差异。
Why:使用 SUSv3 标准类型能让代码在不同 UNIX 实现间可移植;不直接假设 int、long 的位宽。
How:常见类型:
| 类型 | 用途 |
|---|---|
|
进程 ID |
|
用户 ID、组 ID |
|
文件权限位 |
|
文件偏移 |
|
无符号大小( |
|
有符号大小(read/write 返回值) |
|
时间(秒数) |
|
设备 ID、i-node 号 |
When:
-
函数参数与返回值涉及系统资源——使用 SUSv3 类型。
-
打印这些类型——不要假设
int/long;用(long)或(long long)强制转换(与printf格式匹配)。
Example:
// 摘自《The Linux Programming Interface》第 3 章
pid_t pid = getpid();
printf("PID = %ld\n", (long) pid); // 强制 long 转换以兼容 %ld
三、关键图表
|
非可视化条目(关键 API 对照)
|
四、思维导图
mindmap
root((第 3 章 系统编程概念))
系统调用机制
包装函数
int 0x80 sysenter
sys_call_table
返回值约定
库函数与 glibc
标准 C 库
POSIX 扩展
GNU 扩展
版本查询
errno 与错误
全局 errno
失败返回 -1
perror strerror
EINTR 处理
可移植
Feature Test Macros
POSIX SUS XPG
_GNU_SOURCE
SUSv3 数据类型
可重入性
静态缓冲区陷阱
_r 版本
信号安全
错误处理辅助
errExit errMsg
usageErr cmdLineErr
fatal
五、重点与易错点
-
「系统调用」vs「库函数」:man page 第 2 节是系统调用(open/read/fork),第 3 节是库函数(fopen/printf/malloc);不要混淆。
-
errno 不在成功调用后被重置:所以必须「先看返回值再看 errno」——这是初学者最常犯的错误。
-
少数函数合法返回 -1:
getpriority、read(在 EOF 时返回 0)、write(short write)——必须先errno = 0再调用。 -
glibc vs musl/uClibc:嵌入式系统或对二进制大小敏感时考虑替代品;多数服务器/桌面场景用 glibc 即可。
-
Feature Test Macros 必须
#include之前定义:否则头文件可能不暴露所需的声明,编译报「implicit declaration」。 -
errno 是线程局部变量(glibc 实现):多线程中每个线程有独立的 errno,无需额外同步;但不要在不同线程间共享
errno变量。 -
信号 handler 只能用 async-signal-safe 函数:
printf、malloc、exit都不可用;详见man 7 signal-safety。 -
strace 是排查系统调用问题的好工具:
strace -e trace=open,read,write ./prog列出指定系统调用;详见附录 A。 -
跨章衔接:第 4-5 章的文件 I/O 大量使用系统调用;第 7 章
malloc基于brk/mmap;第 8-9 章用 SUSv3 类型;第 20-22 章的信号处理必须考虑可重入性。 -
避免
man 2与man 3编号混淆:用man -a open(看所有节)、man 3 errno(库函数 errno 定义)。