裸系统调用出错时给你 -1 加一个 errno;包装函数把它翻成异常、顺手检查参数、补齐平台差异。代价只是几个百分点,换来的是错误不会被静默忽略。
2.1 讲门本身,这一节讲门边那个【传达室】——你写的 open() 其实不是系统调用,是包装函数;真正的系统调用在它后面一层。
一、裸系统调用长什么样
直接从 C 里调系统调用,你会看到这种画风:
返回 -1 表示失败,具体什么错放在全局变量 errno 里。成功时返回文件描述符(一个非负整数)。
问题在于:-1 是一个合法的值域外的数,但它不会自己喊。你不检查,程序就带着一个无效的 fd 往下跑,直到某处炸掉——而炸掉的地方跟真正的错因隔着十万八千里。
更麻烦的是 errno 是个全局变量:一个线程改了,另一个线程读到的是别人的错。所以现代实现把它做成每线程一份。
二、包装函数干了三件事
第一件,翻错误。把 -1 + errno 翻成异常或错误码,让调用方必须处理——语言帮你把【忘记检查】这条路堵死。
第二件,检查参数。路径是不是空、flags 组合有没有矛盾、缓冲区大小是不是超了。这些检查在用户态做比进内核做便宜得多。
第三件,补平台差异。同一个 open,不同操作系统、不同架构的系统调用号、参数顺序、甚至语义都不一样。包装函数把这些抹平,你的代码才可移植。
还有第四件,容易被忽略:重试。某些系统调用会被信号打断,返回 EINTR——包装函数会悄悄替你重来一次,你的代码里看不见这件事。
三、Python 的 os 模块就是那层包装
os.open 是最贴近裸系统调用的一个:参数就是 flags 位运算,没有缓冲、没有文件对象。失败时它抛 OSError,异常里带着 errno。
而 open() 更靠上一层:它返回文件对象,自带缓冲、自带编码转换、自带 with 关闭。
所以同一件事你在 Python 里有三档可选:
1. open()——最舒服,缓冲与编码都替你管好了。
2. os.open + os.read——贴地飞行,能精确控制每一次过门。
3. mmap——干脆把文件映射进地址空间,读写变成访存。
三档不是新旧关系,是三条不同的路,代价不同。
四、代价与收益的账
下面 Demo 把两档摆在一起量:直接 os.open 与走 open() 对象,同一件事各花多少。
结果会看到包装那一层确实更贵——对象要造、缓冲要建、异常路径要准备。
但代价是常数级的(几倍以内),收益是结构性的:
1. 错误不会被漏掉(异常强迫你处理)。
2. 代码可移植(平台差异被抹平)。
3. 资源不会漏关(with 保证释放)。
所以判据是:默认走包装层,只有在【每秒几百万次】这种量级上才考虑往下钻。
五、动手 Demo:包装函数替你做了什么——从 -1 与 errno 到异常
同一件事写两遍:一遍直接调 os.open(拿返回值和 errno),一遍走 open()(拿异常)。然后看两种交法各付出多少。
def raw_open(path):
try:
return os.open(path, os.O_RDONLY), 0
except OSError as e:
return -1, e.errno # 裸风格:返回 -1 + 错误码
def wrapped_open(path):
return open(path, "rb") # 包装风格:直接抛异常
# 同一个失败,两种交法
fd, err = raw_open("_d29_no_such_file.txt")
print(fd, err, os.strerror(err)) # -1 2 No such file...
实跑输出:
--------------------------------------------------------------------
包装函数替你做了什么——从 -1 与 errno 到异常
--------------------------------------------------------------------
① 同一个失败,两种交法
风格 返回什么 调用方要做什么
----------------------------------------------------------
裸系统调用 fd = -1,errno = 2 自己查 errno 再判断
裸风格里 errno = 2,含义:No such file or directory
包装风格抛了 FileNotFoundError,errno = 2
**两种交法的信息量完全一样,差别在【谁来检查】。**
**裸风格把检查推给每个调用方——漏一个就是一个静默的错。**
② 一次多余的门:同样的活儿,包一层要多花多少
写法 每次耗时 多出来的是什么
-------------------------------------------------------
直接调系统调用 106044 纳秒 ——
走包装函数 86988 纳秒 对象、缓冲、异常翻译
**包装函数不是白给的便宜,它有成本,只是成本远小于它省下的事。**
**它替你办三件:把 -1 翻成异常、把缓冲管好、把参数检查掉。**
两种交法的信息量完全一样:都是【没找到】。差别只在谁来检查。 裸风格把检查推给每一个调用方——漏一个,就是一个静默的错,而错会一直传到很远的地方才炸。 包装风格把检查变成强制的:不处理异常,程序就停在那儿——停在你写错的地方,而不是三小时后在别处。 第二张表是价钱:包装层更贵,但贵在常数上。它买的不是速度,是【错误不会被忽略】。
📋 在真 Linux 上对照:man 2 open 是系统调用(返回值 -1 / errno),man 3 fopen 是标准库(返回 NULL / 返回错误码),两个手册页码不同,就在告诉你它们不是一层东西。
30 秒实验
- 在 Python 里试
os.open("/no/such", os.O_RDONLY),捕获异常并打印e.errno与e.strerror——异常里原样带着 errno。 - 试
open("/etc/hostname", "r")与os.open("/etc/hostname", os.O_RDONLY),分别 type 一下返回值:一个是文件对象,一个是整数。 python -c "import os; os.close(999)",看包装层怎么把EBADF翻成异常。
常见误区
- 【包装函数是多余的一层,去掉更快】。快几个百分点,换来的是错误被静默吞掉的概率。只有在每秒百万级的循环里才值得往下钻。
- 【errno 是每个进程一份】。现代实现是每线程一份(
__thread int errno);进程级别的话,多线程程序里读到别人的错是常态。 - 【异常比返回值慢,所以异常是不好的设计】。异常只在出错路径上贵;正常路径几乎不花钱。拿错误处理的开销去评价异常,是拿错了尺子。
自测三问(点开对答案)
1. 裸系统调用怎么报错?它最大的隐患是什么? —— 返回 -1,错误码放进 errno。隐患是**不会自己喊**——忘了检查就带着无效值往下跑,炸的地方离错因很远。 2. 包装函数干的三件事(外加一件容易忽略的)是什么? —— 翻错误、检查参数、补平台差异;容易忽略的是**被信号打断时自动重试**(EINTR)。 3. 什么时候该绕过包装层直接用系统调用? —— 只在每次过门的成本都重要的场合——比如每秒百万次的小读写、需要精确控制 mmap 与缓冲的时候。**常规业务代码不该绕过。**- ☐ 2.3 C标准库
相关笔记
- 系统调用 — 门本身
- C标准库 — 再往上还有一层缓冲
- OS提供的程序 — 最外面那层是给人用的
- 🐧 Linux是怎样工作的 · 索引