文件指针 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 回收避免僵尸。
C 文件操作与进程配图:深蓝渐变背景上左侧叠放文档纸片与文件夹方块,箭头导入中央分叉成两条平行路径的管道,路径上有齿轮与圆形节点,右侧文档方块汇聚
数据像流过管道:从文件进来、经过分叉的进程、再流回文件——I/O 与进程,就是这条管子的两端。

文本与二进制:按模式打开文件

第二个参数决定了"以什么方式打开"。常用模式不多,记住这张表就够了:

模式含义文件不存在时
"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、所有"启动一个子命令"的程序,底层都是这三步的组合。