环境变量 (environment variables) 和 shell 变量 (shell variables) 的有区别吗?环境变量在进程之间是怎么传递的?

环境变量

环境变量是 UNIX/Linux 系统级别的概念,最常见的一个应当是 PATH 了。环境变量查找表会存储于每一个进程内存空间中的 mian 栈的上面的位置,具体参考我之前的关于虚拟内存部分的文章,环境变量查找表指向的值(环境变量)根据是其否进行二次修改,存储在 main 栈之上或者堆中,如《The Linux Programming Interface》书中的截图。

environ

在 C 编程中,环境变量查找表可以视为在 unistd.h 中的导出变量 extern char **environ;,也可以由 main 函数传入,新建下面的文件 showenv.c。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// showenv.c
#include <stdio.h>
#include <unistd.h>

extern char **environ;
// env 和 environ 是一样的,都是指向的存储着环境变量字符串指针的数组
int main(int argc, char **argv, char **env)
{
    for (int i=0; env[i] != NULL; i++) {
        printf("env[%d] at %p = %s\n", i, env[i], env[i]);
    }
    printf("End of Env\n");
    getchar();
    return 0;
}

之后进行测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ gcc showenv.c -o showenv
# 打印当前进程的环境变量,限于篇幅和隐私省略部分,但是也可以看到下面有很多有意思的环境变量
$ ./showenv
env[0] at 0xbfa1a7ed = HOME=/home/creaink
env[1] at 0xbfa1a800 = LANG=en_US.UTF-8
env[11] at 0xbfa1ae35 = PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games
env[12] at 0xbfa1ae93 = PWD=/home/creaink/program
env[13] at 0xbfa1aead = SHELL=/usr/bin/zsh
env[21] at 0xbfa1af76 = USER=creaink
env[24] at 0xbfa1afb3 = ZSH=/home/creaink/.oh-my-zsh
env[25] at 0xbfa1afd0 = _=/home/creaink/program/./showenv

同时每一个进程都是可以增删其自己进程的环境变量,如可以通过 C 库里面的 setenv()unsetenv() 函数完成,下面新建 testenv.c 文件进行测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// testenv.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

extern char **environ;
// 更改 pos 位置上的环境变量的值
void setEnvAt(int pos, char *val) {
    if (environ[pos] == NULL) return;
    int len = strchr(environ[pos], '=') - environ[pos];
    char *name = malloc(sizeof(char)*len+1);
    memcpy(name, environ[pos], len);
    name[len] = '\0';

    setenv(name, val, 1);
    free(name);
}

int main(int argc, char **argv) {
    int a;
    int *b = malloc(sizeof(int));

    printf("stack: %p, heap: %p \n", &a, b);
    printf("env_arr_addr: %p, env[0]_addr: %p, env[1]_addr: %p, env_var_addr: %p \n", environ, environ[0], environ[1], &environ);

    setEnvAt(0, "test 0");
    printf("env: %p, env[0]: %p, value: %s \n", environ, environ[0], environ[0]);

    setEnvAt(1, "test 1");
    printf("env[0]: %p, value: %s \n", environ[0], environ[0]);

    // getchar();
    return 0;
}

使用上面的程序进行试验:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$ gcc testenv.c -o testenv
$ ./testenv
stack: 0xbfc5c224, heap: 0x9127008
env_arr_addr: 0xbfc5c2ec, env[0]_addr: 0xbfc5d7ed, env[1]_addr: 0xbfc5d800, env_var_addr: 0x804a034
env: 0xbfc5c2ec, env[0]: 0x9127430, value: HOME=test 0
env[0]: 0x9127430, value: HOME=test 0

# 取消在 testenv.c 的 return 上的 getchar 再编译
$ gcc testenv.c -o testenv && ./testenv
stack: 0xbfc74264, heap: 0x91bd008
env_arr_addr: 0xbfc7432c, env[0]_addr: 0xbfc747e0, env[1]_addr: 0xbfc747f3, env_var_addr: 0x804a038
env: 0xbfc7432c, env[0]: 0x91bd430, value: HOME=test 0
env[0]: 0x91bd430, value: HOME=test 0

# 另外一个终端
$ program ps -ef |grep testenv
creaink  14577 13295  0 18:17 pts/3    00:00:00 ./testenv
$ program cat /proc/14577/maps
08048000-08049000 r-xp 00000000 08:01 789637     /home/creaink/program/testenv
08049000-0804a000 r--p 00000000 08:01 789637     /home/creaink/program/testenv
0804a000-0804b000 rw-p 00001000 08:01 789637     /home/creaink/program/testenv
091bd000-091de000 rw-p 00000000 00:00 0          [heap]
b75bf000-b75c0000 rw-p 00000000 00:00 0
b75c0000-b7770000 r-xp 00000000 08:01 913947     /lib/i386-linux-gnu/libc-2.23.so
b7770000-b7772000 r--p 001af000 08:01 913947     /lib/i386-linux-gnu/libc-2.23.so
b7772000-b7773000 rw-p 001b1000 08:01 913947     /lib/i386-linux-gnu/libc-2.23.so
b7773000-b7776000 rw-p 00000000 00:00 0
b777c000-b777d000 rw-p 00000000 00:00 0
b777d000-b7780000 r--p 00000000 00:00 0          [vvar]
b7780000-b7781000 r-xp 00000000 00:00 0          [vdso]
b7781000-b77a4000 r-xp 00000000 08:01 913923     /lib/i386-linux-gnu/ld-2.23.so
b77a4000-b77a5000 r--p 00022000 08:01 913923     /lib/i386-linux-gnu/ld-2.23.so
b77a5000-b77a6000 rw-p 00023000 08:01 913923     /lib/i386-linux-gnu/ld-2.23.so
bfc54000-bfc75000 rw-p 00000000 00:00 0          [stack]

会看到一个有点诡异的地方,在修改环境变量之前,第一个环境变量的值 env[0]_addr 存储在 0xbfc5d7ed,可以看到这个值是大于 stack: 0xbfc5c224 的,而环境变量的查找表 env_arr_addr 存储在 0xbfc5c2ec 也大于栈的地址但是小于第一个环境变量值存储的地址,这都符合前面提到的 memory mapping。而在修改环境变量之后,第一个环境变量的值 env[0]_addr 存储的位置发生了变化,变为了 0x9127430,可以看这是位于堆区域的,而查找表 env_arr_addr 的地址没有发生变化,在一次修改环境变量之后没有也发生变化。其原因与分析见 环境变量的传递

由上面的 program cat /proc/14577/maps 可以看出,env 的变量落在 [stack] 区域,而且每次运行程序地址都不一样,这是会有一个随机偏移量,为的是提高程序的安全性。

由于环境变量存于进程的空间内所以在 /proc/<PID>/environ 下也有映射也是可以看到,或者通过 shell 的 printenv 或者 env 命令也可以看到其实是一些字符键值对。

同时环境变量也就理所当然的可以通过 fork 传递给子进程的,上面的 setenv()/unsetenv() 是 C 里面的库函数的方式,在 shell 下可以通过内建 export 命令来完成,删除可以通过 unset 命令。说道这里应当会想起熟悉 export PATH=/XXX/bin:$PAHT 这句命令了,这句命令就是将用户的给的值和 $PATH shell 变量拼接在一起然后更新到环境变量 PATH 里面。这里有可能就会造成混淆了,又是 $PATH 又是 PATH ,有什么区别?

Shell 变量

Shell 变量 也可以被称为局部环境变量,可以通过 echo $<VARNAME> 显示单个 shell 变量的值,要想列举可以使用 set 命令 (zsh 下是输出 ok 的,bash 下有可能不太对),使用 VARNAME=varvalue 的方式可以设置 shell 变量。对于 shell 变量和环境变量可以理解 shell 会将环境变量融合/映射到其 shell 变量当中,这样使用 echo $PAHT 也是可以的输出环境变量的值的。但是 shell 变量的值是不会在 fork 时候被子进程继承的,那么 shell 变量存储在哪?这个是个好问题。

通过下面的例子可观察下环境变量与局部环境变量的差别:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ SVAR=shell
$ export EVAR=env
$ echo $SVAR $EVAR
env shell
# 但是在 proc 下的 environ 没有看到,这是因为这力只会映射初始的环境变量
$ cat /proc/$$/environ |grep -a EVAR

# 但是在子进程就可以看到了环境变量,但是看不到 shell 变量,这里也可以使用子 bash 进程测试
$ cat /proc/self/environ |grep -a 'EVAR\|SVAR'
EVAR=env

# 使用 printenv 是可以立即看见的
$ printenv | grep 'EVAR\|SVAR'
EVAR=env

# 更改环境变量之后"映射"的 shell 变量也随之改变,并且不用重新 export,子进程也是最新的值
$ export EVAR=environment
$ echo $EVAR
environment
$ cat /proc/self/environ |grep -a 'EVAR\|SVAR'
EVAR=environment

# unset 可以同时删除环境变量和 shell 变量
$ unset EVAR SVAR
$ echo $EVAR $SVAR

环境变量的传递

这里就有一个问题,既然环境变量是通过继承 fork 而来的父进程的,那么 exec* 系列或者 init 进程的环境变量是哪里来的?

先看看 init 进程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 可以看到这里 pid 为 1 的 systemd 进程的环境变量和 PATH 非常简单
$ sudo cat /proc/1/environ
HOME=/init=/sbin/initNETWORK_SKIP_ENSLAVED=recovery=TERM=linuxdrop_caps=BOOT_IMAGE=/boot/vmlinuz-4.15.0-46-genericPATH=/sbin:/usr/sbin:/bin:/usr/binPWD=/rootmnt=/root

# 由 systemd 直接启动的 cron 的配置开业看到里面会提供如 EnvironmentFile 的选项去扩展从 systemd 进程继承的环境变量
$ cat /etc/systemd/system/multi-user.target.wants/cron.service
[Unit]
Description=Regular background program processing daemon
Documentation=man:cron(8)

[Service]
EnvironmentFile=-/etc/default/cron
ExecStart=/usr/sbin/cron -f $EXTRA_OPTS
IgnoreSIGPIPE=false
KillMode=process

[Install]
WantedBy=multi-user.target

从上面的演示可以看到 init 进程有可能是直接给定的几个简单的环境变量,之后的其他进程会通过其他的方式如配置文件的方式进行扩展。/etc/environment 文件算是一个这样的文件,但是其被被哪些程序锁读取,有待研究。

虽说 exec 代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一留下进程号,但是默认情况下如 shell 命令如 exec 运行的程序还是继承了原来进程的环境变量这是怎么回事?

通过 man exec 可以知道 exec 系列在 C 中有下面的可供调用的函数:

1
2
3
4
5
6
7
8
9
int execl(const char *path, const char *arg, ...
                /* (char  *) NULL */);
int execlp(const char *file, const char *arg, ...
                /* (char  *) NULL */);
int execle(const char *path, const char *arg, ...
                /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

execl*execv* 的区别在于 arg 是否使用的是可变参。而且可以注意到 execle 和 execvpe 是可以传如环境变量参数的,那么其他的函数在不提供环境变量下的环境变量是如何处理的?这就得看看再看创建进程时候如何将环境变量传递的。

这里再看看创建进程时候如何将环境变量传递的,fork 方式直接拷贝一份就不说了,环境变量肯定是一样的。对于 exec 系列这里主要关注 2 两个函数作为对比,一个 execv 不能指定环境变量,一个 execvpe 可以指定环境变量,这里可以自行用 man exec 看看其差别。在 execv 源码中:

1
2
3
4
5
6
/* Execute PATH with arguments ARGV and environment from `environ'.  */
int
execv (const char *path, char *const argv[])
{
  return __execve (path, argv, __environ);
}

对上面的代码主要讲明两点:

  • execv 调用的 __execve ,最后其实会是调用的 do_execve 最终的最终会使用系统调用完成的,其细节与本文无关,这里就不涉及了。
  • __execve (path, argv, __environ) 里的参数 __environ 有点意思,这个是个导出的全局变量,在 __environ 代码里可以看到 char **__environ = NULL; 那这个和 C 编程时候从 <unistd.h> 导出的 environ 有什么区别?其实在该文件的注释里已经给出答案 This file just defines the '__environ' variable (and alias 'environ'). 也就是通过 _weak_alias弱符号 完成的。

也就是默认的不提供环境变量的情况下会默认将 __environ 传入,也就是也能像 fork 一样继承,只不过是通过调用时候传参的方式实现的。新建一个 testexec.c 文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// testexec.c
#include <stdio.h>
#include <unistd.h>

extern char **environ;

int main(int argc, char **argv) {
    if (argc < 2) {
        printf("please input argv");
        return 1;
    }
    // 这简单的设置了一个环境变量的值,然后调用输入的程序替换本进程时候传入
    const char *env[] = {"MYENV=myenv", NULL};
    execvpe(argv[1], &argv[2], env);
    return 0;
}

然后来实践验证一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ gcc testexec.c -o testexec

# 使用 strace 分析使用 testexec 调用之前的 showenv 时候的系统调用情况,过滤只剩 execve
$ strace ./testexec ./showenv

execve("./exec", ["./exec", "./showenv"], [/* 26 vars */]) = 0
execve("./showenv", [], [/* 1 var */])  = 0
env[0] at 0xbfb09fe6 = MYENV=myenv
End of Env

# 上面的 [/* 26 vars */] 是哪些参数?
$ ./showenv
env[0] at 0xbff117ed = HOME=/home/creaink
...
env[25] at 0xbff11fd0 = _=/home/creaink/program/./showenv

上面的 strace 捕获到两个 execve 系统调用,第一个是用于加载 testexec ,注意这里的 execve 是系统调用,上面提到的 exec* 6 个函数只是 execve 或者 execveat 系统调用的封装,所以即使在程序里使用的是 execvpe 最后实际使用的系统调用也是 execve,系统调用列表参见 syscalls,其包装的实现的中间函数是前面提到的 do_execve。第二个 execve 即使程序里使用 execvpe 造成。

第一的 execve 调用后面有 26 个忽略的参数其实就是环境变量,这个在后面运行 ./showenv 的已验证,第二个 execve 调用的环境变量只有一个即在程序里设定的 MYENV=myenv。这就是 execv 在调用时候默认把 __environ 传给 execve 系统调用,这样替换的程序也继承了环境变量,而 execvpe 显式指定了环境变量就不会使用原有进程的 __environ 了。

再来尝试解释最前面的那个问题:当 setenv 之后,为什么 environ 里的环境变量值的地址由 main 栈上面变为了堆,这对 fork 和 execv* 有什么影响。上面提到 environ 是个弱符号,编译连接生成 elf 文件时候可以没有定义,相对于强符号。由此可以推断,传入 execve 系统调用的环境变量查找表,其指向的值(可能在堆可能在 mian 栈上)和本身会被内核复制到 main 栈上面,当环境变量发生改变时候即调用 setenv 时候,会将 main 栈上的环境变量值赋值到堆中并修改环境变量表的指向,之后的修改就在堆上面了,如果发生 exec* 的时候,由于有 __execve (path, argv, __environ) 的默认参数也就少将该环境变量表传递给系统调用,这时候就和上面的一致了,即内核会将环境变量查找表的指向的值压入 mian 栈上。

具体的是实现可以可以看看 setenv()__add_to_environ 源码,这里就不具体分析了。之所以这样做,是无法再 main 栈上面进行内存分配,就算能,当有大量环境变量时候不能够保证该区域不会和 main 栈重叠,所以就是使用了堆,而 exec 创建进程时候是知道环境变量大小的所以就直接写在 mian 栈上面了。

在 shell 中的 exec 命令其实就是默认把 __environ 传入的。env 命令除了可以显示环境变量之外,还可以对传入的环境变量进行细节控制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 新开一个终端
$  exec ./showenv
env[0] at 0xbfed37ed = HOME=/home/creaink
...
env[25] at 0xbfed3fd0 = _=/home/creaink/program/./showenv
End of Env

# 使用 -i 参数忽略所有的环境变量
$ env -i ./showenv
End of Env

env 命令还有更多用法,参见 man env

常用环境变量

路径与 SHELL 相关的

  • PATH 命令的搜索路径
  • PWD 当前目录
  • OLDPWD 上个目录,对于 zsh 来说,所以可以 cd -,这里的 - 就和 ~ 一样是缩写,如果不回车直接 TAB 会出现最近访问的路径
  • SHELL 当前 shell 程序路径
  • HOME 当前用户的家目录
  • LOGNAME 当前登录用户名
  • USER 当前登录用户名
  • TERM 如 xterm-256color

默认配置相关

  • LANG 语言如,en_US.UTF-8
  • PAGER 默认的分页方式,如 /usr/bin/less
  • EDITOR 默认文本编辑的路径,如 vim
  • VISUAL 默认可视化编辑,如 gedit
  • BROWSER 默认浏览器

下面的在写 Makefile 什么时候会常用到:

Variable Value Examples What it’s for
CC gcc The name of the C compiler to use
CXX g++ The name of the C++ compiler to use
CFLAGS -o out.o A list of debugging/optimization flags to pass to the C compiler
CXXFLAGS -Wall A list of debugging/optimization flags to pass to the C++ compiler
CPPFLAGS -DDEBUG A list of flags to pass to the C/C++ pre-processor/compiler
LIBRARY_PATH /usr/lib/firefox A list of directories (separated by colons) in which library files should be searched for
INCLUDE /opt/app/src/include A colon-separated list of directories in which header files should be searched for
CPATH ..:$HOME/include:/usr/local/include A colon-separated list of directories in which header files should be searched for

还有 ssh 相关

  • SSH_CLIENT 如 192.168.48.1 63886 22
  • SSH_CONNECTION 如 192.168.48.1 63886 192.168.48.137 22
  • SSH_TTY 如/dev/pts/0

Shell 常用变量

注意下面的有些变量只适用于 sh 脚本编程:

  • $0 当前脚本的文件名
  • $n 传递给脚本或函数的参数。n 是一个数字,表示第几个参数。例如,第一个参数是$1,第二个参数是$2。
  • $# 传递给脚本或函数的参数个数。
  • $* 传递给脚本或函数的所有参数。
  • $@ 传递给脚本或函数的所有参数。被双引号 (” “) 包含时,与 $* 稍有不同,下面将会讲到。
  • $? 上个命令的退出状态,或函数的返回值。一般情况下,大部分命令执行成功会返回 0,失败返回 1。
  • $$ 当前 Shell 进程 ID。对于 Shell 脚本,就是这些脚本所在的进程 ID。
  • $PPID 父进程 PID
  • $PS1 shell 命令行前面的部分如 bash username@hostname:~$,里面是一些占位符,shell 程序会进行填充,具体参见 ps1

其中 $*$@ 的异同:

  • 都表示传递给函数或脚本的所有参数,不被双引号 (” “) 包含时,都以 “$1” “$2” … “$n” 的形式输出所有参数。
  • 但是当它们被双引号 (” “) 包含时,”$*” 会将所有的参数作为一个整体,以”$1 $2 … $n”的形式输出所有参数;”$@” 会将各个参数分开,以”$1” “$2” … “$n” 的形式输出所有参数。

实战

之前遇到用 cron 定时更新 Let’s encrypt 的证书,结果更新失败的,查看日志发现是找不到 nginx,无法重启完成更新,下面探究一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ crontab -e
# 添加下面的进进行测试,每隔一分钟输出一下
*/1 *   *   *   *    cat /proc/self/environ > /home/creaink/env

$ cat /home/creaink/env
LANGUAGE=en_US:HOME=/home/creainkLOGNAME=creainkPATH=/usr/bin:/binLANG=en_US.UTF-8SHELL=/bin/shPWD=/home/creaink

# 发现 nginx 没有在上面的 path 里
$ where nginx
/usr/sbin/nginx

# 但是神奇的的是 cron 的环境变量是有的
$ sudo cat /proc/$(pgrep -f cron)/environ
LANG=en_US.UTF-8LANGUAGE=en_US:PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin%

这是 cron 最终会调用 shell 去解释这行命令,默认的 shell 是 /bin/sh 而且是非登录非交互模式(该模式是基础的环境变量,只能预加载 $BASE_ENV 的文件来初始化一些东西)。所以解决方法有下面的几种

方法一,直接在命令前面加上 export ,如:

1
*/1 *   *   *   *   export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin  && cat /proc/self/environ > /home/creaink/env`

方法二,添加全局的环境变量,同时可以把 shell 改了:

1
2
3
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
*/1 *   *   *   *    cat /proc/self/environ > /home/creaink/env

方法三,使用 $BASE_ENV 指定一个初始化脚本,可以是 .bashrc 或者自定义的。

1
2
BASE_ENV=/home/creaink/xxx.sh
*/1 *   *   *   *    cat /proc/self/environ > /home/creaink/env

参考