C++ POSIX API超详细分析
发布时间:2026/10/8 14:37:21 锦皓数字建站

前言POSIXPortable Operating System Interface是一套C 语言接口标准不是 C 标准的一部分。它规定了open、read、write、fork、pthread_create这些函数的语义也规定了unistd.h、fcntl.h、sys/stat.h这些头文件里该有什么。C 代码当然可以直接调用它们——头文件里的声明本身就带extern C所以不存在名字修饰name mangling不匹配的问题。先说三个必须澄清的前提否则后面全是空中楼阁POSIX 不是「Linux API」。Linux 基本符合 POSIX又加了一大堆自己的扩展epoll、O_DIRECT、eventfd、clone都不是 POSIX。反过来 macOS 是认证过的 UNIXPOSIX 符合度很高但很多 Linux 扩展没有它用kqueue。Windows 原生不提供 POSIX 接口。MSVC 里没有unistd.hopen/read/write叫_open/_read/_write且语义不同。想用真正的 POSIX 只能走 WSL、Cygwin、MSYS2 这类环境或者用 MinGW 的部分实现。POSIX 函数不抛异常只用返回值和errno报错。所以「C 里怎么用好 POSIX」这个问题的核心答案就是用 RAII 把文件描述符、映射内存、互斥量这些资源管起来——这是本文的重点。本文的代码在 Linux 和 macOS 上可编译g -stdc17 -Wall -Wextra demo.cpp -o demo。一、文件描述符与基本 I/O文件描述符file descriptorfd是一个非负整数是进程内打开文件、管道、套接字、终端的统一句柄。三个标准 fd 是 0标准输入、1标准输出、2标准错误。最核心的五个函数签名如下来自 POSIXint open(const char *path, int oflag, ...); ssize_t read(int fildes, void *buf, size_t nbyte); ssize_t write(int fildes, const void *buf, size_t nbyte); int close(int fildes); off_t lseek(int fildes, off_t offset, int whence);注意open是变参函数只有带了O_CREAT时才需要第三个参数mode_t mode。不带O_CREAT却传了模式多余参数会被忽略反过来带O_CREAT却不传模式则模式是栈上的垃圾值文件权限完全随机——这是实打实的 UB。ssize_t是有符号的这一点至关重要read/write返回-1表示出错。若把它当成无符号的size_t接收-1会变成 64 位最大值后续所有长度计算全错。另外两个必须记住的行为短读短写short read / short write。read返回的字节数可能小于请求的字节数write更是常见尤其是写到管道或套接字时。必须循环直到写满只调用一次就当作成功是 bug 的经典来源。EINTR。当进程收到信号signal时某些阻塞的系统调用会中断并返回-1同时把errno置为EINTR。这不是真错误应该重试。二、把 fd 包装成 RAII 类型裸 fd 最大的问题是「所有提前返回的分支都得记得close」。C 的答案是 RAIIResource Acquisition Is Initialization把资源交给一个对象的生命周期管理。#include cerrno #include cstddef #include cstdio #include cstring #include string #include fcntl.h #include sys/stat.h #include sys/types.h #include unistd.h // 只读文件描述符的 RAII 包装不拷贝、可移动。 class Fd { public: Fd() noexcept default; explicit Fd(int fd) noexcept : fd_(fd) {} ~Fd() { reset(); } Fd(const Fd) delete; // 拷贝会带来「两次 close 同一个 fd」UB Fd operator(const Fd) delete; Fd(Fd other) noexcept : fd_(other.fd_) { other.fd_ -1; } Fd operator(Fd other) noexcept { if (this ! other) { reset(); fd_ other.fd_; other.fd_ -1; } return *this; } int get() const noexcept { return fd_; } bool valid() const noexcept { return fd_ 0; } void reset(int newFd -1) noexcept { if (fd_ 0) { // 注意Linux 上不要因为 EINTR 重试 close()见坑点第 3 条 ::close(fd_); } fd_ newFd; } private: int fd_ -1; }; // 循环写满处理短写和 EINTR bool writeAll(int fd, const char* data, std::size_t len) { std::size_t written 0; while (written len) { const ssize_t n ::write(fd, data written, len - written); if (n 0) { if (errno EINTR) { continue; // 被信号打断重试 } return false; } if (n 0) { return false; // 防御理论上不该发生 } written static_caststd::size_t(n); } return true; }reset()里对close()的返回值故意不处理Linux 上如果close()因EINTR失败文件描述符其实已经被释放了重试可能关掉另一个线程刚打开的新 fd——这是个真实存在、且很难复现的竞态。稳妥做法是干脆不重试。三、完整示例写文件、stat、mmap下面这段把上面几个 API 串起来用g -stdc17 demo.cpp -o demo在 Linux 或 macOS 上可直接编译运行。int main() { const char* path posix_demo.txt; const std::string payload hello, posix\n; // 1) 写文件 { Fd fd(::open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644)); if (!fd.valid()) { std::fprintf(stderr, open(O_WRONLY) failed: %s\n, std::strerror(errno)); return 1; } if (!writeAll(fd.get(), payload.data(), payload.size())) { std::fprintf(stderr, write failed: %s\n, std::strerror(errno)); return 1; } } // 作用域结束Fd 析构自动 close // 2) fstat 取文件大小 struct stat st{}; { Fd fd(::open(path, O_RDONLY)); if (!fd.valid()) { std::fprintf(stderr, open(O_RDONLY) failed: %s\n, std::strerror(errno)); return 1; } if (::fstat(fd.get(), st) ! 0) { std::fprintf(stderr, fstat failed: %s\n, std::strerror(errno)); return 1; } // off_t 的宽度和 int 不同必须转成 long long 再配 %lld std::printf(size %lld bytes\n, static_castlong long(st.st_size)); } // 3) mmap 只读映射 { Fd fd(::open(path, O_RDONLY)); if (!fd.valid()) { return 1; } const std::size_t len static_caststd::size_t(st.st_size); if (len 0) { return 0; } void* addr ::mmap(nullptr, len, PROT_READ, MAP_PRIVATE, fd.get(), 0); if (addr MAP_FAILED) { // MAP_FAILED 就是 (void*)-1 std::fprintf(stderr, mmap failed: %s\n, std::strerror(errno)); return 1; } const char* p static_castconst char*(addr); std::fwrite(p, 1, len, stdout); ::munmap(addr, len); // 必须配对否则映射会一直留着 } return 0; }输出是size 13 bytes hello, posix几个关键点mmap之后即使close(fd)映射依然有效。映射建立时内核已经把它和文件关联起来了fd 只是用来「打开发起映射」的。所以上面第 3 段里Fd析构关掉 fd 之后映射照常可用本段代码里munmap发生在作用域内顺序无所谓。mmap的长度必须是页大小page size的整数倍吗传len时不需要内核会按页对齐并映射但超出文件末尾的那部分页面一旦访问就会收到SIGBUS。所以映射长度只有len是安全的把映射当成「整个页」来访问是取死之道。MAP_FAILED是(void*)-1不是nullptr。判空判错了就会出现「检查通过然后崩溃」。fstat的st_size是off_t。在 64 位 Linux 上它是 64 位有符号整数用%d打印是未定义行为——参数类型和格式串不匹配。四、进程与信号fork、exec 与 SIGPIPE进程相关的最常用一组是fork/execve/waitpid加上管道pipepid_t fork(void); int execve(const char *path, char *const argv[], char *const envp[]); pid_t waitpid(pid_t pid, int *wstatus, int options); int pipe(int fildes[2]);fork()之后父子进程各自继续靠返回值区分父进程拿到子进程 pid子进程拿到 0。要点在多线程程序里fork之后子进程只能安全调用 async-signal-safe 的函数write、_exit、execve等在名单里printf、malloc、std::string的操作都不在名单里。原因是子进程只复制了调用fork的那个线程其他线程持有的锁包括malloc内部锁在子进程里永远处于「被锁住」的状态一调用就死锁。因此多线程程序想启动子进程正确姿势是fork之后立刻execve换掉整个映像中间不要碰任何非 async-signal-safe 的东西。更省心的做法是用posix_spawn。SIGPIPE向一个已经关闭读端的管道或已断开的套接字write内核会给进程发SIGPIPE默认动作是终止进程。网络服务如果没处理这个信号会因为客户端断开连接而整个挂掉。处理方式有两种全局忽略SIGPIPEsignal(SIGPIPE, SIG_IGN)然后靠write返回EPIPE来判断或者写套接字时用send加MSG_NOSIGNAL标志。关于 I/O 多路复用select和poll是 POSIX 标准里的epoll是 Linux 专有kqueue是 BSD/macOS 专有。写跨平台代码时这一层通常需要封装。常见坑点忽略write的返回值❌::write(fd, buf, len);就当写完了管道和套接字上的短写会静默丢数据。✅ 循环调用直到写满并且每次用实际返回值推进偏移。忽略EINTR❌if (::read(fd, buf, n) 0) return -1;被信号打断就误判成真错误。✅ 判断errno EINTR后重试。close因EINTR重试❌while (::close(fd) -1 errno EINTR) {}Linux 上 fd 已经被释放重试可能关掉别的线程刚分配的 fd。✅ 不重试或者干脆不管返回值。open不带O_CLOEXEC❌ 打开 fd 后forkexecve子进程继承了不该拿到的 fd比如监听套接字、日志文件。✅ 加O_CLOEXECPOSIX.1-2008 起可用或者用fcntl设FD_CLOEXEC——前者是原子的后者有竞态窗口。多线程里fork后调用非 async-signal-safe 函数❌ 子进程里调printf或new可能死锁在别人持有的锁上。✅fork后只做execve或改用posix_spawn。没处理SIGPIPE❌ 客户端断开后服务端进程直接被杀。✅signal(SIGPIPE, SIG_IGN)并检查EPIPE或send时带MSG_NOSIGNAL。mmap后不解映射 / 访问超出文件大小的页❌ 映射没munmap会一直占着虚拟地址空间访问文件末尾之后的字节会收到SIGBUS。✅ 配对munmap并且只访问文件内实际存在的字节。用%d打印off_t/ssize_t❌ 类型宽度不匹配是 UB在 64 位平台上输出被截断。✅ 转成long long配%lld或者干脆用std::cout。总结需求POSIX 接口C 标准库替代选择建议打开文件openstd::ifstream/std::ofstream需要 fd 传给epoll、mmap、sendfile只能用 POSIX读写read/writeistream::read/ostream::write要处理短读短写和EINTR时POSIX 更可控文件元信息stat/fstatstd::filesystem::file_sizeC17标准库版本跨平台老 GCC8.x 及更早需额外链接-lstdcfs内存映射mmap/munmap无只能走 POSIX进程创建fork/execve/posix_spawnstd::system功能弱得多需要控制 fd 和信号时用 POSIX线程pthread_createstd::thread底层通常就是 pthread优先std::thread错误处理返回值 errno异常POSIX 层立即把errno存下来再翻译成 C 异常C 用 POSIX 的正确姿势可以归纳成两句话资源用 RAII 包起来错误用返回值立刻处理。前者保证异常和提前返回都不会漏掉close/munmap后者是因为errno是个全局的、随时会被下一次调用覆盖的值——必须在使用前先存到局部变量里否则打印出来的错误信息完全对不上真正出错的那次调用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。