• 计算机基础之异常控制流


    计基之异常控制流

    Author:onceday Date:2022年8月3日

    长路漫漫,而今才刚刚启程!

    本内容收集整理于《深入理解计算机系统》一书。

    1.引言

    从给处理器加电开始,程序处理器一直在执行一个值的序列: a 0 , a 1 , a 2 , . . . , a n − 1 a_0,a_1,a_2,...,a_{n-1} a0,a1,a2,...,an1

    每次从 a k a_k ak a k + 1 a_{k+1} ak+1的过渡称为控制转移(control transfer),这样的控制转移序列叫做处理器的控制流(flow of control 或 control flow)。

    现代操作系统通过使控制流发生突变来对系统状态的变化做出反应,一般称为异常控制流(Exceptional Control Flow, ECF)

    现代操作系统依靠异常控制流来实现以下功能:

    • 硬件中断服务程序

    • 进程和线程切换

    • 系统服务调用

    • 信号处理程序

    • 异常处理代码

    在这里,异常(exception)被定义为控制流中的突变,用来响应处理器状态中的某些变化

    这个状态变化往往是 某些事件(event) 发生了。

    2.异常处理

    当处理器检测到了有事件发生时,它会通过一张叫做异常表(exception table),进行一个间接过程调用(异常),到一个专门处理该事件的操作系统子程序(异常处理程序(exception handler)

    根据引起异常的事件类型,会发生以下三种情况:

    • 处理程序将控制返回给当前指令 I c u r r I_{curr} Icurr,即当事件发生时正在执行的指令。

    • 处理程序将控制返回给 I n e x t I_{next} Inext,如果没有发生异常将会执行的下一条指令。

    • 处理程序终止被中断的程序。

    系统会给每种类型的异常都分配一个唯一的非负整数的异常号(exception number)

    诸如零除、缺页、内存访问违例、断点以及算术溢出是处理器的设计者分配的方案。

    系统调用和来自外部I/O设备的信号是操作系统分配。

    在处理器复位时,会初始化异常表,填入各类服务例程函数,以及异常表的起始地址—异常表基址寄存器(exception table base register)

    异常调用过程和普通函数过程的不同:

    • 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中,当前指令或者下一条指令。

    • 处理器会把一些额外的处理器状态压入栈里。

    • 如果控制从用户程序转移到内核,所有这些项目被压到内核栈,而不是用户栈中。

    • 异常处理程序运行到内核模式下,这意味着它们对所有系统资源都有完全的访问权限。

    异常服务程序执行完后,会使用从中断返回指令,该指令会恢复CPU的寄存器状态

    异常的类别:

    类别原因异步/同步返回行为
    中断来自I/O设备的信号异步总是返回到下一条指令
    陷阱有意的异常同步总是返回到下一条指令
    故障潜在可恢复的错误同步可能返回到当前指令
    终止不可恢复的错误同步不会发生
    2.1 中断

    中断是异步发生的,是来自处理器外部的I/O设备的信号的结果,硬件中断不是任何一条专门的指令造成的,从这个意义来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。

    2.2 陷阱和系统调用

    陷阱是有意的异常,是执行一条指令的结果。

    处理器提供一条特殊的syscall n指令,当用户想要使用内核服务时,可以使用该指令,系统调用运行在内核模式中,允许系统调用执行特权指令,并访问内核栈。

    2.3 故障

    故障由错误情况引起,可能被故障处理程序修正。当故障发生时,处理器将控制转移给故障程序。

    如果故障处理程序能修正这个情况,它就将控制返回到引起故障的指令,从而重新执行它。

    否则,处理程序返回到内核中的abort例程,会终止引起故障的应用程序。

    一个典型的例子是缺页异常,当成功载入物理页面之后,就可恢复正常运行。

    2.4 终止

    终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。

    3.进程控制流

    每个进程有一个自己的逻辑控制流,里面也可以在细分线程。

    一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),

    多个流并发地执行的一般现象被称为并发(concurrency)。

    一个进程和其他进程轮流运行的概念称为多任务(multitasking)。

    一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。

    因此,多任务也叫做时间分片(time slicing)。

    如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称他们为并行流(parallel flow),它们并行地运行(running in parallel),且并行地执行(parallel execution)。

    3.1 上下文切换

    操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。

    上下文是内核重新启动一个被抢占的进程所需的状态:

    • 数据寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈

    • 内核数据结构,如描述地址空间的页表、包含有关当前进程信息的进程表、包含进程已打开文件的信息的文件表。

    3.2 获取进程PID

    每个进程都有唯一的非负数的进程ID。

    使用函数getpid获取当前进程的PID,函数getppid获取当前进程的父线程的PID。

    #include 
    #include 
    #include 
    
    int main(void)
    {
        pid_t temp;
    
        temp = getpid();
        printf("pid = %d \n",temp);
    
        temp = getppid();
        printf("pgrp = %d \n",temp);
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    3.3 创建和终止进程

    fork函数创建的子进程会继承父进程的进程环境、堆栈、文件描述符、信号控制设定、调度方式等。但父进程的线程、定时器、信号状态等不继承。

    fork函数在子进程里返回0,在父进程里返回子进程PID。

    #include 
    #include 
    #include 
    #include 
    
    int main(void)
    {
        pid_t temp;
        int x=1;
    
        temp = getpid();
        printf("1: father pid = %d \n",temp);
    
        temp = fork();
        if(temp == 0) {
            temp = getpid();
            printf("2: son pid = %d \n",temp);
            temp = getppid();
            printf("2: my father pid = %d \n",temp);
            x++;
            printf("2: son's x = %d\n",x);
        }
        sleep(1);
        printf("x's value = %d \n",x);
    
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    虽然子进程的上下文环境复制于父进程,但两者是独立的,一旦修改,就会创建出副本出来。

    不可以假设父进程代码和子线程代码的运行顺序,他们之间是拓扑排序,即任意可能排序。

    使用void exit(int status)函数可以主动终止进程。

    3.4 僵死进程

    父进程终止了,但还没有回收已终止的子进程,那么子进程会被init(PID=1)收养,子进程此时也被称为僵死进程

    可使用以下两个函数来等待子进程结束:

    pid_t wait(int *stat_loc); //相当于waitpid(-1,xxx,0)
    pid_t waitpid(pid_t pid, int *stat_loc, int options);
    
    • 1
    • 2

    pid用来指定等待的子进程,如果为-1,那么等待集合由所有子进程组成。

    options选项可填入三个常量:

    • WNOHANG,如果等待集合的任何子进程都没有终止,那么就立即返回。

    • WUNTRACED,挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。

    • WCONTINUED,挂起被调用进程的执行,直到等待集合中一个正在运行的进程终止,或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行。

    这些选项可以用或运算组合起来。

    stat_loc是进程的返回状态,可以用WIFEXITED(status)等宏来检查,具体可man waitpid来查看具体说明。

    如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。如果被信号中断,也会返回-1,并且设置errno为EINTR。

    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(void)
    {
        pid_t temp;
        int x;
    
        temp = getpid();
        printf("1: father pid = %d \n",temp);
    
        temp = fork();
        if(temp == 0) {
            temp = getpid();
            printf("2: son pid = %d \n",temp);
            temp = getppid();
            printf("2: my father pid = %d \n",temp);
            sleep(1);
        }
        else {
            wait(&x);
            printf("son return %d\n",x);
            if (WIFEXITED(x) != 0) {
                printf("exit number =%d\n",WEXITSTATUS(x));
            }
        }
    
        return 1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    3.5 让进程休眠

    sleep函数可以让进程挂起一段指定的时间,但这个睡眠的时间可能少于给出的时间。

    因为信号可以打断睡眠。

    unsigned int sleep (unsigned int __seconds);
    
    • 1

    返回的是实际睡眠的时间。

    pause函数可以让一个进程休眠,直到该进程收到一个信号:

    int pause (void);
    
    • 1

    4.加载并运行程序

    execve函数在当前的进程的上下文中加载并运行一个新程序。

    int execve (const char *path, char *const argv[],
               char *const envp[]);
    
    • 1
    • 2

    参数列表和环境变量列表都指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。

    按照惯例,参数列表是第一个参数字符串是可执行目标文件的文件名。

    在这里插入图片描述

    对应的main函数的原型为:

    int main(int argc, char *argv[], char *envp[]);
    
    • 1

    在这里插入图片描述

    • argc 是参数列表的参数个数,其值在%rdi中

    • argv是参数列表的首地址,其值在%rsi中

    • envp是环境变量列表的首地址,其值在%rdx中

    以下是几个操作环境变量数组的函数:

    char *getenv (const char *name);//返回指定name的value指针,或者null
    int putenv (char *string);//移除字符串
    int setenv (const char *name, const char *value, int replace);//设置或更新
    int unsetenv (const char *name);//移除字符串
    
    • 1
    • 2
    • 3
    • 4

    实现一个输出参数列表和环境变量列表的程序:

    #include 
    #include 
    #include 
    
    int main(int argc, char *argv[], char *envp[])
    {
        int i;
    
        printf("command-line arguments:\n");
        for (i = 0; i < argc; i++) {
            printf("%s\n",argv[i]);
        }
    
        printf("Environment variable:\n");
        while( *envp != NULL) {
            printf("%s\n",*envp++);
        }
    
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    execve函数的参数列表和环境变量列表可以为空,此时加载后的程序两者都是空的。

    可以使用全局变量environ或者__environ将当前程序的全局变量直接传给加载后的程序。

    5.信号处理

    信号是一种更高级的软件形式的异常,允许进程和内核中断其他进程。

    使用man 7 signal可以查看关于信号的具体解释。

    底层的硬件异常是由内核异常程序处理的,正常情况下,用户进程是不可见,信号则提供了一种机制,通知用户进程发生了这些异常。

    以下是常见的30种异常信号:

    序号名称默认行为相应事件
    1SIGHUP终止终端线挂断
    2SIGINT终止来自键盘的中断
    3SIGQUIT终止来自键盘的退出
    4SIGILL终止非法指令
    5SIGTRAP终止并转储内存跟踪陷阱
    6SIGABRT终止并转储内存来自abort函数的终止信号
    7SIGBUS终止总线错误
    8SIGFPE终止并转储内存浮点异常
    9SIGKILL终止(不能被捕获或忽略)杀死程序
    10SIGUSR1终止用户定义的信号1
    11SIGSEGV终止并转储内存无效的内存引用
    12SIGUSR2终止用户定义的信号2
    13SIGPIPE终止向一个没有读用户的管道做写操作
    14SIGALRM终止来自alarm函数的定时器信号
    15SIGTERM终止软件终止信号
    16SIGSTKFLT终止协处理器上的栈故障
    17SIGCHLD忽略一个子进程停止或者终止
    18SIGCONT忽略继续进程如果该进程停止
    19SIGSTOP停止直到下一个SIGCONT(不能被捕获或忽略)不是来自终端的停止信号
    20SIGTSTP停止直到下一个SIGCONT来自终端的停止信号
    21SIGTTIN停止直到下一个SIGCONT后台进程从终端读
    22SIGTTOU停止直到下一个SIGCONT后台进程向终端写
    23SIGURG忽略套接字上的紧急情况
    24SIGXCPU终止CPU时间超出限制
    25SIGXFSZ终止文件大小限制超出
    26SIGVTALRM终止虚拟定时器期满
    27SIGPROF终止剖析定时器期满
    28SIGWINCH忽略窗口大小变化
    29SIGIO终止在某个描述符上可执行IO操作
    30SIGPWR终止电源故障

    发送信号:内核可以通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。

    • 内核检测到一个系统事件

    • 调用kill函数发送信号给目的进程

    接受信号:当目的进程被内核强迫以某种方式对信号的发送做出反应,它就接受了信号。

    • 可以忽略信号、终止程序以及捕获信号并通过信号处理程序来处理。

    一个发出而没有被接收的信号叫做待处理信号(pending signal)

    任意一时刻一种类型信号只会有一个待处理信号,一个待处理信号最多只能被接收一次。

    5.1 发送信号

    Unix系统提供了大量向进程发送信号的机制,所有的这些机制都是基于进程组(process)这个概念的。

    getpgrp函数返回当前进程的进程组ID:

    pid_t getggrp(void);
    
    • 1

    一个子进程默认与父进程属于同一个进程组

    setpgid函数将进程pid的进程组改为pgid。

    如果pid是0,那么就使用当前进程组的PID,如果pgid是0,那么就用pid指定的进程的PID作为进程组ID。

    1. 使用``/bin/kill`程序发送信号

    kill -9 15236    #发送信号9(SIGKILL)给进程15213 
    
    • 1

    2. 通过按键也可以CTRL+C会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况下,终止前台作业。

    3. 类似的地,输入CTRL+Z会发送一个SIGTSTP信号到前台进程组的每个进程,默认是停止前台作业。

    4.用kill函数也可发送信号

    #include 
    #include 
    
    int signal(pid_t pid, int sig);
    
    • 1
    • 2
    • 3
    • 4
    • 如果PID大于0,则发给指定的进程。

    • 如果PID=0,则发给所在进程组的每个进程。

    • 如果PID<0,则发送给进程组abs(PID)的每个进程。

    5.用alarm函数发送信号:

    #include 
    
    unsigned int alarm(unsigned int secs);
    
    • 1
    • 2
    • 3

    alarm函数安排内核在secs秒后发送一个SIGALRM信号给调用进程。返回值是前一次闹钟剩余的秒数,若没有设置则为0。

    5.2 接收信号

    当每次从内核模式切换到用户模式时,都会检查进程P的未被阻塞的待处理信号的集合。

    请注意,上下文切换也是内核调度。

    进程可以通过signal函数来修改默认信号的行为,但SIGKILL和SIGSTOP信号的默认行为不能被修改。

    #include 
    
    typedef void (*sighandler_t)(int);
    
    sighandler_t signal(int signum,sighandler_t handler);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    signal函数成功返指向前次处理程序的指针,如出错,则返回SIG_ERR。

    handler的值有以下三种情况:

    • 如果handler是SIG_IGN,那么忽略类型为signum的信号

    • 如果handler是SIG_DFL,那么类型为signum的信号行为恢复默认行为

    • 非以上两种情况,那么handler就是用户定义的函数的地址,这个函数就被称为信号处理程序。通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序,调用信号处理处理程序被称为捕获程序,执行信号处理程序被称为处理信号。

    5.3 阻塞和解除阻塞信号

    一个信号被阻塞时,它可以被发送,但是产生的待处理信号不会被接收,直到进程取消对他的阻塞。

    隐式阻塞机制:

    • 内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。

    显示阻塞机制:

    • 可以使用sigprocmask函数以及辅助函数,明确阻塞和解除阻塞选定的信号。
    #include 
    int sigprocmask(int how, const sigset_t *set,sigset_t *oldset);
    int sigemptyset(sigset_t *set);
    int sigfillset(sigset_t *set);
    int sigaddset(sigset_t *set, int signum);
    int sigdelset(sigset_t *set, int signum);
    //如果成功返回0,出错则返回-1。
    int sigismember(const sigset_t *set, int signum);
    //若signum是set的成员则为1,如果不是则为0,出错则为-1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    sigprocmask函数改变当前阻塞的信号集合,即blocked位向量。具体行为由how决定:

    • SIG_BLOCK,把set中的信号添加到blocked中,置位。
    • SIG_UNBLOCK,从blocked中删除set中的信号,清除位。
    • SIG_SETMASK,block=set。

    blocked位向量之前的值保存在oldset中。

    以下是辅助函数的作用:

    1. sigemptyset初始化set为空集合。
    2. sigfillset把每个信号都添加到set集合。
    3. sigaddset把signum添加到set。
    4. sigdelset从set中删除signum。
    5. sigismember判断signum是否为set的成员。
    5.4 编写信号处理程序

    信号处理非常棘手,原因如下:

    • 处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰
    • 如何以及何时接收信号的规则常常有违人的直觉
    • 不同的系统有不同的信号处理语义

    以下是保守的建议:

    • 处理程序要尽可能的简单
    • 在处理程序中只调用异步信号安全的函数,man 7 signal-safety查看对应函数。
    • 保存和恢复errno,许多Linux异步安全函数在出错时设置errno,可能干扰主程序,因此需要暂时保存errno的值,并在返回时恢复。
    • 阻塞所有信号,保护对共享全局数据结构的访问。
    • 用volatile声明全局变量,避免编译器使用寄存器副本值。
    • 用sig_atomic_t声明原子性整数变量。

    未处理的信号是不排队的,因此信号不可以用来对事件来计数。

    如果存在一个未处理的信号,至少代表有一个该信号到达了。

    5.5 可移植的信号处理

    Unix信号处理的另一个缺陷在于不同的系统有不同的信号处理语义。

    • signal函数的语义不同。部分老系统在信号k被处理程序捕获之后就把对信号k的反应恢复到默认值。因此,每次运行之后,处理程序需要调用signal函数显示地重新设置自己。

    • 系统调用可以被中断。像read、write和accept这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。在较早的系统中,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR。因此程序员需要手动重启被中断的系统调用的代码。

    可以通过sigaction函数在设置信号处理时,明确指定他们想要的信号处理语义。

    5.6 同步流问题

    当信号处理程序和主程序同时需要访问全局变量或操作同一事物时, 就存在竞争问题。

    在不需要等待,仅需要原子性操作某一段代码时,可用sigprocmask函数。

    在需要等待信号处理程序完成某一工作时,可用sigsuspend函数来等待接收到一个信号。

    #include 
    
    int sigsuspend(const sigset_t *mask);
    
    • 1
    • 2
    • 3

    sigsuspend函数暂时用mask奇幻当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程。终止时不返回,如果是一个处理程序,那么sigsuspend从处理程序返回,恢复调用sigsuspend时原有的阻塞集合。

    6.操作进程的工具

    LInux的监控和操作进程的有用工具:

    • STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。

    • PS:列出当前系统中的进程(包括僵死进程)。

    • TOP:打印出关于当前进程资源使用的信息。

    • PMAP:显示进程的内存映射。

    STRACE和PMAP需要在较高的权限下才能运作,并且提供足够的信息。

    注:关于C语言本地跳转将在单独的专题文章里讲述。
  • 相关阅读:
    JavaScript中类的学习
    DIY调频(FM) MP3电台-基于增强管道数据流转(EPDR)的taskBus实时水位控制技术
    V8 GC 的实现
    C++之设计模式
    redis命令使用
    一次较波折的MySQL调优
    Node.js基础---Express
    Github工程中的Markdown语言应用
    Apache POI使用
    双线性插值
  • 原文地址:https://blog.csdn.net/Once_day/article/details/126157380