1. 命令行参数:程序与用户之间的第一座桥
1.1 从位置参数聊起:那些"看不见的下标"
用了几年的 Linux 系统,命令行参数和环境变量应该是接触最多、却最容易被忽视的两个概念了。参数让程序学会听人话,环境变量让系统记住你是谁、想要什么。我刚入行的时候总觉得这俩东西太基础,不值得深究,直到后来写脚本、调服务、排查故障时一次次被它们"绊倒",才明白当初欠下的功课迟早要还。
先聊命令行参数里最朴素的一种:位置参数。你在终端敲ls -l /etc,这里的-l和/etc就是传给ls的两个参数。Shell 内部会按照先后顺序把它们编号成$1、$2……其中$0比较特殊,它保存的是脚本或命令本身的名字。我早期写脚本时犯过一个低级错误:想取脚本名当日志前缀,结果用了$1,日志全被写成了第一个参数的名字,排查了半天才反应过来。
Shell 对位置参数的数量限制和取值方式,是新手最容易踩坑的地方。超过$9必须写成${10}、${11},否则 Shell 会把$10解析成${1}0,也就是第一个参数后面跟个字符0。你要是在脚本里写echo $10,大概率输出的是一串奇怪的东西。这类问题在写批量处理多个文件的脚本时特别常见,我第一次遇到时还以为环境出了 bug。
另外两个高频技巧是shift和$#。shift每执行一次,位置参数就整体左移一位,原本的$2变成$1,这样就能循环消费所有参数。$#则是参数个数,配合if [ $# -lt 2 ]; then这样的判断,可以在脚本开头做入参校验。我习惯在每写一个脚本的头部都加上参数数量检查,宁可多写几行,也不让脚本在缺参数时"裸奔"出错。
1.2 选项参数:当短横线和双短横线开始较真
位置参数适合处理"按顺序给值"的场景,但实际使用中更多的是-h、--help、--version这类选项。它们的核心价值在于顺序无关:ls -l /etc和ls /etc -l效果一样,使用者的心智负担大大降低。很多工具甚至支持把短选项合并,比如tar -xzvf等价于-x -z -v -f,这在 Shell 里是约定俗成的惯例。
解析选项参数时,getopts是 Shell 内置命令,比手工case判断靠谱得多。它的用法有点像个迭代器:while getopts "ab:c" opt; do case $opt in ...,注意b:这个写法——冒号表示b后面必须跟一个参数值,否则会报错。这个细节我当年死活记不住,后来想通了:冒号就像一张嘴,它后面的位置是用来吞参数的。
还有一个容易忽视的设计问题:选项参数的顺序。很多人写脚本时没有约定参数顺序,导致调试时来回试错。我的做法是在脚本开头统一做一次"标准化解析",把所有选项值存进变量,后续逻辑只认这些变量,不再直接碰位置参数。这样哪怕用户把参数顺序打乱,脚本内部依然是稳定的。
有时候你会看到一个程序用双横线--来终结选项解析。比如rm -- -foo.txt就能正常删除一个名字以横线开头的文件,不然rm -foo.txt会被当成选项处理。这个小技巧救过我几次,在清理临时文件的时候特别有用。
2. 环境变量:系统里的"全局记忆库"
2.1 环境变量到底是个什么物件
如果你把命令行参数理解为"每次调用时临时告诉程序的信息",那环境变量就是"提前塞进进程环境里、随进程一直携带的默认配置"。它们像一张挂在墙上的便利贴:每个用户登录时,系统都会从配置文件里读出一张便利贴,复制一份贴在你启动的每一个程序上,程序需要时抬头看一眼就能拿到。
最典型的是PATH。你敲python就能进入解释器,不是因为 Shell 在每个目录里大海捞针地找,而是因为PATH里记录了可执行文件所在目录的搜索顺序。它的取值是用冒号分隔的一串路径,从前往后匹配,命中即止。我见过有人把自定义脚本目录追加到PATH末尾,结果被执行了同名的系统命令,改了才明白:顺序就是优先级,想覆盖系统命令就放前面,想兜底就放后面。
环境变量还有一层关键属性:继承性。父进程的环境变量会原封不动传给子进程,这既是便利也是隐患。举个例子,你在终端里export http_proxy=,然后运行一个 Python 爬虫,它就会自动走代理。可你要是用sudo提权,安全策略会把大部分环境变量掐掉,这也是很多人在sudo下跑程序发现网络异常的根源。
2.2 变量的一生:从定义到销毁
在 Shell 里定义一个变量很简单:MY_NAME="zhangsan"。但这样定义出来的只是"局部变量",只有当前 Shell 进程能看到,子进程根本不知道它的存在。想让子进程也能访问,必须用export把它导出成环境变量。我用一个特别土的类比来记:变量是"写在草稿纸上的字",export 是"把纸贴到墙上,"。
export可以配合赋值一步完成:export JAVA_HOME=/opt/jdk21。有些新手容易犯一个错误——在等号两边加空格,写成export JAVA_HOME = /opt/jdk21。Shell 不是编程语言,它不会智能忽略空白,这样写会被拆成三条命令,报一堆奇怪的错。我见过运维同事被这个问题困了半小时,所以现在每次在教程里都要刻意强调一遍。
要查看环境变量,用env或者printenv。env和printenv都能列出所有环境变量,但printenv PATH能直接打印某个变量,更省事。如果你想在一个临时环境里运行程序,而不污染当前 Shell,可以用env VAR=value command这种前缀写法,它只在命令运行时临时注入环境变量,命令结束就恢复原样。这套玩法在测试脚本时非常好用,不用每次export然后再unset。
销毁一个变量用unset。不过在脚本里我很少主动 unset,更多是直接覆盖成空值——因为 unset 之后变量完全消失,逻辑里一判断[ -z "$VAR" ]容易误判;而置空的话至少"这个变量存在"这个语义还在。这种细微差别在复杂脚本里会体现得很明显。
2.3 持久化:写在哪个文件里才算数
环境变量的生命周期在 Shell 退出后就结束了,想让配置长期有效,必须写进配置文件的"持久化存储"。Linux 下的加载链路大致是:系统级/etc/profile→ 用户级~/.bash_profile/~/.bashrc→ 每次打开终端时还会再读一遍~/.bashrc。这个链路最常见的问题是"改了不生效",很多人改完配置文件立刻运行echo $VAR,发现还是旧的,就以为改错了。
其实配置文件只会在"登录 Shell"启动时被读取,当前已经处于运行状态的 Shell 并不会自动感知文件变化。正确做法是source ~/.bashrc手动重新加载,或者直接开一个新终端。这里有个陷阱:~/.bash_profile和~/.bashrc的分工在不同发行版上略有差异,Ubuntu 登录 Shell 会读取~/.profile,CentOS 则偏好~/.bash_profile。我建议把"用户自定义环境变量"统一放~/.bashrc,兼容性最好,也最不容易被系统版本差异坑到。
系统级配置里,/etc/environment是个特殊存在:它不执行脚本,只是纯变量赋值。这意味着你可以在里面写PATH=...,但不能写条件判断。它最大的优势是登录前的登录界面阶段就生效,适合存放全局都要用的变量。不过我不推荐没事就去动它,改坏了影响所有用户,排查成本不低。
3. 实战:写一个支持参数和环境的部署脚本
3.1 需求场景:从手动发布到一键部署
理论知识说多了容易飘,我们直接上手一个具体场景:写一个 Java 应用的一键部署脚本。需求是支持选择环境(dev / prod)、指定 JAR 包路径、决定是否在部署后重启服务,同时要能从环境变量里读取数据库连接信息。这类脚本在真实工作中几乎是标配,也是检验你对命令行参数和环境变量理解深度的一块试金石。
我把脚本的功能拆成了三块:参数解析负责"用户说什么"(要部署哪个包、选哪个环境),环境变量负责"系统知道什么"(数据库地址、端口号),脚本逻辑负责把两者组合起来。这种拆分不是拍脑袋定的——参数是显式的、临时的,适合放跟本次操作强相关的信息;环境变量是隐式的、全局的,适合放环境和账号相关的默认配置。
3.2 用 getopts 把参数捻顺
先看参数解析部分。我定义了一套规则:
-e后面接环境名,取值为 dev 或 prod,默认 dev-j后面接 JAR 包路径,必填-r是开关,出现就表示部署后重启
getopts 的写法如下:
#!/bin/bash # deploy.sh - 简易 Java 应用部署脚本 ENV_NAME="dev" JAR_PATH="" RESTART_FLAG=0 usage() { echo "用法: $0 -e <dev|prod> -j <jar路径> [-r]" exit 1 } while getopts "e:j:r" opt; do case $opt in e) ENV_NAME="$OPTARG" ;; j) JAR_PATH="$OPTARG" ;; r) RESTART_FLAG=1 ;; ?) usage ;; esac done if [ -z "$JAR_PATH" ]; then echo "错误: 缺少 -j 参数" >&2 usage fi # 校验环境名合法性 if [ "$ENV_NAME" != "dev" ] && [ "$ENV_NAME" != "prod" ]; then echo "错误: 环境名必须是 dev 或 prod" >&2 exit 1 fi echo "部署参数确认: 环境=$ENV_NAME, JAR=$JAR_PATH, 重启=$RESTART_FLAG"注意getopts "e:j:r"字符串里字母后面的冒号,就是告诉解析器这个选项必须附带参数值。$OPTARG就是刚才那个选项的值,这是 getopts 的内置变量。万一用户传了未定义的选项,case 会落到?分支,我在这里直接打印用法并退出。
3.3 环境变量联动的配置设计
接下来看环境变量部分。我在脚本中为数据库配置定义了三个变量,并提供了默认值和强制校验两种策略:
# 数据库配置,优先读环境变量,允许直接传入 DB_HOST="${DB_HOST:-'localhost'}" DB_PORT="${DB_PORT:-'3306'}" DB_USER="${DB_USER:-'admin'}" if [ "$ENV_NAME" = "prod" ] && [ -z "$DB_PASSWORD" ]; then echo "错误: 生产环境必须设置 DB_PASSWORD 环境变量" >&2 exit 1 fi这里的${VAR:-default}语法是变量默认值的经典写法,冒号加短横线的组合表示"变量未设置或为空时,用后面的默认值"。我特意在生产环境加了一道强校验:如果DB_PASSWORD没设,直接拒绝部署。真实生产环境里数据库密码这种敏感信息不适合写进脚本或者命令行参数——命令行会被 shell 历史记录、ps命令等暴露,用环境变量注入要安全得多。
连实际部署动作一起看:
JAVA_CMD="java" EXTRA_OPTS="" if [ "$ENV_NAME" = "prod" ]; then EXTRA_OPTS="-Xms512m -Xmx1024m" fi echo "开始部署: 使用 JAR $JAR_PATH,环境 $ENV_NAME" nohup $JAVA_CMD $EXTRA_OPTS -jar "$JAR_PATH" \ --server.port="$PORT" \ --spring.datasource.url="jdbc:mysql://$DB_HOST:$DB_PORT/appdb" \ > "/var/log/app_$ENV_NAME.log" 2>&1 & if [ "$RESTART_FLAG" = "1" ]; then sleep 3 PID=$(pgrep -f "$JAR_PATH" | head -1) echo "应用已启动,PID: $PID" fi这里把环境变量拼进 Spring Boot 的命令行参数,实现了脚本的"二次参数化":外层参数决定环境,环境决定内存和端口,数据库连接则完全由环境变量控制。用的时候只要提前 export 一组配置,脚本就能在不同环境下复用,不用改一行代码。
我把这个脚本放到生产服务器上跑通后,又顺手扩展了一个功能:支持从.env文件加载环境变量。实现方式是set -a; source .env; set +a,set -a会让所有被 source 的赋值自动带 export 属性,这样.env文件里每行KEY=VALUE都能变成真正的环境变量。注意.env文件里不能写export关键字,否则 source 后变量名会变成export,这是新手常踩的坑。
4. C 语言视角:操作系统到底做了什么
4.1 main 函数里藏着的第三个参数
如果你只写 Shell 脚本,可能永远不会意识到命令行参数和环境变量在操作系统层面是怎么运作的。但理解了 C 语言的视角,回头看 Shell 的一切都会豁然开朗。我们天天写的main函数,标准原型其实是int main(int argc, char *argv[], char *envp[])——第三个参数envp就是环境变量的指针数组,每个元素形如KEY=VALUE。
也就是说,当你执行一个程序时,内核会把命令行参数和环境变量都放在进程的初始栈上,然后调用execve传递给新程序。这不是什么"魔法",而是一种约定俗成的运行时布局。我早年在学习时自己写了个小 C 程序,把envp打印了一遍,瞬间理解了为什么"父进程的环境变量能被子进程继承"——本质就是把那一段内存数据复制过去了而已。
在 C 程序里读环境变量更常用的函数是getenv(),它可以按名字精确取值:
#include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { const char *home = getenv("HOME"); if (home == NULL) { fprintf(stderr, "HOME 未设置\n"); return 1; } printf("用户目录: %s\n", home); for (int i = 0; i < argc; i++) { printf("参数 %d: %s\n", i, argv[i]); } return 0; }这里有个细节:getenv在变量不存在时返回 NULL,使用时一定要判空,直接 printf%s传 NULL 在 glibc 上会打印(null),但在某些严格实现下可能直接崩溃。安全编程的底线就是不信任任何外部输入。
4.2 环境变量的修改变量与进程间隔离
用setenv()可以设置或修改环境变量,用unsetenv()可以删除。这些修改只对当前进程及其后续创建的子进程生效,不会反向影响父进程。很多人问过我:在父进程设置了环境变量,子进程改一下,为什么父进程看不到?这就是"写入时复制"和"进程隔离"机制决定的,子进程拿到的是父进程环境的一份拷贝,二者已经各走各路。
想让孩子进程真正影响父进程环境,唯一的办法是通过文件、管道等 IPC 机制传递,或者用eval这样的"输出后回源"技巧。Shell 脚本里常见的source file之所以能让当前 Shell 获得新变量,本质上是在当前进程内执行文件内容,而不是启动子进程去修改环境。搞清楚了这一点,很多"环境变量不生效"的谜团都有了解答:新开终端为什么读得到配置,而写脚本里的 export 出了函数就丢,都是"进程边界"作祟。
我建议有条件的读者亲手写一个 20 行以内的 C 程序,用fork创建子进程,在子进程里 setenv 再 getenv,看看差异。这个实验比看十篇博客都直观。
5. 常见问题与排查技巧实录
5.1 PATH 配置错误导致"命令找不到"
这是环境变量领域的第一大坑。现象是输入一个明明已经安装的命令,却提示command not found。排查第一步是确认安装路径:which java如果没有任何输出,说明 PATH 里没有包含 Java 的 bin 目录。第二步检查 PATH:echo $PATH看看目录之间是不是用冒号分隔、末尾有没有多余空格。我见过最离谱的情况是在配置 PATH 时后面多了一个空格,结果系统根本认不出这个路径。
给一个典型配置示例:
export JAVA_HOME=/opt/jdk21 export PATH=$JAVA_HOME/bin:$PATH注意一个顺序:把$JAVA_HOME/bin放在$PATH前面,这样 JDK 的可执行文件优先被搜索。如果你把它放后面,而系统里又存在旧版本,可能莫名其妙跑到旧版上去。
注意:
export PATH=$JAVA_HOME/bin:$PATH里的$PATH必须带上,不然你会把原来的 PATH 整个覆盖掉,后果是 ls、cp 这些基础命令全部找不到了,还要靠绝对路径/usr/bin/ls自救。
5.2 环境变量改了却不生效
这类问题几乎都是"没有重新加载配置"或"加载了错误的配置文件"。规则其实只有三条:改完必须source;新开的终端必然加载最新配置;如果还没生效,检查你是不是同时配置了多个文件,后加载的文件把先加载的值覆盖了。
我排查时会先用env | grep看看当前 Shell 里变量到底是什么值,然后type 命令名看解析到的路径指向哪里。如果变量值已经正确但程序还是读不到,就要怀疑是程序和预期的不一致:比如你用systemctl启动服务,它读的是系统级环境变量,跟你在终端里 export 的是两条线。
Systemd 的环境变量加载逻辑尤其容易坑人。它不像 Shell 那样去读.bashrc,而是要用EnvironmentFile指令从文件中加载。这是很多人写 systemd service 时"明明设置了环境变量却拿不到"的根源。正确示例:
[Service] Environment=JAVA_HOME=/opt/jdk21 EnvironmentFile=-/etc/default/app ExecStart=/opt/jdk21/bin/java -jar /opt/app/app.jarEnvironment=单行设置一个变量,EnvironmentFile读取整个文件,-前缀表示文件不存在时忽略错误而不报错。这两者在 systemd 的 man page 里讲得很清楚,但新手很少会去翻,踩坑后再回头查才恍然大悟。
5.3 参数里带空格和通配符的坑
命令行参数因为要用空格分隔,天然对"包含空格的内容"不友好。比如你想传给脚本一个文件路径/home/my files/data.txt,如果不加引号,Shell 会把它拆成两个参数。解决办法很简单:永远给包含空格的参数加双引号。
./myscript.sh "/home/my files/data.txt"注意区分两种"引用"写法:双引号会执行$VAR展开和反引号替换,单引号则是完全的"所见即所得"。写脚本时无脑用双引号基本不会出错,但如果你是拼接命令字符串,就要格外小心参数里万一有恶意内容,可能导致命令注入。
还有一个更隐蔽的坑:通配符。*.log在传递参数时会被 Shell 展开成当前目录下所有匹配的文件名。如果你希望原样传*.log给程序处理,就得用引号包住,或者在参数里转义。这个问题的典型案例是find . -name "*.log",find的-name参数接受的是匹配模式,不加引号的话 Shell 会先把*.log展开成一大堆真实文件名,find反而找不到任何东西。
我整理的常见问题速查表,方便大家直接对照:
| 问题现象 | 根本原因 | 最直接的解决方式 |
|---|---|---|
command not found但命令已安装 | PATH 未包含可执行目录 | 检查并修正 PATH,建议使用绝对路径临时测试 |
修改.bashrc后无变化 | 当前 Shell 未重新加载配置 | 执行source ~/.bashrc或新开终端 |
脚本里$10输出异常 | Shell 将$10解析为${1}0 | 改用${10}写法 |
| systemd 服务读不到环境变量 | systemd 不读.bashrc | 用EnvironmentFile=加载配置 |
| 参数为空格文件路径被拆分 | 引号未使用 | 给路径加双引号 |
| 通配符被展开而非原样传递 | Shell 先做了文件名扩展 | 用引号或转义符包裹 |
| sudo 命令下环境变量丢失 | sudo 默认重置环境变量 | 使用sudo env VAR=value显式注入 |
还有一个非常实用的小排查技巧:strace -f -e execve CMD一下,能直接看到程序实际收到的环境变量和参数。这比用echo $VAR一层层猜要快很多,尤其适合排查复杂的服务启动场景。我有一次在生产环境排查问题时用 strace 发现程序发生了两次 execve,参数在中间被重置了,原因是有个 wrapper 脚本搞的鬼——这种问题不看系统调用,靠肉眼真的很难定位。
5.4 一个绕不开的细节:环境变量名的大小写
环境变量名约定俗成使用大写字母,但不是强制要求。Linux 系统上你甚至可以设置小写的abc=1,然后echo $abc正常取出。不过我不建议这么做,因为惯例的力量很强大,大写变量名一来便于识别"这是环境变量",二来避免和 shell 内部的局部变量命名冲突。
这里有个反直觉的点:Windows 的环境变量不区分大小写,而 Linux 严格区分。Path和PATH在 Linux 完全是两个变量。如果你把配置从 Windows 迁移过来,很可能在 Linux 上踩这个坑——配置写的是Path,而系统读取的是PATH,静默失败。
JAVA_HOME 是另一个典型例子。Maven、Gradle 等工具约定认JAVA_HOME,你要是手滑写成JAVA_home,启动mvn时会收到一个含糊的错误警告,折腾一段时间才发现只是大小写问题。这类问题没有更好的办法,只能靠细心和习惯:配置环境变量之前,先查一下目标工具官方文档里写的字段名是什么,不要凭感觉猜测。
最后再分享一个我自己的小习惯。每次配置完环境变量,我都会开一个干净的终端跑一遍env | sort,把关键变量从头到尾过一眼,然后再执行printenv | wc -l看看数量是否异常。这两个命令不会花超过三十秒,但能过滤掉 80% 以上的低级错误。命令行参数和环境变量这对搭档虽然基础,却贯穿了日常开发和运维的每一处细节,值得花点时间把它们彻底理清楚。