环境变量¶
当我在Bash上编写Shell脚本,或者配置Bash运行环境的时候,时常会对环境变量感到困惑,这个困惑主要来自两个方面,第一是对于环境变量具体是存在于什么地方并不清楚,第二是其作用规则是怎样的不够了解。这里主要探索Linux+Bash的环境变量相关内容,回答如下几个问题:(阿里云CentOS7.6)
- 环境变量本身是字符串,这些字符串是存在OS内核中,还是存在于用户进程空间?是否有某个后台服务管理着这些环境变量?
- 某个进程对环境变量做了一些增删改的操作,会有什么影响?
- Linux下通过/proc/self/environ可以获取到某个进程自身的环境变量列表,Bash下执行cat /proc/self/environ可以打印出刚刚通过export添加的环境变量,这是否能说明环境变量存在内核中?
- Bash中环境变量与Shell变量有什么关系?xxx=xxx cmd arg 这样的方式调用命令,前置参数是作为环境变量传递给该命令吗?是否会影响当前操作的Bash上下文?
- Bash中有个环境变量叫$PWD,记录着当前工作目录,这个值可以通过cd命令来修改(cd命令对应系统调用chdir,内核中保存了该路径节点的引用),难道这不能断定环境变量是保存在内核中?
对于这些问题,这里先给出答案:
- 环境变量这些字符串,是存在于进程用户态空间中,由用户进程自己管理,并没有专门管理环境变量的后台服务。在BashShell环境下,环境变量的管理者也是Shell进程本身,没有其他服务。(其实在系统内核中也存放了一份该进程的只读环境变量,这份环境变量的值为该进程从execve得到的字符串数组,并且,进程后续对环境变量的修改不会反应到这里)
- 某个进程对于环境变量做了一些增删改,会影响自身后续读取对应环境变量所得到的值,也会影响后续创建子进程的环境变量(子进程的环境变量默认继承父进程的环境变量,除非特殊指定子进程的环境变量数组,具体继承方式为execve系统调用传入的参数)。
- 不能!因为cat /proc/self/environ打印的环境变量,是cat命令对应的进程中在OS内核中保存的那份环境变量,并不能说明Bash的环境变量是保存在内核中。
- Bash中通过export命令可以将Shell变量导出为环境变量,不export的Shell变量,不会影响本Bash的环境变量(某个Shell变量被导出为环境变量之后,如果修改该Shell变量,则对应的环境变量也会跟着同步修改),前置参数是通过环境变量的方式传递给新命令进程,这种方式不会影响原本Shell环境(不会添加Shell变量,也不会添加到原本Shell的环境变量)。
- 不能!当前工作目录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进程虚拟内存空间分布如下:(可以看出,环境变量和参数列表在栈底位置存放着)
考虑如下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命令执行该脚本即可。基于这样的运行流程,我们可以得到如下推论:
- 在BashShell设置了一次环境变量之后,后续跑的shell脚本或者命令行程序都能感知到该环境变量
- 当某个子进程开始跑了之后,Shell再改变环境变量,这个子进程就感知不到
- Shell脚本中用export改的环境变量,不会影响用户操作的当前Bash
- 子进程里自己再怎么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获得)