CMU15-213学习笔记(六)Exceptional Control Flow

CMU15-213学习笔记(六)Exceptional Control Flow

程序的正常执行顺序有两种:

  1. 按顺序取下一条指令执行
  2. 通过CALL/RET/Jcc/JMP等指令跳转到转移目标地址处执行

CPU所执行的指令的地址序列称为CPU的控制流,通过上述两种方式得到的控制流为正常控制流

异常控制流

CPU会因为遇到内部异常外部中断等原因而打断程序的正常控制流,转去执行操作系统提供的针对这些特殊事件的处理程序。

由于某些特殊情况引起用户程序的正常执行被打断所形成的意外控制流称为异常控制流(Exceptional Control of Flow,ECF)。

异常也可以定义为“把控制交给系统内核来响应某些事件(例如处理器状态的变化)”,其中内核是操作系统常驻内存的一部分。异常实际上是将低级别的控制权转移到操作系统,将控制权从用户代码转移到内核代码

异常控制流形成的原因:

  • 内部异常(缺页(页表中V=0,即我要找的页不在内存中,无法取指,所以程序也无法运行下去。用户程序无法直接访问磁盘,需要转到操作系统处理缺页。由操作系统去读磁盘,把缺失的页装入内存,更新页表。然后从缺页处理程序返回,回来以后继续执行之前的程序)、越权、越级、整除0、溢出等)发生在硬件层
  • 外部中断(Ctrl-C、打印缺纸、DMA结束等) 发生在硬件层
  • 进程的上下文切换(发生在操作系统层)
  • 一个进程直接发送信号给另一个进程(发生在应用软件层)

异常和异常控制流是两个概念,凡是不属于正常控制流的都是异常控制流(废话)。异常控制流包括异常(广义)和进程的上下文切换。

  • 异常(广义,包括内部异常(同步异常)和外部中断(异步异常))指的是将控制权由用户进程转移到操作系统,然后还是回到原来的进程。
  • 进程的上下文切换是指从一个用户进程到另一个用户进程。

异常和中断

这里的异常指的是把控制交给系统内核来响应某些事件(例如处理器状态的变化),其中内核是操作系统常驻内存的一部分

image-20210804090404368

发生异常(exception)中断(interrupt)事件后,系统将进入OS内核态对相应事件进行处理,即改变处理器状态(用户态→内核态)

image-20210804091028942

中断或异常处理执行的代码不是一个进程,而是“内核控制路径”, 它代表异常或中断发生时正在运行的当前进程在内核态执行一个独立的指令序列(CPU正在处理进程的ID还是之前用户进程的ID,但是在CPU上运行的代码不是用户进程的代码,而是内核的代码)

所以进程的上下文切换和异常中断是不一样的,异常中断是指在用户进程中插入一段内核控制路径,然后还是回到原来的进程。而进程的上下文切换是指从一个用户进程到另一个用户进程。

异常(同步异常)

同步异常(Synchronous Exception)是因为执行某条指令所导致的事件,是CPU内部事件的中断。同步异常分为陷阱(Trap)、故障(Fault)和终止(Abort)三种情况。

类型原因行为示例
陷阱(Trap)预先安排的异常事件,是一种自愿中断返回到下一条指令系统调用,断点
故障(fault)执行指令引起的异常事件,有些可以恢复,有些不能恢复返回到当前发生异常的指令重新执行,或终止溢出、页故障(page faults)
终止(Abort)硬故障事件,不可恢复的错误终止当前程序非法指令

自陷(Trap) :预先安排的事件(“埋地雷”),如单步跟踪、断点、 系统调用 (执行访管指令) 等。是一种自愿中断。CPU调出特定程序进行相应处理,处理结束后返回到陷阱指令下一条指令执行。

  • 陷阱的作用之一是在用户和内核之间提供一个像过程(函数)一样的接口,对这个接口的调用称为系统调用,用户程序利用这个接口可方便地使用操作系统内核提供的一些服务,将控制转移到操作系统内核(与之相对应的是函数调用,使用由用户进程提供的一些服务)。

    在 x86-64 系统中,每个系统调用都有一个唯一的 ID,称为系统调用号。例如,Linux系统调用fork、read和execve的调用号分别是1、3和11。

异常举例:

  • 页故障

    CPU执行每条指令都要访存(取指令、取操作数、存结果) ,而每次访存都要进行逻辑地址向物理地址转换,在地址转换过程中会发现是否发生了“页故障”

    逻辑地址向物理地址的转换由硬件(MMU)实现,故“页故障” 事件由硬件发现。所有异常和中断事件都由硬件检测发现!

    • 缺页:页表项有效位为0
    • 地址越界:地址大于最大界限
    • 访问越级或越权(保护违例):
      • 越级:用户进程访问内核数据(CPL=3 / DPL=0)
      • 越权:读写权限不相符(如对只读段进行了写操作)
    image-20210804095035053

比如:

int a[1000];
main()
{
    a[500] = 13;
}

发生缺页,那么系统会通过 Page Fault 把对应的部分载入到内存中,然后重新执行赋值语句:

img

但是如果代码改为这样:

int a[1000];
main()
{
    a[5000] = 13;
}

此时有可能超过了栈的范围,也就是引用非法地址,整个流程就会变成:

img

具体来说会像用户进程发送 SIGSEGV 信号,用户进程会以 segmentation fault 的标记退出。

中断(异步异常)

**异步异常(Asynchronous Exception)**称之为中断(Interrupt),是由处理器外面发生的事情引起的。对于执行程序来说,这种“中断”的发生完全是异步的,因为不知道什么时候会发生。中断与当前执行的程序没有关系,由CPU外部事件引起的中断。

每执行完一条指令,CPU就查看中断请求引脚,若引脚的信号有效,则进行中断响应:将当前PC(断点,也就是下一条指令的地址)和当前机器状态保存到栈中,并“关中断”(以上事件是同时进行的,不分先后), 保护现场(将寄存器的内容保存到栈中),然后,从数据总线读取中断类型号,根据中断类型号跳转到对应的中断服务程序执行(与前面的异常处理程序一样,都是内核提供的程序)。中断检测及响应过程由硬件完成。

image-20210804113152710 image-20210804115902940

Intel:所有事件都被分配一个**“中断类型号”,每个中断都有相应的“中断服务程序”。由硬件获取中断类型号,相当于中断向量表的索引,根据此号,在中断向量表中找到对应的中断服务程序的入口地址**,将该地址送PC(rip),执行该程序。

程序和进程

  • 程序(program)指按某种方式组合形成的代码和数据集合,代码即是机器指令序列,因而程序是一种静态概念。
  • 进程( process)指程序的一次运行过程。更确切说,进程是具有独立功能的一个程序关于某个数据集合的一次运行活动,因而进程具有动态含义 。同一个程序处理不同的数据就是不同的进程
    • 进程是OS对CPU执行的程序的运行过程的一种抽象。进程有自己的生命周期,它由于任务的启动而创建,随着任务的完成(或终止)而消亡,它所占用的资源也随着进程的终止而释放
    • 一个可执行目标文件(即程序)可被加载执行多次,也即,一个程序可能对应多个不同的进程

操作系统(管理任务)以外的都属于“用户”的任务。计算机处理的所有“用户”的任务由进程完成。 为强调进程完成的是用户的任务,通常将进程称为用户进程。 计算机系统中的任务通常就是指进程。

image-20210802201714728

“进程”的引入简化了程序员的编程以及语言处理系统的处理 ,即简化了编程、编译、链接、共享和加载等整个过程。造成一种整个处理器和存储空间都为我们的程序服务的假象,我们的代码可以随便放在存储空间的任何一个位置,CPU可以按照我们需要的流程执行,程序员不需要管其他的程序。

逻辑控制流与物理控制流

对于确定的数据集,某进程指令执行地址序列是确定的 ,称为进程的逻辑控制流。对于单处理器系统,进程会轮流使用处理器,即处理器的物理控制流由多个逻辑控制流组成

image-20210802203329929

逻辑控制流不会因被其他进程打断而改变, 还能回到原被打断的“断点”处继续执行。不同进程的逻辑控制流在时间上交错或重叠的情况称为并发(concurrency)

注意!只要两个进程的逻辑流在时间上重叠,则它们就是并发流。如果并发流在不同核或计算机上运行,则成为并行流。并行流属于并发流,区别只在于是否在同一处理器上运行

OS根据在shell中输入的可执行文件名,在磁盘中找到对应的可执行文件,把这个可执行文件加载入内存。OS通过处理器调度让处理器轮流执行多个进程。实现不同进程中指令交替执行的机制称为进程的上下文切换(context switching)也就是指把正在运行的进程换下,换一个新的进程到处理器执行

在一个进程的生命周期中,可能会有其他不同进程在处理器上交替运行!

处理器调度等事件会引起用户进程正常执行被打断,因而形成异常控制流。 进程的上下文切换机制很好地解决了这类异常控制流,实现了从一个进程安全切换到另一个进程执行的过程。

进程的物理实体(代码和数据等)支持进程运行的环境合称为进程的上下文。

  • 由进程的程序块、数据块、运行时的堆和用户栈(两者通称为用户堆栈)等组成的用户空间信息被称为用户级上下文(即进程的物理实体)

  • 由进程标识信息、进程现场信息、进程控制信息和系统内核栈等组成的内核空间信息被称为系统级上下文;(即支持进程运行的环境)

    • 处理器中各寄存器的内容被称为寄存器上下文 (也称硬件上下文),即进程的现场信息。

    • 在进行进程上下文切换时,操作系统把换下进程的寄存器上下文保存到系统级上下文中的现场信息位置。

  • 用户级上下文地址空间和系统级上下文地址空间一起构成了一个进程的整个存储器映像

image-20210803100922944

上下文切换发生在OS调度一个新进程到处理器上运行时,它需要完成以下三件事:

  1. 将当前处理器的寄存器上下文保存到当前进程的系统级上下文的现场信息中;
  2. 将新进程系统级上下文中的现场信息作为新的寄存器上下文恢复到处理器的各个寄存器中;
  3. 将控制转移到新进程执行。

这里,一个重要的上下文信息是PC的值,当前进程被打断的断点处的PC作为寄存器上下文的一部分被保存在进程现场信息中,这样,下次该进程再被调度到处理器上执行时,就可以从其现场信息中获取到断点处的PC,从而从上次的断点处继续执行

image-20210803142224613

进程是由内存中共享的内核进行管理的,内核并不是独立的进程,而是作为某些现有进程的一部分,始终位于地址空间顶部的代码,当出现异常时会进行执行。

进程控制

系统调用的错误处理

现在Linux提供许多函数,可以从用户程序调用来操作进程,这个操作进程的过程称为进程控制(Process Control)。这些函数主要通过系统级函数的形式来进行系统调用如果出现错误,通常会返回-1,然后设置全局变量errno来指明原因,所以我们必须检查这些函数的返回值,通常可以对这些函数进行封装

例如,对于 fork() 函数(用来创建子进程),我们应该这么写:

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

如果觉得这样写太麻烦,可以利用一个辅助函数:

void unix_error(char *msg) /* Unix-style error */
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}

// 上面的片段可以写为
if ((pid = fork()) < 0)
    unix_error("fork error");

我们甚至可以更进一步,把整个 fork() 包装起来,就可以自带错误处理,比如

pid_t Fork(void)
{
    pid_t pid;
    if ((pid = fork()) < 0)
        unix_error("Fork error");
    return pid;
}

调用的时候直接使用 pid = Fork(); 即可(注意这里是大写的 F)

获取进程信息

每个进程都有一个唯一的正数进程ID(PID),我们可以用下面两个函数获取进程的相关信息:

  • pid_t getpid(void) - 返回当前进程的 PID
  • pid_t getppid(void) - 返回当前进程的父进程的 PID

类型都为pid_t,Linux系统中在type.h中定义为int

#include <unistd.h>
#include <sys/types.h>
pid_t getpid(void);
pid_t getppid(void);
进程状态

进程会处于以下三种状态之一:

  • 运行:进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。即被抢占的进程也属于运行状态
  • **停止:当进程收到SIGSTOPSIGTSTPSIGTTINSIGTTOU信号时,进程的执行被挂起(Suspended)**且不会被调度,直到收到SIGCONT信号,进程才会运行。即进程暂时不会被调度,但是还有可能被调度。
  • 终止:进程被永久停止了,主要原因在于:
    • 进程收到了终止进程的信号;
    • 从主程序返回return
    • 调用exit函数。

exit 函数会被调用一次,但从不返回,具体的函数原型是:

// 以 status 状态终止进程,0 表示正常结束,非零则是出现了错误
void exit(int status)
创建进程

父进程通过fork函数创建一个子进程

#include <unistd.h>
#include <sys/types.h>
pid_t fork(void);

当调用fork函数时,就立即以当前进程作为父进程,创建一个新的子进程,具有以下特点:

新创建的子进程几乎但不完全与父进程相同:

  • 子进程得到与父进程虚拟地址空间相同独立的一份副本;
    • 两个进程具有相同的地址空间,意味着两个进程具有相同的用户栈、局部变量值、堆、全局变量和代码。但是两者的地址空间又是独立的,所以fork函数之后对两个进程的操作都是独立的。即父子进程具有执行fork函数之前相同的设置,而执行完fork函数后,两个进程就是并发独立的了。CPU实际上是在执行两段一模一样的代码,断点位置也一样。
  • 子进程获得与父进程任何打开文件描述符相同的副本;
    • 即子进程可以读写父进程打开的任何文件。
  • 子进程与父进程的PID不同。
    • fork函数调用一次,但是返回两次,会有两个返回值——在父进程中会返回子进程的PID,而在子进程中会返回0。所以我们可以通过fork函数的返回值判断当前所处的进程,如果不加以区分,则父进程和子进程会执行后续相同的代码。

例1

#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>

int main(){
  int x = 1;
  pid_t pid;
  
  pid = Fork();
  if(pid == 0){ //处于子进程中
    printf("Child%d : x=%d\n",getpid(), ++x);
  }else{ //处于父进程中
    printf("parent%d : x=%d\n", getpid(), --x);
  }
  exit(0);
}

这里在父进程中的x=0,在子进程中的结果为x=2,由于这两个进程是并发的,所以无法确定这两条输出哪条先显示。而且由于父子进程共享相同的打开文件,父进程和子进程都打印到标准输出

img

例2

image-20210804180544047
回收子进程

当子进程终止时,内核会一直保持它的状态直到它被父进程回收(Reaped),因为父进程可能想知道子进程的退出状态,这类进程称为僵尸进程(Zombie),保持在一种已终止(terminating)的状态中,仍然消耗系统的内存资源。当父进程回收僵尸进程时,内核就会将子进程的退出状态返回给父进程,并抛弃僵尸进程。

  • 父进程利用 waitwaitpid 回收已终止的子进程,然后获取子进程的退出状态,kernel 就会把 zombie child process 给删除。
  • 如果父进程没有回收它的子进程就终止了,那么内核会安排init进程(PID=1)去接管收它的子进程。

长时间运行的进程应当主动回收它们的僵死进程,不然最终会填满内存空间并导致内核崩溃。

比如以下代码

void fork7() {
    if (fork() == 0) {
        /* Child */
        printf("Terminating Child, PID = %d\n", getpid());
        exit(0);
    } else {
        printf("Running Parent, PID = %d\n", getpid());
        while (1)
            ; /* Infinite loop */
    }
}

这里会死循环父进程,而子进程会直接退出

image-20210804183015722

可以看见,父进程的PID为61015,子进程的PID位61016,由于父进程没有对子进程进行回收,所以子进程变成了僵死进程defunct。当终止父进程时,子进程会由init进程回收,则父进程和子进程都被删除了。

waitpid

我们可通过调用以下函数来等待子进程的终止,父进程会得到被回收的子进程PID,且内核会删除僵死进程

#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options); 
  • 等待集合pid

    • 如果pid>0,则等待集合就是一个单独的子进程
    • 如果pid=-1,则等待集合就是该进程的所有子进程
    • **注意:**当父进程创造了许多子进程,这里通过pid=-1进行回收时,子程序的回收顺序是不确定的,并不会按照父进程生成子进程的顺序进行回收。可通过按顺序保存子进程的PID,然后按顺序指定pid参数来消除这种不确定性。
  • 等待行为options

    • 0:默认选项,则会挂起当前进程,直到等待集合中的一个子进程终止,则函数返回该子进程的PID。此时,已终止的子进程已被回收。
    • WNOHANG:如果等待子进程终止的同时还向做其他工作,该选项会立即返回,如果子进程终止,则返回该子进程的PID,否则返回0。
    • WUNTRACED:当子进程被终止或暂停时,都会返回。
    • WCONTINUED:挂起当前进程,知道等待集合中一个正在运行的子进程被终止,或停止的子进程收到SIGCONT信号重新开始运行。
    • **注意:**这些选项可通过|合并。
  • 如果statusp非空,则waitpid函数会将子进程的状态信息放在statusp中,可通过wait.h中定义的宏进行解析

    • WIFEXITED(statusp):如果子进程通过调用exitreturn正常终止,则返回真,。此时可通过WEXITSTATUS(statusp)获得退出状态。
    • WIFSIGNALED(status):如果子进程是因为一个未捕获的信号终止的,则返回真。此时可通过WTERMSIG(statusp)获得该信号的编号。
    • WIFSTOPPED(statusp):如果引起函数返回的子进程是停止的,则返回真。此时可通过WSTOPSIG(statusp)获得引起子进程停止的信号编号。
    • WIFCONTINUED(statusp):如果子进程收到SIGCONT信号重新运行,则返回真。
  • 如果当前进程没有子进程,则waitpid返回-1,并设置errnoECHILD,如果waitpid函数被信号中断,则返回-1,并设置errnoEINTR。否则返回被回收的子进程PID。

注意:waitpid通过设置options来决定是否回收停止的子进程。并且能通过statusp来判断进程终止或停止的原因。

有个简化的waitpid函数

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *statusp);

调用wait(&status)等价于调用waitpid(-1, &status, 0)

image-20210804185826126
休眠进程
#include <unistd.h>
unsigned int sleep(unsigned int secs);
int pause(void);

函数sleep将进程挂起一段时间,而该函数的返回值为剩下的休眠时间。

函数pause将进程挂起,直到该进程收到一个信号。

加载并运行程序

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

#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]); 

execve函数加载并运行filename可执行目标文件,参数列表argv和环境列表envp是以NULL结尾的字符串指针数组,其中argv[0]为文件名。若错误(如找不到指定文件filename) ,则返回-1,并将控制权交给调用程序; 若函数执行成功,则不返回 ,最终将控制权传递到可执行目标中的主函数main。

img

调用exevec函数其实就是调用加载器,则加载器会在可执行目标文件filename的指导下,将文件中的内容复制到当前进程的虚拟内存空间仍然是一样的进程,PID不变,只是运行一个不同的程序),再调用_libc_start_main来初始化执行环境,调用main函数,main函数的函数原型如下所示

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

其中,argc为参数数目,argv为参数列表,envp为环境列表。argc指定参数个数,参数列表中第一个总是命令名(可执行文件名) 例如:命令行为“ld -o test main.o test.o” 时,argc=5

其用户栈的架构如下所示

image-20210803232159673

**注意:**可以通过全局变量environ来获得环境列表。

这里还有一些函数用来对环境变量进行操作

#include <stdlib.h>

char *getenv(const char *name); //获得名字为name的环境值
int setenv(const char *name, const char *newvalue, int overwrite); //对环境值进行修改
int unsetenv(const char *name); // 删除环境变量

fork函数和execve区别:

  • fork函数新建一个不同PID的子进程,具有和父进程相同的上下文,是父进程的复制品,有相同的虚拟内存空间,运行相同的代码、程序和变量,而在不同进程。

    execve函数保持PID不变,在当前进程的上下文中加载并运行一个新程序,会覆盖当前进程的地址空间,并继承调用execve函数时已打开的所有文件描述符,就是保持进程不变,但是运行完全不同的程序

  • fork函数调用一次返回两次,而execve函数调用后,只有出现错误才会返回到调用程序。

  • 当你想要创建并发服务器时,可以通过fork函数创建多个服务器副本,可以运行多个相同代码。

想要保持当前进行运行的情况下,运行另一个程序,可以先通过fork新建一个进程,然后在子进程中用execve执行另一个程序,此时在父进程就运行原来的程序,而在子进程中就运行另一个程序。

image-20210803231707593 image-20210803231733245

Linux Process Hierarchy

这里介绍一下shell程序的内容。实际上系统上的进程呈现为层次结构

img

当你启动系统时,第一个创建的进程是init进程,它的PID为1,系统上其他所有进程都是init进程的子进程。init进程启动时会创建守护进程(Daemon),该进程一般是一个长期运行的程序,通常用来提供服务,比如web服务等其他你想要一直在系统上运行的服务。然后init进程还会 创建登录进程,即登录shell(Login Shell),它为用户提供了命令行接口,所以当你登录到一个Linux系统,最终得到的是一个登录shell。

shell会以你的身份来执行程序,比如我们输入ls命令,即要求shell运行名为ls的可执行程序,则shell会创建一个子进程,在该子进程中执行ls程序,而该子进程也可能创建其他的子进程。

所以shell就是就是一个以用户身份来运行程序的应用程序。在Linux下,默认的shell叫bash

在Linux中,如果命令行以&结尾,那么可以使shell在后台运行此作业(shell不会等待它完成)。如果没有,shell则会在前台运行此作业,会等待它完成。

在shell中执行程序就是一系列读和解析命令行的过程。

int main()
{
    char cmdline[MAXLINE]; /* command line */

    while (1) {
        /* read */
        printf("> ");//首先shell打印一个提示符,等待用户输入命令行
        Fgets(cmdline, MAXLINE, stdin);//从标准输入中读取用户的输入到cmdline中
        if (feof(stdin))
            exit(0);

        /* 计算命令行 */
        eval(cmdline);
        //计算结束后,继续等待用户下一次的输入
    }
}

void eval(char *cmdline)
{
    char *argv[MAXARGS]; /* Argument list execve() */
    char buf[MAXLINE];   /* Holds modified command line */
    int bg;              /* Should the job run in bg or fg? */
    pid_t pid;           /* Process id */
	
    strcpy(buf, cmdline);
    //cmdline是用户输入的一串包含空格的字符串,以空格为分隔,转换成字符串数组argv。
    //返回值bg代表是否在后台运行此任务(bg=background)
    bg = parseline(buf, argv);
    if (argv[0] == NULL)
        return;   /* Ignore empty lines */
	//如果输入的不是一个内置的命令,意味着用户在要求shell运行一些程序
    if (!builtin_command(argv)) {
        //shell创建一个子进程
        if ((pid = Fork()) == 0) {   /* Child runs user job */
            //这个子进程使用execve调用来运行这个程序
            if (execve(argv[0], argv, environ) < 0) {
                //如果execve的返回值为-1,打印错误信息
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }

        /* Parent waits for foreground job to terminate */
   		 //如果不是后台作业,父进程(shell)就要阻塞直到子进程终止    
		if (!bg) {
            int status;
        	//等待进程号为pid(即刚刚创建的进程)的进程结束,然后回收它
            if (waitpid(pid, &status, 0) < 0)
                unix_error("waitfg: waitpid error");
        }
        //如果是后台作业,那么输出一条消息后回到主程序,继续读取用户输入的命令
        else
            printf("%d %s", pid, cmdline);
    }
    return;
}

前台作业和后台作业的区别只是shell需要等待前台作业完毕。上面的代码的问题是,如果是后台工作,我们不会回收任何的后台进程,最终会导致内存泄漏。

这就引出了异常控制流来帮助我们解决这个问题。在shell的子进程结束时,内核会告知shell,shell会对此做出反应,并放出waitpid。内核用到的这个通知机制就是信号(signal)

signal信号

这一章将讨论一种更高层次的软件形式的异常,称为Linux信号。信号就是一条小消息,可以通知一个用户进程系统中发生了一个某种类型的事件

信号是类似于异常和中断(只是异常是由硬件和软件共同实现的,而信号时完全由软件实现的),是由内核向当前进程发出的(有时是在其他进程的请求下由内核发给其他的进程)。信号的类型由 1-30 的整数定义,信号所能携带的信息极少,一是对应的编号,二就是信号到达这个事实。

编号名称默认动作对应事件
2SIGINT终止当用户输入Ctrl+C时,内核会向前台作业发送SIGINT信号,该信号默认终止该作业。
9SIGKILL终止该信号的默认行为是用来终止进程的,无法被修改或忽略。
11SIGSEGV终止且 Dump段冲突 Segmentation violation,当你试图访问受保护的或非法的内存区域,就会出现段错误,内核会发送该信号给进程,默认终止该进程。
14SIGALRM终止时间信号,设置定时器,超时后将信号发给自己
17SIGCHLD忽略当子进程终止或停止时,内核会发送该信号给父进程,由此父进程可以对子进程进行回收。

内核发送信号给目标进程,通过为目标进程的上下文设置一些状态来实现。

  • **发送信号:**内核通过更新目的进程上下文中的某个状态,来表示发送了一个信号到目的进程,所以这里除了目标进程上下文中的一些位被改变了,其他没有任何变化。

    内核发送信号的原因:

    • 内核检测到系统中的一些事件,比如子进程被终止(SIGCHLD)或除以0(SIGFPE
    • 一个进程请求内核代表它发送一个信号给另一个进程(比如系统调用kill
  • **接收信号:**当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接受了信号。

    • 比如忽略信号、终止进程,或执行用户级的**信号处理程序(Signal Handler)**来捕获信号。

    执行信号处理程序类似于执行异常处理程序,只是异常处理程序是内核级别的,而信号处理程序就只是你的C代码程序

    img

    当执行完信号处理程序后,会返回到下一条指令继续执行,类似于一次中断。

我们将发送了但是还没被接收(处理)的信号称为待处理信号(Pending Signal)同类型的信号至多只会有一个待处理信号(pending signal),一定要注意这个特性,因为内部实现机制不可能提供较复杂的数据结构,所以信号的接收并不是一个队列。比如说进程有一个 SIGCHLD 信号处于等待状态,那么之后进来的 SIGCHLD 信号都会被直接扔掉。

  • 内核为每个进程在pending位向量中维护待处理信号的集合,不同的信号具有不同的编码,pending位向量中的每一位都对应着一个特定的信号。这就是为什么信号的接收不排队,因为每个信号在位向量中仅有一个位表示。当内核传递一个信号时,设置pending中的对应的位。当信号被接收时,pending中的对应的位被清除。

而进程可以选择阻塞接收某种信号,但信号的发送并不受控制,所以被阻塞的信号仍然可以被发送,不过直到进程取消阻塞该信号之后才会被接收

  • 内核为每个进程在blocked位向量中维护了被阻塞的信号集合,
发送信号

进程组由一个正整数进程组ID来标识,每个进程组包含一个或多个进程,而每个进程都只属于一个进程组,默认父进程和子进程属于同一个进程组。

我们将shell为了对一条命令行进行求值而创建的进程称为作业(Job),比如输入ls / sort命令行,就会创建两个进程,分别运行ls程序和sort程序,这两个进程通过Unix管道连接到一起,由此就得到了一个作业。

  • 任何时刻,最多只有一个前台作业和任意数量的后台作业
  • shell会为每个作业创建一个独立的进程组,该进程组ID由该作业中任意一个父进程的PID决定。

shell为每一条命令行都创建一个作业,每个作业是一个独立的进程组,这个命令行中创建的进程以及以后在这些进程中创建的子进程都属于同一个进程组。

img
  • getpgrp() - 返回当前进程的进程组
  • setpgid() - 设置一个进程的进程组
#include <unistd.h>
pid_t getpgrp(void); //返回所在的进程组
int setpgip(pid_t pid, pid_t pgid); //设置进程组
/* 
 * 如果pid大于零,就使用进程pid;如果pid等于0,就使用当前进程的PID。
 * 如果pgid大于0,就将对应的进程组ID设置为pgid;如果pgid等于0,就用pid指向的进程的PID作为进程组ID
 */ 

这里提供了以下对进程组的操作,允许你可以同时给一组进程发送信号。

  • /bin/kill向一个进程或进程组发送任意信号

    程序/bin/kill具有以下格式

    /bin/kill [-信号编号] id  
    

    id>0时,表示将信号传递给PID为id的进程;当id<0时,表示将信号传递给进程组ID为|id|的所有进程。我们可以通过制定信号编号来确定要传输的信号,默认使用-15,即SIGTERM信号,为软件终止信号。

  • 从键盘发送信号

    通过键盘上输入Ctrl+C会使得内核发送一个SIGINT信号到前台进程组中的所有进程,终止前台作业;通过输入Ctrl+Z会发送一个SIGTSTP信号到前台进程组的所有进程,停止前台作业(挂起进程),直到该进程收到SIGCONT信号。

    img

    ps命令可以查看进程的信息,STAT表示进程的状态:S表示进程处于睡眠状态,T表示进程处于停止状态,R表示进程处于运行状态,Z表示僵死进程,而+表示前台作业。

    在以上代码中,我们输入Ctrl-Z,可以发现两个fork进程的状态变成了停止状态了,通过输入fg命令可以将这些被挂起的进程恢复到前台运行,再通过Ctrl+C可以停止这两个前台进程。

  • 用系统调用kill函数发送信号

    可以在函数中调用kill函数来对目的进程发送信号

    #include <sys/types.h>
    #include <signal.h>
    int kill(pid_t pid, int sig); 
    

    pid>0时,会将信号sig发送给进程pid;当pid=0时,会将信号sig发送给当前进程所在进程组的所有进程;当pid<0时,会将信号sig发送给进程组ID为|pid|的所有进程。

    img
  • alarm函数发送SIGALARM信号

    #include <unistd.h>
    unsigned int alarm(unsigned int secs); 
    

    alarm函数时,会取消待处理的闹钟,返回待处理闹钟剩下的时间,并在secs秒后发送一个SIGALARM信号给当前进程。

接收信号

当内核把进程p从内核模式切换回用户模式时,比如从系统调用返回、异常处理或完成了一次上下文切换时,会检查进程p的未被阻塞的待处理信号的集合,即计算pnb = pending & ~blocked

  • 如果pnb=0,说明是空集合,则内核会将控制传递给p的逻辑流中的下一条指令
  • 如果pnb != 0,说明集合非空
    • 内核会选择集合中编号最小的信号k,即选择 pnb 中最小的非零位,并强制进程 p 接收信号 k
    • 接收到信号之后,进程 p 会执行对应的动作
    • 对该集合中的所有信号都重复这个操作(对 pnb 中所有的非零位进行这个操作),直到集合为空
    • 此时内核再将控制传递回p的逻辑流中的下一条指令。

每次从内核模式切换回用户模式,将处理所有信号

image-20210805110833494
信号处理程序

每种信号类型具有以下一种预定的默认行为:

  • 进程终止
  • 进程终止并dumps core
  • 进程挂起直到被SIGCONT信号重启
  • 进程忽略信号

我们这里可以通过signal函数来修改信号的默认行为(signal与kill一样具有误导性,它们的行为与名字不相符),但是无法修改SIGSTOPSIGKILL信号的默认行为

#include <signal.h>
typedef void (*sighandler_t)(int); 
sighandler_t signal(int signum, sighandler_t handler);
  • signum为信号编号,可以直接输入信号名称

  • handler为我们想要对信号signum采取的行为

    • handlerSIG_IGN,表示要进程忽略该信号

    • handlerSIG_DFL,表示要恢复该信号的默认行为

    • handler为用户自定义的信号处理程序地址,则会调用该函数来处理该信号,该函数原型为void signal_handler(int sig);

      通过把信号处理程序的地址传递到signal函数从而改变默认行为,叫做设置信号处理程序

      调用信号处理程序称为捕获信号

      执行信号处理程序称为处理信号。当信号处理程序返回时,会将控制传递回逻辑流中的下一条指令。**注意:**信号处理程序可以被别的信号处理程序中断。

  • signal函数执行成功,则返回之前signal handler的值,否则返回SIG_ERR

例子1:

#include <signal.h>
void handler(int sig){
  if((waitpid(-1, NULL, 0)) < 0)
    unix_error("waitpid error");
}
int main(){
  if(signal(SIGCHLD, handler) == SIG_ERR)
    unix_error("signal error");
    
  return 0;
}

这里只要在main函数开始调用一次signal,就相当于从此以后改变了SIGCHLD信号的默认行为,让它去执行handler处理程序。当子进程终止或停止时,内核发送SIGCHLD信号到父进程,则父进程会调用handler函数来对该子进程进行回收。

例子2:

这里我们屏蔽了 SIGINT 函数,修改了进程收到SIGINT信号的默认行为,即使按下 ctrl+c 也不会终止

void sigint_handler(int sig) // SIGINT 处理器
{
    printf("想通过 ctrl+c 来关闭我?\n");
    sleep(2);
    fflush(stdout);
    sleep(1);
    printf("OK. :-)\n");
    exit(0);
}

int main()
{
    // 设定 SIGINT 处理器
    if (signal(SIGINT, sigint_handler) == SIG_ERR)
        unix_error("signal error");
        
    // pause暂停当前进程,直到收到一个信号并且在这个进程中执行了它的处理程序
    pause();
    return 0;
}

信号处理程序与主程序运行在同一个进程中,它们之间属于多线程的关系,是并发运行的(同时运行,分别拥有独立的逻辑流),而不是串行的。

  • 它们之间并不是多线程的关系!多线程是指CPU在多个代码之间跳来跳去,我们无法知道CPU什么时候会执行哪一段代码。主程序中接收了信号,从内核态回到用户态时,就要转而执行信号处理程序,等到信号处理程序执行完了,才能继续执行主程序
image-20210805131857888

从内核态回到进程A的用户态时,内核检测到进程A的标志位pending的未处理信号,转而执行对应的信号处理程序。执行结束后,先返回内核态,再返回进程A的用户态,继续执行进程A的程序。(?)

信号处理程序也可以被其他信号处理程序中断。

img
阻塞信号

一个进程可以有选择地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的信号不会被接收,直到进程取消对这种信号的阻塞(信号被阻塞时不会消失)。

  • 隐式阻塞机制:内核会默认阻塞与当前在处理的信号同类型的其他待处理信号,也就是说,一个 SIGINT 信号处理器是不能被另一个 SIGINT 信号中断的。

  • **显示阻塞机制:**应用程序通过sigprocmask函数来显示阻塞和解阻塞选定的信号。

    #include <signal.h>
    int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
    
    • 通过how来决定如何改变阻塞位向量blocked

      • how=SIG_BLOCK时,blocked = blocked | set
      • how=SIG_UNBLOCK时,blocked = blocked & ~set
      • how=SETMASK时,block = set
    • 如果oldset非空,则会将原始的blocked值保存在oldset中,用于恢复原始的阻塞位向量

    以及其他一些辅助函数,这些函数可以置位或复位位向量中的位:

    #include <signal.h>
    int sigemptyset(sigset_t *set); //初始化set为空集合
    int sigfillset(sigset_t *set); //把每个信号都添加到set中
    int sigaddset(sigset_t *set, int signum); //将signum信号添加到set中
    int sigdelset(sigset_t *set, int signum); //将signum从set中删除
    int sigismember(const sigset_t *set, int signum); //如果signum是set中的成员,则返回1,否则返回0
    

临时阻塞特定的信号:

sigset_t mask, prev_mask;

Sigemptyset(&mask); // 创建空集,这是一个全为0的mask(阻塞位向量)
Sigaddset(&mask, SIGINT); // 把 SIGINT 信号加入屏蔽列表中,即把mask中对应的位置为1

// 阻塞对应信号(blocked=mask|blocked,即在阻塞位向量中添加mask中的阻塞位)
// 并且prev_mask=blocked,保存之前的阻塞位向量作为备份
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
...
... // 这部分代码不会被 SIGINT 中断
...
// blocked=prev_mask,取消阻塞信号,恢复原来的状态
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);

阻塞与修改信号的处理程序不一样,阻塞只是在一段代码中不处理某个信号(即不会被某个信号中断),这个信号不会消失,阻塞结束后,还是需要处理该信号。而修改信号的处理程序则是:在该进程中,从此以后,完全屏蔽该信号(或者完全改变该信号的处理程序)。

安全处理信号

信号处理的一个**难点在于:**处理程序与主程序在同一进程中是并发运行的,它们共享同样的全局变量,可能会与主程序和其他处理程序相互干扰,因为并发访问导致而数据破坏问题。

  • **G0:**处理程序要尽可能简单。

    • 当处理程序尽可能简单时,就能避免很多错误。**推荐做法:**处理程序只是简单地设置全局标志并立即返回;所有与接收信号相关的处理都由主程序执行,它周期性检查并重置这个全局标志。
  • **G1:**在处理程序中只调用异步信号安全的函数。

    • 异步信号安全的函数能被处理程序安全地调用,因为:

      • 它是可重入的(只访问局部变量)
      • 它不能被信号处理程序中断的。

      Linux中保证安全的系统级函数如下所示,注意:printfsprintfmallocexit是不安全的,而write是安全的。

img

  • **G2:**保存和恢复errno

    • 许多Linux异步信号安全的函数都会在出错时返回并设置errno,在处理程序中调用这样的函数可能会干扰主程序中其他依赖于errno的部分。解决方法是在进入处理程序前将errno保存为局部变量,再在返回时恢复errno,使得主程序可以使用原本的errno
  • G3:阻塞所有的信号,保护对共享全局数据结构的访问

    • 对于数据结构的访问(读取或写入),可能需要若干条指令,当主程序在访问全局数据结构中途被中断,进入处理程序时,如果处理程序也访问当前数据结构,可能会发现该数据结构的状态是不一致的。所以对全局数据结构进行访问时,要阻塞所有的信号(无论在主程序还是信号处理程序中)。(与多线程中的加锁一样)
  • **G4:**用volatile声明在主程序和信号处理程序共享的全局变量

    • 比如G0说的使用全局变量来保存标志,处理程序更新标志,主程序周期性读取该标志,编译器可能会感觉主程序中该标注没有变化过,所以直接将其值缓存在寄存器中,则主程序就无法读取到处理程序的更新值。所以我们需要使用volatile来声明该标志,使得编译器不会缓存该变量,使得主程序每次都从内存中读取该标志。
  • **G5:**用sig_atomic_t声明那些仅进行读写操作,不会进行增量或更新操作的变量

    • 通过使用C提供的整型数据类型sig_atomic_t来声明变量,使得对它的读写都是原子的,不会被中断,所以就不需要暂时阻塞信号了。大多数系统中,sig_atomic_tint类型。**注意:**对原子性的保证只适用于单个读和写,不适用于flag++flag+=1这类操作。

**综上所述:**是处理函数尽可能简单,在处理程序中调用安全函数,保存和恢复errno,保护对共享数据结构的访问,使用volatilesig_atomic_t

正确信号处理

待处理的信号是不排队的。对于每种信号类型,pending位向量只有1位与之对应,因此每种信号类型最多只能有1个未处理信号。

注意,如果存在一个未处理的信号就表明至少有一个信号到达了,所以不能用信号来对其它进程中发生的事件进行计数。

错误的信号处理:

image-20210805203441666

主程序创建N个子进程,每个子进程结束都会给父进程发送SIGCHLD信号,父进程每收到一个信号,就进行信号处理(回收子进程)。问题在于,如果众多子进程一次性发送过多的SIGCHLD信号给父进程,当父进程还在信号处理程序时,就会丢失若干个SIGCHLD信号,使得无法得到正确的回收子进程的数目。

解决方法:

void child_handler2(int sig)
{
    int olderrno = errno;
    pid_t pid;
    //一直循环到所有的子进程都被终止,wait返回-1(正常情况wait返回被回收的子进程pid),errno被置为ECHID
    while ((pid = wait(NULL)) > 0) {
        ccount--;
        Sio_puts("Handler reaped child ");
        Sio_putl((long)pid);
        Sio_puts(" \n");
    }
    if (errno != ECHILD)
        Sio_error("wait error");
    errno = olderrno;
}

这里我们假设接收到一个SIGCHLD信号意味着有多个信号被终止或停止,所以通过while循环来回收所有的进程,此时就不会遗漏子进程的回收。

可移植的信号处理

信号处理的另一个缺陷是:不同的系统有不同的信号处理语义,比如:

  • signal函数的语义各不相同,有的Unix系统,当处理完信号时,就会将对该信号的处理恢复为默认行为。
  • 存在一些潜在的会阻塞进程较长时间的系统调用,称为慢速系统调用,比如readwriteaccpet。在执行慢速系统调用时,如果进程接收到一个信号,可能会中断该慢速系统调用,并且当信号处理程序返回时,无法继续执行慢速系统调用,而是返回一个错误条件,并将errno设置为EINTR

这些可以通过sigaction函数来明确信号处理语义,由于该函数的复杂性,提供一个封装好的函数

image-20210805205501470

可以类似signal函数那样使用,信号处理语义为:

  • 只有当前信号处理程序正在处理的信号类型会被阻塞
  • 只要可能,被中断你的系统调用会自动重启
  • 一旦设置了信号处理程序,就会一直保持
同步流来消除并发错误

并发流可能以任何交错方式运行,所以信号发送的时机很难预测,可能会出现错误,所以需要首先对目标信号进行阻塞,先执行预定操作,然后将其解阻塞进行捕获。比如以下代码

img

如果缺少30和32行,在父进程中,addjob执行之前子进程可能就终止了,父进程收到SIGCHID信号,转到信号处理程序,执行deletejob。则会导致父进程将已经被信号处理程序回收的子进程加入job中,这些job将永远不会被删除。

所以我们必须要控制SIGCHID处理程序在addjob之后执行。可以如上所示,我们用sigprocmask在创建子进程之前阻塞SIGCHID信号

  • 创建了子进程之后,子进程的虚拟内存空间与父进程一模一样,所以继承了父进程的阻塞位向量,此时子进程也处于阻塞SIGCHID的状态。if内只有子进程可以执行,execve之前,我们要取消子进程中对SIGCHID的阻塞( 因为我们不知道这个子进程是否需要回收它的子进程)
  • 在主进程中先对SIGCHLD信号进行阻塞,然后在执行addjob之前对所有的信号进行阻塞(对公共数据结构进行操作时,应避免处理信号,所以应该阻塞所有的信号),在执行完addjob函数后再解阻塞。此时才能执行信号处理程序,保证了先执行addjob函数再执行deletejob函数。

**经验之谈:**不要对代码做任何假设,比如子进程运行到这里才终止。

**注意:**可以通过阻塞信号来控制信号的接收时机。

显示等待信号

进程的存储器映射

每个用户进程具有独立的私有虚拟地址空间 ,可执行文件中的符号定义和引用的地址实际上是可执行文件映射到虚拟地址空间中的地址

每个用户进程具有独立的私有虚拟地址空间 ,可执行文件中的符号定义和引用的地址实际上是可执行文件映射到虚拟地址空间中的地址

image-20210803103538932

整个虚拟地址空间分为两大部分:内核虚拟存储空间(简称内核空间)和进程虚拟存储空间(简称用户空间)。在采用虚拟存储器机制的系统中,每个程序的可执行目标文件都被映射到同样的虚拟地址空间上,也即,所有用户进程的虚拟地址空间是一致的, 只是在相应的只读区域和可读写数据区域中映射的信息不同而已。

进程描述符

Linux将进程对应的虚拟地址空间组织成若干**“区域(area)”的集合,这些区域是指在虚拟地址空间中的一个有内容的连续区块( 即已分配的)**(其实就是只读代码段、可读写数据段、运行时堆、用户栈、共享库等区域)。每个区域可被划分成若干个大小相等的虚拟页面,每个存在的虚拟页面一定属于某个区域。

OS要对进程进行管理,为进程分配主存空间(从磁盘调取虚页到内存由OS完成),OS必须要知道进程的地址空间中每个区域的分布

Linux内核为每个进程维护了一个进程描述符,数据类型为task_ struct 结构。task_struct 中记录了内核运行该进程所需要的所有信息,例如,进程的PID、指向用户栈的指针、可执行目标文件的文件名等。task_struct 结构可对进程虚拟地址空间中的区域进行描述。

image-20210803162958567

mm_struct中还有一个字段mmap,它指向一个由vm_area_struct 构成的链表表头Linux 采用链表方
式管理用户空间中的区域,使得内核不用记录那些不存在的“空洞”页面

image-20210803163223523

每个vm_area_struct实际上是由mmap()函数生成的,实际上是一个系统调用。

void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset); 

读取可执行文件中的程序头表而获得mmap的实参

将指定文件fd中偏移量offset开始的长度为length个字节的一块信息映射到虚拟空间中起始地址为start、长度为length个字节的一块区域,得到vm_area_struct 结构的信息,并初始化相应页表项,建立文件地址和虚存区域之间的映射关系(注意!此时是初始化!V=0,此时只是刚刚建立映射,对应的虚页还在磁盘中,还没有调入内存。

image-20210803210252154

MAP_ANON:.bss、堆和用户栈这些区域在文件中不包含任何信息,没有实际的磁盘文件,无需从磁盘读入,在虚拟内存空间中初始化为0,所以称为请求0的页,是由内核创建的匿名文件

image-20210803215721413
共享库文件中的共享对象
image-20210803224527778
Linux中的页故障处理

当CPU中的MMU在对某地址VA进行地址转换时,若检测到页故障(页表中访问到V=0的表项),则转由操作系统内核进行页故障处理。

  • Linux 内核可根据上述对虛拟地址空间中各区域的描述,将VA与vm_area_struct 链表中每个vm_ start 和vm_ end 进行比较,以判断VA是否属于“空洞”页面。

    • 若是,则发生“段故障(segmentationfault)";

    • 若不是,则再判断所进行的操作是否和所在区域的访问权限(由vm_prot 描述)相符。

      • 若不相符,例如,假定VA属于代码区,访问权限为PROT_EXE ( 可执行),但对地址VA的操作是“写”,那么就发生了**“访问越权”**;

        假定在用户态下访问属于内核的区域,访问权限为PROT_NONE (不可访问),那么就发生了**“访问越级”**。

    段故障、访问越权和访问越级都会导致终止当前进程。

  • 若不是上述几种情况,则内核判断发生了正常的缺页异常,此时,只需在主存中找到一个空闲的页框,从硬盘中将缺失的页面装人主存页框中。若主存中没有空闲页框,则根据页面替换算法,选择某个页框中的页面交换出去,然后从硬盘上装入缺失的页面到该页框中。

从页故障处理程序返回后,将回到发生缺页的指令重新执行

存储管理全局
image-20210803165648400

磁盘中的可执行文件中的代码和数据与主存中的页框不能直接映射,靠虚拟地址空间进行映射

  • 在生成可执行文件时,通过程序头表,先描述可执行文件与虚拟地址空间之间的映射

  • 生成一个进程时,通过mmap,生成vm_area_struct ,对进程虚拟内存空间中的区域进行描述

  • 在进程执行时,生成一个页表,描述虚拟地址空间与主存地址空间之间的映射

用户态和内核态

处理器要么执行用户代码,要么执行内核代码。为了区分处理器运行的是用户代码还是内核代码,必须有一 个状态位来标识,这个状态位称为模式位

  • 用户模式(也称目态、用户态)下,处理器运行用户进程, 此时不允许使用特权指令
  • 内核模式(有时称系统模式、管理模式、超级用户模式、管态、内核态、核心态)下处理器运行内核代码,允许使用特权指令,例如:停机指令、开/关中断指令、Cache冲刷指令等。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值