你的程序是怎么被操作系统“看见“的——从命令行到虚拟地址(费曼skill写作版)
这篇文章讲三件事,但它们其实是一件事的三个阶段。你敲了一个命令,回车。一个程序被启动了。就这么简单的一个动作,背后有一个长长的故事:你敲的字符串是怎么变成程序能读到的参数的?程序怎么知道你登录的是哪个用户、你的家目录在哪儿?程序被加载到内存之后,它"以为"自己住在哪里?我们先从一个实验开始。
文章目录
你的程序是怎么被操作系统"看见"的——从命令行到虚拟地址
这篇文章讲三件事,但它们其实是一件事的三个阶段。
你敲了一个命令,回车。一个程序被启动了。就这么简单的一个动作,背后有一个长长的故事:你敲的字符串是怎么变成程序能读到的参数的?程序怎么知道你登录的是哪个用户、你的家目录在哪儿?程序被加载到内存之后,它"以为"自己住在哪里?
我们先从一个实验开始。
一、main 函数拿到的不只是一个"启动信号"
1.1 先跑起来看
创建一个最简单的程序:
// myproc.c
#include <stdio.h>
int main(int argc, char *argv[])
{
for (int i = 0; i < argc; i++) {
printf("argv[%d]: %s\n", i, argv[i]);
}
return 0;
}
gcc -o myproc myproc.c
./myproc
输出:
argv[0]: ./myproc
只有一个元素,是程序自己的名字。现在带点参数:
./myproc abc bcd
输出:
argv[0]: ./myproc
argv[1]: abc
argv[2]: bcd
再多一些:
./myproc abc bcd 12 34 56 78
输出就会依次打印 argv[0] 到 argv[6],一个不落。
所以结论很简单:你在命令行里输入的所有东西,以空格分开,一个一个地塞进了 argv 这个数组。argv[0] 永远是程序名,后面跟着的就是参数。argc 告诉你有几个有效元素。
1.2 所以 ls -a 和 ls -l 是怎么区分的?
再写一段:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
(void)argc;
if (strcmp(argv[1], "-a") == 0) printf("功能一\n");
else if (strcmp(argv[1], "-b") == 0) printf("功能二\n");
else printf("默认功能\n");
return 0;
}
./myproc -a # → 功能一
./myproc -b # → 功能二
./myproc -c # → 默认功能
你现在知道 ls 是怎么做到的了。ls 就一个二进制程序,它拿到 argv[1],如果是 -a 就走显示隐藏文件的逻辑,如果是 -l 就走详细列表的逻辑。不是有三个 ls 程序,是同一个程序读了不同的 argv[1]。
这就是命令行参数。 不是你主动去读的——操作系统在你程序启动之前,已经把你敲的那一整串字符串拆好、填进一个数组、连数组长度一起交给了你的 main 函数。
1.3 谁拆的?怎么拆的?
拆这个活的不是你的程序,是 bash。bash 做的事情很简单:把你输入的那一行字符串——./myproc abc bcd 12 34——按空格切开,切成 "./myproc"、"abc"、"bcd"、"12"、"34" 五个子串。然后把每个子串的起始地址填进一个 char * 数组,最后放一个 NULL 标记结尾。
用 C 语言的想象来说,bash 内部大概做了这么一件事:
char **argv = malloc(sizeof(char *) * (token_count + 1));
argv[0] = "./myproc";
argv[1] = "abc";
argv[2] = "bcd";
argv[3] = "12";
argv[4] = "34";
argv[5] = NULL;
然后把这个 argv 和 argc = 5 交给即将启动的程序的 main 函数。每一个在命令行里启动的进程,启动时都带着这么一张表。这张表可以为空——如果你一个参数都没传,表里就只有一个 argv[0](程序名)和一个 NULL 终止符。
二、环境变量:你的程序是怎么"认识"你的
好了,现在我们知道每个进程启动时都带着一张 argv 表。但还有另一张表,这张表更大,而且它不是从你这次输入的命令里来的——它从你登录的那一刻就准备好了。
2.1 “凭什么 ls 不用 ./ 就能跑?”
这个问题是整件事的入口。你先在命令行里试:
ls # 能跑
pwd # 能跑
top # 能跑
myproc # command not found
./myproc # 能跑
ls、pwd、top 都是用 C 语言写的,和 myproc 本质上没区别——都是编译好的二进制可执行程序。凭什么它们不用带路径?
因为你运行 ls 的时候,系统不是靠"记住 ls 在哪"来找到它的。系统有一个搜索路径清单。这个清单存在一个叫 PATH 的环境变量里:
echo $PATH
输出大概长这样:
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/whb/bin
每一项都是一条路径,用冒号隔开。当你敲 ls 的时候,bash 不是直接"知道" ls 在哪——它拿着 ls 这个名字,去这个清单里的每一个路径下面找:/usr/local/bin/ls 存在吗?不存在。/usr/bin/ls 存在吗?存在!好,直接执行 /usr/bin/ls。
你不需要看到这个过程。它看起来像是系统"认识" ls,但系统不认识任何人——它只是按清单一个一个敲门。
你的 myproc 为什么找不到?因为这个清单里没有任何一条路径指向你当前的工作目录。系统从来没有去你当前目录找过。你用 ./myproc —— . 意思是"当前目录",/ 是路径分隔符——等于你手动告诉系统:别翻了,就在这里。
验证一下——把程序搬进清单里
sudo cp myproc /usr/local/bin/
myproc # 能跑了!
sudo rm /usr/local/bin/myproc
myproc # 又不行了
/usr/local/bin 在 PATH 清单里。把可执行文件拷贝进去,系统在敲门的时候就能敲到它。删掉,门就敲不开了。
再验证一下——把清单搞坏
现在我直接告诉 PATH:你就只搜我当前目录,别的地方都不要去了:
PATH=/current/working/directory
然后你试试 ls。没了。top、mkdir、touch,全都没了。不是这些命令被删了,是 bash 不知道该去哪儿找它们了。它手里只剩下你刚刚给的这一条路径,别的路径全被覆盖掉了。你把电话簿撕得只剩一页,然后抱怨找不到人——不是人的问题。
为什么会这样?因为 PATH 是一个变量。你写 PATH=xxx,就是对它做覆盖赋值,就像 a = 10; a = 20; 一样——旧的值没了。
怎么修?关掉终端重新打开。为什么重新打开就好了?因为你刚才那一改只改的是当前这个 bash 进程内存里的值,没有写到磁盘上。bash 重新启动的时候,它会重新从配置文件里把 PATH 读回来。换句话说——你只是搞脏了内存,没搞脏硬盘。
正确的做法——追加,不是覆盖
export PATH=$PATH:/当前工作路径
这说的是:新的 PATH 等于旧的 PATH 全部内容,再拼上冒号和我的当前路径。不是替换,是追加。现在 myproc 能跑了,其他命令也都好好的。
which 命令没什么神奇的
which ls
# 输出 /usr/bin/ls
which 做的事跟刚刚描述的 bash 搜索流程一模一样——它遍历 PATH 清单,把你要找的命令名和每条路径挨个拼起来,检查文件存不存在。找到了就把路径打印出来,没找到就什么都不打。没有魔法,就是遍历加文件检查。
2.2 HOME——为什么你一登录就站在某个目录
echo $HOME
如果你是普通用户 whb:/home/whb。如果你是 root:/root。
你登录成功后默认所在的目录,就是这个值。不是操作系统"记住"了你上次在哪儿——是环境变量里写好了"这个用户的家目录是谁"。cd ~ 展开的也是这个值。不同用户、不同的 HOME,所以不同人登录后站在不同的起点。
2.3 别的环境变量——快速过一遍
用 env 命令可以看到当前 bash 拿到的完整环境变量清单。这里不列全部,只说几个有意思的:
| 环境变量 | 它是干什么的 |
|---|---|
SHELL |
你当前用的命令行解释器是谁(通常是 /bin/bash) |
USER |
当前登录的用户名 |
HOSTNAME |
这台机器的名字——所以 bash 提示符前面能显示机器名 |
PWD |
你当前在哪个目录 |
OLDPWD |
你上一次在哪个目录——cd - 能在两个目录之间跳来跳去,就靠它俩 |
HISTSIZE |
历史命令最多记多少条(默认 1000)。超出以后旧的就被挤出去——队列,先进先出。 |
SSH_TTY |
你的终端文件是谁。你在这个终端里创建的进程,默认输出都往这个文件打,因为子进程继承了这个信息。 |
每个环境变量管一摊小事。合在一起,就是"当前这个 shell 会话的运行环境快照"。
2.4 环境变量表长什么样
每个程序收到的不只是 argv 那张表。还有第二张表——环境变量表。它和 argv 一样,也是一个 char * 数组,每个元素指向一个字符串,最后一个元素是 NULL。不一样的是字符串的格式:变量名=变量内容。
environ[0] → "SHELL=/bin/bash"
environ[1] → "USER=whb"
environ[2] → "PATH=/usr/local/bin:/usr/bin:..."
environ[3] → "HOME=/home/whb"
...
environ[N] → NULL
三种方式可以拿到这张表。
方式一:main 函数第三个参数
int main(int argc, char *argv[], char *env[])
{
for (int i = 0; env[i]; i++)
printf("%s\n", env[i]);
}
env 就是那张表。结构和 argv 一样,只是名字换了。大部分时候不写,因为有更直接的方式。
方式二:全局变量 environ
extern char **environ;
int main()
{
for (int i = 0; environ[i]; i++)
printf("%s\n", environ[i]);
}
extern char **environ 是 libc 提供的一个全局二级指针,直接指向环境变量表。为什么要声明 extern?因为 environ 没写在任何头文件里,你需要告诉编译器"这个东西存在,但定义在别的地方"。至于为什么是 char **(二级指针)——你想,表本身是一个数组,数组里每个元素是 char *(一个字符串指针),那指向这个数组首元素的指针自然就是 char **。
方式三:getenv——最常用的一种
#include <stdlib.h>
int main()
{
printf("PATH: %s\n", getenv("PATH"));
printf("HOME: %s\n", getenv("HOME"));
printf("不存在的变量: %s\n", getenv("NOTHING")); // 返回 NULL
}
getenv("变量名") 帮你遍历环境变量表,在每个字符串里做前缀匹配——是不是以 "PATH=" 开头?以 "HOME=" 开头?找到了,把等号后面那部分返回给你。找不到,返回 NULL。
为什么需要三种方式?不是让你三选一死记硬背,是三种不同的抽象层次:
- 如果你要写一个类似
env的命令,或者遍历所有环境变量做批量处理 → 用 environ,拿到原始表、自己遍历 - 如果你只需要某一个变量的值,比如判断当前用户是谁、决定程序行为 → 用 getenv,它会帮你遍历和匹配
- main 函数的第三个参数和 environ 指向的是同一张表,只是入口不同。大部分人不写第三个参数,用 environ 或 getenv 就够了。
2.5 为什么子进程也能看到环境变量?——继承机制
这个问题是理解"全局属性"的关键。你试一下:
// check_myenv.c
#include <stdio.h>
#include <stdlib.h>
int main() {
char *val = getenv("MYENV");
printf("%s\n", val ? val : "MYENV 不存在");
return 0;
}
./check_myenv # → MYENV 不存在
export MYENV="hello world"
./check_myenv # → hello world
export 做了什么?它在当前 bash 进程的环境变量表里加了一行——MYENV=hello world。然后你运行 ./check_myenv,这个程序是 bash 的子进程。子进程是怎么看到这行新增内容的?
因为你每次在命令行启动一个程序,bash 都会 fork 出一个子进程。fork 的时候,bash 的环境变量表——一块全局数据——被子进程原样继承了。
你没改环境变量 → 父子共享同一份数据(物理上可能通过写时拷贝共享同一块内存)。你改了 → 在写时拷贝的机制下,改的那个进程拿到自己的一份副本。但环境变量我们一般不改,所以子进程天然就有一份和父进程一样的表。
子进程再创建子进程呢? 一样的——孙进程继承子进程的环境变量表。子子孙孙,一路传下去。你在 bash 里 export 一个变量,从这一刻起,你在这个终端里启动的任何一个程序(以及这个程序启动的任何程序),都能看到它。不是因为这个变量是"全局变量"——是因为它沿着进程树往下传递。
现在你能回答最开始那个问题了:为什么环境变量具有"全局属性"? 不是因为它名字里有个"全局"——是因为 bash 是这棵进程树的根,而 fork 默认把环境变量表传给了每一个后代。
2.6 那如果不 export 呢?——本地变量
在 bash 里还可以这样写:
ok=1234
echo $ok # → 1234
set | grep ok # set 能看到
env | grep ok # env 看不到
./check_myenv # 子进程看不到
这个 ok=1234 只在 bash 自己内部有效。bash 把它维护在自己的变量池里,但不放进环境变量表。fork 只传递环境变量表,不传递 bash 的私人变量池。所以子进程看不到。
这两种变量的区别不是"一个更重要、一个不重要"——是一个在遗产清单上、一个不在。环境变量在清单上,子进程继承时自动获得。本地变量不在清单上,bash 留着自己用。
如果你想让本地变量上清单:
export ok
现在 ok 被写进了环境变量表。子进程就能看到了。export 做的事就是"把这个本地变量从私人笔记本誊写到公告栏上"。
unset 是反向操作——把一项从环境变量表里擦掉:
unset MYENV
./check_myenv # → MYENV 不存在
2.7 bash 自己的环境变量是从哪来的?——配置文件
还有一个问题你没问但应该问:bash 也是一个进程,它的环境变量表又是从哪来的?
答案:从配置文件读的。
你每次登录系统——输入账号密码回车——操作系统做三件事:
- 验证你的用户名和密码
- 根据你的用户名找到你的家目录
- 启动一个 bash 进程,并且让它在启动时去读你家里几个特定的隐藏文件
这几个文件是关键:
~/.bash_profile~/.bashrc/etc/bashrc(系统级别的全局配置)
加载顺序大致是:先读 ~/.bash_profile → 它发现 ~/.bashrc 存在 → 用 . 命令(相当于 source)加载 ~/.bashrc → ~/.bashrc 又发现 /etc/bashrc 存在 → 加载 /etc/bashrc。
在 /etc/bashrc 这些文件里,系统把你需要的环境变量都设好了——PATH、SHELL、USER、HISTSIZE、命令行提示符的格式……bash 启动时走一遍这些配置,环境变量表就构建完成了。你后来在命令行里 export 的变量,是临时往这张表里追加内容,不会写回配置文件。
这也就解释了为什么你搞坏 PATH 之后关掉终端重开就好了——bash 新启动时重新读了配置文件,拿到的是一张干净的表。
你自己也可以往配置文件里加东西:
vim ~/.bash_profile
写入:
export MY_CUSTOM_VAR="每次登录自动生效"
echo "欢迎回来"
关掉终端,重新登录。你会看到"欢迎回来",而且 echo $MY_CUSTOM_VAR 就能拿到那个值。不是因为什么神奇的系统配置——就是因为你登录时 bash 执行了这个脚本文件,脚本里的 export 命令把变量写进了 bash 的环境变量表。
2.8 一个小例子——用环境变量做权限判断
既然环境变量里有 USER,程序就可以根据它来决定行为:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *who = getenv("USER");
if (strcmp(who, "whb") == 0)
printf("程序正常运行\n");
else
printf("权限错误:不认识你,%s!\n", who);
}
你自己跑,正常。root 来跑,拒绝。程序不认识人,但它认识环境变量。能用来做什么取决于你的想象力——它本身只是一个键值对。
2.9 内建命令——不是所有命令都是子进程
还有一个东西需要提一下。
按照上面的逻辑:命令行里执行的所有程序都是 bash 的子进程。那 export 自己呢?如果 export 也是一个子进程,它在自己的环境变量表里加了一行,然后退出——对父进程 bash 没有任何影响。那为什么你执行 export MYENV=xxx 之后,bash 的环境变量表确实变了?
因为 export 不是一个子进程。它是 bash 自己提供的函数。bash 内部有一个叫 export 的函数——用 C 语言写的,编译在 bash 这个可执行程序里面——当 bash 发现你输入的命令是 export 时,它不 fork,它直接调用自己内部的 export 函数。这样修改的就是 bash 自己的环境变量表。
这类命令叫内建命令(built-in commands)。cd、echo、export、unset、set 都是。它们不是磁盘上独立的可执行文件,它们是 bash 自己的一部分。
那 bash 怎么判断一个命令是不是内建命令?很简单——先查自己的内建命令列表。是,就自己执行。不是,再去 PATH 清单里查磁盘上的可执行文件。磁盘上有,就 fork 子进程去跑。查不到,command not found。
三、程序"以为"自己住在哪里——虚拟地址空间
3.1 一个实验:打印所有东西的地址
现在换一个话题。但这个话题和前面是连着的——argv 和环境变量表被交给程序之后,程序被加载到内存,操作系统给这个进程分配了一块"地盘"。这块地盘长什么样?直接打印地址来看。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval; // 未初始化的全局变量
int g_val = 100; // 已初始化的全局变量
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld"; // 字符串常量
printf("代码区 (main 函数地址): %p\n", main);
printf("已初始化数据区 (g_val): %p\n", &g_val);
printf("未初始化数据区 (g_unval): %p\n", &g_unval);
static int test = 10; // static 局部变量
printf("static 变量 (test): %p\n", &test);
char *h0 = (char*)malloc(10);
char *h1 = (char*)malloc(10);
char *h2 = (char*)malloc(10);
char *h3 = (char*)malloc(10);
printf("堆区 (h0): %p\n", h0); // 打印的是 h0 的内容——堆空间的地址
printf("堆区 (h1): %p\n", h1);
printf("堆区 (h2): %p\n", h2);
printf("堆区 (h3): %p\n", h3);
printf("栈区 (&h0): %p\n", &h0); // &h0——h0 这个指针变量本身在栈上
printf("栈区 (&h1): %p\n", &h1);
printf("栈区 (&h2): %p\n", &h2);
printf("栈区 (&h3): %p\n", &h3);
printf("字符串常量区 (str): %p\n", str);
for (int i = 0; i < argc; i++)
printf("argv[%d]: %p\n", i, argv[i]);
for (int i = 0; env[i]; i++)
printf("env[%d]: %p\n", i, env[i]);
}
gcc -std=c99 -o layout layout.c
./layout
输出(你的机器可能不一样,但大小关系是一致的):
代码区 (main 函数地址): 0x40055d ← 最小
已初始化数据区 (g_val): 0x601034 ← 6 开头
static 变量 (test): 0x601038 ← 和全局变量放一起
未初始化数据区 (g_unval): 0x601040 ← 比已初始化的稍大
堆区 (h0): 0x1791010 ← 1 开头,在数据区上面很远
堆区 (h1): 0x1791030 ← 增大了 0x20
堆区 (h2): 0x1791050 ← 继续增大
堆区 (h3): 0x1791070 ← 继续增大
栈区 (&h0): 0x7ffd0f9a4368 ← 7F 开头,比堆大得多
栈区 (&h1): 0x7ffd0f9a4360 ← 减小了
栈区 (&h2): 0x7ffd0f9a4358 ← 继续减小
栈区 (&h3): 0x7ffd0f9a4350 ← 继续减小
字符串常量区 (str): 0x400800 ← 4 开头!和代码区挨着
argv 和 env: (地址更大,在栈的上面)
把这些地址从小到大排个序,画出来就是:
高地址
┌──────────────────────────┐
│ 命令行参数 (argv) │
│ 环境变量 (environ) │
├──────────────────────────┤
│ 栈区 │ ← 往低地址方向增长(地址越分越小)
│ ↓↓↓ │
│ │
│ ↑↑↑ │
│ 堆区 │ ← 往高地址方向增长(地址越分越大)
├──────────────────────────┤
│ 未初始化数据区 (.bss) │
├──────────────────────────┤
│ 已初始化数据区 (.data) │ ← static 变量也在这里
├──────────────────────────┤
│ 代码区 (.text) │ ← 字符串常量也在这里
└──────────────────────────┘
低地址
几个关键观察:
- 堆和栈相向生长,中间有一大片镂空(留给它们各自扩张的空间)
- 字符串常量
"helloworld"的地址是0x400800,代码区是0x40055d——都是 4 开头。而已初始化数据区是 6 开头。所以字符串常量跟代码编在一起,不在数据区 static局部变量的地址0x601038跟全局已初始化变量0x601034挨着——它们被放在同一个区域- 注意堆地址这里:
h0和h1差了0x20(32 字节),但你只 malloc 了 10 字节。多出来的那 22 字节不是你申请的——是 malloc 的"内部记账本":这块内存多大、是否空闲、下一块在哪。就像你租了 10 平米的房间,但房东给你分配了 32 平米,多出来的放了租房合同
3.2 从两个细节问题深入
字符常量为什么不能改?
char *s = "hello world";
*s = 'H'; // 编译通过。运行:段错误,直接崩
平时你可能会想:哦,因为字符串常量"不可修改"。但为什么不可修改?
因为你刚看到,字符串常量 0x400800 和代码区 0x40055d 是4 开头的同一片区域。代码区是只读的——操作系统把它标记为只读,任何对这片内存的写操作都会被硬件拦截,然后操作系统直接杀掉你的进程。你改字符串常量,实际上是在尝试改代码区。
那如果加上 const:
const char *s = "hello world";
*s = 'H'; // 编译:直接报错!
不加 const → 编译不报错,运行崩。加了 const → 编译就直接拦住你。
const 做的不是"让变量不可修改"——标不标 const,这个字符串都在只读区,都不可修改。const 做的是把错误从运行期搬到编译期。你定义这个变量在第一行,不小心改了它在第一百行,不带 const 你得到的是一个运行时崩溃;带 const,编译器在第一百行就告诉你"这个地方有问题"。这就是防御性编程——不是让 bug 不存在,是让 bug 更早被揪出来。
static 局部变量为什么"生命周期变成全局"?
// 普通局部变量
int test = 10; // 地址在栈区(0x7ffd...)
// static 局部变量
static int test = 10; // 地址在已初始化数据区(0x601038)
加了一个 static,地址从 0x7ffd 跳到了 0x601038——直接从栈区搬到了已初始化数据区,跟全局变量住一个楼层。
所以"生命周期变成全局"不是因为什么生命周期魔法——是编译器发现你加了 static,直接在生成目标文件的时候把这个变量放在了 .data 段。.data 段里的变量,从程序启动到程序退出一直存在。只是编译器同时做了手脚:它给这个变量名加上了一个函数名前缀之类的东西,让你的代码在其他函数里无法直接访问它。存储位置变了,访问权限加了限制——这就是 static 的全部秘密。
3.3 这个故事最重要的结论——你看到的地址是假的
上面我们把地址分区、排序、画图,把一切都理得清清楚楚。但其实有一个更大的问题躲在背后。
看这段代码:
#include <stdio.h>
#include <unistd.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if (id == 0) {
// 子进程
g_val = 100;
printf("child[%d]: %d @ %p\n", getpid(), g_val, &g_val);
} else {
// 父进程
sleep(3); // 等子进程先跑完
printf("parent[%d]: %d @ %p\n", getpid(), g_val, &g_val);
}
}
输出:
child[3046]: 100 @ 0x80497e8
parent[3045]: 0 @ 0x80497e8
同一个地址,不同的内容。 父子进程都看到 0x80497e8,但子进程在那看到 100,父进程在那看到 0。
如果 0x80497e8 真的是内存条上某个晶体管的物理地址,这不可能发生。同一块物理内存在同一时刻只能存一个值。所以只有一个解释:
你看到的地址不是真的物理地址。是假的。操作系统给每个进程发了一张假地图。
每个进程都以为自己的代码在 0x400000 附近、数据在 0x600000 附近、栈在 0x7fff... 附近——但它们实际被放在物理内存的完全不同的位置。操作系统在中间做翻译:进程说"我要读 0x80497e8",操作系统去查一张表,把 0x80497e8 翻译成实际的物理地址,然后才去读内存。
父子进程的 0x80497e8 映射到了不同的物理页——所以它们的 g_val 实际上是两个不同的物理变量,只是碰巧在各自的假地图上标注了同一个门牌号。
这个假地图就是虚拟地址空间。每个进程有自己独立的一份——从进程的视角看,整个 4GB(32 位系统)都是它一个人的,别人不占地方。实际上呢?操作系统在背后把不同进程的虚拟地址映射到不同的物理内存区域,互相隔离。
你在 C/C++ 里用 & 取到的每一个地址,用 %p 打印出的每一个指针值——全是假地图上的坐标,没有一个例外。真正的物理地址,你作为用户态程序永远看不到。
至于操作系统具体怎么翻译——它内部有一张巨大的表,叫页表,每个进程一份。硬件里有一个叫 MMU 的部件在不停地做翻译。这件事的细节,我们下节课再拆。
四、把三件事串起来
现在回头看——这三件事其实是一个连贯的故事:
-
你敲了一个命令
./myproc -a -b。bash 把这一行字符串按空格拆开,填进 argv 表。 -
bash 在启动时已经从
~/.bash_profile和/etc/bashrc这些配置文件里读好了一批系统参数,填进了环境变量表——PATH、HOME、USER 等等都在里面。 -
bash fork 出子进程。子进程继承了 bash 的两张表——argv 和环境变量。所以你的程序天生就知道自己是怎么被调用的、当前运行环境是什么样的。
-
操作系统把程序加载到内存,给这个新进程分配了一个虚拟地址空间。在这个空间里,代码、数据、堆、栈、argv、environ 各自有固定的楼层。程序在虚拟地址空间里自由活动,操作系统在背后把每个虚拟地址翻译成真正的物理地址。
-
程序跑起来,用
getenv查环境变量,用argv读命令行参数,用malloc在堆上要空间,在栈上分配局部变量——一切都在那张假地图上进行。
你不需要记住每一个环境变量的名字。你只需要记住这个模式:系统用表格和继承来传递信息,用虚拟地址来隔离和保护进程。 剩下的,都是在需要的时候查一下的事。
下一篇预告:操作系统怎么把虚拟地址翻译成物理地址?页表是什么?MMU 是怎么工作的?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)