UNIX 系统接口与存储分配
复刻 K&R 第八章:文件描述符、低级 I/O、open/close/unlink、lseek、stdio 实现思想、目录遍历与存储分配器。
学习目标
- 能解释文件描述符与标准 I/O 流的分层状态,实现正确处理短读、短写和中断的文件复制
- 能比较
open、creat、close、unlink与lseek的资源和偏移语义,判断哪些属于 POSIX 而非 ISO C - 能设计基于公开接口的目录列表,并分析教材版
fopen/getc与存储分配器的对齐、溢出和所有权不变量
机制总览
UNIX 系统接口与存储分配:机制路径
- 1
从“这一章讲的是 UNIX,不是全部 C”开始
K&R 前七章主要使用 ISO C 语言与标准库;第八章故意下探到 UNIX 系统接口。
- 2
低级 I/O:返回值就是本次进度
write 返回正数时也只承诺这些字节已被接口接受,可能小于请求量。代码必须从返回位置继续,而不是整块重写;否则前缀会重复。阻塞调用被信号打断时可能以 EINTR 失败,是否重试要结合接口是否已报告进度与上层取消协议。
- 3
open、creat、close 与 unlink
int open output(const char path) return open(path, O WRONLY O CREAT O TRUNC, (mode t)0666);
章级决策实验
UNIX 系统接口与存储分配:机制与证据
切换《UNIX 系统接口与存储分配》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 从“这一章讲的是 UNIX,不是全部 C”开始
K&R 前七章主要使用 ISO C 语言与标准库;第八章故意下探到 UNIX 系统接口。
可核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“这一章讲的是 UNIX,不是全部 C”开始」的实际契约。
学完《UNIX 系统接口与存储分配》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
UNIX 系统接口与存储分配:失效与核验
从“这一章讲的是 UNIX,不是全部 C”开始
典型失效
若把「从“这一章讲的是 UNIX,不是全部 C”开始」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“这一章讲的是 UNIX,不是全部 C”开始」的实际契约。
低级 I/O:返回值就是本次进度
典型失效
若把「低级 I/O:返回值就是本次进度」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「低级 I/O:返回值就是本次进度」的实际契约。
open、creat、close 与 unlink
典型失效
若把「open、creat、close 与 unlink」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。
核验证据
以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「open、creat、close 与 unlink」的实际契约。
从“这一章讲的是 UNIX,不是全部 C”开始
K&R 前七章主要使用 ISO C 语言与标准库;第八章故意下探到 UNIX 系统接口。open、read、write、lseek、opendir 等属于 UNIX/POSIX 家族,不是任何 C 实现都必须提供。把边界写清,才能同时理解原书的系统思想与现代可移植代码的责任。
↡进程用于引用已打开文件或其他 I/O 对象的非负整数句柄;POSIX 调用通过它定位进程描述符表项。
是进程局部编号。open
成功时返回当前可用编号,通常是最低可用值,但不能断言第一次一定为 3:0、1、2
可能关闭或被重定向,进程也可能继承其他描述符。可用数量由运行环境和资源限制决定,不存在通用“默认
1024”的语言规则。
低级 I/O:返回值就是本次进度
↡绕过 ISO C 格式化流接口、直接以文件描述符和字节缓冲调用 read 与 write 等 POSIX 接口的数据传送。
把每次操作的实际进度交给调用者。read(fd, buffer, capacity)
返回正数表示本次读到的字节数,0 表示文件尾,-1
表示失败;正数小于请求量仍可能只是管道、终端、信号时机或可用数据造成的短读,不能当成文件尾。
write 返回正数时也只承诺这些字节已被接口接受,可能小于请求量。代码必须从返回位置继续,而不是整块重写;否则前缀会重复。阻塞调用被信号打断时可能以 EINTR 失败,是否重试要结合接口是否已报告进度与上层取消协议。
#include <errno.h>
#include <unistd.h>
int write_all(int fd, const unsigned char *data, size_t size)
{
size_t offset = 0;
while (offset < size) {
ssize_t written = write(fd, data + offset, size - offset);
if (written > 0) {
offset += (size_t)written;
} else if (written < 0 && errno == EINTR) {
continue;
} else {
return 0;
}
}
return 1;
}若 write 对非零请求返回 0,示例把它当失败以避免无进度死循环。对非阻塞描述符还要处理 EAGAIN/EWOULDBLOCK 并等待可写事件;那是更高层事件循环协议,不能简单忙重试。
open、creat、close 与 unlink
↡按路径和标志取得文件描述符的 POSIX 函数;使用创建标志时还接收权限模式,实际权限会受进程 umask 等环境规则影响。
用 O_RDONLY、O_WRONLY、O_RDWR 选择方向,并可组合
O_CREAT、O_TRUNC、O_APPEND 等标志。只有在使用需要创建权限的标志时才传
mode_t 参数。0666 表示允许环境从读写权限中通过 umask
移除位,不等于新文件最终必然是该权限。
↡传统 UNIX 创建接口,语义近似以只写、创建和截断标志调用 open;现代代码通常直接使用 open 明确组合标志。
是历史便捷函数。名字没有末尾 e
是早期命名限制的遗迹;它不是“只创建、不覆盖”,而会截断已存在的普通文件,所以不能在不知道目标身份时盲用。
↡释放当前进程对文件描述符的引用的 POSIX 函数;它不一定立即删除文件对象,也不保证此前写入已经物理持久化。
必须由拥有者恰好调用一次。关闭可以报告延迟错误;失败后无条件再次 close
很危险,因为某些平台上编号可能已经释放并被另一线程复用。需要持久化保证时还要使用目标系统的同步接口,close
本身不是断电事务。
↡删除路径对应目录项的 POSIX 函数;若文件仍被打开,现有描述符通常仍可访问对象,直到最后引用释放。 操作名字而不是强制关闭所有打开实例。成功后新路径查找不到该目录项,但已有描述符继续引用打开文件描述;这解释了 UNIX 临时文件模式,也说明“删除路径”与“释放描述符”是两个生命周期。
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
int open_output(const char *path)
{
return open(path, O_WRONLY | O_CREAT | O_TRUNC, (mode_t)0666);
}真实程序还要决定是否允许跟随符号链接、是否覆盖现有文件、是否原子替换,以及描述符是否跨 exec 继承。不同安全目标需要不同标志与路径协议,不能用一个复制示例覆盖所有情形。
复制文件:每条失败路径都保持单一所有者
下面用低级 I/O 组合原书的 read 与 write。它不假定一次读满,也不会在目标打开失败后泄漏源描述符。为了让主流程清楚,诊断由调用者根据返回状态和保存的上下文完成。
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#define COPY_BUFFER_SIZE 8192
int copy_descriptors(int input, int output)
{
unsigned char buffer[COPY_BUFFER_SIZE];
for (;;) {
ssize_t count = read(input, buffer, sizeof buffer);
if (count > 0) {
if (!write_all(output, buffer, (size_t)count))
return 0;
} else if (count == 0) {
return 1;
} else if (errno != EINTR) {
return 0;
}
}
}调用层应先打开输入,再打开输出;第二步失败就关闭输入。复制结束后两个描述符都要关闭,且目标关闭失败使整体失败。若输出不能留下半成品,应写入同目录临时文件,完成数据与必要元数据同步、验证关闭,再按平台语义发布;仅 O_TRUNC 原目标没有回滚能力。
随机访问与共享文件偏移
↡用 lseek 改变可定位文件描述符所关联打开文件偏移的访问方式;管道、终端等对象可能不支持定位。
通过 lseek(fd, offset, SEEK_SET/SEEK_CUR/SEEK_END)
改变后续读写位置,成功返回结果偏移,失败返回
(off_t)-1。返回位置超过当前文件末尾并不立即分配中间字节;后续写入可能形成稀疏文件,空洞读取为零,但物理分配由文件系统决定。
#include <sys/types.h>
#include <unistd.h>
int read_at(int fd, off_t position, void *buffer, size_t capacity,
ssize_t *count)
{
if (lseek(fd, position, SEEK_SET) == (off_t)-1)
return 0;
*count = read(fd, buffer, capacity);
return *count >= 0;
}这不是并发安全的定位读取:lseek 与 read 之间,另一个共享同一打开文件描述的线程或进程可能改变偏移。现代 POSIX 可使用 pread 把位置与读取合成一次操作;它不改变共享偏移。原书通过 lseek 建立随机访问概念,工程实现还要审计共享关系。
文件描述符与 FILE 流不能随意混用
POSIX
↡将现有文件描述符关联为标准 I/O 流的 POSIX 函数;成功后流接管关闭责任,fclose 会关闭关联描述符。
和 fileno 可以桥接两层,但不消除状态差异。ISO C 的 FILE *
管理用户态缓冲、读写方向、文件尾和错误状态;描述符层管理字节操作与文件偏移。标准
C 不要求 FILE 内部一定含一个 Unix fd,只有 POSIX 环境才定义这些桥接接口。
若 getc(stream) 已预读一块数据,底层描述符偏移可能早于调用者眼中的流位置。此时直接 read(fileno(stream), ...) 会跳过仍在 stdio 缓冲中的字节。输出方向也可能有尚未写到描述符层的数据。最安全规则是同一资源选定一层;必须切换时遵循 POSIX 的活动句柄协调规则,正确刷写或定位,并保持唯一关闭者。
fopen 与 getc 的实现思想
原书用一个小型 _iobuf 表展示
↡把路径模式转换为底层打开标志、分配并初始化流状态,再在失败时回滚全部资源的标准 I/O 构造过程。 思想:流对象记录方向和错误标志、缓冲区位置与剩余字符数,并关联底层句柄。真正的 ISO C 库可采用完全不同表示,应用代码不能复制或访问其私有结构。
↡从流返回下一字符的操作;快路径可从缓冲区取值,缓冲耗尽后由补充函数读取更多数据并更新文件尾或错误状态。
常被设计成宏或内联快路径,因此参数不应带有会被多次求值的副作用,除非具体标准保证一次求值。无论内部优化如何,公开返回类型是
int,以同时表示所有 unsigned char 值和负的 EOF。
#include <stddef.h>
struct TinyStream {
int descriptor;
unsigned char *next;
size_t remaining;
int eof_seen;
int error_seen;
};
int tiny_getc(struct TinyStream *stream)
{
if (stream->remaining == 0)
return tiny_fill(stream);
--stream->remaining;
return (int)*stream->next++;
}这是概念骨架,不是替代 stdio 的完整实现:缺少缓冲分配、方向切换、并发锁、文本模式、宽字符、定位、退出清理和大量错误路径。关键教学点是“快路径消费缓存,慢路径补充并设置状态”,而不是依赖某个库版本的字段名字。
目录列表:不要解析原始目录字节
↡通过目录接口枚举目录项名称,再按需要查询元数据的系统操作;目录磁盘格式与 dirent 之外字段不应由应用猜测。
在现代 POSIX 中使用 opendir、readdir、closedir。readdir
返回空指针既可能表示枚举结束,也可能表示错误,因此先把 errno
置零,循环结束后再检查。目录项名称与文件元数据是不同层;若需要大小和类型,应对完整路径调用
stat/fstatat 并处理文件在两次操作间变化的竞态。
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
int list_names(const char *path)
{
DIR *directory;
struct dirent *entry;
int ok;
directory = opendir(path);
if (directory == NULL)
return 0;
errno = 0;
while ((entry = readdir(directory)) != NULL)
if (printf("%s\n", entry->d_name) < 0) {
closedir(directory);
return 0;
}
ok = errno == 0;
if (closedir(directory) == -1)
ok = 0;
return ok;
}readdir 返回的指针由目录流管理,下一次调用可覆盖内容,关闭后失效;需要持久保存名称就复制。不要默认 d_type 存在或总是有效,也不要直接把名称拼接到固定数组而忽略长度。显示顺序由文件系统决定,需要稳定输出时收集、验证分配,再排序。
存储分配器:对齐、溢出与空闲链
K&R 的
↡管理一片存储中的已用块和空闲块,为 malloc 类请求寻找、拆分或扩展块,并在 free 时归还和合并空间的组件。
用块头记录大小,用循环空闲链寻找可用块,相邻块释放后合并以减轻外部碎片。教材还用联合成员尝试获得足够对齐;现代标准和
ABI
的类型集合更复杂,生产分配器必须满足“适合任何具有基本对齐要求对象”的契约,而不能假设按
long 或指针对齐就覆盖一切。
调用 malloc 前还要检查尺寸算术。count * size 若在 size_t 中回绕,会分配一个过小对象,随后按原元素数写入越界。零大小请求可以返回空指针或不可解引用的唯一指针,调用者不应写入;free(NULL) 安全,但重复释放、释放内部地址或释放非分配器对象均未定义。
#include <stddef.h>
#include <stdlib.h>
void *allocate_array(size_t count, size_t element_size)
{
size_t maximum = (size_t)-1;
if (element_size != 0 && count > maximum / element_size)
return NULL;
return malloc(count * element_size);
}教材分配器用于理解边界标签、首次适配、拆分和合并,不应直接投入生产:它没有现代线程同步、安全加固、大页策略、调试隔离和平台内存归还。自己实现分配器只有在明确环境、完整测试和可观测性要求下才合理;普通应用应使用维护良好的运行库。
先预测:
malloc(0)返回非空指针,是否可以写一个字节?不可以;零大小结果不能被解引用,唯一可靠操作是按接口需要保存比较并最终free。
系统接口的可移植边界
K&R 的价值不是让所有程序都绕过 stdio,而是展示抽象如何建立:低级 I/O 只承诺本次字节进度,标准流在其上增加缓冲与格式,目录 API 隔离文件系统布局,分配器隔离原始存储来源。实现层可以变化,公开协议必须稳定。
当目标是 ISO C 可移植程序,优先使用 fopen、fread、fwrite、fseek、malloc。当需要描述符继承、权限位、非阻塞 I/O、目录遍历等 UNIX 能力,明确声明 POSIX 版本和平台前置条件,检查特性宏与返回值,并为 Windows 或嵌入式环境提供适配层。
小结
- 文件描述符是 POSIX 进程句柄;编号和数量受当前进程状态与资源限制影响,不能写死首次为 3 或上限 1024
read与write以返回值报告本次进度,短读短写、中断和无进度都需要显式策略open/creat取得资源,close释放描述符引用,unlink删除目录项;这些生命周期不能混为一谈lseek改变共享文件偏移且并非所有对象都可定位,并发位置式读取应避免两步竞态FILE流与描述符是两层状态机;桥接后也必须协调缓冲、偏移和唯一关闭责任- 目录列表依赖公开目录 API;教材分配器用于理解空闲链、对齐和合并,生产代码还要处理溢出与平台要求
练习
问题 1 设计一个可靠的描述符复制循环:分别说明短读、短写、EINTR、目标打开失败和关闭失败怎样处理。
问题 2 为什么在 getc(stream) 后直接 read(fileno(stream), ...) 可能跳过数据?lseek 加 read 又为什么不适合共享偏移的并发读取?
问题 3 解释 K&R 空闲链分配器必须维护的三个不变量,并说明为什么 malloc(count * size) 前需要除法形式的溢出检查。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 文件描述符
POSIX 进程用于引用打开文件或其他 I/O 对象的非负整数句柄,具体编号由当前描述符表状态决定。
- 低级 I/O
以描述符和字节缓冲调用
read、write等 POSIX 接口的传送方式,每次返回实际进度。- open
按路径、方向和创建等标志取得描述符的 POSIX 函数,创建权限还受
umask等环境规则影响。- creat
传统 UNIX 创建函数,近似只写、创建并截断形式的
open,不会保护已有文件免于覆盖。- close
释放当前进程描述符引用的函数,可能报告延迟错误,但不等同于删除路径或保证物理持久化。
- unlink
删除路径目录项的函数;已有打开引用可继续使用对象,直到最后引用释放。
- 随机访问
通过
lseek改变可定位对象文件偏移的访问方式;共享打开文件描述的执行者可能共享该偏移。- fdopen
把已有描述符关联为标准 I/O 流的 POSIX 函数;成功后
fclose负责关闭关联描述符。- fopen 实现
将模式映射为底层资源,并初始化方向、缓冲、位置和错误状态的构造过程;具体
FILE布局不公开。- getc 实现
从流缓冲取下一字符,耗尽时补充缓冲并设置文件尾或错误状态的快慢路径设计。
- 目录列表
经目录 API 枚举名称并按需查询元数据的操作,不依赖文件系统私有目录字节布局。
- 存储分配器
管理空闲块并满足分配、对齐、拆分、合并和释放协议的组件;生产实现还需线程与安全加固。