5UNIX环境高级编程(第3版)

标准I/O库

高级I/O

流缓冲格式化I/O

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 vulnerability
  • scanf的空白字符处理经常导致输入解析错误
  • 临时文件的不安全创建可能导致竞态条件攻击

此外,标准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系列函数

c
#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,说明输出被截断了。

c
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);
}

这个特性可以用来动态分配足够大的缓冲区:

c
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或%eprintf("%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)
c
// 表格格式化输出
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.30

2.2 格式化输入

2.2.1 scanf系列函数

c
#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。

c
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会跳过前导空白,读取到下一个空白为止。

c
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:缓冲区溢出

c
char buf[10];
scanf("%s", buf);  // 危险!如果输入超过9个字符,会溢出

// 安全写法:指定最大宽度
scanf("%9s", buf);  // 最多读9个字符 + \0

陷阱3:不检查返回值

c
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创建一个唯一的临时文件,以二进制读写模式打开,程序正常退出时自动删除。

c
#include <stdio.h>

FILE *tmpfile(void);
c
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会被替换为唯一字符。

c
#include <stdlib.h>
#include <unistd.h>

int mkstemp(char *template);
c
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在指定的内存缓冲区上创建一个流。

c
#include <stdio.h>

FILE *fmemopen(void *buf, size_t size, const char *mode);
c
// 从字符串中解析数据
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创建一个动态增长的内存缓冲区流。

c
#include <stdio.h>

FILE *open_memstream(char **ptr, size_t *sizeloc);
c
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 流与文件描述符的互操作

c
#include <stdio.h>
#include <unistd.h>

int fileno(FILE *stream);
FILE *fdopen(int fd, const char *mode);
c
// 从文件描述符创建流
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);

混合使用的注意事项:

c
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库的缓冲机制在网络编程中可能成为问题:

c
// 问题场景:使用fgets读取网络数据
FILE *fp = fdopen(sockfd, "r");
char buf[256];
fgets(buf, sizeof(buf), fp);
// fgets可能阻塞等待\n,但网络数据可能不以\n结尾
// 而且标准I/O缓冲区可能预读了更多数据,导致后续的read出错

2. 需要精确控制I/O时机

c
// 标准I/O库无法控制何时刷新(除了显式fflush)
// 在某些实时应用中,这可能是问题
write(fd, "urgent data", 11);  // 立即发送
// vs
fprintf(fp, "urgent data");     // 可能在缓冲区中等待

3. 非文件设备

某些设备不支持lseek,但标准I/O库可能隐式使用lseek(如ftell/fseek)。


三、实战案例

3.1 安全的字符串构建器

c
#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创建安全的临时文件

c
#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解析器

c
#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处理输入输出。


5/12标准I/
0%