Unix环境高级编程第七章:进程环境

进程环境
☐ 1:C程序是如何启动和终止的
当内核执行c程序时(使用一个exec函数),在调用main前先调用一个特殊的启动l例程。可执行程序文件将次启动例程指定为程序的起始地址,启动例程从内核取得的命令行参数和环境变量。
☐ 2.进程终止
Linux 系统一共有 8 种进程终止方式,其中 5 种为正常终止方式:
1)从 main() 函数返回;

2)调用 exit(3) 函数;

3)调用 _exit(2) 或 _Exit(2) 函数;

4)最后一个线程从其启动例程返回;

5)从最后一个线程调用 pthread_exit(3) 函数。

剩下的 3 种为异常终止方式:
6)调用 abort(3) 函数;

7)接收到一个信号;

8)最后一个线程对取消请求作出响应。

下面我们来逐条解释一下:

第 1 条:在 main() 函数中执行 return 语句,可以将一个 int 值作为程序的返回值返回给调用者,一般是 shell。返回 0 表示程序正常结束,返回 非零值 表示程序异常结束。

第 2 条:在 main() 函数中执行 return 语句相当于调用 exit(3) 函数,exit(3) 是专门用于结束进程的,它依赖于 _exit(2) 或 _Exit(2) 系统调用。程序中任何地方调用 exit(3) 都会退出,但 return 语句只有在 main() 函数中才能结束进程,在其它函数中执行 return 语句只能退出当前函数。

第 3 条:_exit(2) 和 _Exit(2) 函数都是系统调用,在程序中的任何地方调用它们程序都会立即结束。

上面三条有两点需要大家注意,我先把问题提出来大家思考一下,下面会有讲解:

(1) return 、exit(3)、_exit(2) 和 _Exit(2) 的返回值取值范围是多少呢?

(2) exit(3)、_exit(2) 和 _Exit(2) 之间有什么区别呢?

第 4、5 条 等到第 11 章我们讨论线程的时候再说,总之进程就是线程的容器,最后一个线程的退出会导致整个进程的消亡。

第 6 条:abort(3) 函数一般用在程序中出现了不可预知的错误时,为了避免异常影响范围扩大,直接调用 abort(3) 函数自杀。实际上 abort(3) 函数也是通过信号实现的。

第 7 条:信号有很多种,有些默认动作是被忽略的,有些默认动作则是杀死进程。

比如程序接收到 SIGINT(Ctrl+C) 信号就会结束,Ctrl + C 是 SIGINT 的一个快捷方式,而不是 Ctrl + C 触发了 SIGINT 信号。

到第 10 章我们会详细的讨论信号。

第 8 条 也要等到第 11 章我们讨论线程的时候再详细说。

☐ 3:函数atexit

1 #include"apue.h"
2 
3 static void my_exit1(void)
4 {
5     printf("first exit handler\n");
6 }
7 
8 static void my_exit2(void)
9 {
10     printf("second exit handler\n");
11 }
12 
13 int main(void)
14 {
15     if(atexit(my_exit2) != 0)
16       err_sys("can't register my_exit2");
17     if(atexit(my_exit1) != 0)
18       err_sys("can't register my_exit1");
19     if(atexit(my_exit1) != 0)
20       err_sys("can't register my_exit1");
21     printf("main is done\n");
22     return 0;
23 }

在这里插入图片描述
☐ 4:命令行参数

1 #include"apue.h"
2 
3 int main(int argc,char* argv[])
4 {
5     int i;
6     for(i = 0;i < argc;++i)
7     {
8         printf("argv[%d]: %s\n",i,argv[i]);
9     }
10     exit(0);
11 }

在这里插入图片描述
☐ 5:C程序的存储空间布局
在这里插入图片描述

• 1:首先需要明确的知识是虚拟内存机制。每个进程都逻辑上独占拥有全部的内存,当然是虚拟的。内存分配是分配虚拟内存给进程,当进程真正访问某一虚拟内存地址时,操作系统通过触发缺页中断,在物理内存上分配一段相应的空间再与之建立映射关系,这样进程访问的虚拟内存地址,会被自动转换变成有效物理内存地址,便可以进行数据的存储与访问了。
Kernel space:操作系统内核地址空间。内存布局中之所以会有内核地址空间,个人有以下两个看法:
(1).操作系统内核代码与数据是已经存储在物理内存上的某一空间,假设它占据了1G内存,物理内存大小为4G,那么对于进程而言,理应显示的可用虚拟内存应该不大于(4 - 1)G,所以它出现在进程内存布局中,占据了一部分虚拟内存,使的进程可见的虚拟内存大小与真实物理内存大小相符。
(2)当然最重要的原因还是因为进程所访问的地址都是虚拟内存地址形式,然而进程需要与内核有交互,因此进程也需要访问内核空间,并且也是通过虚拟内存地址形式访问,所以内核空间需要出现在进程内存布局中。
还有值得注意的是,每个进程的内核虚拟地址空间都是映射到相同的真实物理地址上,因为都是共享同一份物理内存上的内核代码。除此之外还要注意内核虚拟地址空间总是存放在虚拟内存的地址最高处。
• 2:栈空间
在这个区域上有一个Random stack offset(栈随机偏移),这个偏移的主要作用是安全起见。因为内存布局按照严格的规则摆放,因此很容易被恶意访问,通过加入随机偏移,使的每个进程的栈空间起始位置都稍有不同,使的被确定栈空间起始位置具有一定难度,防止被恶意访问。后面的Random brk offset等也是同理。
1.栈空间大小是一个固定的值,例如4K、8K之类的。但是可以调整其大小,例如在进程中通过改变setrlimit函数的RLIMIT_STACK来调整其大小。栈空间有大小限制,体现在:
1.1.不能开辟很大的局部对象,例如栈空间为4K,在函数内开辟一个char [1024 * 1024]的数组就会被阻止,因为需要1M空间。
1.2.嵌套函数调用有层数限制,每当发生一次函数调用时,首先将当前地址压栈,再将函数的参数分别压栈,然后跳转到函数内代码处开始执行,在函数执行结束时,函数的参数分别出栈,再弹出原先压栈的地址,这样就能跳转回函数调用完毕后的代码处了。由此可见,调用一次函数实质上会占用一定的栈空间,当嵌套函数调用层数很大时,就会占据大量栈空间,最终导致栈空间用完。

2.栈空间有对应的栈指针(esp)。关系是这样的,栈有大小限制,而栈指针表示当前栈用了多少空间。例如,当压栈新的数据时,栈指针便向下增长(esp指针向低地址移动),栈指针到栈空间起始处的内存区域不得大于栈空间大小限制。
• 3:Heap与Memory Mapping Segment
堆与内存映射段都属于堆空间,注意下文所说的堆与堆空间并非同一个。堆空间与栈空间的关系很微妙,按理来说都是存数据,为什么还要用两个空间来分别表示呢,从哲学的角度看,数据是有生命周期,大小与顺序的。例如A函数内定义了数据a,B函数内定义了数据b,A函数调用B函数。那么a与b就有了顺序,a的生命周期比b长,b在a之后定义,但a一定在b之后销毁。在这种关系下,完全符合栈的先进后出规则,并且像类似于函数内部的数据(局部数据)而言,每调用一次函数就会产生一个生命周期只在函数调用周期的数据。于是,局部数据就有以下特征:生命周期短且固定(和一个函数的生命周期一致),可能会频繁定义(每次使用函数都会产生),数据之间有严格顺序(a一定比b后销毁),于是用栈来存储这些数据,完美契合。但并不是所有数据都有上面的特征,例如下面这种数据:生命周期不固定(例如打开一个文件,用户决定何时关闭这个文件,那么这个对象数据的生命周期就是不固定的),就不适合放在栈空间,于是用堆空间来保存这些数据。当我们需要在堆空间开辟一块内存来存储这种数据时,称这个过程为动态分配过程。
1.堆有一个堆指针(break brk),也是按照栈的方式运行的。内存映射段是存在在break brk指针与esp指针之间的一段空间。如前所述,堆空间是要解决生命周期不固定的数据,那为什么会有一个跟栈空间长的很像的堆与堆指针呢。理由是内存分配是一个很昂贵的工作,需要触发缺页中断在虚拟地址与物理地址之间建立映射。而栈空间有大小限制,所以在进程构建之初,便一次性分配了相应的内存,栈大小有4K,那么在进程构建之初,这4K便已经分配好了。然而堆空间的内存,都是用的时候才去分配,如果我们用动态分配去频繁的分配小额内存,那么系统开销会很大(频繁的缺页中断,又释放内存),如果是动态分配一大块内存,那么就比较划算了。例如花一秒钟去分配1G的内存,听上去不亏,但花一秒钟的时间去分配1字节的内存,那么这个一秒钟的开销就很昂贵了。

2.使用动态分配过程时会根据要分配的内存大小,使用不同的方式。例如在Linux中当动态分配内存大于128K时,会调用mmap函数在esp到break brk之间找一块相应大小的区域作为内存映射段返回给用户,当小于128K时,才会调用brk或者sbrk函数,将break brk向上增长(break brk指针向高地址移动)相应大小,增长出来的区域便作为内存返回给用户。两者的区别是内存映射段销毁时,会释放其映射到的物理内存,而break brk指向的数据被销毁时,不释放其物理内存,只是简单将break brk回撤,其虚拟地址到物理地址的映射依旧存在,这样使的当再需要分配小额内存时,只需要增加break brk的值,由于这段虚拟地址与物理地址的映射还存在,于是不会触发缺页中断。只有在break brk减少足够多,占据物理内存的空闲虚拟内存足够多时,才会真正释放它们。

3.综合上述两点,堆负责小额内存的管理,内存映射段负责大额内存的管理,如此就能方便的存储数据并且减少动态分配内存时系统的开销了。
• 4:代码段
这一块区域是用来存放进程代码的。也就是存放的机器指令。
• 5:数据段
又来了一个存放数据的区域,较之于局部数据、动态分配数据,数据段存放的数据自然也有它的特征。全局数据与静态数据是两种很特殊的数据,它们有固定的生命周期:同进程生命周期一样。也就是进程只要存在,那么就需要能访问到这些数据,当然可以把它们存入到栈空间的最底部(最开始入栈的数据,最后被销毁),这样从栈空间存储的数据语义角度上来说没问题,但这样带来了不方便访问的问题,栈空间的访问应遵循从栈顶开始找,栈顶esp的值又是变化的,因此计算起来不方便,直接存储并访问栈底,又不符合栈的规范,因此新开一片空间存储这些数据变的很合理,并且由于可执行文件也是有一个数据段来存储这些数据,因此当操作系统加载可执行文件时,直接复制可执行文件的数据段到内存中的数据段就很方便。
• 6:BSS段
又。。来一个存放数据的区域。存放的也是全局或者静态数据,只不过相较于数据段,BBS段保存的是全局/静态未初始化数据。前面说了全局或者静态数据在可执行文件中占据一部分区域,也就是说可执行文件的大小会根据程序的全局/静态数据的大小而变化,我们当然期望文件的大小都能小一点,但是已经初始化的数据必须保存在文件中,因为操作系统加载一个程序时,并不会去查看一遍程序本身,它只将可执行文件的不同区域加载到内存中相应位置,因此可执行文件本身必须携带数据的初始值,但是倘若是全局/静态数据并未初始化,换而言之其初始值没有意义,不会被用到,因此可执行文件本身就不需要保存它,只需要记录它便可,例如有100字节的全局字符数组,并未初始化,那么对于可执行文件本身来说,并不需要用真正的100字节来存储这个数据,只需要记录有一个”长度为100的字符数组“即可。前者需要100字节的真实空间,而后者可能只需要几个字符来存储这个信息。所以可执行文件中存储数这种数据使用BBS段存储,减少了文件本身的大小。而操作系统在加载可执行文件时,碰到BBS段,就会解析声明格式并创建真正的空间,然后全部填充为0,作为程序运行时存放全局/静态未初始化数据的区域。

☐ 6:函数setjmp和longjmp

1#include<stdio.h>
2 #include<stdlib.h>
3 #include<setjmp.h>
4 #include"apue.h"
5 
6 static jmp_buf save;
7 
8 void d(void)
9 {
10     printf("%s():Begin().\n",__FUNCTION__);
11     printf("%s():Jump now.\n",__FUNCTION__);
12     longjmp(save,8);
13     printf("%s():End.\n",__FUNCTION__);
14 }
15 
16 void c(void)
17 {
18     printf("%s():Begin().\n",__FUNCTION__);
19     printf("%s():Call d().\n",__FUNCTION__);
20     d();
21     printf("%s():d() returned.\n",__FUNCTION__);
22     printf("%s():End.\n",__FUNCTION__);
23 }
24 
25 void b(void)
26 {
27     printf("%s():Begin.\n",__FUNCTION__);
28     printf("%s():Call c().\n",__FUNCTION__);
29     c();
30     printf("%s():c() returned.\n",__FUNCTION__);
31     printf("%s():End.\n",__FUNCTION__);
32 }
33 
34 void a(void)
35 {
36      int ret;
37      printf("%s():Begin.\n",__FUNCTION__);
38      ret = setjmp(save);
39      if(ret == 0) // 设置跳转点
40      {
41         printf("%s():Call b().\n",__FUNCTION__);
42         b();
43         printf("%s():b() returned.\n",__FUNCTION__);
44      }
45      else // 跳回到这
46      {
47          printf("ret = %d\n",ret);
48         printf("%s():Jumped back here with code %d\n",__FUNCTION__,ret);
49      }
50      printf("%s():End.\n",__FUNCTION__);
51 }
52 
53 int main()
54 {
55     printf("%s():Begin.\n",__FUNCTION__);
56     printf("%s():Call a().\n",__FUNCTION__);
57     a();
58     printf("%s():a() returned.\n",__FUNCTION__);
59     printf("%s():End.\n",__FUNCTION__);
60 
61     return 0;
62 }

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值