【Linux】命令行参数、环境变量与程序地址空间
概览
- 环境变量是什么
- 命令行参数与 main 的参数
- argv 表的组织方式
- 指令选项的实现原理
- PATH 与 bash 内部表
- 环境表的层次与获取路径
- 全局特性的边界
- 程序地址空间、页表与写时拷贝
核心知识
一、环境变量是什么
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。这句话单独看比较抽象,落到具体的动作上反而清楚:写代码时链接动态库,编译命令里并没有写库文件的完整路径,链接照样能成功,因为有相关的环境变量帮链接器做了查找;在终端里敲下一个命令名,系统一开始也不知道对应的二进制文件放在哪个目录,同样是有环境变量在指路。
这两件事一件发生在编译链接的时候,一件发生在运行命令的时候,用的却是同一类东西。查看当前 shell 里的环境变量,直接敲 env 就能列出来:
bash
# 所属目录:/home/cocatrice/lab10
env | head -8
输出里每一行都是一个「名称=内容」的串,截取前几行是这样:
text
XDG_SESSION_ID=7608
SHELL=/bin/bash
TERM=vt100
HISTSIZE=10000
SSH_CLIENT=120.208.162.62 33716 22
OLDPWD=/home/cocatrice
SSH_TTY=/dev/pts/0
USER=cocatrice
这台机器上登录之后环境变量一共 24 条,数量随登录方式、发行版和 shell 配置变化。这里面 LD_LIBRARY_PATH 管的是动态库的搜索路径,PATH 管的是命令的搜索路径,SHELL 记的是当前用的是哪个 shell,TERM 记的是终端类型,SSH_CLIENT 和 SSH_TTY 是这次远程登录留下的痕迹,每一条都对应运行环境里的一个具体问题。
这些变量还有一层性质:它们通常具有全局特性。这里的全局有两层意思,一是在当前这个 shell 里随处可读,二是新起的进程默认也能看见同一份。第二层意思的边界在第十节展开,结论是先说清楚:这个全局并不表示整个系统只有一份,每个进程各自带着一份。
环境这个词在这里指的是进程运行时的外部条件。除了环境变量,进程还有几样同类的东西:当前工作目录、打开的文件描述符、信号的处理方式、资源上限,它们都在进程启动或运行的过程中被设定,程序读出来用,而不是由程序自己带在身上。环境变量是其中最容易改、最常被外壳程序代管的一份。
环境表本身是可以改的,putenv、setenv、unsetenv 三个接口能增删表项,改动影响当前进程以及它之后创建的子进程,已经在运行的其他进程看不到。这一点与文件描述符相反,后者在 fork 之后父子共享同一个打开文件,连偏移量都是同一份;环境表在 fork 之后各持一份,父进程再改动,子进程不受影响。
二、命令行参数
在终端里敲下 ./args -a -b -c,这串字符先被 shell 接收,再由 shell 拆开,最后交给程序。程序这一头看到的是一张表:
bash
# 所属目录:/home/cocatrice/lab10
./args -a -b -c
text
argc = 4
argv[0] = [./args]
argv[1] = [-a]
argv[2] = [-b]
argv[3] = [-c]
argv[argc] 是 NULL
argv 这个指针数组自己放在 0x7ffd3d252988
第一个参数字符串放在 0x7ffd3d253827
env 这个指针数组放在 0x7ffd3d2529b0
第一个环境字符串放在 0x7ffd3d253837
argc 是 4,因为程序的路径加三个选项一共四个。与直觉不同的是,argv0 里装的是程序自己的名字 ./args,真正的第一个选项在 argv1。数组的最后一项是 NULL,程序靠它判断参数到哪里结束,这也是为什么遍历这张表可以不依赖 argc:
c
int main(int argc, char *argv[])
{
for (int i = 0; i < argc; i++) {
printf("argv[%d]: %s\n", i, argv[i]);
}
return 0;
}
main 函数有两个参数,argc 是表的长度,argv 是表本身。另外还有第三种写法 int main(int argc, char *argv[], char *env[]),第三个参数就是环境表,第九节会用到。
参数不一定来自手工输入,敲下 ./args *.c,shell 会先把通配符展开成当前目录下的所有 .c 文件名,再交给程序:
bash
# 所属目录:/home/cocatrice/lab10
./args *.c | head -6
text
argc = 25
argv[0] = [./args]
argv[1] = [addr.c]
argv[2] = [arglen.c]
argv[3] = [args.c]
argv[4] = [cow.c]
目录里当时有 24 个 .c 文件,加上程序自己的名字,argc 变成 25。程序这一头完全不知道有通配符这回事,它拿到的就是 25 个已经展开好的字符串。展开这件事发生在 shell 里,不在程序里。
参数还可以是空字符串,./args "" 的 argc 是 2,argv1 打印出来是 \[\],也就是一个长度为 0 的字符串。空字符串和没有参数是两回事,程序里用 argv[1] == NULL 判断的是后者。

图 1 argv 数组与 env 数组都在栈的高地址处

图 2 从敲下命令到 main 开始跑:argv 与环境表是怎么交过去的
三、选项是怎么实现的
一个程序往往要能干好几件相关的事,比如列目录的 ls 有 -a、-l、-t 这些开关。实现方式没有秘密,就是把 argv 表逐项比对一遍:
c
#include <stdio.h>
#include <string.h>
static void usage(const char *name)
{
printf("用法: %s [-a|-b|-c]\n", name);
}
int main(int argc, char *argv[])
{
if (argc != 2) {
usage(argv[0]);
return 1;
}
if (strcmp(argv[1], "-a") == 0) {
printf("功能 A:打印一行说明\n");
} else if (strcmp(argv[1], "-b") == 0) {
printf("功能 B:再打印一行\n");
} else if (strcmp(argv[1], "-c") == 0) {
printf("功能 C:还是打印一行\n");
} else {
usage(argv[0]);
return 1;
}
return 0;
}
编译之后分别敲三个选项和一个不认得的选项:
bash
# 所属目录:/home/cocatrice/lab10
gcc -o opt opt.c && ./opt -a && ./opt -b && ./opt -c
echo "---- 给一个不认得的选项 ----"
./opt -d; echo "退出码 = $?"
./opt; echo "退出码 = $?"
text
功能 A:打印一行说明
功能 B:再打印一行
功能 C:还是打印一行
---- 给一个不认得的选项 ----
用法: ./opt [-a|-b|-c]
退出码 = 1
用法: ./opt [-a|-b|-c]
退出码 = 1
参数数量不对、或者选项不在认识的范围内,程序打印一行用法说明并以 1 退出。这个约定在系统命令上也是一样的,ls 认识 -a 和 -l,给它一个不认识的选项同样会报错退出。从命令行参数进来的字符串,一路走到代码里的分支,指令的选项就是这么实现的。
选项的写法有一些约定俗成的规矩,单个字母的短选项用减号开头,可以带自己的参数,也可以把几个不需要参数的短选项写在一起,ls -al 就是把 -a 和 -l 合成了一条;长选项用两个减号开头,写成完整单词,可读性更好。哪一种写法被接受、每种写法对应什么行为,全部由程序自己判断,解析库只是把 argv 里的字符串按规则拆开,语义仍然要程序一条条写出来。参数是这一次调用的输入,下一次调用可以完全不同;环境变量是这一类进程共同的背景,通常不在每次调用时改动。
四、一个例子一个环境变量:PATH
要执行一个程序,前提是先找到它,终端里敲 ls,并没有写它放在 /usr/bin 下,系统凭什么知道去找这个文件?靠的是 PATH:
bash
# 所属目录:/home/cocatrice/lab10
echo "PATH = $PATH"
type -a ls
command -v ls
text
PATH = /usr/local/bin:/usr/bin
ls is /usr/bin/ls
/usr/bin/ls
PATH 是一串用冒号隔开的目录,查找命令时按顺序在这些目录里找,找到第一个就停下。type 的输出说明 ls 是一个落在 /usr/bin 下的文件,而不是 shell 自己的内建命令。
把这串目录改坏,命令立刻找不到了:
bash
# 所属目录:/home/cocatrice/lab10
PATH=/nonexistent
hash -r
ls /etc/hostname; echo "PATH=/nonexistent 时的退出码 = $?"
text
ls: command not found
PATH=/nonexistent 时的退出码 = 127
hash -r 用来清掉 shell 里的命令位置缓存,否则刚才是从哪找到的,接下来还从哪找。退出码 127 是 shell 专门留给「命令没找到」的。
反过来,把自己写的命令放进 PATH,它就跟系统命令一样可以敲了:
bash
# 所属目录:/home/cocatrice/lab10
mkdir -p /home/cocatrice/lab11-out/bin
printf '#!/bin/bash\necho 这是我自己的命令\n' > /home/cocatrice/lab11-out/bin/mycmd
chmod +x /home/cocatrice/lab11-out/bin/mycmd
PATH=$PATH:/home/cocatrice/lab11-out/bin
mycmd
text
这是我自己的命令,放在 PATH 的最后一个目录里
PATH 是环境变量里最常被提到的一个,它示范了这类变量的共同点:某个具体的查找动作需要一份配置,这份配置不在程序里写死,而是放在环境里由使用者维护。
搜索顺序也由 PATH 决定:从左到右逐个目录去找,在前一个目录里命中就不再往后看。当前目录通常不写在 PATH 里,敲 ls 执行的是 /usr/bin/ls,而不是当前目录下恰好叫做 ls 的文件;要执行当前目录里的程序必须写 ./ls,把路径明确说清楚。这个约定挡掉了一类误执行,从别处下载的文件与系统命令同名时,只要 PATH 里没有当前目录,敲命令不会执行到它。反过来说,PATH 里如果放了一个所有人都能写的目录,别人在里面放一个同名文件就能顶替真正的命令,这一点在第十节还会再遇到。
五、bash 内部的两张表
敲命令时找命令是 bash 的活,找到之后把它启动起来也是 bash 的活。启动一个程序之前,bash 手里有两张表要准备:一张是命令行参数表,一张是环境变量表。
命令行参数表就是第二节里的 argv。敲下 ls /home/cocatrice/lab10 时,bash 先把这行文字按空白切开,第一个词是程序名,后面的词依次放进表里,表尾补一个 NULL,然后交给新程序。切分和补 NULL 都是 bash 做的,程序只是把表接过来。
环境变量表是另一张,bash 启动时先从一串配置文件里把变量读出来,之后自己维护这张表,等到要启动新程序时,再把这张表递过去。这张表的每一项形如名称等于内容,比如:
text
XDG_SESSION_ID=7608
HOSTNAME=hcss-ecs-4cd1
TERM=vt100
SHELL=/bin/bash
USER=cocatrice
HOME=/home/cocatrice
PATH=/usr/local/bin:/usr/bin
PWD=/home/cocatrice/lab10
这两张表都属于 bash 这个进程,一个 Linux 系统上如果有 10 个用户同时登录,就有 10 个 bash 进程在跑,也就有 10 份各自独立的表。同一个用户开两个终端,同样是各自独立的两份。环境变量并不由整个系统共享,每个进程自己带着一份数据,这一点在第十三节看到父子进程各自的地址时会更直观。
bash
# 所属目录:/home/cocatrice/lab10
echo "SHLVL = $SHLVL"
text
SHLVL = 2
SHLVL 记的是当前 shell 的嵌套层数,每进一层 bash 加一,它本身就是「每个 shell 各有一份环境」的直接证据。
两张表的差别在于谁负责切分、谁负责传递。命令行参数表由终端里敲下的那一行切出来,切分动作在外壳里完成,切完把数组交给要执行的程序,用完即弃;环境变量表要长期留在外壳进程里,每创建一次子进程就复制一份下去。一个是一次性的输入,一个是持续存在的背景,前者多了会启动失败,后者少一条通常只是某个功能不生效。

图 3 bash 手里的两张表:命令行参数表与环境变量表
六、环境表的结构
环境变量表在内存里是两层结构,第一层是一个指针数组,数组的每一项是一个 char *,指向一个字符串;第二层就是那些字符串本身。数组本身以 NULL 收尾,遍历时以它为终点。C 语言里这张表有两种写法,等价:
c
extern char **environ;
c
int main(int argc, char *argv[], char *env[])
把数组里存放的指针值打印出来,再和字符串自身的地址对照,结构就很清楚了:
bash
# 所属目录:/home/cocatrice/lab10
./envaddr
text
env 数组本身 : 0x7ffc2b5b7838
env[0] 指针的值 : 0x7ffc2b5b882c 字符串自己在哪里: 0x7ffc2b5b882c
env[1] 指针的值 : 0x7ffc2b5b8840 字符串自己在哪里: 0x7ffc2b5b8840
env[2] 指针的值 : 0x7ffc2b5b8850 字符串自己在哪里: 0x7ffc2b5b8850
env[3] 指针的值 : 0x7ffc2b5b885b 字符串自己在哪里: 0x7ffc2b5b885b
env[4] 指针的值 : 0x7ffc2b5b886a 字符串自己在哪里: 0x7ffc2b5b886a
左边是数组里存的值,右边是那个字符串本身的地址,两列完全相同,说明数组并不存放字符串,只存放指向字符串的指针。这些字符串一个挨一个排在高地址处,相邻两条的地址差就是前一条的长度加一(那个一是结尾的 \0)。
数组放在哪、字符串放在哪,也能算出来。再写一个程序,同时打印 argv、argv 数组末尾和 env:
bash
# 所属目录:/home/cocatrice/lab10
./layout a b c d
text
argc = 5
argv = 0x7ffcdf2b4eb8
argv[argc] = 0x7ffcdf2b4ee0
env = 0x7ffcdf2b4ee8
env - argv = 6 个指针
argc + 1 = 6
argv 这个数组一共有 argc 加 1 项,多出来的那一项是结尾的 NULL,所以它的下一个位置正好就是环境表数组的开头,两张表挨着摆放,中间没有别的东西。
两张表能挨在一起,是因为它们由内核在启动程序时一起放到栈顶:先是参数字符串与它们的指针数组,接着是环境字符串与它们的指针数组,顺序固定,中间没有别的数据。程序打印出来的 argv、env 以及那些字符串的地址都落在 0x7ff 开头的一片区域里,正是这个摆放顺序带来的结果。程序运行过程中新建的变量不会跑到那片区域去,栈上的局部变量从栈顶往下长,堆上的内存从低地址往上长,与环境表互不干扰。
地址空间里的内容分两类:一类来自可执行文件,装载时按段映射进来;另一类是运行时由内核划出来的,堆、栈、共享区都属于这一类。可执行文件里并没有一个叫堆的段,堆的起点在 bss 结束之后,随程序的需要向上生长。

图 4 环境表的两层结构:指针数组在栈上,字符串连续存放
七、环境变量里都有什么
把一次登录会话里的变量列全,一共 23 条,按用途分一下就清楚多了:
bash
# 所属目录:/home/cocatrice/lab10
env | sort
text
GCC_COLORS=
HISTSIZE=10000
HISTTIMEFORMAT=%F %T cocatrice
HOME=/home/cocatrice
LANG=en_US.UTF-8
LD_LIBRARY_PATH=:/home/cocatrice/.VimForCpp/vim/bundle/YCM.so/el7.x86_64
LESSOPEN=||/usr/bin/lesspipe.sh %s
LOGNAME=cocatrice
LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:...(后面还有一长串后缀配色)
MAIL=/var/mail/cocatrice
OLDPWD=/home/cocatrice
PATH=/usr/local/bin:/usr/bin
PWD=/home/cocatrice/lab10
SHELL=/bin/bash
SHLVL=2
SSH_CLIENT=120.208.162.62 33224 22
SSH_CONNECTION=120.208.162.62 33224 172.31.5.198 22
SSH_TTY=/dev/pts/0
TERM=vt100
USER=cocatrice
XDG_RUNTIME_DIR=/run/user/1000
XDG_SESSION_ID=7662
_=/usr/bin/env
身份类的几条是 USER、LOGNAME、HOME、SHELL,登录之后程序要判断自己以谁的身份在跑,靠的就是它们。终端与登录会话类的是 TERM、SSH_CLIENT、SSH_CONNECTION、SSH_TTY、XDG_SESSION_ID,记录这次会话从哪个地址来、落在哪个伪终端上。运行环境类的最多,PATH 决定去哪找命令,LD_LIBRARY_PATH 决定去哪找动态库,LANG 决定字符集,LS_COLORS 决定 ls 给不同文件配什么颜色,MAIL 指向收信文件。shell 自己维护的有 PWD、OLDPWD、SHLVL、HISTSIZE、HISTCONTROL,分别记当前目录、上一个目录、shell 嵌套层数、历史记录条数和历史去重规则。最后一条 _=/usr/bin/env 是上一个执行的命令,属于 shell 自己记下的。
LS_COLORS 这条值最长,它是一串用冒号隔开的配色规则,每个前缀对应一类文件:
bash
# 所属目录:/home/cocatrice/lab10
echo "$LS_COLORS" | tr ':' '\n' | head -5
text
rs=0
di=01;34
ln=01;36
mh=00
pi=40;33
di 是目录,配的是 01;34 也就是加粗的蓝色;ln 是符号链接,01;36 是青色;ex 是可执行文件。规则本身由 shell 从配置文件里读进来,ls 只负责按规则着色。
LD_LIBRARY_PATH 与链接相关,用 ldd 看一个程序的动态库依赖,再对照这条变量就能理解它的作用:
bash
# 所属目录:/home/cocatrice/lab10
ldd ./args
echo "LD_LIBRARY_PATH = $LD_LIBRARY_PATH"
text
linux-vdso.so.1 => (0x00007ffe05893000)
libc.so.6 => /lib64/libc.so.6 (0x00007f7c450b7000)
/lib64/ld-linux-x86-64.so.2 (0x00007f7c45485000)
LD_LIBRARY_PATH = :/home/cocatrice/.VimForCpp/vim/bundle/YCM.so/el7.x86_64
程序能跑起来,前提是 libc.so.6 和 ld-linux 都被找到了。这两条路径不是写死在程序里的,加载器会依次查 LD_LIBRARY_PATH、缓存文件和系统默认目录。
这个查找顺序是有讲究的:链接时记录在可执行文件里的路径先看,然后才是 LD_LIBRARY_PATH 指定的目录,接着是 ldconfig 维护的缓存,最后才是系统默认目录。缓存由 ldconfig 在软件安装或升级之后重新生成,新放进默认目录的库要等缓存刷新才会被找到,这也是装完库之后常要执行一次 ldconfig 的原因。
八、echo,export,env,unset
日常操作环境变量靠四个内建命令,加上一个用来连本地变量一起看的 set:
| 动作 | 命令 | 说明 |
|---|---|---|
| 读一条 | echo $HOME | 加 展开成内容, 后面跟变量名 |
| 设一条 | export MYVAL=abc | 赋值并导出,子进程可见 |
| 列出来 | env | 只列环境变量 |
| 列全部 | set | 环境变量加本地变量一起列 |
| 删一条 | unset MYVAL | 从环境表里去掉 |
读一条变量时,变量名前面要加 $,可以用花括号把名字括起来避免和后缀混在一起:
bash
# 所属目录:/home/cocatrice/lab10
echo "HOME=$HOME USER=$USER SHELL=$SHELL"
echo "HOME 的长度是 ${#HOME}"
echo "${HOME}abc 和 $HOMEabc 不一样"
export 除了直接赋值,还可以把一个已经存在的本地变量导出:
bash
# 所属目录:/home/cocatrice/lab10
i=10
export i
env | grep '^i='
text
i=10
这里有个容易被忽略的点:export 是 shell 的内建命令(built-in command),它由 bash 自己执行,不创建子进程。type 可以看出来:
bash
# 所属目录:/home/cocatrice/lab10
type export
type ls
type cd
text
export is a shell builtin
ls is /usr/bin/ls
cd is a shell builtin
export 和 cd 都是内建,ls 是一个实实在在的文件。这件事还能用 strace 从系统调用的角度验证。执行 export FOO=1 时,整个命令只对应一次 execve,那次 execve 是 strace 自己拉起来的 bash;执行 /bin/true 时多出一次:
bash
# 所属目录:/home/cocatrice/lab10
strace -f -e trace=execve,clone -o /tmp/a.log bash -c 'export FOO=1'
echo "export FOO=1 背后 execve 的次数 = $(grep -c execve /tmp/a.log)"
strace -f -e trace=execve,clone -o /tmp/b.log bash -c '/bin/true'
echo "跑一次 /bin/true 背后 execve 的次数 = $(grep -c execve /tmp/b.log)"
grep execve /tmp/b.log
text
export FOO=1 背后 execve 的次数 = 1
跑一次 /bin/true 背后 execve 的次数 = 2
11551 execve("/usr/bin/bash", ["bash", "-c", "/bin/true"], 0x7ffd217bfbb8 /* 24 vars */) = 0
11551 execve("/bin/true", ["/bin/true"], 0x22803b0 /* 23 vars */) = 0
两次 execve 里,第一次都是 bash 自己,第二次才是 /bin/true 这个程序。内建命令的那一次没有第二个 execve,说明它确实没有另起进程。cd 是内建的原因也能反过来理解:如果 cd 是独立进程,它改的只是自己的当前目录,退出之后 shell 还在原来的目录里,切换目录就失效了。环境变量同理,如果 export 由子进程执行,那个变量只会设在子进程里,退出就没了。
常用的内建命令有 cd、export、unset、alias、type、source 这些。判断一条命令属于哪一类,用 type 比猜更直接,它会把结果明确写出来。还有一种情况介于两者之间:echo 这类命令既有内建实现,也有同名的外部程序,执行时优先走内建的那一份,两者在个别边界上的行为可能不同。
九、代码里取环境变量
程序里读环境变量有三条路,殊途同归,拿到的都是同一张表,区别只在书写方式和能不能直接改动它。
第一条路是 main 的第三个参数:
c
int main(int argc, char *argv[], char *env[])
{
for (int i = 0; env[i] != NULL; i++) {
printf("env[%d] = %s\n", i, env[i]);
}
return 0;
}
第二条路是声明外部的 environ,它是 libc 里维护的一个全局变量,指向同一张表:
c
extern char **environ;
第三条路是 getenv,只需要名字,不用管表长什么样:
c
char *path = getenv("PATH");
三条路的实测对照:
bash
# 所属目录:/home/cocatrice/lab10
./envthree
text
--- 方式一:main 的第三个参数 ---
env[0] = XDG_SESSION_ID=7666
env[1] = SHELL=/bin/bash
env[2] = TERM=vt100
env 这张表自己放在 0x7ffed546f878
--- 方式二:extern char **environ ---
environ[0] = XDG_SESSION_ID=7666
environ[1] = SHELL=/bin/bash
environ[2] = TERM=vt100
environ 这个变量自己放在 0x601080,它的值也就是表的首地址是 0x7ffed546f878
--- 两种方式拿到的是不是同一张表 ---
env == environ ? 是
--- 方式三:getenv ---
PATH = /usr/local/bin:/usr/bin
HOME = /home/cocatrice
SHELL = /bin/bash
MYNOTEXIST 没有,getenv 返回 (nil)
--- 环境表里一共有 23 条 ---
environ 这个变量自己在 0x601080,那是数据段(已初始化全局变量所在的位置);它的值 0x7ffdf2df12f8 是字符串所在的高地址区,两张表就是同一张。
getenv 找不到对应名字时返回 NULL,指针打印出来是 (nil),这个返回值必须判断,直接用会有麻烦。
改环境变量用 putenv、setenv、unsetenv:
c
int putenv(char *string); /* 传进来的字符串会直接进表 */
int setenv(const char *name, const char *value, int overwrite);
int unsetenv(const char *name);
setenv 的第三个参数管的是覆盖与否:给 1,同名变量被改成新值;给 0,已经存在就保持原样。实测:
bash
# 所属目录:/home/cocatrice/lab10
./envapi
text
读一个存在的:PATH = /usr/local/bin:/usr/bin
读一个不存在的:getenv 返回 (nil)
putenv 之后:MYVAL = 用 putenv 设的值
setenv 之后:MYVAL2 = 第一次设的值
第三个参数给 1,覆盖:MYVAL2 = 第二次设的值
第三个参数给 0,不覆盖:MYVAL2 = 第二次设的值
--- 改动能不能传给子进程 ---
MYVAL2=第二次设的值
MYVAL=用 putenv 设的值
unsetenv 之后:MYVAL 的返回是 (nil)
--- 再起一个子进程看看 ---
MYVAL2=第二次设的值
中间那两行来自程序里执行的一句 system("env | grep ^MYVAL"),子进程能看到程序设进去的两条变量,说明在 fork 或 system 的那一刻,进程手里这张表被复制给了子进程。程序退出之后回到 shell,再敲 env | grep MYVAL 就找不到了,因为 shell 手里那张表从没被改过。
除了代码,还有一个地方能看到这张表,系统给每个进程在 /proc 下留了一个文件:
bash
# 所属目录:/home/cocatrice/lab10
sleep 30 &
spid=$!
tr '\0' '\n' < /proc/$spid/environ | head -6
echo "一共 $(tr '\0' '\n' < /proc/$spid/environ | grep -c '=') 条"
kill $spid
text
后台 sleep 的 pid = 18523
XDG_SESSION_ID=7668
SHELL=/bin/bash
TERM=vt100
HISTSIZE=10000
SSH_CLIENT=120.208.162.62 33248 22
OLDPWD=/home/cocatrice
环境变量一共 23 条
文件里各个字符串之间用 \0 分隔,所以先用 tr 把 \0 换成换行再读。这份内容和进程自己拿到的那张表是一致的,改动之后这个文件里也会跟着变。
三种走法看到的是同一张表,区别在于入口本身放在哪里。environ 是一个全局变量,它自己占着数据段里的一小块位置(实测里是 0x601080),里面存的值才是那张表的首地址;main 的第三个参数 envp 是一个形参,存在栈上。两者指向同一片内存,生命周期却不同:形参随函数返回失效,environ 这个变量在整个进程里一直有效,把表往外传的时候要留意这一点。

图 5 取环境变量的三条路:environ、getenv 与 envp 指向同一张表
十、全局特性与它的边界
环境变量的全局特性容易被理解错,它不是整个系统共享一份,新进程能看见父进程的那份,靠的是继承。
先看一个没有被导出的变量:
bash
# 所属目录:/home/cocatrice/lab10
i=10
echo "当前 shell 里的 i = $i"
set | grep '^i='
env | grep '^i=' ; echo "env 里没有 i,退出码 = $?"
bash -c 'echo "子 shell 里的 i = [$i]"'
text
当前 shell 里的 i = 10
i=10
env 里没有 i,退出码 = 1
子 shell 里的 i = []
i 在 set 的输出里(连本地变量一起列),不在 env 的输出里,子 shell 里读出来是空的。这样的变量叫本地变量,只属于当前这个 bash。把它导出之后,同样的四条命令结果就变了:
bash
# 所属目录:/home/cocatrice/lab10
export i
bash -c 'echo "export 之后子 shell 里的 i = [$i]"'
env | grep '^i='
text
export 之后子 shell 里的 i = [10]
i=10
另一头也成立:子进程改自己的环境,父进程看不到。第九节里 envapi 用 putenv 设了 MYVAL,程序内部读得到,回到 shell 再查就没有了,父子各有一份,谁改谁的,不影响对方。
这个性质经常被用来做身份判断,写法是在程序里读 USER,不是期望的值就拒绝执行:
bash
# 所属目录:/home/cocatrice/lab10
./whoami_env
USER=root ./whoami_env; echo "退出码 = $?"
env -u USER ./whoami_env; echo "退出码 = $?"
text
USER = cocatrice
身份校验通过,开始干活
USER = root
身份校验不通过,退出
退出码 = 1
USER 不存在,拒绝执行
退出码 = 1
同一个二进制的程序,只是启动时前面加一句 USER=root,它读到的 USER 就变了,身份校验跟着换了结果。环境变量是调用方递给程序的参数,谁启动进程谁说了算,程序自己没有鉴别能力。要真正判断身份,必须走系统调用拿真实的用户 id,这一条在踩坑点里再列一次。
可信的东西只有内核提供的那几样:getuid 与 geteuid 返回调用进程的真实用户与有效用户,/proc/self/status 里的 Uid 一行给出同样的信息,文件的属主与权限位也由内核维护,调用方改不动。环境变量适合承担的事情是配置,程序去哪里找文件、用哪种语言输出、日志写到什么级别;它不适合承担的事情是授权,谁在调用这个程序、调用者有没有资格,这类问题不能交给调用方自己声明。
换个角度看,这条边界也解释了环境变量为什么普及得这么广:它足够方便,任何语言都能读,任何外壳都能设,改动不需要重新编译,也正因为方便,改动它没有任何门槛。方便的接口与可信的接口是两回事,做权限判断时要选后者。
十一、一段打印地址的代码
前面十节都在讲进程手里的两张表,接下来换一个角度,看这两个表本身放在哪里。写一个程序,把六类东西的地址各打一个出来:
c
#include <stdio.h>
#include <stdlib.h>
int g_init = 100; /* 已初始化全局变量 */
int g_uninit; /* 未初始化全局变量 */
static int s_static = 200; /* 静态局部变量 */
int main(int argc, char *argv[])
{
int a = 1, b = 2, c = 3;
const char *str = "helloworld";
char *heap1 = malloc(10);
char *heap2 = malloc(10);
char *heap3 = malloc(10);
char *heap4 = malloc(10);
printf("main 函数 : %p\n", (void *)main);
printf("已初始化全局变量: %p\n", (void *)&g_init);
printf("未初始化全局变量: %p\n", (void *)&g_uninit);
printf("静态局部变量 : %p\n", (void *)&s_static);
printf("堆 第一次 malloc: %p\n", (void *)heap1);
printf("堆 第二次 malloc: %p\n", (void *)heap2);
printf("堆 第三次 malloc: %p\n", (void *)heap3);
printf("堆 第四次 malloc: %p\n", (void *)heap4);
printf("栈 第一个局部量 : %p\n", (void *)&a);
printf("栈 第二个局部量 : %p\n", (void *)&b);
printf("栈 第三个局部量 : %p\n", (void *)&c);
printf("只读字符串常量 : %p\n", (void *)str);
printf("argv 数组 : %p\n", (void *)argv);
printf("env 数组 : %p\n", (void *)environ);
return 0;
}
编译之后跑出来的地址是这样:
bash
# 所属目录:/home/cocatrice/lab10
gcc -o addr addr.c && ./addr
text
main 函数 : 0x4005bd
已初始化全局变量: 0x601044
未初始化全局变量: 0x601050
静态局部变量 : 0x601048
堆 第一次 malloc: 0x2570010
堆 第二次 malloc: 0x2570030
堆 第三次 malloc: 0x2570050
堆 第四次 malloc: 0x2570070
栈 第一个局部量 : 0x7ffcc814add4
栈 第二个局部量 : 0x7ffcc814add0
栈 第三个局部量 : 0x7ffcc814adcc
只读字符串常量 : 0x400810
argv 数组 : 0x7ffcc814aee8
env 数组 : 0x7ffcc814aef8
这些地址分成三个区间,最小的 0x4005bd 和 0x400810 落在 0x400000 开头的低区,代码和只读数据在那里;0x601044 开始的几个全局量落在一个稍高的位置,未初始化数据、已初始化数据和静态局部变量挤在一起;0x2570010 开头的四个堆地址每次加 0x20,是 malloc 连续分配的痕迹;0x7ffcc814 开头的几个地址最高,局部变量和两张表都在那里,栈上的三个变量地址依次递减,因为栈向低地址方向生长。
把它们和 C 语言里那张内存布局图对上,规律是清楚的:
- 代码段 text:函数体编译出来的机器指令,只读可执行
- 只读数据段 rodata:字符串常量、const 修饰的全局量,只读
- 已初始化数据段 data:g_init、s_static 这类带有初值的全局量
- 未初始化数据段 bss:g_uninit 这类没有初值的全局量,程序文件里不占空间
- 堆 heap:malloc 出来的内存,向上生长
- 栈 stack:函数里的局部变量、参数、返回地址,向下生长
一个变量落在哪一段,由它的存储类别决定,和它在哪里被使用没有关系。静态局部变量 s_static 写在函数里,地址却在数据段,因为它的生命周期是整个程序,编译器把它安排进了数据段。

图 6 C 程序的内存布局:从代码段到栈
段的划分还能用工具从可执行文件里读出来。size 给出三个段的大小,objdump 和 readelf 给出每一个段被安排到哪个地址:
bash
# 所属目录:/home/cocatrice/lab10
size addr
readelf -S addr | grep -E '\.text|\.rodata|\.data|\.bss'
readelf -h addr | grep -E 'Type:|Entry'
text
text data bss dec hex filename
2179 572 12 2763 acb addr
[13] .text PROGBITS 00000000004004d0 000004d0
[15] .rodata PROGBITS 0000000000400800 00000800
[24] .data PROGBITS 0000000000601040 00001040
[25] .bss NOBITS 000000000060104c 0000104c
Type: EXEC (Executable file)
Entry point address: 0x4004d0
.text 从 0x4004d0 起,长度 0x322;.rodata 从 0x400800 起;.data 从 0x601040 起;.bss 的类型是 NOBITS,意思是它在文件里不占空间,程序加载时在内存里留出位置再清零。Type 那一行写的是 EXEC,表示这是一个固定加载地址的可执行文件,所以两次运行打印出来的地址一模一样。换一种编译方式,结果就变了:
bash
# 所属目录:/home/cocatrice/lab10
gcc -fPIE -pie -o addr_pie addr.c && ./addr_pie | head -4 && ./addr_pie | head -4
text
main 函数 : 0x56112a1c0785
已初始化全局变量: 0x56112a3c104c
未初始化全局变量: 0x56112a3c1058
静态局部变量 : 0x56112a3c1050
main 函数 : 0x555658ff7785
已初始化全局变量: 0x5556591f804c
未初始化全局变量: 0x5556591f8058
静态局部变量 : 0x5556591f8050
两次运行的地址不同,各段之间的相对距离却不变。这种编译产物叫位置无关可执行文件(position independent executable,PIE),现代发行版默认就是这么编的,加载地址每次随机,属于地址空间布局随机化(address space layout randomization,ASLR)的一部分。
十二、进程的地址空间
把这些段拼起来,就是一张地址空间的图。32 位机器上这张图正好是 4GB:最高的 1GB 留给内核,用户能用的是低 3GB,从下往上依次是代码段、已初始化数据、未初始化数据、堆、一大片空当、共享区、栈,栈的上方是命令行参数和环境变量。

图 7 64 位机器上一个进程的地址空间布局
我们这台机器是 64 位,地址宽度是 48 位有效,图上各段的位置要换成实测的形态:
bash
# 所属目录:/home/cocatrice/lab10
./showmaps > /tmp/sm.out &
sleep 1
spid=$(awk '{print $3}' /tmp/sm.out)
cat /proc/$spid/maps
text
00400000-00401000 r-xp 00000000 fd:01 923986 /home/cocatrice/lab10/showmaps
00600000-00601000 r--p 00000000 fd:01 923986 /home/cocatrice/lab10/showmaps
00601000-00602000 rw-p 00001000 fd:01 923986 /home/cocatrice/lab10/showmaps
7f0e32490000-7f0e32654000 r-xp 00000000 fd:01 528183 /usr/lib64/libc-2.17.so
7f0e32654000-7f0e32853000 ---p 001c4000 fd:01 528183 /usr/lib64/libc-2.17.so
7f0e32853000-7f0e32857000 r--p 001c3000 fd:01 528183 /usr/lib64/libc-2.17.so
7f0e32857000-7f0e32859000 rw-p 001c7000 fd:01 528183 /usr/lib64/libc-2.17.so
7f0e32859000-7f0e3285e000 rw-p 00000000 00:00 0
7f0e3285e000-7f0e32880000 r-xp 00000000 fd:01 528176 /usr/lib64/ld-2.17.so
7f0e32a75000-7f0e32a78000 rw-p 00000000 00:00 0
7f0e32a7d000-7f0e32a7f000 rw-p 00000000 00:00 0
7f0e32a7f000-7f0e32a80000 r--p 00021000 fd:01 528176 /usr/lib64/ld-2.17.so
7f0e32a80000-7f0e32a81000 rw-p 00022000 fd:01 528176 /usr/lib64/ld-2.17.so
7f0e32a81000-7f0e32a82000 rw-p 00000000 00:00 0
7ffe3a76b000-7ffe3a78c000 rw-p 00000000 00:00 0 [stack]
7ffe3a7d3000-7ffe3a7d5000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
每一行是一段连续的区域,格式是起始地址、结束地址、权限、偏移量、设备号、inode 和文件名。前两行可以看权限的写法:r-xp 表示可读可执行、不可写,对应代码段;r--p 只读,对应只读数据;rw-p 可读可写不可执行,对应数据段。libc 和 ld 各占了四段,是因为同一个文件被映射了多次,代码段共享、数据段各自一份。最后几行没有文件名,是匿名映射,栈是其中一段,vdso 和 vsyscall 是内核放进来给用户态调用的两个特殊页。
地址的形态值得单独看一眼,代码段在 0x400000 附近,共享库在 0x7f 开头的高处,栈在 0x7ffe 开头,命令行参数和环境变量就在栈的最高处,和我们第二节看到的地址(0x7ffd 开头)是同一个区域。32 位机器上这些段挤在 4GB 里,64 位机器上它们被摊到 128TB 的用户空间里,段与段之间留出了大量空洞。
这个进程自己报了三个数字,也能对照着看:
bash
# 所属目录:/home/cocatrice/lab10
cat /proc/$spid/status | grep -E 'VmPeak|VmSize|VmRSS|VmData|VmStk|VmExe|VmLib|VmPTE'
cat /proc/$spid/statm
text
VmPeak: 4236 kB
VmSize: 4216 kB
VmRSS: 352 kB
VmData: 52 kB
VmStk: 132 kB
VmExe: 4 kB
VmLib: 1944 kB
VmPTE: 28 kB
1054 88 70 1 0 46 0
VmSize 是地址空间里所有区域的总和,4216 kB;VmRSS 是真正落在物理内存里的部分,352 kB,两者差了十倍以上;VmLib 1944 kB 是那些动态库贡献的;VmPTE 28 kB 是页表本身占用的内存,页表也是要花内存的。statm 里的七个数字依次是总大小、常驻、共享、代码、库、数据、页表,单位是页。
32 位与 64 位的差别体现在这张图的纵深上。32 位机器的地址空间一共 4GB,用户能用的部分是 3GB,动态库与映射区被挤在中间一小段;64 位机器上用户空间有 128TB,中间那片空当非常大,动态库、mmap 出来的匿名内存与文件映射都放在靠近栈的这一侧,地址通常是 0x7f 开头。共享区这个名字来自它的用途,多个进程可以把同一个文件的同一段映射到各自的地址空间里,代码段就是这么共享的,动态库在物理内存里只需要存一份。
readelf 列出来的段名与这张图里的区域逐一对应:.text 与 .rodata 是只读的两段,.data 装已初始化且初值不为零的全局变量,.bss 只记长度、不占文件空间,因为没有初值需要存。程序文件里没有堆这一段,堆是运行时由内核按需划出来的区域。
十三、fork 之后地址一样值不一样
地址空间这个概念的入口,是一个看起来矛盾的现象。下面这个程序 fork 一次,父子进程都打印同一个全局变量的地址和值:
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int g_val = 0;
int main(int argc, char *argv[])
{
int mode = argc > 1 ? atoi(argv[1]) : 0;
pid_t id = fork();
if (id == 0) {
if (mode >= 1) {
g_val = 100;
}
printf("child [%d] : g_val = %3d, &g_val = %p\n", getpid(), g_val, (void *)&g_val);
fflush(NULL);
_exit(0);
}
if (mode == 2) {
g_val = 200;
}
printf("parent[%d] : g_val = %3d, &g_val = %p\n", getpid(), g_val, (void *)&g_val);
wait(NULL);
return 0;
}
三种模式各跑一次:
bash
# 所属目录:/home/cocatrice/lab10
gcc -o forkval forkval.c
./forkval 0
./forkval 1
./forkval 2
text
child [12675] : g_val = 0, &g_val = 0x601078
parent[12674] : g_val = 0, &g_val = 0x601078
child [12677] : g_val = 100, &g_val = 0x601078
parent[12676] : g_val = 0, &g_val = 0x601078
child [12717] : g_val = 100, &g_val = 0x601078
parent[12716] : g_val = 200, &g_val = 0x601078
三组输出里,&g_val 都是 0x601078,一个字节不差;值却各走各的,子进程改成 100 不影响父进程,父进程改成 200 也不影响子进程。如果打印出来的是物理地址,同一个地址不可能同时装两个值,一个进程改另一个进程必然会看到。唯一的解释是:这里打印的是虚拟地址,父子两个进程各自有一套映射,同一个虚拟地址 0x601078 在两个进程里指向不同的物理内存。

图 8 父子进程同一个虚拟地址指向不同的物理页
同样的现象在命令行参数和环境变量上也能看到。第一节的 args 程序打印过 argv 数组和 env 数组的地址,也在 0x7ffd 开头的高地址区,它们同样是虚拟地址,每个进程各有一份。
fork 得到的子进程,地址空间描述是从父进程复制来的,连每个区域的范围都一样,因此打印出来的地址数值一致。这些描述指向的映射在开始时也是同一批物理页,只有页表本身必须各自复制一份,否则一方的改动会直接改到另一方的映射关系上。命令行参数与环境变量位于栈顶,属于父进程创建子进程之前就已经摆好的内容,同样以这个方式被继承下来,值一致,地址也一致。
地址翻译这一步在用户程序里是隐身的,x86-64 的页表分成四级,最高一级的基址存在地址空间描述里,CPU 每访问一个地址,硬件按四级逐层查下去,中间哪一层不存在或者权限不符,就触发一次缺页异常交给内核处理。进程切换时更换的正是这个最高一级的基址,一次写控制寄存器的动作就把整套映射换成了另一个进程的。
十四、一个进程一个地址空间、一套页表
进程的独立性有两层,一层在内核里:每个进程有自己的 task_struct,有自己的页表,页表记录着这个进程的虚拟地址到物理页的对应关系。另一层在用户侧:代码和数据是各进程自己的,父子两个进程看到的同一个虚拟地址,落到不同的物理页上。
这两层是配合的,fork 之后父子进程的虚拟地址一模一样,是因为子进程复制了父进程的地址空间描述;值互不影响,是因为描述里指向的物理页在写入的时候分开了。打印地址这个动作,走的是虚拟地址,交给 CPU 之后由页表翻译,翻译的结果在哪个进程里查,就用哪个进程的页表。
有一个常见说法是进程之间共享代码段,可以省内存。这个说法本身没错,不过它和「进程独立」并不矛盾:共享的是同一个只读文件的映射,谁都不写,共用一份物理页是安全的;一旦要写,映射关系就会改成分开的两份。这一节的结论可以先记成一句话:进程的独立性,落在了每个进程自己那张映射表上。
把这句话反过来读就是地址空间的意义:进程不知道也不需要知道物理内存在哪里、哪一页被谁占用,它只在自己的编号体系里工作,编号到物理位置的对应关系由内核维护。地址空间因此是一份描述,而不是一段内存。进程被挂起时,这份描述还在,页表还在,物理页可能已经被写到磁盘上,等它再次被调度时按需装回来。
十五、地址空间是什么
十三节那个现象还有一层没解释:父子进程打印出来的地址离得很近,都在 0x601078 和 0x7ffd 开头,看上去像是所有进程共用同一片内存。实际上每个进程都以为自己独占整个地址空间,跑起来的时候谁也不觉得旁边还有别人。
这是地址空间这个概念最反直觉的地方,真机上同时跑着几十个进程,每一个都能 malloc 出几百 MB 而不担心别人抢;每一个打印出来的栈地址都在 0x7ffe 开头,堆地址都在 0x2 开头,谁都不觉得自己和别人撞了。会撞的前提是地址唯一,而这些地址本来就不唯一,每个进程手里一张自己的表,同一个数字在不同的表里可以指向不同的物理页。

图 9 每个进程都以为自己独占整个地址空间
说得再直接一点,程序地址空间到底是不是内存?它只是一份描述,内核里描述一个进程地址空间的结构体叫 struct mm_struct:
c
struct mm_struct {
struct vm_area_struct *mmap; /* 指向第一个虚拟内存区域 */
struct rb_root mm_rb; /* 用红黑树组织这些区域 */
struct vm_area_struct *mmap_cache; /* 上一次命中的区域,用于加速查找 */
pgd_t *pgd; /* 页全局目录,页表的顶层 */
atomic_t mm_users; /* 多少个线程在用这个地址空间 */
atomic_t mm_count; /* 结构体本身被引用多少次 */
int map_count; /* 一共有多少个虚拟内存区域 */
atomic_long_t nr_ptes; /* 页表的页数 */
unsigned long total_vm; /* 映射的总页数 */
/* ... */
};
(字段来自 /usr/src/kernels/3.10.0-1160.119.1.el7.x86_64/include/linux/mm_types.h:400 起的定义。)
这份结构里没有一个字节是给用户程序用的内存。它记的是这个进程的地址空间长什么样:从哪到哪是一段代码,从哪到哪是一段数据,页表在哪,一共划了几个区。程序要用的内存,是内核另外从物理内存里分配、再把映射写进这份描述里的。
所以「每个进程都以为自己独占 4GB」这句话,准确的说法是:每个进程手里的这份描述,是独立的一份,描述里的地址也只在这个进程内部有意义。至于物理内存有多少、别人在用多少,描述里根本不关心。
十六、区域划分
描述一份地址空间,不需要把每一页都记下来。想象一张长桌子,人站在桌子前面说话,要把某个位置指出来,只要说清楚「从离我 100 厘米的地方开始」就够了,不需要给整张桌子画满刻度。地址空间也是这样,内核记的是每一个区域的起点和终点,中间的部分不记。
区域这个概念在代码里就是两个数,有一段用来打比方的结构体就是这么写的,一个区域用 size、起始刻度、结束刻度三个成员描述,改区域就是改这几个数。内核里对应的结构体是 struct vm_area_struct,把有效范围收窄到实际用得到的字段:
c
struct vm_area_struct {
unsigned long vm_start; /* 区域的起始虚拟地址 */
unsigned long vm_end; /* 区域的结束虚拟地址,第一个不属于本区域的字节 */
struct vm_area_struct *vm_next, *vm_prev; /* 链表中的前后区域 */
struct rb_node vm_rb; /* 红黑树节点,区域多的时候用 */
struct mm_struct *vm_mm; /* 所属的地址空间 */
/* ... */
};
(vm_types.h 里的行号:struct vm_area_struct 从 mm_types.h:283 起,vm_start 在 286 行,vm_end 在 287 行,vm_next 与 vm_prev 在 291 行,vm_rb 在 293 行,vm_mm 在 305 行。)

图 10 从桌子的刻度到区域划分:只用起止两个数描述一段
一个区域就是一对起止地址,一个进程的整个地址空间就是若干个这样的区域串起来的链表。十一节里 readelf 读出来的那些段、十二节里 /proc/PID/maps 打印出来的那些行,都是区域。maps 文件里每一行的前两列正是 vm_start 和 vm_end:
text
00400000-00401000 r-xp 00000000 fd:01 923986 /home/cocatrice/lab10/showmaps
这一行的意思是:00400000 到 00401000 之间是一个区域,长度正好一页,权限是可读可执行,来自 showmaps 这个文件。下一行 00600000-00601000 又是一个区域,和上一个不挨着,中间的空洞没有被描述,程序访问到那里会触发异常,因为没有任何一个区域覆盖它。
mm_struct 里也给几段常用的区域单独留了字段,省得每次都去链表里找:
text
441: unsigned long start_code, end_code, start_data, end_data;
442: unsigned long start_brk, brk, start_stack;
443: unsigned long arg_start, arg_end, env_start, env_end;
(来自 mm_types.h 的 441 到 443 行。)代码段的起止、数据段的起止、堆的起点和当前位置、栈的起点、命令行参数与环境变量的区间,都在这三行里。brk 是堆的当前位置,malloc 要找新地方的时候看的就是它。
区域本身是可以增删改的,malloc 一块大内存、把动态库映射进来、mmap 一个文件,都是在链表里插一个新区域,或者改一改现有区域的结束地址。改区域只改几个数,不用搬动任何数据,这也是地址空间这个概念能落地的原因。
十七、虚拟地址到物理地址
有了区域划分,还差最后一步:地址怎么落到物理内存上。CPU 发出来的地址是虚拟地址,交给内存管理单元(memory management unit,MMU)翻译,翻译的依据是页表。
虚拟地址被切成两段:高位是页号,低位是页内偏移。页表按页号查出一项,这一项里有物理页号和一些权限位,把物理页号和原来的页内偏移拼起来,就是物理地址。页的大小是 4KB,所以页内偏移占 12 位,这一部分不参与翻译,原样保留。

图 11 虚拟地址翻译:MMU 拆地址、查页表、过权限位
权限位是这一路上最重要的东西,十二节 maps 里那些 r-xp、r--p、rw-p,就是权限位在用户态的样子:代码段可读可执行不可写,只读数据只读,数据段可读可写不可执行。任何一个访问都要过这一关,包括读写,也包括取指令。
写只读区会怎么样,跑一次就知道:
c
#include <stdio.h>
int main(void)
{
const char *str = "helloworld";
printf("字符串常量所在的地址: %p\n", (void *)str);
*((char *)str) = 'H';
return 0;
}
bash
# 所属目录:/home/cocatrice/lab10
gcc -g -o rodata rodata.c && ./rodata
gdb -batch -ex run -ex bt ./rodata
text
字符串常量所在的地址: 0x400700
Segmentation fault
$ echo $?
139
Program received signal SIGSEGV, Segmentation fault.
0x0000000000400644 in main () at rodata.c:10
10 *((char *)str) = 'H';
0x400700 落在 .rodata 那一段里,那一段的权限是只读,写操作被拦下来,进程收到 SIGSEGV 信号,退出码是 139(128 加 11)。gdb 给出的崩溃位置精确到 rodata.c 的第 10 行,那正是赋值语句。
页表本身也是要占内存的,十二节里 VmPTE 那一项给的是 28 kB,含义是页表占用的物理内存。这个数不大,但它是真实的开销,而且地址空间映射得越碎,这个数越大,因为每一段映射都要有对应的页表项。
页表的开销会随着实际用到的页数增长,这一点可以直接称量出来。下面这个程序里有一个 256MB 的静态数组,落在 .bss 里,一开始只占着地址不占内存:
bash
# 所属目录:/home/cocatrice/lab10
gcc -std=gnu99 -o vmapte vmapte.c && ./vmapte
text
静态数组 big 的大小 = 268435456 字节 = 256 MB,按 4096 字节一页算共 65536 页
如果每一页都碰过,页表大约要 524288 字节 = 512 KB
--- 1 一行代码都没碰这个数组 ---
VmSize: 266496 kB
VmRSS: 352 kB
VmPTE: 32 kB
碰了前 8 MB,也就是 2048 页
--- 2 只碰了前 8MB ---
VmSize: 266496 kB
VmRSS: 8732 kB
VmPTE: 48 kB
把剩下 248 MB 也全碰了一遍,现在总共碰过 65536 页
--- 3 256MB 全部碰过 ---
VmSize: 266496 kB
VmRSS: 262444 kB
VmPTE: 544 kB
三个阶段里 VmSize 一直是 266496 kB,一动不动,说明映射在程序加载的时候就建好了;VmRSS 从 352 kB 涨到 262444 kB,是真正碰过的内存;VmPTE 从 32 kB 到 48 kB 再到 544 kB,多出来的正是为那些页配上的页表项。65536 页每页一项 8 字节,算下来是 512 kB,和实测的涨幅对得上。
延迟分配是页表机制带来的一个附带效果。malloc 100MB 之后一个字节都不写,地址空间里已经多出了这一段,物理内存却一个页都没给:
bash
# 所属目录:/home/cocatrice/lab10
gcc -o lazy lazy.c && ./lazy
text
=== 刚启动 ===
VmSize: 4352 kB VmRSS: 356 kB VmData: 188 kB
=== malloc 100MB 之后,一个字节都不写 ===
VmSize: 106756 kB VmRSS: 568 kB VmData: 102592 kB
maps 里多出来的那一行:
7f7db339d000-7f7db979e000 rw-p 00000000 00:00 0
=== 把这 100MB 全部写一遍 ===
VmSize: 106756 kB VmRSS: 102736 kB
第一个字节的值: 0
VmSize 从 4352 kB 涨到 106756 kB,涨了 100MB,VmLib、VmStk 都没动,只有 VmData 从 188 kB 变成 102592 kB;VmRSS 从 356 kB 只涨到 568 kB。写满一遍之后 VmSize 一点没变,VmRSS 涨到 102736 kB。也就是说 malloc 只是把这一段登记进了地址空间描述,写的时候才逐页分配物理内存。
maps 里多出来的那一行值得留意,它没有文件名,是匿名映射,地址在 0x7f7db 开头而不是 heap 区域,因为这一块超过了 malloc 的阈值,glibc 直接走了 mmap。这说明「堆」在地址空间的图上是一个区域,但 malloc 拿到的内存不一定都在那个区域里。

图 12 malloc 与写入三个阶段 VmSize 与 VmRSS 的变化
十八、写时拷贝
有了虚拟地址、页表和权限位这三件东西,fork 的行为就能讲清楚了。子进程刚创建出来的时候,内核并不复制父进程的物理内存,而是让子进程的页表指向和父进程同一批物理页,并且把这些页在两个进程里都标成只读。谁要写,谁就触发一次异常,内核这时才给这个进程复制一份新的物理页,改掉它自己的页表项,然后让写操作继续。
这个机制叫写时拷贝(copy on write,COW),分两步:第一步共享,第二步谁写谁复制。

图 13 写时拷贝的两步:先共享,谁写谁复制
接下来具体看看内存的涨落,下面是 cow2.c 的运行结果,父进程先 malloc 出 100MB,然后 fork,子进程把这 100MB 写一遍:
bash
# 所属目录:/home/cocatrice/lab10
gcc -o cow2 cow2.c && ./cow2
text
父进程 malloc 100MB 之后 VmRSS = 352 kB
子进程刚出生 VmRSS = 100 kB
子进程正在写这 100MB VmRSS(父)= 596 kB
子进程写完这 100MB VmRSS(子)= 102428 kB
子进程退出之后 VmRSS(父)= 608 kB
父进程 malloc 之后,那 100MB 还没有落到物理内存上,VmRSS 只有 352 kB。子进程刚出生的时候 VmRSS 是 100 kB,说明 fork 没有复制那 100MB。子进程开始写之后,自己的 VmRSS 涨到 102428 kB,而同一时刻父进程的 VmRSS 只有 596 kB,那 100MB 只在子进程这边被复制出来。子进程退出之后,父进程的 VmRSS 是 608 kB,那 100MB 始终没有被父进程写,也就始终没有复制。
内存里的涨落是一层证据,物理页框号是另一层。把全局变量对齐到一页的开头,用 /proc/PID/pagemap 把这一页的物理页框号读出来,父进程、子进程、子进程写过之后各读一次:
bash
# 所属目录:/home/cocatrice/lab10
gcc -std=gnu99 -o cowpfn cowpfn.c
sudo ./cowpfn | grep -E 'pagemap|g_val ='
text
父进程读自己 pid=20148 虚拟地址 0x604000 pagemap 原始值 0x81800000000587e2 present=1 PFN=362466
父进程读自己 pid=20148 虚拟地址 0x604000 pagemap 原始值 0x80800000000587e2 present=1 PFN=362466
父进程读子进程 pid=20149 虚拟地址 0x604000 pagemap 原始值 0x80800000000587e2 present=1 PFN=362466
子进程写完后读自己 pid=20149 虚拟地址 0x604000 pagemap 原始值 0x818000000006bb94 present=1 PFN=441236
父进程读自己 pid=20148 虚拟地址 0x604000 pagemap 原始值 0x81800000000587e2 present=1 PFN=362466
父进程读子进程 pid=20149 虚拟地址 0x604000 pagemap 原始值 0x818000000006bb94 present=1 PFN=441236
父进程看到的 g_val = 0x12345678,地址还是 0x604000
四行数字把写时拷贝的两步走完了,fork 之前父进程这一页的页框号是 362466;fork 之后子进程报出来的还是 362466,父子共用同一个物理页;子进程把 g_val 改成 0xabcdef 之后再读,页框号变成 441236,新的物理页在这一刻才被复制出来;同一时刻父进程那一页仍然是 362466,值仍然是 0x12345678,地址仍然是 0x604000。
顺带一个细节:读 pagemap 是要权限的。普通用户读到的原始值长这样:
text
父进程读自己 pid=20159 虚拟地址 0x604000 pagemap 原始值 0x8180000000000000 present=1 PFN=0
present 位还是 1,页框号却被抹成了 0。内核从 4.0 起把页框号限定给有 CAP_SYS_ADMIN 能力的进程读,普通用户只能知道这一页在内存里,知道不了它在哪一页。
写时拷贝的两个好处都能对上面这组数字。第一个是创建快:fork 的时候不用搬 100MB,子进程立刻就能开始跑,需要哪一页复制哪一页。第二个是省内存:父子进程如果只是读同一批数据,物理内存里始终只有一份;真正会写的部分才会分成两份。
forkval 那个程序里父子进程的 &g_val 都是 0x601078,值各是多少互不影响,原因也在这里:页表分开之后,同一个虚拟地址在两个进程里指向的物理页不同;没写之前指向同一个物理页,由于是只读的,谁也改不动,也就不会互相干扰。
十九、地址空间的结构
最后把整条链子串起来,进程在内核里的主体是 task_struct,里面有一个字段指向它的地址空间:
c
struct task_struct {
/* ... */
struct mm_struct *mm, *active_mm; /* 地址空间 */
/* ... */
};
(这一段引用的字段来自 sched.h:1436 的定义。)mm 指向这个进程的地址空间描述,也就是 mm_struct,它里面存着页表的顶层地址和第一个 vm_area_struct,而每一个 vm_area_struct 描述一段区域,再用 vm_next 串成链表,或者用 vm_rb 组织成红黑树。串起来的样子是三层:进程、地址空间、区域。

图 14 task_struct 到 mm_struct 再到 vm_area_struct 的全景
对照着 /proc/PID/maps 看会更直观,maps 里的一行就是一个 vm_area_struct,行数就是 map_count。showmaps 那个程序的 status 里,map_count 对应的信息也能从 maps 的行数里数出来,17 行就是 17 个区域,其中 4 段来自 libc,4 段来自 ld,加上自己的三段、栈、两个特殊页和几段匿名映射。
区域为什么要有两种组织方式,也和区域的数量有关。区域少的时候链表就能应付,从头顺着找就行;进程跑久了,映射一个动态库、mmap 一块内存都会新增区域,区域一多,链表查找就慢,这时候用红黑树按地址排序,找某个地址落在哪个区域是对数时间。mm_struct 里的 mmap_cache 是另一层加速,它记住上一次命中的区域,连续访问同一个区域的相邻地址时可以直接命中。
mm_struct 里还有一批字段专门记录各个区域的边界,start_code、end_code、start_data、end_data、start_brk、brk、start_stack 都在其中,命令行参数与环境变量的起止地址也各有字段。这些字段在装载程序时被填好,/proc/PID/maps 里的行就是它们的展开形式,一行给出一个区域的起止、权限和它对应的文件。
二十、为什么要有虚拟地址空间
把前面几节的机制收在一起,答案落在三个问题上。
第一个问题是隔离,如果程序直接操作物理地址,任何一个进程的野指针都可能改掉别的进程甚至内核的数据,一个程序写崩了整台机器都会受影响。加了页表和权限位之后,越界访问会被拦在翻译这一步,进程只能碰自己的区域,写别人的内存这件事在硬件层面就不成立。
第二个问题是地址不确定,程序编译的时候不知道运行时会被放在物理内存的哪个位置,如果有别的程序占着地方,地址就得整体挪一遍。有了虚拟地址,编译产物按固定地址生成,加载到哪里由内核安排映射,程序里的地址始终有效。
第三个问题是进程管理与内存管理的解耦。进程只需要一张描述自己地址空间的表,物理内存的分配、回收、换出由内存管理单独处理。物理内存不够的时候,内核可以把某些页写到磁盘再腾出位置,进程看到的地址不变,读回来的时候再换入;进程被换出、被挂起、被恢复,改的都是页表和描述,进程自己不知情。
还有几个常被追问的点需要澄清,内核里没有直接对应「程序」和「代码数据」的结构体,有的只是 task_struct、mm_struct 和页表,代码和数据是文件里的内容被映射进来之后的结果。创建进程的时候,先有 task_struct 和 mm_struct 这些描述,程序文件的映射是在 exec 的时候才建立的。进程挂起和恢复,做法是把某个进程的描述和它的页表从「正在被使用」的状态里摘出去,再按需要放回来,地址空间这个概念本身并不会消失。
这一篇把两张表和一张图讲完了:进程手里的 argv 表和环境变量表,以及这份描述自己地址空间的 mm_struct。下一节会进到进程控制,从进程的创建与终止开始。
实例代码
工程结构
这一篇用到的程序都放在服务器上的同一个目录里,每个程序只做一件事,名字和用途一一对应:
text
/home/cocatrice/lab10/
├── args.c 展开 argc 与 argv,顺带打印 argv 数组、env 数组的位置
├── layout.c 量出 env 与 argv 之间隔了几个指针
├── envthree.c 三种方式取环境表,验证 env 与 environ 是不是同一张表
├── envapi.c getenv、putenv、setenv、unsetenv 四个接口挨个跑
├── myenv.c 只读环境变量 i,用来区分本地变量与环境变量
├── opt.c 按 argv[1] 分发 -a、-b、-c 三个功能
├── whoami_env.c 按环境变量 USER 判断身份
├── addr.c 打印六类地址,对照段布局
├── showmaps.c 打印自己的 pid 之后挂住,供外部读 /proc/PID/maps
├── lazy.c malloc 100MB 前后与写满前后,读 VmSize 与 VmRSS
├── cow2.c 父子进程的 VmRSS 对照,看写时拷贝
├── cowpfn.c 读 /proc/PID/pagemap 的页框号,看写时拷贝的物理证据
├── vmapte.c 256MB 静态数组碰与不碰,看 VmPTE 的增长
├── forkval.c fork 之后父子同址不同值
├── rodata.c 写字符串常量,触发段错误
└── e2big.c 用 20 万个参数把 execve 撑爆
完整代码
前面各节为了讲清楚一个点,只贴了相关的片段,这里把完整源码集中放一遍。
下面每个程序都能单独编译运行,文件名与前面各节一致,编译命令在对应的小节里已经给过。args.c 从命令行参数讲到地址,是前两节的主程序:
c
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0;
printf("argc = %d\n", argc);
for (i = 0; i < argc; i++) {
printf("argv[%d] = [%s]\n", i, argv[i]);
}
printf("argv[argc] 是 %s\n", argv[argc] == NULL ? "NULL" : "非 NULL");
printf("argv 这个指针数组自己放在 %p\n", (void *)argv);
printf("第一个参数字符串放在 %p\n", (void *)argv[0]);
printf("env 这个指针数组放在 %p\n", (void *)env);
printf("第一个环境字符串放在 %p\n", (void *)env[0]);
return 0;
}
layout.c 只回答一个问题:两张表隔了多远。
c
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
printf("argc = %d\n", argc);
printf("argv = %p\n", (void *)argv);
printf("argv[argc] = %p\n", (void *)&argv[argc]);
printf("env = %p\n", (void *)env);
printf("env - argv = %ld 个指针\n", (long)(env - argv));
printf("argc + 1 = %d\n", argc + 1);
return 0;
}
envthree.c 把取环境表的三条路放在一个程序里对照:
c
#include <stdio.h>
#include <stdlib.h>
extern char **environ;
int main(int argc, char *argv[], char *env[])
{
static const char *keys[] = { "PATH", "HOME", "SHELL", "MYNOTEXIST" };
int i = 0;
(void)argc;
(void)argv;
printf("--- 方式一:main 的第三个参数 ---\n");
for (i = 0; i < 3; i++) {
if (env[i] == NULL) {
printf("env[%d] 已经是表尾的 NULL,一条都没有\n", i);
break;
}
printf("env[%d] = %s\n", i, env[i]);
}
printf("env 这张表自己放在 %p\n", (void *)env);
printf("--- 方式二:extern char **environ ---\n");
for (i = 0; i < 3; i++) {
if (environ[i] == NULL) {
printf("environ[%d] 已经是表尾的 NULL,一条都没有\n", i);
break;
}
printf("environ[%d] = %s\n", i, environ[i]);
}
printf("environ 这个变量自己放在 %p,它的值也就是表的首地址是 %p\n",
(void *)&environ, (void *)environ);
printf("--- 两种方式拿到的是不是同一张表 ---\n");
printf("env == environ ? %s\n", env == environ ? "是" : "不是");
printf("--- 方式三:getenv ---\n");
for (i = 0; i < (int)(sizeof(keys) / sizeof(keys[0])); i++) {
char *val = getenv(keys[i]);
if (val == NULL)
printf("%s 没有,getenv 返回 %p\n", keys[i], (void *)val);
else
printf("%s = %s\n", keys[i], val);
}
for (i = 0; env[i]; i++) {
}
printf("--- 环境表里一共有 %d 条 ---\n", i);
return 0;
}
envapi.c 把四个接口串成一条链,每一步当场读回来验证:
c
#include <stdio.h>
#include <stdlib.h>
static char myval[] = "MYVAL=用 putenv 设的值";
int main(void)
{
char *p = NULL;
printf("读一个存在的:PATH = %s\n", getenv("PATH"));
p = getenv("MYNOTEXIST");
printf("读一个不存在的:getenv 返回 %p\n", (void *)p);
putenv(myval);
printf("putenv 之后:MYVAL = %s\n", getenv("MYVAL"));
setenv("MYVAL2", "第一次设的值", 1);
printf("setenv 之后:MYVAL2 = %s\n", getenv("MYVAL2"));
setenv("MYVAL2", "第二次设的值", 1);
printf("第三个参数给 1,覆盖:MYVAL2 = %s\n", getenv("MYVAL2"));
setenv("MYVAL2", "第三次设的值", 0);
printf("第三个参数给 0,不覆盖:MYVAL2 = %s\n", getenv("MYVAL2"));
printf("--- 改动能不能传给子进程 ---\n");
fflush(stdout);
system("env | grep -E '^MYVAL' | sort");
unsetenv("MYVAL");
printf("unsetenv 之后:MYVAL 的返回是 %p\n", (void *)getenv("MYVAL"));
printf("--- 再起一个子进程看看 ---\n");
fflush(stdout);
system("env | grep -E '^MYVAL' | sort");
return 0;
}
myenv.c 只有二十行,作用是当一个探针,看某个名字在子进程的环境表里到底有没有:
c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char *val = getenv("i");
if (val == NULL) {
printf("我是另外起的进程,我的环境表里没有 i\n");
return 0;
}
printf("我是另外起的进程,我的环境表里读到了 i = %s\n", val);
return 0;
}
opt.c 演示选项分发,两个分支之外都退回用法说明:
c
/* 与第三节的 opt.c 完全相同,这里不再重复。 */
whoami_env.c 用环境变量判身份,三个分支都要走到:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
const char *user = getenv("USER");
if (user == NULL) {
printf("USER 不存在,拒绝执行\n");
return 1;
}
printf("USER = %s\n", user);
if (strcmp(user, "cocatrice") == 0) {
printf("身份校验通过,开始干活\n");
} else {
printf("身份校验不通过,退出\n");
return 1;
}
return 0;
}
addr.c 与 forkval.c 的完整源码在十一节与十三节已经给过,这里不再重复。showmaps.c 只有十四行,它的用途是让别的命令去读它的 maps:
c
#include <stdio.h>
#include <unistd.h>
int g_val = 100;
int g_unval;
int main(void)
{
printf("pid = %d\n", getpid());
fflush(stdout);
sleep(20);
return 0;
}
lazy.c 与 cow2.c 里读内存数字的那几行值得单独看一眼,两个程序都是靠 /proc/self/status 拿到的:
c
static void show(const char *tag)
{
FILE *fp = fopen("/proc/self/status", "r");
char line[256];
printf("--- %s ---\n", tag);
while (fgets(line, sizeof(line), fp)) {
if (strncmp(line, "VmSize", 6) == 0 ||
strncmp(line, "VmRSS", 5) == 0 ||
strncmp(line, "VmData", 6) == 0) {
printf("%s", line);
}
}
fclose(fp);
}
e2big.c 用一个 20 万项的指针数组去撞 execve 的限制:
c
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int n = 200000;
char **argv = malloc(sizeof(char *) * (n + 1));
char *big = malloc(16);
int i = 0;
if (argv == NULL || big == NULL) {
printf("malloc 失败\n");
return 1;
}
strcpy(big, "aaaaaaaaaaaaaaa");
for (i = 0; i < n; i++) {
argv[i] = big;
}
argv[n] = NULL;
execve("/bin/echo", argv, NULL);
printf("20 万个参数:execve 失败,%s (errno = %d)\n", strerror(errno), errno);
return 1;
}
编译这一批程序的命令只有一行,每个 .c 编出一个同名的可执行文件:
bash
# 所属目录:/home/cocatrice/lab10
for f in args layout envthree envapi myenv opt whoami_env addr showmaps lazy cow2 cowpfn vmapte forkval rodata e2big; do gcc -std=gnu99 -o $f $f.c; done
ls -1 args envthree lazy cow2
正常情况
下面六组回显对应前文的六件事:参数怎么进来并且怎么被切分、两张表在栈上挨得有多近、三条路取环境变量各得到什么、四个接口怎么改环境表、身份校验能不能被绕过、外部命令怎么被找到。下面每一条都是先把命令写出来,再把它的原样输出贴一遍。
先看命令行参数这一组,同一个程序,四次调用的结果摆在下面:
bash
# 所属目录:/home/cocatrice/lab10
./args a b c d
text
argc = 5
argv[0] = [./args]
argv[1] = [a]
argv[2] = [b]
argv[3] = [c]
argv[4] = [d]
argv[argc] 是 NULL
argv 这个指针数组自己放在 0x7ffcfdd11a78
第一个参数字符串放在 0x7ffcfdd13828
env 这个指针数组放在 0x7ffcfdd11aa8
第一个环境字符串放在 0x7ffcfdd13837
两张表的距离用 layout 量:
bash
# 所属目录:/home/cocatrice/lab10
./layout a b c d
text
argc = 5
argv = 0x7ffed7db0eb8
argv[argc] = 0x7ffed7db0ee0
env = 0x7ffed7db0ee8
env - argv = 6 个指针
argc + 1 = 6
环境表的三条路:
bash
# 所属目录:/home/cocatrice/lab10
./envthree
text
--- 方式一:main 的第三个参数 ---
env[0] = XDG_SESSION_ID=7676
env[1] = SHELL=/bin/bash
env[2] = TERM=vt100
env 这张表自己放在 0x7fff6df301a8
--- 方式二:extern char **environ ---
environ[0] = XDG_SESSION_ID=7676
environ[1] = SHELL=/bin/bash
environ[2] = TERM=vt100
environ 这个变量自己放在 0x601080,它的值也就是表的首地址是 0x7fff6df301a8
--- 两种方式拿到的是不是同一张表 ---
env == environ ? 是
--- 方式三:getenv ---
PATH = /usr/local/bin:/usr/bin
HOME = /home/cocatrice
SHELL = /bin/bash
MYNOTEXIST 没有,getenv 返回 (nil)
--- 环境表里一共有 23 条 ---
四个接口连起来跑:
bash
# 所属目录:/home/cocatrice/lab10
./envapi
text
读一个存在的:PATH = /usr/local/bin:/usr/bin
读一个不存在的:getenv 返回 (nil)
putenv 之后:MYVAL = 用 putenv 设的值
setenv 之后:MYVAL2 = 第一次设的值
第三个参数给 1,覆盖:MYVAL2 = 第二次设的值
第三个参数给 0,不覆盖:MYVAL2 = 第二次设的值
--- 改动能不能传给子进程 ---
MYVAL2=第二次设的值
MYVAL=用 putenv 设的值
unsetenv 之后:MYVAL 的返回是 (nil)
--- 再起一个子进程看看 ---
MYVAL2=第二次设的值
本地变量与环境变量的差别,用一个探针程序跑两次就能看出来:
bash
# 所属目录:/home/cocatrice/lab10
i=10
./myenv
export i
./myenv
text
我是另外起的进程,我的环境表里没有 i
我是另外起的进程,我的环境表里读到了 i = 10
选项分发与身份校验:
bash
# 所属目录:/home/cocatrice/lab10
./opt -a; ./opt -b; ./opt -c
printf 'USER=%s\n' cocatrice; ./whoami_env
text
功能 A:打印一行说明
功能 B:再打印一行
功能 C:还是打印一行
USER = cocatrice
身份校验通过,开始干活
常见报错
第一组是编译期就过不去,想直接用 environ 这张表,必须先声明它,不然编译器不认识这个名字:
bash
# 所属目录:/home/cocatrice/lab10
# 改前
gcc -std=gnu99 -o task task.c
text
task_noenv.c: In function 'main':
task_noenv.c:57:76: error: 'environ' undeclared (first use in this function)
printf("argv 数组在 %p,env 数组在 %p\n", (void *)argv, (void *)environ);
^
task_noenv.c:57:76: note: each undeclared identifier is reported only once for each function it appears in
退出码 1
bash
# 所属目录:/home/cocatrice/lab10
# 改后
gcc -std=gnu99 -o task task.c && echo "编译通过"
text
编译通过
改的地方是在文件开头加一行 extern char **environ;,把这张表的声明带进来。
第二组是 PATH 被改坏,把 PATH 换成一个不存在的目录,原来能跑的 ls 立刻找不到,因为 bash 找命令靠的就是这张表:
bash
# 所属目录:/home/cocatrice/lab10
# 改前
export PATH=/nonexistent
hash -r
ls
text
bash: ls: command not found
bash
# 所属目录:/home/cocatrice/lab10
# 改后
export PATH=/usr/local/bin:/usr/bin
ls args.c
text
args.c
第三组是变量设了却没传下去,直接赋值只是在当前 shell 里记了一笔,子进程看不到;加上 export 之后,同一个程序才读得到:
bash
# 所属目录:/home/cocatrice/lab10
# 改前
i=10
./myenv
text
我是另外起的进程,我的环境表里没有 i
bash
# 所属目录:/home/cocatrice/lab10
# 改后
export i
./myenv
text
我是另外起的进程,我的环境表里读到了 i = 10
第四组是往只读区里写,字符串常量落在只读段,赋值那一句会直接把进程打掉:
bash
# 所属目录:/home/cocatrice/lab10
# 改前
gcc -g -o rodata rodata.c && ./rodata; echo "退出码 $?"
text
字符串常量放在 0x400700,内容是 helloworld
把它当成可写的数组,改第一个字符
Segmentation fault
退出码 139
bash
# 所属目录:/home/cocatrice/lab10
# 改后
gcc -o rodata_ok rodata_ok.c && ./rodata_ok
text
改之前是 helloworld
改的是数组里的副本,改完是 Helloworld
改后的写法把字符串放进数组,数组在栈上或者数据段里,可读可写,改的是自己那一份,和只读段里的原始字符串没有关系。
第五组是参数太多,参数表要经过内核复制,整个长度超限时 fork 加 exec 这一对动作里的第二步会失败:
bash
# 所属目录:/home/cocatrice/lab10
# 改前
./e2big; echo "退出码 $?"
text
20 万个参数:execve 失败,Argument list too long (errno = 7)
退出码 1
bash
# 所属目录:/home/cocatrice/lab10
# 改后
./args a b c; echo "退出码 $?"
text
argc = 4
argv[0] = [./args]
argv[1] = [a]
argv[2] = [b]
argv[3] = [c]
argv[argc] 是 NULL
argv 这个指针数组自己放在 0x7ffd5f0e1c38
第一个参数字符串放在 0x7ffd5f0e2829
env 这个指针数组放在 0x7ffd5f0e1c58
第一个环境字符串放在 0x7ffd5f0e2830
退出码 0
边界情况
不带任何参数时 argc 等于 1,argv0 仍然是程序自己的路径,这一条永远成立,程序里要按 argc 判断有没有拿到东西:
bash
# 所属目录:/home/cocatrice/lab10
./args
text
argc = 1
argv[0] = [./args]
argv[argc] 是 NULL
argv 这个指针数组自己放在 0x7ffd994aea88
第一个参数字符串放在 0x7ffd994af830
env 这个指针数组放在 0x7ffd994aea98
第一个环境字符串放在 0x7ffd994af837
加引号的一串字符算一个参数,中间的空格原样保留:
bash
# 所属目录:/home/cocatrice/lab10
./args "a b"
text
argc = 2
argv[0] = [./args]
argv[1] = [a b]
argv[argc] 是 NULL
通配符不由程序处理,交给 shell 展开之后再传进去,目录里有多少个匹配的文件,argc 就变成多少:
bash
# 所属目录:/home/cocatrice/lab10
./args *.c | sed -n '1p;26p;27p'
text
argc = 30
argv[24] = [printi.c]
argv[25] = [rodata.c]
把环境清空之后取环境变量,三条路全都取不到东西,返回值必须判空:
bash
# 所属目录:/home/cocatrice/lab10
env -i ./envthree
text
--- 方式一:main 的第三个参数 ---
env[0] 已经是表尾的 NULL,一条都没有
env 这张表自己放在 0x7ffe81abe238
--- 方式二:extern char **environ ---
environ[0] 已经是表尾的 NULL,一条都没有
environ 这个变量自己放在 0x601080,它的值也就是表的首地址是 0x7ffe81abe238
--- 两种方式拿到的是不是同一张表 ---
env == environ ? 是
--- 方式三:getenv ---
PATH 没有,getenv 返回 (nil)
HOME 没有,getenv 返回 (nil)
SHELL 没有,getenv 返回 (nil)
MYNOTEXIST 没有,getenv 返回 (nil)
--- 环境表里一共有 0 条 ---
单个参数的长度卡在 131072 这个数字上,从 1 数到 131071 都能传进去,再多一个字节就在 exec 这一步被挡下:
bash
# 所属目录:/home/cocatrice/lab10
head -c 131071 /dev/zero | tr '\0' 'x' > /tmp/big1.txt
bash -c './args "$(cat /tmp/big1.txt)" | head -2'; echo "退出码 $?"
head -c 131072 /dev/zero | tr '\0' 'x' > /tmp/big2.txt
bash -c './args "$(cat /tmp/big2.txt)"'; echo "退出码 $?"
text
argc = 2
argv[0] = [./args]
退出码 0
bash: ./args: Argument list too long
退出码 126
环境表也有容量上限,这个上限跟着栈的大小走,三档 ulimit 各跑一遍就看出来了:
| 栈上限(ulimit -s) | 装上的变量个数(每个 8190 字节) | 合计 |
|---|---|---|
| 8192 kB | 255 个 | 约 2.0 MB |
| 16384 kB | 510 个 | 约 4.0 MB |
| 32768 kB | 766 个 | 约 6.0 MB |
装不下的那一次,执行外部命令时的报错是 Argument list too long。
8192 的三分之一分给参数和环境,算下来是 2 MB 左右;栈放宽到 16M,容量跟着涨到 4 MB;再放宽到 32M 时被另一个上限截住,停在 6 MB 附近。getconf 报出来的 ARG_MAX 是 2097152,那是一个写死的常量,实际能装多少要看栈。
实测数据
把这一篇里量出来的数字集中列一遍,单位都是实测输出里的原值:
| 项目 | 数值 | 对应的实验 |
|---|---|---|
| 环境变量条数 | 23 条 | envthree、env、/proc/PID/environ |
| env 与 argv 的距离 | argc + 1 个指针 | layout 四种传参 |
| argv 数组位置 | 0x7ffcfdd11a78 | args a b c d |
| 第一个参数串位置 | 0x7ffcfdd13828 | args a b c d |
| 通配符展开后的 argc | 30 | ./args *.c |
| 环境表首地址 | 0x7fff6df301a8 | envthree |
| environ 变量自身位置 | 0x601080 | envthree |
| 六类地址 | 0x4005bd 到 0x7ffcc814aef8 | addr |
| maps 行数 | 17 行 | showmaps |
| 段大小 | text 2179、data 572、bss 12 | size addr |
| malloc 100MB 后 VmSize | 4352 到 106756 kB | lazy |
| malloc 100MB 后 VmRSS | 356 到 568 kB | lazy |
| 写满 100MB 后 VmRSS | 102736 kB | lazy |
| 父子同址不同值的地址 | 0x601078 | forkval |
| 写时拷贝的页框号 | 362466 变 441236 | cowpfn |
| 256MB 数组的 VmPTE | 32 到 544 kB | vmapte |
| 只读区写入的退出码 | 139 | rodata |
| 20 万参数的 errno | 7(E2BIG) | e2big |
| 单个参数上限 | 131071 字节 | ./args |
| 环境表容量上限 | 8192 栈下约 2.0 MB | 三档 ulimit |
实例
最后把这一篇的东西串成一个完整的场景:写一个程序,它按环境变量决定要不要干活,按命令行参数决定干什么,跑起来之后再把自己的地址空间从头到尾看一遍。
程序本身只有三段:判身份、分选项、打地址。
判身份这一段是有意写成反例的:它靠环境变量确认调用者是谁,而第十节已经证明这个方法挡不住有心改一下的人。把它放进实例,是为了把前面散落的几条结论装进同一段能跑起来的代码里,顺便看看这几条结论在真实程序里长什么样。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int g_cfg = 0;
static void show_self(void)
{
FILE *fp = fopen("/proc/self/maps", "r");
char line[512];
int n = 0;
while (fp != NULL && fgets(line, sizeof(line), fp) != NULL) {
n++;
if (n <= 3 || strstr(line, "[stack]") != NULL) {
printf("%s", line);
}
}
if (fp != NULL) {
fclose(fp);
}
printf("maps 一共 %d 行\n", n);
}
int main(int argc, char *argv[])
{
const char *user = getenv("TASK_USER");
const char *home = getenv("HOME");
if (user == NULL) {
printf("TASK_USER 没有设置,拒绝执行\n");
return 1;
}
if (strcmp(user, "cocatrice") != 0) {
printf("TASK_USER = %s,不是本人,退出\n", user);
return 1;
}
printf("TASK_USER = %s,HOME = %s\n", user, home == NULL ? "(没有)" : home);
if (argc < 2) {
printf("用法: %s [-c|-s]\n", argv[0]);
return 1;
}
if (strcmp(argv[1], "-c") == 0) {
g_cfg = 1;
printf("功能:配置,g_cfg = %d\n", g_cfg);
} else if (strcmp(argv[1], "-s") == 0) {
printf("功能:查看自己\n");
show_self();
} else {
printf("不认识的选项 %s\n", argv[1]);
return 1;
}
printf("argv 数组在 %p,env 数组在 %p\n", (void *)argv, (void *)environ);
printf("g_cfg 在 %p,pid = %d\n", (void *)&g_cfg, getpid());
return 0;
}
编译之后先不给环境变量,直接被挡:
bash
# 所属目录:/home/cocatrice/lab10
gcc -std=gnu99 -o task task.c
./task -c; echo "退出码 $?"
text
TASK_USER 没有设置,拒绝执行
退出码 1
把身份挂上,选项对了才干活:
bash
# 所属目录:/home/cocatrice/lab10
TASK_USER=cocatrice ./task -c
TASK_USER=other ./task -c; echo "退出码 $?"
text
TASK_USER = cocatrice,HOME = /home/cocatrice
功能:配置,g_cfg = 1
argv 数组在 0x7fff59d3ee18,env 数组在 0x7fff59d3ee30
g_cfg 在 0x601084,pid = 22394
TASK_USER = other,不是本人,退出
退出码 1
换成查看自己的选项,程序把自己的 maps 前几行打出来:
bash
# 所属目录:/home/cocatrice/lab10
TASK_USER=cocatrice ./task -s
text
TASK_USER = cocatrice,HOME = /home/cocatrice
功能:查看自己
00400000-00401000 r-xp 00000000 fd:01 924085 /home/cocatrice/lab10/task
00600000-00601000 r--p 00000000 fd:01 924085 /home/cocatrice/lab10/task
00601000-00602000 rw-p 00001000 fd:01 924085 /home/cocatrice/lab10/task
7ffe84892000-7ffe848b3000 rw-p 00000000 00:00 0 [stack]
maps 一共 18 行
argv 数组在 0x7ffe848b18b8,env 数组在 0x7ffe848b18d0
g_cfg 在 0x601084,pid = 22396
选项写错和压根不给选项,都退回用法说明,退出码都是 1:
bash
# 所属目录:/home/cocatrice/lab10
TASK_USER=cocatrice ./task -x; echo "退出码 $?"
TASK_USER=cocatrice ./task; echo "退出码 $?"
text
TASK_USER = cocatrice,HOME = /home/cocatrice
不认识的选项 -x
退出码 1
TASK_USER = cocatrice,HOME = /home/cocatrice
用法: ./task [-c|-s]
退出码 1
这几段输出把两种输入接上了:环境变量决定这个进程能不能跑,命令行参数决定它跑哪条分支,最后打印出来的地址在 maps 里都能找到对应的行。g_cfg 在 0x601084 落在第三行的数据段里,argv 和 env 两个数组在 0x7ffe 开头的栈区,和十二节的那张图完全对得上。
踩坑点
- argv 数组以 NULL 结尾,argvargc 就是那个 NULL;遍历参数时用 i < argc,写成 i <= argc 会多打一行空白。
- getenv 找不到变量时返回 NULL,拿到指针之后必须判空;用 env -i 起的进程里整张环境表是空的,每一次取值都会落到这个分支上。
- 命令行参数与环境变量的地址都落在栈顶附近,同一个程序每次运行的具体数值都不一样,对照时看的是相对位置而不是印出来的那串数。
- i=10 得到的是本地变量,只存在于当前外壳进程内部,子进程看不到;export i 之后它才进入环境表,随着 fork 一起传下去。
- putenv 只把指针塞进表里,字符串本身要一直有效。把栈上的临时缓冲区传进去,函数返回之后表里留下的是悬空指针,后面任何一次 getenv 都可能读到乱码或者直接崩溃。
- setenv 的第三个参数决定要不要覆盖,给 0 时已有的值保持不动,getenv 读回来还是原来那一份。
- 环境变量谁都能改,一条 USER=root 前缀就能让程序相信自己是 root;判断身份要用 geteuid,或者读 /etc/passwd 这类不受调用者控制的数据。
- 改了 PATH 之后,外壳会清空自己缓存的命令路径表,旧路径不会被继续复用;缓存的路径被搬走时,报错信息里给的是完整路径 No such file or directory,执行 hash -r 之后才会重新在 PATH 里搜索。
- 字符串常量落在只读段,通过指针写它会直接段错误,退出码 139 就是 128 加上信号 11;要修改就先复制到字符数组里。
- malloc 申请大块内存走的是匿名映射而不是 brk,这一块在 /proc/PID/maps 里不会显示成 heap。
- 只申请不写的内存只占虚拟地址,VmSize 涨上去而 VmRSS 不动;写到哪一页,哪一页的物理内存才真正分配。
- fork 之后子进程如果直接 _exit,标准输入输出库的缓冲区不会刷新;输出重定向到文件时子进程已经打印的内容会丢掉,退出前调用 fflush(NULL) 才能保住。
- /proc/PID/pagemap 里的页框号需要 CAP_SYS_ADMIN 才能读,普通用户读到的 present 位是 1 而页框号是 0,不能把 0 当成物理页 0。
- 借助 smaps 里的 Shared_Clean 与 Private_Dirty 观察写时拷贝并不可靠,本次实测中这几个字段在父子进程里全程没有变化;要确认某一页有没有被复制,读页框号更直接。
本篇总结(模拟面试问题)
问:命令行参数是怎么传到 main 函数里的?
核心要点:内核在执行新程序时,把参数字符串、参数字符串的指针数组、环境字符串和它的指针数组一起复制到新进程的栈顶,再按调用约定把 argc、argv、envp 交给入口代码,最终落到 main 的三个参数上。argv 数组的第 argc 个元素是 NULL,用来标记结束,这也是遍历参数时的边界。
问:一个程序怎么知道该执行哪一步?
核心要点:按参数判断。argc 只说明有几个参数,argv1 及之后的字符串才是内容,逐项比较字符串再分发到不同分支。系统里的 ls、gcc 这类命令都靠这套机制实现选项,选项不合法时打印用法并返回非零退出码是通行做法。
问:环境变量在进程里存在什么地方?
核心要点:存在进程自己的地址空间里,位于栈顶附近。指针数组与字符串本体都在那里,数组在低地址、字符串在高地址,数组以 NULL 结尾。外壳程序内部还维护两张表,一张放本地变量,一张放环境变量,创建子进程时只把环境表复制下去。
问:环境变量一开始是从哪里来的?
核心要点:登录时由外壳程序读取 /etc/profile 与 ~/.bash_profile 这类文件,逐条设置出来,再由外壳进程复制给子进程,一直传下去。每个登录用户各有一个外壳进程,也就各自拥有一份表。
问:取环境变量的三种方式有什么区别?
核心要点:getenv 按名字查找,找不到返回 NULL;environ 是完整的指针数组,可以自己遍历,也是 getenv 内部真正去查的那张表;main 的第三个参数 envp 是同一张表的另一种入口。三者看到的内容一致,实测中 env 与 environ 指向同一张表,条数也相同。
三条路各有适用场合:临时读一个变量的值用 getenv,一行就够;要把整张表打印出来或者逐条判断前缀,用 environ 更直观;envp 只有 main 这一层能直接拿到,往下传要靠自己加参数。无论走哪一条,读到的都是调用方递进来的值,程序没有校验能力。
问:本地变量和环境变量的区别在哪里?
核心要点:本地变量只存在于外壳进程内部,子进程看不到;环境变量会随 execve 传给子进程,还能继续往下传。export 把本地变量提升为环境变量,它是内建命令,由外壳自己执行,不创建新进程。
问:fork 之后父子进程打印同一个地址却得到不同的值,怎么解释?
核心要点:打印出来的是虚拟地址,每个进程有自己的页表,同一个虚拟地址在不同进程里映射到不同的物理页。fork 时两边的页表指向同一份物理页并全部标成只读,任何一方写入都会触发缺页,内核复制一页再修改页表,这就是写时拷贝。
问:程序地址空间里的区域是怎么划分的?
核心要点:从低地址到高地址依次是代码段、只读数据、已初始化数据、未初始化数据、堆、共享区、栈,最上面是内核空间。代码段与只读数据段不可写,数据段可读写,堆向上增长,栈向下增长。这些区域的名字来自 ELF 文件里的段,映射进地址空间之后按权限分开。
问:为什么要有虚拟地址空间?
核心要点:三点。地址从无序变得有序,每个进程看到同样整齐的布局;地址转换的过程中可以检查权限与合法性,越界访问和写只读区都会被拦下;物理内存的使用与程序里的地址解耦,进程不必关心物理页在哪里,也不必关心别的进程在用哪些页。
问:malloc 申请的内存什么时候真正占到物理内存?
核心要点:申请时只登记地址范围,物理页在第一次写入时才分配。实测里 malloc 100 MB 之后 VmSize 涨了约 100 MB,VmRSS 几乎不动,写满之后 VmRSS 才跟着涨上来。超过阈值的大块内存走 mmap 匿名映射,不走 brk。
问:页表本身要占多少内存?
核心要点:每一个已映射的页对应一条页表项,页表的大小与映射的页数成正比。256 MB 的数组按 4 KB 一页算有 65536 页,页表项 8 字节,合计 512 KB,实测 VmPTE 从 32 kB 涨到 544 kB,与这个估算对得上。地址空间开得越大,页表这项间接开销越不能忽略。
参考
-
environ 手册页,环境表的组织方式与相关接口清单:https://man7.org/linux/man-pages/man7/environ.7.html
-
execve 手册页,新程序如何拿到参数与环境:https://man7.org/linux/man-pages/man2/execve.2.html
-
getenv 手册页,getenv、setenv、unsetenv、putenv 四个接口的语义:https://man7.org/linux/man-pages/man3/getenv.3.html
-
bash 手册页,内建命令与启动文件的加载顺序:https://man7.org/linux/man-pages/man1/bash.1.html
-
fork 手册页,写时拷贝与父子地址空间的关系:https://man7.org/linux/man-pages/man2/fork.2.html
-
mmap 手册页,匿名映射与大块内存的分配路径:https://man7.org/linux/man-pages/man2/mmap.2.html
-
malloc 手册页,分配阈值与分配器的行为:https://man7.org/linux/man-pages/man3/malloc.3.html
-
proc 手册页,maps、smaps、status 与 pagemap 的字段说明:https://man7.org/linux/man-pages/man5/proc.5.html
-
elf 手册页,代码段与数据段在文件中的布局:https://man7.org/linux/man-pages/man5/elf.5.html