你的程序是怎么被操作系统"看见"的——从命令行到虚拟地址


这篇文章讲三件事,但它们其实是一件事的三个阶段。

你敲了一个命令,回车。一个程序被启动了。就这么简单的一个动作,背后有一个长长的故事:你敲的字符串是怎么变成程序能读到的参数的?程序怎么知道你登录的是哪个用户、你的家目录在哪儿?程序被加载到内存之后,它"以为"自己住在哪里?

我们先从一个实验开始。


一、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 -als -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;

然后把这个 argvargc = 5 交给即将启动的程序的 main 函数。每一个在命令行里启动的进程,启动时都带着这么一张表。这张表可以为空——如果你一个参数都没传,表里就只有一个 argv[0](程序名)和一个 NULL 终止符。


二、环境变量:你的程序是怎么"认识"你的

好了,现在我们知道每个进程启动时都带着一张 argv 表。但还有另一张表,这张表更大,而且它不是从你这次输入的命令里来的——它从你登录的那一刻就准备好了。

2.1 “凭什么 ls 不用 ./ 就能跑?”

这个问题是整件事的入口。你先在命令行里试:

ls          # 能跑
pwd         # 能跑
top         # 能跑

myproc      # command not found
./myproc    # 能跑

lspwdtop 都是用 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。没了。topmkdirtouch,全都没了。不是这些命令被删了,是 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 也是一个进程,它的环境变量表又是从哪来的?

答案:从配置文件读的。

你每次登录系统——输入账号密码回车——操作系统做三件事:

  1. 验证你的用户名和密码
  2. 根据你的用户名找到你的家目录
  3. 启动一个 bash 进程,并且让它在启动时去读你家里几个特定的隐藏文件

这几个文件是关键:

  • ~/.bash_profile
  • ~/.bashrc
  • /etc/bashrc(系统级别的全局配置)

加载顺序大致是:先读 ~/.bash_profile → 它发现 ~/.bashrc 存在 → 用 . 命令(相当于 source)加载 ~/.bashrc~/.bashrc 又发现 /etc/bashrc 存在 → 加载 /etc/bashrc

/etc/bashrc 这些文件里,系统把你需要的环境变量都设好了——PATHSHELLUSERHISTSIZE、命令行提示符的格式……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)。cdechoexportunsetset 都是。它们不是磁盘上独立的可执行文件,它们是 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 挨着——它们被放在同一个区域
  • 注意堆地址这里:h0h1 差了 0x20(32 字节),但你只 malloc 了 10 字节。多出来的那 22 字节不是你申请的——是 malloc 的"内部记账本":这块内存多大、是否空闲、下一块在哪。就像你租了 10 平米的房间,但房东给你分配了 32 平米,多出来的放了租房合同

3.2 从两个细节问题深入

字符常量为什么不能改?
char *s = "hello world";
*s = 'H';     // 编译通过。运行:段错误,直接崩

平时你可能会想:哦,因为字符串常量"不可修改"。但为什么不可修改?

因为你刚看到,字符串常量 0x400800 和代码区 0x40055d4 开头的同一片区域。代码区是只读的——操作系统把它标记为只读,任何对这片内存的写操作都会被硬件拦截,然后操作系统直接杀掉你的进程。你改字符串常量,实际上是在尝试改代码区。

那如果加上 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 的部件在不停地做翻译。这件事的细节,我们下节课再拆。


四、把三件事串起来

现在回头看——这三件事其实是一个连贯的故事:

  1. 你敲了一个命令 ./myproc -a -b。bash 把这一行字符串按空格拆开,填进 argv 表

  2. bash 在启动时已经从 ~/.bash_profile/etc/bashrc 这些配置文件里读好了一批系统参数,填进了环境变量表——PATH、HOME、USER 等等都在里面。

  3. bash fork 出子进程。子进程继承了 bash 的两张表——argv 和环境变量。所以你的程序天生就知道自己是怎么被调用的、当前运行环境是什么样的。

  4. 操作系统把程序加载到内存,给这个新进程分配了一个虚拟地址空间。在这个空间里,代码、数据、堆、栈、argv、environ 各自有固定的楼层。程序在虚拟地址空间里自由活动,操作系统在背后把每个虚拟地址翻译成真正的物理地址。

  5. 程序跑起来,用 getenv 查环境变量,用 argv 读命令行参数,用 malloc 在堆上要空间,在栈上分配局部变量——一切都在那张假地图上进行。

你不需要记住每一个环境变量的名字。你只需要记住这个模式:系统用表格和继承来传递信息,用虚拟地址来隔离和保护进程。 剩下的,都是在需要的时候查一下的事。


下一篇预告:操作系统怎么把虚拟地址翻译成物理地址?页表是什么?MMU 是怎么工作的?

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐