期末作业第八章

第八章 异常控制流

从开机到关机,处理器做的工作其实很简单,就是不断读取并执行指令,每次执行一条,整个指令执行的序列,称为处理器的控制流。到目前为止,我们已经学过了两种改变控制流的方式:

  • 跳转和分支
  • 调用和返回

这两个操作对应于程序的改变。但是这实际上仅仅局限于程序本身的控制,没有办法去应对更加复杂的情况。系统状态发生变化的时候,无论是跳转/分支还是调用/返回都是无能为力的,比如:

  • 数据从磁盘或者网络适配器到达
  • 指令除以了零
  • 用户按下 ctrl+c
  • 系统的计时器到时间

这时候就要轮到另一种更加复杂的机制登场了,称之为异常控制流(exceptional control flow)。

异常

这里的异常指的是把控制交给系统内核来响应某些事件(例如处理器状态的变化),其中内核是操作系统常驻内存的一部分,而这类事件包括除以零、数学运算溢出、页错误、I/O 请求完成或用户按下了 ctrl+c 等等系统级别的事件。

具体的过程可以用下图表示:
在这里插入图片描述
系统会通过异常表(Exception Table)来确定跳转的位置,每种事件都有对应的唯一的异常编号,发生对应异常时就会调用对应的异常处理代码

异常的类别

异常分为四种:

  1. 中断
    只有中断是异步的,是来自处理器外部的I/O设备的信号的结果。
    是在当前指令执行完之后才进行中断处理程序。
  2. 陷阱和系统调用
    有意的异常,是执行指令的结果。用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
    用户经常需要向内核请求服务,比如读文件,fork,execve等,为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的“syscall n”指令来请求服务n。这哥指令导致一个异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。
    系统调用运行在内核模式中。
  3. 故障
    由错误情况引起,可能被故障处理程序修正。
    经典的是缺页异常,处理是从磁盘中加载适当的页面,然后将控制返回给引起故障的指令。再重新运行这个指令。
  4. 终止
    不可恢复的致命错误导致,通常是一些硬件错误。
进程

经典定义:一个执行中程序的实例
进程提供给应用程序的关键抽象:

  • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
  • 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
上下文切换

切换进程时,内核会负责具体的调度,如下图所示
在这里插入图片描述

fork()的原理及用法

我们都知道通过fork()系统调用我们可以创建一个和当前进程印象一样的新进程.我们通常将新进程称为子进程,而当前进程称为父进程.而子进程继承了父进程的整个地址空间,其中包括了进程上下文,堆栈地址,内存信息进程控制块(PCB)等.

  1. 父子进程
    那么我们首先来先说说父进程和子进程之间的区别:
  • 父进程设置了锁,子进程不继承
  • 进程ID不同
  • 子进程的未决告警被清除
  • 子进程的未决信号集设置为空集
  1. fork系统调用说明
      通过man手册我们可以轻松知道fork()包含的头文件<sys/types.h>和<unistd.h>,功能就是创建一个子进程.函数原型:pid_t fork(void),pid_t是带一个代表经常号pid的数据结构.如果创建成功一个子进程,对于父进程来说是返回子进程的ID.而对于子进程来说就是返回0.而返回-1代表创建子进程失败.
    在这里插入图片描述
进程图

进程图是一个很好的帮助我们理解进程执行的工具:

  • 每个节点代表一条执行的语句
  • a -> b 表示 a 在 b 前面执行
  • 边可以用当前变量的值来标记
  • printf 节点可以用输出来进行标记
  • 每个图由一个入度为 0 的节点作为起始

fork函数:

if ((pid = fork()) < 0) {
    fprintf(stderr, "fork error: %s\n", strerror(errno));
    exit(0);
}

我们可以用下面两个函数获取进程的相关信息:

  • pid_t getpid(void) - 返回当前进程的 PID
  • pid_t getppid(void) - 返回当前进程的父进程的 PID
把整个 fork() 包装起来,就可以自带错误处理,比如
pid_t Fork(void)
{
    pid_t pid;
    if ((pid = fork()) < 0)
        unix_error("Fork error");
    return pid;
}
对于进程图来说,只要满足拓扑排序,就是可能的输出。用例子来简单示意一下:
int main()
{
    pid_t pid;
    int x = 1;
    
    pid = Fork();
    if (pid == 0) 
    {   // Child
        printf("child! x = %d\n", --x);
        exit(0);
    }
    
    // Parent
    printf("parent! x = %d\n", x);
    exit(0);
}

对应的进程图为:
在这里插入图片描述

回收子进程

即使主进程已经终止,子进程也还在消耗系统资源,我们称之为『僵尸』。为了『打僵尸』,就可以采用『收割』(Reaping) 的方法。父进程利用 wait 或 waitpid 回收已终止的子进程,然后给系统提供相关信息,kernel 就会把 zombie child process 给删除。
如果父进程不回收子进程的话,通常来说会被 init 进程(pid == 1)回收,所以一般不必显式回收。但是在长期运行的进程中,就需要显式回收(例如 shell 和 server)。
如果想在子进程载入其他的程序,就需要使用 execve 函数,具体可以查看对应的 man page,这里不再深入。

信号

对于前台进程来说,我们可以在其执行完成后进行回收,而对于后台进程来说,因为不能确定具体执行完成的时间,所以终止之后就成为了僵尸进程,无法被回收并因此造成内存泄露。

这怎么办呢?同样可以利用异常控制流,当后台进程完成时,内核会中断常规执行并通知我们,具体的通知机制就是『信号』(signal)。

信号是 Unix、类 Unix 以及其他 POSIX 兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作都将被中断。如果进程定义了信号的处理函数,那么它将被执行,否则就执行默认的处理函数。

这样看来,信号其实是类似于异常和中断的,是由内核(在其他进程的请求下)向当前进程发出的。信号的类型由 1-30 的整数定义,信号所能携带的信息极少,一是对应的编号,二就是信号到达这个事实。下面是几个比较常用的信号的编号及简介:
在这里插入图片描述
内核通过给目标进程发送信号,来更新目标进程的状态,具体的场景为:

  • 内核检测到了如除以零(SIGFPE)或子进程终止(SIGCHLD)的系统事件
  • 另一个进程调用了 kill 指令来请求内核发送信号给指定的进程

目标进程接收到信号后,内核会强制要求进程对于信号做出响应,可以有几种不同的操作:

  • 忽略这个型号
  • 终止进程
  • 捕获信号,执行信号处理器(signal handler),类似于异步中断中的异常处理器(exception handler)
    具体的过程如下:
    在这里插入图片描述
进程组

每个进程都只属于一个进程组,想要了解相关信息,一般使用如下函数:

  • getpgrp() - 返回当前进程的进程组
  • setpgid() - 设置一个进程的进程组
如果想要发送信号,可以使用 kill 函数,下面是一个简单的示例,父进程通过发送 SIGINT 信号来终止正在无限循环的子进程。
void forkandkill()
{
    pid_t pid[N];
    int i;
    int child_status;
    
    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0)
            while(1) ;  // 死循环
    
    for (i = 0; i < N; i++)
    {
        printf("Killing process %d\n", pid[i]);
        kill(pid[i], SIGINT);
    }
    
    for (i = 0; i < N; i++)
    {
        pid_t wpid = wait(&child_status);
        if (WIFEXITED(child_status))
            printf("Child %d terminated with exit status %d\n",
                    wpid, WEXITSTATUS(child_status));
        else
            printf("Child %d terminated abnormally\n", wpid);
    }
}
非本地跳转

所谓的本地跳转,指的是在一个程序中通过 goto 语句进行流程跳转,尽管不推荐使用goto语句,但在嵌入式系统中为了提高程序的效率,goto语句还是可以使用的。本地跳转的限制在于,我们不能从一个函数跳转到另一个函数中。如果想突破函数的限制,就要使用 setjmp 或 longjmp 来进行非本地跳转了。

setjmp 保存当前程序的堆栈上下文环境(stack context),注意,这个保存的堆栈上下文环境仅在调用 setjmp 的函数内有效,如果调用 setjmp 的函数返回了,这个保存的堆栈上下文环境就失效了。调用 setjmp 的直接返回值为 0。

longjmp 将会恢复由 setjmp 保存的程序堆栈上下文,即程序从调用 setjmp 处重新开始执行,不过此时的 setjmp 的返回值将是由 longjmp 指定的值。注意longjmp 不能指定0为返回值,即使指定了 0,longjmp 也会使 setjmp 返回 1。

我们可以利用这种方式,来跳转到其他的栈帧中,比方说在嵌套函数中,我们可以利用这个快速返回栈底的函数,我们来看如下代码

jmp_buf env;

P1()
{
    if (setjmp(env))
    {
        // 跳转到这里
    } else 
    {
        P2();
    }
    
}

P2()
{
    ...
    P2();
    ...
    P3();
}

P3()
{
    longjmp(env, 1);
}

对应的跳转过程为:
在这里插入图片描述
也就是说,我们直接从 P3 跳转回了 P1,但是也有限制,函数必须在栈中(也就是还没完成)才可以进行跳转

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值