前言
POSIX(Portable 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 是认证过的 UNIX,POSIX 符合度很高,但很多 Linux 扩展没有(它用kqueue)。 - Windows 原生不提供 POSIX 接口。MSVC 里没有
<unistd.h>,open/read/write叫_open/_read/_write且语义不同。想用真正的 POSIX 只能走 WSL、Cygwin、MSYS2 这类环境,或者用 MinGW 的部分实现。 - POSIX 函数不抛异常,只用返回值和
errno报错。所以「C++ 里怎么用好 POSIX」这个问题的核心答案,就是用 RAII 把文件描述符、映射内存、互斥量这些资源管起来——这是本文的重点。
本文的代码在 Linux 和 macOS 上可编译(g++ -std=c++17 -Wall -Wextra demo.cpp -o demo)。
一、文件描述符与基本 I/O
文件描述符(file descriptor,fd)是一个非负整数,是进程内打开文件、管道、套接字、终端的统一句柄。三个标准 fd 是 0(标准输入)、1(标准输出)、2(标准错误)。
最核心的五个函数,签名如下(来自 POSIX):
int 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++ 的答案是 RAII(Resource 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_cast<std::size_t>(n); } return true; }reset()里对close()的返回值故意不处理:Linux 上如果close()因EINTR失败,文件描述符其实已经被释放了,重试可能关掉另一个线程刚打开的新 fd——这是个真实存在、且很难复现的竞态。稳妥做法是干脆不重试。
三、完整示例:写文件、stat、mmap
下面这段把上面几个 API 串起来,用g++ -std=c++17 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_cast<long long>(st.st_size)); } // 3) mmap 只读映射 { Fd fd(::open(path, O_RDONLY)); if (!fd.valid()) { return 1; } const std::size_t len = static_cast<std::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_cast<const 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,加上管道pipe:
pid_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,默认动作是终止进程。网络服务如果没处理这个信号,会因为客户端断开连接而整个挂掉。处理方式有两种:全局忽略SIGPIPE(signal(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 后fork+execve,子进程继承了不该拿到的 fd(比如监听套接字、日志文件)。✅ 加O_CLOEXEC(POSIX.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++ 标准库替代 | 选择建议 |
|---|---|---|---|
| 打开文件 | open | std::ifstream/std::ofstream | 需要 fd 传给epoll、mmap、sendfile只能用 POSIX |
| 读写 | read/write | istream::read/ostream::write | 要处理短读短写和EINTR时,POSIX 更可控 |
| 文件元信息 | stat/fstat | std::filesystem::file_size(C++17) | 标准库版本跨平台,老 GCC(8.x 及更早)需额外链接-lstdc++fs |
| 内存映射 | mmap/munmap | 无 | 只能走 POSIX |
| 进程创建 | fork/execve/posix_spawn | std::system(功能弱得多) | 需要控制 fd 和信号时用 POSIX |
| 线程 | pthread_create | std::thread(底层通常就是 pthread) | 优先std::thread |
| 错误处理 | 返回值 +errno | 异常 | POSIX 层立即把errno存下来再翻译成 C++ 异常 |
C++ 用 POSIX 的正确姿势,可以归纳成两句话:资源用 RAII 包起来,错误用返回值立刻处理。前者保证异常和提前返回都不会漏掉close/munmap;后者是因为errno是个全局的、随时会被下一次调用覆盖的值——必须在使用前先存到局部变量里,否则打印出来的错误信息完全对不上真正出错的那次调用。