目录
一、阻塞信号
1、信号的状态
2、内核中的信号
信号集(Signal Set)
task_struct 结构体
信号处理函数(Handler)
信号传递与调度
3、“signal_struct结构体”与“信号集sigset_t”
4、信号集操作函数
5、信号屏蔽字sigprocmask
6、sigpending
二、内核态与用户态
1、概念
2、内核级页表
3、内核态与用户态区别
三、信号处理流程
四、捕捉信号
1、过程
2、sigaction函数
3、可重入函数
4、volatile
5、SIGCHLD信号
一、阻塞信号
1、信号的状态
信号递达(Delivery): 实际执行信号所携带指示的操作或行为的过程被称为信号的递达。这意味着当一个信号到达其目标进程时,操作系统会采取相应行动,可能是调用预设的信号处理函数,或者按照默认行为处理信号。
信号未决(Pending): 信号从生成时刻至其被实际处理(即递达)之前的这段时间状态,被称为信号未决状态。在这期间,信号已经产生但尚未对目标进程产生实际影响。
信号阻塞(Block): 进程拥有主动选择暂时不让某些信号生效的能力,这一过程称为阻塞信号。当一个进程决定阻塞某个信号时,即使该信号在此期间产生,也不会立即被执行,而是被置于待处理队列中,继续保持未决状态。
信号阻塞与忽略的区别:
- 阻塞: 当进程阻塞一个信号时,操作系统不会将该信号立即交付给进程,而是将其保存起来。直到进程取消对该信号的阻塞,信号才会从未决状态变为递达状态,并得到执行。
- 忽略: 忽略信号则是在信号递达后的一种处理策略。即使信号已经送达进程,进程也可以选择忽略该信号,即不执行任何特殊的处理动作。相比之下,阻塞侧重于延迟信号的处理,而忽略则是在信号实际递达之后明确地弃置信号,不采取任何应对措施。
2、内核中的信号
信号集(Signal Set)
Linux内核使用sigset_t
数据结构来表示一组信号。这是一个位图结构,其中每一位代表一种信号。例如,如果某个信号编号对应的位被设置为1,则表示该信号处于有效或未屏蔽状态;如果为0,则表示该信号被进程屏蔽或忽略。
task_struct 结构体
对于每个运行在内核中的进程,内核都有一个对应的task_struct
结构体,它是进程控制块(PCB)。在这个结构体中,有几个字段与信号处理相关:
-
sighand
: 这是一个指向sighand_struct
结构体的指针,用于存放信号处理函数。sighand_struct
结构体中包含了一个action[]
数组,用于存放信号对应的处理函数。这里的action[0]
和action[1]
分别代表两种不同的信号处理函数。 -
signal
: 这是一个指向signal_struct
结构体的指针,signal_struct是一个嵌套在task_struct
中的结构体,包含了一系列信号相关的字段,比如sig_blocked
(信号屏蔽集),用于记录当前进程中被阻塞的信号集合,即进程不想立即接收的信号。// kernel/signal.c 或类似的文件中(简化的伪代码) // 定义信号结构体 struct signal_struct { // ... sigset_t blocked; // 阻塞信号集 sigpending_t pending; // 未决信号集 struct sigaction sig[NSIG]; // 信号处理动作数组 // ... };
-
sigset_t blocked: 这是一个信号集数据结构,它代表了当前进程中被阻塞(屏蔽)的信号集合。在信号处理机制中,如果一个信号被加入到
blocked
集合中,那么即便该信号已被发送给进程,进程也不会立即对其进行处理,直至后来进程取消对它的阻塞。 -
sigpending_t pending: 这也是一个信号集,但它表示的是当前进程中所有已到达但尚未被处理的信号,即未决信号集。当一个信号被发送给进程,且该信号不在进程的阻塞信号集中时,该信号将被添加到
pending
集合中。进程在适当的时候(比如从系统调用返回用户空间时)会检查并处理这些未决信号。 -
struct sigaction sig[NSIG]: 这是一个数组,数组的每个元素对应一种信号,数组的索引号即信号编号。每个
struct sigaction
结构体包含了该信号的处理动作,包括信号处理函数的地址、信号处理模式以及其他与信号处理相关的属性。通过设置这个数组,进程可以为不同的信号指定不同的处理方式。
-
-
pending:
task_struct
中包含一个名为pending
的struct sigpending
类型的成员,它表示所有已经到达但尚未由进程实际处理的信号。pending.signal
是一个未决信号集,记录了所有未决信号。
pending.signal与signal->shared_pending的区别
-
pending.signal:
这个字段位于task_struct
中的struct sigpending pending
结构体内,它表示当前线程(或进程)私有的未决信号集。当一个信号被发送给线程且该信号不在线程的信号屏蔽集中时,这个信号会被添加到pending.signal
中。只有当线程有机会(比如从系统调用返回到用户空间)并检查自己的未决信号时,才会处理这些信号。 -
signal->shared_pending:
这个字段位于task_struct
中的signal_struct
结构体中,它表示与整个进程相关的、所有线程共享的未决信号集。当一个信号被发送给进程中的任何一个线程时,如果该信号可以送达,则会被添加到shared_pending
中。这个信号集对于进程中的所有线程都是可见的,并且当任何线程有机会处理信号时,都会考虑到这些共享的未决信号。
信号处理函数(Handler)
每个信号还可以关联一个信号处理函数,这个信息通常不是直接在task_struct
中表示,而是通过另一个结构struct sigaction
来管理。sigaction
结构体包含了信号处理函数的地址以及信号处理的一系列属性,如信号处理程序、信号的默认行为、以及是否应重置信号掩码等。
#include <signal.h>
struct sigaction {
void (*sa_handler)(int); // 信号处理函数
sigset_t sa_mask; // 在调用信号处理函数前临时阻塞的信号集
int sa_flags; // 标志位,用于控制信号行为
// 下面这两个字段在Linux内核中可能不存在,但在一些POSIX标准库实现中有定义,
// 它们与`siginfo_t`结构一起提供了更多关于信号的上下文信息。
void (*sa_sigaction)(int, siginfo_t *, void *); // 支持siginfo的信号处理函数
sigset_t sa_restorer; // 在某些架构上用于恢复信号上下文(现已废弃)
};
信号传递与调度
在Linux内核中,信号的传递和调度遵循一套精密的规则。当内核需要向一个进程发送一个信号时,它首先会检查该信号是否出现在进程的信号屏蔽集中。如果信号不在屏蔽范围之内:
-
内核会立即将信号添加至进程的未决信号列表。此时,信号并不会立即触发进程做出反应,而是等待合适的时机处理。
-
根据信号的具体属性,内核会据此调整进程的状态。例如,某些信号可能会要求中断进程正在进行的系统调用,使其提前返回到用户态进行信号处理。
相反,如果信号正处于进程的信号屏蔽集中:
- 内核并不会立即传递此信号,而是选择暂时储存起来。该信号会等待进程解除对该信号的屏蔽状态后,才得以传递并处理。
与此同时,在多线程环境下,信号的处理更为细致:
-
当一个信号被发送给进程时,它首先会被纳入到进程共享的
shared_pending
链表中,意味着所有隶属于该进程的线程都能感知到此信号的存在。 -
更进一步,如果该信号已被进程中的某个特定线程注册了特定的处理函数,那么除了加入到共享链表外,该信号还会被添加至该线程的私有未决信号列表(即
pending
链表)。
这样一来,当线程进入到可以安全处理信号的状态(如从繁忙状态转为空闲状态),它会主动检查并处理自己pending
链表中的所有未决信号,从而确保信号得到了及时有效的响应和处理。
3、“signal_struct
结构体”与“信号集sigset_t”
在Linux内核的实现中,信号集通常由
sigset_t
类型表示,该类型内部就是一个位图结构。通过位运算,可以方便地查询、设置和清除信号集中的某一位,以此来表示信号是否被阻塞或是否处于未决状态。
在系统层面,每个信号还各自配备了用于表示其未决状态和阻塞状态的独立比特位,这两种状态只有“开启”(值为1)或“关闭”(值为0)两种可能,并且并不记录信号发生的实际次数。
在进程控制块(PCB)或相关的数据结构中,信号管理通常由三个关键部分表示:
-
block(阻塞): 这是一个信号集,其中包含哪些信号当前被进程阻塞。当一个信号被阻塞时,即使它被发送给进程,也不会立即传递给进程处理,而是暂时存放在pending(未决)队列中,直至进程取消对相应信号的阻塞。
-
pending(挂起/未决): 这也是一个信号集,记录了所有已发送给进程但尚未处理的信号。这些信号可能是由于被阻塞或是因为进程当前不在安全点(能处理信号的地方)而暂时无法处理。
-
handler(处理器/处理程序): 对于每个信号,进程都可以指定一个处理函数,这是一个函数指针。它可以指向系统的默认信号处理动作(SIG_DFL)、选择忽略信号(SIG_IGN),或者是指向用户自定义的信号处理函数。
在这个例子中,我们看到三个不同的信号及其状态:
- SIGHUP(1):这个信号既没有被阻塞也没有被挂起,因此可以随时递送给进程。当SIGHUP信号递达时,它将按照默认的动作进行处理,通常是终止进程。
- SIGINT(2):这个信号已经产生过,但由于被标记为阻塞,目前还无法递送给进程。尽管处理器字段设置为SIG_IGN,即忽略信号,但是在解除阻塞之前,进程仍然有可能更改处理动作。因此,此时信号的忽略操作并未生效。
- SIGQUIT(3):这个信号目前没有产生过,但是当它产生时会被标记为阻塞。处理器字段指向了一个名为sighandler的用户自定义函数,这意味着进程希望使用这个函数来处理SIGQUIT信号。
对于多个信号产生的情况,POSIX.1标准规定了两种可能的处理方式:递送一次或多次。
在Linux系统中,常规信号在递送前即使产生了多次,也会被视为一次事件进行处理。然而,对于实时信号,Linux提供了另一种策略:这些信号可以在递送前产生多次并按顺序排队,以便在后续过程中逐一处理。
- 例如,假设有一个进程设置了SIGINT(中断信号)的处理函数,并且在短时间内接收到两次Ctrl+C操作(这通常会产生两次SIGINT信号)。按照Linux对常规信号的处理方式,即使实际收到了两次中断请求,但如果第一次信号送达时处理函数尚未执行完毕,则第二次产生的SIGINT信号会被合并,只当作一次事件处理,不会立即再次触发处理函数。
- 例如,若一个进程设置了一个实时信号SIGRTMIN的处理函数,并且连续收到了三次这个实时信号。在这种情况下,进程会依次处理这三个信号,即使它们几乎是同时到达的。
4、信号集操作函数
编程语言通过
.h
或.hpp
头文件为我们提供了语言本身定义的自定义数据类型以及相应的接口。与此同时,操作系统也会通过其自身的.h
头文件向开发者提供一系列系统自定义类型及相关的操作函数。
- 在信号处理机制中,
sigset_t
是一种由操作系统提供的特殊类型,设计上并不鼓励用户直接进行底层的位操作。操作系统为了方便开发者管理信号集,提供了一系列用于操作信号位图的方法,这些方法封装在相应的系统接口中,可供用户间接调整sigset_t
类型的变量。- 对于
sigset_t
类型,用户可以直接在代码中声明并使用它,就如同使用任何其他内置类型或自定义类型一样。然而,要实现对信号集合的完整功能操作,如添加、移除或测试信号,用户确实需要依赖于操作系统提供的系统接口。这些接口函数通常会接收包含sigset_t
变量作为参数,以确保正确有效地对信号集进行操作。
信号集操作函数利用sigset_t类型来高效地管理信号状态,其中每个信号都由一个比特位代表其“启用”或“无效”状态。
- 然而,sigset_t类型的内部数据结构细节如何组织存储,完全依赖于底层系统的具体实现,对于用户而言无需深究。用户仅能通过一组特定的函数对sigset_t变量进行操作,而非直接解析或解读其内部数据,比如试图使用printf直接输出sigset_t变量的内容并无实际意义。
以下是用于操作sigset_t变量的关键函数,均包含在 <signal.h>
头文件中:
特别强调,在使用sigset_t类型的变量前,务必先调用
sigemptyset
或sigfillset
对其进行初始化,确保信号集处于明确清晰的状态。初始化完成后,便可通过sigaddset
和sigdelset
函数灵活地在信号集中增删有效的关注信号。
-
int sigemptyset(sigset_t *set);
此函数用于初始化set
指向的信号集,将所有信号对应的比特位清零,表明该信号集当前不包含任何有效的待处理信号。 -
int sigfillset(sigset_t *set);
此函数初始化set
指向的信号集,将所有信号对应的比特位置位,这意味着该信号集涵盖了系统支持的所有信号,即所有信号都被视为有效。 -
int sigaddset(sigset_t *set, int signo);
此函数用于将特定的信号(通过signo
指定)添加到set
指向的信号集中,使其成为有效信号。 -
int sigdelset(sigset_t *set, int signo);
此函数用于从set
指向的信号集中删除指定的信号(通过signo
指定),将其从有效信号列表中移除。 -
int sigismember(const sigset_t *set, int signo);
此函数用于判断特定信号(通过signo
指定)是否存在于set
指向的信号集中,如果是,则返回非零值,否则返回0。 -
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
示例:
#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
#include <cstdlib>
// 示例函数
void print_sigset(const char *msg, const sigset_t *set) {
int i;
printf("%s:\n", msg);
for (i = 1; i < NSIG; ++i) {
if (sigismember(set, i)) {
printf("\tSignal %d is in the set.\n", i);
}
}
}
int main() {
sigset_t my_signals;
// 初始化信号集为空集
if (sigemptyset(&my_signals) == -1) {
perror("sigemptyset");
//EXIT_FAILURE==1,返回1示程序执行表示失败
return(1);
}
// 添加信号SIGINT到信号集中
if (sigaddset(&my_signals, SIGINT) == -1) {
perror("sigaddset");
return(1);
}
// 检查SIGINT是否在信号集中
if (sigismember(&my_signals, SIGINT) == 1) {
printf("SIGINT is present in the signal set.\n");
} else {
printf("An error occurred while checking SIGINT presence.\n");
return(1);
}
// 删除信号SIGQUIT
if (sigdelset(&my_signals, SIGQUIT) == -1) {
perror("sigdelset");
return(1);
}
// 打印当前信号集的状态
print_sigset("Current signal set:", &my_signals);
// 将信号集设置为包含所有信号(注意:在实际场景中可能不需要这样做)
if (sigfillset(&my_signals) == -1) {
perror("sigfillset");
return(1);
}
// 再次打印信号集状态
print_sigset("Signal set after sigfillset:", &my_signals);
return(0);
}
- 首先创建了一个信号集,并将
SIGINT
(信号编号2)添加到了该信号集中,然后确认SIGINT
是否在信号集中(结果显示是的)。接下来,程序打印了当前信号集的内容,只显示出了SIGINT
(信号2)在信号集中。 - 随后,程序调用了
sigfillset
函数,该函数将信号集初始化为包含所有可能的信号。之后再次打印信号集的状态,可以看到大量信号号(从1到64)都在信号集中,这是因为sigfillset
函数将所有系统支持的信号都加入了信号集。
[hbr@VM-16-9-centos signal]$ ./mysignal
SIGINT is present in the signal set.
Current signal set::
Signal 2 is in the set.
Signal set after sigfillset::
Signal 1 is in the set.
Signal 2 is in the set.
//…………
Signal 61 is in the set.
Signal 62 is in the set.
Signal 63 is in the set.
Signal 64 is in the set.
[hbr@VM-16-9-centos signal]$
5、信号屏蔽字sigprocmask
sigprocmask函数的作用在于读取或更新进程当前的信号屏蔽字,即阻塞信号集。通过调用该函数,可以有效地管理和控制进程中哪些信号应当被阻塞,哪些信号应当被解除阻塞。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
- 在函数调用中,若
oset
是指向非空地址的指针,则sigprocmask
会将当前进程的信号屏蔽字(阻塞信号集)通过oset
参数传回。 - 如果
set
是一个非空指针,则依据set
和how
参数来修改进程的信号屏蔽字。 - 如果
oset
和set
两者都不为空,那么首先会将原信号屏蔽字备份至oset
,然后根据set
和how
参数来更新信号屏蔽字。 - 函数返回值:成功执行时返回0,若出现错误则返回-1。
这里假定当前的信号屏蔽字为mask
,下面详述how
参数的不同取值选项:
-
SIG_BLOCK
:此时,set
包含了希望加入当前信号屏蔽字的信号集合,效果等同于mask = mask | set
,即将set
中的信号添加至阻塞列表。 -
SIG_UNBLOCK
:在这种情况下,set
包含了想要从当前信号屏蔽字中移除的阻塞信号集合,操作效果类似mask = mask & ~set
,即将set
中的信号从阻塞列表中解除阻塞。 -
SIG_SETMASK
:直接将当前进程的信号屏蔽字设置为set
所指向的信号集,等同于mask = set
,即彻底替换当前的阻塞信号集。
值得注意的是,如果在调用sigprocmask
函数时解除了对某些当前已处于未决状态信号的阻塞,在sigprocmask
函数返回之前,至少会确保其中一个未决信号得以传递给进程。这就意味着,解除阻塞的未决信号不会被忽略,而是会在函数调用后立即得到处理。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int signum) {
printf("Caught signal %d\n", signum);
}
int main() {
sigset_t old_mask, new_mask;
int ret;
// 创建一个新的信号集,并添加SIGINT信号
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);
// 使用SIG_BLOCK选项,将SIGINT信号添加到当前的阻塞信号集中
ret = sigprocmask(SIG_BLOCK, &new_mask, NULL);
if (ret == -1) {
perror("sigprocmask SIG_BLOCK failed");
return 1;
}
printf("SIGINT has been blocked. Send Ctrl+C to generate SIGINT...\n");
// 模拟一些工作,期间SIGINT信号将被阻塞
sleep(5);
// 获取当前的阻塞信号集并存储在old_mask中
ret = sigprocmask(SIG_BLOCK, NULL, &old_mask);
if (ret == -1) {
perror("sigprocmask read failed");
return 1;
}
printf("Before unblocking: The blocked signals are: ");
for (int i = 1; i < NSIG; ++i) {
if (sigismember(&old_mask, i)) {
printf("%d ", i);
}
}
printf("\n");
// 使用SIG_UNBLOCK选项,从当前的阻塞信号集中移除SIGINT
ret = sigprocmask(SIG_UNBLOCK, &new_mask, NULL);
if (ret == -1) {
perror("sigprocmask SIG_UNBLOCK failed");
return 1;
}
printf("SIGINT has been unblocked. Sending SIGINT now...\n");
// 注册SIGINT信号的处理函数
signal(SIGINT, handler);
// 模拟发送一个SIGINT信号,由于刚才已经解除阻塞,现在进程会立即捕获到SIGINT
raise(SIGINT);
// 若进程没有因SIGINT退出,这里继续执行后续代码...
return 0;
}
[hbr@VM-16-9-centos block]$ ./mysignal
SIGINT has been blocked. Send Ctrl+C to generate SIGINT...
^C^C^C^C^C^C^CBefore unblocking: The blocked signals are: 2
[hbr@VM-16-9-centos block]$ ./mysignal
SIGINT has been blocked. Send Ctrl+C to generate SIGINT...
Before unblocking: The blocked signals are: 2
SIGINT has been unblocked. Sending SIGINT now...
Caught signal 2
在第一个命令行示例中,您连续按下了几次 Ctrl+C
,但由于 SIGINT
信号被阻塞,这些信号并未引发程序的响应。然后程序继续执行,并在 sleep(5)
后获取了当前的阻塞信号集,并打印出 "Before unblocking: The blocked signals are: 2"。
- 一旦程序解除对 SIGINT 信号的阻塞,内核会立即检查是否有待处理的 SIGINT 信号。如果有,那么第一个待处理的 SIGINT 信号会被立即发送给进程,导致程序立刻终止(默认情况下,SIGINT 信号会使进程终止)。在本例中,这可能导致 "SIGINT has been unblocked. Sending SIGINT now..." 这一行输出还没有来得及刷新到终端就被 SIGINT 信号中断了程序的执行,同样地,紧随其后的
raise(SIGINT)
和信号处理函数handler()
的输出也无法完成。
在第二个命令行示例中,您在程序开始运行后没有立即按下 Ctrl+C
,而是等到程序解除对 SIGINT
信号的阻塞之后再发送模拟信号。这时,SIGINT
已经解除阻塞,所以当调用 raise(SIGINT)
时,程序能够捕获并处理该信号,进而调用 handler
函数打印 "Caught signal 2"。
6、sigpending
sigpending()
函数是POSIX标准中用于处理进程信号的一个接口,它主要用于查询当前进程中有哪些信号正处于挂起(pending)状态,即已经到达但尚未被处理的信号。
函数原型:
#include <signal.h>
int sigpending(sigset_t *set);
- 这里的参数
set
是一个指向sigset_t
类型的指针,用来存储查询结果,即当前进程未决信号集合。sigset_t
是一个信号集数据类型,它可以包含多个不同的信号。 - 当调用
sigpending()
函数时,系统会将当前进程中所有挂起的信号填充到set
指向的信号集中。如果函数调用成功,则返回0;如果发生错误(如无效的指针等),则返回-1,并设置errno
来指示具体的错误原因。 - 这个函数通常与
sigaction()
、sigwait()
等其他信号处理函数配合使用,以便于进程可以按照特定策略来管理和处理信号。例如,在多线程环境中,一个线程可以通过调用sigpending()
来检查是否有待处理的信号,然后调用sigwait()
阻塞并接收这些信号进行处理。
#include <stdio.h>
#include <signal.h>
void printsigset(sigset_t *set) {
for (int i = 0; i < 32; i++) {
if (sigismember(set, i)) {
printf("1");
} else {
printf("0");
}
}
puts("");
}
int main() {
sigset_t s, p;
sigemptyset(&s);
sigaddset(&s, SIGINT);
sigprocmask(SIG_BLOCK, &s, NULL); // 设置阻塞信号集,阻塞SIGINT信号
while (1) {
sigpending(&p); // 获取未决信号集
printsigset(&p); // 打印信号集
sleep(1); // 程序休眠1秒
}
return 0;
}
- 在这个程序中,我们首先定义了一个名为
printsigset
的函数,它的作用是检查指定信号是否在一个目标信号集中。具体来说,它会遍历信号集中的所有信号,并输出相应的结果。如果信号存在,则输出'1';否则输出'0'。 - 接下来,在
main
函数中,我们定义了两个sigset_t
类型的对象s
和p
,并将它们清空初始化。然后,我们将SIGINT
信号添加到s
信号集中。 - 接着,我们调用了
sigprocmask
函数来设置阻塞信号集,将SIGINT
信号阻塞起来。这意味着当程序运行时,任何收到的SIGINT
信号都会被忽略,不会立即产生效果。 - 然后,我们进入一个无限循环,不断地检查未决信号集
p
。这里,我们使用了sigpending
函数来获取当前进程的未决信号集,并将其传递给printsigset
函数进行打印。 - 在每次循环中,我们都调用
sleep(1)
函数让程序休眠一秒,以便我们可以观察到信号的变化。
00000000000000000000000000000000
^C0000000000000000000000000000000
^C0000000000000000000000000000000
^C0000000000000000000000000000000
...
^\Quit (core dumped)
- 最后,当我们按下
Ctrl+C
键时,会产生一个SIGINT
信号。由于我们在前面设置了信号阻塞,所以这个信号会被test
程序所阻塞,一直处于未决状态,无法得到处理。因此,我们会看到printsigset
函数的输出中出现了^C
字符,表示SIGINT
信号的存在。 - 同时,如果我们继续按
Ctrl+C
键,那么printsigset
函数的输出就会不断变化,反映出信号的状态。直到我们输入^Quit (core dumped)
命令后,程序才会终止,并显示出最终的结果。 - 需要注意的是,这个程序只能在支持
<signal.h>
头文件的操作系统上运行。
二、内核态与用户态
1、概念
用户态与内核态:
-
用户态:这是一种受限制的状态,进程在此状态下执行用户级别的代码,仅能访问分配给它的资源,不能直接进行敏感操作,如硬件访问、改变系统全局状态等。用户态下的进程权限较低,其执行指令和访问内存的空间受到硬件(如MMU,Memory Management Unit)基于内存分段和分页机制的严格限制。
-
内核态:这是操作系统核心代码执行的状态,具有最高的权限级别。内核态可以执行任何指令,包括直接访问硬件、管理内存、调度进程等。当进程执行系统调用、处理硬件中断、异常或陷入内核时,会从用户态转换到内核态。
系统调用与进入内核态:
- 进程通过系统调用主动请求操作系统服务,如读写文件、创建新进程等。在x86架构下,传统的系统调用方法是通过
int 0x80
或syscall
指令实现的。这条指令触发了一个软中断,让CPU从用户态转为内核态,并跳转到预设好的内核处理程序执行相应的服务。
2、内核级页表
内核级页表是操作系统内存管理中的一个重要组件,尤其是在支持分页内存管理的现代计算机系统中,如x86-64、ARM64等架构。在这样的系统中,为了实现虚拟内存和物理内存之间的高效映射,同时提供安全的内存访问隔离,操作系统使用了一种称为分页的机制。
内核级页表主要负责以下几方面的工作:
-
地址转换:内核级页表提供了从虚拟地址到物理地址的转换机制。操作系统为每个进程维护一个虚拟地址空间,而物理内存是所有进程共享的。通过多级页表结构(如x86-64的四级页表体系),操作系统能够将虚拟地址空间分割为较小的固定大小的单元(称为页),并将这些虚拟页映射到物理内存的不同位置。
-
内存权限控制:页表条目(页表项)包含关于所映射内存页的各种信息,包括读/写/执行权限。内核级页表尤其关注内核空间的内存权限设定,确保只有在内核态下才能够访问特权级的内存区域,防止用户态进程无意或恶意地篡改内核数据。
-
进程上下文切换:当CPU从一个进程切换到另一个进程时,内核需要更新处理器的页表基址寄存器(如x86架构中的CR3),指向新的进程页表。这样,无论是在用户态还是内核态,CPU都能够正确地执行地址翻译,访问正确的内存位置。
-
内核空间管理:内核级页表不仅要处理用户态进程的内存映射,还要管理内核本身的地址空间。内核有自己的内核空间,这部分内存始终可用,并且在所有进程之间是共享的,包含内核代码、数据结构和各种共享资源。
-
硬件支持与协同:内核级页表的设计与硬件紧密协作,利用处理器提供的硬件支持如MMU(内存管理单元)来进行地址转换和权限检查。同时,当用户态进程通过系统调用或异常进入内核态时,硬件会自动切换到内核页表,以便内核能够访问其专属的地址空间。
3、内核态与用户态区别
内核态与用户态之间的核心区别在于权限级别和对系统资源的访问控制:
-
权限级别:在计算机操作系统中,处理器有多个运行级别或模式,其中内核态拥有最高权限级别,允许执行任何指令,包括那些可以直接操纵硬件、修改内存管理结构和其他关键系统资源的操作。相反,用户态下运行的进程权限受限,只能执行非特权指令,不允许直接访问硬件和内核的数据结构。
-
系统资源访问:用户态进程无法直接访问内存中的任何位置,其地址空间受到操作系统(通过MMU和页表机制)的严格控制,确保进程只能访问分配给它的虚拟地址空间。而内核态下,CPU可以访问完整的地址空间,包括用户进程不可见的内核空间。
-
系统调用:为了执行诸如文件操作(如open函数)、进程间通信、设备I/O等需要内核支持的功能,用户态进程必须通过系统调用的方式切换到内核态。例如,在Linux系统中,通过中断(如int 0x80)发起系统调用,CPU会从用户态切换到内核态,执行内核提供的服务例程,完成所需操作后,再返回到用户态继续执行。
-
信号处理:信号也是内核与用户态交互的一个重要方面。当内核检测到一个应当发送给某个进程的信号时,它会在该进程上下文中记录此信号,并将其置为挂起状态。随后,依据进程的信号掩码决定信号何时能够传递给进程。当进程因为某种原因(比如从系统调用返回或主动检查信号)进入内核态且该信号不再被阻塞时,内核会安排执行相应的信号处理函数,或采取默认动作。
-
地址空间转换:内核维护着每进程的独立页表(用户级页表和内核级页表),在进行上下文切换时,CR3寄存器会被更新为对应的页表基址,从而实现了用户态和内核态地址空间的隔离和映射。当从用户态切换至内核态时,CPU使用的页表也会相应地转变为内核页表,使得内核能访问全局资源;反之亦然。
三、信号处理流程
在现代操作系统中,当用户态的进程执行时,如果有特定的事件发生(如硬件中断、系统调用、或接收到信号等),系统可能需要从用户态切换至内核态来处理这些事件。以下是针对信号处理流程的详细解释:
-
执行用户态代码:用户态进程正在执行其自身的代码,比如读写文件、进行数学运算等。
-
陷入内核:当进程接收到一个信号(如Ctrl+C中断请求,或者其它进程通过kill系统调用发送过来的SIGTERM信号),操作系统会触发一个中断,使得当前进程从用户态陷入到内核态。在此状态下,CPU开始执行操作系统内核的代码。
-
执行操作系统代码:内核首先检查信号队列,确认是否存在待处理的信号(信号pending状态)。如果发现有信号且该信号没有被阻塞,同时进程设置了自定义的信号处理函数(handler),内核就准备切换回用户态执行这个信号处理函数。
-
返回用户态执行信号处理:内核保存当前进程上下文(包括CPU寄存器状态、堆栈信息等),并将控制权转移回用户态,让进程执行自定义的信号处理函数。在这个函数中,进程可以针对性地处理信号带来的影响,比如清理资源、记录日志、更改程序状态等。
-
信号处理完毕后重新陷入内核:信号处理函数执行完毕后,进程可能需要通过特定的系统调用(如sigreturn)再次陷入内核态。这是为了让内核恢复之前的进程上下文,撤销因处理信号而做的临时改变,并继续执行被信号打断前的用户态代码。
-
内核做收尾工作和恢复执行:内核在接收到进程的系统调用请求后,会进行必要的收尾工作,如更新信号屏蔽字(mask)以允许后续信号的接收,以及恢复进程的执行环境(包括程序计数器PC,指向被打断的用户态代码位置)。
-
恢复执行用户态代码:最后,内核切换回用户态,使进程从原先被打断的地方继续执行,整个处理流程至此结束。
通过这样的机制,操作系统能够在保证安全性和稳定性的前提下,灵活地处理各种异步发生的信号事件,确保用户态进程能在受到外部事件影响时做出适当的响应。
四、捕捉信号
1、过程
-
注册信号处理函数:首先,用户程序通过调用
signal()
或sigaction()
系统调用注册一个针对特定信号(如SIGQUIT)的处理函数sighandler
。这样内核就知道当该信号发生时,应调用哪个用户空间的函数进行处理。 -
信号的产生与递达:假设在进程正在执行
main
函数的时候,由于外部事件(如键盘中断、定时器到期等)触发了内核级的中断或异常处理。在内核处理这些中断或异常的过程中,它会检查是否有待递送给进程的信号。如果此时检测到SIGQUIT信号已经到达该进程,内核便会在恰当的时机安排信号的处理。 -
从内核态切换至用户态执行处理函数:在内核完成中断处理即将返回用户态时,它并不直接恢复
main
函数的执行上下文,而是构造一个新的上下文,指向sighandler
函数的入口地址,并切换至用户空间执行sighandler
。由于sighandler
有自己的独立堆栈空间,这意味着它和main
函数虽然共享进程资源,但在执行层面是相互独立的,不存在常规的函数调用关系。 -
信号处理函数执行与返回:
sighandler
函数在用户空间执行完毕后,会调用内建的sigreturn
系统调用返回到内核,这是一个特殊的系统调用,专门用于从信号处理函数返回到内核。sigreturn
调用通知内核,信号处理已完成,应当恢复进程的原始状态。 -
检查其他信号与恢复执行:内核在接到
sigreturn
调用后,会检查是否有其他待处理的信号。如果没有新的信号需要递达,这次内核将恢复main
函数原有的执行上下文,使得进程能从上次暂停的地方继续执行下去。
2、sigaction
函数
sigaction
函数是POSIX标准中用于处理进程信号的核心接口之一,它允许程序员读取和设定针对特定信号的处理行为。函数声明如下:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
在这个函数中,signo
参数代表要操作的信号编号,它标识了我们想要定制或查询其处理方式的具体信号。
如果act
指针非空,那么函数将根据act
指向的struct sigaction
结构体内容来更新指定信号的处理动作。
struct sigaction
结构体包含了对信号处理的各种详细设定,其中最重要的一项是sa_handler
字段:
- 若将
sa_handler
成员设置为常量SIG_IGN
,则表明我们将忽略指定的信号,即不对其进行任何特殊处理。 - 若将
sa_handler
设为SIG_DFL
,则恢复信号的默认处理动作,通常是进程结束(对于某些信号)或者其他特定于操作系统的默认响应。 - 若将
sa_handler
设为一个函数指针,则意味着当指定信号发生时,系统将会调用该函数来进行自定义处理。这个函数通常无返回值,且接受一个整型参数(即信号编号),从而使单个函数能够灵活应对多种不同的信号。
另外,如果oact
指针非空,sigaction
函数将在修改信号处理动作前,通过oact
保存原信号处理设置,便于后续恢复或比较。
总的来说,sigaction
函数为开发者提供了精细控制进程对各类信号响应的能力,通过注册回调函数实现信号捕捉逻辑,增强了程序在面对异步事件时的健壮性和可控性。
3、可重入函数
当main函数调用insert函数向链表head中插入节点node1时,这个过程被分为两步进行。
- 在执行完第一步后,由于硬件中断导致进程切换至内核态,在此期间检测到有待处理的信号,因此转而执行sighandler函数。sighandler函数同样调用了insert函数,目的是向同一链表head中插入节点node2,并且成功完成了两步插入操作。
- 随后,sighandler函数返回至内核态,再回到用户态时,程序继续从main函数调用的insert函数中执行未完成的第二步。
- 最终,尽管main函数和sighandler函数试图分别向链表中插入两个节点,但实际结果却是链表中仅插入了一个节点。
这种现象揭示了insert函数在多控制流程下的重入问题。
- 当一个函数在同一时刻可能被不同的控制流程调用,且尚未返回前再次进入该函数时,我们称其为“重入”。
- 由于insert函数在本例中直接访问了全局链表,这就可能导致因重入引发的数据混乱,故将此类函数定义为“不可重入函数”。
相反,若一个函数仅访问自身的局部变量或参数,则不会因多控制流程同时调用而引起数据错乱,这类函数被称为“可重入函数”(Reentrant Function)。
总结一下,以下情况可能会导致函数成为不可重入函数:
-
函数调用了如malloc或free等内存管理函数,因为这些函数通常依赖于全局链表来管理堆内存,存在竞态条件。
-
函数调用了标准I/O库函数。许多标准I/O库实现采用了不可重入的方式来操作全局数据结构,从而在多线程或多进程环境下易引发数据不一致的问题。
4、volatile
#include <stdio.h>
#include <signal.h>
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;
}
这段C代码实现了一个简单的信号处理程序,它注册了一个信号处理器函数handler
来处理信号SIGINT(即通过按Ctrl+C触发的信号,编号为2)。当接收到信号时,handler
函数会被调用,打印一条消息并将全局变量flag
的值从0改为1。
在main
函数中,首先通过signal(2, handler)
设置了信号处理器,然后进入了一个无限循环,循环的终止条件是全局变量flag
变为非零。
在未优化编译的情况下,当你按下Ctrl+C时,信号处理函数会被调用,flag
的值被改变,然后循环检测到flag
不再是0,因此退出循环并打印"process quit normal"。
而在优化编译(添加-O2标志)的情况下,虽然信号处理函数仍然正常执行,将flag
的值改为1,但由于编译器优化可能导致变量flag
被存储在CPU寄存器中而不是内存中。
sig:sig.c
gcc -o sig sig.c -O2
.PHONY:clean
clean:
rm -f sig
- 在这种情况下,由于循环检测
flag
值的语句可能不会每次都重新从内存加载最新的flag
值,因此即使flag
已经在信号处理函数中被修改为1,循环也可能依然持续执行,表现为多次按下Ctrl+C后循环仍未退出的现象。
在优化编译环境下,编译器为了提高效率可能会对循环内的
flag
访问进行优化,将其值缓存到 CPU 寄存器中,而不是每次都从内存中读取。这意味着尽管信号处理器已经改变了内存中flag
的值,但循环内的flag
访问可能仍然查看的是 CPU 寄存器中旧的、未更新的值,这就造成了数据一致性问题,导致循环无法按照预期结束。
要解决这个问题,我们需要确保 flag
变量的修改对于所有线程和上下文都是可见的,尤其是对于信号处理函数这种异步操作。为此,我们应该将 flag
定义为 volatile
类型:
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int sig) {
printf("Change flag 0 to 1\n");
flag = 1;
}
int main() {
signal(SIGINT, handler); // 注意:使用SIGINT(2号信号)替换数字2
while (!flag);
printf("Process quit normally\n");
return 0;
}
通过声明 flag
为 volatile
类型,编译器将会保证每次访问 flag
时都直接从内存读取,而非依赖于寄存器中的缓存值,这样就解决了数据一致性问题。在接收到 SIGINT 信号后,handler
函数会修改 flag
的值,而主循环也能及时感知到这个变化,从而正常结束。
5、SIGCHLD信号
在进程管理章节中,我们了解到可以通过wait
和waitpid
函数来处理子进程结束后的资源回收,即清理僵尸进程。
- 其中,父进程可以选择阻塞等待子进程结束,但这将导致父进程无法执行其他任务;
- 另一种方法是父进程采用非阻塞的方式周期性查询子进程是否已结束,但这无疑增加了程序实现的复杂性,要求父进程在执行自身任务的同时还需不断检查子进程状态。
实际上,操作系统为父进程提供了一种更为优雅的方式来监控子进程的终止情况。
- 当子进程结束后,系统会自动向父进程发送一个SIGCHLD信号。
- 默认情况下,父进程对SIGCHLD信号的处理方式是忽略,但父进程可以根据需求自定义SIGCHLD信号的处理函数。
- 这样一来,父进程无需主动去查询子进程的状态,只需专注于自身的业务逻辑,待子进程结束时,系统会通过SIGCHLD信号及时通知父进程。
示例:
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <signal.h>
void sigchld_handler(int s) {
// 自定义SIGCHLD信号处理函数
while(waitpid(-1, NULL, WNOHANG) > 0);
printf("Received SIGCHLD, child process has terminated.\n");
}
int main() {
pid_t pid;
struct sigaction sa;
// 初始化信号处理动作
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 保证系统调用在信号处理后能重启
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
if ((pid = fork()) < 0) { // 创建子进程
perror("fork");
exit(EXIT_FAILURE);
} else if (pid == 0) { // 子进程
exit(2); // 子进程调用exit终止
} else { // 父进程
// 父进程继续执行自身任务,当接收到SIGCHLD信号时,由自定义处理函数进行清理
while(1) {
printf("father proc is doing some thing!\n");
}
}
return 0;
}
在上述代码中,父进程首先设置了SIGCHLD信号的处理函数为sigchld_handler
。当子进程调用exit(2)
终止后,父进程会接收到SIGCHLD信号,然后在sigchld_handler
函数中调用waitpid
回收子进程资源,并打印子进程已终止的信息。这样,父进程既完成了自身的工作,又能及时有效地处理子进程结束的情况。
实际上,考虑到UNIX操作系统的演变历程,除了前述通过自定义SIGCHLD信号处理函数的方法防止僵尸进程产生外,还有一种历史悠久的做法可以规避此类问题。
具体而言,父进程可以调用sigaction
函数,将SIGCHLD信号的处理动作设置为SIG_IGN,意即忽略此信号。如此一来,当父进程通过fork创建的子进程结束时,系统将自动清理子进程资源,而不产生僵尸进程,并且不会向父进程发送通知。尽管系统默认对SIGCHLD信号的忽略与用户通过sigaction
函数明确设置为忽略在大多数情况下并无差异,但在处理SIGCHLD信号时却是个例外。这种方法适用于Linux系统,但在其他UNIX系统上不一定适用。
#include <stdio.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
struct sigaction sa;
// 设置SIGCHLD信号处理动作为忽略
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
// 创建子进程
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
} else if (pid == 0) { // 子进程
printf("Child process (%d) is exiting...\n", getpid());
exit(0);
} else { // 父进程
printf("Parent process (%d) continues its execution...\n", getpid());
// 父进程无需处理SIGCHLD信号,也不必调用wait系列函数
sleep(2); // 模拟父进程执行其他任务
// 此处无需处理子进程的终止状态,子进程已自动清理
printf("Parent process finished.\n");
}
return 0;
}
在此程序中,父进程通过sigaction
将SIGCHLD信号忽略,然后创建子进程,子进程退出后,父进程无需做任何进一步操作,子进程资源会自动释放,不会形成僵尸进程。通过运行此程序并检查系统进程列表,可以验证这一点。