下一章将介绍进程控制原语,在此之前需先了解进程的环境。本章中将学习:当程序执行时,其main函数是如何被调用的;命令行参数是如何传递给新程序的;典型的存储空间布局是什么样式;如何分配另外的存储空间;进程如何使用环境变量;进程的各种不同终止方式等。另外,还将说明longjmp和setjmp函数以及它们与栈的交互作用。本章结束之前,还将查看进程的资源限制。
C程序总是从main函数开始执行。main函数的原型是:
int main(int argc, char *argv[]);
其中,argc是命令行参数的数目,argv是指向参数的各个指针所构成的数组。7.4 节将对命令行参数进行说明。
当内核执行C程序时(使用一个exec函数,8.10节将说明exec函数),在调用main前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址——这是由连接编辑器设置的,而连接编辑器则由C编译器调用。启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用main函数做好安排。
有8种方式使进程终止(termination),其中 5种为正常终止,它们是:(1)从main返回;
(2)调用exit;
(3)调用_exit或_Exit;
(4)最后一个线程从其启动例程返回(11.5节);
(5)从最后一个线程调用pthread_exit(11.5节)。
异常终止有3种方式,它们是:
(6)调用abort(10.17节);
(7)接到一个信号(10.2节);
(8)最后一个线程对取消请求做出响应(11.5节和12.7节)。
在第11章和第12章讨论线程之前,我们暂不考虑专门针对线程的3种终止方式。
上节提及的启动例程是这样编写的,使得从main返回后立即调用exit函数。如果将启动例程以C代码形式表示(实际上该例程常常用汇编语言编写),则它调用main函数的形式可能是:
exit(main(argc, argv));
1.退出函数
3个函数用于正常终止一个程序:_exit和_Exit立即进入内核,exit则先执行一些清理处理,然后返回内核。
#include <stdlib.h>
void exit(int status);
void _Exit(int status);
#include <unistd.h>
void _exit(int status);
我们将在8.5节中讨论这3个函数对其他进程(如正在终止进程的父进程和子进程)的影响。
使用不同头文件的原因是exit和_Exit是由ISO C说明的,而_exit是由POSIX.1说明的。
由于历史原因,exit 函数总是执行一个标准 I/O 库的清理关闭操作:对于所有打开流调用fclose函数。回忆5.5节,这造成输出缓冲中的所有数据都被冲洗(写到文件上)。
3个退出函数都带一个整型参数,称为终止状态(或退出状态,exit status)。大多 数UNIX系统shell都提供检查进程终止状态的方法。如果(a)调用这些函数时不带终止状态,或(b)main执行了一个无返回值的return语句,或(c)main没有声明返回类型为整型,则该进程的终止状态是未定义的。但是,若main的返回类型是整型,并且main执行到最后一条语句时返回(隐式返回),那么该进程的终止状态是0。
这种处理是ISO C标准1999版引入的。历史上,若main函数终止时没有显式使用return语句或调用exit函数,那么进程终止状态是未定义的。
main函数返回一个整型值与用该值调用exit是等价的。于是在main函数中
exit(0);
等价于
return(0);
实例
图7-1中的程序是经典的“hello, world”实例。
图7-1 经典C程序
对该程序进行编译,然后运行,则可见到其终止码是随机的。如果在不同的系统上编译该程序,我们很可能得到不同的终止码,这取决于main函数返回时栈和寄存器的内容:
$ gcc hello.c
$ ./a.out
hello, world
$ echo $? 打印终止状态
13
现在,我们启用1999 ISO C编译器扩展,则可见到终止码改变了:
$ gcc -std=c99 hello.c 启用 gcc的1999 ISO C扩展
$ echo $? 打印终止状态
hello.c:4: warning: return type defaults to 'int'
$ ./a.out
hello, world
0
注意,当我们启用1999 ISO C扩展时,编译器发出警告消息。打印该警告消息的原因是:main函数的类型没有显式地声明为整型。如果我们增加了这一声明,那么此警告消息就不会出现。但是,如果我们使编译器所推荐的警告消息都起作用(使用-Wall标志),则可能见到类似于“control reaches end of nonvoid function.”(控制到达非void函数的尾端)这样的警告消息。
将main声明为返回整型,但在main函数体内用exit代替return,对某些C编译器和UNIX lint(1)程序而言会产生不必要的警告信息,因为这些编译器并不了解main中的exit与return语句的作用相同。避开这种警告信息的一种方法是在main中使用return语句而不是exit。但是这样做的结果是不能用UNIX的grep实用程序来找出程序中所有的exit调用。另一个解决方法是将main说明为返回void而不是int,然后仍然调用exit。这样做可以避免编译器的警告,但从程序设计角度看却并不正确,而且会产生其他的编译警告,因为 main 的返回类型应当是带符号整型。本章将main表示为返回整型,因为这是ISO C和POSIX.1所定义的。
不同的编译器产生警告消息的详细程度是不一样的。除非使用警告选项,否则GNU C编译器不会发出不必要的警告消息。
下一章我们将了解进程如何造成程序被执行,如何等待进程完成,然后又如何获取其终止状态。
2.函数atexit
按照ISO C的规定,一个进程可以登记多至32个函数,这些函数将由exit自动调用。我们称这些函数为终止处理程序(exit handler),并调用atexit函数来登记这些函数。
#include <stdlib.h>
int atexit(void (*func)(void));
返回值:若成功,返回0;若出错,返回非0
其中,atexit 的参数是一个函数地址,当调用此函数时无需向它传递任何参数,也不期望它返回一个值。exit调用这些函数的顺序与它们登记时候的顺序相反。同一函数如若登记多次,也会被调用多次。
终止处理程序这一机制是由ANSI C标准于1989年引入的。早于ANSI C的系统,如SVR3和4.3BSD,都不提供这种终止处理程序。
ISO C要求,系统至少应支持32个终止处理程序,但实现经常会提供更多的支持(参见图2-15)。为了确定一个给定的平台支持的最大终止处理程序数,可以使用sysconf函数(如图2-14所示)。
根据ISO C和POSIX.1,exit首先调用各终止处理程序,然后关闭(通过fclose)所有打开流。POSIX.1扩展了ISO C标准,它说明,如若程序调用exec函数族中的任一函数,则将清除所有已安装的终止处理程序。图7-2显示了一个C程序是如何启动的,以及它终止的各种方式。
图7-2 一个C程序是如何启动和终止的
注意,内核使程序执行的唯一方法是调用一个exec函数。进程自愿终止的唯一方法是显式或隐式地(通过调用 exit)调用_exit 或_Exit。进程也可非自愿地由一个信号使其终止(图 7-2中没有显示)。
实例
图7-3的程序说明如何使用atexit函数。
图7-3 终止处理程序实例
执行该程序产生:
$ ./a.out
main is done
first exit handler
first exit handler
second exit handler
终止处理程序每登记一次,就会被调用一次。在图7-3的程序中,第一个终止处理程序被登记两次,所以也会被调用两次。注意,在main中没有调用exit,而是用了return语句。
当执行一个程序时,调用exec的进程可将命令行参数传递给该新程序。这是UNIX shell的一部分常规操作。在前几章的很多实例中,我们已经看到了这一点。
实例
图7-4 所示的程序将其所有命令行参数都回显到标准输出上。注意,通常的 echo(1)程序不回显第0个参数。
图7-4 将所有命令行参数回显到标准输出
编译此程序,并将可执行代码文件命名为echoarg,则得到:
$ ./echoarg arg1 TEST foo
argv[0]: ./echoarg
argv[1]: arg1
argv[2]: TEST
argv[3]: foo
ISO C和POSIX.1都要求argv[argc]是一个空指针。这就使我们可以将参数处理循环改写为:
for (i = 0; argv[i] != NULL; i++)
每个程序都接收到一张环境表。与参数表一样,环境表也是一个字符指针数组,其中每个指针包含一个以null结束的C字符串的地址。全局变量environ则包含了该指针数组的地址:
extern char **environ;
例如,如果该环境包含5个字符串,那么它看起来如图7-5中所示。其中,每个字符串的结尾处都显式地有一个null字节。我们称environ为环境指针(environment pointer),指针数组为环境表,其中各指针指向的字符串为环境字符串。
图7-5 由5个字符串组成的环境
按照惯例,环境由
name = value
这样的字符串组成,如图7-5中所示。大多数预定义名完全由大写字母组成,但这只是一个惯例。
在历史上,大多数UNIX系统支持main函数带3个参数,其中第3个参数就是环境表地址:
int main(int argc, char *argv[], char *envp[]);
因为ISO C规定main函数只有两个参数,而且第3个参数与全局变量environ相比也没有带来更多益处,所以 POSIX.1 也规定应使用 environ 而不使用第 3 个参数。通常用 getenv 和putenv函数(见7.9节)来访问特定的环境变量,而不是用environ变量。但是,如果要查看整个环境,则必须使用environ指针。
历史沿袭至今,C程序一直由下列几部分组成:
•正文段。这是由CPU执行的机器指令部分。通常,正文段是可共享的,所以即使是频繁执行的程序(如文本编辑器、C编译器和shell等)在存储器中也只需有一个副本,另外,正文段常常是只读的,以防止程序由于意外而修改其指令。
•初始化数据段。通常将此段称为数据段,它包含了程序中需明确地赋初值的变量。例如, C程序中任何函数之外的声明:
int maxcount = 99;
使此变量以其初值存放在初始化数据段中。
•未初始化数据段。通常将此段称为bss段,这一名称来源于早期汇编程序一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,内核将此段中的数据初始化为0或空指针。函数外的声明:
long sum[1000];
使此变量存放在非初始化数据段中。
•栈。自动变量以及每次函数调用时所需保存的信息都存放在此段中。每次函数调用时,其返回地址以及调用者的环境信息(如某些机器寄存器的值)都存放在栈中。然后,最近被调用的函数在栈上为其自动和临时变量分配存储空间。通过以这种方式使用栈,C递归函数可以工作。递归函数每次调用自身时,就用一个新的栈帧,因此一次函数调用实例中的变量集不会影响另一次函数调用实例中的变量。
•堆。通常在堆中进行动态存储分配。由于历史上形成的惯例,堆位于未初始化数据段和栈之间。
图 7-6 显示了这些段的一种典型安排方式。这是程序的逻辑布局,虽然并不要求一个具体实现一定以这种方式安排其存储空间,但这是一种我们便于说明的典型安排。对于 32 位 Intel x86 处理器上的 Linux,正文段从 0x08048000 单元开始,栈底则在0xC0000000 之下开始(在这种特定结构中,栈从高地址向低地址方向增长)。堆顶和栈顶之间未用的虚地址空间很大。
图7-6 典型的存储空间安排
a.out中还有若干其他类型的段,如包含符号表的段、包含调试信息的段以及包含动态共享库链接表的段等。这些部分并不装载到进程执行的程序映像中。
从图7-6还可注意到,未初始化数据段的内容并不存放在磁盘程序文件中。其原因是,内核在程序开始运行前将它们都设置为 0。需要存放在磁盘程序文件中的段只有正文段和初始化数据段。
size(1)命令报告正文段、数据段和bss段的长度(以字节为单位)。例如:
$ size /usr/bin/cc /bin/sh
text data bss dec hex filename
346919 3576 6680 357175 57337 /usr/bin/cc
102134 1776 11272 115182 1c1ee /bin/sh
第4列和第5列是分别以十进制和十六进制表示的3段总长度。
现在,大多数UNIX系统支持共享库。Arnold[1986]说明了System V上共享库的一个早期实现,Gingell等[1987]则说明了SunOS上的另一个实现。共享库使得可执行文件中不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种库例程的一个副本。程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相链接。这减少了每个可执行文件的长度,但增加了一些运行时间开销。这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。共享库的另一个优点是可以用库函数的新版本代替老版本而无需对使用该库的程序重新连接编辑(假定参数的数目和类型都没有发生改变)。
在不同的系统中,程序可能使用不同的方法说明是否要使用共享库。比较典型的有 cc(1)和ld(1)命令的选项。作为长度方面发生变化的例子,先用无共享库方式创建下列可执行文件(典型的hello.c程序):
$ gcc -static hello1.c 阻止gcc 使用共享库
-rwxrwxr-x 1 sar 879443 Sep 2 10:39 a.out
text data bss dec hex filename
787775 6128 11272 805175 c4937 a.out
$ ls -l a.out
$ size a.out
如果再使用共享库编译此程序,则可执行文件的正文和数据段的长度都显著减小:
$ gcc hello1.c gcc 默认使用共享库
-rwxrwxr-x 1 sar 8378 Sep 2 10:39 a.out
text data bss dec hex filename
1176 504 16 1696 6a0 a.out
$ ls -l a.out
$ size a.out
ISO C说明了3个用于存储空间动态分配的函数。
(1)malloc,分配指定字节数的存储区。此存储区中的初始值不确定。
(2)calloc,为指定数量指定长度的对象分配存储空间。该空间中的每一位(bit)都初始化为0。
(3)realloc,增加或减少以前分配区的长度。当增加长度时,可能需将以前分配区的内容
移到另一个足够大的区域,以便在尾端提供增加的存储区,而新增区域内的初始值则不确定。
#include <stdlib.h>
void *malloc(size_t size);
void *calloc(size_t nobj, size_t size);
void *realloc(void *ptr, size_t newsize);
3个函数返回值:若成功,返回非空指针;若出错,返回NULL
void free(void *ptr);
这3个分配函数所返回的指针一定是适当对齐的,使其可用于任何数据对象。例如,在一个特定的系统上,如果最苛刻的对齐要求是,double必须在8的倍数地址单元处开始,那么这3个函数返回的指针都应这样对齐。
因为这 3 个 alloc 函数都返回通用指针 void *,所以如果在程序中包括了#include<stdlib.h>(以获得函数原型),那么当我们将这些函数返回的指针赋予一个不同类型的指针时,就不需要显式地执行强制类型转换。未声明函数的默认返回值为int,所以使用没有正确函数声明的强制类型转换可能会隐藏系统错误,因为int类型的长度与函数返回类型值的长度不同(本例中是指针)。
函数free 释放ptr指向的存储空间。被释放的空间通常被送入可用存储区池,以后,可在调用上述3个分配函数时再分配。
realloc函数使我们可以增、减以前分配的存储区的长度(最常见的用法是增加该区)。例如,如果先为一个数组分配存储空间,该数组长度为 512,然后在运行时填充它,但运行一段时间后发现该数组原先的长度不够用,此时就可调用 realloc 扩充相应存储空间。如果在该存储区后有足够的空间可供扩充,则可在原存储区位置上向高地址方向扩充,无需移动任何原先的内容,并返回与传给它相同的指针值。如果在原存储区后没有足够的空间,则 realloc 分配另一个足够大的存储区,将现存的512个元素数组的内容复制到新分配的存储区。然后,释放原存储区,返回新分配区的指针。因为这种存储区可能会移动位置,所以不应当使任何指针指在该区中。习题4.16和图C-3显示了在getcwd中如何使用realloc,以处理任何长度的路径名。图17-27的程序是使用realloc的另一个例子,用其可以避免使用编译时固定长度的数组。
注意,realloc的最后一个参数是存储区的新长度,不是新、旧存储区长度之差。作为一个特例,若ptr是一个空指针,则realloc的功能与malloc相同,用于分配一个指定长度为newsize的存储区。
这些函数的早期版本允许调用realloc分配自上次malloc、realloc或calloc调用以来所释放的块。这种技巧可回溯到 V7,它利用 malloc 的搜索策略,实现存储器紧缩。Solaris仍支持这一功能,而很多其他平台则不支持。这种功能不被赞同,不应再使用。
这些分配例程通常用sbrk(2)系统调用实现。该系统调用扩充(或缩小)进程的堆(见图7-6)。malloc和free的一个样例实现请见Kernighan和Ritchie[1988]的8.7节。
虽然sbrk可以扩充或缩小进程的存储空间,但是大多数malloc和free的实现都不减小进程的存储空间。释放的空间可供以后再分配,但将它们保持在malloc池中而不返回给内核。
大多数实现所分配的存储空间比所要求的要稍大一些,额外的空间用来记录管理信息——分配块的长度、指向下一个分配块的指针等。这就意味着,如果超过一个已分配区的尾端或者在已分配区起始位置之前进行写操作,则会改写另一块的管理记录信息。这种类型的错误是灾难性的,但是因为这种错误不会很快就暴露出来,所以也就很难发现。
在动态分配的缓冲区前或后进行写操作,破坏的可能不仅仅是该区的管理记录信息。在动态分配的缓冲区前后的存储空间很可能用于其他动态分配的对象。这些对象与破坏它们的代码可能无关,这造成寻求信息破坏的源头更加困难。
其他可能产生的致命性的错误是:释放一个已经释放了的块;调用free时所用的指针不是3个alloc函数的返回值等。如若一个进程调用malloc函数,但却忘记调用free函数,那么该进程占用的存储空间就会连续增加,这被称为泄漏(leakage)。如果不调用free函数释放不再使用的空间,那么进程地址空间长度就会慢慢增加,直至不再有空闲空间。此时,由于过度的换页开销,会造成性能下降。
因为存储空间分配出错很难跟踪,所以某些系统提供了这些函数的另一种实现版本。每次调用这3个分配函数中的任意一个或free时,它们都进行附加的检错。在调用连接编辑器时指定一个专用库,在程序中就可使用这种版本的函数。此外还有公共可用的资源,在对其进行编译时使用一个特殊标志就会使附加的运行时检查生效。
FreeBSD、Mac OS X以及Linux通过设置环境变量支持附加的调试功能。另外,通过符号链接/etc/malloc.conf可将选项传递给FreeBSD函数库。
替代的存储空间分配程序
有很多可替代malloc和free的函数。某些系统已经提供替代存储空间分配函数的库。另一些系统只提供标准的存储空间分配程序。如果需要,软件开发者可以下载替代函数。下面讨论某些替代函数和库。
1.libmalloc
基于SVR4的UNIX系统,如Solaries,包含了libmalloc库,它提供了一套与ISO C存储空间分配函数相匹配的接口。libmalloc库包括mallopt函数,它使进程可以设置一些变量,并用它们来控制存储空间分配程序的操作。还可使用另一个名为mallinfo的函数,以对存储空间分配程序的操作进行统计。
2.vmalloc
Vo[1996]说明一种存储空间分配程序,它允许进程对于不同的存储区使用不同的技术。除了一些vmalloc特有的函数外,该库也提供了ISO C存储空间分配函数的仿真器。
3.quick-fit
历史上所使用的标准 malloc 算法是最佳适配或首次适配存储分配策略。quick-fit(快速适配)算法比上述两种算法快,但可能使用较多存储空间。Weinstock和Wulf[1988]对该算法进行了描述,该算法基于将存储空间分裂成各种长度的缓冲区,并将未使用的缓冲区按其长度组成不同的空闲区列表。现在许多分配程序都基于快速适配。
4.jemalloc
jemalloc函数实现是FreeBSD 8.0中的默认存储空间分配程序,它是库函数malloc族在FreeBSD中的实现。它的设计具有良好的可扩展性,可用于多处理器系统中使用多线程的应用程序。Evans[2006]说明了具体实现及其性能评估。
5.TCMalloc
TCMalloc函数用于替代malloc函数族以提供高性能、高扩展性和高存储效率。从高速缓存中分配缓冲区以及释放缓冲区到高速缓存中时,它使用线程-本地高速缓存来避免锁开销。它还有内置的堆检查程序和堆分析程序帮助调试和分析动态存储的使用。TCMalloc库是开源可用的,是Google-perftools工具中的一个。Ghemawat和Menage[2005]对此做了简单介绍。
6.函数alloca
还有一个函数也值得一提,这就是alloca。它的调用序列与malloc相同,但是它是在当前函数的栈帧上分配存储空间,而不是在堆中。其优点是:当函数返回时,自动释放它所使用的栈帧,所以不必再为释放空间而费心。其缺点是:alloca 函数增加了栈帧的长度,而某些系统在函数已被调用后不能增加栈帧长度,于是也就不能支持alloca函数。尽管如此,很多软件包还是使用alloca函数,也有很多系统实现了该函数。
本书中讨论的4个平台都提供了alloca函数。
如同前述,环境字符串的形式是:
name=value
UNIX内核并不查看这些字符串,它们的解释完全取决于各个应用程序。例如,shell使用了大量的环境变量。其中某一些在登录时自动设置(如HOME、USER等),有些则由用户设置。我们通常在一个shell启动文件中设置环境变量以控制shell的动作。例如,若设置了环境变量MAILPATH,则它告诉Bourne shell、GNU Bourne-again shell和Korn shell到哪里去查看邮件。
ISO C定义了一个函数getenv,可以用其取环境变量值,但是该标准又称环境的内容是由实现定义的。
#include <stdlib.h>
char *getenv(const char *name);
返回值:指向与name关联的value的指针;若未找到,返回NULL
注意,此函数返回一个指针,它指向name=value字符串中的value。我们应当使用getenv从环境中取一个指定环境变量的值,而不是直接访问environ。
Single UNIX Specification中的POSIX.1定义了某些环境变量。如果支持XSI扩展,那么其中也包含了另外一些环境变量定义。图7-7列出了由Single UNIX Specification定义的环境变量,并指明本书讨论的4种实现对它们的支持情况。由POSIX.1定义的各环境变量标记为•,否则为XSI扩展。本书讨论的4种UNIX实现使用了很多依赖于实现的环境变量。注意,ISO C没有定义任何环境变量。
图7-7 Single UNIX Specification定义的环境变量
除了获取环境变量值,有时也需要设置环境变量。我们可能希望改变现有变量的值,或者是增加新的环境变量。(在下一章将会了解到,我们能影响的只是当前进程及其后生成和调用的任何子进程的环境,但不能影响父进程的环境,这通常是一个shell进程。尽管如此,修改环境表的能力仍然是很有用的。)遗憾的是,并不是所有系统都支持这种能力。图7-8列出了由不同的标准及实现支持的各种函数。
图7-8 对于各种环境表函数的支持
clearenv不是Single UNIX Specification的组成部分。它被用来删除环境表中的所有项。在图7-8中,中间3个函数的原型是:
#include <stdlib.h>
int putenv(char *str);
函数返回值:若成功,返回0;若出错,返回非0
int setenv(const char *name, const char *value, int rewrite);
int unsetenv(const char *name);
两个函数返回值:若成功,返回0;若出错,返回−1
这3个函数的操作如下。
•putenv取形式为name=value的字符串,将其放到环境表中。如果name已经存在,则先删除其原来的定义。
•setenv将name设置为value。如果在环境中name已经存在,那么(a)若rewrite非0,则首先删除其现有的定义;(b)若rewrite为0,则不删除其现有定义(name不设置为新的value,而且也不出错)。
•unsetenv删除name的定义。即使不存在这种定义也不算出错。
注意,putenv和setenv之间的差别。setenv必须分配存储空间,以便依据其参数创建name=value字符串。putenv可以自由地将传递给它的参数字符串直接放到环境中。确实,许多实现就是这么做的,因此,将存放在栈中的字符串作为参数传递给putenv就会发生错误,其原因是,从当前函数返回时,其栈帧占用的存储区可能将被重用。
这些函数在修改环境表时是如何进行操作的呢?对这一问题进行研究、考察是非常有益的。回忆图7-6,其中,环境表(指向实际name=value字符串的指针数组)和环境字符串通常存放在进程存储空间的顶部(栈之上)。删除一个字符串很简单——只要先在环境表中找到该指针,然后将所有后续指针都向环境表首部顺次移动一个位置。但是增加一个字符串或修改一个现有的字符串就困难得多。环境表和环境字符串通常占用的是进程地址空间的顶部,所以它不能再向高地址方向(向上)扩展:同时也不能移动在它之下的各栈帧,所以它也不能向低地址方向(向下)扩展。两者组合使得该空间的长度不能再增加。
(1)如果修改一个现有的name:
a.如果新value的长度少于或等于现有value的长度,则只要将新字符串复制到原字符串所用的空间中;
b.如果新value的长度大于原长度,则必须调用malloc为新字符串分配空间,然后将新字符串复制到该空间中,接着使环境表中针对name的指针指向新分配区。
(2)如果要增加一个新的name,则操作就更加复杂。首先,必须调用malloc为name=value字符串分配空间,然后将该字符串复制到此空间中。
a.如果这是第一次增加一个新name,则必须调用malloc为新的指针表分配空间。接着,将原来的环境表复制到新分配区,并将指向新name=value字符串的指针存放在该指针表的表尾,然后又将一个空指针存放在其后。最后使environ指向新指针表。再看一下图7-6,如果原来的环境表位于栈顶之上(这是一种常见情况),那么必须将此表移至堆中。
但是,此表中的大多数指针仍指向栈顶之上的各name=value字符串。
b.如果这不是第一次增加一个新name,则可知以前已调用malloc在堆中为环境表分配了空间,所以只要调用 realloc,以分配比原空间多存放一个指针的空间。然后将指向新name=value字符串的指针存放在该表表尾,后面跟着一个空指针。
在C中,goto语句是不能跨越函数的,而执行这种类型跳转功能的是函数setjmp和longjmp。这两个函数对于处理发生在很深层嵌套函数调用中的出错情况是非常有用的。
考虑图7-9程序的骨架部分。其主循环是从标准输入读一行,然后调用do_line处理该输入行。do_line函数调用get_token从该输入行中取下一个标记。一行中的第一个标记假定是一条某种形式的命令,switch语句就实现命令选择。对程序中示例的命令调用cmd_add函数。
图7-9 进行命令处理程序的典型骨架部分
图7-9的程序的骨架部分在读命令、确定命令的类型,然后调用相应函数处理每一条命令这类程序中是非常典型的。图7-10显示了调用了cmd_add之后栈的大致使用情况。
自动变量的存储单元在每个函数的栈桢中。数组line在main的栈帧中,整型cmd在do_line的栈帧中,整型token在cmd_add的栈帧中。
如上所述,这种形式的栈安排是非常典型的,但并不要求非如此不可。栈并不一定要向低地址方向扩充。某些系统对栈并没有提供特殊的硬件支持,此时一个 C实现可能要用链表实现栈帧。
在编写图7-9 这样的程序时经常会遇到的一个问题是,如何处理非致命性的错误。例如,若 cmd_add 函数发现一个错误(比如一个无效的数),那么它可能先打印一个出错消息,然后忽略输入行的余下部分,返回main函数并读下一输入行。但是如果这种情况出现在main函数中的深层嵌套层中时,用C语言难以做到这一点(在本例中,cmd_add函数只比main低两个层次,在有些程序中往往低5个层次或更多)。如果我们不得不以检查返回值的方法逐层返回,那就会变得很麻烦。
图7-10 调用cmd_add后的各个栈帧
解决这种问题的方法就是使用非局部goto——setjmp和longjmp函数。非局部指的是,这不是由普通的C语言goto语句在一个函数内实施的跳转,而是在栈上跳过若干调用帧,返回到当前函数调用路径上的某一个函数中。
#include <setjmp.h>
int setjmp(jmp_buf env);
返回值:若直接调用,返回0;若从longjmp返回,则为非0
void longjmp(jmp_buf env, int val);
在希望返回到的位置调用setjmp,在本例中,此位置在main函数中。因为我们直接调用该函数,所以其返回值为0。setjmp参数env的类型是一个特殊类型jmp_buf。这一数据类型是某种形式的数组,其中存放在调用 longjmp 时能用来恢复栈状态的所有信息。因为需在另一个函数中引用env变量,所以通常将env变量定义为全局变量。
当检查到一个错误时,例如在cmd_add函数中,则以两个参数调用longjmp函数。第一个就是在调用setjmp时所用的env;第二个参数是具非0值的val,它将成为从setjmp处返回的值。使用第二个参数的原因是对于一个setjmp可以有多个longjmp。例如,可以在cmd_add中以val为1调用longjmp,也可在get_token中以val为2调用longjmp。在main函数中,setjmp的返回值就会是1或2,通过测试返回值就可判断造成返回的longjmp是在cmd_add还是在get_token中。
再回到程序实例中,图7-11中给出了经修改过后的main和cmd_add函数(其他两个函数do_line和get_token未更改)。
图7-11 setjmp和longjmp实例
执行main时,调用setjmp,它将所需的信息记入变量jmpbuffer中并返回0。然后调用do_line,它又调用cmd_add,假定在其中检测到一个错误。在 cmd_add 中调用 longjmp 之前,栈如图 7-10 中所示。但是longjmp使栈反绕到执行main函数时的情况,也就是抛弃了cmd_add和do_line的栈帧(见图 7-12)。调用 longjmp 造成 main 中setjmp 的返回,但是,这一次的返回值是 1 (longjmp的第二个参数)。
图7-12 在调用longjmp后的栈帧
1.自动变量、寄存器变量和易失变量
我们已经了解在调用 longjmp 后栈帧的基本结构,下一个问题是:“在main函数中,自动变量和寄存器变量的状态如何?”当longjmp返回到main 函数时,这些变量的值是否能恢复到以前调用setjmp时的值(即回滚到原先值),或者这些变量的值保持为调用do_line时的值(do_line调用cmd_add,cmd_add 又调用longjmp)?遗憾的是,对此问题的回答是“看情况”。大多数实现并不回滚这些自动变量和寄存器变量的值,而所有标准则称它们的值是不确定的。如果你有一个自动变量,而又不想使其值回滚,则可定义其为具有volatile属性。声明为全局变量或静态变量的值在执行longjmp时保持不变。
实例
下面我们通过图7-13程序说明在调用longjmp后,自动变量、全局变量、寄存器变量、静态变量和易失变量的不同情况。
图7-13 longjmp对各类变量的影响
如果以不带优化和带优化选项对此程序分别进行编译,然后运行它们,则得到的结果是不同的:
$ gcc testjmp.c 不进行任何优化的编译
$ ./a.out
in f1():
globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99
after longjmp:
globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99
$ gcc -O testjmp.c 进行全部优化的编译
$ ./a.out
in f1():
globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99
after longjmp:
globval = 95, autoval = 2, regival = 3, volaval = 98, statval = 99
注意,全局变量、静态变量和易失变量不受优化的影响,在 longjmp 之后,它们的值是最近所呈现的值。在某个系统的setjmp(3)手册页上说明,存放在存储器中的变量将具有longjmp时的值,而在CPU和浮点寄存器中的变量则恢复为调用setjmp时的值。这确实就是运行图7-13程序时所观察到的值。不进行优化时,所有这5个变量都存放在存储器中(即忽略了对regival变量的register存储类说明)。而进行了优化后,autoval和regival都存放在寄存器中(即使autoval并未说明为register),volatile变量则仍存放在存储器中。通过这一实例我们可以理解到,如果要编写一个使用非局部跳转的可移植程序,则必须使用volatile属性。但是从一个系统移植到另一个系统,其他任何事情都可能改变。
在图7-13中,某些printf的格式字符串可能不适宜安排在程序文本的一行中。我们没有将其分成多个printf调用,而是使用了ISO C的字符串连接功能,于是两个字符串序列
"string1" "string2"
等价于
"string1string2"
第 10 章讨论信号处理程序及 sigsetjmp 和 siglongjmp 时,将再次涉及 setjmp 和longjmp函数。
2.自动变量的潜在问题
前面已经说明了处理栈帧的一般方式,现在值得分析一下自动变量的一个潜在出错情况。基本规则是声明自动变量的函数已经返回后,不能再引用这些自动变量。在整个UNIX手册中,关于这一点有很多警告。
图7-14中给出了一个名为open_data的函数,它打开了一个标准I/O流,然后为该流设置缓冲。
图7-14 自动变量的不正确使用
问题是:当open_data返回时,它在栈上所使用的空间将由下一个被调用函数的栈帧使用。但是,标准I/O库函数仍将使用这部分存储空间作为该流的缓冲区。这就产生了冲突和混乱。为了改正这一问题,应在全局存储空间静态地(如static或extern)或者动态地(使用一种alloc函数)为数组databuf分配空间。
每个进程都有一组资源限制,其中一些可以用getrlimit和setrlimit函数查询和更改。
#include <sys/resource.h>
int getrlimit(int resource, struct rlimit *rlptr);
int setrlimit(int resource, const struct rlimit *rlptr);
两个函数返回值:若成功,返回0;若出错,返回非0
这两个函数在Single UNIX Specification的XSI扩展中定义。进程的资源限制通常是在系统初始化时由0进程建立的,然后由后续进程继承。每种实现都可以用自己的方法对资源限制做出调整。
对这两个函数的每一次调用都指定一个资源以及一个指向下列结构的指针。
struct rlimit {
rlim_t rlim_cur; /* soft limit: current limit */
rlim_t rlim_max; /* hard limit: maximum value for rlim_cur */
};
在更改资源限制时,须遵循下列3条规则。
(1)任何一个进程都可将一个软限制值更改为小于或等于其硬限制值。
(2)任何一个进程都可降低其硬限制值,但它必须大于或等于其软限制值。这种降低,对普通用户而言是不可逆的。
(3)只有超级用户进程可以提高硬限制值。
常量RLIM_INFINITY指定了一个无限量的限制。
这两个函数的 resource 参数取下列值之一。图 7-15 显示哪些资源限制是由 Single UNIX Specification定义并由本书讨论的4种UNIX系统实现支持的。
图7-15 对资源限制的支持
RLIMIT_AS 进程总的可用存储空间的最大长度(字节)。这影响到 sbrk 函数(1.11节)和mmap函数(14.8节)。
RLIMIT_CORE core文件的最大字节数,若其值为0则阻止创建core文件。
RLIMIT_CPU CPU时间的最大量值(秒),当超过此软限制时,向该进程发送SIGXCPU信号。
RLIMIT_DATA 数据段的最大字节长度。这是图 7-6 中初始化数据、非初始以及堆的总和。
RLIMIT_FSIZE 可以创建的文件的最大字节长度。当超过此软限制时,则向该进程发送SIGXFSZ信号。
RLIMIT_MEMLOCK 一个进程使用mlock(2)能够锁定在存储空间中的最大字节长度。
RLIMIT_MSGQUEUE 进程为POSIX消息队列可分配的最大存储字节数。
RLIMIT_NICE 为了影响进程的调度优先级,nice值(8.16节)可设置的最大限制。
RLIMIT_NOFILE 每个进程能打开的最多文件数。更改此限制将影响到sysconf函数在参数_SC_OPEN_MAX中返回的值(见2.5.4节),亦见图2-17。
RLIMIT_NPROC 每个实际用户 ID 可拥有的最大子进程数。更改此限制将影响到sysconf函数在参数_SC_CHILD_MAX中返回的值(见2.5.4节)。
RLIMIT_NPTS 用户可同时打开的伪终端(第19章)的最大数量。
RLIMIT_RSS 最大驻内存集字节长度(resident set size in bytes,RSS)。如果可用的物理存储器非常少,则内核将从进程处取回超过RSS的部分。
RLIMIT_SBSIZE 在任一给定时刻,一个用户可以占用的套接字缓冲区的最大长度(字节)。
RLIMIT_SIGPENDING 一个进程可排队的信号最大数量。这个限制是sigqueue函数实施的(10.20节)。
RLIMIT_STACK 栈的最大字节长度。见图7-6。
RLIMIT_SWAP 用户可消耗的交换空间的最大字节数
RLIMIT_VMEM 这是RLIMIT_AS的同义词。
资源限制影响到调用进程并由其子进程继承。这就意味着,为了影响一个用户的所有后续进程,需将资源限制的设置构造在shell之中。确实,Bourne shell、GNU Bourne-again shell和Korn shell具有内置的ulimit命令,C shell具有内置limit命令。(umask和chdir函数也必须是shell内置的。)
实例
图7-16的程序打印由系统支持的所有资源当前的软限制和硬限制。为了在各种实现上编译该程序,我们已经条件地包括了各种不同的资源名。注意,有些平台定义rlim_t为unsigned long long 而非 unsigned long。在同一系统中这个定义可能也会变动,这取决于我们在编译程序候是否支持 64 位文件。有些限制作用于文件大小,因此 rlim_t 类型必须足够大才能表示文件大小限制。为了避免使用错误的格式说明而导致编译器警告,通常会首先把限制复制到 64 位整型,这样只需处理一种格式。
图7-16 打印当前资源限制
注意,在doit宏中使用了ISO C的字符串创建算符(#),以便为每个资源名产生字符串值。例如:
doit(RLIMIT_CORE);
这将由C预处理程序扩展为:
pr_limits("RLIMIT_CORE", RLIMIT_CORE);
在FreeBSD下运行此程序,得到:
$ ./a.out
RLIMIT_AS (infinite) (infinite)
RLIMIT_CORE (infinite) (infinite)
RLIMIT_CPU (infinite) (infinite)
RLIMIT_DATA 536870912 536870912
RLIMIT_FSIZE (infinite) (infinite)
RLIMIT_MEMLOCK (infinite) (infinite)
RLIMIT_NOFILE 3520 3520
RLIMIT_NPROC 1760 1760
RLIMIT_NPTS (infinite) (infinite)
RLIMIT_RSS (infinite) (infinite)
RLIMIT_SBSIZE (infinite) (infinite)
RLIMIT_STACK 67108864 67108864
RLIMIT_SWAP (infinite) (infinite)
RLIMIT_VMEM (infinite) (infinite)
在Solaris下运行此程序,得到:
$ ./a.out
RLIMIT_AS (infinite) (infinite)
RLIMIT_CORE (infinite) (infinite)
RLIMIT_CPU (infinite) (infinite)
RLIMIT_DATA (infinite) (infinite)
RLIMIT_FSIZE (infinite) (infinite)
RLIMIT_NOFILE 256 65536
RLIMIT_STACK 8388608 (infinite)
RLIMIT_VMEM (infinite) (infinite)
在介绍了信号机制后,习题10.11将继续讨论资源限制。
理解UNIX系统环境中C程序的环境是理解UNIX系统进程控制特性的先决条件。本章说明了一个进程是如何启动和终止的,如何向其传递参数表和环境。虽然参数表和环境都不是由内核进行解释的,但内核起到了从exec的调用者将这两者传递给新进程的作用。
本章也说明了C程序的典型存储空间布局,以及一个进程如何动态地分配和释放存储空间。详细地了解用于维护环境的一些函数是有意义的,因为它们涉及存储空间分配。本章也介绍了setjmp 和 longjmp 函数,它们提供了一种在进程内非局部转移的方法。最后介绍了各种实现提供的资源限制功能。
7.1 在Intel x86系统上,使用Linux,如果执行一个输出“hello, world”的程序但不调用exit或return,则程序的返回代码为13(用shell检查),解释其原因。
7.2 图7-3中的printf函数的结果何时才被真正输出?
7.3 是否有方法不使用(a)参数传递、(b)全局变量这两种方法,将main中的参数argc和argv传递给它所调用的其他函数?
7.4 在有些 UNIX 系统实现中执行程序时访问不到其数据段的 0 单元,这是一种有意的安排,为什么?
7.5 用C语言的typedef为终止处理程序定义了一个新的数据类型Exitfunc,使用该类型修改atexit的原型。
7.6 如果用calloc分配一个long型的数组,数组的初始值是否为0?如果用calloc分配一个指针数组,数组的初始值是否为空指针?
7.7 在7.6节结尾处size命令的输出结果中,为什么没有给出堆和栈的大小?
7.8 为什么7.7节中两个文件的大小(879 443和8 378)不等于它们各自文本和数据大小的和?
7.9 为什么7.7节中对于一个简单的程序,使用共享库以后其可执行文件的大小变化如此巨大?
7.10 在7.10节中我们已经说明为什么不能将一个指针返回给一个自动变量,下面的程序是否正确?
int
f1(int val)
{
}
int num = 0;
int *ptr = #
if (val == 0) {
int val;
val = 5;
ptr = &val;
}
return(*ptr + 1);