第5章:标准I/O库(Standard I/O Library)
一、导读
1.1 本章学习目标
第5章继续深入标准I/O库的高级特性。在前一章介绍了标准I/O库的基本概念和缓冲机制之后,本章将聚焦于格式化I/O、临时文件、内存流等高级功能。这些特性在日常编程中频繁使用,但很多程序员对其细节知之甚少,容易写出有安全隐患或性能问题的代码。
本章的核心学习目标包括:
- 掌握格式化I/O的细节:
printf/scanf系列的格式说明符、修饰符、返回值含义 - 理解安全编程实践:
snprintf如何防止缓冲区溢出?scanf的常见陷阱有哪些? - 学会创建安全的临时文件:
tmpfile、mkstemp的区别和安全性差异 - 掌握内存流:
fmemopen/open_memstream在内存缓冲区上创建流 - 理解标准I/O流与文件描述符的互操作:
fileno和fdopen的使用场景和注意事项 - 了解标准I/O库的局限性:何时应该放弃标准I/O库,转向底层I/O?
1.2 为什么本章如此重要
格式化I/O是C程序中最常用的功能之一。printf、sprintf、scanf等函数几乎出现在每一个C程序中。然而,这些函数的误用也是安全漏洞的主要来源之一:
sprintf的缓冲区溢出是经典的security vulnerabilityscanf的空白字符处理经常导致输入解析错误- 临时文件的不安全创建可能导致竞态条件攻击
此外,标准I/O库的高级特性(如内存流)可以简化很多编程任务。例如,使用snprintf配合内存流可以方便地构建字符串;使用fmemopen可以在内存中模拟文件操作,便于测试。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
snprintf的返回值是什么?当缓冲区不够大时,返回值与实际写入的字节数有什么关系?
为什么scanf("%s", buf)是不安全的?如何安全地读取字符串?
tmpnam和mkstemp创建临时文件有什么本质区别?为什么前者被认为不安全?
内存流(fmemopen)有什么实际应用场景?
为什么在fork之后混合使用标准I/O和底层I/O可能导致数据混乱?
标准I/O库的哪些特性在网络编程中会成为问题?
二、核心概念详解
2.1 格式化输出
2.1.1 printf系列函数
#include <stdio.h>
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);
int dprintf(int fd, const char *format, ...); // POSIX返回值:成功时返回输出的字符数(不包括\0),出错时返回负值。
snprintf的返回值特别重要:它返回的是"如果缓冲区足够大,应该输出的字符数"(不包括\0)。因此,如果返回值 >= size,说明输出被截断了。
char buf[10];
int ret = snprintf(buf, sizeof(buf), "Hello, %s!", "World");
// buf = "Hello, Wor"(截断)
// ret = 13("Hello, World!"的长度)
if (ret >= sizeof(buf)) {
printf("Output was truncated! Need %d bytes\n", ret);
}这个特性可以用来动态分配足够大的缓冲区:
char *format_string(const char *fmt, ...) {
va_list ap;
// 第一次调用:计算需要的长度
va_start(ap, fmt);
int len = vsnprintf(NULL, 0, fmt, ap);
va_end(ap);
// 分配缓冲区
char *buf = malloc(len + 1);
if (buf == NULL) return NULL;
// 第二次调用:实际格式化
va_start(ap, fmt);
vsnprintf(buf, len + 1, fmt, ap);
va_end(ap);
return buf;
}2.1.2 格式说明符
常用格式说明符:
| 说明符 | 含义 | 示例 |
|---|---|---|
| %d / %i | 有符号十进制整数 | printf("%d", -42) → -42 |
| %u | 无符号十进制整数 | printf("%u", 42) → 42 |
| %x / %X | 十六进制(小写/大写) | printf("%x", 255) → ff |
| %o | 八进制 | printf("%o", 8) → 10 |
| %f | 浮点数(小数形式) | printf("%f", 3.14) → 3.140000 |
| %e / %E | 浮点数(科学计数法) | printf("%e", 3.14) → 3.140000e+00 |
| %g / %G | 自动选择%f或%e | printf("%g", 3.14) → 3.14 |
| %c | 字符 | printf("%c", 'A') → A |
| %s | 字符串 | printf("%s", "hello") → hello |
| %p | 指针地址 | printf("%p", ptr) → 0x7fff... |
| %n | 不输出,将已输出字符数存入int* | printf("%d%n", 123, &n) → n=3 |
| %% | 输出% | printf("%%") → % |
修饰符:
| 修饰符 | 含义 | 示例 |
|---|---|---|
| 数字 | 最小宽度 | printf("%10d", 42) → 42 |
| - | 左对齐 | printf("%-10d", 42) → 42 |
| 0 | 前导零填充 | printf("%010d", 42) → 0000000042 |
| .数字 | 精度 | printf("%.2f", 3.14159) → 3.14 |
| * | 动态宽度 | printf("%*d", width, val) |
// 表格格式化输出
printf("%-15s %8s %10s\n", "Name", "Age", "Score");
printf("%-15s %8d %10.2f\n", "Alice", 25, 95.5);
printf("%-15s %8d %10.2f\n", "Bob", 30, 88.3);
// 输出:
// Name Age Score
// Alice 25 95.50
// Bob 30 88.302.2 格式化输入
2.2.1 scanf系列函数
#include <stdio.h>
int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
int sscanf(const char *str, const char *format, ...);返回值:成功匹配并赋值的输入项数。如果遇到EOF,返回EOF。
int a, b;
int n = scanf("%d %d", &a, &b);
if (n == 2) {
printf("Read two integers: %d, %d\n", a, b);
} else if (n == EOF) {
printf("End of input\n");
} else {
printf("Failed to read two integers (read %d)\n", n);
}2.2.2 scanf的常见陷阱
陷阱1:空白字符处理
scanf的格式字符串中的空白字符(空格、制表符、换行符)匹配输入中的零个或多个空白字符。%s会跳过前导空白,读取到下一个空白为止。
int n;
char str[32];
// 问题场景
scanf("%d", &n); // 输入: "42\n"
// %d读取42,\n留在输入缓冲区
fgets(str, sizeof(str), stdin); // str = "\n"(读到空行)
// 解决方案1:在%d后加空格
scanf("%d ", &n); // 跳过所有后续空白
fgets(str, sizeof(str), stdin); // 现在读到正确的行
// 解决方案2:手动清除缓冲区
scanf("%d", &n);
int c;
while ((c = getchar()) != '\n' && c != EOF);
fgets(str, sizeof(str), stdin);陷阱2:缓冲区溢出
char buf[10];
scanf("%s", buf); // 危险!如果输入超过9个字符,会溢出
// 安全写法:指定最大宽度
scanf("%9s", buf); // 最多读9个字符 + \0陷阱3:不检查返回值
int a, b;
scanf("%d %d", &a, &b); // 如果输入不是数字,a和b的值未定义
// 正确做法
if (scanf("%d %d", &a, &b) != 2) {
fprintf(stderr, "Invalid input\n");
exit(1);
}2.3 临时文件
2.3.1 tmpfile
tmpfile创建一个唯一的临时文件,以二进制读写模式打开,程序正常退出时自动删除。
#include <stdio.h>
FILE *tmpfile(void);FILE *fp = tmpfile();
if (fp == NULL) {
perror("tmpfile");
exit(1);
}
// 使用临时文件
fprintf(fp, "Temporary data\n");
rewind(fp); // 回到文件开头
char buf[256];
fgets(buf, sizeof(buf), fp);
printf("Read: %s", buf);
fclose(fp); // 文件自动删除tmpfile的局限:无法知道临时文件的名称,无法在程序异常退出时保留文件。
2.3.2 mkstemp
mkstemp创建一个唯一的临时文件,返回文件描述符。模板字符串中的XXXXXX会被替换为唯一字符。
#include <stdlib.h>
#include <unistd.h>
int mkstemp(char *template);char template[] = "/tmp/myapp_XXXXXX"; // 必须是可修改的数组
int fd = mkstemp(template);
if (fd == -1) {
perror("mkstemp");
exit(1);
}
printf("Created: %s\n", template); // template已被修改为实际文件名
// 使用文件
write(fd, "Hello\n", 6);
// 如果需要文件名可见,不要unlink
// 如果希望程序退出后自动删除:
// unlink(template); // 立即删除文件名,但fd仍然有效
// close(fd); // 关闭fd后文件真正删除mkstemp vs tmpnam:
tmpnam/tempnam生成的文件名可能被其他程序抢先创建(TOCTOU竞态)mkstemp原子地创建文件,不存在竞态问题- 现代编程中应该始终使用mkstemp,避免使用
tmpnam
2.4 内存流
内存流允许在内存缓冲区上使用标准I/O函数,就像操作文件一样。
2.4.1 fmemopen
fmemopen在指定的内存缓冲区上创建一个流。
#include <stdio.h>
FILE *fmemopen(void *buf, size_t size, const char *mode);// 从字符串中解析数据
char data[] = "Name: Alice\nAge: 25\nScore: 95.5\n";
FILE *fp = fmemopen(data, strlen(data), "r");
char line[256];
while (fgets(line, sizeof(line), fp) != NULL) {
printf("Line: %s", line);
}
fclose(fp);2.4.2 open_memstream
open_memstream创建一个动态增长的内存缓冲区流。
#include <stdio.h>
FILE *open_memstream(char **ptr, size_t *sizeloc);char *buf;
size_t size;
FILE *fp = open_memstream(&buf, &size);
// 动态构建字符串
fprintf(fp, "Hello, ");
fprintf(fp, "%s!", "World");
fprintf(fp, " Number: %d", 42);
fclose(fp); // 关闭后buf和size仍然有效
printf("Result: %s (size: %zu)\n", buf, size);
free(buf); // 记得释放2.5 流与文件描述符的互操作
#include <stdio.h>
#include <unistd.h>
int fileno(FILE *stream);
FILE *fdopen(int fd, const char *mode);// 从文件描述符创建流
int fd = open("data.txt", O_RDONLY);
FILE *fp = fdopen(fd, "r");
char buf[256];
fgets(buf, sizeof(buf), fp);
fclose(fp); // 同时关闭流和底层fd
// 从流获取文件描述符
FILE *fp = fopen("data.txt", "r");
int fd = fileno(fp);
// 可以使用底层I/O操作
struct stat st;
fstat(fd, &st);
fclose(fp);混合使用的注意事项:
FILE *fp = fdopen(fd, "w");
fprintf(fp, "Hello");
// 此时"Hello"在标准I/O缓冲区中,还未写入文件
// 如果直接用底层I/O
write(fd, " World", 6);
// " World"直接写入文件,但"Hello"还在缓冲区
fflush(fp); // 必须先刷新标准I/O缓冲区
write(fd, " World", 6); // 现在顺序正确2.6 标准I/O库的局限性
标准I/O库虽然方便,但在某些场景下不适用:
1. 网络编程
标准I/O库的缓冲机制在网络编程中可能成为问题:
// 问题场景:使用fgets读取网络数据
FILE *fp = fdopen(sockfd, "r");
char buf[256];
fgets(buf, sizeof(buf), fp);
// fgets可能阻塞等待\n,但网络数据可能不以\n结尾
// 而且标准I/O缓冲区可能预读了更多数据,导致后续的read出错2. 需要精确控制I/O时机
// 标准I/O库无法控制何时刷新(除了显式fflush)
// 在某些实时应用中,这可能是问题
write(fd, "urgent data", 11); // 立即发送
// vs
fprintf(fp, "urgent data"); // 可能在缓冲区中等待3. 非文件设备
某些设备不支持lseek,但标准I/O库可能隐式使用lseek(如ftell/fseek)。
三、实战案例
3.1 安全的字符串构建器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
typedef struct {
char *buf;
size_t size;
size_t len;
} StringBuilder;
StringBuilder *sb_create(size_t initial_size) {
StringBuilder *sb = malloc(sizeof(StringBuilder));
sb->buf = malloc(initial_size);
sb->size = initial_size;
sb->len = 0;
sb->buf[0] = '\0';
return sb;
}
void sb_append(StringBuilder *sb, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
// 计算需要的空间
va_list ap_copy;
va_copy(ap_copy, ap);
int needed = vsnprintf(NULL, 0, fmt, ap_copy);
va_end(ap_copy);
// 扩容
while (sb->len + needed + 1 > sb->size) {
sb->size *= 2;
sb->buf = realloc(sb->buf, sb->size);
}
// 追加
vsnprintf(sb->buf + sb->len, needed + 1, fmt, ap);
sb->len += needed;
va_end(ap);
}
void sb_free(StringBuilder *sb) {
free(sb->buf);
free(sb);
}
int main() {
StringBuilder *sb = sb_create(64);
sb_append(sb, "SELECT * FROM users WHERE ");
sb_append(sb, "name = '%s' AND ", "Alice");
sb_append(sb, "age > %d", 18);
printf("SQL: %s\n", sb->buf);
sb_free(sb);
return 0;
}3.2 使用mkstemp创建安全的临时文件
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
FILE *create_secure_tempfile(char **path_out) {
char template[] = "/tmp/secure_XXXXXX";
int fd = mkstemp(template);
if (fd == -1) {
perror("mkstemp");
return NULL;
}
// 设置权限(mkstemp默认0600)
fchmod(fd, 0600);
if (path_out) {
*path_out = strdup(template);
}
// 立即unlink,文件在close后自动删除
unlink(template);
return fdopen(fd, "w+");
}
int main() {
FILE *fp = create_secure_tempfile(NULL);
if (fp == NULL) {
fprintf(stderr, "Failed to create temp file\n");
return 1;
}
// 使用临时文件
fprintf(fp, "Sensitive data\n");
rewind(fp);
char buf[256];
fgets(buf, sizeof(buf), fp);
printf("Read: %s", buf);
fclose(fp); // 文件自动删除
return 0;
}3.3 CSV解析器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_FIELDS 100
#define MAX_FIELD_LEN 256
int parse_csv_line(char *line, char fields[][MAX_FIELD_LEN]) {
int n = 0;
char *token = line;
while (token && n < MAX_FIELDS) {
char *comma = strchr(token, ',');
if (comma) {
*comma = '\0';
}
// 去除前后空白和引号
while (*token == ' ' || *token == '\t') token++;
size_t len = strlen(token);
while (len > 0 && (token[len-1] == ' ' || token[len-1] == '\n'))
token[--len] = '\0';
if (len >= 2 && token[0] == '"' && token[len-1] == '"') {
token[len-1] = '\0';
token++;
}
strncpy(fields[n], token, MAX_FIELD_LEN - 1);
fields[n][MAX_FIELD_LEN - 1] = '\0';
n++;
token = comma ? comma + 1 : NULL;
}
return n;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <csv_file>\n", argv[0]);
return 1;
}
FILE *fp = fopen(argv[1], "r");
if (!fp) {
perror("fopen");
return 1;
}
char line[1024];
int line_num = 0;
while (fgets(line, sizeof(line), fp)) {
line_num++;
char fields[MAX_FIELDS][MAX_FIELD_LEN];
int n = parse_csv_line(line, fields);
printf("Line %d (%d fields): ", line_num, n);
for (int i = 0; i < n; i++) {
printf("[%s] ", fields[i]);
}
printf("\n");
}
fclose(fp);
return 0;
}四、本章小结
第5章深入讲解了标准I/O库的高级特性。核心知识点包括:
格式化输出:snprintf的返回值表示"需要的空间",可用于检测截断和动态分配。格式说明符的修饰符可以精确控制输出格式。
格式化输入:scanf的空白字符处理、缓冲区溢出风险、返回值检查是常见的陷阱。应该始终指定最大宽度并检查返回值。
临时文件:mkstemp是创建临时文件的安全方式,原子操作避免竞态条件。tmpfile方便但灵活性不足。
内存流:fmemopen在固定缓冲区上创建流,open_memstream创建动态增长的流。适用于字符串构建、数据解析等场景。
流与fd互操作:fileno获取fd,fdopen创建流。混合使用时必须注意缓冲区同步。
标准I/O的局限性:网络编程、实时I/O、非文件设备等场景可能不适合使用标准I/O库。
五、思考与练习
思考题
为什么snprintf的返回值不是实际写入的字符数,而是"需要的空间"?这个设计有什么优势?
scanf("%[^\n]", buf)和fgets(buf, size, stdin)有什么区别?各自有什么优缺点?
为什么mkstemp比tmpnam安全?TOCTOU(Time of Check to Time of Use)竞态是什么?
内存流在什么场景下比直接操作字符串更方便?
编程练习
实现一个safe_asprintf函数,类似asprintf,使用snprintf动态分配足够大的缓冲区。
编写一个程序,使用mkstemp创建临时文件,写入数据,然后使用fmemopen在内存中读取这些数据。
实现一个简单的模板引擎:读取模板文件,将{{variable}}替换为对应的值,输出结果。
编写一个CSV到JSON的转换器,使用fmemopen和open_memstream处理输入输出。