文件指针 FILE:一切 I/O 的入口
在 C 里操作文件,第一步不是读也不是写,而是用 fopen 把文件"打开",拿到一个 FILE* 指针。之后所有读写都通过这个指针进行,用完再 fclose 关掉。它和你之前手动 malloc/free 的节奏很像:申请与释放必须成对。
#include <stdio.h>
int main(void) {
FILE *fp = fopen("note.txt", "w"); /* 以写方式打开 */
if (fp == NULL) { /* 打开失败要判 */
perror("fopen");
return 1;
}
fputs("hello, file\n", fp);
fclose(fp); /* 一定要关 */
return 0;
}
fopen 失败会返回 NULL,常见原因是路径不对或没有权限。先判 NULL 再用,是文件编程的第一条纪律。
- FILE*fopen 打开文件拿到句柄,fclose 释放,二者必须配对。
- 打开模式"r"读、"w"写覆盖、"a"追加、加 b 表二进制。
- 缓冲区printf/fwrite 先进缓冲区,fflush 或 fclose 才真正落盘。
- fork/exec/waitfork 复制出子进程,exec 换上新程序,wait 回收避免僵尸。
文本与二进制:按模式打开文件
第二个参数决定了"以什么方式打开"。常用模式不多,记住这张表就够了:
| 模式 | 含义 | 文件不存在时 |
|---|---|---|
| "r" | 只读,文件必须已存在 | 失败返回 NULL |
| "w" | 只写,会清空原有内容 | 自动新建 |
| "a" | 追加,写到文件末尾 | 自动新建 |
| "r+" | 可读可写,不清空 | 失败返回 NULL |
| 末尾加 b | 二进制模式(如 "rb"、"wb") | 原样读写字节 |
文本模式适合存人能看的文字;二进制模式适合存图片、结构体数组这类原始字节。跨平台上,文本模式可能悄悄转换换行符,处理二进制数据时务必加 b,否则读图片会莫名其妙出错。
读写循环:fread 与文件指针
fprintf/fputs 适合写格式化文本;要整块读写二进制,用 fread/fwrite。它们返回"成功读写了几个块",读写位置会顺着文件自动往后移。
#include <stdio.h>
int main(void) {
/* 写:把一个数组整块存进文件 */
int data[5] = {10, 20, 30, 40, 50};
FILE *fp = fopen("nums.bin", "wb");
fwrite(data, sizeof(int), 5, fp);
fclose(fp);
/* 读:整块读回另一个数组 */
int out[5] = {0};
fp = fopen("nums.bin", "rb");
size_t n = fread(out, sizeof(int), 5, fp);
fclose(fp);
printf("读到 %zu 块,out[2]=%d\n", n, out[2]); /* 30 */
return 0;
}
fread 的第二个参数是"每块多大"、第三个是"读几块"。返回值是实际读到的块数——读到文件末尾时它会小于你请求的数量。这也是为什么读文件要循环判断,而不是假设一次就读完。
缓冲区:为什么要 fflush
你调用 fwrite 后,数据常常并没有立刻落到磁盘,而是先待在 C 库的内存缓冲区里,攒够一批才真正写一次——这是为了减少昂贵的磁盘次数。理解了缓冲,很多"看起来写了却没保存"的怪事就有了解释。
- 正常 fclose 会自动刷新缓冲区,所以正常关闭的文件不会丢数据。
- 程序中途崩溃、或死循环里一直没关文件,缓冲区里的数据就丢了。
- 想立刻落盘,显式调用
fflush(fp);日志类程序常用它保证每条记录即时可见。
结论很简单:该关文件就关,别指望操作系统替你兜底。打开了多少个 FILE*,就该在所有路径上关掉多少个。
进程是什么:fork 出一个子进程
写完文件,我们跳到进程。在类 Unix 系统里,fork() 会把当前进程复制一份,分出一个子进程。调用一次,却返回两次——父进程拿到子进程的编号,子进程拿到 0,靠这个区别两边各走各的路。
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
/* 子进程走这里 */
printf("我是子进程,pid=%d\n", getpid());
} else {
/* 父进程走这里,pid 是子进程号 */
printf("我是父进程,刚生出 %d\n", pid);
}
return 0;
}
关键认知:fork 之后父子是两个独立进程,各有各的内存副本——子进程拿到的是调用那一刻父进程内存的一份拷贝。它们不共享变量,互相之间要用管道、信号等机制通信。
exec 与 wait:跑别的程序并回收
fork 出来的子进程默认还在跑你这份程序。想让它去跑一个全新的程序,就用 exec 系列——它会把当前进程的内存整个换成新程序。而父进程必须 wait,等子进程结束并收尸,否则子进程会变成"僵尸进程"占着系统表项。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
/* 子进程:换上 ls 这个程序 */
execlp("ls", "ls", "-l", NULL);
perror("execlp"); /* 只有失败才会走到这 */
_exit(1);
} else {
/* 父进程:等子进程结束 */
int status;
wait(&status);
printf("子进程已结束\n");
}
return 0;
}
把这条链条背下来:fork 分家、exec 换程序、wait 收尸。几乎所有 shell、所有"启动一个子命令"的程序,底层都是这三步的组合。
实战:一个最小的外部命令调用工具
串起来:父进程接收一个命令参数,fork 出子进程去执行它,再 wait 等它跑完。这就是 shell 最核心的那一截。
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "用法: %s 命令\n", argv[0]);
return 1;
}
pid_t pid = fork();
if (pid == 0) {
execvp(argv[1], &argv[1]); /* 用用户给的命令替换自己 */
perror("execvp");
_exit(127);
}
int status;
wait(&status);
printf("命令 %s 执行完毕\n", argv[1]);
return 0;
}
这个小程序编译后,你可以用它去跑 ./run ls -l、./run pwd——它负责把命令交给子进程、再等结果。文件部分则提醒你:凡是打开的文件句柄,都要在所有退出路径上正确关闭,否则就会像"忘记 wait"一样,悄悄留下资源没回收。
文件与进程的检查清单
把这两件事记成一组配对:fopen 对 fclose,fork 对 wait。提交代码前对照自查:
- fopen 的返回值判 NULL 了吗?失败路径会正确退出吗?
- 所有分支(含错误 return)都能走到 fclose 吗?
- 读写二进制文件时,打开模式带了 b 吗?
- 对实时性要求高的日志,需要时有没有 fflush?
- 每个 fork 出来的子进程,父进程都 wait 回收了吗?
- exec 失败时,子进程有没有用 _exit 干净退出,而不是继续跑父进程代码?
文件与进程都是"你申请了、就得负责到底"的资源。C 语言不替你记账,于是纪律就成了代码的一部分。
下一篇我们回到 R:当数据已经准备好,怎么用 ggplot2 的图层语法,把它画成一张能直接放进报告的图。