1 信号快速认识

1-1 生活角度的信号

        闹钟,红绿灯,上课铃声,狼烟,肚子叫,敲门声 ...... 这个时候人就相当于进程
 

1-2 概念角度的信号

        信号就是中断我们人正在做的事情,是一种事情的异步通知机制。是一种给进程发送的,用来进行事件异步通知的机制。

举例理解同步异步:
        老师,让张三去帮我拿东西,如果我们自习一会,等张三回来再讲课就是同步机制。如果我们继续上课,不等张三就是异步。

        信号的产生,相当于进程的运行,是异步的。信号是发送给进程的

1-3 信号的基本结论

从上面两个角度我们可以得到关于信号的一些基本结论

        1 信号处理,进程在没有产生的时候,早就知道信号该如何处理了

        2 信号的处理,不是立即处理,而是可以等一会在处理,合适的时候,进行信号处理

        3  人能识别信号,是因为被“提前”教育过的,进程也是如此,OS程序员设计的进程,进程早已经内置了对于信号的识别和处理方式

        4 信号源非常多 -> 给进程产生的,信号源,也非常多   

1-4 信号概念

        信号是进程之间事件异步通知的一种方式,属于软中断。

1-4-1 查看信号       

        每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义
#define SIGINT 2
        编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

1-4-2 信号处理

信号处理有三种方式:

        1 默认处理动作

        2 自定义信号处理,自定义捕捉方法,一般是使用系统调用接口

        3 忽略处理        

2 信号的产生

当前阶段:

2-1 通过终端按键产生信号

2-1-1 基本操作

        例如ctrl+c 是给目标进程发送信号。相当一部分信号的处理动作,就是让自己终止

        2 代码示例验证

#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;


void Catch(int signal)
{
    cout<<"捕捉到一个信号:"<<signal<<endl;
}

int main()
{
    signal(SIGINT,Catch);

    while(true)
    {
        sleep(1);
        cout<<"我是进程"<<endl;
    }

    return 0;
}

2-1-2 理解OS如何得知键盘有数据

        通过键盘产生信号,发给目标进程,什么是目标进程呢?
        目标进程就是前台进程,键盘产生的信号,只能交给前台进程。
进程分类:
        1 前台进程 。/XXX ---前台进程,命令行shell进程就是一个前台进程,能从键盘中获取输入
        2 后台进程。 /XXX & ---后台进程,无法从标准输入比如键盘中获取输入内容

注意:
        前台进程就只能有一个,但是后台进程可以有多个。为什么? 因为前台进程的本质,就是从键盘获取数据,但是输入就只有一个,所以规定前台进程只能有一个。

补充命令:

        jobs--查看所以后台任务
        fb --- 任务号,特定进程,提到前台
        ctrl+z 进程切换到后台
        bg 任务号:让后台任务恢复运行

2-1-3 初步理解信号起源

        信号其实是从纯软件角度,模拟硬件中断的行为,只不过硬件中断是发给CPU,而信号是发给进程两者有相似性,但是层级不同,这点我们后面的感觉会更加明显     
         什么叫做给进程发送信号?信号产生之后,并不是立即处理,所以要求,进程必须把信号记录下来!记录在内核task_struct 结构体中,通过位图的方式来映射信号,所以修改位图,本质就是修改内核数据结构,所以是由操作系统自己修改的,提供系统调用,但是不论信号怎么产生,发送信号,在底层,必须让OS发送
        所以发送信号本质是什么?向目标进程写信号,修改位图

2-2 使用系统命令产生信号                                                                      

kill -信号 进程pid

2-3 调用系统调用产生信号   

2-3-1 kill 函数

        kill 命令是调用 kill 函数实现的。 kill 函数可以给⼀个指定的进程发送指定的信号。

NAME
    kill - send signal to a process
SYNOPSIS

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

RETURN VALUE
On success (at least one signal was sent), zero is returned. On error,
-1 is returned, and errno is set appropriately.

参数:

        pid--传入目标进程pid

        sig -- 传入要传入的信号标号


2-3-2 raise 函数

        raise 函数可以给当前进程发送指定的信号(自己给自己发信号)。

NAME
    raise - send a signal to the caller
SYNOPSIS

    #include <signal.h>
    int raise(int sig);

RETURN VALUE
raise() returns 0 on success, and nonzero for failure.

参数:

        sig -- 传入要传入的信号标号

2-3-3 abort

        abort 函数使当前进程接收到信号而异常终止。

NAME
    abort - cause abnormal process termination
SYNOPSIS

    #include <stdlib.h>
    void abort(void);

RETURN VALUE
The abort() function never returns.
// 就像exit函数⼀样,abort函数总是会成功的,所以没有返回值。

2-4 由软件条件产生信号       

2-4-1 如何理解软件条件 

        在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。简而言之之,软件条件是因操作系统内部或外部软件操作而触发的信号产生。

2-4-1 常见的软件条件--时钟

NAME
alarm - set an alarm clock for delivery of a signal
SYNOPSIS

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

RETURN VALUE
alarm() returns the number of seconds remaining until any previously
scheduled alarm was due to be delivered, or zero if there was no previ‐
ously scheduled alarm.
        调用 alarm 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发SIGALRM 信号,该信号的默认处理动作是终止当前进程。
        这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡⼀觉,设定闹 钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

2-4-3 如何简单快速理解系统闹钟

        系统闹钟,其实本质是OS必须自身具有定时功能,并能让用户设置这种定时功能,才可能实现闹钟这样的技术。
        现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:
struct timer_list {
    struct list_head entry;
    unsigned long expires;
    void (*function)(unsigned long);
    unsigned long data;
    struct tvec_t_base_s *base;
};
        我们不在这部分进行深究,为了理解它,我们可以看到:定时器超时时间expires和处理方法
function。
        操作系统管理定时器,采用的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构",通过堆排序来进行对于最短超时时钟的响应。

2-5 硬件异常产生信号

2-5-1 硬件异常是什么

        硬件异常是指被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令, CPU的运算单元会产生异常, 内核将这个异常解释为SIGFPE信号发送给进程。再比如如当前进程访问了非法内存地址, MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

2-5-2 如何理解硬件异常     

        操作系统如何识别到进程出错了??CPU 在运行时如果出现错误会触发异常(CPU 内部异常)。当异常发生,CPU 硬件会自动产生对应的异常编号,并将当前程序的寄存器上下文压入内核栈;操作系统的异常处理程序根据异常编号判断错误类型,后续会把进程的上下文保存在task_struct内的thread_struct字段中。                   

        其实在CPU中有⼀些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位、溢出标记位。OS 会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法。

2-5-3 子进程退出core dump

                                                       

2-5-4 Core Dump

        SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证⼀下。
        什么是Core Dump?当⼀个进程要异常终止时,可以选择把进程的用户空间内存数据全部
保存到磁盘上,问件名通常是core,这叫做Core Dump。
        进程异常终止通常是因为有Bug,比如非发内存访问导致段错误,事后可以用调试器检查core问件以查清错误原因,这叫做 Post-mortem Debug (事后调试)。
        ⼀个进程允许 产生多大的 core 文件取决于进程的 Resource Limit (这个信息保存 在PCB
中)。默认是不允许产生 core 文件的, 因为 core 文件中可能包含用户密码等敏感信息,不安全。
        在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。 首先用 ulimit 命令
改变 Shell 进程的 Resource Limit ,如允许 core 文件最打为 1024K
$ ulimit -c ​​​​​​​1024

2-5-5 核心转储

        Core状态指的是,会在当前路径下形成一个文件进程异常退出的时候,进程在内存中的核心数据,从内存拷贝到磁盘。形成一个文件,这个过程称为 ---- 核心转储。

        核心转储支持debug,开启了 core dump ,直接运行崩溃,gdb,core-file core ,直接帮我们定位到出错行 --- 事后调试

        这一个core dump 标志位表示了是否发生了核心转储

3 保存信号

当前阶段:

3-1 信号其他相关常见概念

        1 实际执行信号的处理动作称为信号递达(Delivery)
        2 信号从产生到递达之间的状态,称为信号未决(Pending)。即信号在位图中还没有来的及处理
        3 进程可以选择阻塞 (Block )某个信号。阻塞(屏蔽)信号
        4 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意:
        阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的⼀种处理动作。

3-2 在内核中的表示

信号在内核中的表示示意图:

        三张表!!!

        block表是位图用来记录对应信号是否阻塞。比特位的位置:表示受到第几个信号。比特位的内容:表示是否阻塞

        pending表保存收到的信号的位图。比特位的位置:表示受到第几个信号。比特位的内容:表示是否受到

         判断一个信号是否要执行对应方法:pending对应的比特位 &(~block对应比特位)

        handler表示函数指针数组,存放对应信号发生的时候调用的方法。

        sighandler_t handler[31]。数组下标就是信号编号       

        SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
        SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数
sighandler。
        如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1 允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计⼀次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。

信号的内核数据结构

// 内核结构 2.6.18
struct task_struct {
    ...
    /* signal handlers */
    struct sighand_struct *sighand;
    sigset_t blocked
    struct sigpending pending;
    ...
}

struct sighand_struct {
    atomic_t count;
    struct k_sigaction action[_NSIG]; // #define _NSIG 64
    spinlock_t siglock;
};

struct __new_sigaction {
    __sighandler_t sa_handler;
    unsigned long sa_flags;
    void (*sa_restorer)(void); /* Not used by Linux/SPARC */
    __new_sigset_t sa_mask;
};

struct k_sigaction {
    struct __new_sigaction sa;
    void __user *ka_restorer;
};

/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);

struct sigpending {
    struct list_head list;
    sigset_t signal;
};

3-3 sigset_t

        从上图来看,每个信号只有一个bit的未决标志, 非0即1, 不记录该信号产生了多少次,阻塞标志也是这样表示的。因此, 未决和阻塞标志可以用相同的数据类型sigset_t来存储, sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,。
        在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞, 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
        下一节将详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略
这里其实可以类比权限哪里的umask
 

3-4 信号集操作函数

3-4-1 sigset_t 操作函数

        sigset_t类型对于每种信号用⼀个bit表示“有效”或“无效”状态, 至于这个类型内部如何存储这些
bit则依赖于系统实现, 从使用者的角度是不必关心的, 使用者只能调用以下函数来操作sigset_ t变量, 而不应该对它的内部数据做任何解释, 比如用printf直接打印sigset_t变量是没有意义的。
#include <signal.h>

int sigemptyset(sigset_t *set);//将位图清空

int sigfillset(sigset_t *set);//将位图全部置为1

int sigaddset(sigset_t *set, int signo);//设置位图的某一位即某个信号

int sigdelset(sigset_t *set, int signo);//清空位图的某一位即某个信号

int sigismember(const sigset_t *set, int signo);//查看某个信号是否在信号集内
        • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
        • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持持的所有信号。
        • 注意,在使用sigset_ t类型的变量之前,⼀定要调用sigemptyset或sigfillset做初始化,使信号集处于 确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
        • 前四个信号集操作函数,这四个函数都是成功返回0,出错返回-1。
        • sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号。若包含则返回1,不包含则返回0,出错返回-1。

3-4-2 sigprocmask

调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

参数:

    how:设置block表更改模式,是添加,解除还是重新设置信号屏蔽自

    set:输入型参数,输入你要设置的信号屏蔽字

    oset:输出型参数,保存信号屏蔽字的设置

返回值:
    
    若成功则为0,若出错则为-1
        如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。
        假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
注意:
        
        如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
        默认情况,进程不对任何信号进行屏蔽

3-4-2 sigpending

#include <signal.h>
    
int sigpending(sigset_t *set);

读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1

注意:

        sigpending不可以设置pending表,因为pending表是否被设置和我们上面说的信号的产生所相关。

4 信号处理

当前阶段:

4-1 信号捕捉的流程

        如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。

        什么是合适的时候处理信号?进程从内核态,返回到用户态的时候做信号检查

        什么时候做进程从内核态,返回到用户态呢?在进程调用系统调用,或者进程切换的时候。这个在后面的穿插话题详细解释

        如果信号处理动作是默认呢?忽略呢?默认和忽略处理动作是由系统定义的,不涉及到用户态和内核态的切换,所以比自定义捕捉要简单,所以我们只需要详细了解自定义捕捉即可

        所以我们在执行自定义方法(用户写的) -> OS也要在做身份切换 -> 必须要以用户的身份执行

由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
        1 用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。
        2 当前正在执行 main 函数,这时发发中断或异常切换到内核态。
        3 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。
        4 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个
独立的控制流程。
        5 sighandler 函数返回后自动动执行特殊的系统调用 sigreturn 再次进入内核态。
        6 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文在之前被中断的地方继续执行了。

 

4-2 操作系统是怎么运行的

4-2-1 硬件中断

        1 中断向量表就是操作系统的⼀部分,启动就加载到内存中了,中断向量表本质就是一个函数指针数组,下标就是中断号

        2 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询,当那个外部设备准备好了,可以通过中断让CPU知道,

        3 CPU会根据中断号,执行已经准备好的中断处理方法,例如读取键盘数据

        4 注意寄存器的概念,外设也有,就是CPU就是通过外设的寄存器来对外设进行控制

        5 由外部设备触发的,中断运行流程,叫做硬件中断     

//Linux内核0.11源码
void trap_init(void)
{
    int i;
    set_trap_gate(0,&divide_error);// 设置除操作出错的中断向量值。以下雷同。
    set_trap_gate(1,&debug);
    set_trap_gate(2,&nmi);
    set_system_gate(3,&int3); /* int3-5 can be called from all */
    set_system_gate(4,&overflow);
    set_system_gate(5,&bounds);
    set_trap_gate(6,&invalid_op);
    set_trap_gate(7,&device_not_available);
    set_trap_gate(8,&double_fault);
    set_trap_gate(9,&coprocessor_segment_overrun);
    set_trap_gate(10,&invalid_TSS);
    set_trap_gate(11,&segment_not_present);
    set_trap_gate(12,&stack_segment);
    set_trap_gate(13,&general_protection);
    set_trap_gate(14,&page_fault);
    set_trap_gate(15,&reserved);
    set_trap_gate(16,&coprocessor_error);
// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱⻔。
    for (i=17;i<48;i++)
        set_trap_gate(i,&reserved);

    set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。
    outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。
    outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。
    set_trap_gate(39,&parallel_interrupt);// 设置并⾏⼝的陷阱⻔。
    }

void rs_init (void)
{
    set_intr_gate (0x24, rs1_interrupt); // 设置串⾏⼝1 的中断⻔向量(硬件IRQ4信号)。
    set_intr_gate (0x23, rs2_interrupt); // 设置串⾏⼝2 的中断⻔向量(硬件IRQ3信号)。
    init (tty_table[1].read_q.data); // 初始化串⾏⼝1(.data 是端⼝号)。
    init (tty_table[2].read_q.data); // 初始化串⾏⼝2。
    outb (inb_p (0x21) & 0xE7, 0x21); // 允许主8259A 芯⽚的IRQ3,IRQ4 中断信号请求。
}

你可以将信号和中断类比:

        发中断 -- 发信号

        保存中断号 -- 记录信号

        中断号 -- 信号编号

        处理中断 -- 处理信号,自定义捕捉

                

4-3-2 时钟中断 

        当没有中断来的时候,OS在做什么??什么都没有做!!OS是暂停的!!

        外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?有就是时钟源。时钟源以固定的,特定的频率,向CPU发送特定的中断!!   

        时钟源每次向CPU发送一次特定的中断,CPU就执行中断服务函数:进程调度,所以这时候OS就会陷入内核进行对于这个进程,执行检查是否时间片耗尽,是否进行调度的调度算法,然后再返回用户态,并且在返回用户态之前进行信号检查

        进程调度需要先检查当前进程的时间片有没有耗尽。那么时间片又是什么呢?时间片本质就是进程PCB上的一个计数器指针,由OS分配,每次时钟中断的时候就++。通过给这个计数器设置来计算这个进程运行的时候,当是时间片耗尽再进行进程调度。所以操作系统,就在硬件时钟中断的驱动下,进行调度了。

        所以操作系统是什么?操作系统,就是基于中断,进行工作的软件

// Linux 内核0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)

// 调度程序的初始化⼦程序。
void sched_init(void)
{
    ...
    set_intr_gate(0x20, &timer_interrupt);
    // 修改中断控制器屏蔽码,允许时钟中断。
    outb(inb_p(0x21) & ~0x01, 0x21);
    // 设置系统调⽤中断⻔。
    set_system_gate(0x80, &system_call);
    ...
}

// system_call.s
_timer_interrupt:
...
;// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。
call _do_timer ;// 'do_timer(long CPL)' does everything from

// 调度⼊⼝
void do_timer(long cpl)

{
    ...
    schedule();
}

void schedule(void)
{
    ...
    switch_to(next); // 切换到任务号为next 的任务,并运⾏之。
}

4-2-3 死循环

        如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可.操作系统的本质:就是一个死循环!
void main(void) /* 这⾥确实是void,并没错。 */
{ 
    /* 在startup 程序(head.s)中就是这样假设的。 */
    ...
    /*
    * 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
    * 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
    * 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
    * 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
    * 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
    */
    for (;;)
        pause();

} // end main
        这样,操作系统,就可以在硬件时钟的推动下,自动调度了

4-2-4 软中断

       用户层怎么把系统调用号给操作系统? - 寄存器(比如EAX)        
        
       操作系统怎么把返回值给用户?- 寄存器或者用户传入的缓冲区地址
        ​​​x86:int,x86_64:syscall,这两个都是指令集,C/C++代码:本质就是编译成为了指令集+数据
        系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,OS会陷入内核,获取系统调用号,并且储存到寄存器中,根据系统调用号,自动查表,执行对应的方法
        系统调用号就是什么?系统调用表是一个函数指针数组,从此往后,每一个系统调用,都有唯一一个下标!!这个下标就是系统调用号!!
        系统调用号的本质:数组下标!
// sys.h
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
extern int sys_setup (); // 系统启动初始化设置函数。 (kernel/blk_drv/hd.c,71)
extern int sys_exit (); // 程序退出。 (kernel/exit.c, 137)
extern int sys_fork (); // 创建进程。 (kernel/system_call.s, 208)
extern int sys_read (); // 读⽂件。 (fs/read_write.c, 55)
extern int sys_write (); // 写⽂件。 (fs/read_write.c, 83)
extern int sys_open (); // 打开⽂件。 (fs/open.c, 138)
extern int sys_close (); // 关闭⽂件。 (fs/open.c, 192)
extern int sys_waitpid (); // 等待进程终⽌。 (kernel/exit.c, 142)
extern int sys_creat (); // 创建⽂件。 (fs/open.c, 187)
extern int sys_link (); // 创建⼀个⽂件的硬连接。 (fs/namei.c, 721)
extern int sys_unlink (); // 删除⼀个⽂件名(或删除⽂件)。 (fs/namei.c, 663)
extern int sys_execve (); // 执⾏程序。 (kernel/system_call.s, 200)
extern int sys_chdir (); // 更改当前⽬录。 (fs/open.c, 75)
extern int sys_time (); // 取当前时间。 (kernel/sys.c, 102)
extern int sys_mknod (); // 建⽴块/字符特殊⽂件。 (fs/namei.c, 412)
extern int sys_chmod (); // 修改⽂件属性。 (fs/open.c, 105)
extern int sys_chown (); // 修改⽂件宿主和所属组。 (fs/open.c, 121)
extern int sys_break (); // (-kernel/sys.c, 21)
extern int sys_stat (); // 使⽤路径名取⽂件的状态信息。 (fs/stat.c, 36)
extern int sys_lseek (); // 重新定位读/写⽂件偏移。 (fs/read_write.c, 25)
extern int sys_getpid (); // 取进程id。 (kernel/sched.c, 348)
extern int sys_mount (); // 安装⽂件系统。 (fs/super.c, 200)
extern int sys_umount (); // 卸载⽂件系统。 (fs/super.c, 167)
extern int sys_setuid (); // 设置进程⽤⼾id。 (kernel/sys.c, 143)
extern int sys_getuid (); // 取进程⽤⼾id。 (kernel/sched.c, 358)
extern int sys_stime (); // 设置系统时间⽇期。 (-kernel/sys.c, 148)
extern int sys_ptrace (); // 程序调试。 (-kernel/sys.c, 26)
extern int sys_alarm (); // 设置报警。 (kernel/sched.c, 338)
extern int sys_fstat (); // 使⽤⽂件句柄取⽂件的状态信息。(fs/stat.c, 47)
extern int sys_pause (); // 暂停进程运⾏。 (kernel/sched.c, 144)
extern int sys_utime (); // 改变⽂件的访问和修改时间。 (fs/open.c, 24)
extern int sys_stty (); // 修改终端⾏设置。 (-kernel/sys.c, 31)
extern int sys_gtty (); // 取终端⾏设置信息。 (-kernel/sys.c, 36)
extern int sys_access (); // 检查⽤⼾对⼀个⽂件的访问权限。(fs/open.c, 47)
extern int sys_nice (); // 设置进程执⾏优先权。 (kernel/sched.c, 378)
extern int sys_ftime (); // 取⽇期和时间。 (-kernel/sys.c,16)
extern int sys_sync (); // 同步⾼速缓冲与设备中数据。 (fs/buffer.c, 44)
extern int sys_kill (); // 终⽌⼀个进程。 (kernel/exit.c, 60)
extern int sys_rename (); // 更改⽂件名。 (-kernel/sys.c, 41)
extern int sys_mkdir (); // 创建⽬录。 (fs/namei.c, 463)
extern int sys_rmdir (); // 删除⽬录。 (fs/namei.c, 587)
extern int sys_dup (); // 复制⽂件句柄。 (fs/fcntl.c, 42)
extern int sys_pipe (); // 创建管道。 (fs/pipe.c, 71)
extern int sys_times (); // 取运⾏时间。 (kernel/sys.c, 156)
extern int sys_prof (); // 程序执⾏时间区域。 (-kernel/sys.c, 46)
extern int sys_brk (); // 修改数据段⻓度。 (kernel/sys.c, 168)
extern int sys_setgid (); // 设置进程组id。 (kernel/sys.c, 72)
extern int sys_getgid (); // 取进程组id。 (kernel/sched.c, 368)
extern int sys_signal (); // 信号处理。 (kernel/signal.c, 48)
extern int sys_geteuid (); // 取进程有效⽤⼾id。 (kenrl/sched.c, 363)
extern int sys_getegid (); // 取进程有效组id。 (kenrl/sched.c, 373)
extern int sys_acct (); // 进程记帐。 (-kernel/sys.c, 77)
extern int sys_phys (); // (-kernel/sys.c, 82)
extern int sys_lock (); // (-kernel/sys.c, 87)
extern int sys_ioctl (); // 设备控制。 (fs/ioctl.c, 30)
extern int sys_fcntl (); // ⽂件句柄操作。 (fs/fcntl.c, 47)
extern int sys_mpx (); // (-kernel/sys.c, 92)
extern int sys_setpgid (); // 设置进程组id。 (kernel/sys.c, 181)
extern int sys_ulimit (); // (-kernel/sys.c, 97)
extern int sys_uname (); // 显⽰系统信息。 (kernel/sys.c, 216)
extern int sys_umask (); // 取默认⽂件创建属性码。 (kernel/sys.c, 230)
extern int sys_chroot (); // 改变根系统。 (fs/open.c, 90)
extern int sys_ustat (); // 取⽂件系统信息。 (fs/open.c, 19)
extern int sys_dup2 (); // 复制⽂件句柄。 (fs/fcntl.c, 36)
extern int sys_getppid (); // 取⽗进程id。 (kernel/sched.c, 353)
extern int sys_getpgrp (); // 取进程组id,等于getpgid(0)。(kernel/sys.c, 201)
extern int sys_setsid (); // 在新会话中运⾏程序。 (kernel/sys.c, 206)
extern int sys_sigaction (); // 改变信号处理过程。 (kernel/signal.c, 63)
extern int sys_sgetmask (); // 取信号屏蔽码。 (kernel/signal.c, 15)
extern int sys_ssetmask (); // 设置信号屏蔽码。 (kernel/signal.c, 20)
extern int sys_setreuid (); // 设置真实与/或有效⽤⼾id。 (kernel/sys.c,118)
extern int sys_setregid (); // 设置真实与/或有效组id。 (kernel/sys.c, 51)

// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
    sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
    sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
    sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
    sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
    sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
    sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
    sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
    sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
    sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
    sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
    sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
    sys_setreuid, sys_setregid
};

// 调度程序的初始化⼦程序。
void sched_init(void)
{
    ...
    // 设置系统调⽤中断⻔。
    set_system_gate(0x80, &system_call);
}

_system_call:
    cmp eax,nr_system_calls-1 ;// 调⽤号如果超出范围的话就在eax 中置-1 并退出。
    ja bad_sys_call
    push ds ;// 保存原段寄存器值。
    push es
    push fs
    push edx ;// ebx,ecx,edx 中放着系统调⽤相应的C 语⾔函数的调⽤参数。
    push ecx ;// push %ebx,%ecx,%edx as parameters
    push ebx ;// to the system call
    mov edx,10h ;// set up ds,es to kernel space

    mov ds,dx ;// ds,es 指向内核数据段(全局描述符表中数据段描述符)。
    mov es,dx
    mov edx,17h ;// fs points to local data space
    mov fs,dx ;// fs 指向局部数据段(局部描述符表中数据段描述符)。
;// 下⾯这句操作数的含义是:调⽤地址 = _sys_call_table + %eax * 4。参⻅列表后的说明。

;// 对应的C 程序中的sys_call_table 在include/linux/sys.h 中,其中定义了⼀个包括72个

;// 系统调⽤C 处理函数的地址数组表。
    call [_sys_call_table+eax*4]
    push eax ;// 把系统调⽤号⼊栈。
    mov eax,_current ;// 取当前任务(进程)数据结构地址??eax。
;// 下⾯97-100 ⾏查看当前任务的运⾏状态。如果不在就绪状态(state 不等于0)就去执⾏调度程序。

;// 如果该任务在就绪状态但counter[??]值等于0,则也去执⾏调度程序。

    cmp dword ptr [state+eax],0 ;// state
    jne reschedule
    cmp dword ptr [counter+eax],0 ;// counter
    je reschedule
;// 以下这段代码执⾏从系统调⽤C 函数返回后,对信号量进⾏识别处理。
ret_from_sys_call:
        可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用上层的函数的啊?那是因为Linux的gnu C标准库,给我们把几乎所有的系统调用全部封装了
        #define SYS_ify(syscall_name) __NR_##syscall_name :是一个宏定义,用于将系统调用的名称转换为对应的系统调用号。比如: SYS_ify(open) 会被展开为 __NR_open
        而系统调用号,不是 glibc 提供的,是内核提供的,内核提供系统调用入口函数 man 2 syscall ,或者直接提供汇编级别软中断命令 int or syscall ,并提供对应的头文件或者开发入口,让上层语言的设计者使用系统调用号,完成系统调用过程
或者部分版本在glibc中,库函数调用实现方式:
# define INTERNAL_SYSCALL_NCS(name, err, nr, args...) \
({ \
    unsigned long int resultvar; \
    LOAD_ARGS_##nr (args) \
    LOAD_REGS_##nr \
    asm volatile ( \
    "syscall\n\t" \
    : "=a" (resultvar) \
    : "0" (name) ASM_ARGS_##nr : "memory", "cc", "r11", "cx"); \
    (long int) resultvar; })

        每次在调用封装函数时,在这个封装函数开头会先从寄存器中读取系统调用号,然后再让OS调用 int 或者 syscall 这个指令集陷入内核,然后执行函数,所以,OS不提供任何系统掉i用号的接口!!OS只提供系统调用号

4-2-5 缺页中断?内存碎片处理?除零野指针错误?

void trap_init(void)
{
    int i;
    set_trap_gate(0,&divide_error);// 设置除操作出错的中断向量值。以下雷同。
    set_trap_gate(1,&debug);
    set_trap_gate(2,&nmi);
    set_system_gate(3,&int3); /* int3-5 can be called from all */
    set_system_gate(4,&overflow);
    set_system_gate(5,&bounds);
    set_trap_gate(6,&invalid_op);
    set_trap_gate(7,&device_not_available);
    set_trap_gate(8,&double_fault);
    set_trap_gate(9,&coprocessor_segment_overrun);
    set_trap_gate(10,&invalid_TSS);
    set_trap_gate(11,&segment_not_present);
    set_trap_gate(12,&stack_segment);
    set_trap_gate(13,&general_protection);
    set_trap_gate(14,&page_fault);
    set_trap_gate(15,&reserved);
    set_trap_gate(16,&coprocessor_error);
// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱⻔。
    for (i=17;i<48;i++)
        ·set_trap_gate(i,&reserved);

    set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。
    outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。
    outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。
    set_trap_gate(39,&parallel_interrupt);// 设置并⾏⼝的陷阱⻔。
}
        缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进程发送信号,杀掉进程等等。
所以:
        操作系统就是躺在中断处理例程上的代码块!
        CPU内部的软中断,比如int 0x80或者syscall,我们叫做 陷阱
        CPU内部的软中断,比如除零/野指针等,我们叫做异常。(所以,能理解“缺页异 常”为什么这么叫了)

4-3 sigaction  

#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction*oact);

参数:

    signum:输入对应的信号编号

    act:输入型参数,传入 struct sigaction 设置对应信号的自定义捕捉方法还有配置信息

    oact:输出型参数,传出对应信号的老的配置信息

    struct sigaction {
        void     (*sa_handler)(int);//自定义捕捉方法
        void     (*sa_sigaction)(int, siginfo_t *, void *);
        sigset_t   sa_mask;
        int        sa_flags;//再调用某个信号的处理函数的时候设置信号屏蔽字
        void     (*sa_restorer)(void);
    };

返回值:

    0:成功返回
    -1:失败返回
        sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空, 则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:
        将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同⼀
个函数处理多种信号。显然,这也是⼀个回调函数,不是被main函数调用,而是被系统所调用。
        
        当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生那么 它会被阻塞到当前处理结束为止。
        如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本文的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函
数,本章不详细解释这两个字段

4-4 如何理解内核态和用户态

        操作系统也是软件,一定也在内存!系统只有一份!所有进程共享,所以操作系统不管怎么切换进程,都能找到同一个操作系统!换句话说操作系统的系统调用方法的执行,是在进程地址空间中执行的

        因为进程地址空间,所以用户和进程,都在同一个[0,4GB]地址空间上了,如果用户,随便拿一个虚拟地址[3,4GB],用户也不可以访问内核的代码和数据。OS为了保护自己,不相信任何人!!必须采用系统调用的方式在进程地址空间内部进行跳转访问

        用户态:以用户的身份,只能访问自己的[0,3GB]这个范围的进程地址空间的虚拟地址
        内核态:以内核身份,允许你通过系统调用的方式,访问OS[3,4GB]

        在系统中,用户或者OS自己,怎么知道自己是处于内核态还是用户态?通过CPU中的CPL寄存器来表示,CPL为0表示内核态,CPL为3表示为用户态

5 可重入函数        

     

        main函数调用insert函数向一个链表head中插入节点node1,插⼊操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
        像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可
重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
        调用了malloc或free,因为malloc也是用全局链表来管理堆的。
        调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

6 volatile

#include <stdio.h>
#include <signal.h>

int flag = 0; //volatile int flag = 0;

void handler(int sig)
{
    printf("chage flag 0 to 1\n");
    flag = 1;
}

int main()
{
    signal(2, handler);
    while(!flag);
    printf("process quit normal\n");
    return 0;
}

        在上述代码的情况下,main里面没有对于flag进行修改,在编译器优化级别较高的时候,就会优化这一个过程,因为flag只需要访问不做修改,所以优化后,每次CPU都不从内存中读取flag的值,而是读取flag储存在寄存器中的值,如果flag一直没有修改那不会有问题,但是当这个进程接受到信号的时候,并且执行的是自定义处理方法,在自定义方法中,对于flag进行修改了,但是main里没有修改,因为CPU还是会读寄存器的值,所以导致了程序死循环,这种情况是寄存器覆盖了进程看到变量的真实情况,内存不可见了!!

        为了保证内存的可见性,所以我们可以在这个变量前面加上volatile关键字,让CPU每次都从内存中搬运数据,保证内存可见性。

7 SIGCHLD信号

        进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

        子进程在终止的时候会给父进程发送SIGCHILD信号,这个信号处理动作是SIG_DFL默认忽略这个信号,但是我们可以自定义捕捉方法,可以对子进程进行回收,使得父进程不用一直wait解放了父进程

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void handler(int sig)
{

    while(true) //回收多进程 
    {
        pid_t n= waitpid(-1,nullptr,WNOHANG);//WNOHANG避免阻塞等待导致父进程卡死
        if(n==0)
            break;
        else if(n<0)
        {
            std::cerr<<"waitpid error"<<std::endl;
            break;
        }
    }
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t cid;
    if((cid = fork()) == 0)
    {
        //child
        printf("child : %d\n", getpid());
        sleep(3);
        exit(1);
    }
    while(1){
        printf("father proc is doing some thing!\n");
        sleep(1);
    }
    return 0;
}
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐