动态内存、文件 IO 与预处理器:C 程序如何走向工程
当程序只处理几个固定变量时,栈上对象足够。 当输入大小运行时才知道,程序需要动态内存。 当数据要从磁盘读取或写出,程序需要文件 IO。 当代码要跨平台、跨配置编译,程序会接触预处理器。 这三者让 C 程序从练习走向工程,也带来资源释放、错误路径和条件编译风险。
动态内存解决运行时大小问题
数组长度如果编译时不知道,就需要动态分配。
#include <stdlib.h>
int* values = malloc(count * sizeof *values);
if (values == NULL) {
return -1;
}
malloc 从堆上申请一段存储。
它返回 void*。
申请失败返回 NULL。
这不是罕见事件,容器限制、内存压力和配额都可能导致失败。
sizeof *ptr 降低类型重复
推荐写法:
int* values = malloc(count * sizeof *values);
不推荐:
int* values = malloc(count * sizeof(int));
如果以后 values 类型改变,第一种写法会自动跟随。
第二种容易留下旧类型。
检查乘法溢出
分配数组时,count * sizeof *values 可能溢出。
溢出后申请到的内存比需要的小,后续写入越界。
if (count > SIZE_MAX / sizeof *values) {
return -1;
}
int* values = malloc(count * sizeof *values);
这类检查在解析外部输入时尤其重要。 安全漏洞经常从长度计算溢出开始。
calloc 会清零
int* values = calloc(count, sizeof *values);
calloc 分配并清零。
它通常也会处理乘法大小。
但仍然要检查返回值。
清零不是初始化所有业务不变量,只是把对象表示置零。
realloc 要用临时指针
错误写法:
buffer = realloc(buffer, new_size);
如果失败,旧指针丢失,造成泄漏。 正确写法:
void* next = realloc(buffer, new_size);
if (next == NULL) {
return -1;
}
buffer = next;
realloc 的语义细节很多。
工程代码要把失败路径写清楚。
free 释放堆内存
free(values);
values = NULL;
free(NULL) 是安全的。
重复 free 同一非空指针是错误。
释放后把当前指针置空只能保护当前变量,不能保护其他别名。
p ─────┐
q ─────┴──> heap block
free(p)
q 仍然悬垂
所有权设计比“free 后置空”更重要。
文件打开和关闭
C 标准库用 FILE* 表示文件流。
#include <stdio.h>
FILE* file = fopen("data.txt", "rb");
if (file == NULL) {
return -1;
}
fclose(file);
fopen 可能失败:
- 文件不存在。
- 权限不足。
- 路径错误。
- 描述符耗尽。
- 沙箱限制。
必须检查返回值。
文件读取
unsigned char buffer[1024];
size_t n = fread(buffer, 1, sizeof buffer, file);
fread 返回实际读取的字节数。
读取少于请求大小不一定是错误,可能是文件结束。
需要用 ferror 和 feof 区分。
if (ferror(file)) {
/* 读取错误 */
}
不要假设一次读取就拿到完整文件。
文件写入
size_t n = fwrite(buffer, 1, length, file);
if (n != length) {
/* 写入失败或部分写入 */
}
写入可能失败。 磁盘满、权限不足、网络文件系统错误都会发生。 写入返回值必须检查。 生产系统还要考虑 fsync、临时文件和原子替换。
错误路径集中清理
动态内存和文件 IO 常常一起出现。 集中 cleanup 可以避免泄漏。
int load(const char* path) {
FILE* file = NULL;
unsigned char* data = NULL;
int rc = -1;
file = fopen(path, "rb");
if (file == NULL) goto cleanup;
data = malloc(4096);
if (data == NULL) goto cleanup;
rc = 0;
cleanup:
free(data);
if (file != NULL) fclose(file);
return rc;
}
C 的 goto cleanup 是资源释放工具。
它不是鼓励混乱跳转。
预处理器在编译前运行
预处理器处理 #include、#define、条件编译等指令。
它发生在语义分析之前。
#define BUFFER_SIZE 4096
#if defined(_WIN32)
#define PATH_SEP '\\'
#else
#define PATH_SEP '/'
#endif
预处理器像文本加工机。 它不理解 C 类型系统。 宏写错后,编译器看到的是展开后的代码。
宏常量和 const
宏常量:
#define MAX_COUNT 1024
const 对象:
static const int max_count = 1024;
宏没有类型和作用域。
const 有类型和作用域。
能用 const 或枚举表达的常量,不要默认用宏。
函数宏要谨慎
#define SQUARE(x) ((x) * (x))
看似函数,实际是文本替换。
int y = SQUARE(i++);
会让 i++ 执行两次。
这种副作用很危险。
现代 C 里很多函数宏可以改成 static inline 函数。
static inline int square_int(int x) {
return x * x;
}
条件编译要可审计
跨平台代码会用条件编译。
#if defined(__linux__)
/* Linux path */
#elif defined(_WIN32)
/* Windows path */
#else
#error "unsupported platform"
#endif
每个分支都要能在 CI 中编译。 从未编译过的条件分支,通常会在最紧急的平台迁移时爆炸。
头文件包含顺序
头文件应尽量自包含。 一个头文件如果需要某个类型,就自己 include 对应声明,而不是依赖调用方先 include。
#ifndef BUFFER_H
#define BUFFER_H
#include <stddef.h>
typedef struct Buffer {
unsigned char* data;
size_t size;
} Buffer;
#endif
这样头文件契约更稳定。
工程风险
动态内存、文件 IO 和预处理器常见风险:
malloc返回值未检查。- 分配大小乘法溢出。
realloc失败导致旧指针泄漏。- 忘记
free或重复free。 - 文件打开失败未处理。
- 部分读取/写入被当成成功。
- 宏重复求值。
- 条件编译分支长期未编译。
- 头文件宏污染全局命名空间。
- 错误路径绕过资源释放。
这些问题都需要测试、日志和审计。
小结
动态内存让程序处理运行时规模。 文件 IO 让程序和外部世界交换数据。 预处理器让源码适配平台和配置。 它们共同把 C 程序带入真实工程,也把资源释放、错误处理、权限、边界和可观测性问题带入工程。 从这里开始,C 不再只是语法练习,而是系统编程。