Re:Linux系统篇(二十二)进程篇·七:环境变量的底层溯源、核心获取机制与安全应用

概要&序論
Hello大家好,我是此方。本文将带大家由浅入深地拆解 Linux 系统级编程中的核心基石:环境变量。我们将从 main 函数隐藏的命令行参数切入,进而深度剖析环境变量的底层指针数组结构、三大核心获取手段 。好,我们开始。
零、 概念补充:命令行参数
0.1 重新认识 main 函数
0.1.1main函数不是程序的开始
main函数不是开始,也许你的老师经常说“main函数是代码的开始。”但是这是对于程序员而言的。vim code.s我们打开汇编代码:
Linux上是这个start,Windows上是_crt_start,这个函数内部调用main函数的逻辑如下
_start
{
int ret = 0;
int arg_count=0;
arg_count=3;
if(arg_count == 0)
ret = main();
else if(arg_count==2)
ret = main(argc, argv);
else
ret =main(argc, argv, env);
}
扫描我们写的main函数,通过词法分析和语法分析得到这个count值是多少 然后调用不同的main函数。
0.1.2main函数也有参数
在初学 C/C++ 编程时,我们接触最多的 main 函数通常是不带任何参数的。然而在 Linux 系统级编程中,真正的 main 函数其实是可以携带参数的。最常见的声明方式如下:
int main(int argc, char *argv[]);
这两个参数在程序启动时由操作系统自动传递。其中,argc是一个整型变量,用来记录命令行参数的个数;而 argv则是一个指向字符指针数组的指针,用来存储具体的命令行参数字符串。
有种写法要知道一下:
int main(int argc, char *argv[], char *env[]){
(void)argc;//为什么这么写: 应用一下变量,取消告警,
//你设置了但是没有用可能会发出警告
(void)argv;
(void)env;
}
0.2 命令行参数的切分与表结构
当我们在终端键入一个诸如 ls -a -b -c 的命令并按下回车时,终端解释器 bash 会首先拿到这一串完整的输入字符串。随后,bash 会以空格作为分隔符,将这个长字符串打散成数个子字符串。
这些被切分好的字符串会被填入一张表中。以输入 ./code a b c 为例,系统内部构建的指针数组结构如图所示:
char *argv[]:
+---+ +----------------+
| 0 | -->| "./code" |
+---+ +----------------+
| 1 | -->| "a" |
+---+ +----------------+
| 2 | -->| "b" |
+---+ +----------------+
| 3 | -->| "c" |
+---+ +----------------+
| 4 | -->| NULL |
+---+ +----------------+
bash 在切分字符串并构建该数组时,会在数组的末尾强制追加一个 NULL 指针作为结束标记。此时,参数的总个数 argc 的值为 4。
通过这种设计,进程便能清晰地知道自己拥有这样一张命令行参数表。(这很重要,我们后面会总结)
0.3 命令行参数的应用演示
为什么要大费周章地把命令行参数传给 main 函数呢?其根本目的在于实现同一个程序的不同子功能。在 Linux 中,我们经常使用的 ls -a、ls -l 等,本质上都是通过解析命令行参数表来实现的。
以下是一段 C++ 的演示代码,展示了如何通过不同的选项让程序支持不同的子功能:
#include <iostream>
#include <string>
using namespace std;
// 演示:如何遍历打印所有的命令行参数
void Test01(int _argc, char* _argv[])
{
for (int i = 0; i < _argc; i++)
{
cout << _argv[i] << endl;
}
}
// 演示:不同的功能是怎么通过命令行参数选项实现的
void Test02(int _argc, char* _argv[])
{
if (_argc != 2)
{
cout << "输入错误!请重新输入" << endl;
}
else
{
string cmd = _argv[1];
if (cmd == "a")
cout << "执行一号功能" << endl;
if (cmd == "b")
cout << "执行二号功能" << endl;
if (cmd == "c")
cout << "执行三号功能" << endl;
}
}
int main(int argc, char *argv[])
{
// Test01(argc, argv); // 取消注释可查看参数遍历结果
Test02(argc, argv);
return 0;
}

通过这段代码可以直观地发现,./code 自身作为 _argv[0] 也是一个命令行参数。通过接收并判断 _argv[1] 传入的选项(如 a、b、c),程序就能够走向不同的分支,从而获得不同的子功能。
一、 引入环境变量
1.1基本概念
1.1.1环境变量的概念
- 环境变量(environment variables) —— 一般是指在操作系统中用来指定操作系统运行环境的一些参数。名称=内容形式
- 如: 我们在编写 C/C++ 代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
- 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
1.1.2常见环境变量
- PATH : 指定命令的搜索路径
- HOME : 指定用户的主工作目录(即用户登陆到 Linux 系统中时,默认的目录)
- SHELL : 当前 Shell,它的值通常是
/bin/bash。
1.1.3怎么看环境变量
| 命令 | 作用 |
|---|---|
echo $VAR |
快速精准查看某一个变量的值 |
env |
观察所有环境变量,常配合 | grep 过滤 |
set |
查所有本地变量 |
export |
不带参数可查看所有环境变量列表 |
1.1.4环境变量和本地变量的设置和导入
在Linux Shell中,变量主要分为本地变量(普通自定义变量)和环境变量(全局变量)。理解它们的区别以及如何转换是掌握Shell编程的基础。
1.1.4.1核心概念对比
| 变量类型 | 作用域 | 特点 |
|---|---|---|
| 本地变量 | 仅限当前Shell进程 | 子进程无法继承该变量。 |
| 环境变量 (全局变量) |
当前Shell进程及其所有子进程 | 全局有效,常用于传递系统配置。 |
本地变量是给Shell脚本局部变量用的。我们不讲Shell脚本。 (面试不怎么考)
算了我简单讲一下吧:
bash如何使用本地变量?bash是一个交互式的命令行解释器。
它也是一门解释性语言。这门解释性语言它也有自己的词法语法分析我们的shell脚本就是这种语言的一种应用:
[whb@bite-alicloud lesson15]$ i=0; while [ $i -le 10 ]; do echo $i; let i++; done
0
1
2
3
4
5
6
7
8
9
10
[whb@bite-alicloud lesson15]$
Shell脚本也可以这样写: 我们用一个文件写下很多的命令,文件以#! /bin/bash开头,这就是shell脚本。我们有些配置文件就是用Shell脚本写的。
1: test.sh
1 #!/bin/bash
2
3 touch file
4 mv file myfile
5 i=100
6 echo $i

1.1.4.2变量的操作指令
① 定义本地变量
直接使用 变量名=值 即可定义本地变量。
等号两边绝对不能有空格。如果值包含空格,需用双引号包裹。
my_var="hello world"
i=10
② export:导入为环境变量(全局变量)
使用 export 命令可以将一个本地变量提升为环境变量,或者在定义时直接声明为环境变量。
- 先定义,后导出 ,或者直接导出。
var1="ubuntu"
export var1
#或者:
export var2="centos"

③ unset:删除变量
使用 unset 命令可以清理不再需要的变量。无论是本地变量还是环境变量,都可以被删除。
# 删除刚才定义的环境变量
unset var2
# 验证是否删除(输出为空)
echo $var2
1.2 main 函数的第三个参数
你觉得命令行参数只有两个吗?不,还有一个:main 函数的完整形态实际上可以包含三个参数(最多三个,从父进程继承过来),其原型的完全体代码如下:
int main(int argc, char *argv[], char *env[]);
这第三个参数 char *env[] 便是环境变量表。与命令行参数表类似,它也是一个字符指针数组,数组的每一个元素都指向一个形如 NAME=value 的环境变量字符串,并且同样以 NULL 指针作为整张表的结尾。
我们输出一个结论: 一个进程内部有两张表:一张叫命令行参数表,一张叫环境变量表。(我们在后文中继续深入了解他们)
父进程把它的两张表传递给子进程。
1.3 为什么执行自己的程序必须加 ./
要想在 Linux 中运行一个可执行程序,核心的前提只有一条:系统必须先找到它。
当我们键入 ls 并回车时,系统之所以能立马响应,是因为 ls 对应的二进制可执行文件原本就存放在系统的 /usr/bin/ 目录下。(核心指令目录)
那么,问题又来了:“为什么是 /usr/bin/ ,而不是别的目录?”
系统内部定义了一个名为 PATH 的全局环境变量,它专门用来存储系统搜索可执行指令的默认路径集合。我们可以通过在终端输入 echo $PATH 来查看它的内容(后面这种用法,我们先用着):
[zbc@VM-0-9-opencloudos ~]$ echo $PATH
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin
当我们在命令行输入一个指令时,命令行解释器 bash 就会顺着 PATH 中配置的路径,从左到右依次去对应的目录下遍历、搜寻同名的二进制文件。(对!查找工作也是bash完成的)因此可以找到。我们自己的程序所在的目录不在PATH里面,所以找不到。
为了解决这个问题,我们必须通过 ./ 显式地告诉系统:请直接在当前的绝对/相对路径下查找并运行该程序。这就是为什么运行我们自己的程序时,必须要加上 ./code 的根本原因。
二、 环境变量的底层表结构与溯源
我们上面讲的那什么?环境变量表。你肯定还是一头雾水。我们来集中研究一下:
2.1 环境变量表的组织形式
当我们通过终端登录 Linux 系统时,系统会为我们启动一个 bash 进程。如果同时有 10 个用户登录,系统内就会存在 10 个独立的 bash 进程,各自维护一套环境变量表。环境变量表以NULL结尾(这个特性可以用来做代码的判断条件)
char *env[]:
+---+ +------------------------------------+
| 0 | -->| "XDG_SESSION_ID=14400" |
+---+ +------------------------------------+
| 1 | -->| "HOSTNAME=bite-alicloud" |
+---+ +------------------------------------+
| 2 | -->| "TERM=xterm" |
+---+ +------------------------------------+
| 3 | -->| "SHELL=/bin/bash" |
+---+ +------------------------------------+
| ...| -->| ... |
+---+ +------------------------------------+
| n | -->| "PATH=/usr/local/bin:/usr/bin:..." |
+---+ +------------------------------------+
|n+1| -->| NULL |
+---+ +------------------------------------+
2.2 环境变量的内存级和磁盘级修改
想一想?我们能不能改环境变量? 比如,你可以在终端直接输入:
[zbc@VM-0-9-opencloudos ~]$ PATH= # 强行把 PATH 清空
[zbc@VM-0-9-opencloudos ~]$ ls
-bash: ls: No such file or directory
完啦!哈哈!这个时候再去执行一些命令,你会发现很多命令都不能用了(这种修改是覆盖式修改,你把原来的环境变量改没了,还能执行的叫内建命令,我们后面会讲)
有一种追加式修改的方法:
[whb@bite-alicloud lesson15]$ PATH=$PATH:/home/whb/code/code/merge_class/lesson15
[whb@bite-alicloud lesson15]$ echo $PATH
/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/whb/.local/bin:/home/whb/bin:/home/whb/code/code/merge_class/lesson15
[whb@bite-alicloud lesson15]$
这时候你只需要把终端关掉,重新连一下就恢复了
那么,问题又来了:“如果我就是想增加一个永久性的环境变量,或者永久修改 PATH,该怎么办呢?”
先说结论:不建议改
但是,为了搞懂底层的原理,我们引出配置文件。每次我们重新打开终端、成功登录 Linux 系统时,命令行解释器 bash 进程在被拉起的那一瞬间,会到当前用户的家目录(~)底下去读取几个隐藏的脚本文件。执行一遍,然后在内存中动态构建 出那张初始的环境变量表。我们知道bash也是一个进程,这个初始的环境变量表就是bash进程内部的环境变量表 。这里面最核心的两个就是:
~/.bash_profile
~/.bashrc

不仅如此,~/.bashrc 还会进一步去调用系统级别的 /etc/bashrc。在这些文件里,早就写好了默认的环境变量导出指令(比如 export PATH=...)。(它就是个shell脚本,没什么了不起的)

.bash_profile调用.bashrc,,.bashrc调用系统的.bashrc
设置完你得重新登录一下你的机器。
2.3怎么在windows下修改环境变量


三、 获取环境变量的三种核心代码手段
3.1 方法一:通过 main 函数的第三个参数隐式获取
用前面提到的 main 函数完全体参数。利用循环遍历该指针数组,直到遇到 NULL 为止:
#include <stdio.h>
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;
}
没人会这么用
3.2 方法二:通过系统调用 API getenv定向获取
方法一虽然能拿到全貌,但不能获取某个特定的环境变量,此时可以使用 C 标准库提供的 getenv 接口:
#include <stdio.h>
#include <stdlib.h>
int main(){
char *user = getenv("USER");
if (user != NULL)
printf("Current user is: %s\n", user);
return 0;
}
3.3 方法三:通过全局第三方指针environ显式获取
如果不想修改 main 函数的参数声明,同时又想遍历完整的环境变量,C 语言提供了一个全局的外部指针 environ。它在 <unistd.h> 中声明(我们要手动进行 extern 声明)
#include <stdio.h>
extern char **environ;
int main(){
for (int i = 0; environ[i] != NULL; i++)
printf("%s\n", environ[i]);
return 0;
}
为什么可以获取?因为这个environ指针是一个二级指针,它指向环境变量表的表头。
3.4 方法四:子进程继承父进程获取环境变量
前三种方法关注的是如何在当前进程中读取环境变量,而方法四探讨的是环境变量的来源与传递机制。在 Linux 系统中,环境变量具有全局属性,这种全局性是通过子进程默认会继承父进程的环境变量表来实现的。
关注两个结论:环境变量具有全局属性,环境变量可以被继承(包括原有的和倒给它的)。
如何理解【全局】这个字:所有的进程都能拿到bash的环境变量,如图:

3.4.1 核心原理
当我们通过 fork() 系统调用创建子进程时,子进程会复制父进程的绝大部分数据,其中就包括环境变量表。
注意写时拷贝:子进程在刚被创建时,其 environ 指针指向的内存空间与父进程完全一致。
3.4.2 示例代码
以下代码演示了父进程通过 putenv 或 setenv 自定义一个环境变量,然后通过 fork 创建子进程。子进程无需显式设置,便能直接继承并读取到该变量:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
// 1. 父进程在自己的独立空间内设置一个自定义环境变量
// setenv 参数:键, 值, 是否覆盖(1表示覆盖)
setenv("MY_TEST_VAR", "Hello_Linux_Process", 1);
printf("[父进程] 成功设置环境变量 MY_TEST_VAR\n");
// 2. 创建子进程
pid_t id = fork();
if (id < 0) {
perror("fork failed");
return 1;
}
else if (id == 0) {
// 3. 子进程逻辑
printf("[子进程] 开始尝试获取父进程留下的遗产...\n");
// 子进程直接调用 getenv 获取父进程在 fork 前设置的变量
char *val = getenv("MY_TEST_VAR");
if (val != NULL) {
printf("[子进程] 成功继承! 获取到的值为: %s\n", val);
} else {
printf("[子进程] 未找到该环境变量。\n");
}
exit(0);
}
else {
// 父进程等待子进程退出
wait(NULL);
}
return 0;
}
四、其他环境变量
4.1所有环境变量罗列
我给重点的加了红色(准确点说是:“常用”)
| 环境变量名 | 变量值 / 含义说明 |
|---|---|
| SHELL | /bin/bash (当前使用的 Shell 解析器) |
| HISTCONTROL | ignoredups (忽略连续重复的命令历史记录) |
| HISTSIZE | 1000 (历史命令保存的最大条数) |
| HOSTNAME | VM-0-9-opencloudos (主机名) |
| PWD | /home/zbc (当前工作目录) |
| LOGNAME | zbc (登录的用户名) |
| XDG_SESSION_TYPE | tty (当前会话类型为 TTY 终端) |
| MOTD_SHOWN | pam (已通过 PAM 模块展示过每日欢迎信息) |
| HOME | /home/zbc (当前用户的家目录) |
| LANG | en_US.UTF-8 (系统的语言与编码环境) |
| LS_COLORS | rs=0:di=01;34:ln=01;36:... (因长度过长,此处省略了具体的各种文件类型颜色配置文件代码) |
| SSH_CONNECTION | 223.104.72.236 58438 10.1.0.9 22 (SSH 连接的客户端 IP、端口及服务端 IP、端口) |
| XDG_SESSION_CLASS | user (会话类别为普通用户) |
| TERM | xterm (终端仿真器类型) |
| LESSOPEN | ` |
| USER | zbc (当前用户名) |
| GOPROXY | https://mirrors.tencent.com/go,direct (Go 语言的腾讯云代理源设置) |
| SHLVL | 1 (Shell 的嵌套层级) |
| XDG_SESSION_ID | 15098 (当前 XDG 会话 ID) |
| XDG_RUNTIME_DIR | /run/user/1001 (当前用户运行时的临时数据目录) |
| SSH_CLIENT | 223.104.72.236 58438 22 (SSH 客户端的 IP、端口和服务器端口) |
| which_declare | declare -f (内部变量,定义了 which 命令声明函数的方式) |
| PATH | /usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin (系统查找可执行命令的路径环境变量) |
| DBUS_SESSION_BUS_ADDRESS | unix:path=/run/user/1001/bus (D-Bus 系统会话总线地址) |
/var/spool/mail/zbc (当前用户的系统邮件信箱路径) |
|
| SSH_TTY | /dev/pts/0 (当前 SSH 会话所绑定的虚拟终端伪设备) |
| BASH_FUNC_which%% | () { ( alias; eval ${which_declare} ) | /usr/bin/which ... } (导出的 Bash 自定义 which 函数) |
| _ | /usr/bin/env (上一个执行的命令或当前拉起进程的工具路径) |
| OLDPWD | /home/student/project (上一次所在的工作目录路径,系统借此实现 cd - 运行时的快捷回跳) |
4.1.1USER和LOGNAME的区别
我用一种最省事的方式告诉你他们的区别:
USER是我当前用户是谁。LOGNAME是我登录的时候是谁。这两个一般都是一致的,但是有一种情况不一致:

4.1.2历史命令与HISTSIZE
bash可以记录我们的历史命令,于是我们就可以调用如下方式找到我们的历史命令。
- ctrl + r
- ! v
- 上下键。
- history
- history | wc -l 表示当前系统里保存的历史命令条数
我来一个一个演示一下:
4.2环境绑定与权限控制用法
在 Linux 环境编程中,我们可以通过获取进程的环境变量或用户身份标识,来实现特定用户的权限绑定。
4.2.1核心逻辑与安全防范
编写此类程序时,仅依赖 getenv("USER") 是不够安全的。原因如下:
- 环境变量可伪造:任何用户(尤其是
root)都可以在终端通过export USER=your_username临时修改该变量,从而绕过简单的字符串检查。 - UID 的唯一性:在 Linux 系统中,用户的 UID(User ID)是硬性绑定的,
root用户的 UID 永远为0。通过getuid()系统调用可以获取真实的当前用户 ID。
因此,最稳固的方案是双重验证:既要匹配 USER 环境变量,又要排除 root 的 UID(或者直接限定为你的专属 UID)。
4.2.2.1示例代码
以下是完整的 C++ 示例代码。请将代码中的 "your_username" 替换为你自己的 Linux 用户名。
#include <iostream>
#include <cstdlib> // 包含 getenv
#include <unistd.h> // 包含 getuid, geteuid
#include <string>
int main() {
// 1. 定义允许执行该程序的专属用户名
const std::string AUTHORIZED_USER = "your_username";
// 2. 获取当前环境变量中的 USER 值
const char* env_user = std::getenv("USER");
std::string current_user = env_user ? env_user : "";
// 3. 获取当前进程的实际用户 ID (UID)
uid_t current_uid = getuid();
// 4. 核心安全检查:
// - 检查环境变量 USER 是否匹配
// - 确保当前用户不是 root (root 的 UID 永远为 0)
if (current_user == AUTHORIZED_USER && current_uid != 0) {
// 正确的逻辑分支
std::cout << "[验证通过] 欢迎回来," << AUTHORIZED_USER << "!" << std::endl;
std::cout << "执行核心绝密逻辑... 正确答案是:42" << std::endl;
}
else {
// 其他人(包括 root)或环境变量被篡改时的分支
std::cout << "[错误] 权限不足:该程序已被锁定,仅限指定用户执行。" << std::endl;
// 故意输出误导性的错误信息或静默退出
return 1;
}
return 0;
}
4.2.3 基于自定义环境变量的控制逻辑
除了系统自带的环境变量外,我们还可以通过自定义环境变量来控制程序的行为(例如作为一种简单的软件激活凭证或 Debug 开关)。
4.2.3.1示例代码
这就是利用进程级的数据传递;来控制子进程的一些控制逻辑
#include <iostream>
#include <cstdlib> // 包含 getenv
#include <string>
int main() {
// 1. 获取自定义的 FLAG 环境变量
const char* env_flag = std::getenv("FLAG");
std::string flag_val = env_flag ? env_flag : "";
// 2. 控制逻辑:仅当 FLAG 显式设置为 "1" 时允许运行
if (flag_val == "1") {
std::cout << "[SUCCESS] FLAG 验证成功,程序开始正常运行!" << std::endl;
// 核心业务逻辑...
} else {
std::cout << "[ERROR] 缺失关键环境变量或参数不正确,程序终止。" << std::endl;
return 1;
}
return 0;
}
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)