
手里拿到一个Linux问题最优先要搞清楚的往往不是代码逻辑本身而是“这个进程是怎么被启动的、启动时带了什么、它凭什么知道自己该怎么跑”。干了这么多年运维和开发我越来越觉得命令行参数和环境变量就是进程启动时刻的两份关键资料一份告诉你用户想拿你做什么一份告诉你周围的世界长什么样。这一篇是Linux进程系列的第五篇专门把这两块掰开揉碎讲清楚既包含surface层面的使用技巧也包含底层的传递机制适合刚接触Linux的初学者也给写过不少脚本但没深究过参数传递机制的老手补上几块拼图。1. 命令行参数进程拿到的第一份任务书1.1 argc和argv到底是怎么来的很多人第一次写C程序就知道int main(int argc, char *argv[])但未必清楚这俩东西是怎么被填进来的。当你在终端敲下一条命令比如ls -la /tmpshell并不是拿这串字符直接搓成程序执行它会先做词法解析切成一个个“单词”然后调用fork()复制出子进程再在子进程里调用exec系列函数去加载真正的ls程序。关键点在于exec系列系统调用会把一个“指向字符串数组的指针”传递给新程序的内核入口这个数组的每个元素就是一个参数数组最后固定放一个NULL指针做结束标记。新程序拿到这个数组之后由C运行时把它折算成argcargument count参数个数和argvargument vector参数向量再交给main函数。写段最朴素的代码验证一下#include stdio.h int main(int argc, char *argv[]) { printf(argc %d\n, argc); for (int i 0; i argc; i) { if (i argc) printf(argv[%d] NULL\n, i); else printf(argv[%d] %s\n, i, argv[i]); } return 0; }编译运行./demo -a hello world 5输出大概是这样的argc 4 argv[0] ./demo argv[1] -a argv[2] hello world argv[3] 5 argv[4] NULL注意三点第一argv[0]通常是可执行文件的路径严格来说它也算一个参数所以argc是“命令参数个数1”第二argv[argc]一定是NULL这是C标准写死的约定因此遍历时可以放心循环到小于argc也可以逐个指针遍历直到碰到NULL第三argv[2]虽然中间有空格但它是一个整体因为shell已经用引号帮我们合并了。1.2 传参之前shell悄悄做了哪些加工如果你以为shell只是简单按空格切开字符串那就错了。ls /tmp/*.c里的*.c、echo $HOME里的$HOME、command $(date)里的命令替换这些都发生在shell内部发生在fork之前。shell会先把通配符展开成多个文件名把变量替换成它的值把命令替换的结果塞进参数位置然后再把最终整理好的数组传给exec。这个机制带来一个很实用的推论程序里拿到的参数是“已经加工过”的最终字符串它不会再做通配符展开和变量展开。你写一个脚本想遍历所有cpp文件直接写for f in $拿到的是shell展开完毕后的干净文件名列表不需要自己在程序里再去glob。实际项目里常遇到的坑是引号。./demo hello world和./demo hello world是两码事。前者是一个参数hello world后者是两个参数hello和world。调试的时候如果发现某个程序把带空格的路径拆碎了十有八九是调用方引号没写对而不是程序自身的问题。1.3 参数解析手写循环到getopt的演进接手过一些内部小工具最常见的参数解析写法就是手写循环。比如支持一个-o filename选项#include stdio.h #include string.h int main(int argc, char *argv[]) { char *output NULL; for (int i 1; i argc; i) { if (strcmp(argv[i], -o) 0 i 1 argc) { output argv[i]; } } if (output) printf(output file: %s\n, output); else printf(no output file\n); return 0; }这种写法应付三五个选项没问题但一旦选项增多长短选项混合--outputfile这种格式也要支持手写就会变得焦头烂额。Linux上更好的选择是用GNU的getopt_long它专门处理各种参数形式包括短选项合并、长选项匹配、可选参数等。复杂命令行工具基本都走这条路没必要自己造轮子。2. 环境变量进程手里的一张动态配置表2.1 环境变量就是一种全局配置字符串环境变量的抽象理解是“一系列KEYVALUE形式的字符串”存进程自己的地址空间里。每个进程都有一份独立的环境表内容默认从父进程那里继承。这个表在进程里的位置比较特殊通常在栈和堆之间的某个固定区域是程序装载时由内核初始化好的。查看环境变量最直观的方式是命令行env printenv echo $PATH还有一个很硬核的查看方式任何进程都有对应的/proc/pid/environ伪文件直接把它读出来就能看到那个进程当前环境变量的原始内容。因为环境变量之间用\0分隔所以通常要用tr把空字符换成换行cat /proc/self/environ | tr \0 \n运行这条命令你会看到当前shell环境的一份“快照”。这个方法在排查线上问题时特别有用后面我会专门展开讲。环境变量和程序内部的全局变量有本质区别。全局变量是你写死的、程序内共享的环境变量是外部世界通过进程启动机制塞进来的你不写代码去getenv程序默认看不到它但它确实存在于进程的地址空间里。更通俗地说环境变量是“进程出生时身上挂着的名牌和地图”由父进程一股脑发下来。2.2 环境变量的继承链条环境变量不是凭空产生的。你登录系统后login程序会读取系统配置文件设置初始环境之后启动的每一个进程都会从父进程那里原样复制一份环境表。shell里执行export FOObar再启动一个子命令这个子命令的FOO就是bar。所以这里有一条很重要的规律你改环境变量只会影响当前shell以及它后续启动的子进程绝不会反向影响父亲shell也不会影响已经跑起来的程序。很多人以为在终端里setenv之后其他终端、其他进程就能立刻感知这完全是误解。用一个小C程序验证继承也很简单#include stdio.h #include stdlib.h #include unistd.h int main() { printf(before: PATH%s\n, getenv(PATH)); if (fork() 0) { printf(child : PATH%s\n, getenv(PATH)); } return 0; }子进程打印出的PATH和父进程完全相同因为fork时子进程复制了父进程的整个地址空间环境表作为其中一部分也被一并复制。2.3 在代码里读写环境变量C标准库提供了一组操作环境变量的函数getenv(const char *name)按名字取值找不到返回NULL。setenv(const char *name, const char *value, int overwrite)设置或覆盖环境变量第三个参数控制是否覆盖已有值。putenv(char *string)直接传入KEYVALUE字符串方式更原始。unsetenv(const char *name)删除变量。还有一个传统的全局变量extern char **environ它指向环境表指针数组和main函数的第三个参数envp指向同一个东西。C标准允许把main写成int main(int argc, char **argv, char **envp)这样第三个参数就是环境表。但更推荐用environ全局变量因为它在函数里也能访问。#include stdio.h #include stdlib.h extern char **environ; int main() { setenv(MY_VAR, hello, 1); printf(MY_VAR%s\n, getenv(MY_VAR)); for (char **env environ; *env ! NULL; env) printf(%s\n, *env); return 0; }每次setenv之后环境表的指针可能会变化因为库内部可能重新分配了更大的数组。这也是多线程程序中setenv不够安全的根本原因后面会提到。3. 动手写一个“进程环境探针”3.1 完整打印参数和环境变量把这一篇的知识串起来写一个能完整展示命令行参数和环境变量的程序以后排查问题时可以直接丢到服务器上用。#include stdio.h extern char **environ; int main(int argc, char *argv[]) { printf( argv \n); for (int i 0; i argc; i) printf(argv[%d]: %s\n, i, argv[i]); printf( environ \n); int index 0; for (char **env environ; *env ! NULL; env) { printf(env[%d]: %s\n, index, *env); index; } printf( key info \n); printf(argc%d, env size%d\n, argc, index); return 0; }编译后运行./probe -k v1 -k v2你会看到环境和参数被清清楚楚打成两张表。这类程序很适合用来验证其他程序或者脚本到底有没有正确传递某个环境变量。比如启动一个服务时担心JAVA_HOME没生效临时改启动命令、让它先打印环境比打日志快得多。3.2 用execve亲手构造参数和环境表系统调用execve是真正的底层入口原型是int execve(const char *pathname, char *const argv[], char *const envp[]);execvp、execl这些库函数最终都会走到它。现在我们把参数和环境表都自己组装看看它们是怎么被吃进去的#include stdio.h #include unistd.h int main() { char *args[] { say-hello, hello, from execve, NULL }; char *envs[] { MY_CUSTOM_ENVcustom_value, ANOTHER1, NULL }; printf(before execve\n); execve(/bin/echo, args, envs); perror(execve failed); return 1; }运行后你会看到echo把所有参数打印出来但根本不会看到环境变量因为/bin/echo不打印环境。如果把/bin/echo换成我们刚写的./probe就能看到参数、环境表全部变成了我们指定的内容。这里有个容易误解的点exec系列在替换进程镜像时新程序默认会继承当前进程的环境表但execve允许你传入完全自定义的envp相当于“重新发放身份证”。Shell的env命令就是基于这个能力实现的比如env -i ./probe会清空所有环境变量后再启动程序这对做复现测试非常有效。3.3 用env命令做环境微操作Linux上我们很少直接写C去构造环境通常用env命令就够了env -i ./probe # 清空一切环境变量后执行 env -u HTTP_PROXY ./probe # 删除某个环境变量后执行 env MY_VARtest ./probe # 临时增加一个环境变量后执行注意env MY_VARtest ./probe并不会永久修改你的shell环境它只是在exec之前把环境表改成一个副本原shell不受影响。掌握了这个原理就能理解为什么很多人说“在命令前加变量赋值命令结束后变量就消失了”。所以测试时想用某个临时环境变量用这个方式最干净不用改配置文件、不用export也不用事后还原。4. 环境变量配置与运维排障4.1 bashrc、profile、environment到底应该改哪个这是Linux使用中出现频率最高的问题之一。改环境变量时候发现有时生效、有时不生效根源在于shell的加载流程有好几条分支。/etc/environment系统级环境变量配置文件登录时被PAM读取适合设置全局的、与shell无关的变量不支持赋值中的变量引用和命令替换。/etc/profile系统级登录shell配置Bourne兼容shell登录时执行。/etc/bash.bashrc系统级交互bash配置。~/.profile用户级登录shell配置。~/.bashrc用户级交互bash配置最常用的个人环境变量出口。简单记如果你ssh登录一个新会话登录shell会读/etc/profile和~/.profile如果这个会话里再开一个交互子shell才会读~/.bashrc。大部分终端软件新开的窗口其实都是交互shell所以很多教程让你改~/.bashrc这是合理的。但如果你希望变量对图形界面启动的进程也生效通常得放到/etc/environment或~/.profile这取决于发行版和桌面环境的会话管理器。4.2 改完环境变量为什么不生效排查这类问题我一般按顺序问三件事改完之后有没有source编辑~/.bashrc后当前终端里的环境并不会自动更新必须执行source ~/.bashrc或重新打开终端。是不是改错文件在zsh用户里改了~/.bashrc新开的zsh根本不会读它。先echo $SHELL确认自己用的什么shell。是不是export写错了只写FOObar而没加export变量只存在于shell本身不会传递给子进程env | grep FOO什么都看不到。还有一个很低级的坑配置文件中存在隐藏字符或者头尾空格。从网页复制粘贴配置经常带来不可见字符导致变量名变成PATH或者JAVA_HOME这种带空格的畸形键。用set -x或者grep看半天都看不出问题最后把配置重敲一遍就好了。4.3 PATH被配坏之后的急救操作老手也会翻车。一次我把/usr/bin从PATH里漏了结果保存完配置、source完一看ls都找不到了。这时候别慌系统命令还在磁盘上。用绝对路径/bin/ls、/usr/bin/cat、/usr/bin/vi。直接恢复一个基本PATHexport PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin。如果连export都打不出来……因为export通常是bash内建命令shell本身还在一般还能用。最保险的恢复方式是让进程环境重置退出终端重新登录或者用干净shell启动env -i /bin/bash先回到一个可控环境。为了避免再翻这种车建议改PATH前先备份export PATH_BEFORE$PATH改坏了还能export PATH$PATH_BEFORE新手尤其推荐。5. 一些真正值得知道的进阶细节5.1 用/proc/pid/environ排查线上进程线上排查Java进程的JAVA_HOME很多时候用户已经启动了很多服务不方便重启一条命令把环境变量打出来。这时候/proc/pid/environ就是神器。# 找到进程号 pgrep -f java.*myapp # 查看该进程的环境变量 cat /proc/1234/environ | tr \0 \n | grep JAVA只要你是同用户或者root就能看到这个进程属于“出生那一刻”的环境表。注意这个文件在程序运行期间是基本不变的即使进程内部用setenv修改了环境也未必会同步到这个特殊文件具体和内核版本相关。所以它的主要价值是回答“这个进程当初是用什么环境拉起来的”。5.2 环境变量是最好的攻击面也是最容易忽略的防线环境变量里经常藏着安全问题。最常见的是LD_PRELOAD它允许你给进程强制加载共享库。如果攻击者能控制你进程的LD_PRELOAD就可以注入自己的代码、截获函数调用。所以Linux在加载setuid程序、或者某些安全敏感场景下会主动忽略或清理部分危险环境变量。另一个容易踩的是PATH劫持。有人当前目录下放一个和系统命令同名的可执行文件配置里又让PATH从当前目录开始一执行就中招。我习惯在脚本里用绝对路径调用关键外部命令或者在必要场景下显式固定PATH而不是依赖外部环境。给root用户写定时任务时更要小心环境变量一定要白名单式地写清楚。5.3 环境变量的大小限制和多线程风险Linux下环境变量并不是无限大的。单个参数或者单个环境变量字符串的长度有上限正常内核下大约是128KB左右整个参数列表加环境表的总大小也有限制。如果设置了一个非常大的环境变量导致execve无法容纳调用会直接失败并返回E2BIG错误。我踩过一次把一整个构建产物内容塞进环境变量传给子进程结果日志报“Argument list too long”。解决方案很简单改用文件传递大规模数据。多线程环境下setenv和unsetenv也要谨慎。因为环境表本质是一个动态增长的字符串数组这些函数在修改时可能realloc整个数组导致别的线程正在使用的environ指针指向旧内存。开发多线程C/C程序时要么在初始化阶段单线程时把需要设置的环境一次性设置完要么干脆用execve替换整个环境表远端配置避免运行期改环境。6. 我自己的排查心得最后分享一点个人经验。我排查问题时最常用的一招就是“给进程做体检”先看/proc/pid/cmdline拿到这个进程的完整命令行参数再看/proc/pid/environ拿到它的环境表两者一结合判断它被启动时到底拿了哪份配置。曾经有个服务起不来看日志一直提示找不到某个路径我第一反应是检查PATH果然启动脚本里漏了必要的路径环境表里根本没有那一项。这类问题如果只盯着应用日志可能翻半天代码也找不到原因。另一个很实用的小技巧是手写的启动脚本里我都会加一行env | sort /tmp/xxx_env.log把关键服务启动时的环境快照留档。遇到“昨天还好好的今天坏了”这种问题翻一下这个快照立刻就知道环境变量被谁改了。环境变量这东西平时没人注意真要出问题却是全局性的。理解了它和命令行参数的传递机制你排查Linux进程问题的底气会完全不一样。