学完UML后开始继续学习Linux环境下的编程,APUE很厚,我直接挑我最感兴趣的几章开始学习,今天学习了进程环境有关的知识,遇到了很多以前从未想过的问题,在这做个笔记做个记录。
进程环境主要要讨论的问题就是当程序执行时,main函数是如何被调用的,命令行参数是如何传递给新程序的,典型的存储空间布局是什么样式,如何分配另外的存储空间,进程如何使用环境变量,进程的各种不同终止方式等等。
进程终止:
在UNIX系统中,Linux 系统一共有 8 种进程终止方式。
其中 5 种为正常终止方式:
1)从 main() 函数返回;
2)调用 exit(3) 函数;
3)调用 _exit(2) 或 _Exit(2) 函数;
4)最后一个线程从其启动例程返回;
5)从最后一个线程调用 pthread_exit(3) 函数。
剩下的 3 种为异常终止方式:
本章要重点讨论的是退出函数,对于退出函数来说,_exit和_Exit都是立即进入内核态,而exit函数则要先执行一些清理操作,然后再返回内核。6)调用 abort(3) 函数;
7)接收到一个信号;
8)最后一个线程对取消请求作出响应。
以下为函数原型:
#include <stdlib.h>
void exit(int status);
void _Exit(int status);
#include <unistd.h>
void _exit(int status);
我们知道,内核启动执行一个C程序的时候,也就是调用我们的main函数之前,是首先调用一个特殊的启动例程的。可执行程序文件将次启动例程指定为程序的起始地址,启动例程还会从内核中取得命令行参数和环境变量的值,然后为按上述方式调用的main函数做好安排。
所以对于上述的exit函数,其真实的调用应该是这样的:
exit(main(argc,argv))
ps:上述的函数中使用不同的头文件是因为exit和_Exit是由ISO C说明的,而_exit是由POSIX.1说明的。
其中三个函数都带一个整型参数是status,这个表示了终止状态。如果说调用这些函数时不带终止状态,main执行了一个无返回值的return语句,或者main没有声明返回类型为整型,则进程的终止状态是未定义的。如果main函数的返回类型是整型,并且main执行到最后一句时返回(隐式返回),那么进程的终止状态是0。
现在对于exit函数,其可以在退出时做一系列清理现场的工作,那么这个工作我们是否可以自定义呢?答案是肯定的。
UNIX系统提供了一个函数叫做atexit,用他,一个进程最多可以登记32个函数,这些函数在退出时候会由exit函数自动调用来辅助清理,这些函数我们称之为终止处理程序。
从这张图可以看到,退出方式有很多,在main函数调用用户函数的时候可以直接进行退出,进入内核态,用户函数main函数和启动例程也都可以调用_exit或者_Exit函数来直接进入内核态,exit函数则要进行一系列的终止处理。书上给出了一个实例:
最后的运行结果是:
main is done
first exit handler
first exit handler
second exit handler
由此可见,对于atexit函数来说,终止处理程序的执行顺序和它们登记的时候的顺序正好相反。
环境表
每个程序都有一张环境表,环境表主要用来保存系统中的一系列环境变量,环境表还有一个环境指针指向他,如图所示:
C程序的存储布局和存储空间的分配:
C程序一直由以下几部分组成:
正文段。这是由CPU执行的机器指令部分。通常,正文段是可共享的,所以即使是频繁的执行程序,在存储器中也只有一个副本,另外,正文段常常是制度的,以防止程序由于意外而修改其指令。
初始化数据段。通常将此称为数据段,它包含了程序中需明确地赋初值的变量。例如,C程序中出现在任何函数之外的声明:
int maxcount = 99;
非初始化数据段。通常将此段称为bss段,这一名称来源于一个早期的汇编运算符,意思是“block started by symbol"(由符号开始的块),在程序开始执行之前,内核将此段中的数据初始化为0或空指针。出现在任何函数外的C声明
long sum[1000];
栈。自动变量以及每次函数调用时所需保存的信息都存放在此段中。每次调用函数时,其返回地址以及调用者的环境变量(例如某些机器寄存器的值)都存放在栈中。然后,最近被调用的函数在栈上为其自动和临时变量分配存储空间。通过以这种方式使用栈,可以递归调用C函数。递归函数每次调用自身时,就使用一个新的栈帧,因此一个函数调用实例中的变量集不会影响另一个函数调用实例中的变量。
堆。通常在堆中进行动态存储分配。由于历史上形成的惯例,堆位于非初始化数据段和栈之间。
特别要注意的是堆,堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。
下面来谈动态分配,UNIX提供了3个用于存储空间动态分配的函数:
(1) malloc 分配指定字节数的存储区。此存储区中的初始值不确定
(2) calloc 为指定长度的对象,分配能容纳其指定个数的存储空间。该空间中的每一位(bit)都初始化为0
(3) realloc 更改以前分配区的长度(增加或减少)。当增加长度时,可能需将以前分配区的内容移到另一个足够大的区域,而新增区域内的初始值则不确定
环境变量:
在UNIX中程序也可以设置自己的环境变量,提供了三个函数:
int putenv(char *str);
int setenv(const char *name, const char *value, int rewrite);
int unsetenv(const char *name);
三个函数返回值:成功返回0,出错返回非0
getenv()函数就是获得某个环境变量的值;
putenv()取形式为name = value的,并将其放入环境表中。如果name已经存在,则先删除原来的定义字符串。
setenv()将name设置为value。如果环境中name已经存在,则若rewrite非0,则首先删除现有的定义;若rewrite为0,则不删除现有定义(什么都不干)。
unsetenv()则删除name的定义,即使不存在也不出错。
现在,环境表和环境字符串是存放在我们前面的存储空间的顶部的,也就是栈的上面,如果我要删除一个环境变量,那么很简单,只要在环境表中找到该指针,然后将所有后续指针都向环境表首部顺次移动一个位置。如果我们要增加一个字符串或者修改呢?那就比较麻烦了。环境表和环境字符串通常占有的是进程地址空间的顶部,所以不能向上扩展,不然就影响到别的程序了,同时下面就是栈,所以也不能向低地址进行扩展,那么我们就需要调用malloc函数为新的表分配空间,这个空间是向堆去申请的,申请过后,把原来的表复制一份到新的内存空间,然后把你新插入的环境变量插入到这个表的表尾,最后在后面再放入一个空指针,再用环境指针指向这个表,就完成了整个过程。书上最后还说,此表中的大多数指针仍然指向栈顶上的各个name=value串,我的理解是,对于原来的环境变量,应该还是老位置,新的环境变量则插入到新申请的堆空间中。