从开始学习C语言以来我们已经写惯了 int main() ,但是别忘了,main函数也是可以有参数的。那么main函数的参数是谁传进来的?程序还没开始跑,谁在调用它?
答案是,可执行文件被 exec 加载时,内核除了把代码和数据搬进新的地址空间,还要把命令行参数和环境变量一起放到栈顶,然后才把控制权交给程序的入口点。main 只是从那里把这两样东西接过来。
这篇文章会具体介绍命令行参数、环境变量和地址空间。
一、main 的两个形参
1. 一次运行
写一个最小的程序,把参数逐个打印出来:
#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;
}
编译后带上几个词运行:
$ ./code a b c d
argv[0]: ./code
argv[1]: a
argv[2]: b
argv[3]: c
argv[4]: d
mian函数的参数中,argc是参数的个数(argument count),argv是参数向量(argument vector),也就是一个存放参数的指针数组。注意第一个参数不是 a,而是程序名 ./code。
2. 数组以 NULL 结尾
C 标准保证 argv[argc] 是一个空指针。所以下面两种写法等价:
for (int i = 0; i < argc; i++) { /* ... */ }
for (char **p = argv; *p != NULL; p++) { /* ... */ }
3. 命令行中的词是谁切开的?
./code a b c d 里那些空格不是你敲给 main 看的,是 bash 切开的。
bash 读入一整行,按空白字符拆成若干个词,第一个词当作命令名,剩下的整体交给这个程序。拆的时候会照顾引号,"./code" "a b" 里的空格不会被切开。你在 shell 里输入 ls -a -b -c,bash 切出四个词,ls 自己再去解析 -a、-b、-c。
这就是命令行选项的实现原理:程序拿到的是一串已经切好的字符串,怎么解释、哪些算选项、哪些算文件名,全由程序自己决定。ls 把以 - 开头的当作选项,grep 也有自己的一套,没有统一的内核机制在管这件事。
二、环境变量
1. 为什么需要它
编译一个链接了动态库的程序时,你并没有告诉链接器 libc 在哪个目录,链接照样成功。运行时也没有告诉加载器去哪找 .so,程序照样跑起来。这些“不用你说,系统自己知道”的信息,很多来自环境变量。
环境变量是一组 名称=内容 的字符串,由父进程传给子进程,用来描述运行环境。它和命令行参数的区别很直接:命令行参数是你这一次运行临时敲的,环境变量是进程一开始就继承下来的,通常在整个会话里保持不变。
2. PATH:为什么 ./hello 要带路径
先看一个现象:
$ ./hello
hello world
$ hello
bash: hello: command not found
同一个文件,带 ./ 能跑,不带就找不到。原因在于 bash 执行命令时,如果命令名里不含 /,它不会去当前目录找,而是到 PATH 列出的目录里逐个搜索。
$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/home/jys/bin
冒号分隔的一串目录。ls、grep 这些命令之所以能直接敲,是因为它们躺在 /usr/bin 这类目录里,而那个目录在 PATH 中。
把当前目录加进去就能直接运行:
$ export PATH=$PATH:.
$ hello
$PATH 会先被展开成原来的值,再拼上 :.,所以是追加而不是覆盖。不过把 . 加进 PATH 是有风险的:如果当前目录里有个叫 ls 的恶意文件,你敲 ls 就会执行它。这也是发行版默认不把当前目录放进 PATH 的原因。
另一个办法是把程序放进一个已经在 PATH 里的目录,比如 /usr/local/bin。
3. 几个常用变量
| 变量 | 含义 |
|---|---|
PATH | 命令的搜索路径 |
HOME | 用户主目录,~ 展开成它 |
SHELL | 当前 shell 的路径,通常是 /bin/bash |
USER | 当前用户名 |
PWD | 当前工作目录 |
LANG | 区域与字符编码设置 |
HOME 可以直接验证:
$ echo $HOME
/home/jys
$ cd ~
$ pwd
/home/jys
~ 是 shell 展开成 $HOME 的结果,不是文件系统里的特殊名字。
4. 操作环境变量的命令
$ env # 列出所有环境变量
$ echo $PATH # 查看某一个
$ export MYENV=hello # 定义并导出
$ unset MYENV # 删除
$ set # 列出本地变量和环境变量
set 和 env 的差别值得留意:set 会连本地变量一起列出来,env 只列环境变量。这个差别后面还会提到。
三、程序怎么拿到环境表
环境变量在内存里是一张表:一个字符指针数组,每个指针指向一个以 \0 结尾的 名称=内容 字符串,数组最后以空指针收尾。
拿到它有几种方式。
1. main 的第三个形参
main函数的第三个形参就是环境变量表。
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
for (int i = 0; env[i] != NULL; i++) {
printf("%s\n", env[i]);
}
return 0;
}
这个写法在 Linux 上普遍可用,但它不属于 C 标准。标准只规定了 argc 和 argv,第三个参数靠的是编译器和 C 库的实现约定。换到别的平台不一定认,写可移植代码时要留意。
2. environ
glibc 有一个全局变量 environ,指向同一张表:
#include <stdio.h>
extern char **environ;
int main(void)
{
for (int i = 0; environ[i] != NULL; i++) {
printf("%s\n", environ[i]);
}
return 0;
}
environ 在 glibc 的 <unistd.h> 里有声明,自己再 extern 声明一次也能编译通过,类型一致即可。两种写法指向同一块数据。
3. getenv
大多数时候你只关心某一个变量,不需要遍历整张表:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
const char *path = getenv("PATH");
if (path == NULL) {
printf("PATH is not set\n");
return 1;
}
printf("%s\n", path);
return 0;
}
getenv 找不到时返回 NULL,一定要判断,不要直接拿去 printf("%s"),那是空指针解引用。
要修改环境,可以用 putenv 或 setenv。两者的差别在所有权:putenv 把传入的字符串本身挂进环境表,调用之后那块内存归环境表管,不能再改;setenv 会复制一份。多线程程序里更推荐 setenv。
四、本地变量与环境变量
1. 赋值与 export
$ i=10
$ echo $i
10
变量确实存在,但它只活在当前 shell 里。子进程看不到。
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
const char *v = getenv("MYENV");
printf("%s\n", v ? v : "(not found)");
return 0;
}
$ MYENV=hello
$ ./code
(not found)
$ export MYENV
$ ./code
hello
同样是赋值,加了 export 之后子进程就能看到。这说明 bash 内部维护着两张表:一张本地变量表,一张环境变量表。i=10 只写本地表,export i 把变量搬进环境表。env 看的是后者,set 看的是两张表合起来。
2. 子进程为什么能看到
fork 出来的子进程一开始和父进程共享同一份数据,随后 exec 换掉整个程序。换程序的时候,地址空间里的代码和数据都被替换,只有内核记录下来的、要传给新程序的那部分内容会保留,环境表就是其中之一。
换句话说,环境变量能被子进程继承,是因为内核在 exec 时把它当作参数传了过去,而不是因为它存在某个全局变量里。
五、这些数据放在哪里
现在可以回答开头那个问题了:argc、argv、envp 这些数据,在进程的地址空间里处于什么位置。
它们全都在栈顶。

图:从低地址往上依次是 argc、argv 数组、NULL、envp 数组、NULL、辅助向量,字符串内容在最上面。
排序从低地址到高地址:
argc,一个整数。argv[0]到argv[argc-1],每个元素是指向字符串的指针。- 一个
NULL,标记 argv 结束。 envp[0]到envp[m-1],环境表。- 再一个
NULL,标记 envp 结束。 - 辅助向量(auxv),内核传给动态链接器的信息,比如页大小、程序入口地址、随机数种子。
- 最后是那些字符串本身。
表的每个格子只存指针,字符串内容在更高的地方,两者不相邻。相邻两个元素指向的字符串之间隔着前一个字符串的长度,所以它们的地址差不是固定的 8 字节,而是随着字符串长短变化。
envp 就是前面说的 environ,同一张表的不同名字。
六、进程地址空间
栈顶只是整个地址空间的一小部分。完整看一遍,才能理解后面关于虚拟地址的结论。

图:32 位 Linux 的经典布局。用户空间 3 GB,内核空间 1 GB,栈在高地址向下生长,堆在低地址向上生长。
从低到高:
- 正文代码段(text),编译出来的机器指令,只读。
- 已初始化数据段(data),比如
int g_val = 100;。 - 未初始化数据段(bss),比如
int g_unval;,不占可执行文件的体积,加载时清零。 - 堆(heap),
malloc从这里要空间,向高地址生长。 - 共享库和文件映射区,
mmap的用武之地,动态库映射在这里。 - 栈(stack),函数调用、局部变量,向低地址生长。
- 栈顶之上是命令行参数和环境变量。
- 最高的 1 GB 归内核,用户态代码访问不到。
这是 32 位的经典划分:总共 4 GB,用户 3 GB,内核 1 GB。64 位下地址空间大得多,用户空间的边界也不再是 3 GB,但各段的相对位置和生长方向是一样的。
内核怎么描述它
每个进程有一个 mm_struct 描述整个用户空间,task_struct 里有指针指向它:
struct task_struct {
/* ... */
struct mm_struct *mm;
struct mm_struct *active_mm;
/* ... */
};
普通进程的 mm 指向自己的地址空间;内核线程没有用户空间,mm 为 NULL,改用 active_mm 借用上一个进程的。
mm_struct 里记着各段的边界:
struct mm_struct {
/* ... */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
/* ... */
};
注意最后一行:arg_start、arg_end、env_start、env_end。前面那两张表的位置,内核是明确记下来的,不是靠约定猜的。
每一段连续的虚拟内存区域用一个 vm_area_struct 描述,也就是 VMA。区域少时用单链表串起来,由 mmap 指向表头;区域多时改用红黑树,由 mm_rb 指向树根,查找更快。
七、父子进程的同一个地址
来看一个奇怪的现象:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int g_val = 100;
int main(void)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
if (id == 0) {
g_val = 200;
printf("child : %d, %p\n", g_val, (void *)&g_val);
} else {
sleep(1);
printf("parent: %d, %p\n", g_val, (void *)&g_val);
}
return 0;
}
child : 200, 0x60104c
parent: 100, 0x60104c
子进程把 g_val 改成了 200,父进程读到的还是 100,两边打印的地址一模一样,值却不一样
如果这个地址是物理地址,同一块内存不可能同时存两个值。所以得出结论:它只能是虚拟地址。

图:fork 时父子共享物理页并置为只读;子进程一旦写入,内核复制出一页给它,虚拟地址不变,物理页变了。
fork 刚完成时,父子两边的页表都指向同一批物理页,这些页被标记为只读。谁尝试写入,就会触发缺页异常,内核复制一份新的物理页,把写者的页表改指向新页。这个过程叫写时拷贝(copy-on-write)。
所以地址相同、内容不同,是因为两个进程的页表不同:同一个虚拟地址,在两张页表里翻译出了不同的物理地址。
0x60104c 是虚拟地址,不是内存条的某个物理位置。你在 C/C++ 里见到的所有指针值,包括 &g_val、malloc 的返回值、printf("%p") 的输出,全是虚拟地址。物理地址只有内核和 MMU 知道,用户态程序看不到。
八、为什么不能直接用物理内存
假设没有虚拟地址,程序直接操作物理地址,会碰到三个问题。
第一个是安全。任何一个进程都能读写任意物理内存,一个恶意程序可以改掉别的进程甚至内核的数据。没有边界,也就没有隔离。
第二个是地址不确定。程序加载到哪块物理内存,取决于当时内存的占用情况。第一次运行可能被放到 0x00000000,第十次运行时内存里已经有别的进程,位置就变了。编译器没法在编译期定下地址,链接器的工作也做不下去。
第三个是效率。物理内存不够时要把进程换到磁盘。如果按物理地址管理,一个进程就是一大块连续内存,换出就得整块搬走;有虚拟地址和分页,就可以按页换,只搬真正需要的那几页。
虚拟地址空间加页表解决了这些。进程看到的是一段连续、独立、从固定起点开始的地址空间,翻译工作交给 MMU 和页表,内核负责维护页表。
顺带解决了一个问题:malloc 申请内存时,内核可能一个字节的物理内存都没给,只在虚拟地址空间里划了一段区间、记下一个 VMA。真正访问到某一页时,才触发缺页,分配物理页、建立映射。这叫延迟分配,用户和程序都察觉不到。
九、总结
本文从main函数的形参写起,交代了它们是 exec 加载程序时由内核放在栈顶的。接着讲环境变量:怎么定义,export 之后子进程为什么就能看见,程序怎样分别用第三个形参、environ、getenv 拿到这些变量,以及 set 和 env 列出的为什么不是同一批变量。
然后讲了地址。先给出栈顶那段数据的排列顺序,再往下看整个进程地址空间,从 text、data、bss 一直到栈和最高的内核空间,也顺带看了内核用 mm_struct 和 VMA 记录这些区域的方式。最后解释了为什么fork 之后父子进程打印出同一个地址,值却不一样以及程序为什么不能直接操作物理内存。
希望对你的学习有所帮助!
766



被折叠的 条评论
为什么被折叠?



