本章涉及C/C++程序中main函数是如何被调用的、命令行参数如何传递给main函数、程序的内存空间布局、程序如何使用环境变量、程序如何终止退出。
main函数C程序或C++程序总是从main函数开始执行的,其中这个总是从main函数开始执行是我们人为约定的,因为main( )函数也是当做一个函数被调用的,所以需要被系统知道被调函数的名字,当然现在从main函数开始执行已经成为语言标准了,在汇编层次,我们可以把程序起始执行地址指向一个自定义的名字。
书本上7.2节这里的翻译很是生硬,字面意思直接翻译过来,让人不太好理解,原文如下:
When a C program is executed by the kernel—by one of the exec functions, which we describe in Section 8.10—a special start-up routine is called before the main function is called. The executable program file specifies this routine as the starting address for the program; this is set up by the link editor when it is invoked by the C compiler. This start-up routine takes values from the kernel—the command-line arguments and the environment — and sets things up so that the main function is called as shown earlier.
中文翻译:
当内核执行C程序时(使用一个exec函数,8.10节将说明exec函数),在调用main前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址——这是由连接编辑器设置的,而连接编辑器则由C编译器调用。启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用main函数做好安排。
这段话中的“启动例程”是什么意思?启动例程即原版中的start-up routine,拿掉形容词修饰start-up只剩下真正的关键词routine,routine是值什么?routine 或 subroutine是近义词,在不同的编程语言中,称呼不太一样,但意思大致相同,在C/C++语言中,它的意思是function函数,在Java中它的意思是method方法。
因此原文的这段话正确翻译是:当C程序要被内核执行时,是通过一个叫做exec的函数来加载的,exec将在8.10节说明。在C程序的main被调用执行起来之前,会先调用一个特殊的启动函数(它的名字是_start,可在汇编层次看到)。可执行程序文件(也即前面的C程序二进制文件)将该启动函数作为程序启动的入口地址,这个启动地址是C编译器在链接阶段配置的。这个启动函数负责从内核那里接收命令行参数和环境变量,设置好这些之后再调用main函数。
进程终止进程正常终止:
1.从main函数返回,即retrun 0;
2.调用exit,即在main函数内或者其他会被main调用的函数体内调用exit();
3.调用_exit或_Exit,即在main函数内或者其他会被main调用的函数体内调用_exit或_Exit;
4.最后一个线程从其所在进程返回;
5.最后一个线程在其所在进程调用pthread_exit。
进程异常终止:
6.调用abort;
7.进程接收到信号;
8.进程中最后一个线程最取消做出响应。
可以使用exit、_exit和_Exit函数来正常终止程序,第一个函数和后面两个函数区别是,exit会进行一些资源清理工作,然后返回内核,而_exit和_Exit则不清理立即返回内核。其头文件及函数原型如下:
#include <stdlib.h> void exit (int status); void _Exit (int status); #include <unistd.h> void _exit (int status);
三个函数的参数都是用于返回给操作系统,其值为程序的退出状态。
在新的C标准和C++标准中,是不允许main函数没有返回值类型说明的,main函数返回类型必须为int类型,如果程序没有通过return语句来返回一个值,则编译器默认强制插入一条return 0;语句。
当我们退出程序时,可以通过ISO C标准提供的一个库函数来实现程序收尾清理工作,该库函数使得进程可以登记至少32个函数来进行收尾处理,该库函数会让exit( )函数在程序退出时自动调用我们登记的清理函数。其头文件及函数原型如下:
#include <stdlib.h> int atexit (void (*func) (void));
该函数负责登记程序退出时要执行的函数,若注册成功则返回0,否则返回非0。atexit函数的参数是一个函数指针,该指针指向的函数不需要参数,也不返回任何值。atexit函数可以登记多个清理函数,清理函数先后顺序是以栈的形式压入,即LIFO(last in first out)。如果函数被注册多次,那么也会被调用多次。
需要注意的是,除非通过return语句来返回,或者通过exit( )来退出才会调用清理函数,_exit和_Exit函数是不会调用清理函数的。
C/C++程序的正常启动结束过程使用C语言代码的形式接近于:exit(main(argc,argv)) ,实际上这段过程一般是用汇编来写,流程大致如下图:
命令行参数命令行参数是例程调用main函数时传递的。除了传递命令行参数给main函数,main程序也可以从环境表中获取环境变量,环境变量是存储在environ指针中的。
环境表每个程序都在一定的环境下运行的。程序可以从其运行环境中获取一个环境表,环境表中包含很多信息,程序可以从中读取。环境表是个数组,数组的元素是一个个的指针,每个指针指向一个字符串。该数组是使用全局变量environ存储的。
C程序的存储空间布局典型的C/C++程序内存布局有5部分:
程序段(Text):程序二进制代码,计算机的工作指令部分,可以被共享,通常是只读的。
初始化数据段(Data):简称为数据段,用于存储已经初始化的全局变量或全局/局部静态变量,会保存在二进制文件中。
未初始化过的数据(BSS):用于存储未初始化的全局/局部静态变量,不会保存在二进制文件中,在程序加载时会将变量初始化为0。
栈 (Stack):保存局部变量、函数调用栈信息,在程序块开始时自动分配内存,结束时自动释放内存,先入后出顺序。
堆 (Heap):用于动态内存分配,手动分配和手动释放,通常位于BSS和Stack中间。
对于上述布局中的Data和BSS段,并不一定与程序中变量大小的内存总和相等,因为编译器可能会考虑字节对齐而额外占用一些空间。
环境变量环境表包含环境变量,ISO C提供了一个库函数用于获取环境变量的值。其头文件及函数原型如下:
#include <stdlib.h> char *getenv (const char *name);
函数成功时返回对应指针,否则返回NULL。
除了获取环境变量值之外,UNIX系统也提供了三个用于增加、更新、删除的库函数。 其头文件及函数原型如下:
#include <stdlib.h> int putenv (char *string); int setenv (const char *name, const char *value, int replace); int unsetenv (const char *name);
上面的函数成功时返回0,出错返回-1。putenv( )比较怪异,慎用。
之前提到过,每个进程都在一个环境下运行,当我们使用上面的设置函数更改了其运行环境之后,该环境会被进程的子进程继承,但不会影响到父进程。
函数setjmp和longjmp在C/C++中,goto语句只能在函数内部跳转,用于跳出过深的循环。但是想要过多的函数嵌套调用时,goto就无法使用了。ISO C和UNIX标准都做了相同的说明,提供了两个用于跳转多层嵌套函数调用的库函数。 其头文件及函数原型如下:
#include <setjmp.h> int setjmp (jmp_buf env); void longjmp (struct jmp_buf_tag env[1], int val);
这两个函数是配套使用的。当第一次调用setjmp函数时,其返回值为0,后续调用返回值由传入longjmp的val值决定。
函数getrlimit和setrlimit每个进程在运行时,都有一个运行环境,除此之外,在书本的开始第一句话就是:所有的操作系统都为它们所运行的程序提供各种服务。提供的服务包括各种资源分配,比如内存分配,对于每个进程都会分配资源,分配资源就会有资源限额方面的规定,这些限额就是资源限制。UNIX系统提供了用于查询和设置资源限制的接口函数。其头文件及函数原型如下:
#include <sys/resource.h> int getrlimit (rlimit_resource_t resource, struct rlimit *rlimits); int setrlimit (rlimit_resource_t resource, const struct rlimit *rlimits);
函数成功时返回0,失败则返回非0。
习题7.1 在Intel x86系统上,使用Linux,如果执行一个输出“hello, world”的程序但不调用exit或return的程序,则程序的返回代码为13(用shell检查),解释其原因。
较早的C/C++标准对于main( )函数的定义过于宽松,允许定义返回值类型为void,所以printf打印输出的返回值作为了程序的返回代码。1999年之后的语言标准已经明确禁止这种行为,所以现在默认都是返回0。
7.2 图7-3中的printf函数的结果何时才会被真正输出?
视具体情况而定,如果重定向输出到文件则是全缓冲,只有在缓冲区满或者程序结束时才会真正输出,如果在交互式终端直接执行则是行缓冲,会在遇到换行时输出。
7.3 是否有方法不使用(a)参数传递(b)全局变量这两种方法,将main中的参数argc和argv传递给它所调用的其他函数?
没有办法,除非UNIX系统提供类似getenv的方法来实现。
7.4 在有些UNIX系统实现中执行程序时访问不到其数据段的0单元,这是一种有意的安排,为什么?
可以将0单元作用空指针的实现地址,该地址不允许访问,那么访问该空指针时一定会时程序内存越界而报错,不会出现可能错误或可能意外写入了内存这种不确定哪种情况的发生。
7.5 用C语言的typedef为终止处理程序定义一个新数据类型Exitfunc,使用该类型修改atexit的原型。
atexit的原型如下:
#include <stdlib.h> int atexit (void (*func) (void));
使用typedef定义新数据类型Exitfunc:
typedef void Exitfunc(void);
新atexit:
int atexit (Exitfunc *fp);
7.6 如果用calloc分配一个long型的数组,数组的初始值是否为0?如果用calloc分配一个指针数组,数组的初始值是否为空指针?
不一定,因为计算机的编码实现方式可能不一定与实际值匹配。
7.7 在7.6节皆为处size命令的输出结果中,为什么没有给出堆和栈的大小?
因为堆和栈是进程的内存布局,而size命令是对二进制程序的内存布局。进程和二进制不是同一样东西。
7.8 为什么7.7节中两个文件的大小(879443和8378)不等于他们各自的文本和数据大小的和?
因为一个二进制文件除了代码文本部分和数据部分,还有其他组成部分,比如链接库信息、符号表等。
7.9 为什么7.7节中对于一个简单的程序,使用共享库以后其可执行文件的大小变化如此巨大?
使用静态库时,库代码会被整合进二进制代码文件中,使用动态库时只是放入一个符号指示,而不是整合整个库代码。
7.10 在7.10节末尾,我们已经说明为什么不能将一个指针返回给一个自动变量,下面的程序是否正确?
1 int f1(int val) 2 { 3 int *ptr; 4 if (val == 0) 5 { 6 int val; 7 val = 5; 8 ptr = &val 9 } 10 return (*ptr + 1); 11 }
错误,因为第8行的代码将一个自动变量的地址赋予了指针,在第9行之后该变量已经被销毁,而在第10行ptr指针仍然访问了该变量。