目录
一、信号处理概述:为什么需要“信号”?
二、用户空间与内核空间:进程的“双重人格”
三、内核态与用户态:权限的“安全锁”
四、信号捕捉的内核级实现:层层“安检”
五、sigaction函数:精细控制信号行为
1. 函数原型
2. 关键结构体
3. 示例代码:动态修改信号处理
六、可重入函数
1. 问题场景
2. 问题分析
3. 原因解释
4. 不可重入函数与可重入函数
5. 如何避免重入问题
七、volatile
1. 问题引入
2. 未使用volatile的情况
3. 使用volatile解决问题
4. volatile的作用总结
一、信号处理概述:为什么需要“信号”?
想象你在办公室工作时,突然有人敲门提醒你快递到了。这里的“敲门”就像操作系统发给进程的信号。信号是操作系统通知进程某个事件发生的机制,例如:
-  
Ctrl+C发送 SIGINT 信号终止进程 -  
程序崩溃时内核发送 SIGSEGV 信号
 -  
用户自定义信号处理逻辑(如保存日志)
 
但进程不会立即处理信号,而是在“合适的时候”——比如从内核态切换回用户态时。这背后隐藏着操作系统的核心设计逻辑。
二、用户空间与内核空间:进程的“双重人格”
每个进程的地址空间分为两部分:
| 用户空间 | 内核空间 | 
|---|---|
| 存储进程私有代码和数据 | 存储操作系统全局代码和数据 | 
| 通过用户级页表映射物理内存 | 通过内核级页表映射物理内存 | 
| 每个进程看到的内容不同 | 所有进程看到的内容相同 | 

// 示例:用户空间的变量
int user_data = 100; // 内核空间的代码(进程无权直接访问)
void kernel_code() 
{// 管理硬件资源 
} 
关键点:
-  
用户态代码无法直接访问内核空间(权限不足)
 -  
执行系统调用(如
printf)时,进程会陷入内核,切换到内核态 
三、内核态与用户态:权限的“安全锁”
| 用户态 | 内核态 | |
|---|---|---|
| 权限等级 | 低(普通用户代码) | 高(操作系统代码) | 
| 操作限制 | 无法直接访问硬件 | 可执行任何指令 | 
| 触发场景 | 执行普通代码 | 系统调用、中断、异常 | 
状态切换示例:
printf("Hello");  // 用户态 -> 内核态(执行write系统调用) -> 用户态 
具体步骤:
1、用户态:调用printf
printf是C标准库函数,负责格式化字符串(如将"Hello"转换为字符流)。- 若输出到终端(如屏幕),最终会调用**系统调用
write**将数据写入文件描述符(如标准输出stdout)。 
2、触发系统调用write
系统调用是用户程序请求操作系统服务的唯一入口。
write的函数签名:
ssize_t write(int fd, const void *buf, size_t count); 
其中fd=1表示标准输出,buf指向数据缓冲区,count为数据长度。
3、从用户态陷入内核态
- CPU执行特殊的陷入指令(如
syscall或int 0x80),触发软中断。 - 硬件自动切换特权级:用户态(ring 3)→ 内核态(ring 0)。
 - 跳转到内核中预定义的系统调用处理函数(如
sys_write)。 
4、内核态:执行sys_write
- 操作系统验证参数合法性(如
fd是否有效)。 - 将用户空间的数据(
"Hello")从缓冲区复制到内核空间(防止用户篡改)。 - 调用设备驱动,将数据发送到终端(如控制台、SSH会话)。
 - 记录返回结果(成功写入的字节数或错误码)。
 
5、返回用户态
- 内核恢复用户程序的寄存器状态和堆栈。
 - CPU特权级切换回用户态(ring 3)。
 - 用户程序继续执行
printf之后的代码。 
🌴 为什么需要切换特权态?
用户态的限制:
用户程序无法直接访问硬件(如磁盘、网卡)或修改关键数据结构(如进程表)。
例:若允许用户程序直接写磁盘,恶意程序可能覆盖系统文件。内核态的权限:
操作系统代码拥有最高权限,可安全管理硬件和资源。
通过系统调用“代理”用户程序的请求,确保所有操作受控。
四、信号捕捉的内核级实现:层层“安检”
在计算机系统里,程序运行时可能会遇到一些特殊情况,比如用户按下某些按键或者系统出现了问题,这时候就需要程序能够及时做出反应。这种反应机制在Linux系统中是通过“信号”来实现的。信号就像是一个信使,负责把发生的事件告诉程序。
现在,假设一个程序正在运行它的主函数(main函数),就好比一个人正在按照计划做一件大事。突然,某个特定的事件发生了,比如用户按下了一个特殊的按键组合(这会触发SIGQUIT信号)。这时候,系统会暂时中断这个人的工作,切换到一个专门处理这种情况的模式,也就是“内核态”,由操作系统来处理这个事件。
操作系统在处理完这个事件后,准备回到原来的程序继续工作之前,会检查有没有需要特别处理的信号。如果发现有SIGQUIT信号,而且这个程序之前已经告诉过操作系统,当这个信号出现时要按照它自己定义的方式来处理(也就是注册了一个信号处理函数sighandler),那么操作系统就会安排一个特殊的操作。
这个操作就是:不是直接回到原来的主函数继续做之前的事情,而是先去执行那个专门定义的处理函数sighandler。这就好比在你做一件大事的时候,突然有紧急情况需要你先去处理一下,处理完了再回来继续做原来的事。
需要注意的是,这个处理函数sighandler和原来的主函数(main函数)是两个完全独立的任务,它们就像两条平行的路,没有直接的调用关系。sighandler有自己的工作空间(不同的堆栈空间)来完成它的任务。
当处理函数sighandler完成自己的任务后,它会触发一个特殊的指令(sigreturn系统调用),再次回到操作系统那里。操作系统会检查是否还有其他的紧急情况需要处理。如果没有,就会回到原来的主函数,恢复之前的状态,继续完成未做完的事情。

当进程从内核态返回用户态时,会检查未决信号集(pending):
-  
检查信号状态
-  
若信号未被阻塞(block),且处理动作为
默认或忽略:
→ 立即处理(如终止进程)并清除pending标志 -  
若处理动作为
自定义:
→ 先返回用户态执行处理函数,再通过sigreturn回到内核 
 -  
 -  
执行自定义处理函数的关键步骤
-  
内核不信任用户代码:必须返回用户态执行处理函数
 -  
处理函数与主流程独立(不同堆栈,无调用关系)
 
 -  
 
五、sigaction函数:精细控制信号行为
1. 函数原型
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact); 
参数说明:
signo :指定信号的编号(如SIGINT)。
act :新的处理动作
oldact :保存旧的处理动作
2. 关键结构体
结构体 sigaction 的定义如下:
struct sigaction 
{void     (*sa_handler)(int);          // 信号处理函数sigset_t   sa_mask;                   // 额外屏蔽的信号int        sa_flags;                  // 控制选项(通常设为0)// 其他字段(如sa_sigaction)暂不讨论
}; 
3. 示例代码:动态修改信号处理
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>struct sigaction act, oact;// 自定义信号处理函数
void handler(int signo)
{printf("捕获信号: %d\n", signo);// 恢复为默认处理方式(仅第一次捕获时自定义)sigaction(SIGINT, &oact, NULL);
}int main()
{memset(&act, 0, sizeof(act));memset(&oact, 0, sizeof(oact));act.sa_handler = handler;  // 设置自定义处理函数act.sa_flags = 0;          // 无特殊标志sigemptyset(&act.sa_mask); // 不额外屏蔽其他信号// 注册SIGINT信号(Ctrl+C触发)sigaction(SIGINT, &act, &oact);while (1){printf("程序运行中...\n");sleep(1);}return 0;
}
 
运行效果:
-  
第一次按下
Ctrl+C→ 打印“捕获信号: 2” -  
再次按下
Ctrl+C→ 进程终止(已恢复默认行为) 

六、可重入函数


1. 问题场景
假设我们有一个简单的链表结构,定义了两个节点 node1 和 node2,以及一个头指针 head。在 main 函数中,我们调用 insert 函数将 node1 插入到链表中。insert 函数的实现分为两步:首先将新节点的 next 指针指向当前头节点,然后更新头指针为新节点。
node_t node1, node2, *head;void insert(node_t *p) {p->next = head; // 第一步:将新节点的 next 指针指向当前头节点head = p;       // 第二步:更新头指针为新节点
}int main() {// ... 其他代码 ...insert(&node1); // 在 main 函数中插入 node1// ... 其他代码 ...
} 
在插入 node1 的过程中,假设刚执行完第一步(p->next = head),此时发生了硬件中断,导致进程切换到内核态。在内核态处理完中断后,检查到有信号待处理,于是切换到信号处理函数 sighandler。sighandler 同样调用 insert 函数,试图将 node2 插入到同一个链表中。
void sighandler(int signo) {// ... 其他代码 ...insert(&node2); // 在信号处理函数中插入 node2// ... 其他代码 ...
} 
当 sighandler 完成插入 node2 的操作并返回内核态后,再次回到用户态,继续执行 main 函数中被中断的 insert 函数的第二步(head = p)。
2. 问题分析
理想情况下,我们希望 main 函数和 sighandler 分别将 node1 和 node2 插入到链表中,最终链表包含两个节点。然而,实际情况却并非如此。
-  
main 函数插入 node1 的第一步 :将 node1 的 next 指针指向当前头节点(初始时 head 为 NULL),此时 node1->next = NULL。
 -  
中断发生,切换到内核态 :main 函数的 insert 操作被中断,此时 head 还未更新为 node1。
 -  
sighandler 插入 node2 :在信号处理函数中,执行 insert(&node2)。此时 head 仍为 NULL,所以 node2->next = NULL,然后 head 被更新为 node2。
 -  
返回 main 函数继续执行 :执行 insert 函数的第二步,将 head 更新为 node1。
 
最终,链表的头指针 head 指向 node1,而 node1 的 next 指针为 NULL。node2 被插入后又被覆盖,实际上没有真正加入链表。
3. 原因解释
这个问题的根源在于 insert 函数被不同的控制流程(main 函数和 sighandler)调用,且在第一次调用还未完成时就再次进入该函数。这种现象称为“重入”(Reentrant)。insert 函数访问了一个全局链表 head,由于全局变量在多个控制流程之间共享,导致数据不一致。
4. 不可重入函数与可重入函数
-  
不可重入函数 :如果一个函数在被调用过程中,其内部操作依赖于全局变量或共享资源,并且在函数执行过程中这些资源可能被其他调用者修改,那么这个函数就是不可重入的。像上面的 insert 函数,因为它操作了全局链表 head,所以在重入情况下容易出错。
 -  
可重入函数 :如果一个函数只访问自己的局部变量或参数,不依赖于全局变量或共享资源,那么它就是可重入的。可重入函数在不同控制流程中被调用时,不会相互干扰。
 
5. 如何避免重入问题
-  
避免使用全局变量 :尽量使用局部变量,或者通过参数传递必要的数据。
 -  
使用互斥机制 :在多线程或信号处理场景中,使用互斥锁(如 mutex)来保护共享资源的访问。
 -  
设计可重入函数 :确保函数只依赖于参数和局部变量,不依赖于外部环境。
 
七、volatile
        在C语言中,volatile 是一个经常被提及但又容易被误解的关键字。今天,我们通过一个具体的信号处理例子,来深入理解 volatile 的作用。
1. 问题引入
考虑以下代码:
#include <stdio.h>
#include <signal.h>int flag = 0;void handler(int sig) {printf("change flag 0 to 1\n");flag = 1;
}int main() {signal(2, handler);while (!flag);printf("process quit normal\n");return 0;
} 
        该程序的功能是:在接收到 SIGINT 信号(如用户按下 Ctrl+C)时,执行自定义信号处理函数 handler,将全局变量 flag 设置为 1,从而退出 while 循环,程序正常结束。

2. 未使用volatile的情况
        在未使用 volatile 修饰 flag 的情况下,编译器可能会对代码进行优化。例如,当使用 -O2(大写字母O) 优化选项编译时,编译器可能会认为 flag 的值在 while 循环中不会被改变(因为从代码的静态分析来看,没有明显的修改操作),于是将 flag 的值缓存到 CPU 寄存器中,而不是每次都从内存中读取。
        这就会导致一个问题:当信号处理函数 handler 修改了 flag 的值时,while 循环中的条件判断仍然使用寄存器中的旧值,无法及时检测到 flag 的变化,程序无法正常退出。这种现象被称为“数据不一致性”或“内存可见性”问题。

3. 使用volatile解决问题
为了解决上述问题,我们需要使用 volatile 关键字修饰 flag 变量:
#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(2, handler);while (!flag);printf("process quit normal\n");return 0;
} 
   volatile 告诉编译器,该变量的值可能会被程序之外的其他因素(如信号处理函数、硬件中断等)改变,因此编译器在优化时不会假设该变量的值不变。每次访问 volatile 修饰的变量时,编译器都会生成代码从内存中重新读取该变量的值,而不是使用寄存器中的缓存值。
        这样,在信号处理函数修改了 flag 的值后,while 循环中的条件判断能够及时检测到变化,程序可以正常退出。

4. volatile的作用总结
volatile 的主要作用是保持内存的可见性,确保程序能够正确地读取和写入变量的最新值。在以下场景中,使用 volatile 是必要的:
-  
信号处理 :当变量可能被信号处理函数修改时,需要使用
volatile修饰,以确保主程序能够及时检测到变量的变化。 -  
多线程编程 :在多线程环境中,当变量可能被其他线程修改时,
volatile可以防止编译器优化导致的内存可见性问题。不过,需要注意的是,volatile并不能完全替代互斥锁等同步机制,因为它不能保证操作的原子性。 -  
硬件寄存器访问 :当程序需要直接访问硬件寄存器时,这些寄存器的值可能会被硬件异步修改,因此需要使用
volatile修饰相关的指针或变量。