
C语言中的write函数:参数、返回值与常见错误
在 C 程序中,写文件、向终端输出或通过管道发送数据,常会遇到 write。它看起来很简单:给出一个文件描述符、一段内存和长度,函数便尝试把数据写出去。但“尝试”是关键:一次调用不一定写完全部数据,返回值也不总是等于请求长度。忽略这些边界,可能导致输出被截断、程序忙等,或把错误当成成功。
本文介绍 POSIX 环境中的 write,讲清参数、返回值、错误处理和适用场景,并给出可编译运行的示例。它不是 ISO C 标准库函数:在 Linux、macOS 等 POSIX 系统上可用;Windows 原生接口和头文件有所不同,不能假设同一段代码在所有平台上都能直接编译。
1. 函数声明与适用场景
在 POSIX 系统中,声明通常来自 <unistd.h>:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
write 面向的是文件描述符,而不是 C 标准库的 FILE *。文件描述符通常由 open、pipe、socket 等系统接口返回;标准输入、标准输出和标准错误通常分别是 0、1、2。它适合需要直接使用 POSIX 文件描述符的场景,例如底层文件 I/O、管道通信或向终端输出。
如果程序使用 fopen、fprintf、fwrite 等标准 I/O 接口,通常应继续使用这些接口。它们与 write 的缓冲机制不同:直接对同一个文件交替使用两套接口,可能因缓冲和文件偏移管理不当而造成顺序混乱。确实需要切换时,应按 POSIX 对流与底层文件描述符同步的规则处理;不要把它们当成可以随意混用的同一层 API。
2. 三个参数分别表示什么
2.1 fd:目标文件描述符
第一个参数 fd 是已经打开、且可写的文件描述符。若它无效,或对应对象不支持写操作,调用会失败。例如,对只读方式打开的文件描述符调用 write,通常会得到 EBADF。描述符的含义由它所引用的对象决定:它可能指向普通文件、终端、管道或其他 POSIX 对象,行为和错误条件也会因此有所差异。
不要将文件描述符和 C 标准库流混淆:STDOUT_FILENO 是描述符常量(通常为 1),而 stdout 是 FILE *。前者传给 write,后者传给 printf、fprintf 等标准 I/O 函数。
2.2 buf:待写入内存的起始地址
buf 指向要写出的字节。它的类型是 const void *,意味着 write 不会通过该指针修改数据;这并不意味着函数会替调用者复制或管理这段内存。调用期间,缓冲区必须有效且可读,不能在数据尚未交给内核前被释放或改写。
write 按字节处理数据,不会查找字符串结尾的 '\0',也不会把字符串自动转换成文本格式。写字符串时,必须明确传入希望写出的长度;写结构体、图像片段或其他二进制数据时,也应使用真实缓冲区长度,而不是依赖字符串函数。
2.3 count:最多请求写入的字节数
count 是本次请求写出的字节数,类型为 size_t。它表示长度,不是元素个数,也不是字符串容量。若写出的对象是数组,通常要传入数组实际占用的字节数;对字符串则要先明确是否需要包含末尾的空字符。
count 为 0 时,函数不请求写入数据。POSIX 允许此时返回 0;对某些文件类型,仍可能检测并报告错误,因此不应把“零长度调用必定验证了描述符”作为程序逻辑。请求长度还受返回类型 ssize_t 的表示范围约束:POSIX 对大于 SSIZE_MAX 的请求规定了实现相关行为。实际程序应避免把超大数据长度不加处理地传给一次调用,而应分块写入。
3. 返回值:不能只判断是否为 -1
成功时,write 返回实际写出的字节数,类型为 ssize_t。失败时返回 -1,并设置 errno 说明原因。因此,正确的基本判断是:
- 返回值等于请求长度:本次请求的字节全部写出。
- 返回值大于 0,但小于请求长度:只写出了一部分,剩余部分仍需处理。
- 返回值为 0:本次没有写出数据。若请求长度大于 0,应避免无条件重试造成死循环。
- 返回值为 -1:发生错误,应结合
errno判断是否重试或报告失败。
最常见的错误之一,是只检查 write(fd, buf, count) < 0,然后把任何非负结果都当成全部成功。若只写出部分数据,后半段会丢失。写入文件、管道或其他目标时,都应根据返回的实际字节数推进缓冲区位置。
4. 为什么会出现部分写入
部分写入并不一定意味着程序出错。POSIX 允许在一些情况下返回已经写出的字节数,即使它小于请求长度。常见原因包括:
- 信号在写入过程中到达:若调用前一个字节都没写入,可能以
EINTR失败;若已经写出部分数据,通常返回已写出的字节数,而不是用EINTR抹掉进度。 - 文件系统空间、配额或文件大小限制导致写入提前停止。
- 非阻塞文件描述符暂时只能接受一部分数据,或暂时无法接受任何数据。
- 管道、终端、套接字等对象具有各自的容量和传输特性。
需要区分“当前调用返回了多少”和“整个业务数据是否可靠地保存或送达”。write 成功返回,只表示内核接受了相应字节;对普通文件而言,它不等价于数据已经持久化到物理介质。若应用对断电后的持久性有要求,还要根据平台与文件系统语义考虑 fsync 等接口,并处理它们自身可能发生的错误。
5. 可复用的完整写入函数
下面的函数循环处理部分写入,并在遇到 EINTR 时重试。示例针对 POSIX 系统,适用于阻塞式描述符的常见用法;遇到非阻塞描述符的 EAGAIN 或 EWOULDBLOCK 时,它会返回错误,调用者应通过 poll、select 或其他事件机制等待可写后再继续,而不是忙循环。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <limits.h>
#include <stddef.h>
#include <unistd.h>
/*
* 成功时返回 0;失败时返回 -1,并保留 write 设置的 errno。
* fd 必须是有效的可写描述符。
*/
int write_all(int fd, const void *buffer, size_t length)
{
const unsigned char *p = buffer;
size_t done = 0;
while (done < length) {
size_t chunk = length - done;
/*
* 避免单次请求超过 ssize_t 可表示的正数范围。
* 具体系统对单次传输还可能有更小的限制;
* write 返回部分长度时,下面的循环会继续处理。
*/
if (chunk > (size_t)SSIZE_MAX) {
chunk = (size_t)SSIZE_MAX;
}
ssize_t n = write(fd, p + done, chunk);
if (n > 0) {
done += (size_t)n;
continue;
}
if (n == -1 && errno == EINTR) {
continue;
}
if (n == 0) {
/* 请求长度非零却没有进展,避免无限循环。 */
errno = EIO;
}
return -1;
}
return 0;
}
将代码保存为 write_all.c,在常见 POSIX 系统上可用以下命令编译运行:
cc -std=c11 -Wall -Wextra -pedantic write_all.c -o write_all
这个函数在全部写完时返回 0;失败时返回 -1。若写出了一部分后才失败,函数仍返回 -1,但调用者无法仅从返回值知道已经写出多少字节。对于网络协议、日志队列等需要记录精确进度的场景,可把已写长度作为输出参数,或设计成返回“已完成字节数并另报错误”的接口。不能因为函数名包含 all,就假设错误时没有任何数据写出。
上例用 SSIZE_MAX 限制单次请求大小。这个宏由 POSIX 环境提供;具体系统对一次调用实际能传输的数量还可能施加更小的限制。分块逻辑的作用是避免把超出返回类型可表示范围的长度直接交给 write,并不意味着一次系统调用一定能写完整个分块。
6. 一个可运行的标准输出示例
以下程序将指定字节写到标准输出。它特意按数组长度写入,因此不会依赖字符串结束符。代码可保存为 hello_write.c,用 cc -std=c11 -Wall -Wextra -pedantic hello_write.c -o hello_write 编译。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
static const char message[] = "Hello from write()\n";
size_t done = 0;
const size_t length = sizeof(message) - 1;
while (done < length) {
ssize_t n = write(STDOUT_FILENO,
message + done,
length - done);
if (n > 0) {
done += (size_t)n;
continue;
}
if (n == -1 && errno == EINTR) {
continue;
}
if (n == 0) {
errno = EIO;
}
fprintf(stderr, "write: %s\n", strerror(errno));
return 1;
}
return 0;
}
sizeof(message) - 1 计算字符串字面量中的可见内容及换行符长度,不包含编译器追加的结尾空字符。如果改成 sizeof(message),末尾的 '\0' 也会被写出;对终端看起来可能没有明显差异,但对二进制文件或协议数据而言,这通常是实际多写的一个字节。
7. 写入文件:打开方式与权限边界
write 本身不会创建文件,也不会设置文件权限。通常先用 open 创建或打开文件,再把返回的描述符交给它。下面是一个保存固定内容的完整程序,展示了创建、截断、权限参数和关闭文件的基本流程:
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(void)
{
static const char data[] = "saved by write()\n";
const char *path = "output.txt";
int fd = open(path,
O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC,
0600);
if (fd == -1) {
fprintf(stderr, "open: %s\n", strerror(errno));
return 1;
}
size_t done = 0;
size_t length = sizeof(data) - 1;
while (done < length) {
ssize_t n = write(fd, data + done, length - done);
if (n > 0) {
done += (size_t)n;
continue;
}
if (n == -1 && errno == EINTR) {
continue;
}
if (n == 0) {
errno = EIO;
}
fprintf(stderr, "write: %s\n", strerror(errno));
close(fd);
return 1;
}
if (close(fd) == -1) {
fprintf(stderr, "close: %s\n", strerror(errno));
return 1;
}
return 0;
}
编译并运行后,程序会在当前目录创建或清空 output.txt,并写入内容。O_TRUNC 会截断已有文件,正式程序使用前应确认这是预期行为。第三个参数 0600 是请求的创建权限:文件最终权限还会受进程的 umask 影响;如果文件原本已经存在,这个创建权限参数不会重设它的权限。O_CLOEXEC 用于让描述符在成功执行新程序时关闭,降低描述符意外泄露给子程序的风险;并非每个平台都支持完全相同的标志,移植代码应查阅目标系统文档。
示例在写入失败时调用了 close,但没有覆盖原错误信息。生产代码还应认真处理关闭和其他清理逻辑;同时要注意,成功写入并成功关闭也不等于满足所有持久化需求。若安全地创建敏感文件,除了权限模式,还要审慎选择目录、路径解析和打开标志,防止不可信路径带来的替换、符号链接或目录权限问题。单靠 write 无法解决这些路径安全问题。
8. 常见错误码与处理方式
失败时,检查 errno,不要只记录“写入失败”。常见错误包括:
EBADF:描述符无效,或没有以可写方式打开。检查open参数、描述符生命周期和是否误传了FILE *。EINTR:在没有写入数据时被信号中断。对普通阻塞式写入,通常可以重试;若已有部分数据写出,程序应根据正的返回值推进位置,而不是重新发送整段数据。EAGAIN或EWOULDBLOCK:非阻塞描述符当前无法继续写入,或暂时不能写。先等待可写事件,再从未完成的位置继续;不要在循环中立即反复调用。EPIPE:写入管道或套接字时,对端已关闭读取端。进程还可能收到SIGPIPE;默认处理通常会终止进程。需要自定义错误处理的程序应了解并配置相应的信号或套接字机制。ENOSPC:设备或文件系统没有可用空间。只重试通常不能解决问题,应向上层报告并按业务策略处理。EFBIG:写入会超过文件大小限制等条件;程序可能需要检查资源限制或目标文件的预期大小。EIO:发生底层 I/O 错误。具体成因依赖设备和文件系统,不能一概视为可安全重试。
错误码的具体适用条件与目标对象、系统实现有关。编写可移植程序时,应以目标 POSIX 系统的文档为准。若使用非阻塞 I/O,通用的“遇到错误就重试”尤其危险:对于 EAGAIN 立即重试可能让 CPU 满负荷运转,却没有任何进展。
9. 原子性:管道、普通文件与多次调用
对管道或 FIFO,POSIX 定义了 PIPE_BUF 相关保证:单次写入请求不超过 PIPE_BUF 时,写入不会与其他进程的并发写入交错;超过该界限时,不能依赖这种保证。非阻塞模式下,对不超过该界限的写入,若空间不足,行为也与可能部分接受数据的大请求不同。应查阅目标平台关于管道和 PIPE_BUF 的具体说明。
这项保证针对的是一次写入请求,不代表“任意长度的数据都能由循环写入函数作为一个整体原子提交”。write_all 可能发起多次系统调用,其他写入者有机会在这些调用之间写入。若一条逻辑记录必须不被其他进程打断,应设计合适的记录长度、同步机制或进程间协议。
普通文件也不能简单理解为“多个进程同时写时绝不会冲突”。例如,共享文件描述符、多个独立打开的描述符、不同偏移和追加模式会影响写入位置。需要追加时可考虑在 open 时使用 O_APPEND,但不能由此推断一整条由多次调用写成的记录不可分割。文件锁和同步协议也必须与所有参与者的行为一致,才可能达到预期。
10. 安全与健壮性:write 能做什么,不能做什么
write 按给定地址和长度写出原始字节,不会自动进行格式化,也不会把输入解释为格式字符串。因此,不要为了输出不可信文本而使用类似 printf(user_input) 的写法;那会把输入当作格式字符串处理。使用 write 时,可以按已验证的缓冲区长度输出字节,但仍须保证长度真实、指针有效,并确认目标描述符指向正确位置。
原始写入也不是完整的安全策略。它不负责校验数据是否符合协议、不负责限制敏感信息的外泄、不负责选择安全的文件路径,也不保证内容已经持久化。输出日志或错误信息时,应考虑是否包含密钥、令牌、个人信息等敏感数据;创建文件时应正确设置权限并保护目录;接收不可信长度时,应先验证长度与缓冲区容量的关系,避免越界访问。
对于普通文件,实际可写空间、文件系统状态、资源限制和并发访问都会影响结果。对管道或终端,接收端行为也可能影响程序进度。将 write 描述为“安全写入函数”并不准确:它是一个底层字节写入接口,安全性取决于调用者如何控制描述符、缓冲区、长度、路径和错误处理。
11. 编写 write 代码时的检查清单
- 确认传入的是有效、可写的文件描述符,而不是
FILE *。 - 确认缓冲区在调用期间有效,且
count不超过实际可读范围。 - 明确是否要写字符串末尾的空字符;通常文本输出不需要它。
- 检查返回值是否为负数,并处理小于请求长度的正数。
- 对
EINTR按实际进度重试;对EAGAIN/EWOULDBLOCK使用等待机制,而非忙循环。 - 合理限制单次请求长度,不把超出
SSIZE_MAX的长度直接当作普通请求处理。 - 确认是否需要追加、截断、创建权限、关闭时机,以及更强的持久化保证。
- 不要假设多次调用等价于一次原子写入,也不要把成功返回当作数据已送达最终读者。
总结
write 的核心接口很小,正确使用却需要关注完整的数据流:它通过文件描述符操作字节,buf 和 count 必须匹配,返回值表示实际写出的数量,而不是无条件表示全部成功。健壮代码应处理部分写入和 EINTR,根据对象类型处理非阻塞状态及其他错误,并明确原子性、权限和持久化需求。
如果只记住一条原则,就是不要把一次 write 调用等同于“所有数据已经写完”。检查返回值、推进缓冲区位置,并在合适的错误条件下继续或退出,才是可靠使用它的基础。





