Fork me on GitHub

Skip to content

打破禁锢,从main开始

印象中的main函数

学过C语言的人一定知道,程序都是从main()函数开始的,不管你的程序有多么复杂。如果在深入一些,你会发现main()函数并不是程序执行的第一个函数,编译器早在之前就为main()函数的执行做下了铺垫。当然,不同平台上实现也不一样。再深入一点,你会发现main()函数是带参数的。举例来说,ISO/ANSI C标准规定main函数的标准语法如下:

int main (int argc, char *argv[])

其中第一个参数为程序执行时参数的个数,第二个参数是参数列表,从形式上可以看出它其实是一个字符型指针数组。用图来表示,看起来应该像这样(假设程序的名字为show_args):

在古老而严肃的UNIX系统上,main函数的定义更加复杂,比上面还要多一个参数:

int main (int argc, char *argv[], char *envp[])

这多出来的一个参数就是程序执行的环境变量,比如$PATH,

$HOME什么的。这些都是*nix系统才有的。当然,这些参数也不是非得从第三个参数传入,GNU/Linux上做了另外一套接口,getenv()/setevn()。同时,POSIX.1标准并不允许三个参数,考虑到程序的可移植性,建议使用两个参数的形式。如果确实需要环境变量,也请使用GNU提供的getenv()/setenv()接口。

argv[0]的传统

问题就出来了。从上图我们可以看到argv[0]就是程序本身的名字,这样给我们一个直觉:argv[0]就等于程序的名字。但是,事实上这只是一个传统(convention),并不是规定。这个传统是由外部维护的,GNU/Linux上就是由bash来负责准备着两个参数。程序是死的,人是活的。通过一些手法,可以绕过bash,直接给argv[0]赋任何想要的值。而这些魔法,来自于一个叫execv的系统调用。让我们来看看man 2 execv是怎么说的:

int execv(const char *path, char *const argv[]);

execv是exec()函数家族中的一员,其中一共有6个类似的函数,功能基本相同,就是启动另外一个进程,它们之间只有细微的差别。其中,path为需要执行程序的路径,argv就是给执行程序的参数列表。请注意,这里的argv[]是全部的参数列表,execv并不会做任何修改。所以,argv[0]就可以事先进行修改。秘密就在这里。以程序为证(exec.c):

/*exec --- run a program with a different name and any arguments */

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
#include<unistd.h>

/* main --- adjust argv and run named program */

int main(int argc, char ** argv)
{
  char * path;

  if(argc < 3){
    fprintf(stderr, "usage : %s path arg0 [ arg ... ]\n", argv[0]);
    exit(1);
  }

  path = argv[1];

  execv(path, argv + 2); /* skip argv[0] and argv[1] */

  fprintf(stderr, "%s: execv() failed: %s\n", argv[0], strerror(errno));
  exit(1);
}  

这个程序的功能非常简单,就是做了一个代理,将传入的参数进行第二次调用。最后fprintf()的输出表明是出现错误。有点迷惑吗?这里没有做任何错误判断,每次调用都会执行这一行并输出错误。诡异的事情就是execv()这个函数。如果执行成功,它不会返回,永远。对,这是打破常规的一个函数,一个永远不会返回的函数。别忘了,这是一个系统调用,最终的处理是内核完成的。

确切的说,内核是这样处理的:当内核检查所有参数合法,会把原进程的进程地址空间上下文全部清除(不用担心,这不是异常终结,内核会优雅地处理这些事情。还有,argv这个参数会被内核从原来程序的堆栈上取出,在另外的地方妥善保存。)然后,内核加载目标程序代码,初始化新进程的环境变量,然后调用该程序的main()函数。于是,新的进程启动了。这里,新进程需要的argc参数是内核帮忙计算好的,argv参数则是原封不动的传送进去。

需要注意的是,这不是一个简单的fork()启动一个新进程,其实进程并不新,它的文件描述符列表还是继承原程序的。更加重要的是,新进程的PID和原进程的一样!execv()只是做了进程角色切换。打一个通俗的比方,一个人在一天之中有不同的角色,父亲,丈夫,朋友,雇员等。每天不同的时间,你的角色是不一样的。

好了,编译一下,看看效果:

$ ./exec /bin/grep whoami foo           //执行
a line                                  //这是用户的输入
a line with foo in it                   //继续输入
a line with foo in it                   //这是grep的输出

这里whoami被当成了argv[0]传给了grep,而grep忽略了,它需要的是argv[1]及以后的参数。所以这样执行与grep foo的效果是一样的。下一个例子:

 
$ ./exec nonexistent-program foo bar 
exec: execv() failed: No such file or directory 

这里演示的是execv()执行失败,则返回,执行原进程的后续代码。还有一个有趣的实验:

 $ ./exec ./exec foo 
usage: foo path arg0 [ arg ... ] 

这又是为什么呢?屏幕前聪明的你仔细想一下,就会发现,这是自身调用,第一次调用成功,执行./exec foo,但后第二次调用的时候发现参数不足3个,遂退出。仔细想一下,这个和函数的递归调用非常类似,但事实上这里并没有任何堆栈的变化,只算得上一个特殊的进程调用。

自动化测试

有了make,最大的好处就是一切自动化。编写以下程序(show_args.c):

#include<stdio.h>
#include<stdlib.h>

int main(int argc, char **argv)
{
  int curarg = 0;
  while(argc --)
  {
    printf("argv[%d] is %s\n", curarg++, *argv++);
  }
  exit(EXIT_SUCCESS);
} 

两个文件放到一个目录,并编写Makefile进行自动化编译、测试:

default: exec show_args

exec: exec.o
    cc -o exec exec.o
exec.o: exec.c
    cc -c -Wall exec.c
show_args: show_args.o
    cc -o show_args show_args.o

show_args.o: show_args.c
    cc -c -Wall show_args.c

test: default
    ./exec ./show_args how are you

clean:
    rm -f exec.o exec show_args.o show_args 

运行make test,会得到以下输出:

...             //编译过程略去
argv[0] is how
argv[1] is are
argv[2] is you

这个例子足以说明一切了。

结论

不要违背标准,更不要被标准禁锢思想。这个世界上没有不可能是事情,而创意就在于程序之中。

你也许会喜欢

Categories: Linux.

Tags: ,

Comment Feed

2 Responses

  1. 代码排版真是漂亮!

    knhunterMay 18, 2009 @ 7:59 pmReply



Some HTML is OK

or, reply to this post via trackback.