你的 Shell 里到底发生了什么?——从环境变量到文件描述符的一次底层之旅
你每天都在终端里敲 ls、echo $PATH、cat file.c。你有没有停下来想过一个问题:当你敲下回车的那一刻,你的电脑到底干了什么?
不是"它执行了你的命令"——那是废话。我问的是:那个 $PATH 是怎么被找到的?那个 > 符号为什么能把文件清空?你写的 fopen 到底打开了什么?文件描述符那个整数究竟是什么东西?
这篇文章就是要把这些事搞清楚。从一个自己写的 shell 开始,一路挖到系统调用。
环境变量——就是一张表
忘掉"环境变量是操作系统的配置"这种说法。换个角度看:环境变量就是 bash 内部维护的一张数组,或者说一张表。
就这么回事。
bash 启动的时候,它从配置文件里读环境变量——你登录时的 bash_profile,系统级的 /etc/profile,这些文件里用 shell 脚本的形式写着环境变量的配置。读完之后,bash 在内部形成一张表,每一个条目都是一个 NAME=value 的字符串。
我们自己写 shell 的时候,读配置文件这件事几乎不可能做出来——老师原话:"工作十几年的工程师都做不到,99.99% 的工程师也做不到。"因为 bash 的配置文件是用 shell 脚本写的,解析它需要完整的词法分析和语法分析。bash 本身就是一门脚本语言,它的内部除了能执行命令,还有一套完整的词法语法解析器。
所以我们耍个花招:直接从父进程那里拷贝环境变量。
// 声明外部变量 environ
extern char **environ;
static int envc = 0;
static char *env[256]; // 我们自己维护的环境变量表
static void load_env() {
// 从父进程继承的环境变量一个个拷贝过来
for (int i = 0; environ[i] != NULL; i++) {
env[envc] = environ[i];
envc++;
}
// 最后必须以 NULL 结尾
env[envc] = NULL;
}
就这么一段代码,我们冒充自己从配置文件里读到了环境变量。实际上是从父进程那儿继承过来的。
env 命令——就是遍历这张表
有了这张表之后,所谓的 env 命令本质上是什么呢?就是一个内建命令,遍历这张表,然后打印出来。
// 内建命令 env:把整张表打出来
if (strcmp(argv[0], "env") == 0) {
for (int i = 0; i < envc; i++) {
printf("%s\\n", env[i]);
}
return 1; // 内建命令,不需要创建子进程
}
不是你以前以为的"env 是一个独立的程序在查询系统配置"——它就是在查 bash 内部维护的那张全局数组。因为这张数组是全局的,fork 之后父子进程共享(写时拷贝),所以子进程也能访问到它。
export 命令——往这张表里插一条
那 export myvalue=100 在干什么?就是往这张表最后面插入一个新的字符串。
if (strcmp(argv[0], "export") == 0) {
if (argc == 2) {
// 关键:不能直接让 env[envc] 指向 argv[1]
// 因为 argv[1] 指向的内存在下一次命令行解析时会被清空!
char *m = (char*)malloc(strlen(argv[1]) + 1);
strcpy(m, argv[1]);
env[envc] = m;
envc++;
env[envc] = NULL; // 保持 NULL 结尾
}
return 1;
}
注意那个 malloc。很多人第一次写会直接写 env[envc] = argv[1],然后运行一次之后再 env 就发现输出了一行空——为什么?因为 argv[1] 指向的是命令行解析时用的那块临时缓冲区,下一次解析就覆盖了。所以你必须自己申请一块新内存,把字符串拷贝过去。
就这么简单。环境变量的本质就是 bash 内部的一张表。你对环境变量的所有操作——查也好、导也好——本质上都是对这张表结构的操作。
本地变量 vs 环境变量——两张表的差别
你定义 a=10 不加 export,它就被放到另一张表里——本地变量表。你 export a,bash 就先去本地变量表里找到 a=10 这个字符串的地址,把它从本地表里摘掉,插到环境变量表里。
环境变量表有一个约定好的全局指针暴露给子进程——C 语言里的 extern char **environ 就是干这件事的。而本地变量表的名字你不告诉子进程,子进程就看不到。两者的数据本质上没有区别,区别只在于你有没有把这个指针暴露出去。
Shell 内部还可以利用环境变量和文件属性做权限管理——拿到用户名、用户 ID,拿到文件的拥有者、权限 mode(一个 32 位整数,位图格式),判断当前用户有没有相应的读写执行权限。如果没有,shell 直接拒绝执行这条命令。要不然,每一条命令都得自己做权限验证。
文件——别再用"就是硬盘上的数据"来糊弄自己了
说完了环境变量,下面进入今天的正题:文件。
在学习操作系统之前,你写 C 语言的时候用过 fopen、fclose、fread、fwrite,可能还用过 fseek、ftell。你会用,但你真的搞清楚了一个文件是什么吗?
要做好心理准备:如果只停留在语言层面,你永远理解不了文件。你得站到操作系统的角度去看。
结论一:文件 = 内容 + 属性
你新建一个 0 字节的空文件——它的大小是 0,但它有文件名、有时间戳、有类型、有权限。这些叫做属性。
属性也是数据。这个世界上,计算机里所有东西都是数据。代码是数据,内容是数据,属性也是数据。
所以对文件的操作无非两类:对内容操作(fread、fwrite),对属性操作(stat、chmod)。
结论二:文件操作的本质是 IO
根据冯诺依曼体系,磁盘是一个外设——永久性存储介质。断电了数据还在。
你对文件内容的修改、对文件属性的修改,本质上都是在向磁盘进行输入输出。在计算机专业术语里,这都叫 IO——Input 和 Output。
但谁是 Input 谁是 Output?很多人分不清楚。
站在内存的角度看。
想象你是一个小人,站在内存里。磁盘上的数据被读进内存——你收到了数据,这是 Input。内存里的数据被写回磁盘——你把数据吐出去了,这是 Output。
永远站在内存视角。站在设备视角你会搞混。
结论三:研究文件,就是研究进程和文件的关系
这是最关键的一个结论。让我把逻辑链盘清楚。
你在操作一个文件之前,必须先打开它。fopen。以前你一直这么干,但你想过没有——为什么要打开?
打开文件的本质是什么?是把文件加载到内存。
你的文件在磁盘上,磁盘是外设。如果你想修改这个文件——把里面的"一二三四"改成"五六七八"——你不能直接在磁盘上改。你必须先把数据从磁盘读到内存,用 CPU 在内存里把"一二三四"改成"五六七八",然后再写回磁盘。
这是冯诺依曼体系结构决定的。CPU 只能直接访问内存,不能直接访问外设。
文件 = 内容 + 属性,所以打开文件的本质 = 把文件的属性和内容(全部或部分)加载到内存里。你的 CPU 在内存里操作完,再刷新回磁盘。
那谁来做这个打开的动作?
你把 fopen 写完了——代码写完了——文件打开了吗?没有。 你只是写了一份计划书。只有当你编译、运行这个程序,当进程真正执行到了 fopen 这一行,文件才被打开。
所以打开文件的是进程。
一个二进制程序加载到内存里运行起来,叫什么?叫进程。所以谁打开文件?进程打开文件。
我们学习文件操作,本质上是在学习进程和文件的关系。这就是为什么操作系统课要先讲进程——进程不搞定,文件这条逻辑链你串不起来。
现在把两件事串在一起:你敲 cat main.c。bash 把 cat 解释为一个子进程,main.c 作为参数传进去。这个子进程在它的内部打开 main.c,把内容全读出来,printf 打印给你。你通过命令操作文件,本质上是进程在操作文件。
结论四:文件分两类
一个 Linux 系统里可能有上万个文件。但运行着的进程只有几十个,每个进程访问的文件也就几十个。绝大多数文件静静地躺在磁盘上,没人碰它。
所以文件分成两类:
- 被进程打开的文件——在内存中,我们关注的是进程和文件的关系
- 没有被进程打开的文件——在磁盘上,我们关注的是怎么组织管理,让人快速找到
这就好比菜鸟驿站。有些快递被取走了,拿到家里拆开用;更多的快递还躺在货架上,按编号分门别类摆好。取走的快递和没取走的快递都要管,但管法不一样。
今天我们只讨论第一类——被进程打开的文件。
结论五:操作系统必须提供系统调用
磁盘是硬件。访问文件就是访问磁盘,访问磁盘就是访问硬件。
这个世界上,只有谁有资格直接访问硬件?操作系统。 你一个普通进程,没有资格绕过操作系统直接读写磁盘。
所以,如果你想操作文件,你必须通过操作系统。这就注定了操作系统必须给你提供系统调用——open、close、read、write。
那你以前用的 fopen、fclose、fread、fwrite 是什么?是 C 语言对系统调用的封装。 你以前只是看不到底层罢了。
C 语言文件操作——快速复习,捡几个真正重要的结论
先把 C 语言的文件接口过一遍,挑那些真正对后面理解系统调用有用的说。
fopen 的三个参数
FILE *fp = fopen("log.txt", "w");
if (fp == NULL) {
perror("fopen");
return 1;
}
// … 读写操作 …
fclose(fp);
path:可以带路径,也可以不带。不带路径不代表没有路径——它会默认使用当前进程的工作目录(cwd)。在 Linux 里,访问文件必须带路径,因为找到文件只能用路径。所以你看到不带路径的访问,其实只是拼接了当前路径。每一个进程都有自己的当前工作路径。 通过 getcwd 可以获取,通过 chdir 可以修改。一旦改掉,文件的新建位置就跟着变了。
mode:最常用的是三个。r 读,w 写,a 追加。带 + 号的(r+、w+、a+)表示读写,但实际用得非常少,原因我后面会说。
返回值:一个 FILE*,更专业的叫法是句柄——一个用来标识被打开文件的唯一性标识符。就像古代"挟天子以令诸侯,掌天下之柄",柄就是把柄的意思。拿着这个 FILE*,你就可以对这个文件做各种操作。
w 模式的三个特征
做一个实验。先手动往 log.txt 里写入 “12345678XYZ”(11 个字节),然后写一段代码:
const char *message = "abcd";
FILE *fp = fopen("log.txt", "w");
fputs(message, fp);
fclose(fp);
运行之后,打开 log.txt,里面只剩 “abcd” 了。“12345678XYZ” 全没了。
为什么?w 模式在每次写入之前会先把文件清空。 这是它的特征,不是 bug。
w 模式的行为总结:如果文件不存在,创建它;如果文件存在,先清空,然后从头开始写。每次写都从头开始写。
利用这个特征,如果你只想清空一个文件而不想写任何东西:
FILE *fp = fopen("log.txt", "w");
fclose(fp); // 打开后立刻关闭,文件就被清空了
这就能解释为什么 shell 里的 > log.txt(左侧没有任何命令)能把文件清空——因为输出重定向在底层就是把这个文件以 w 方式打开,然后立刻关闭。 它要做输出重定向,但左侧没有要输出的内容,所以打开、关闭,文件就被清空了。
输出重定向 > 就是 w 模式,追加重定向 >> 就是 a 模式
echo "hello" > log.txt——这条命令执行时,bash 必须先把 log.txt 打开。以什么方式打开?观察现象:原来内容被清空,新内容从头写入。这和 w 模式完全吻合。
echo "hello" >> log.txt——内容不断追加到文件末尾,不覆盖原有内容。这和 a 模式完全吻合。
所以别管什么"输出重定向"这个名字起得有多高大上,它的本质就是写文件。 写文件之前必须先打开文件(这是我们已经建立的共识),所以 bash 在解析重定向符号时,最终做的事情就是调用类似 fopen 的接口,根据是 > 还是 >> 来决定用 w 还是 a 模式。
命名不等于理解。"重定向"三个字让你以为这里面有什么神秘机制。换个说法——“把本该显示在屏幕上的东西写到文件里,并且在写之前清空文件”——这才是 ‘>’ 真正在做的事情。就这么回事。
文件内容的本质——一个一维数组
换一个视角。不要站在 vim 或者 VS Code 这些文本编辑器的角度去看文件。站在操作系统的角度去看。
文件内容 ABCD\\n1234,在操作系统眼里不是"ABCD 换行 1234"——它是 'A' 'B' 'C' 'D' '\\n' '1' '2' '3' '4'。一个字节一个字节的,像一个一维数组。
把文件想象成一个可以自动扩容的一维数组。 从文件开头读,就是从下标 0 开始读。写到文件末尾,就是往数组最后面追加。文本编辑器只是帮你把 \\n 显示成了换行,文件本身不区分"文本"和"二进制"——每一字节都是一样的 8 比特数据。
有了"文件 = 一维数组"这个模型,读写位置就好理解了:系统为每个被打开的文件维护一个下标——当前位置。
- ftell(fp):返回当前读写位置(下标)
- rewind(fp):把位置重置为 0(回到开头)
- fseek(fp, offset, whence):自由移动位置
但这个模型有一个反直觉的坑:读和写共用同一个下标。 没有"读位置"和"写位置"之分,就一个。
所以如果你用 r+ 或 w+ 以读写方式打开文件,先写了 10 个字节,这时下标移到了 10。紧接着去读——你读不到刚刚写入的那 10 个字节,因为在读的时候位置从 10 开始往后走了。你必须先 rewind 回到 0,然后再读,才能把刚写的东西读上来。
这就是 r+ / w+ 模式难用的原因。 你得不断地调整读写位置。这也是为什么最佳实践中,单独读用 r,单独写用 w,追加用 a,这三个用得最多。
要不要把 \\0 写进文件?
写字符串到文件时,要不要带 \\0?
C 语言规定字符串以 \\0 结尾。但文件不管你这套——文件说:“在我这儿,\\0 就是一个字符,你要写就写,不写拉倒。”
最佳实践是不要带 \\0。 因为文件的规矩不该由 C 语言的约定来决定。你写什么就是什么,将来读的时候再加 \\0。带了 \\0 在文件里会显示为乱码,而且以后做网络编程时——网络也可以看作文件——带 \\0 可能导致解析协议失败。
二进制写入 vs 文本写入
fputs、fprintf 写的是文本。fwrite 写的也是数据,但它是二进制格式——参数是 void*,与类型无关。
如果你写的是一段字符串,用哪个都行。但如果你写的是一张图片——图片的二进制数据中间可能恰好有 \\0,用 fputs 会被截断。这时就必须用 fwrite,它严格按照你给的长度来写。
走进系统调用——open、close、read、write
前面说了,访问文件 = 访问磁盘 = 访问硬件。你没资格直接访问硬件,只有操作系统能。所以必须通过系统调用。
那就来看看真正的系统调用长什么样。头文件:sys/types.h、sys/stat.h、fcntl.h。
open——比 fopen 啰嗦一百倍
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) {
perror("open");
return 1;
}
// … 读写操作 …
close(fd);
返回的不是 FILE*,是一个整数——文件描述符(file descriptor),简称 fd。这个整数的概念是整个文件子系统最重要的概念之一。fd >= 0 表示成功,-1 表示失败。
文件描述符也是一种句柄。 拿着这个整数,你就可以对文件做读写操作——read(fd, …)、write(fd, …)、close(fd)。跟 FILE* 一样,只是形式不同。
位图传参——一个整数传 32 个标志位
O_WRONLY | O_CREAT | O_TRUNC——这个写法是什么意思?
来看一个小 demo,跟文件操作没有直接关系,但它说明白了位图传参的原理:
#define ONE (1 << 0) // 0001
#define TWO (1 << 1) // 0010
#define THREE (1 << 2) // 0100
#define FOUR (1 << 3) // 1000
void show(int flags) {
if (flags & ONE) printf("one ");
if (flags & TWO) printf("two ");
if (flags & THREE) printf("three ");
if (flags & FOUR) printf("four ");
printf("\\n");
}
int main() {
show(ONE); // one
show(ONE | TWO); // one two
show(ONE | TWO | THREE); // one two three
show(ONE | TWO | THREE | FOUR); // one two three four
return 0;
}
原理很简单:一个整数有 32 个比特位。每个比特位代表一个"是/否"的标志。用或运算(|)可以把多个标志位组合起来,用位与运算(&)可以检查某个标志位是否被设置了。
O_WRONLY、O_CREAT、O_TRUNC 这些宏,每一个都是只有一个比特位为 1 的整数。你用 | 把它们组合起来传给 open,内核就能解析出你到底想要哪些行为。
用一个整数传递最多 32 个标志位——这叫做位图传参。在操作系统层面,这是一种非常常见的做法,因为它可以有效减少数据拷贝。后面你只要看到大写字母加下划线的整数值,十有八九就是位图传参。
umask——谁离 open 近就听谁的
你明明传了 0666 作为权限,为什么新建出来的文件权限是 0664?
因为系统有一个默认的权限掩码(umask),它会默认去掉 other 的某些权限位。0666 & ~umask 得到的才是最终权限。
如果你想让新建的文件严格按照 0666 来:
umask(0); // 把当前进程的权限掩码清零
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
谁离 open 近就听谁的——就近原则。 系统有 umask,你的代码也可以设 umask。你在 open 之前设的那个 umask 只影响当前进程,不影响系统。程序退出后,系统的 umask 还是原来的。
write——比 fwrite 清爽得多
const char *message = "abcd\\n";
ssize_t n = write(fd, message, strlen(message));
// n 是实际写入的字节数
三个参数:文件描述符、缓冲区、要写的字节数。就完了,没有 FILE*、没有 mode、没有"多少个基本单元"那一套啰嗦的东西。
重要差异:系统调用不会自动清空
用 O_WRONLY | O_CREAT(不带 O_TRUNC)打开文件,然后写入 “ccc”(3 个字节),原来文件里的内容 “123456789abcdefg” 变成了 “ccc456789abcdefg”——前面的 3 个字节被覆盖了,后面的内容还在。
而 C 语言的 w 模式是自动清空的。这个差异说明:C 语言帮你做了更多事。 底层系统调用是纯的——你告诉我干嘛我就干嘛,不帮你多做任何事。
如果你想要清空,就加上 O_TRUNC:
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
这样就和 fopen(…, "w") 的行为完全一致了。
所以 C 语言的 w 到底对应了什么?
// fopen("log.txt", "w") 在底层相当于:
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
一个 w,对应底层三个标志位。你现在知道为什么 C 语言要封装了——不封装的话,你得跟用户解释什么叫 O_CREAT、什么叫 O_TRUNC、什么叫 0666、什么叫 umask。而实际上大部分场景,用户就想说一句话:"我要写这个文件。"这就是 w。
为什么 echo 和 env 既是内建命令,又是磁盘上的可执行文件?
最后聊聊一个很多人困惑的问题。你敲 which env,发现 /usr/bin/env 确确实实是一个 64 位的 ELF 可执行文件。那你怎么又说 env 是内建命令?
Shell 本身也是一门脚本语言。 bash 有两种工作模式:交互式(你平时敲命令),和批处理式(执行 shell 脚本)。后者需要完整的词法分析和语法分析能力——变量定义、循环、条件判断——这些 bash 内部全都支持。
正因为 bash 能解析 shell 脚本,它才能读取那些用 shell 脚本写成的配置文件。
但问题是:当一个 shell 脚本作为子进程运行的时候,它也需要执行 env、echo、set 这些命令。如果这些命令只是内建在 bash 内部,那非 bash 的子进程就调不到它们了。所以系统必须提供一份磁盘上的独立可执行版本。
这些命令有两份: 一份是 shell 内部的内建版本(交互式使用,快,不需要 fork),一份是磁盘上的独立程序(给脚本子进程用)。两者并存,各有用处。
你手写的 mini shell 目前的代码不支持重定向(>、>>),不支持管道(|),也不支持别名(ll),也不支持 echo $PATH 这种变量引用——后者需要你在内建命令里解析 $ 符号,去环境变量表里做字符串匹配,把变量名对应的 value 提取出来。这些功能都需要后面逐步添加。
我们从中学到了什么
这篇文章里,我们做了几件事:
第一,搞清楚了环境变量。它就是 bash 内部维护的一张全局字符串数组,env 遍历它,export 往里面插。本地变量是另一张不暴露给子进程的表。别名的本质也是一张表。
第二,建立了关于文件的五个结论:
- 文件 = 内容 + 属性
- 文件操作的本质是 IO(站在内存角度看)
- 研究文件是研究进程和文件的关系(因为打开文件的是进程)
- 文件分两种——被进程打开的(在内存中)和没被打开的(在磁盘上)
- C 语言的文件操作本质是封装了系统调用
第三,打通了 C 语言文件操作和系统调用的关系。fopen("log.txt", "w") 在底层是 open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666)。C 语言用一个字母封装了三个标志位加权限概念的复杂性。
第四,认识了文件描述符——一个整数,用来标识被打开的文件。它是整个文件子系统中最重要的概念。
还有一个概念我没来得及展开:为什么打开文件返回的恰好是一个整数而不是别的什么?这个整数 0、1、2 分别代表什么?文件描述符和重定向之间到底有什么关系?文件缓冲区到底在哪里?
这些,留到下一篇文章再说。
本文从自己写的 mini shell 出发,带着学生先搞定环境变量和内建命令,再建立文件操作的底层认知框架,最后过渡到系统调用,让学生亲眼看到 C 语言文件接口和底层系统调用之间的对应关系。




