理解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
类型的变量之前,一定要调用 sigemptyset
或 sigfillset
进行初始化,以确保信号集处于一个确定的状态。初始化 sigset_t
变量之后,就可以通过调用 sigaddset
和 sigdelset
在该信号集中添加或删除某种有效信号。
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
指示如何进行更改。具体来说: - 如果
oset
和set
都是非空指针,则首先将原来的信号屏蔽字备份到oset
中,然后根据set
和how
参数来更改信号屏蔽字。
假设当前的信号屏蔽字为 mask
,how
参数的可选值及其含义如下:
具体来说:
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,÷_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,¶llel_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设计了相应的汇编指令(如
int
或syscall
),使得在没有外部硬件中断的情况下,通过这些指令也能触发中断逻辑。
这样通过软件实现上述逻辑的机制被称为软中断。软中断有固定的中断号,用来索引特定的中断处理程序,常见的形式包括 syscall: XXX
或 int: 0x80
。
操作系统会在中断向量表中为软中断配置处理方法,并将系统调用的入口函数放置于此。当触发软中断时,会通过这个入口函数找到对应的系统调用函数指针数组,进而匹配并调用具体的系统调用。系统调用表使用系统调用号作为数组下标来查找对应的系统调用。
系统调用过程
系统调用的过程本质上是通过触发软中断(例如 int 0x80
或 syscall
),使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语言有关联。
如何理解内核态和用户态
每个进程都有自己的虚拟地址空间,这个地址空间分为几个部分:
- 用户区:这部分地址空间是进程私有的,每个进程都有自己独立的一份用户区。用户区包含了进程的代码、数据、堆栈等。
- 内核区:这部分地址空间是所有进程共享的,包含了内核代码和数据结构。
用户页表和内核页表
-
用户页表:
- 每个进程都有自己独立的用户页表,用于映射用户区的虚拟地址到物理地址。
- 用户页表确保了每个进程的用户区是独立的,互不影响。
-
内核页表:
- 内核页表在整个操作系统中只有一份,所有进程共享这份内核页表,这样所有进程都能看到同一个操作系统(OS)。
- 内核页表用于映射内核区的虚拟地址到物理地址,确保所有进程都能访问相同的内核数据和代码。
内核页表的作用
-
共享内核数据:
- 内核页表使得所有进程都能看到同一个操作系统内核数据和代码,确保了内核功能的一致性和可靠性。
- 例如,内核数据结构如文件系统、网络协议栈等都是共享的。
-
增强进程独立性:
- 尽管内核页表是共享的,但每个进程的虚拟地址空间中都包含了一份内核页表的映射。
- 这样,进程在进行系统调用或其他内核操作时,可以直接在自己的虚拟地址空间中访问内核数据,而不需要切换到其他地址空间。
- 这种设计增强了进程的独立性,减少了上下文切换的开销。
简单总结
进程的虚拟地址空间分为两部分:用户区和内核区。用户区包括我们熟知的栈区、堆区、共享区、代码区、数据区等,是每个进程独有的。内核区则是独立的一个区域,用于存放操作系统内核的代码和数据。值得注意的是,内核区资源通常是只读不可修改的,整个操作系统只有一份内核页表,所有进程共享这份内核页表,从而所有进程都能看到同一个操作系统。当进程需要执行程序访问操作系统内核时,可以直接在自己的虚拟地址空间中的内核区访问,这使得操作更为便捷。
以设计者将系统调用都封装成了函数,并集成到了 GNU glibc
库中。