你不知道的环境变量

805 阅读7分钟

提到环境变量,我们都知道PATH中包含着常用可执行文件的路径,有了它在命令行程序中直接输入文件名就可以运行程序。在 Node.js 环境中,我们也经常使用process.env.NODE_ENV来区分开发和线上环境,比如在开发环境下可以打印日志、不压缩资源,以方便调试。

但是,除了这些,你对环境变量还知道多少?本文将带领大家全面了解一下环境变量。

一点背景

我们现在使用的环境变量是在 1979 年 Version 7 Unix 中成型的,此后所有 Unix 系统包括 Linux 和 macOS 都实现了同样的特性。1982 年,从 PC DOS 2.0 起,所有 Windows 操作系统也包含了环境变量,只是语法、用法和标准变量名有所差异。

环境变量是与进程紧密相关的一个特性。进程是什么?通俗地理解,进程就是 “进行中的程序”,书面说法则是 “运行中程序的一个实例”。意思其实都一样。之所以要抽象出进程这个概念,部分原因是为了隐藏使用 CPU 和内存资源的复杂度。有了进程的概念,一个程序在运行时就好像可以占有全部 CPU 和内存一样,不必考虑其他同时运行的程序。而每个程序都是在一定的上下文中运行,这个上下文中包含了程序正确运行所需的状态。这里所说的状态包括程序的代码、数据、栈、通用寄存器的内容、程序计数器、环境变量及打开的文件描述符,等等。

访问环境变量

环境变量可以在脚本中使用,也可以在命令行中使用。通常需要在变量名前面或两侧添加特殊符号来引用某个环境变量。比如,要显示用户的主目录,在大多数脚本环境中必须使用:

echo $HOME

在 DOS/Windows 命令行解释器(如cmd.exe)中,要这样写:

ECHO %HOME%

在 Windows PowerShell 中,则要这样写:

Write-Output $env:HOMEPATH

命令行程序有 3 个内置命令,可以列出环境变量及它们的值:

  • env
  • set
  • printenv

在 Unix 和类 Unix 系统中,环境变量区分大小写。

fork,exec

在 Unix 中,环境变量通常在系统启动时由初始化脚本进行初始化,然后由系统中的所有其他进程继承。同样,当在一个程序中打开另一个程序时,调用程序会先复制一个与自身完全一样的进程,即子进程。子进程可以根据需要修改环境变量。最后,子进程再通过执行被调用的程序来覆盖自己。其中,复制进程对应fork,执行程序对应exec

  • fork:是由操作系统内核实现的系统调用,用于创建当前进程自身的一个副本;
  • exec:同样是由操作系统内核实现的系统调用,用于在已有进程的上下文中运行一个可执行文件。

exec 在实际实现中通常是一组函数的代称,比如在 C 语言中就有 execlexecleexeclpexecvexecveexecvp 等几个函数,它们共用exec这个名字,只是分别在末尾追加了一两个字母,表示自己接受的参数不同(比如,e 表示接收环境变量的指针数组,l 表示一个一个地接收命令行参数,即参数列表,p 表示使用PATH环境变量查找文件,v 表示接收命令行参数的指针数组或者叫向量)。Linux 内核只有一个叫execve的实现,前面所有其他的函数都是在用户空间中对这个系统调用的封装。

下面我们通过分析execve来理解父进程在创建子进程时如何传递环境变量。先看一看execve的接口:

#include <unistd.h>

int execve(const char *pathname, char *const argv[],
           char *const envp[]);

(man7.org/linux/man-p…)

execve函数接收 3 个参数,第一个是可执行文件的路径pathname,第二个是参数的指针数组argv,第三个是环境变量的指针数组envp。下图展示了参数数组的数据结构:

如图所示,变量argv指向一个 NULL 结尾的指针数组,前面的每个元素都是一个指向参数字符串的指针。按照约定,argv[0]是可执行文件的名称。下面是环境变量指针数组的数据结构:

两上数据结构类似。唯一的区别是,环境变量数组元素指向的字符串都是名 - 值对形式的,比如"PWD=/usr/droh"

在找到pathname对应的可执行文件后,execve会调用操作系统永驻内存的loader代码,把可执行文件的代码和数据从磁盘复制到内存。然后,跳到其第一个指令或 “入口点” 开始执行该程序。这个过程叫加载。

加载之后,就是通过系统启动函数来运行用户的main函数:

int main(int argc, char *argv[], char *envp[]);

同样,main函数也接收 3 个参数,其中最后一个参数envp就是新进程或子进程继承的环境变量。

命令行程序

命令行程序是我们最常用的启用其他程序的工具,因此会频繁地使用上述的fork/exec过程。比如:

node ./index.js

就会启用一个新的 Node.js 进程,运行 index.js。与此同时,父进程的环境变量也会传递给这个新的子进程。

在 Unix 中,脚本或编译的程序修改的环境变量只会影响当前进程及其子进程。父进程及其他不相关的进程不受影响。

在命令行程序中,内置的export命令用来在当前进程中创建环境变量(自然也会被子进程继承):

export API_URL=http://example.com/api

不使用export关键字也可以创建变量,虽然以这种方式定义的变量可以通过set命令显示,但却不是真正的环境变量。这种变量只是命令行程序存储的,操作系统内核并不认。因此envprintenv命令不会显示它们,子进程也不会继承它们:

API_URL=http://example.com/api

然而,在命令行中调用其他程序时,在前面添加类似上面的变量赋值,则会将该变量添加到子进程的环境变量中:

API_URL=http://example.com/api node ./index.js

这样,index.js 就可以通过process.env.API_URL取得传入的 API 地址了。

用户可以在自己所用的命令行工具的配置脚本(profile script)中添加或修改环境变量。

在 DOS 和 Windows 命令行解释器中,使用SET命令创建环境变量并赋值:

SET VAR_NAME=VALUE

只有SET命令会显示所有环境变量及它们的值。

hashbang

命令行脚本除了可以直接使用环境变量,还有一种常见的用法,就是 “hashbang”。比如,下面这个脚本使用 Node.js 作为解释器:

#!/usr/bin/env node

console.log('Hello, You Got It!')

第一行中的#!/usr/bin/env是命令行程序内置env命令的完整路径,后面跟着运行当前脚本的程序名node。意思是在env的环境变量PATH中去查找解释程序node

当然,不使用env而直接给出node的路径也是可以的:

#!/usr/local/bin/node

console.log('Hello, You Got It!')

但这样是不是就不灵活了?毕竟不同环境下node的位置可能不一样。使用env就可以做到在运行时再定位解释器的位置,从而让脚本的兼容性更好。

当然,使用env也有缺点:不同机器上env的位置同样可能不一样!

[完]

参考文献