发布时间:2026/7/17 19:39:03
现代C:标准库:信号与操作系统软中断有什么关系? 相信你在第一次学习编程时也写出过与下面这段类似的代码#include stdio.h int main(void) { int x 10; int y 0; printf(%d, x / y); return 0; }可以很明显地看到这里在代码中我们通过 printf 函数打印出了除法表达式 x / y 的计算结果。但定睛一看你就会发现在这个表达式中除数变量 y 对应的值为 0。因此程序在运行时便会发生“除零异常”。当然你可能会说这只是我们故意构造的一段带有问题的程序。但在真实场景中四则运算作为最基本的数学运算被广泛应用在各类大型 C 项目中。而当参与运算的具体操作数可以被来自于用户的输入影响且程序实现并没有进行完备的边界条件检查时类似的运行时异常难免会发生。除此之外程序在运行过程中都会直接或间接地与操作系统甚至底层硬件进行交互。因此你可能会遇到下面这几种情况程序运行时由于访问了非法的内存资源导致出现 “Segmentation Fault” 等异常用户想要结束程序急切地按下了 CtrlC / CommandC 键计算机底层硬件系统出现故障导致无法实现某项特定功能……在这些情况下应用程序应该如何响应其实我上面提到的所有问题都可以通过“信号Signal”来解决。今天我们就来看看什么是信号以及如何在 C 代码中通过标准库提供的相关接口与信号进行交互。什么是信号从广义上来讲信号实际上是操作系统提供的一种可以用来传递特定消息的机制。通过这种方式操作系统可以将程序运行过程中发生的各类特殊情况转发给程序并按照其指定的逻辑进行处理。每一种具体的信号都有它对应的名称这些名称以 “SIG” 作为前缀。比如当程序访问非法内存时便会产生名为 SIGSEGV 的信号。而除零异常则对应着名为 SIGFPE 的信号。信号的产生是一个随机的过程。毕竟异常的出现并不是人们预期中会发生的事情。而这也就意味着程序无法通过简单轮询某个全局变量值的方式来检测信号是否出现然后再进行相应处理。相对地程序需要提前“告诉”操作系统当某个信号到来时应该按怎样的既定方式进行处理。而这就是典型的异步事件处理方式。如果我们再进一步深入到操作系统内核你会发现信号其实是一种“软中断”。信号与软件中断在计算机中中断是指 CPU 需要临时暂停当前正在进行的活动保存相应状态然后转而去执行某个具体的中断服务程序后面我将其简称为 ISR以响应外部环境变化的过程。而在 ISR 执行完毕后在大多数情况下CPU 将继续执行之前暂停的任务。通常来说中断的触发分为两种形式即硬件中断与软件中断也被简称为硬中断与软中断。其中硬件中断是指与计算机硬件特定状态相关的中断过程该过程直接由硬件触发。比如当磁盘完成了某次由用户程序指定的 IO 操作后便会通过硬件中断的方式来通知 CPU 这一消息并让 CPU 进行后续的处理。在这个过程中便存在着 CPU 执行流程从应用程序的某行机器指令到磁盘中断处理程序代码的转移。相对地软件中断则是指由计算机软件通过 int 等机器指令引起的 CPU 执行流程的临时转移过程。而我在这门课中多次提到过的“系统调用”便是其中的一种。在早期的 i386 架构中用户程序需要通过指令 int 0x80 才能够借由软件中断从用户态进入到内核态并使用内核提供的系统调用函数。同样地在这个过程中也存在着 CPU 从用户代码到内核代码的执行流程转移。回过头我们再来看看为什么说信号也是一种软中断呢其实很好理解。当特定事件比如“除零”这个操作发生时操作系统会将对应的信号值即对应的 SIGFPE发送给相关程序。而通常情况下如果应用程序并未设置自定义的信号处理程序则操作系统将会执行默认信号处理程序中的逻辑。对于大多数信号来说这个逻辑便是直接终止当前程序的运行。同样地整个信号处理的过程中也存在着 CPU 从用户程序到信号处理程序的执行流程转移。讲到这里我相信你已经基本理解了信号的概念。那么在 C 程序中应该如何与信号进行交互呢在 C 代码中与信号交互C 语言自 C90 标准开始便为我们提供了名为 signal.h 的头文件以用于支持信号的相关功能。同样地还是先来看一段代码#include stdio.h #include signal.h #include stdlib.h void sigHandler(int sig) { printf(Signal %d catched!\n, sig); exit(sig); } int main(void) { signal(SIGFPE, sigHandler); int x 10; int y 0; printf(%d, x / y); }这里我在本讲开头处代码的基础上进行了适当改进。在这段代码中我们首先在 main 函数的开头处使用标准库提供的 signal 函数为当前程序设置了一段针对信号 SIGFPE 的自定义处理逻辑。其中该函数的第一个参数为具体的某个信号。C 标准库一共提供了 6 种不同类型的信号它们均以宏常量的形式被定义。我将它们的具体信息整理在了下面这个表格中供你参考。需要注意的是该参数也支持使用某些与具体操作系统实现相关的其他信号值但需要确保程序运行所在的系统支持这些非 C 标准信号。第二个参数为具体的信号处理函数它的原型需要满足 void (*handler) (int) 这样一种形式即接收一个整型的信号值但不返回任何内容void。随着程序的运行当位于代码第 12 行的除法运算发生除零异常时操作系统便会将信号 SIGFPE 发送给当前进程并根据之前通过 signal 函数注册的信号处理信息调用用户自定义或默认的信号处理函数。需要注意的是信号处理函数的调用过程是由来自操作系统内核的软中断触发的因此这个过程与我们平时见到的通过 call 指令进行的函数调用过程并不相同。总的来看上述应用程序和操作系统围绕信号的基本交互逻辑可以被粗略地描述为以下几个步骤CPU 执行除法指令 idiv发现除零异常CPU 暂停当前程序运行并将控制权转交给操作系统操作系统将信号 SIGFPE 发送给出错的程序操作系统根据情况执行相应的信号处理程序函数信号处理程序执行完毕后若程序未退出则将程序执行恢复到之前的中断点即 CPU 会重新执行 idiv 指令。当然除了上面这种自定义信号处理函数的方式外C 标准库还为我们提供了两种基本的信号处理方式。同样地它们也以宏的形式被定义可以直接作为 signal 函数的第二个参数来使用。我将它们整理在了下面的表格中供你参考其中第一种 SIG_DFL 表示对信号进行默认处理操作系统会按照信号既定的默认处理方式来处理程序SIG_IGN 则会忽略程序收到的信号。但需要注意的是并非所有类型的信号都可以被忽略比如某些难以恢复的软硬件异常对应的信号或是 Linux 上专有的 SIGKILL 和 SIGSTOP 信号等。关于它们的一个简单使用示例如下所示#include signal.h #include stdio.h int main(void) { signal(SIGTERM, SIG_IGN); // 忽略信号 SIGTERM raise(SIGTERM); // 向当前程序发送 SIGTERM 信号 printf(Reachable!\n); // Reachable code! return 0; }这里在代码的第 5 行我们还使用了由标准库提供的 raise 函数。该函数可以让我们在代码中直接向当前程序发送指定的信号。在上面的代码中我们为信号 SIGTERM 设置了 SIG_IGN 作为它的处理方式因此当执行到 raise 函数后虽然它向程序发送了 SIGTERM 信号但程序却不会被立即终止。相反它将继续执行第 6 行的 printf 函数然后以正常方式退出。可以看到操作系统对信号处理函数的调用可能会发生在整个应用程序运行过程中的任意时刻。而在某些情况下这可能会给程序的实际执行带来影响。接下来让我们进一步看看这个过程是如何影响程序运行的。可重入函数试想这样一种情况某一时刻CPU 还在正常执行 main 函数内函数 A 的代码而由于某些外部原因程序在此刻收到了某个信号操作系统便暂停当前程序运行并将执行流程调度至对应的信号处理函数。而在这个信号处理函数中函数 A 又被再次调用。当信号处理完毕后执行流返回了之前的“中断点”并继续处理 main 函数内函数 A 中还未被执行到的指令。那么在这种情况下信号处理函数中对函数 A 的再次调用会不会影响之前还未调用完成的函数 A 的执行状态呢让我们来看下面这段“模拟”代码#include stdio.h #include signal.h #include string.h #define BUF_SIZE 16 // 全局静态数组大小 #define FORMAT_NUM_(N) $#N #define FORMAT_NUM(N) FORMAT_NUM_(N) #define RAISE_EXP_false_ASM() // 调用 raise 函数向当前程序发送信号 #define RAISE_EXP_true_ASM() \ movl $4, %%edi\n\t \ call raise\n\t // 内联汇编实现 #define INLINE_ASM(ID, HAS_EXP) \ mov %0, %%r8\n\t /* 复制传入的字符串数据到全局静态数组 */ \ testq %%rsi, %%rsi\n\t \ je .L1 #ID \n\t \ xorl %%eax, %%eax\n\t \ .L3 #ID :\n\t \ movzbl (%%rdi,%%rax), %%ecx\n\t \ movb %%cl, (%%r8,%%rax)\n\t \ addq $1, %%rax\n\t \ cmpq %%rsi, %%rax\n\t \ jne .L3 #ID \n\t \ .L1 #ID :\n\t \ RAISE_EXP_##HAS_EXP##_ASM() /* 选择性调用 raise 函数 */ \ mov $1, %%rax\n\t \ mov $1, %%rdi\n\t \ mov %0, %%rsi\n\t \ mov FORMAT_NUM(BUF_SIZE) , %%rdx\n\t \ syscall\n\t /* 触发系统调用打印内容 */ static char buf[BUF_SIZE]; // 用于保存字符的全局静态数组 void print_with_exp(const char* str, size_t len) { // 会引起信号中断的版本 asm(INLINE_ASM(a, true) :: g (buf)); } void print_normal(const char* str, size_t len) { // 正常的版本 asm(INLINE_ASM(b, false) :: g (buf)); } void sigHandler(int sig) { const char* str Hello; print_normal(str, strlen(str)); } int main(void) { signal(SIGILL, sigHandler); const char* str , world!; print_with_exp(str, strlen(str)); return 0; }第一眼看上去这段代码可能稍显复杂但它的执行逻辑实际上十分简单。在 main 函数中我们首先通过 signal 函数为信号 SIGILL 注册了一个自定义信号处理函数。接下来我们调用了名为 print_with_exp 的函数并传入了内容为 “Hello” 的字符串 str以及相应的长度信息。函数在调用后会打印出这个字符串的完整内容。这里借助函数 print_with_exp我们将会模拟上面提到的位于 main 函数中的函数 A 的执行流程。但与真实案例稍有不同的是为了模拟操作系统向程序随机发送信号的过程这里我们选择直接在函数 print_with_exp 中通过调用 raise 函数的方式来做到这一点。当然你可能会说“这并不随机”但在现实案例中类似的情况却可能发生。为了从更加细微的角度直观地观察信号处理函数的调用对“函数重入”的影响这里我们选择直接用内联汇编的方式来实现 print_with_exp 函数。相应地名为 print_normal 的函数其功能与 print_with_exp 相同只是在这个函数中我们不会调用 raise 函数。你可以简单地将这两个函数视为功能完全一致的 “print” 函数。为了方便组织代码以将相同功能的实现部分进行抽离我们通过宏的方式“封装”了这两个函数的内联汇编内容。位于代码第 13 行的宏函数 INLINE_ASM将会根据传入参数的不同来选择性地生成不同的汇编代码。在这两个函数的汇编实现中我们首先会将由第一个参数传入的字符串内容根据第二个参数传入的长度信息逐字符复制到程序中的全局静态数组 buf 中。然后通过相应的系统调用再将该数组中的内容输出到终端。只是对于 print_with_exp 函数来说在此之前我们会通过调用 raise 函数将信号 SIGILL 发送给当前程序。你可以参考代码中的注释来了解这部分实现。到这里按照我们的理解程序正常运行后应该会按顺序进行两次输出对应两次系统调用并完整打印字符串 “Hello, world!”。其中前半段由首先调用完成的信号处理函数 sigHandler 中的 print_normal 函数输出后半段由 main 函数内的 print_with_exp 函数输出。但真实情况却并非如此。程序运行后你会发现实际的输出内容是 “Hellold!Hellold!”。这是为什么呢仔细查看代码可以看到函数 print_with_exp 在调用时存入全局静态数组 buf 中的字符串内容 “, world!”会被信号处理代码中 print_normal 函数调用时存入的字符串 “Hello”覆盖了一部分。而当执行流再次退回到 print_with_exp 函数中继续执行时由于此时数组 buf 中的内容已经发生改变因此便打印出了错误的结果。你可以参考下图来理解上述程序的执行流程。对于这种一旦在执行中途被临时打断在此刻再次调用就可能影响前一次调用正常执行的函数我们一般称之为“不可重入函数”。相反不受中途打断和重新调用影响的函数便被称为“可重入函数”。而在信号处理程序中“尽量使用可重入函数”便是我们需要遵守的首要规则。虽然大多数情况下在信号处理代码中对它们的调用都可以正常工作但从程序健壮性的角度来看却并不推荐这样做。实际上包括 printf、exit以及 malloc 在内的众多常用 C 标准库函数它们都并非可重入函数。因此我们能够在信号处理代码中进行的操作也并不多。而为了进一步简化信号处理模型C 标准库为我们提供了一个名为 sig_atomic_t 的整数类型该类型可以保证即使存在可能由于中断导致的执行流程转移对该类型变量的读写过程也都是原子的注意不要与并发编程中的原子类型混淆标准中仅规定了 sig_atomic_t 类型在异步信号处理场景下的原子性。在这种情况下对信号的处理过程可以变为如下代码所示的模式#include signal.h #include stdio.h #include unistd.h #include stdlib.h volatile sig_atomic_t sig 0; void sigHandler(int signal) { sig signal; } int main(void) { signal(SIGINT, sigHandler); int counter 0; // 计数器变量 while(1) { switch (sig) { // 信号筛选与处理 case SIGINT: { printf(SignalValue: %d, sig); /* 异常处理的主要逻辑 */ exit(SIGINT); } } if (counter 5) raise(SIGINT); printf(Counter: %d\n, counter); sleep(1); } return 0; }这里在 main 函数中我们使用死循环来表示一个常驻程序比如 HTTP 服务器的执行状态。在代码的第 10 行通过 signal 函数我们为信号 SIGINT 注册了相应的处理函数 sigHandler。在该函数内部sig_atomic_t 类型变量 sig 的值被更新为收到的信号值。回到主函数在代码的第 13~19 行我们在主循环每次迭代的开头处通过检测变量 sig 的值来判断当前程序是否收到信号并作出相应处理。而在第 18 行当 counter 变量的值被递增到 5 时raise 函数向当前程序发送了信号 SIGINT从而模拟了程序收到信号发生软中断时的场景。这是一种常见的信号处理模式并非适合所有场景。但无论如何你都需要注意对不可重入函数在信号处理程序中的不当使用可能会给程序的运行埋下问题。多线程应用的信号处理学习到这里对于单线程应用我们已经可以很好地在程序中与信号进行交互。但遗憾的是C 语言并没有对并发编程中的信号处理做任何规范上的约束和建议。这意味着在多线程应用中使用 signal 和 raise 函数可能会产生未定义行为。这些函数会如何影响程序执行则取决于不同 C 标准库在具体操作系统上的实现。所以如果你对程序的可移植性及健壮性有要求那么请不要在多线程应用中使用信号处理。作为替代你可以考虑通过宏的方式来区分程序运行所在的不同操作系统然后分别使用 POSIX、Win32 等 API 来以不同方式实现多线程模式下的信号处理过程。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。这一讲我主要介绍了 C 语言中与信号处理相关的内容包括信号的基本概念如何在 C 程序中使用信号处理函数以及信号中断可能对程序运行带来的影响等。借助信号操作系统可以将程序运行过程中发生的特殊情况及时通知给程序并按照其要求的特定方式或系统的默认方式进行处理。而信号本质上是一种软件中断即操作系统通过特殊机器指令引起的 CPU 执行流程的临时转移。在 C 语言中可以通过引入标准库头文件 signal.h 的方式来使用相关的信号处理接口。其中通过 signal 函数我们可以为当前程序设置针对某个信号的自定义处理程序而 raise 函数则为我们提供了可以向当前程序发送指定信号的能力。C 标准中一共规定了 6 种不同类型的信号以及两种默认的信号处理方式它们均以宏的形式被定义。信号的中断过程可以在任意时刻发生因此为了保证程序的健壮性我们只能在信号处理程序中使用“可重入函数”。这类函数的重新执行不会对前一个还未执行完成的函数的最终执行结果产生影响。为了简化信号的处理方式C 标准还为我们提供了名为 sig_atomic_t 的整数类型。这也就意味着标准可以保证即使在发生信号中断的情况下对该类型变量的读写也都是原子性的。最后C 标准并未对如何在多线程应用中进行信号处理作过多说明。因此在并发编程场景中对 signal 和 raise 等函数的不当使用方式可能会产生未定义行为。

相关新闻

2026/7/17 19:34:02

WSL2与Ubuntu24.04开发环境搭建与优化指南

1. WSL2与Ubuntu24.04环境搭建全攻略最近在Windows系统上折腾开发环境,发现WSL2(Windows Subsystem for Linux 2)配合Ubuntu24.04 LTS的组合特别香。相比传统虚拟机,WSL2性能更好、资源占用更低,还能直接访问Windows文…

2026/7/17 19:34:02

AI Agent 面试题 650:如何实现RAG系统的检索延迟优化?

🔥 AI Agent 面试题 650:如何实现RAG系统的检索延迟优化?摘要:本文深入解析了「如何实现RAG系统的检索延迟优化?」这一 AI Agent 领域的核心面试题。文章从 检索排序优化 的基本概念出发,系统性地剖析了 延…

2026/7/17 20:44:53

05-08-YooAsset源码-Unity缓存系统与内存管理

源码-资源缓存机制 篇章:05-源码深度-运行时核心模块 阅读时间:约 25 分钟 前置知识:了解引用计数机制 一、引言 本章将深入解析 YooAsset 资源缓存机制的源码实现。资源缓存是 YooAsset 资源管理的重要组成部分,通过缓存机制可以…

2026/7/17 20:44:53

Creo二次开发实战:MCADExTools安装配置与阶梯剖视图自动化

1. 项目概述:MCADExTools 是什么,为什么值得花时间装它MCADExTools 这个名字在 Creo 用户圈里不算陌生,但真正搞清楚它到底能干什么、值不值得为它折腾安装流程的人,其实不多。我第一次听说它,是在帮一家做精密齿轮箱的…

2026/7/17 20:44:53

SolidWorks_钣金设计19_钣金参数化设计

钣金参数化设计:通过方程式和全局变量驱动尺寸实现快速改型 摘要 在现代产品开发中,钣金件因其轻量化、高强度和低成本等优势,广泛应用于电子、汽车、航空航天等领域。然而,传统钣金设计往往面临改型频繁、重复劳动多的问题。本文…

2026/7/17 5:59:06

3步解锁音乐自由:ncmdumpGUI终极NCM文件解密转换指南

3步解锁音乐自由:ncmdumpGUI终极NCM文件解密转换指南 【免费下载链接】ncmdumpGUI C#版本网易云音乐ncm文件格式转换,Windows图形界面版本 项目地址: https://gitcode.com/gh_mirrors/nc/ncmdumpGUI 你是否曾在网易云音乐下载了心爱的歌曲&#…

2026/7/17 1:21:45

CANoe 19 SP3 配置 GB/T 27930-2023 A类系统:3步搭建BMS仿真测试环境

CANoe 19 SP3 配置 GB/T 27930-2023 A类系统:3步搭建BMS仿真测试环境随着新能源汽车行业的快速发展,充电通信协议的标准化和测试验证变得尤为重要。GB/T 27930-2023作为中国智能充电协议的最新版本,对充电机与电动汽车之间的通信提出了更严格…

2026/7/17 7:39:19

3步搞定RTL8852BE驱动:从零开始配置Wi-Fi 6网卡

3步搞定RTL8852BE驱动:从零开始配置Wi-Fi 6网卡 【免费下载链接】rtl8852be Realtek Linux WLAN Driver for RTL8852BE 项目地址: https://gitcode.com/gh_mirrors/rt/rtl8852be 还在为Linux系统无法识别RTL8852BE Wi-Fi 6网卡而烦恼吗?&#x1f…

2026/7/17 0:04:23

BiSheng JDK-build性能调优:构建速度提升30%的优化策略

BiSheng JDK-build性能调优:构建速度提升30%的优化策略 【免费下载链接】bishengjdk-build BiSheng JDK build and test scripts - common across all releases/versions 项目地址: https://gitcode.com/openeuler/bishengjdk-build 前往项目官网免费下载&am…

2026/7/17 14:59:44

3个高效策略:快速掌握Axure中文界面配置

3个高效策略:快速掌握Axure中文界面配置 【免费下载链接】axure-cn Chinese language file for Axure RP. Axure RP 简体中文语言包。支持 Axure 11、10、9。不定期更新。 项目地址: https://gitcode.com/gh_mirrors/ax/axure-cn 还在为Axure RP的英文界面感…