【Linux系统】信号:信号保存 / 信号处理、内核态 / 用户态、操作系统运行原理(中断)

理解Linux系统内进程信号的整个流程可分为:

  • 信号产生

  • 信号保存

  • 信号处理

上篇文章重点讲解了 信号的产生,本文会讲解信号的保存和信号处理相关的概念和操作:

两种信号默认处理

1、信号处理之忽略

c++ 复制代码
::signal(2, SIG_IGN); // ignore: 忽略
c++ 复制代码
#include <vector>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>

void handler(int signo)
{
    std::cout << "get a new signal: " << signo << std::endl;
    exit(1);
}

int main()
{
    // 信号捕捉:
    // 1. 默认
    // 2. 忽略
    // 3. 自定义捕捉
    ::signal(2, SIG_IGN); // ignore: 忽略
    while(true)
    {
        pause();
    }
}

运行结果如下: 显然对二号信号(ctrl+c) 没有效果了

2、信号处理之默认

c++ 复制代码
::signal(2, SIG_DFL); // default:默认。
c++ 复制代码
#include <vector>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <iostream>
#include <string>

void handler(int signo)
{
    std::cout << "get a new signal: " << signo << std::endl;
    exit(1);
}

int main()
{
    // 信号捕捉:
    // 1. 默认
    // 2. 忽略
    // 3. 自定义捕捉
    //::signal(2,SIG IGN);// ignore:忽略:本身就是一种信号捕捉的方法,动作是忽略
    ::signal(2, SIG_DFL); // default:默认。
    while (true)
    {
        pause();
    }
}

这些本质上是宏,而且是被强转后的

信号保存

1、信号保存相关概念

信号递达 / 信号未决 / 阻塞信号

  • 实际执行信号的处理动作称为信号递达(Delivery)。

  • 信号从产生到递达之间的状态,称为信号未决(Pending)。

  • 进程可以选择阻塞(Block)某个信号。

  • 被阻塞的信号产生时将保持在未决状态(Pending),直到进程解除对此信号的阻塞,才执行递达的动作。

  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。


简单来说:

  • 信号递达:信号已经被接收处理了

  • 信号未决:信号未被处理之前的状态

  • 阻塞信号:可以使某个信号不能被处理,该信号会一直被保存为未处理之前的状态,即信号未决 pending 状态

这里的阻塞呢和进程进行 IO 获取数据的阻塞不一样,他们是完全不同的概念

这个阻塞是翻译 block 的问题

其实,信号未决(Pending) 叫做屏蔽信号会更加好理解

2、信号相关的三张表

block 表 / Pending 表 / handler表



Pending 表 的作用由图中可以看到,是一种位图结构的表,不过该位图不是只有一个整数,而是有系统自己封装的结构

handler表

  • handler_t XXX[N]:函数指针数组
  • 信号编号:就是函数指针数组的下标!

其中,该表内的前两项刚好是 0 和 1,也就是两个信号处理的宏定义:忽略和默认

该 handler表函数指针数组中的每个数组元素都是一个函数指针,每个指针都对应指向 该数组下标序号的信号 的默认信号处理方式,如 信号 2 ,即对应数组下标为 2,这个指针指向信号 2 的默认处理函数

我们使用系统调用 signal(2, handler) 就是通过信号 2 的编号索引对应 handler 表的位置(即数组下标为 2 的位置),修改对应的函数指针指向用户自定义的处理函数,这样就完成了自定义信号处理的定义

这就解释了,为什么 系统调用 signal(2, handler) 在整个程序全局中只需定义一次,因为函数指针数组 handler 表修改一次指向的函数即可

Block



Block 就是用来决定是否阻塞或屏蔽特定信号的!

这三个表的顺序就像图中所示:只要**Block 表**将某个信号屏蔽了,即使该信号已经在 pending 表 中,它也无法通过查找 handler 表 来执行相应的处理方法!

简单来说,如果你在 Block 表 中屏蔽了一个信号,即便之后进程接收到了这个信号,它也不会生效。


问题:我们能否提前屏蔽一个信号?这与当前是否已经接收到该信号有关系吗?

答:可以提前进行信号的屏蔽。因为只有当信号屏蔽设置好了,比信号实际到达要早,这样才能有效地阻止该信号生效。


到这里,这就回答了"你如何识别信号?"这个问题。

信号的识别是内建的功能。进程能够识别信号,是因为程序员在编写程序时内置了这一特性。通过使用这三张表(Block 表Pending 表Handler 表),就可以让进程具备识别和处理信号的能力。


3、三张表的内核源码

c++ 复制代码
// 内核结构 2.6.18
struct task_struct {
    /* signal handlers */
    struct sighand_struct *sighand;  // handler表指针
    sigset_t blocked;				 // block 表: 屏蔽信号表
    struct sigpending pending;		 // pending 表: 信号未决表
};

// handler表结构:包含函数指针数组
struct sighand_struct {
    atomic_t count;
    struct k_sigaction action[_NSIG]; // #define _NSIG 64
    spinlock_t siglock;
};

// handler表结构中的函数指针数组的元素的结构类型
struct k_sigaction {
    struct __new_sigaction sa; 
    void __user *ka_restorer;
};

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

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;
};






// pending 表 的结构类型
struct sigpending {
    struct list_head list;
    sigset_t signal;
};


// sigset_t : 是系统封装的位图结构
typedef struct {
    unsigned long long sig[_NSIG_WORDS];
} sigset_t;

问题:为什么要对位图封装成结构体

答:利于扩展、利于该结构整体使用(定义对象就可以获取该位图)

4、sigset_t 信号集


从前面的图中可以看出,每个信号只有一个 bit 用于未决标志,非 0 即 1,这意味着它并不记录该信号产生了多少次。阻塞标志也是以同样的方式表示的。因此,未决状态和阻塞状态可以使用相同的数据类型 sigset_t 来存储。可以说 sigset_t 是一种信号集数据类型。

具体来说,在阻塞信号集中,"有效"和"无效"指的是该信号是否被阻塞;而在未决信号集中,"有效"和"无效"则表示该信号是否处于未决状态。

阻塞信号集也被称为当前进程的信号屏蔽字(Signal Mask)。

简而言之,你可以把这想象成一个32位整数的位图。每个位代表一个信号的状态,无论是未决还是阻塞状态,都通过设置相应的位来标记为"有效"或"无效"。

5、信号集操作函数


sigset_t 类型使用一个 bit 来表示每种信号的"有效"或"无效"状态。至于这个类型内部如何存储这些 bit,则依赖于系统的具体实现。从使用者的角度来看,这其实是不需要关心的细节。使用者应该仅通过调用特定的函数来操作 sigset_t 变量,而不应对它的内部数据进行任何直接解释或修改。例如,直接使用 printf 打印 sigset_t 变量是没有意义的。

简单来说:信号集 sigset_t 是系统封装好的一种类型,不建议用户自行使用位操作等手段对该"位图"进行操作。相反,应当使用系统提供的信号集操作函数来进行处理。


信号集操作函数就是对该 信号集 sigset_t 类型的增删查改

c++ 复制代码
#include <signal.h>
int sigemptyset(sigset_t *set);   				// 清空:全部置为0
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);// 查找:在指定信号集,查找是否有该信号

注意:在使用 sigset_t 类型的变量之前,一定要调用 sigemptysetsigfillset 进行初始化,以确保信号集处于一个确定的状态。初始化 sigset_t 变量之后,就可以通过调用 sigaddsetsigdelset 在该信号集中添加或删除某种有效信号。

6、sigprocmask :修改进程的 block

调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(即阻塞信号集)。

上一点讲解的各个信号集操作函数,是用于对一个信号集 sigset_t 类型的增删查改,而此处学习的 sigprocmask 则是修改本进程的 信号屏蔽字

c++ 复制代码
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功则为 0,若出错则为 -1

  • 如果 oset 是非空指针,则通过 oset 参数读取并传出进程的当前信号屏蔽字(阻塞信号集)。
  • 如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何进行更改。具体来说:
  • 如果 osetset 都是非空指针,则首先将原来的信号屏蔽字备份到 oset 中,然后根据 sethow 参数来更改信号屏蔽字。

假设当前的信号屏蔽字为 maskhow 参数的可选值及其含义如下:

具体来说:

int how :传递操作选项

  • SIG_BLOCK :将 set 中设置的信号,添加到修改进程的 block 表(相当于添加对应信号)

  • SIG_UNBLOCK :将 set 中设置的信号,解除进程的 block 表对应的信号(相当于删除对应信号)

  • SIG_SETMASK :将 set 中设置的信号,直接设置成为进程的 block 表(相当于覆盖)

const sigset_t *set :传递设置期望的信号集

sigset_t *oset :输出型参数,就是 old set 将旧的信号集保存下来,因为后续可能还需用于恢复

简单来说:我们通过一系列信号集操作函数,设置一个我们期望的信号集,通过系统调用 sigprocmask 修改进程的 block

7、sigpending :读取当前进程的 pending

c++ 复制代码
#include <signal.h>
int sigpending(sigset_t *set);

读取当前进程的未决信号集,通过参数 set 传出

调⽤成功则返回 0 ,出错则返回 -1

该函数只是用于获取 pending 表,而系统不提供修改 pending 表 的函数接口,没必要,因为上一章节讲解的 5 种信号产生的方式都在修改 pending 表!

8、做实验:验证 block 表的效果

演示屏蔽 2 号信号


下面这段代码:

先使用 sigprocmask ,修改进程的 block 表,屏蔽 2 号信号

通过循环打印当前进程的 pending 表,然后通过另一个终端向该进程发送 2 号信号

c++ 复制代码
#include <iostream>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
using namespace std;


void PrintPending(sigset_t& pending)
{
    // 打印pending表的前32位信号:后面的信号是实时信号不打印
    // int sigismember(const sigset_t *set, int signo);
    // 若包含则返回1,不包含则返回0,出错返回-1
    cout << "pending: ";
    for(int i = 0; i < 32; ++i)
    {
        int ret = sigismember(&pending, i);
        if(ret != -1) cout << ret << " ";
    }
    cout << '\n';
}


int main()
{
    //(1)block表屏蔽2号信号
    //(2)不断打印pending表
    //(3)发送2号 ->看到2号信号的pending效果!

    /*
    int sigemptyset(sigset_t *set);   				// 清空:全部置为0
    int sigaddset(sigset_t *set, int signo);		// 添加:向指定信号集,添加对应信号
    int sigdelset(sigset_t *set, int signo);		// 删除:向指定信号集,删除对应信号
    */


    //设置存有2号信号的信号集
    sigset_t set, oset;
    sigemptyset(&set);
    sigaddset(&set, 2);


    // block表屏蔽2号信号
    sigprocmask(SIG_BLOCK, &set, &oset);

    int cnt = 0;
    while(true)
    {
        // 不断打印pending表
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);


        cnt++;
        sleep(1);
    }
}

运行结果如下:循环打印当前进程的 pending

当另一个终端向该进程发送 2 号信号时,当前进程的 pending 表的 第二个位置信号置为 1

证明了 2 号信号被 block 成功屏蔽!

演示去除对 2 号信号的屏蔽

循环中加入:当到达 cnt = 10 时,去除对 2 号信号的屏蔽

c++ 复制代码
#include <iostream>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
using namespace std;

void handler(int signo)
{
    std::cout << "get a new signal: " << signo << std::endl;
    //exit(1);
}

void PrintPending(sigset_t& pending)
{
    // 打印pending表的前32位信号:后面的信号是实时信号不打印
    // int sigismember(const sigset_t *set, int signo);
    // 若包含则返回1,不包含则返回0,出错返回-1
    printf("pending [pid %d] : ", getpid());

    for(int i = 0; i < 32; ++i)
    {
        int ret = sigismember(&pending, i);
        if(ret != -1) cout << ret << " ";
    }
    cout << '\n';
}


int main()
{
    //(1)block表屏蔽2号信号
    //(2)不断打印pending表
    //(3)发送2号 ->看到2号信号的pending效果!

    /*
    int sigemptyset(sigset_t *set);   				// 清空:全部置为0
    int sigaddset(sigset_t *set, int signo);		// 添加:向指定信号集,添加对应信号
    int sigdelset(sigset_t *set, int signo);		// 删除:向指定信号集,删除对应信号
    */


    //设置存有2号信号的信号集
    sigset_t set, oset;
    sigemptyset(&set);
    sigaddset(&set, 2);


    // block表屏蔽2号信号
    sigprocmask(SIG_BLOCK, &set, &oset);


    // 给2号信号添加自定义处理函数:方便解除对2号信号的屏蔽时,可以查看pending表的变化,不至于因为2号信号杀掉进程导致进程退出
    signal(2, handler);

    int cnt = 0;
    while(true)
    {
        // 不断打印pending表
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);


        cnt++;
        sleep(1);

        if(cnt == 10)
        {
            std::cout<<"解除对2号信号的屏蔽:"<<std::endl;
            // 将block表中2号信号的屏蔽消除:即旧的block表覆盖回去
            sigprocmask(SIG_SETMASK, &oset, NULL);
        }
    }
}

运行结果:

9、用户态和内核态(重要)

问题:信号来了,并不是立即处理的。什么时候处理?

答:当进程从内核态返回用户态时,会检查当前是否有未决(pending)且未被阻塞的信号。如果有,就会根据 handler 表来处理这些信号。

这些概念后文会详细讲解


9.1 何为用户态和内核态(浅显理解)



9.2 信号有自定义处理的情况



注意,上面这种情况会发生 4 次 用户态和内核态 的转变

这个无穷符号的中间交点在内核态里面

在执行主控制流程的某条指令时因为中断、异常或系统调用进入内核

进入内核后会回到用户态,回去之前会自动检测一下 pending 表和 block 表,查询是否有信号需要处理



类似于下面的流程:

对于信号的自定义处理或信号的默认处理,可以理解为独立于进程运行的程序之外



9.3 何为用户态和内核态(深度理解)

穿插话题 - 操作系统是怎么运行的
硬件中断:


这个操作系统的中断向量表可以看作一个函数指针数组:IDT[N],通过数组下标索引对应的中断处理服务"函数",这个数组下标就是 中断号

执行中断例程:

1、保存现场

2、通过中断号n,查表

3、调用对应的中断方法

例如外设磁盘需要将部分数据写到内存,当磁盘准备好了,通过一个硬件中断,中断控制器通知 CPU,CPU得知并获取对应的中断号,通过该中断号索引中断向量表的对应中断处理服务,

操作系统通过该中断服务将磁盘的就绪的数据读入内存

  • 中断向量表就是操作系统的⼀部分,启动就加载到内存中 了,操作系统主函数中含有一个"硬件中断向量表初始化逻辑,如下源码展示:tap_init(void)"
  • 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
  • 由外部设备触发的,中断系统运行流程,叫做硬件中断
c++ 复制代码
//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 中断信号请求。
}
时钟中断

问题:

  • 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执⾏呢??
  • 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?

如下图,会有一个硬件:时钟源,向CPU发送时钟中断,CPU根据该中断号执行时钟源对应的 中断服务:进程调度等操作



只要时钟源发送时钟中断,操作系统就会不断的进行进程调度等操作,这样不就通过

时钟中断,一直在推进操作系统进行调度!

什么是操作系统?操作系统就是基于中断向量表,进行工作的!!!

操作系统在时钟中断的推动下,不断的进行进程调度

因为时间源这个硬件需要不断按一定时间的发送时钟中断,现代机器的设计干脆直接将时间源集成到 CPU 内部,这就叫做主频!!!

主频的速度越快,发送的时钟中断的频率越高,操作系统内部处理进程调度进程的速度越快,一定程度上影响电脑性能,因此主频越高电脑一般越贵

时钟中断对应的中断处理服务不直接是进程调度,而是一个函数,该函数内部含有进程调度的相关处理逻辑:

我们看下源码



其中 schedule() 就是用于进程调度的函数,

这样,操作系统不就在硬件的推动下,自动调度了么

c++ 复制代码
// 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 的任务,并运⾏之。
}
死循环

如果是这样,操作系统不就可以躺平了吗?对,操作系统⾃⼰不做任何事情,需要什么功能,就向中断向量表⾥⾯添加⽅法即可

操作系统的本质:就是⼀个死循环!循环进行 pause()

需要进程调度就通过时钟中断来告诉操作系统要干活了,否则就死循环的呆着!

c++ 复制代码
void main(void) /* 这⾥确实是void,并没错。 */
{ 
    /* 在startup 程序(head.s)中就是这样假设的。 */
    //...
    /*
	* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
	* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
	* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
	* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
	* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
	*/
    
    for (;;)
        pause();
} 
// end main

因此 我们之前写的通过信号模拟实现操作系统的代码中,void Handler(int signum) 这个自定义信号处理函数,不就可以类似传入中断号,索引查询中断向量表,执行对应的中断处理函数吗??

这样操作系统只需要死循环等待着硬件发来中断,再干活,

因此操作系统也可以称为通过中断推动运行的进程

c++ 复制代码
#include<iostream>
#include<functional>
#include<vector>
#include<unistd.h>
#include <signal.h>
using namespace std;

// 定义一个函数指针类型,用于处理信号
typedef void (*sighandler_t)(int);
// 定义一个函数对象类型,用于存储要执行的函数
using func = function<void()>;
// 定义一个函数对象向量,用于存储多个要执行的函数
vector<func>funcV;
// 定义一个计数器变量
int count = 0;

// 信号处理函数,当接收到信号时,执行向量中的所有函数
void Handler(int signum)
{
    // 遍历函数对象向量
    for(auto& f : funcV)
    {
        // 执行每个函数
        f();
    }
    // 输出计数器的值和分割线
    cout << "------------------------------ count = " << count << "------------------------------" << '\n';
    // 设置一个新的闹钟,1 秒后触发
    alarm(1);
}

int main()
{
    // 设置一个 1 秒后触发的闹钟
    alarm(1);
    // 注册信号处理函数,当接收到 SIGALRM 信号时,调用 Handler 函数
    signal(SIGALRM, Handler); // signal用于整个程序,只会捕获单个信号

    // 向函数对象向量中添加一些函数
    funcV.push_back([](){cout << "我是一个内核刷新操作" << '\n';});
    funcV.push_back([](){cout << "我是一个检测进程时间片的操作,如果时间片到了,我会切换进程" << '\n';});
    funcV.push_back([](){cout << "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << '\n';});

    // 进入一个无限循环,程序不会退出
    while(1){
        pause();
        cout << "我醒来了~" << '\n';
        count++;
    }; //  死循环,不退出

    return 0;
}
时间片

进程调度时,每个被调度的进程都会被分配一个时间片,时间片实际上就是存储到进程PCB中的一个整型变量:int count

每次CPU内部的主频,即时钟源,发出一个时钟中断,操作系统处理时钟中断时,就会给当前调度的进程的时间片 :count--

当时间片减为零时,表示本轮该进程调度结束,此时就准备进程切换了



给当前调度的进程的时间片 :count--的逻辑就是在时钟中断对应的中断处理函数中的 do_timer()



进程相关切换逻辑好像就是放到 schedule() 函数中:



软中断
  • 外部硬件中断:需要由硬件设备触发。
  • 软件触发的中断(软中断) :是的,可以通过软件原因触发类似的逻辑。为了让操作系统支持系统调用,CPU设计了相应的汇编指令(如 intsyscall),使得在没有外部硬件中断的情况下,通过这些指令也能触发中断逻辑。

这样通过软件实现上述逻辑的机制被称为软中断 。软中断有固定的中断号,用来索引特定的中断处理程序,常见的形式包括 syscall: XXXint: 0x80

操作系统会在中断向量表中为软中断配置处理方法,并将系统调用的入口函数放置于此。当触发软中断时,会通过这个入口函数找到对应的系统调用函数指针数组,进而匹配并调用具体的系统调用。系统调用表使用系统调用号作为数组下标来查找对应的系统调用。

系统调用过程

系统调用的过程本质上是通过触发软中断(例如 int 0x80syscall),使CPU执行该软中断对于的中断处理例程,该中断处理函数通常是系统调用操作函数的入口,通过该函数可以找到系统调用数组。接着,以系统调用号作为下标查询该系统调用数组,找到并执行对应的系统调用程序操作。

问题:如何让操作系统知道系统调用号?

操作系统通过CPU的一个寄存器(比如 EAX)获取系统调用号。不需要传递系统调用号作为参数,在系统调用处理方法 void sys_function() 中有一些汇编代码(如 move XXX eax),用于从寄存器中取出预先存储的系统调用号。

系统调用所需的相关参数也通过寄存器传递给操作系统。

问题:操作系统如何返回结果给用户?

操作系统通过寄存器或用户传入的缓冲区地址返回结果。例如,在汇编层面,callq func 调用某个函数之后,通常跟着一个 move 指令,用于将某个寄存器中的返回值写入指定变量。

因此,在底层操作系统的通信过程中,信息的传递一般通过寄存器完成。

我们看一下系统调用处理函数的源码::是使用汇编实现的



其中:这句指令就能说明操作系统如何查找系统调用表的


  • _sys_call_table_ 是系统调用表的开始指针地址
  • eax 寄存器中存储着系统调用号,即系统调用表数组下标
  • eax*4:表示通过系统调用号*4 == 对应系统调用的地址(4 为当前系统的指针大小)

定位到 _sys_call_table_ 系统调用表:可以看到该表存储着大部分系统调用函数



因此,系统调用的调用流程是:

通过触发软中断进入内核,根据中断号找到系统调用入口函数。在寄存器中存放系统调用号,并通过一句汇编代码计算出该系统调用在系统调用表中的位置,从而找到并执行相应的系统调用。

实际上,我们上层使用的系统调用是经过封装的,系统调用的本质是 中断号(用于陷入内核)+汇编代码(临时存放传递进来的参数和接收返回值)+系统调用号(用于查询系统调用数组中的系统调用程序)

问题:用户自己可以设计用户层的系统调用吗?

我们是否可以认为,用户想调用操作系统中的系统调用,可以写一段这样的汇编代码,同时通过系统调用号计算出系统调用表中该系统调用的位置,然后找到并使用该系统调用?也就是说用户自己是否可以设计一个用户层的系统调用,用于调用系统内部的系统调用程序?

答:其实是可以的!

问题:但是为什么没见过有人这样用?

因为这样做过于麻烦。所以设计者将系统调用都封装成了函数,并集成到了 GNU glibc 库中。

在封装的系统调用内部:

  • 拿到我们传递进来的参数。
  • 使用设定好的固定系统调用号,通过汇编指令查表找到并执行对应的系统调用。
  • 将返回值等信息存储在其他寄存器中,便于上层应用获取。

GNU glibc 库的作用

GNU glibc 库封装了各种平台的系统调用,使得用户可以更方便地使用这些功能,而不需要直接编写底层汇编代码。实际上,几乎所有的软件都或多或少与C语言有关联。

如何理解内核态和用户态

每个进程都有自己的虚拟地址空间,这个地址空间分为几个部分:

  1. 用户区:这部分地址空间是进程私有的,每个进程都有自己独立的一份用户区。用户区包含了进程的代码、数据、堆栈等。
  2. 内核区 :这部分地址空间是所有进程共享的,包含了内核代码和数据结构。
用户页表和内核页表
  1. 用户页表

    • 每个进程都有自己独立的用户页表,用于映射用户区的虚拟地址到物理地址。
    • 用户页表确保了每个进程的用户区是独立的,互不影响。
  2. 内核页表

    • 内核页表在整个操作系统中只有一份,所有进程共享这份内核页表,这样所有进程都能看到同一个操作系统(OS)。
    • 内核页表用于映射内核区的虚拟地址到物理地址,确保所有进程都能访问相同的内核数据和代码。
内核页表的作用
  1. 共享内核数据

    • 内核页表使得所有进程都能看到同一个操作系统内核数据和代码,确保了内核功能的一致性和可靠性。
    • 例如,内核数据结构如文件系统、网络协议栈等都是共享的。
  2. 增强进程独立性

    • 尽管内核页表是共享的,但每个进程的虚拟地址空间中都包含了一份内核页表的映射。
    • 这样,进程在进行系统调用或其他内核操作时,可以直接在自己的虚拟地址空间中访问内核数据,而不需要切换到其他地址空间。
    • 这种设计增强了进程的独立性,减少了上下文切换的开销。
简单总结

进程的虚拟地址空间分为两部分:用户区和内核区。用户区包括我们熟知的栈区、堆区、共享区、代码区、数据区等,是每个进程独有的。内核区则是独立的一个区域,用于存放操作系统内核的代码和数据。值得注意的是,内核区资源通常是只读不可修改的,整个操作系统只有一份内核页表,所有进程共享这份内核页表,从而所有进程都能看到同一个操作系统。当进程需要执行程序访问操作系统内核时,可以直接在自己的虚拟地址空间中的内核区访问,这使得操作更为便捷。

以设计者将系统调用都封装成了函数,并集成到了 GNU glibc 库中。

相关推荐
XINO3 分钟前
防火墙双机热备实践
运维·安全
神洛华14 分钟前
Docker概念详解
运维·docker·容器
四川合睿达自动化控制工程有限公司14 分钟前
管道位移自动化监测方案
运维·自动化
007php00717 分钟前
Docker Compose 安装Elasticsearch8和kibana和mysql8和redis5 并重置密码的经验与总结
大数据·运维·elasticsearch·搜索引擎·docker·容器·jenkins
XINO35 分钟前
企业常见安全事故排查思路
运维·安全
林政硕(Cohen0415)39 分钟前
在ARM Linux应用层下驱动MFRC522
linux·mfrc522·ic-s50·m1卡
艾伦_耶格宇1 小时前
shell 脚本实验 -5 while循环
linux
独隅1 小时前
PyCharm 在 Linux 上的完整安装与使用指南
linux·ide·pycharm
想躺在地上晒成地瓜干1 小时前
树莓派超全系列教程文档--(38)config.txt视频配置
linux·音视频·树莓派·raspberrypi·树莓派教程
深圳信迈科技DSP+ARM+FPGA1 小时前
基于RK3588+FPGA+AI YOLO全国产化的无人船目标检测系统(二)平台设计
人工智能·yolo·目标检测·计算机视觉·fpga开发·信号处理