【Linux 系统编程】命令行参数与环境变量
在终端输入一条命令后,Shell 不只是“找到程序并运行”这么简单。它先解析输入,生成命令行参数,再确定程序需要继承哪些环境变量,最后把这些信息交给将要运行的程序。
命令行参数回答“这一次要程序做什么”,环境变量描述“程序运行时所处的外部环境”。二者都由字符串组成,也都会在程序启动时进入进程,但用途和组织方式并不相同。
从终端输入到程序启动
先把整条主线串起来:
终端中的一行输入
↓
Shell 进行分词、引用处理和各种展开
↓
得到命令名及参数列表
↓
函数 / 内建命令 / PATH 路径搜索
↓
组织 argv 与环境表 envp
↓
加载目标程序
↓
程序通过 argc、argv、getenv() 等接口读取信息
这里有两组数据:
argv:本次调用显式传入的参数
envp:程序启动时得到的 name=value 环境项
例如,同一个程序每次可以接收不同的选项和文件名,这是命令行参数的作用;它还可能根据语言、主目录、搜索路径等外部设置调整行为,这些信息通常来自环境变量。
1. 命令行参数是怎样进入程序的
1.1 Shell 不是简单地按空格切割
把 Shell 理解成“按空格切字符串”只能解释最简单的情况。真正执行前,Bash 大致还会完成以下处理:
读取输入
↓
识别单词与操作符,同时遵守引号规则
↓
解析简单命令、管道等语法结构
↓
进行变量展开、命令替换、通配符展开等处理
↓
处理重定向,并从参数列表中移除重定向部分
↓
执行命令
因此,引号可以让带空格的内容保留为一个参数。例如,"two words" 在去掉外层引号后,仍然是一个完整参数,而不是两个参数。* 是否会被展开成多个文件名,也取决于引用方式和当前目录中的文件。
1.2 argc 与 argv
C 程序常用下面的入口形式接收命令行参数:
int main(int argc, char *argv[])
它们之间的关系是:
| 项目 | 含义 |
|---|---|
argc |
参数指针数组中有效元素的数量,包含 argv[0] |
argv[0] |
按约定保存调用程序时使用的名称 |
argv[1]~argv[argc - 1] |
真正传给程序的其余参数 |
argv[argc] |
空指针,用来标记数组结束 |
不要假设 argv[0] 一定是完整路径,也不要把它当成可信的身份信息。调用者可以控制传入的参数内容;程序在解析其余参数时,同样需要检查数量、格式和取值范围。
1.3 观察参数边界
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("argument_count = %d\n", argc - 1);
for (int i = 1; i < argc; ++i) {
printf("arg[%d] = <%s>\n", i, argv[i]);
}
printf("argv[argc] is %s\n", argv[argc] == NULL ? "NULL" : "not NULL");
return 0;
}
运行结果:
argument_count = 3
arg[1] = <hello>
arg[2] = <two words>
arg[3] = <42>
argv[argc] is NULL
结果中一共传入了三个业务参数,其中 two words 虽然包含空格,仍然完整地位于 argv[2]。这说明程序收到的是 Shell 处理后的参数数组,而不是终端里最初输入的原始文本。
2. 为什么有的程序要写路径,有的不用
输入命令名后,Bash 会先判断它是不是函数或内建命令。如果都不是,而且命令名中不含 /,才会依次到 PATH 指定的目录中查找可执行文件。
PATH 的值由若干目录组成,目录之间使用冒号分隔:
目录 1 : 目录 2 : 目录 3 : ...
整个过程可以写成:
命令名不含斜杠
↓
函数中是否存在?——是→执行函数
↓否
是否为内建命令?——是→执行内建命令
↓否
按 PATH 从左到右搜索可执行文件
↓
找到后执行;全部找不到则报错
而 ./程序名 中已经包含 /,它表达的是一个相对路径。此时 Shell 直接按这个路径尝试执行,不再使用 PATH 搜索。
2.1 为什么当前目录通常不放进 PATH
当前目录如果位于搜索路径前部,攻击者或误操作可能让一个同名程序抢在系统命令之前被执行。例如,当前目录中出现一个与常用命令同名的可执行文件,输入短命令名时就可能运行错误的目标。
所以,更稳妥的做法是:
- 当前目录中的程序明确使用相对路径;
- 自己长期使用的工具放入专门目录,再把该目录加入
PATH; - 不要为了省略路径,随意把程序复制到系统目录;
- 修改
PATH时保留原值,并明确目录的先后顺序。
常见查看与设置方式如下:
| 目的 | Bash 写法 |
|---|---|
| 查看当前搜索路径 | echo "$PATH" |
| 查看某个名称最终怎样解析 | type -a 名称 |
| 查询将要使用的位置 | command -v 名称 |
| 临时加入个人工具目录 | export PATH="$HOME/.local/bin:$PATH" |
这里的“临时”指只影响当前 Shell 以及它之后启动的子进程;关闭这个 Shell 后,其他会话不会凭空保留该修改。
3. 环境变量到底是什么
环境变量不是操作系统中所有进程共同读写的一组“真正全局变量”。更准确的说法是:每个进程都有自己的环境,创建新程序时,可以把一组环境字符串传给它。
环境项通常采用下面的格式:
名称=值
名称区分大小写,值本质上也是字符串。所谓整数、路径或开关,只是使用这个变量的程序对字符串作出的解释。
在 C 程序中,可以把环境表理解成一个以空指针结尾的字符指针数组:
环境表
│
├──► "PATH=/usr/local/bin:/usr/bin:/bin\0"
├──► "HOME=/home/student\0"
├──► "LANG=zh_CN.UTF-8\0"
├──► "COURSE_STAGE=process\0"
└──► NULL
这里要区分两层结束标记:
- 每个环境字符串以字符
\0结束; - 整个指针数组以空指针
NULL结束。
3.1 环境怎样交给新程序
从系统调用层面看,execve() 同时接收参数向量和环境向量:
int execve(const char *path, char *const argv[], char *const envp[]);
可以把它理解成:
path:加载哪个程序
argv:这次调用给它哪些参数
envp:新程序启动后处于什么环境
execve() 成功后,当前进程开始执行新的程序映像,并不是额外创建一个 PID。真正创建子进程通常由 fork() 完成;子进程随后再执行新程序,是 Shell 启动外部命令时常见的组合。
Shell 进程
│
├─ fork() ─► 子进程获得父进程环境的副本
│ ↓
│ execve()
│ ↓
│ 新程序获得 argv 与 envp
│
└────────── 父 Shell 继续等待或接收下一条命令
4. 程序怎样读取和修改环境
4.1 第三个 main 参数
在 Linux 等常见 Unix 环境中,经常能看到下面的入口形式:
int main(int argc, char *argv[], char *envp[])
envp 指向环境表,可以一直遍历到空指针。但是,main 的第三个参数并不是 ISO C 或 POSIX 规定的可移植入口形式。写通用代码时,读取单个变量优先使用标准接口 getenv();在 POSIX 环境下,也可以通过外部变量 environ 访问整张环境表。
4.2 常用接口
| 接口 | 作用 | 需要注意的地方 |
|---|---|---|
getenv(name) |
查找变量值 | 不存在时返回 NULL |
setenv(name, value, overwrite) |
新增或修改变量 | 会复制名称和值;成功返回 0 |
unsetenv(name) |
删除变量 | 只修改当前进程的环境 |
putenv(string) |
把 name=value 字符串放入环境 |
通常直接使用传入字符串,生命周期更容易出错 |
environ |
访问整个环境表 | 适合遍历,不适合把全部环境无条件写入日志 |
getenv() 返回的指针通常指向当前环境表内部。程序不应随意改写这段内存,而且后续调用 setenv()、unsetenv() 或 putenv() 后,之前保存的指针可能失效。
另外,环境变量由调用者提供,不能天然视为可信数据。程序应当像处理命令行参数一样检查它的格式和范围。
5. 环境继承不是共享写入
“环境变量可以被子进程继承”经常被简化成“环境变量是全局的”,但这两句话不是一回事。
父进程创建子进程时,子进程获得父进程环境的副本。此后父子进程位于不同的进程地址空间中,一方修改自己的环境,不会自动把另一方也改掉。
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static const char *read_stage(void)
{
const char *value = getenv("COURSE_STAGE");
return value == NULL ? "<not set>" : value;
}
int main(void)
{
setvbuf(stdout, NULL, _IONBF, 0);
if (setenv("COURSE_STAGE", "parent", 1) == -1) {
perror("setenv");
return 1;
}
printf("parent before fork: %s\n", read_stage());
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
}
if (pid == 0) {
printf("child inherited : %s\n", read_stage());
if (setenv("COURSE_STAGE", "child", 1) == -1) {
perror("setenv");
_exit(1);
}
printf("child changed : %s\n", read_stage());
_exit(0);
}
int status = 0;
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
return 1;
}
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "child process failed\n");
return 1;
}
printf("parent after child: %s\n", read_stage());
return 0;
}
运行结果:
parent before fork: parent
child inherited : parent
child changed : child
parent after child: parent
结果可以分三步看:
- 创建子进程前,父进程中的值是
parent; - 子进程最初能读到同样的值,说明继承确实发生了;
- 子进程改成
child后,父进程中的值仍然是parent,说明继承的是各自使用的环境,而不是父子进程共同操作同一份变量。
这也解释了一个常见现象:在子程序中修改环境,不能反向改变启动它的父 Shell。普通子进程没有“把变量写回父进程环境”的接口。
6. Shell 本地变量与环境变量
在 Bash 中执行赋值,并不等于这个变量已经能够被外部程序继承。
名称=值
↓
先成为当前 Shell 的参数/变量
↓ export
被标记为导出变量
↓ Shell 启动外部程序
进入该程序的环境表
二者的区别如下:
| 对比项 | Shell 本地变量 | 已导出的环境变量 |
|---|---|---|
| 当前 Shell 能否使用 | 能 | 能 |
| 新启动的外部程序能否继承 | 不能 | 能 |
| 是否一定存在于进程环境表 | 不一定 | 启动子程序时会加入 |
| 常见创建方式 | NAME=value |
export NAME=value |
常用操作可以归纳为:
COURSE_STAGE=local
export COURSE_STAGE
export COURSE_STAGE=process
unset COURSE_STAGE
- 第一行只在当前 Shell 中建立变量;
- 第二行给已有变量添加导出属性;
- 第三行完成赋值并导出;
- 第四行从当前 Shell 中删除变量,之后启动的程序也不会再继承它。
如果只想让一个命令临时看到某个环境值,可以把赋值放在简单命令前:
COURSE_STAGE=test 某个命令
这个赋值只进入该命令的执行环境,不会永久改写当前 Shell 中同名变量的值。
env 与 set 不完全相同
| 命令 | 主要用途 |
|---|---|
env |
查看将传给外部程序的环境项,也可以在临时环境中执行命令 |
printenv |
查看全部环境或查询指定环境变量 |
set |
Bash 中显示 Shell 变量、函数等更广泛的 Shell 状态 |
export |
把 Shell 变量标记为可传给子进程 |
unset |
删除 Shell 变量或函数 |
因此,set 的输出通常比 env 多。只在 set 中出现、没有导出属性的变量,不会自动进入外部程序的环境。
7. 常见环境变量及边界
| 变量 | 常见含义 | 容易忽略的边界 |
|---|---|---|
PATH |
外部命令的搜索目录 | 顺序有意义;不应随意加入不可信目录 |
HOME |
当前用户的主目录 | 程序不要自行拼接 /home/用户名 代替它 |
SHELL |
用户的登录 Shell 路径 | 不保证就是当前正在解释命令的 Shell |
USER、LOGNAME |
登录用户名信息 | 可被调用者修改,不能作为授权依据 |
LANG、LC_* |
语言、字符集和本地化规则 | LC_ALL 等变量之间存在覆盖关系 |
PWD |
Shell 维护的当前工作目录字符串 | 程序需要真实工作目录时可使用 getcwd() |
尤其要注意,getenv("USER") 只能读取一段环境字符串,不能证明进程实际以哪个用户身份运行。涉及权限和安全判断时,应依据真实用户 ID、有效用户 ID、组、能力及系统访问控制结果,而不是相信可修改的环境变量。
环境还可能包含令牌、代理配置、调试开关等敏感内容。随意打印整张环境表会把信息写进终端、日志或错误报告,因此调试时最好只读取确实需要的变量,并避免把秘密长期放入不受保护的环境中。
8. 环境设置为什么有时重开终端就没了
在交互式 Shell 中直接赋值或 export,改变的是当前 Shell 的状态。新终端通常会启动一个新的 Shell,它会根据自己的启动方式读取初始化文件,所以原先的临时修改不会自动出现。
以 Bash 为例,启动文件不能笼统地只记成一个 .bash_profile:
| Bash 启动方式 | 典型读取规则 |
|---|---|
| 交互式登录 Shell | 先读 /etc/profile,再从 ~/.bash_profile、~/.bash_login、~/.profile 中读取第一个存在且可读的文件 |
| 交互式非登录 Shell | 读取 ~/.bashrc |
| 非交互式 Bash | 如果设置了 BASH_ENV,读取它指向的文件 |
不少系统会让登录配置主动加载 .bashrc,但这是配置文件写出的联系,不代表 Bash 无条件同时读取所有文件。选择把变量写到哪里之前,应先确认自己使用的 Shell、它是否为登录 Shell,以及系统现有配置之间的调用关系。
修改配置文件后,新启动的对应 Shell 会按规则重新读取;当前 Shell 是否立即变化,则取决于是否主动加载了该配置。系统级配置还会影响更多用户,修改时要比个人配置更谨慎。
9. 几个容易混淆的问题
9.1 环境变量等于全局变量吗
不等于。它只是在进程创建关系中可以向后代传递,看起来具有较大的作用范围;每个进程仍然操作自己的环境。
9.2 子进程能修改父进程的环境吗
不能通过普通的 setenv()、putenv() 或 Shell 赋值直接做到。子进程只能修改自己的环境。如果确实需要把结果交还父进程,应使用退出状态、管道、文件、共享内存或套接字等进程间通信方式。
9.3 export 是把变量保存到系统里吗
不是。export 只是让当前 Shell 在启动子程序时把该变量放入子程序环境。是否在新会话中继续存在,要看启动文件等持久化配置。
9.4 PATH 中有程序,就一定能执行吗
不一定。还要考虑目录的搜索权限、文件执行权限、文件格式、解释器或动态加载器是否存在,以及挂载点是否禁止执行等条件。
9.5 环境变量能保存任意类型吗
环境表保存的是字符串。布尔值、数字、列表和路径都需要程序自行约定格式并完成解析。
9.6 envp、environ 和 getenv() 应该选哪个
- 只读取一个变量:优先使用
getenv(); - 需要遍历 POSIX 环境表:使用
environ; - 正在研究程序启动时收到的原始环境,且平台明确支持:可以观察
main的第三个参数; - 需要修改当前进程环境:使用
setenv()和unsetenv()通常比直接改指针更清晰。
10. 小结
命令行参数与环境变量都在程序启动时提供信息,但不要混为一谈:
命令行参数
└─ 描述本次调用要做什么
└─ 通过 argc / argv 读取
环境变量
└─ 描述程序运行时的外部设置
├─ 以 name=value 字符串组织
├─ 创建子进程、执行新程序时向下传递
└─ 通过 getenv() / environ 等接口读取
理解这部分最关键的不是记住几个命令,而是把边界分清:Shell 先解析输入再形成 argv;PATH 只负责特定情况下的命令搜索;环境属于各个进程;export 控制变量是否进入子程序环境;继承不等于父子进程共享写入。
更多推荐



所有评论(0)