环境变量

当我在Bash上编写Shell脚本,或者配置Bash运行环境的时候,时常会对环境变量感到困惑,这个困惑主要来自两个方面,第一是对于环境变量具体是存在于什么地方并不清楚,第二是其作用规则是怎样的不够了解。这里主要探索Linux+Bash的环境变量相关内容,回答如下几个问题:(阿里云CentOS7.6)

  1. 环境变量本身是字符串,这些字符串是存在OS内核中,还是存在于用户进程空间?是否有某个后台服务管理着这些环境变量?
  2. 某个进程对环境变量做了一些增删改的操作,会有什么影响?
  3. Linux下通过/proc/self/environ可以获取到某个进程自身的环境变量列表,Bash下执行cat /proc/self/environ可以打印出刚刚通过export添加的环境变量,这是否能说明环境变量存在内核中?
  4. Bash中环境变量与Shell变量有什么关系?xxx=xxx cmd arg 这样的方式调用命令,前置参数是作为环境变量传递给该命令吗?是否会影响当前操作的Bash上下文?
  5. Bash中有个环境变量叫$PWD,记录着当前工作目录,这个值可以通过cd命令来修改(cd命令对应系统调用chdir,内核中保存了该路径节点的引用),难道这不能断定环境变量是保存在内核中?

对于这些问题,这里先给出答案:

  1. 环境变量这些字符串,是存在于进程用户态空间中,由用户进程自己管理,并没有专门管理环境变量的后台服务。在BashShell环境下,环境变量的管理者也是Shell进程本身,没有其他服务。(其实在系统内核中也存放了一份该进程的只读环境变量,这份环境变量的值为该进程从execve得到的字符串数组,并且,进程后续对环境变量的修改不会反应到这里)
  2. 某个进程对于环境变量做了一些增删改,会影响自身后续读取对应环境变量所得到的值,也会影响后续创建子进程的环境变量(子进程的环境变量默认继承父进程的环境变量,除非特殊指定子进程的环境变量数组,具体继承方式为execve系统调用传入的参数)。
  3. 不能!因为cat /proc/self/environ打印的环境变量,是cat命令对应的进程中在OS内核中保存的那份环境变量,并不能说明Bash的环境变量是保存在内核中。
  4. Bash中通过export命令可以将Shell变量导出为环境变量,不export的Shell变量,不会影响本Bash的环境变量(某个Shell变量被导出为环境变量之后,如果修改该Shell变量,则对应的环境变量也会跟着同步修改),前置参数是通过环境变量的方式传递给新命令进程,这种方式不会影响原本Shell环境(不会添加Shell变量,也不会添加到原本Shell的环境变量)。
  5. 不能!当前工作目录CWD确实是属于保存在内核中的一个进程属性(就像PID、文件描述符那样),cd命令也是通过chdir系统调用来改变这个值,但是,Bash中的$PWD环境变量跟着改变了却不是chdir系统调用干的,而是Bash自己干的。

环境变量在用户态

判断环境变量是存在于用户态还是在内核态,最简单的方式就是尝试在进程中修改环境变量,并监控此时的系统调用,假如,期间并没有触发任何相关的系统调用,即可确定环境变量的操作完全是在用户态。考虑如下C语言程序:usermode.c

#include<stdio.h>
#include<stdlib.h> // for getenv setenv
// extern char** environ;
int main(int argc, char* argv[]){
    printf("Start...\n");
    fflush(stdout);
    char* path = getenv("PATH");
    printf("$PATH: %s\n", path);
    fflush(stdout);
    setenv("HELLO", "WORLD", 1);
    char* hello = getenv("HELLO");
    printf("$HELLO: %s\n", hello);
    fflush(stdout);
    printf("End\n");
    fflush(stdout);
    return 0;
}

程序很简单,操作环境变量和打印相关内容,在Bash中执行如下命令:

gcc -o usermode usermode.c
strace ./usermode > trace.log 2>&1

查看trace.log文件,如下:(这里只显示关键部分)

...

write(1, "Start...\n", 9Start...

) = 9

write(1, "$PATH: /usr/local/bin:/usr/bin:/"..., 126$PATH: /usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/zyk/.dotnet/tools:/home/zyk/workspace/go/bin:/usr/local/go/bin

) = 126

brk(NULL) = 0x1cd9000

brk(0x1cfa000) = 0x1cfa000

brk(NULL) = 0x1cfa000

write(1, "$HELLO: WORLD\n", 14$HELLO: WORLD

) = 14

write(1, "End\n", 4End

) = 4

exit_group(0) = ?

可以发现,在打印出Start和End之间,仅有的系统调用只有两种,一是write系统调用往文件描述符为1的文件写东西,二是brk系统调用调整program break,并没有任何其他系统调用用于操作环境变量,就此我们可以断定,环境变量的所有操作(增删改查)均完完全全是在用户态进行,与系统内核无关。(通过此试验我们还发现了Start和End之间的一系列操作做了动态内存申请)


环境变量的继承

首先我们考究一个系统调用的函数原型:

#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);

我们知道,类Unix系统执行新程序的方式是通过fork+execve两个系统调用,fork系统调用被调用一次,而返回两次,一次在父进程,一次在子进程(子进程首次被执行代码,显然,虽然此时已经是属于子进程的执行过程,但执行的却是共享父进程的代码段),之后,子进程中执行execve系统调用,通过filename指定的可执行文件来替换当前进程上下文,execve获取到argv命令行参数列表和envp环境变量列表并将其设置给子进程的栈底(栈开始位置)并跳转到二进制可执行文件的入口点处开始执行,execve系统调用成功后,进程上下文完全被替换,之后子进程就执行新的可执行文件中的代码,与父进程完全无关,分道扬镳了,此时子进程的环境变量列表,完全来自于父进程。

可以看出,环境变量也是父进程给子进程传递参数的一种方式。

典型的Linux进程虚拟内存空间分布如下:(可以看出,环境变量和参数列表在栈底位置存放着)

LinuxProcessVirtualMemory.jpg

考虑如下C语言程序:env_in_c.c

#include<stdio.h>
#include<stdlib.h> // for getenv setenv
#include<string.h>
extern char** environ;
int main(int argc, char* argv[], char** envp){
    printf("Start...\n");
    for(int i=0; envp[i]!=NULL; i++){
        if(0 == strncmp(envp[i], "PATH", 4)){
            printf("PATH in envp: %x\n", envp[i]);
            break;
        }
    }
    char* path = getenv("PATH");
    printf("getenv PATH: %x, environ: %x\n", path, environ);
    setenv("PATH", "NoPath", 1);
    path = getenv("PATH");
    printf("getenv PATH: %x, environ: %x\n", path, environ);
    setenv("HELLO", "WORLD", 1);
    setenv("HELLO1", "WORLD1", 1);
    setenv("HELLO2", "WORLD2", 1);
    setenv("HELLO3", "WORLD3", 1);
    setenv("HELLO4", "WORLD4", 1);
    path = getenv("PATH");
    printf("getenv PATH: %x, environ: %x\n", path, environ);
    printf("End\n");
    return 0;
}

在Bash中执行如下命令:

gcc -std=c11 -o env_in_c env_in_c.c
./env_in_c

得到如下结果:

Start...

PATH in envp: 3db14e23

getenv PATH: 3db14e28, environ: 3db12918

getenv PATH: 857015, environ: 3db12918

getenv PATH: 857015, environ: 857190

End

我们可以发现,通过getenv获取到的PATH环境变量字符串地址,正是在main函数参数的envp中,3db14e23与3db14e28刚好相差5个字符,也就是“PATH=”

通过setenv操作修改PATH环境变量,可以发现,修改之后,重新获得的PATH环境变量内存地址已经发生了变化,另外,添加了5个新的环境变量之后,environ指针的值竟然发生的变化,这是为什么?

其实仔细思考不难发现,main函数传递进来的envp字符串数组,是由execve设置在栈底的,这些字符串是只读的;为了进程自身对环境变量进行增删改,进程还在自己堆空间中维护了environ字符指针数组,当对环境列表进行增删改时,仅修改environ所维护的指针数组;所以,修改了PATH环境变量,就是抛弃了原来的那份,而新建新的字符串,并让environ索引到新的字符串

environ指针数组是定长的,当加入环境变量超过其上限之后,environ就需要扩容,C语言数组扩容的方式是什么?当然就是构建一个新的足够容量的数组,并将旧数组的相关内容拷贝过来咯,因此,扩容之后的environ指针自然就发生变化(C语言中保存environ指针的值就没什么意义了);由于扩容只是影响environ指针数组,并不影响环境变量字符串,所以此时PATH环境变量的字符串指针依然不变。(这里我们知道C库实现环境变量列表是用的数组,据我所知,Bash实现环境变量不是用数组,而是双向链表,或许这跟Bash下频繁操作环境变量相关吧,采用链表容易增删改)


内核中的环境变量

环境变量在父进程传递给子进程的过程中,通过execve系统调用的时候传递给了内核,显然此时内核需要复制一份保存起来,之后execve配置子进程上下文的时候,又复制了一份给子进程的栈底,那么,我们如何访问到内核保存起来的这一份环境变量呢?Linux为我们提供了一种方式,通过访问/proc/PID/environ文件,即可获得通过execve传递给该进程的环境变量。在BashShell下执行如下命令:

export HELLOWORLD=DayDayUp
cat /proc/self/environ
cat /proc/`echo $$`/environ

export命令添加一个环境变量,/proc/self指向进程自身的/proc/PID,由于cat命令会新建一个子进程来执行,假如此时BashShell的PID为2000,cat命令所在子进程的PID为2001,那么此时的/proc/self指向的不是/proc/2000,而是/proc/2001,而2001号进程的环境变量继承自2000号进程的环境变量,因此HELLOWORLD应该会打印出来。

对于第二个cat命令,直接打印的是BashShell的/proc/PID/environ,由于HELLOWORLD是BashShell进程启动之后,Shell内部用户态自身的修改,并没有影响到内核态中的只读副本,所以并不会打印出HELLOWORLD。

运行命令之后我们会发现,第一个cat命令会打印出HELLOWORLD环境变量,第二个cat命令并不会,这完全符合我们的预期。

简单的说,第一个cat打印的是BashShell用户态自己维护的那份环境变量,第二个cat打印的是BashShell在execve的时候第三个参数传入的环境变量数组。


Bash中的环境变量

说到Bash的环境变量,可能更多的是与Shell脚本相关,当多个脚本或者脚本中多个命令相互作用的时候,就经常会用到环境变量,这里我们先明确一点,就是Shell脚本中的每调用一条命令,在运行过程都会创建一个新的进程来执行该命令,调用其他Shell脚本也同样是在新进程中执行,因此,环境变量的传递方式,就完全是父进程传递给子进程的方式(通过execve系统调用的第三个参数)。考虑如下Shell脚本:export.sh

echo "Start Shell Script"
echo $GOOD
export GOODDAY=hello
echo "End of Script"

执行如下命令:

echo $GOOD
GOOD=Nice ./export.sh
echo $GOOD
echo $GOODDAY

我们会发现,当前Shell中并没有GOOD变量,Shell脚本中才能够正常打印出Nice值,并且,GOODDAY变量是没有作用到脚本外的Bash环境。

其实要想让shell中的操作作用于当前Bash环境,只需要通过source命令执行该脚本即可。基于这样的运行流程,我们可以得到如下推论:

  1. 在BashShell设置了一次环境变量之后,后续跑的shell脚本或者命令行程序都能感知到该环境变量
  2. 当某个子进程开始跑了之后,Shell再改变环境变量,这个子进程就感知不到
  3. Shell脚本中用export改的环境变量,不会影响用户操作的当前Bash
  4. 子进程里自己再怎么export,也无法影响父进程的环境变量内容(可以通过进程间通信的方式去通知父进程修改,但这种做法依然还是要父进程自己去改)

进程CWD

CWD全称是Current Working Directory,当前工作目录,与环境变量所不同的是,进程CWD是记录于内核中;CWD存在的目的是解释相对路径在磁盘中的路径,比如open系统调用第一个参数传入文件路径,如果传入相对路径,则被内核解释为相对于CWD的路径,chdir、mkdir、chmod、chown、mount等系统调用也都可以使用相对路径。考虑如下C程序:env_cwd.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> // for chdir
int main(int argc, char* argv[]) {
    printf("Start...\n");
    char* pwd = getenv("PWD");
    printf("$PWD: %s\n", pwd);
    char buffer[1024];
    getcwd(buffer, 1024);
    printf("getcwd: %s\n", buffer);
    chdir("/etc");
    printf("After chdir syscall\n");
    pwd = getenv("PWD");
    printf("$PWD: %s\n", pwd);
    getcwd(buffer, 1024);
    printf("getcwd: %s\n", buffer);
    printf("End\n");
    return 0;
}

执行如下命令:

gcc -o env_cwd env_cwd.c
./env_cwd

得到如下结果:

Start...

$PWD: /home/zyk/workspace/LinuxUnix/Demo/env

getcwd: /home/zyk/workspace/LinuxUnix/Demo/env

After chdir syscall

$PWD: /home/zyk/workspace/LinuxUnix/Demo/env

getcwd: /etc

End

我们可以得出结论:虽然Bash下可以通过环境变量$PWD获取到当前工作目录,并且当用户调用cd命令切换当前工作目录时,$PWD环境变量也会跟着改变,但是在非Shell环境下,比如C语言写的程序,调用chdir函数(底层会调用chdir系统调用)改变当前工作目录,此时$PWD环境变量并不会自动修改来适应,显然,BashShell的$PWD环境变量会跟着变是Shell自身额外实现的,并非chdir系统调用所实现的。

另外,一般情况下Bash对于每一条命令都会开启一个新的子进程来执行并用wait系统调用等待子进程结束,子进程结束之后,其退出状态值被放到$?变量中,但是,对于cd命令却不是这样的一个流程,假如cd命令是开启一个新的进程,那在新进程中调用chdir是无法影响到Bash自身的,这也是为什么cd命令必须被实现为Shell的内建命令,而不能是单独的可执行文件,当用户调用cd命令的时候,Shell直接调用chdir系统调用,并做相应的修改(如更新$PWD环境变量)即可,此时并没有新的子进程被创建。


总结

总的来说,一个进程的运行过程总是包含有内核态和用户态两方面,一个父进程刚刚通过 fork+execve 的方式创建新的进程和运行上下文的时候,环境变量是 父进程==>execve系统调用==>内核==>子进程栈底,此时,内核中和子进程栈底都会各自保留一份此时环境变量的只读副本,随着子进程的运行,子进程中会有各种针对环境变量的操作(只读副本是不允许修改的,所以子进程会在堆空间中建立新的可读写副本),那两份只读副本依旧纹丝不动(通过/proc/PID/environ可以获取内核那份副本,通过main函数的第三个参数可以获得子进程栈底的那份副本),在子进程的堆空间中存在的那份(通过全局变量char** environ可以访问到)才是日常操作和引用的目标,同时也是传递给下一个子进程的那份环境变量。(以上测试代码均可在我的Github获得)

environ-transfer.png