《UNIX环境高级编程》笔记 第七章-进程环境
1. 进程终止
有八种方式使进程终止。其中5种是正常 ,它们是:
- 从main函数返回
- 调用exit
- 调用_exit或_Exit
- 最后一个线程从其启动例程返回
- 从最后一个线程调用pthread_exit
异常终止 有三种方式:
- 调用abort
- 收到一个信号
- 最后一个线程对取消请求做出响应
1.1 退出函数
以下三个函数用于正常终止一个程序 。其中_exit和_Exit是系统调用,立即进入内核;exit则先执行一些清理工作,然后返回内核 。
void _exit(int status);
void _Exit(int status);
void exit(int status);
exit函数总是执行标准I/O库的清理关闭操作,对于所有打开的流调用fclose函数,这会造成输出缓冲中的所有数据被写(冲洗)到文件上。
这三个退出函数都有一个参数,即终止状态(或退出状态) 。main函数返回一个整形值与用该值调用exit是等价的。于是在main函数中exit(0);等同于return 0;
1.2 atexit函数
一个进程可以登记最多32个函数(一些操作系统实现可能更多)个函数,这些函数将由exit自动调用。我们称这些函数为终止处理程序,通过atexit函数来登记这些函数
int atexit(void (*function)(void));
exit调用这些函数的顺序与登记它们的顺序相反,同一函数如果被登记多次也会被调用多次 。
exit函数会先调用各终止处理程序,再fclose所有打开流。然后再调用_exit函数终止进程 。
如果程序调用exec函数族,则会清除所有已经注册的终止处理程序 。

内核使程序执行的唯一方法是调用一个exec函数。进程自愿终止的唯一方法是显式或隐式(通过exit)调用_exit或_Exit 。进程也可以非自愿的由一个信号终止。
2. 命令行参数
调用exec函数的进程可以将命令行参数传递给新程序 。
UNIX内核并不查看这些字符串,它们的解释完全取决于各个应用程序,因此需要通过exec将这些参数传递给进程
命令行参数保存在main函数的形参内
int main(int argc, char* argv[]);
其中argc是命令行参数个数,argv是一个指针数组并且该数组以NULL元素结尾。
示例 :
int main(int argc, char* argv[]) {
for(int i = 0 ; i < argc ; ++i) {
cout << argv[i] <<" ";
}
cout << endl;
}
$ ./a.out aa bb 123
> ./a.out aa bb 123
可以看出可执行程序名即为第一个命令行参数。
3. 环境表
每一个进程都有一张环境表,该表也是一个字符指针数组,其中每个指针指向一个以null结束的C字符串地址。全局变量environ包含了该指针数组的地址 。
extern char **environ;

每一个环境变量由name=value形式的字符串构成,其中name字段一般是大写字母组成。
当然也可以通过main函数的第三个参数来访问环境变量
int main(int argc, char* argv[], char**envp);
4. C程序的存储空间布局
C程序由以下部分组成:
正文段(或代码段).text :
由CPU执行的机器指令部分组成 (即程序编译之后,编译器会将代码翻译成二进制的机器码,机器码存储在代码段(.text)中)。通常正文段可以共享,所以即使是频繁执行的程序在存储器中也只需有一个副本。并且正文段通常是只读的,以防止程序由于意外而修改其指令。也有可能包含一些只读的常数变量,例如字符串常量等。
初始化数据段.data :
通常将此段称为数据段。用于保存有非0初始值的全局变量和静态变量 。
未初始化数据段.bss :
用于保存没有初始值或初值为0的全局变量和静态变量。在程序开始执行之前,内核将此段中的数据初始化为0和空指针 。
栈stack :
局部变量与每次函数调用时需要保存的信息存放在stack中 。每次函数调用时,其返回地址以及调用者的环境信息(如某些寄存器的值)都存放入栈。然后,最近被调用的函数在栈上为其分配栈帧 。递归的原理就是每次调用自身时,就用一个新的栈帧,因此一次函数调用实例中的变量不会影响到另一次函数调用实例中的变量。
堆heap :
通常在堆中进行动态存储分配。位于bss和stack中间。

可执行文件中还有一些其他类型的段:包含符号表的段;包含调试信息的段;包含动态库链接表的段等。这些部分并不装载到进程执行的程序映像中 。
可以看出,未初始化数据段的内容并不存放在磁盘程序文件中。因为内核在程序开始运行前将它们都设置为0。需要存放在磁盘程序文件中的只有正文段和初始化数据段 。
一个问题:
FILE* open_data() {
FILE* fp;
char databuf[BUFSIZ];
fp = fopen("t.txt","r");
setvbuf(fp,databuf,_IOLBF,BUFSIZ);//设置流缓冲区
return fp;
}
当函数返回时,它在栈上使用的空间将由下一个被调用函数的栈帧使用。但是标准I/O库仍将使用这部分存储空间作为该流的缓冲区,这样就造成冲突。因此应设置缓冲区为全局的或静态的,或在堆上动态创建缓冲区。
5. 共享库
共享库使得可执行文件中不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种库例程的一个副本。
程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相链接。这减少了每个可执行文件的长度,但增加了一些运行时间开销 。这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。共享库的另一个优点是可以用库函数的新版本代替老版本而无需对使用该库的程序重新编译 (假如参数个数与类型都不变)。
例子:
先用无共享库方式创建可执行文件
$ g++ -static test.cpp # 阻止g++使用动态库
$ size a.out
> text data bss dec hex filename
> 1763630 47652 17944 1829226 1be96a a.out
再使用共享库方式编译此程序,可以看出可执行文件的正文和数据段长度都显著减小 :
$ g++ test.cpp # g++默认使用共享库
$ size a.out
> text data bss dec hex filename
> 2414 664 280 3358 d1e a.out
6. 存储空间分配
6.1 在堆上动态分配内存
以下三个函数用于存储空间动态分配
- malloc:分配指定字节数的存储区,此存储区中的初始值不确定
- calloc:为指定数量指定长度的对象分配存储空间。该空间中的每一位都初始化为0
- realloc:增加或减少以前分配区的长度。当增加长度时,可能需要将以前分配区的内容移到另一个足够大的区域 ,以便在尾端提供增加的存储区,而新增区的初始值不确定。
void *malloc(size_t size);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
void free(void *ptr);
函数free释放ptr指向的存储空间,被释放的空间通常被送入可用存储区池 。之后,可在调用上述3个分配函数时再分配这些空间。
realloc函数使我们可以增减以前分配的存储区长度。比如我们在堆上有一个数组,想要扩充该数组的长度,并且在该存储区后有足够的空间可供扩充,则可以在原存储区位置上向高地址方向扩充,无需移动原先数组任何内容。如果在原存储区后没有足够空间,则realloc分配另一个足够大的存储区,将现有数组内容全部复制到新分配的存储区,然后释放原存储区,返回新存储区地址 。如果ptr是NULL,则realloc与malloc函数功能相同。
这些分配函数通常底层使用sbrk系统调用 。该系统调用扩充或缩小进程的堆。
虽然sbrk可以缩小堆区大小,但是大多数malloc和free的实现都不减少进程的存储空间,释放的空间可供以后再分配,将它们保存在malloc池中而不返回给内核
大多数实现所分配的存储空间比所要求的要稍微大一些,额外的空间用来记录管理信息:分配块的长度、指向下一个分配块的指针等 。这意味着如果超过一个已分配区的尾端或者在已分配区起始位置之前进行写操作,则会改写另一块的管理记录信息或其他动态分配对象,这种错误是灾难性的。
致命错误:释放了一个已经释放了的块;调用free时使用的指针不是3个alloc函数的返回值等 。
若使用malloc函数在堆上动态分配内存空间但是忘记调用free函数,那么该进程占用的存储空间就会连续增加,这称为内存泄漏 。如果不调用free释放不再使用的空间,那么进程地址空间长度会慢慢增加,直至不再有空闲空间。
6.2 在栈上分配内存空间
void *alloca(size_t size);
它的调用方式与malloc相同,但是在当前函数的栈帧上分配存储空间而不是在堆中 。
优点:当函数返回时自动释放它所使用的栈帧,不用手动free释放
缺点:增加了栈帧的长度,而某些系统的函数在已经被调用后不能增加栈帧长度,于是也不支持alloca函数 。
6.3 malloc函数分配堆空间算法:
摘自https://www.cnblogs.com/MrListening/p/5538665.html
常见的 malloc 分配算法三种:空闲链表、位图、对象池
空闲链表:
空闲链表的方法实际就是把堆上各个空闲的块按照链表的方式连接起来。
当调用malloc函数申请空间时,malloc将扫描空闲块链表,直到找到一个足够大的块为止。该算法称为“首次适应”,与之相对的算法是“最佳适应”,它寻找满足条件的最小块 。如果该块恰好与请求的大小相符合,则将它从链表中移走并返回给用户。如果该块太大,则将它分成两部分:大小合适的块返回给用户,剩下的部分留在空闲块链表中 。如果找不到一个足够大的块,则向操作系统申请一个大块并加入到空闲块链表中。
释放过程也是首先搜索空闲块链表,以找到可以插入被释放块的合适位置。如果与被释放块相邻的一边是一个空闲块,则将这两个块合成一个更大的块,这样存储空间不会有太多的碎片 。因为空闲块链表是以地址的递增顺序链接在一起的,所以很容易判断相邻的块是否空闲。
位图:
将整个堆分为大量的块 ,每个块的大小相同。当用户请求内存的时候,总是分配整数个块的数据:分配给用户的块中第一个块称为已分配区域的头(Head) ,分配给用户的其他块称为已分配区域的主体(Body) 。未分配出去的块称为空闲块(Free) 。可以用一个整数数组来记录块的使用情况。

比如上面的位图就是一个例子,该堆中分配了两片内存,第一片占用了四个块,第二片占用的五个块
这样分配的话,对于这个堆的存储信息,它是记录在一个数组里。因为每一个小块是有三种可能状态,那么用二进制的两个位就能够表示了,假如设为00位空闲,10位主体,11为头。这样整个位图在一个数组中就能表示了。
比如堆的大小为1Mb,分成1M/128=8000个块(每个块设为128字节),一个int是4个字节32位,那么能用8000/(32/2)=512个int来存储,所以一个大小为512int的数组就是一个完整的位图。
优点:
速度快:通过位图的特性,存储在一个数组内
稳定性好,易于管理。
每个块都必有一种状态,不需要额外信息
缺点:
- 容易造成块的浪费(容易产生碎片),因为毕竟它是整数倍的分配
- 当堆比较大的时候,可能这个位图会很大,数组很庞大,可能效率也并不像想象那么快
对象池:
在一些特定场合,被分配对象的大小经常是固定的几个值,针对这种情况设计一个更为高效的堆分配算法:如果每一次分配的空间大小都一样,可以按照这个标准为一个单位,将整个堆空间划分为大量的小块,每次请求只需要找到一个小块就可以了。因为不用每次查找合适的大小的内存返回,所以效率很高。
对象池的管理方法,可以采用空闲链表,也可以采用位图。
7. 环境变量
环境变量字符串形式是 :
NAME=value1:value2:value3
UNIX内核并不查看这些字符串,它们的解释完全取决于各个应用程序。例如shell就是用了大量环境变量。
Linux中常用环境变量 :
| 环境变量名 | 说明 |
|---|---|
| PATH | 搜索可执行文件的路径前缀列表 |
| HOME | 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录) |
| HISTSIZE | 指保存历史命令记录的条数。 |
| LOGNAME | 指当前用户的登录名。 |
| HOSTNAME | 指主机的名称,许多应用程序如果要用到主机名的话,通常是从这个环境变量中来取得的。 |
| SHELL | 指当前用户用的是哪种Shell。 |
| LANG/LANGUGE | 和语言相关的环境变量,使用多种语言的用户可以修改此环境变量 |
| PS1 | 命令基本提示符,对于root用户是#,对于普通用户是$ |
| PS2 | 附属提示符,默认是“>” |
可以通过getenv函数获取指定环境变量值
char *getenv(const char *name);
此函数返回一个指针,它指向name=value字符串中的value部分。
当我们要获取某个环境变量值时,不建议使用environ指针数组(因为这样需要遍历确定哪一个环境变量),而是要使用getenv函数。
通过下面函数设置环境变量
int putenv(char *string);
int setenv(const char *name, const char *value, int overwrite);
int unsetenv(const char *name);
我们可以通过上面函数改变现有变量的值,或者是增加新的环境变量。但是注意这里影响的只是当前进程及其子进程,不影响父进程环境 (通常是一个shell进程),即你在程序里做的改变不会反映到外部环境中。这里很好理解,因为这几个函数操作的对象都是C程序存储空间中的环境表(见之前的图),因此无法影响到外部。
- putenv取形式为name=value的字符串,将其放置到环境表中。如果name已存在则删除原来的定义。
- setenv将name设置为value。如果name不存在,则将name与value一起添加到环境中。如果环境中存在name,则如果overwrite为非零,则其值将更改为value;如果overwrite为零,则不会更改其值
- unsetenv删除name定义
需要注意,putenv函数直接将传递给它的参数放到环境表中 (即环境表指针数组中有一个元素直接赋值为该参数),因此将存放在栈中的字符串作为参数传递给putenv就会出错,因为从当前函数返回时,其栈帧占用的存储区可能会被重用 。
而setenv函数则会复制传入的字符串(即分配存储空间以存放name=value字符串),因此setenv不存在上面putenv中的问题。
以上函数具体操作过程:
环境表和环境字符串通常存放在进程存储空间顶部(栈之上)。删除一个字符串很简单,在环境表中找到该指针,然后将所有后续指针都向环境表首部移动一个位置;但是增加一个字符串或者修改一个现有字符串则较为困难。环境表和环境字符串通常占用的是进程地址空间顶部,所以不能再向高地址(向上)扩展,同时也不能移动在它之下的各栈帧,所以不能向低地址(向下)扩展。因此该空间长度不能增加。
-
修改一个现有环境变量:
- 如果新value长度少于或等于现有value长度,则只需要将新字符串复制到原字符串所用空间
- 如果value长度大于原长度,则必须调用malloc为新字符串分配空间 ,然后将新字符串复制到该空间中,使环境表中对应指针指向新分配区
-
如果要增加新环境变量,首先调用malloc为name=value字符串分配空间,然后将字符串复制到此空间中 。
- 如果这是第一次增加一个新name,则必须调用malloc为新的指针表分配空间。接着将原来的环境表复制到新分配区,并将指向新name=value字符串的指针存放在该指针表的表尾,然后将一个空指针放在其后,将environ指向新指针表。如之前的图所示,如果原来的环境表位于栈顶之上,name必须将此表移至堆中。但是此表中大多数指针仍指向栈顶之上的各name=value字符串。
- 如果这不是第一次增加一个新name,则可知以前已调用malloc为环境表分配了空间,所以只要调用realloc,为该空间多存放一个指针的空间,然后将指向新name=value字符串的指针存放在该表尾,后面跟NULL。
8. 函数setjmp和longjmp
在C中,goto语句不能跨越函数。因此可以执行这种跳转功能函数setjmp呵longjmp 。
比如一个例子:main函数调用do_line函数,do_line函数调用cmd_add函数,那么此时该进程栈的情况如下:

当cmd_add函数发现一个错误,那么如果想要返回main函数的话,因为它出现在main函数的深层嵌套层中,因此不得不以检查返回值的方法逐层返回,会变得很麻烦 。尤其是嵌套层数很多的时候,问题就会更加严重。
解决问题的方法就是使用非局部goto-setjmp和longjmp函数。这两个函数不是在一个函数内实施跳转,而是在栈上跳过若干调用帧,返回到当前函数调用路径上的某一个函数中 。
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);
在希望返回到的位置调用setjmp,直接调用该函数返回0。其中参数env是一个特殊类型jmp_buf,用于存放在调用longjmp时能用来恢复栈状态的所有信息。因为需在另一个函数中引用env,因此该变量应定义为全局变量
当在调用嵌套函数中发现一个错误(比如说cmd_add函数),可以调用longjmp跳转到setjmp处(会导致抛弃嵌套函数的栈帧)。其中第一个参数是setjmp时的env,第二个参数是一个非0值,会作为跳转到setjmp函数的返回值 。
8.1 longjmp后局部变量、全局变量、寄存器变量、静态变量、易失变量的不同情况
寄存器变量:
用register修饰的变量如register int n;register暗示编译程序相应的变量将被频繁地使用,如果可能的话,应将其保存在CPU的寄存器中,以加快其存储速度 * register变量必须是能被CPU所接受的类型。这通常意味着register变量必须是一个单个的值,并且长度应该小于或者等于整型的长度。不过,有些机器的寄存器也能存放浮点数。
* 因为register变量可能不存放在内存中,所以不能用“ &”来获取register变量的地址。由于寄存器的数量有限,而且某些寄存器只能接受特定类型的数据(如指针和浮点数),因此真正起作用的register修饰符的数目和类型都依赖于运行程序的机器,而任何多余的register修饰符都将被编译程序所忽略。在某些情况下,把变量保存在寄存器中反而会降低程序的运行速度。因为被占用的寄存器不能再用于其它目的;或者变量被使用的次数不够多,不足以装入和存储变量所带来的额外开销。
* 早期的C编译程序不会把变量保存在寄存器中,除非你命令它这样做,这时register修饰符是C语言的一种很有价值的补充。然而,随着编译程序设计技术的进步,在决定那些变量应该被存到寄存器中时,现在的C编译环境能比程序员做出更好的决定。实际上,许多编译程序都会忽略register修饰符,因为尽管它完全合法,但它仅仅是暗示而不是命令 。
C和C++中register区别:
在c++中:
c++中依然支持register关键字,但是c++编译器也有自己的优化方式,即某些变量不用register关键字进行修饰,编译器也会将多次连续使用的变量优化放入寄存器中,例如入for循环的循环变量i 。
register 关键字无法在全局中定义变量,否则会被提示为不正确的存储类。
register 关键字在局部作用域中声明时,可以用 & 操作符取地址,一旦使用了取地址操作符,被定义的变量会强制存放在内存中。
在c中:
register 关键字可以在全局中定义变量,当对其变量使用 & 操作符时,只是警告“有坏的存储类”。
register 关键字可以在局部作用域中声明,但这样就无法对其使用 & 操作符。否则编译不通过。
易失变量:
用volatile关键字修饰的变量,如volatile int n;
只要变量的值可能意外更改,就应将其声明为volatile。volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象 。
例子:查看longjmp后这几个变量的变化情况
jmp_buf jmp;
void func() {
longjmp(jmp,1);
}
int gloval; //全局变量
int main(int argc, char* argv[]) {
static int statval; //静态变量
register int regval; //寄存器变量
volatile int volval; //易失变量
int autoval; //局部变量
gloval = 1; statval = 2; regval = 3; volval = 4; autoval = 5;
if(setjmp(jmp) != 0) { //进行longjmp跳转后
cout << "全局变量:" << gloval << "静态变量:" << statval <<
"寄存器变量:" << regval << "易失变量:" << volval <<
"局部变量:" << autoval << endl;
exit(0);
}
cout << "全局变量:" << gloval << "静态变量:" << statval <<
"寄存器变量:" << regval << "易失变量:" << volval <<
"局部变量:" << autoval << endl;
gloval = 6; statval = 7; regval = 8; volval = 9; autoval = 10;
func();
}
不进行任何优化的编译:
$ ./a.out
> 全局变量:1静态变量:2寄存器变量:3易失变量:4局部变量:5
> 全局变量:6静态变量:7寄存器变量:8易失变量:9局部变量:10
进行优化的编译
$ ./a.out
> 全局变量:1静态变量:2寄存器变量:3易失变量:4局部变量:5
> 全局变量:6静态变量:7寄存器变量:3易失变量:9局部变量:5
**可以看出全局变量,静态变量,易失变量不受优化影响,longjmp之后它们呈现的值是最近的值。**不进行优化时,这五个变量都存放在存储器中(即忽略了register关键字);进行了优化后,局部变量和寄存器变量都存放在了寄存器中(即使局部变量没有register修饰),因此可以从结果中看出寄存器变量和局部变量值回滚了(即为调用setjmp时的值)
易失变量,全局变量,静态变量的值在longjmp后不会回滚到原先值(不会回滚到setjmp处,而是保持最新的值),而寄存器变量和局部变量则没有保证,大多数实现并不回滚寄存器变量和局部变量,但是不保证一定是这样。因此如果想定义一个局部变量,又不想其值回滚,则应该定义其为volatile 。
9. 资源限制
每个进程都有一组资源限制,可以通过getrlimit和setrlimit函数进行查询和修改
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);
struct rlimit {
rlim_t rlim_cur; /* 软限制 */
rlim_t rlim_max; /* 硬限制 (rlim_cur最大值) */
};
其中第一个参数是宏,指定要访问、修改的资源。第二个参数是限制资源内容。
- 任何进程都可以将软限制更改为小于或等于其硬限制值
- 任何进程都可降低其硬限制值,但是其必须大于等于软限制值
- 只有超级用户进程可以提高硬限制值
- 常量RLIM_INFINITY指定无限量的限制
第一个参数可以是以下宏 (部分)
| 限制资源 | 说明 |
|---|---|
| RLIMIT_AS | 进程总的可用存储空间最大长度(字节) |
| RLIMIT_CPU | CPU时间最大值,若超过此软限制则向该进程发送SIGXCPU信号 |
| RLIMIT_DATA | 数据段最大字节长度(这里是.bss.data和heap的总和) |
| RLIMIT_FSIZE | 可以创建的文件的最大字节长度。超过此软限制则向该进程发送SIGXFSZ信号 |
| RLIMIT_STACK | 栈的最大字节长度 |
| RLIMIT_SIGPENDING | 一个进程可排队的信号最大数量,这个限制是sigqueue函数实施的 |
资源限制影响到的是调用进程及其子进程,不会影响到其他进程 。因此如果要影响一个用户的所有后续进程,需要将资源限制的设置构造在shell之中。(比如ulimit命令)
