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

news/2025/2/3 8:14:34 标签: linux, 信号处理, 运维




在这里插入图片描述



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


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



两种信号默认处理


1、信号处理之忽略

::signal(2, SIG_IGN); // ignore: 忽略
#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、信号处理之默认

::signal(2, SIG_DFL); // default:默认。
#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、三张表的内核源码

// 内核结构 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 类型的增删查改

#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 则是修改本进程的 信号屏蔽字

#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


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

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

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


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



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


演示屏蔽 2 号信号


在这里插入图片描述


下面这段代码:

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

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


#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 号信号的屏蔽

#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)
  • 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
  • 由外部设备触发的,中断系统运行流程,叫做硬件中断
//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() 就是用于进程调度的函数,

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

// 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()

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

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


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

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

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

#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 库中。




http://www.niftyadmin.cn/n/5840637.html

相关文章

线性数据结构:单向链表

放弃眼高手低&#xff0c;你真正投入学习&#xff0c;会因为找到一个新方法产生成就感&#xff0c;学习不仅是片面的记单词、学高数......只要是提升自己的过程&#xff0c;探索到了未知&#xff0c;就是学习。 目录 一.链表的理解 二.链表的分类&#xff08;重点理解&#xf…

浅谈知识蒸馏技术

最近爆火的DeepSeek 技术&#xff0c;将知识蒸馏技术运用推到我们面前。今天就简单介绍一下知识蒸馏技术并附上python示例代码。 知识蒸馏&#xff08;Knowledge Distillation&#xff09;是一种模型压缩技术&#xff0c;它的核心思想是将一个大型的、复杂的教师模型&#xff0…

小程序设计和开发:如何研究同类型小程序的优点和不足。

一、确定研究目标和范围 明确研究目的 在开始研究同类型小程序之前&#xff0c;首先需要明确研究的目的。是为了改进自己的小程序设计和开发&#xff0c;还是为了了解市场趋势和用户需求&#xff1f;不同的研究目的会影响研究的方法和重点。例如&#xff0c;如果研究目的是为了…

AI智慧社区--Excel表的导入导出

Excel表导入导出的环境配置 1.导入依赖 <dependency><groupId>cn.afterturn</groupId><artifactId>easypoi-spring-boot-starter</artifactId><version>${easypoi.version}</version></dependency>2.配置Excel的导入导出以及…

如何本地部署DeepSeek

第一步&#xff1a;安装ollama https://ollama.com/download 打开官网&#xff0c;选择对应版本 第二步&#xff1a;选择合适的模型 https://ollama.com/ 模型名称中的 1.5B、7B、8B 等数字代表模型的参数量&#xff08;Parameters&#xff09;&#xff0c;其中 B 是英文 B…

ASUS/华硕天选4R FA617N 原厂Win11 22H2系统 工厂文件 带ASUS Recovery恢复

华硕工厂文件恢复系统 &#xff0c;安装结束后带隐藏分区&#xff0c;带一键恢复&#xff0c;以及机器所有的驱动和软件。 支持型号&#xff1a;FA617NS, FA617NT 系统版本&#xff1a;Windows 11 23H2 文件下载&#xff1a;点击下载 文件格式&#xff1a;工厂文件 安装教…

2025年最新在线模型转换工具优化模型ncnn,mnn,tengine,onnx

文章目录 引言最新网址地点一、模型转换1. 框架转换全景图2. 安全的模型转换3. 网站全景图 二、转换说明三、模型转换流程图四、感谢 引言 在yolov5&#xff0c;yolov8&#xff0c;yolov11等等模型转换的领域中&#xff0c;时间成本常常是开发者头疼的问题。最近发现一个超棒的…

力扣第435场周赛讲解

文章目录 题目总览题目详解3442.奇偶频次间的最大差值I3443.K次修改后的最大曼哈顿距离3444. 使数组包含目标值倍数的最少增量3445.奇偶频次间的最大差值 题目总览 奇偶频次间的最大差值I K次修改后的最大曼哈顿距离 使数组包含目标值倍数的最少增量 奇偶频次间的最大差值II …