
今天整理笔记翻到2.15这一天正好是Linux命令行参数、环境变量、程序地址空间三节内容。说实话刚学Linux那会儿我完全不觉得这三样东西有太多关联命令行参数不就是main函数里那两个参数吗环境变量不就是export出来的配置吗程序地址空间更是感觉像天书。等到真正动手写C程序被各种“奇怪行为”卡住之后才明白把它们连起来理解整个进程的启动和运行瞬间就通了。这篇笔记适合正在学Linux系统编程的人、写C/C程序时被指针和栈/堆搞晕的人以及想搞明白“一个程序从敲下回车到main函数执行中间到底发生了什么”的人。我会把命令行参数、环境变量、程序地址空间放在一起讲因为它们在进程启动时是一套完整的故事内核加载程序后在进程地址空间里搭好初始栈把命令行参数和环境变量放到栈顶然后才跳进程序入口。先懂了这个链条后面看任何开源项目的入口代码都不会发怵。1. 为什么这三块内容要放在一起学单独看每一个概念感觉都不算难。命令行参数就是argc和argv环境变量就是那一堆KEYVALUE地址空间就是内存分布图。但一旦把它们分开学你很容易产生一种割裂感为什么程序里能拿到用户输入为什么子进程会继承父进程的环境变量为什么打印指针地址时有的地址看起来很大有的很小这些问题其实是同一条知识链上的不同环节。我建议把这三块当成“程序启动三部曲”来理解。第一步shell解析你敲的命令把命令名和参数整理好第二步shell把当前导出的环境变量打包第三步内核加载可执行文件在新建的地址空间中把这些数据布置好然后启动程序。这三步做得怎么样直接决定一个进程的“初始状态”。1.1 命令行参数程序启动时的“第一手输入”命令行参数是用户与程序之间最直接的交互方式。你在终端里输入./demo hello worldshell会把hello和world当作两个独立的字符串传给demo这个程序。在C语言里它们最终会以char *argv[]的形式出现在main函数的参数列表里。但这里有一个新手容易忽略的点argv[0]并不是第一个“真正的参数”而是程序本身的名字。所以一个正常的进程argc至少是1。如果你执行./demo看到的argc是1而不是0执行./demo a b cargc才是4。理解了这一点再看shell的命令解析就会很自然shell并不负责告诉你“哪个参数是选项、哪个参数是输入文件”它只是把所有以空格分隔的字符串原样传给你。至于怎么解析是你程序内部的事。1.2 环境变量不随代码变化的“运行环境配置”环境变量解决的是“程序行为不想写死”的问题。比如程序需要读取用户的主目录你不能在代码里硬编码一个路径因为不同用户、不同系统路径不一样这时候读HOME环境变量就对了。再比如日志级别、默认编码、临时目录都可以通过环境变量在运行前调整而不用重新编译。环境变量和配置文件最大的区别是生命周期。配置文件是程序启动后自己读的环境变量在进程启动前就已经从父进程继承下来了。也就是说程序只要一启动环境变量这张“表”就已经固定了。如果程序内部修改自己的环境变量影响的也只是自己并不会传回父进程。1.3 程序地址空间进程眼中的虚拟内存程序地址空间是理解进程的底层模型。每个进程都以为自己独享一整块内存实际上操作系统给每个进程发了一套“私有地图”这就是虚拟地址空间。在Linux下这套地图从低地址到高地址大致分为代码段、数据段、BSS、堆、共享库区、栈区以及内核空间。命令行参数和环境变量并不是藏在某个配置结构体里它们一开始就被内核放在新进程的栈顶。所以你下一次打印argv指针、环境变量地址的时候会发现它们非常靠近栈的高地址区域。这也是为什么我坚持认为学地址空间的时候一定要结合前面的参数和环境变量一起看否则你看到的只是一张静态图而不是一幅动态的启动画卷。2. 命令行参数的接收逻辑与常见坑命令行参数本身不复杂复杂的是各种解析需求和边界情况。很多初学者在main函数里打印argc、argv之后觉得“我会了”结果一到写带选项的工具就手忙脚乱。这一章我会先讲清楚参数从内核到main的路径再给几种实用的解析姿势最后说说我自己踩过的坑。2.1 main函数的两个入口参数是怎么来的先写一个最普通的程序#include stdio.h int main(int argc, char *argv[]) { printf(argc %d\n, argc); for (int i 0; i argc; i) { printf(argv[%d] %s\n, i, argv[i]); } return 0; }编译后执行./demo one two three输出会是argc 4 argv[0] ./demo argv[1] one argv[2] two argv[3] three这里有一个容易忽略的内部细节C程序的真正入口并不是main函数而是_start。_start是操作系统加载程序后跳转到的第一个用户态函数它负责初始化运行时环境其中就包括把argc和argv从调用栈上取出来然后调用main。也就是说在你看到main的第一行代码之前内核已经把参数表放到新进程的栈上了。argv的结构值得多说一句它是一个“指针数组”每个元素指向一个字符串数组最后有一个NULL结尾。所以你可以用一个循环从0遍历到argc-1也可以换个方式写for (char **p argv; *p ! NULL; p) { printf(%s\n, *p); }我建议初学者至少两种写法都试一次这能帮助你理解指针数组在内存中是如何存放的。2.2 参数解析的三种实用姿势如果你的程序只需要固定格式比如./demo 文件名手写循环最简单#include stdio.h #include string.h int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s 文件名\n, argv[0]); return 1; } const char *filename NULL; int verbose 0; for (int i 1; i argc; i) { if (strcmp(argv[i], -v) 0) { verbose 1; } else if (strcmp(argv[i], -f) 0 i 1 argc) { filename argv[i]; } else { fprintf(stderr, 未知参数: %s\n, argv[i]); } } if (filename) { printf(处理文件: %sverbose%d\n, filename, verbose); } else { fprintf(stderr, 缺少 -f 参数\n); return 1; } return 0; }如果选项很多推荐使用getopt。它专门处理短选项、组合选项和带参数选项比手写解析要省心得多#include stdio.h #include unistd.h int main(int argc, char *argv[]) { int opt; const char *output NULL; int verbose 0; while ((opt getopt(argc, argv, vo:)) ! -1) { switch (opt) { case v: verbose 1; break; case o: output optarg; break; default: fprintf(stderr, 用法: %s [-v] [-o 输出文件]\n, argv[0]); return 1; } } printf(verbose%d, output%s\n, verbose, output ? output : (默认)); for (int i optind; i argc; i) { printf(剩余位置参数: %s\n, argv[i]); } return 0; }注意getopt会改变argv的排序处理完选项后剩下的非选项参数会排到argv尾部optind指向第一个非选项参数。这个机制能让“选项位置参数”混合输入变得很好处理。2.3 我踩过的参数解析坑第一个坑是没有区分“参数个数”和“参数内容”。很多人在写./demo hello world时以为shell会把hello world用一个空格切分需要程序自己拼回去。其实shell在解析命令行时就已经根据空格切分好了。想传一个带空格的完整字符串必须在敲命令时加引号./demo hello world。这时程序收到的argv[1]是一个完整的字符串hello world里面自带空格。第二个坑是通配符。你执行./demo *.cshell会先尝试把*.c展开成当前目录下所有匹配的文件名再启动程序。所以程序里看到的往往不是字面量*.c而是一串文件名。如果程序确实需要处理字面量*.c就得让用户用单引号包起来./demo *.c。这个坑在写脚本、日志处理工具时特别常见。第三个坑是依赖argv[0]。有些多合一程序会根据被调用的名字改变行为比如用符号链接创建不同命令然后通过argv[0]判断当前扮演的是哪个角色。这种设计有它的价值但你自己写普通工具时千万别依赖argv[0]因为用户可以通过execve完全自定义这个值甚至写一个空字符串。任何“野蛮”的用法都可能让程序行为不可预期。3. 环境变量继承、修改与作用域环境变量的核心机制是继承。在shell里执行一个程序时shell会把自己管理的环境变量表复制一份传给子进程。子进程可以修改自己的环境但修改不会回传给父进程。这个“复制”的特性解释了无数让人摸不着头脑的问题。3.1 环境变量从哪里来shell导出与进程继承环境变量的源头是登录过程。你登录系统之后shell会从一系列配置文件中加载初始环境再结合你的登录会话生成一张环境表。之后每当你运行一条外部命令shell都会拿着这张环境表去启动新进程。在C语言层面环境变量虽然是进程全局可见的但它并不属于程序的数据段。前面说过初始的argv和envp内容由内核放在新进程的栈顶区域C运行时库再把它们整理成可访问的字符串数组。environ这个全局变量指向环境字符串数组每个元素都是名字值的格式。一个很常见的误解是“环境变量是全局的”。其实环境变量只是“继承的”不是“全局共享的”。同一个环境变量在父进程里是FOO1在子进程里可以改成FOO2二者互不干扰因为它们各自维护一份副本。3.2 用代码读、写、删环境变量C标准库提供了几个函数来操作环境变量#include stdio.h #include stdlib.h int main(void) { const char *home getenv(HOME); if (home) { printf(HOME %s\n, home); } else { printf(没有设置 HOME\n); } setenv(MY_MODE, debug, 1); printf(MY_MODE %s\n, getenv(MY_MODE)); unsetenv(MY_MODE); if (getenv(MY_MODE) NULL) { printf(MY_MODE 已被删除\n); } return 0; }这里有几个关键点getenv返回的指针指向进程内部的环境字符串不要直接修改它。想改就用setenv不要依赖返回值去做字符串写入。setenv的第三个参数表示是否覆盖已有值传1表示覆盖传0表示保留原值。unsetenv用于删除环境变量。putenv我建议新人尽量别碰。它不会复制字符串而是直接把指针挂到环境表上。如果传入的是一个栈上的局部数组函数返回后指针就会变成悬垂指针后续getenv可能读到垃圾数据。3.3 export、临时赋值和env命令的区别在shell里设置环境变量有几种方式作用范围完全不同场景示例是否影响当前shell仅给一条命令设置临时环境变量FOObar ./demo否使用env命令启动程序env FOObar ./demo否导出到当前shell及后续子进程export FOObar是仅创建shell变量FOObar否最后一行最容易踩坑。很多人以为在终端里执行FOObar之后运行的程序就能读到FOO实际上这只是定义了一个shell变量并不会自动放进环境表。只有执行了export FOObar它才会进入当前shell的环境表并传染给之后启动的所有子进程。还有一个细节值得注意FOObar ./demo这种写法只对./demo这一条命令有效命令执行完就没了。这个特性在临时调整程序行为时很好用比每次改代码重新编译方便太多。3.4 常见环境变量以及需要避开的坑Linux系统里有大量环境变量各有约定。平时见得比较多的有这几个变量含义典型用途PATH可执行文件搜索路径shell找命令用HOME当前用户主目录程序读写用户配置文件LANG语言和编码影响程序输出文本是否乱码TMPDIR临时目录程序创建临时文件EDITOR默认编辑器很多命令行工具会调用LD_LIBRARY_PATH动态库搜索路径调试共享库时使用LD_LIBRARY_PATH是一个特别容易惹事的环境变量。它可以临时指定动态链接库的搜索路径看起来很方便但也很容易让程序加载到不兼容的库版本。我在实际项目里基本只在本地调试时用真正部署不会依赖它而是通过配置动态链接器或者把库放到标准目录来解决。还有一个安全习惯不要把密钥和敏感信息当成环境变量传给程序。环境变量是“继承”的任何子进程都能看到甚至通过/proc/pid/environ也可能被同用户的其他进程读取。密码放进环境变量等于把钥匙挂在了门边上。4. 程序地址空间一张进程内存地图程序地址空间是Linux系统编程里绕不开的硬核概念。不夸张地说搞懂这张地图你再看segmentation fault、栈溢出、堆碎片这些问题都会淡定很多。4.1 从高地址到低地址的经典布局经典的用户态地址空间布局可以画成一张从上到下的图高地址 ---------------------------- | 内核空间用户态不可访问 | ---------------------------- | 栈区向下增长 | | ......... | | 共享库 / 内存映射区 | | ......... | | 堆区向上增长 | | BSS段未初始化全局变量 | | 数据段已初始化全局变量 | | 代码段TEXT | ---------------------------- 低地址这里最直观的规律是栈在高地址堆在栈的下方代码段和全局数据在很低的地址。现代系统普遍开启了地址空间布局随机化ASLR所以实际地址每次运行都会变化但相对顺序基本不变。初学时可以把这张图当作“底图”后续遇到任何指针、内存分配问题先往这张图上套往往能快速建立一个初步判断。4.2 用C程序打印地址验证看一百遍图不如自己打印一遍。下面这个程序展示了不同对象在地址空间中的大致位置#include stdio.h #include stdlib.h int global_data 100; int global_bss; void function(void) { int local_in_function 0; printf(函数内局部变量地址: %p\n, (void *)local_in_function); } int main(void) { int local_in_main 0; void *heap_ptr malloc(1024); printf(main局部变量地址: %p\n, (void *)local_in_main); printf(堆内存地址: %p\n, heap_ptr); printf(已初始化全局地址: %p\n, (void *)global_data); printf(未初始化全局地址: %p\n, (void *)global_bss); printf(函数地址: %p\n, (void *)function); function(); free(heap_ptr); return 0; }我在一台普通Linux机器上编译运行一次可能的输出是main局部变量地址: 0x7ffd8a3c8d1c 堆内存地址: 0x55f0c7a552a0 已初始化全局地址: 0x55f0c7a4d010 未初始化全局地址: 0x55f0c7a4d018 函数地址: 0x55f0c7a4d169 函数内局部变量地址: 0x7ffd8a3c8cec注意看规律所有局部变量的地址都以0x7ffd开头这就是栈区堆和全局变量、函数地址都落在0x55f0附近。由于可执行文件启用了PIE位置无关可执行文件代码段和数据段会加载到同一个随机基址附近所以它们看起来挨在一起。跑这个实验时千万别把具体地址当作真理因为ASLR会让每次运行结果不一样。核心是看“相对位置”而不是“绝对数字”。4.3 为什么堆和栈方向相反栈之所以从高地址向下增长最直观的理解是函数调用是一个不断“压入帧”的过程每一次函数调用都会在栈顶分配一块空间返回时再释放。栈天生是“后进先出”的方向向下可以让新栈帧占据更低的地址并且不会和内核区域冲突。堆从低地址向上增长则是因为它需要给动态分配的内存留出灵活的扩展方向。堆和栈像两个相向生长的区域理论上可以更有效地共享同一段地址空间栈在顶端往下压堆在底端往上顶两者之间的区域可以动态调节。不过这句话只能用来建立直觉不要死记“堆一定向上增长”。现代内存分配器会使用mmap分配大块内存堆里还可能存在碎片和释放后复用所以连续两次malloc得到的地址不一定递增甚至可能差得很远。看到地址“变小了”不代表堆变成向下增长更常见的是分配器复用了之前释放的空隙。4.4 虚拟地址空间与物理内存的关系程序打印出来的地址并不是真实物理内存地址而是虚拟地址。操作系统通过页表把虚拟地址映射到物理页框。每个进程都有自己的页表所以两个进程完全可以使用同一个虚拟地址但最终映射到不同的物理内存。这一点对理解进程隔离非常重要你在程序A里看到地址0x7ffd...不代表你可以在程序B里通过同样地址读取A的数据。反过来一个进程里的“内存泄漏”并不会真的让物理内存无限增长只有进程退出后它占用的物理页才会被完全回收。理解了这个你也就不会再对“为什么每个进程的栈地址看起来都差不多”感到奇怪。命令行参数和环境变量与地址空间还有一个有趣的连接点在新进程的初始栈里高地址附近会同时存在argv和envp的字符串数据。所以你在程序里打印argv[0]的地址它会落在栈区的高地址一侧。用/proc/self/maps观察时能看到[stack]这一行覆盖的范围非常大参数和环境变量就在这个范围的高端。5. 三者的联动写一个启动自检程序理论知识说再多不如动手写一个综合实验。下面这个自检程序会把命令行参数、环境变量、地址空间三块内容同时打印出来方便你观察它们如何共存。5.1 自检程序的结构与编译运行创建一个selfcheck.c文件#include stdio.h #include stdlib.h int global_value 7; int main(int argc, char *argv[]) { int local_value 1; void *heap_ptr malloc(4096); printf(argc %d\n, argc); for (int i 0; i argc; i) { printf(argv[%d] %s\n, i, argv[i]); } const char *mode getenv(SELFCHECK_MODE); if (mode) { printf(SELFCHECK_MODE %s\n, mode); } else { printf(SELFCHECK_MODE 未设置\n); } printf(局部变量地址: %p\n, (void *)local_value); printf(堆变量地址: %p\n, heap_ptr); printf(全局变量地址: %p\n, (void *)global_value); free(heap_ptr); return 0; }编译并运行gcc -g -Wall -o selfcheck selfcheck.c SELFCHECK_MODEdebug ./selfcheck a b c你会看到参数和环境变量同时被程序接收而且局部变量地址、堆地址、全局地址依次落在不同区域。这个实验最大的价值是让你亲眼看到“进程从启动一开始就已经把外部输入和环境整整齐齐地摆进了地址空间”。5.2 用/proc/self/maps观察完整映射/proc/self/maps是Linux内核暴露给进程查看自身内存映射的接口。在程序里读取它可以打印出当前进程的完整虚拟地址地图#include stdio.h #include stdlib.h static void dump_maps(void) { FILE *fp fopen(/proc/self/maps, r); if (!fp) { perror(fopen /proc/self/maps); return; } char line[256]; while (fgets(line, sizeof(line), fp)) { fputs(line, stdout); } fclose(fp); } int main(void) { dump_maps(); return 0; }编译运行后输出会非常长但你可以重点关注几类信息地址范围两列形如起始地址-结束地址。权限字段比如r--p、rw-p对应读、写、执行权限。最后有映射对象名称的通常是可执行文件或某个共享库文件。带有[heap]标记的行对应堆区。带有[stack]标记的行对应主线程栈。把自检程序的地址打印结果和maps里的行对照起来你会对“虚拟地址空间到底长什么样”建立非常直观的认知。建议新手跑一次把第一行代码段、中间某个共享库段、以及最后[stack]段分别抄下来再对比自己程序里函数的地址落在哪一段。5.3 fork和exec如何影响这三块内容综合实验最后一步是把fork和exec也加进来。这里先给一个极简示意#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h int main(void) { setenv(INHERITED, from parent, 1); pid_t pid fork(); if (pid 0) { printf(子进程看到 INHERITED%s\n, getenv(INHERITED)); setenv(INHERITED, changed in child, 1); printf(子进程修改为 INHERITED%s\n, getenv(INHERITED)); execlp(./selfcheck, selfcheck, child, (char *)NULL); perror(execlp); _exit(1); } else if (pid 0) { wait(NULL); printf(父进程仍看到 INHERITED%s\n, getenv(INHERITED)); } return 0; }结果会显示子进程一开始继承了父进程的环境变量修改后父进程不受影响紧接着execlp用新程序替换了当前进程的地址空间旧程序的代码、数据全部被扔掉但新程序的入口参数argc/argv是由execlp重新传入的。这就是从shell到进程、从fork到exec的完整故事。注意编译的时候要先保证selfcheck可执行文件在当前目录下否则execlp会失败。为了安全可以用execlp(./selfcheck,...)我这里为了示意用的是相对路径。6. 学习与调试环境的几个实用习惯学到这里你大概已经明白三块内容的底层结构了。但知道归知道遇到问题还是得靠工具去验证。下面分享几个我日常在用的调试习惯。6.1 gdb里最常用的几个排查动作写代码验证的时候编译选项一定要带-g否则gdb里看不到变量名和行号。推荐命令gcc -g -Wall -o demo demo.c进入gdb后可以这样操作(gdb) break main (gdb) run a b c (gdb) print argc (gdb) print argv[0] (gdb) print local_var (gdb) info proc mappingsinfo proc mappings会打印当前进程的映射信息和/proc/self/maps类似。如果某些环境下该命令不可用可以直接在程序里加一个读取/proc/self/maps的函数效果一样。调试环境变量也很方便。你可以在gdb里先设置好环境变量再启动程序(gdb) set environment MY_VALUE10 (gdb) run这样就不用来回切终端做临时变量赋值了尤其适合复现“只在特定环境变量下出现”的问题。6.2 用strace观察execve时的参数与环境strace是一个非常直观的系统调用追踪工具。当你想确认shell到底是怎么把参数和环境变量传给程序的可以运行strace -e execve -s 256 ./selfcheck a b c-e execve表示只过滤execve相关的系统调用-s 256表示打印参数字符串时最多显示256字节。输出里会看到一行execve(./selfcheck, [./selfcheck, a, b, c], ...)其中环境变量列表也会打印出来这就是内核实际接收到的数据。这个实验能解答一个很常见的问题“为什么程序里看到的参数和我敲的不完全一样”因为shell在启动程序之前已经做完了引号处理、通配符展开等工作你看到的execve参数才是程序真正拿到的。6.3 该查文档时不要硬猜遇到函数行为不确定优先看本机手册页。和今天内容相关的几个man 2 execve看参数和环境变量如何传入。man 3 getopt看选项解析函数的全部细节。man 7 environ看环境变量的约定和常见变量。man 5 proc看/proc文件系统中各字段的含义尤其是maps。手册页虽然一开始读着枯燥但它比任何二手资料都准确而且不会过时。我自己的习惯是遇到一个API的边界条件先打开手册页搜索“BUGS”和“NOTES”这两个章节往往藏着最容易踩坑的地方。最后再分享一个小体会我后来写任何稍微复杂一点的命令行工具都不会急着写功能代码而是先写一个“启动参数环境变量地址自检”的小框架。别小看这几分钟它能让你在功能出问题之前先排除一半的基础配置错误。命令行参数、环境变量、程序地址空间这三件套看起来是基础中的基础但真正把它们揉在一起想清楚之后读源码、调试程序、排查线上问题的速度都会快一大截。