UNIX 系统接口与存储分配

复刻 K&R 第八章:文件描述符、低级 I/O、open/close/unlink、lseek、stdio 实现思想、目录遍历与存储分配器。

学习目标

  • 能解释文件描述符与标准 I/O 流的分层状态,实现正确处理短读、短写和中断的文件复制
  • 能比较 opencreatcloseunlinklseek 的资源和偏移语义,判断哪些属于 POSIX 而非 ISO C
  • 能设计基于公开接口的目录列表,并分析教材版 fopen/getc 与存储分配器的对齐、溢出和所有权不变量

机制总览

UNIX 系统接口与存储分配:机制路径

  1. 1

    从“这一章讲的是 UNIX,不是全部 C”开始

    K&R 前七章主要使用 ISO C 语言与标准库;第八章故意下探到 UNIX 系统接口。

  2. 2

    低级 I/O:返回值就是本次进度

    write 返回正数时也只承诺这些字节已被接口接受,可能小于请求量。代码必须从返回位置继续,而不是整块重写;否则前缀会重复。阻塞调用被信号打断时可能以 EINTR 失败,是否重试要结合接口是否已报告进度与上层取消协议。

  3. 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 系统接口。openreadwritelseekopendir 等属于 UNIX/POSIX 家族,不是任何 C 实现都必须提供。把边界写清,才能同时理解原书的系统思想与现代可移植代码的责任。

是进程局部编号。open 成功时返回当前可用编号,通常是最低可用值,但不能断言第一次一定为 3:0、1、2 可能关闭或被重定向,进程也可能继承其他描述符。可用数量由运行环境和资源限制决定,不存在通用“默认 1024”的语言规则。

低级 I/O:返回值就是本次进度

把每次操作的实际进度交给调用者。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 并等待可写事件;那是更高层事件循环协议,不能简单忙重试。

O_RDONLYO_WRONLYO_RDWR 选择方向,并可组合 O_CREATO_TRUNCO_APPEND 等标志。只有在使用需要创建权限的标志时才传 mode_t 参数。0666 表示允许环境从读写权限中通过 umask 移除位,不等于新文件最终必然是该权限。

是历史便捷函数。名字没有末尾 e 是早期命名限制的遗迹;它不是“只创建、不覆盖”,而会截断已存在的普通文件,所以不能在不知道目标身份时盲用。

必须由拥有者恰好调用一次。关闭可以报告延迟错误;失败后无条件再次 close 很危险,因为某些平台上编号可能已经释放并被另一线程复用。需要持久化保证时还要使用目标系统的同步接口,close 本身不是断电事务。

操作名字而不是强制关闭所有打开实例。成功后新路径查找不到该目录项,但已有描述符继续引用打开文件描述;这解释了 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 组合原书的 readwrite。它不假定一次读满,也不会在目标打开失败后泄漏源描述符。为了让主流程清楚,诊断由调用者根据返回状态和保存的上下文完成。

#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(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;
}

这不是并发安全的定位读取:lseekread 之间,另一个共享同一打开文件描述的线程或进程可能改变偏移。现代 POSIX 可使用 pread 把位置与读取合成一次操作;它不改变共享偏移。原书通过 lseek 建立随机访问概念,工程实现还要审计共享关系。

文件描述符与 FILE 流不能随意混用

POSIX

fileno 可以桥接两层,但不消除状态差异。ISO C 的 FILE * 管理用户态缓冲、读写方向、文件尾和错误状态;描述符层管理字节操作与文件偏移。标准 C 不要求 FILE 内部一定含一个 Unix fd,只有 POSIX 环境才定义这些桥接接口。

getc(stream) 已预读一块数据,底层描述符偏移可能早于调用者眼中的流位置。此时直接 read(fileno(stream), ...) 会跳过仍在 stdio 缓冲中的字节。输出方向也可能有尚未写到描述符层的数据。最安全规则是同一资源选定一层;必须切换时遵循 POSIX 的活动句柄协调规则,正确刷写或定位,并保持唯一关闭者。

fopen 与 getc 的实现思想

原书用一个小型 _iobuf 表展示

思想:流对象记录方向和错误标志、缓冲区位置与剩余字符数,并关联底层句柄。真正的 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 的完整实现:缺少缓冲分配、方向切换、并发锁、文本模式、宽字符、定位、退出清理和大量错误路径。关键教学点是“快路径消费缓存,慢路径补充并设置状态”,而不是依赖某个库版本的字段名字。

目录列表:不要解析原始目录字节

在现代 POSIX 中使用 opendirreaddirclosedirreaddir 返回空指针既可能表示枚举结束,也可能表示错误,因此先把 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 的

用块头记录大小,用循环空闲链寻找可用块,相邻块释放后合并以减轻外部碎片。教材还用联合成员尝试获得足够对齐;现代标准和 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 可移植程序,优先使用 fopenfreadfwritefseekmalloc。当需要描述符继承、权限位、非阻塞 I/O、目录遍历等 UNIX 能力,明确声明 POSIX 版本和平台前置条件,检查特性宏与返回值,并为 Windows 或嵌入式环境提供适配层。

小结

  • 文件描述符是 POSIX 进程句柄;编号和数量受当前进程状态与资源限制影响,不能写死首次为 3 或上限 1024
  • readwrite 以返回值报告本次进度,短读短写、中断和无进度都需要显式策略
  • open/creat 取得资源,close 释放描述符引用,unlink 删除目录项;这些生命周期不能混为一谈
  • lseek 改变共享文件偏移且并非所有对象都可定位,并发位置式读取应避免两步竞态
  • FILE 流与描述符是两层状态机;桥接后也必须协调缓冲、偏移和唯一关闭责任
  • 目录列表依赖公开目录 API;教材分配器用于理解空闲链、对齐和合并,生产代码还要处理溢出与平台要求

练习

问题 1 设计一个可靠的描述符复制循环:分别说明短读、短写、EINTR、目标打开失败和关闭失败怎样处理。

问题 2 为什么在 getc(stream) 后直接 read(fileno(stream), ...) 可能跳过数据?lseekread 又为什么不适合共享偏移的并发读取?

问题 3 解释 K&R 空闲链分配器必须维护的三个不变量,并说明为什么 malloc(count * size) 前需要除法形式的溢出检查。

名词解释

本章出现的专业名词,用大白话再讲一遍。

文件描述符

POSIX 进程用于引用打开文件或其他 I/O 对象的非负整数句柄,具体编号由当前描述符表状态决定。

低级 I/O

以描述符和字节缓冲调用 readwrite 等 POSIX 接口的传送方式,每次返回实际进度。

open

按路径、方向和创建等标志取得描述符的 POSIX 函数,创建权限还受 umask 等环境规则影响。

creat

传统 UNIX 创建函数,近似只写、创建并截断形式的 open,不会保护已有文件免于覆盖。

close

释放当前进程描述符引用的函数,可能报告延迟错误,但不等同于删除路径或保证物理持久化。

随机访问

通过 lseek 改变可定位对象文件偏移的访问方式;共享打开文件描述的执行者可能共享该偏移。

fdopen

把已有描述符关联为标准 I/O 流的 POSIX 函数;成功后 fclose 负责关闭关联描述符。

fopen 实现

将模式映射为底层资源,并初始化方向、缓冲、位置和错误状态的构造过程;具体 FILE 布局不公开。

getc 实现

从流缓冲取下一字符,耗尽时补充缓冲并设置文件尾或错误状态的快慢路径设计。

目录列表

经目录 API 枚举名称并按需查询元数据的操作,不依赖文件系统私有目录字节布局。

存储分配器

管理空闲块并满足分配、对齐、拆分、合并和释放协议的组件;生产实现还需线程与安全加固。

讨论

评论区加载中…