前言
我相信每一个学习 C 语言的人都写过 int main(int argc, char *argv[]),但你是否真的理解这两个参数背后的内存模型?为什么 argv[argc] 一定是 NULL?参数是如何从 shell 传递到你的程序中的?本篇文章将从标准规定、内存布局、系统调用等方面,带你更加深入的了解这个熟悉的陌生人。
文章主要针对 Unix-like 系统,Windows 的argv处理略有不同。
1. 一个看似多余却引人深思的判断
我们在使用 C 语言编写程序时,总是免不了用下面的方法遍历命令行参数:
for (int i=0;i<argc;i++)
{
printf("argv[%d] = %s\n", i, argv[i]);
}
但是,我们在这里冒昧的使用了argc作为了遍历字符串数组argv的边界,这不禁引人深思,argv的元素打印完了吗?
于是,对于并不确定是否存在的下一个数组元素,也就是一个字符串,我进行了如下判断:
if (argv[argc] == NULL)
{
printf("argv[argc]为NULL\n");
}
我编写的完整测试代码如下,可直接复制粘贴进行验证:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("接收到的参数数量argc = %d\n",argc);
printf("参数列表如下:\n");
for(int i=0;i<argc;i++)
{
printf("argv[%d] = %s\n",i,argv[i]);
}
if(argv[argc] == NULL)
{
printf("argv[argc]为NULL\n");
}
return 0;
}
这段代码都是一些最基本的 C 语言操作,相信大家都可以看懂,这段代码的运行结果如下:
运行结果证明,argv[argc] 确实等于 NULL。argv字符串数组的元素个数比argc的值多一个,最后一个元素是NULL。
2. 标准是怎么规定的
在查阅资料之后会发现,argv[argc] == NULL 其实是 ISO C 标准 的强制规定。
在 C99 标准中明确写道:argv[argc] shall be a null pointer.
那么为什么要这样设计呢?要知道这个NULL指针被使用的情形少之又少,以至于不少人可能都不知道它的存在。实际上这恰恰体现了 C 语言设计中的一种哲学,就是双重保障。
argc 告诉了我们数组的长度,方便我们用for进行循环。NULL 结尾则让 argv 变成了一个以空指针结尾的指针数组。这意味着即使我们不知道 argc 的值,也可以像处理字符串一样处理参数列表,如下:
char **ptr = argv;
while (*ptr != NULL)
{
printf("%s\n", *ptr);
ptr++;
}
这种设计让 argv 和环境变量(environ)的数据结构保持了一致性,下一章我们将深入介绍这方面。
3. 栈上的布局
要真正理解 argc 和 argv,我们必须看透进程的虚拟内存布局。
当程序被执行时,操作系统会为新进程分配内存。在进程的栈(Stack) 的高地址部分,布局通常是这样的(栈由高地址向低地址生长):
从这张内存布局示意图中,我们可以轻松的看到一些特点:
真正的字符串,比如 "-a" 存在于栈的最顶部,而 argv 数组里存的只是指向这些字符串的指针。
argv 数组的上方紧接着就是环境变量数组。这也解释了为什么argv[argc]==NULL,因为在栈上,它是argv数组后的分隔符,紧接environ指针数组。
此外,在传统的 Unix 实现中,参数字符串通常是连续存储的,但标准并不保证这一点,现代 Linux 内核通常会把它们紧凑排列,这就形成了上面的这种内存布局。
4. 追溯根源
在了解了栈上的内存布局之后,可能会有不少人产生疑问:我们只是在运行程序时添加了几个命令行参数,程序运行起来后这些参数就跑到了栈上面,这个操作到底是谁完成的呢?
事实上,这涉及到 Linux 的进程启动流程。当我们执行 ./app -a xlp 时,发生了以下操作:
首先,Shell 会读取你的输入,并按照空格将字符串切分成数组。
然后,Shell 会先 fork() 出一个子进程,然后在子进程中进行系统调用execve()替换当前进程映像,函数原型如下:
int execve(const char *filename, char *const argv[], char *const envp[])
这一步过后,参数就从用户空间被拷贝到了内核空间。fork + execve 这套组合拳在 Unix 进程模型中是非常常见的操作。
内核读取可执行文件,建立新进程的虚拟内存映射,并将参数从内核空间拷贝回新进程的栈顶。
最后,程序入口并不是 main,而是 _start,是由 glibc 提供的。_start 调用 __libc_start_main,该函数从栈上获取 argc 和 argv 的位置,最终调用 main 函数。
此外,这里我觉得有必要讲一下 Shell 对通配符的处理,如果是执行 ./app *.c,Shell 会先将 *.c 展开成 a.c b.c这种形式,然后再传给 argv,而程序本身并不知道你输入的是通配符。因此,可以理解为通配符是 Shell 层面的操作,根本轮不到 C 程序来处理它。
5. 实现一个简单的进程伪装
既然我们知道了 argv 指向的是可读写的栈内存,那我们能不能修改它?
答案是肯定的。
在 Linux 下,ps 命令或 top 命令查看进程名时,读取的往往就是 argv[0] 指向的内存区域。如果我们修改了这段内存,就能改变进程在系统中的显示名称。
完整代码如下:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(int argc,char *argv[])
{
printf("原始进程名:%s\n",argv[0]);
strcpy(argv[0], "xxxxxxxxx66666");
printf("进程名已修改\n");
printf("按任意建退出\n");
getchar();
return 0;
}
这段代码中我们使用getchar让程序阻塞,运行程序后,我们重开一个终端,执行ps aux命令,这是按照进程id大小排列的,这个程序我们刚刚启动,我们直接翻到最下面:
从图中可以看到,进程名确实变了。但是这里要注意一个潜在的 bug ,如果进程名过长可能会导致覆盖掉后面的环境变量内容,大家可以对比上面的栈布局示意图理解一下。如果不小心覆盖了 environ 区域,会导致程序内调用 getenv() 失效甚至崩溃,因为 environ 指针被破坏了。
ps 命令其实是去读取 /proc/[pid]/cmdline 文件(对于 Linux 而言是这样的)。而这个文件映射的正是这块栈内存,修改栈内存的内容自然就修改了 ps 的查看结果。
这个操作在实际中也是有应用实例的:
恶意软件通过修改 argv[0] 把自己伪装成 syslogd 或 kworker 等系统内核线程,从而欺骗管理员。
正规软件如 Nginx、Redis 等服务软件,会利用这个特性修改进程名,用来显示当前进程的状态,例如 nginx: worker process,从而方便运维排查。