在终端输入一条命令后,Shell 不只是“找到程序并运行”这么简单。它先解析输入,生成命令行参数,再确定程序需要继承哪些环境变量,最后把这些信息交给将要运行的程序。

命令行参数回答“这一次要程序做什么”,环境变量描述“程序运行时所处的外部环境”。二者都由字符串组成,也都会在程序启动时进入进程,但用途和组织方式并不相同。


从终端输入到程序启动

先把整条主线串起来:

终端中的一行输入
        ↓
Shell 进行分词、引用处理和各种展开
        ↓
得到命令名及参数列表
        ↓
函数 / 内建命令 / PATH 路径搜索
        ↓
组织 argv 与环境表 envp
        ↓
加载目标程序
        ↓
程序通过 argc、argv、getenv() 等接口读取信息

这里有两组数据:

argv:本次调用显式传入的参数
envp:程序启动时得到的 name=value 环境项

例如,同一个程序每次可以接收不同的选项和文件名,这是命令行参数的作用;它还可能根据语言、主目录、搜索路径等外部设置调整行为,这些信息通常来自环境变量。


1. 命令行参数是怎样进入程序的

1.1 Shell 不是简单地按空格切割

把 Shell 理解成“按空格切字符串”只能解释最简单的情况。真正执行前,Bash 大致还会完成以下处理:

读取输入
  ↓
识别单词与操作符,同时遵守引号规则
  ↓
解析简单命令、管道等语法结构
  ↓
进行变量展开、命令替换、通配符展开等处理
  ↓
处理重定向,并从参数列表中移除重定向部分
  ↓
执行命令

因此,引号可以让带空格的内容保留为一个参数。例如,"two words" 在去掉外层引号后,仍然是一个完整参数,而不是两个参数。* 是否会被展开成多个文件名,也取决于引用方式和当前目录中的文件。

1.2 argcargv

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

结果可以分三步看:

  1. 创建子进程前,父进程中的值是 parent
  2. 子进程最初能读到同样的值,说明继承确实发生了;
  3. 子进程改成 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 中同名变量的值。

envset 不完全相同

命令 主要用途
env 查看将传给外部程序的环境项,也可以在临时环境中执行命令
printenv 查看全部环境或查询指定环境变量
set Bash 中显示 Shell 变量、函数等更广泛的 Shell 状态
export 把 Shell 变量标记为可传给子进程
unset 删除 Shell 变量或函数

因此,set 的输出通常比 env 多。只在 set 中出现、没有导出属性的变量,不会自动进入外部程序的环境。


7. 常见环境变量及边界

变量 常见含义 容易忽略的边界
PATH 外部命令的搜索目录 顺序有意义;不应随意加入不可信目录
HOME 当前用户的主目录 程序不要自行拼接 /home/用户名 代替它
SHELL 用户的登录 Shell 路径 不保证就是当前正在解释命令的 Shell
USERLOGNAME 登录用户名信息 可被调用者修改,不能作为授权依据
LANGLC_* 语言、字符集和本地化规则 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 envpenvirongetenv() 应该选哪个

  • 只读取一个变量:优先使用 getenv()
  • 需要遍历 POSIX 环境表:使用 environ
  • 正在研究程序启动时收到的原始环境,且平台明确支持:可以观察 main 的第三个参数;
  • 需要修改当前进程环境:使用 setenv()unsetenv() 通常比直接改指针更清晰。

10. 小结

命令行参数与环境变量都在程序启动时提供信息,但不要混为一谈:

命令行参数
  └─ 描述本次调用要做什么
     └─ 通过 argc / argv 读取

环境变量
  └─ 描述程序运行时的外部设置
     ├─ 以 name=value 字符串组织
     ├─ 创建子进程、执行新程序时向下传递
     └─ 通过 getenv() / environ 等接口读取

理解这部分最关键的不是记住几个命令,而是把边界分清:Shell 先解析输入再形成 argvPATH 只负责特定情况下的命令搜索;环境属于各个进程;export 控制变量是否进入子程序环境;继承不等于父子进程共享写入。


Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐