目录
三、处理中断处理代码之前还是之后切换CPU状态? (了解即可)
十、信号机制总结与思考(重要!!!后面学习一定要回头看这个!!!)
一、操作系统如何得知键盘有数据(了解)
这个流程图展示了从键盘按键到操作系统处理键盘数据的完整过程。
1、键盘按下(第一步)
当用户按下键盘上的某个键时,键盘控制器会检测到这一物理动作
键盘控制器将按键信息转换为特定的扫描码(scancode)
这个扫描码代表了被按下键的物理位置信息
2、向CPU发送硬件中断(第二步)
键盘控制器通过中断请求线(IRQ)向CPU发送硬件中断信号
在x86架构中,键盘通常使用IRQ1
这个中断信号是一个电信号,告诉CPU有重要事件需要立即处理
3、CPU识别中断(第三步)
CPU的特定针脚接收到高电压的中断信号
CPU会暂停当前正在执行的任务
CPU通过查询中断向量表确定这是来自键盘的中断
CPU保存当前上下文(寄存器状态等)以便之后恢复
4、CPU执行中断处理程序(第四步)
CPU跳转到操作系统预设的键盘中断处理代码
这个处理程序是操作系统内核的一部分
处理程序从键盘控制器读取扫描码
处理程序将扫描码转换为ASCII码或其他格式
5、操作系统处理数据(第五步)
操作系统将转换后的键盘数据存储到内存缓冲区
操作系统可能会唤醒等待键盘输入的进程
操作系统恢复之前被中断的任务上下文
被中断的程序可以从缓冲区读取键盘输入
关键概念
硬件中断:外设通知CPU有事件需要处理的机制,比轮询更高效
中断向量表:CPU用来确定不同中断对应处理程序的查找表
上下文切换:CPU保存和恢复任务状态的过程
设备驱动程序:操作系统与硬件设备交互的专用代码
这种中断驱动的I/O方式使得CPU不必持续轮询外设状态,大大提高了系统效率。
信号产生的五种来源
键盘输入(如Ctrl+C触发SIGINT)
系统调用(如kill()、sigqueue()等)
系统命令(如终端执行kill命令)
硬件异常(如除零错误触发SIGFPE)
软件条件(如定时器到期触发SIGALRM)
二、信号机制核心概念
1、信号本质:
信号是底层的操作系统发送和处理的!!!
进程间通信的异步通知机制(软中断)
信号记录在task_struct结构的pending位图中(负责记录哪一个信号是否存在)(后面会详细讲解)
发送信号即修改目标进程的信号位图(修改内核数据结构)
2、信号处理特点:
信号产生与处理是异步的
每个信号对应位图中的一个二进制位
9号信号(SIGKILL)和19号信号(SIGSTOP)不可被捕获或忽略
信号编号 |
名称 |
触发方式 |
默认动作 |
---|---|---|---|
2 |
SIGINT |
Ctrl+C |
终止进程 |
3 |
SIGQUIT |
Ctrl+\ |
核心转储 |
9 |
SIGKILL |
kill -9 |
强制终止 |
15 |
SIGTERM |
kill默认信号 |
优雅终止 |
20 |
SIGTSTP |
Ctrl+Z |
暂停进程 |
3、信号处理
信号屏蔽:
通过sigprocmask()临时阻塞指定信号
被屏蔽的信号会进入blocked位图
系统调用:
int kill(pid_t pid, int sig); // 向指定进程发送信号 int raise(int sig); // 向自身发送信号
信号处理函数:
typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
4、底层机制(后面会讲解)
位图操作:当CPU检测到pending位图中有信号时:
检查该信号是否被阻塞(blocked位图)
若未被阻塞则查找信号处理函数
执行完处理函数后清除pending位
信号队列:
实时信号(32-64)支持排队
标准信号(1-31)不排队,多次发送视为一次
三、处理中断处理代码之前还是之后切换CPU状态? (了解即可)
在处理中断时,CPU状态的切换时机取决于具体的架构设计和中断处理流程,但通常遵循以下原则:
1、中断触发时(进入中断前)
硬件自动保存部分状态:CPU响应中断后,硬件会自动完成以下操作(具体行为因架构而异):
将当前程序计数器(PC)、处理器状态字(如PSW/EFLAGS)等关键寄存器压栈或保存到特定位置(如中断栈或内核栈)。
切换到特权模式(如从用户态切换到内核态),以确保中断处理代码有足够的权限执行。
可能关闭本地中断(防止嵌套中断干扰)(也就是保持原子性),但并非所有架构都这样做。
此时的状态切换目的:确保中断处理程序能安全运行,同时保留被中断任务的上下文以便后续恢复。
2、中断处理代码中(软件处理阶段)
手动保存剩余上下文:在中断处理代码的开头(通常由汇编编写的“中断入口”部分),软件需要:
手动保存硬件未自动保存的寄存器(如通用寄存器、浮点寄存器等)。
进一步调整栈帧或状态(如切换到独立的中断栈)。
可能的模式切换:某些场景下,中断处理程序可能需要临时切换CPU状态(例如从IRQ模式切换到SVC模式,常见于ARM架构),以便使用更丰富的资源或处理更复杂的逻辑。
3、中断返回前(退出中断时)
恢复上下文并切换回原状态:在中断处理完成后,软件需要:
手动恢复之前保存的寄存器。
执行特定的中断返回指令(如x86的
IRET
或ARM的RFI
),该指令会:自动恢复硬件保存的状态(如PC、PSW)。
切换回中断前的CPU模式(如从内核态返回到用户态)。
关键总结
切换CPU状态(如特权级)的时机:
进入中断时由硬件自动切换(到内核态/特权态)。
退出中断时由硬件自动恢复(通过专用指令返回到原状态)。
软件的角色:
补充保存/恢复硬件未处理的上下文。
若需临时调整状态(如切换运行模式),需在中断处理代码中显式操作。
不同架构的差异
x86:
硬件自动保存
CS:EIP
和EFLAGS
,并切换到内核态。IRET
指令恢复状态并返回。
ARM:
不同异常模式(如IRQ、SVC)有独立寄存器,需手动切换模式并保存上下文。
RFI
或等效指令恢复状态。
始终参考具体架构的编程手册以确保正确性。
四、信号与硬件中断的类比理解
1、信号的本质与起源
信号(Signal)是操作系统从软件层面模拟硬件中断行为的一种进程间通信机制。理解信号需要把握以下几个关键点:
1. 信号与硬件中断的相似性
异步通知机制:两者都是异步事件通知方式
中断处理流程:
硬件中断 → CPU暂停当前任务 → 执行中断处理程序 → 恢复原任务
信号 → 进程暂停当前执行 → 执行信号处理函数 → 继续原流程
优先级处理:都有相应的优先级系统
2. 信号与硬件中断的关键区别
特性 | 硬件中断 | 信号 |
---|---|---|
触发层级 | 硬件层面 | 软件/操作系统层面 |
接收对象 | CPU | 进程 |
响应时间要求 | 实时性要求高(微秒级) | 相对宽松(毫秒级) |
处理上下文 | 内核态 | 用户态(通常) |
传递信息量 | 通常较小(中断号) | 可以携带更多信息(信号值+附加数据) |
3. 信号的典型应用场景
进程控制:SIGTERM(终止)、SIGKILL(强制终止)
异常处理:SIGSEGV(段错误)、SIGFPE(算术异常)
用户交互:SIGINT(Ctrl+C中断)、SIGTSTP(Ctrl+Z暂停)
进程间通信:SIGUSR1/SIGUSR2(用户自定义信号)
深入理解信号特性
异步性:信号可能在进程执行的任意时刻到达
不可靠性:早期Unix信号可能丢失,现代系统已改进
处理方式多样性:
忽略信号(SIG_IGN)
默认处理(SIG_DFL)
自定义处理(安装信号处理函数)
信号屏蔽:进程可以暂时阻塞特定信号
学习建议
理解信号机制时,建议:
先建立与硬件中断的类比认知
再通过实际编程(如signal()/sigaction()调用)加深理解
注意区分不同Unix-like系统对信号实现的差异
特别关注信号处理中的可重入性问题
这种"软件中断"的设计体现了计算机系统中"分层抽象"的重要思想,后续学习中这种抽象层级的概念会越来越清晰。
五、信号处理与组合按键实验
1、实验目的
通过编写一个信号处理程序,可以:
验证不同组合按键发送的信号编号
观察不可捕获的信号特性
2、实验代码
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signal)
{
printf("Received signal: %d\n", signal);
}
int main()
{
// 注册1-31号信号的处理函数
for (int signo = 1; signo <= 31; signo++) {
signal(signo, handler);
}
// 保持程序运行以接收信号
while (1) {
sleep(1);
}
return 0;
}
这段代码演示了如何在Linux/Unix系统中捕获和处理信号。下面我将详细解释代码的各个部分:
头文件
#include <stdio.h> // 标准输入输出,用于printf函数
#include <unistd.h> // 提供sleep函数
#include <signal.h> // 信号处理相关函数和宏定义
信号处理函数
void handler(int signal) {
printf("Received signal: %d\n", signal);
}
这是一个简单的信号处理函数,当接收到信号时会被调用
参数
signal
是接收到的信号编号函数只是简单地打印出接收到的信号编号
主函数
int main() {
// 注册1-31号信号的处理函数
for (int signo = 1; signo <= 31; signo++) {
signal(signo, handler);
}
// 保持程序运行以接收信号
while (1) {
sleep(1);
}
return 0;
}
详细说明:
信号注册循环:
使用
for
循环遍历信号1到31对每个信号调用
signal()
函数,将其处理函数设置为handler
这意味着程序会捕获1-31号信号(除了一些无法捕获的信号)
无限循环:
while(1)
循环使程序保持运行状态,以便能够接收信号sleep(1)
让程序每秒醒来一次,减少CPU占用
3、注意事项
并非所有1-31号信号都能被捕获:
SIGKILL(9)和SIGSTOP(19)不能被捕获或忽略
尝试为这些信号设置处理函数是无效的
信号处理函数的限制:
信号处理函数中应尽量避免使用不可重入函数(如malloc, printf等)(后面会讲解)
这里的printf仅用于演示,实际应用中需谨慎
信号编号在不同系统可能有所不同:通常1-31是标准信号,32以上是实时信号
4、程序用途
这个程序可以用来测试各种信号的效果。你可以:
在另一个终端使用
kill -信号编号 进程ID
发送信号使用Ctrl+C(SIGINT,通常为2)或Ctrl+\(SIGQUIT,通常为3)发送信号
5、实验步骤与观察结果
组合按键测试:
Ctrl+C
(中断信号):发送2号信号(SIGINT)Ctrl+\
(退出信号):发送3号信号(SIGQUIT)Ctrl+Z
(暂停信号):发送20号信号(SIGTSTP)
特殊信号测试:使用下面命令查看该进程PID,然后进行杀掉进程处理操作
当向该进程发送9号信号(SIGKILL)时:ps aux | head -1 && ps aux | grep demo1 | grep -v grep
这是因为9号信号是不可捕获和忽略的
进程会立即终止
进程不会打印"Received signal: 9"
原理说明
可捕获信号:
大多数信号(如SIGINT、SIGQUIT等)可以被捕获并自定义处理
这给了程序优雅处理信号的机会
不可捕获信号:
SIGKILL(9)和SIGSTOP(19)是特殊的
它们不能被捕获、阻塞或忽略
这是操作系统设计的保护机制,确保管理员始终有办法终止失控的进程
设计意义:
如果允许捕获所有信号,恶意或错误程序可能捕获所有信号并忽略
这将导致系统无法管理这些进程
因此操作系统保留了这些"终极"信号作为最后手段
扩展知识
可以通过kill -l
命令查看系统支持的所有信号列表。不同Unix-like系统可能有些许差异,但核心信号(1-31)通常是相同的。
六、调用系统命令向进程发送信号
1、示例代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main() {
while(true) {
sleep(1); // 程序进入无限循环,每秒休眠一次
}
return 0;
}
2、操作步骤
编译程序:
g++ sig.cc -o sig
在后台运行程序:
./sig &
查看进程信息:
ps ajx | head -1 && ps ajx | grep sig
输出示例:
发送信号实验
向进程发送SIGSEGV信号:
kill -SIGSEGV 662585
说明
进程终止显示时机:Shell会在用户输入下一条命令后才显示"Segmentation fault"信息,这是为了避免错误信息与用户输入交错显示。
信号发送的多种写法:
使用信号名称:
kill -SIGSEGV 662585
使用信号编号:
kill -11 662585
(11是SIGSEGV的信号编号)
信号来源差异:
通常段错误(Segmentation fault)由非法内存访问产生
本例中通过发送SIGSEGV信号也能产生段错误,尽管程序本身没有内存访问错误
信号处理机制:
打开核心转储后,SIGSEGV信号会导致进程终止并产生核心转储
程序可以通过signal()或sigaction()函数捕获并处理该信号
其他常用信号:
SIGINT (2): 终端中断信号(通常由Ctrl+C产生)
SIGKILL (9): 强制终止进程信号
SIGTERM (15): 终止进程信号(默认)
七、通过系统函数向进程发送信号
第一个:kill命令和函数
1、kill命令的使用方法
kill
命令是Unix/Linux系统中用于向进程发送信号的标准工具,其基本使用格式如下:
1. 使用信号名称发送信号
kill -信号名 进程ID
示例:
# 向进程ID为1234的进程发送SIGTERM(终止)信号
kill -TERM 1234
# 向进程ID为5678的进程发送SIGHUP(挂起)信号
kill -HUP 5678
2. 在命令中使用信号编号发送信号
kill -信号编号 进程ID
示例:
# 使用编号9发送SIGKILL信号(强制终止)到进程1234
kill -9 1234
# 使用编号15发送SIGTERM信号到进程5678
kill -15 5678
2、常用信号及其编号对照表
信号名称 | 信号编号 | 默认动作 | 说明 |
---|---|---|---|
SIGHUP | 1 | 终止 | 终端挂断或控制进程终止 |
SIGINT | 2 | 终止 | 键盘中断(Ctrl+C) |
SIGQUIT | 3 | 终止+核心转储 | 键盘退出(Ctrl+) |
SIGKILL | 9 | 终止 | 强制终止信号(不可捕获或忽略) |
SIGTERM | 15 | 终止 | 终止信号(默认kill发送的信号) |
SIGSTOP | 17,19,23 | 停止进程 | 停止信号(不可捕获或忽略) |
3、kill函数原型与功能
kill()
是Unix/Linux系统中用于向进程发送信号的核心系统调用,其函数原型为:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数说明:
pid
:目标进程IDpid > 0
:发送信号给指定PID的进程pid == 0
:发送信号给与调用进程同组的所有进程pid == -1
:发送信号给调用进程有权限发送的所有进程pid < -1
:发送信号给进程组ID等于|pid|
的所有进程
sig
:要发送的信号编号(如SIGTERM为15,SIGKILL为9)
返回值:
成功时返回0
失败时返回-1,并设置errno
4、kill命令的模拟实现
下面是一个模拟kill
命令的完整实现示例:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
void Usage(char* proc)
{
printf("Usage: %s pid signo\n", proc);
}
int main(int argc, char* argv[])
{
if (argc != 3){
Usage(argv[0]);
return 1;
}
pid_t pid = atoi(argv[1]);
int signo = atoi(argv[2]);
kill(pid, signo);
return 0;
}
这段代码实现了一个简单的命令行工具,用于向指定进程发送信号。下面详细解释代码的各个部分:
1. 头文件包含
#include <stdio.h> // 标准输入输出,用于printf函数
#include <stdlib.h> // 标准库函数,用于atoi函数
#include <sys/types.h> // 系统类型定义,用于pid_t类型
#include <signal.h> // 信号处理相关函数和常量,用于kill函数
2. 使用说明函数
void Usage(char* proc)
{
printf("Usage: %s pid signo\n", proc);
}
这个函数用于打印程序的使用方法。当用户输入参数不正确时,会调用此函数显示正确的使用格式。
3. 主函数
int main(int argc, char* argv[])
{
// 检查参数数量是否正确(程序名 + pid + 信号编号 = 3个参数)
if (argc != 3){
Usage(argv[0]); // 打印使用说明
return 1; // 返回非零表示错误
}
// 将命令行参数转换为进程ID和信号编号
pid_t pid = atoi(argv[1]); // 将第一个参数转换为进程ID
int signo = atoi(argv[2]); // 将第二个参数转换为信号编号
// 使用kill函数发送信号
kill(pid, signo);
return 0; // 返回0表示成功
}
程序功能
这个程序的主要功能是向指定的进程发送指定的信号。它需要两个命令行参数:
目标进程的PID(进程ID)
要发送的信号编号
使用方法
编译后,可以这样使用该程序:
./程序名 目标进程PID 信号编号
例如:
./demo2 656423 9
这将向PID为656423的进程发送9号信号(SIGKILL),强制终止该进程。
注意事项
需要有足够的权限才能向其他进程发送信号
常见的信号编号:
2 (SIGINT): 中断信号,相当于Ctrl+C
9 (SIGKILL): 强制终止进程
15 (SIGTERM): 正常终止请求
如果参数数量不正确,程序会显示使用说明并退出。
这个程序是一个简单的信号发送工具,演示了如何使用Linux系统调用kill()
来向其他进程发送信号。
5、高级用法
向多个进程发送信号:
kill -9 1234 5678 9012
向进程组发送信号:
kill -9 -进程组ID
列出所有可用信号:
kill -l
使用pkill按名称发送信号:
pkill -信号名 进程名
注意事项
只有进程的所有者或root用户才能向进程发送信号
SIGKILL(9)和SIGSTOP信号不能被捕获、阻塞或忽略
使用信号前最好先确认进程状态:
ps -p 进程ID
默认情况下(不指定信号),kill发送的是SIGTERM(15)信号
第二个:raise函数详解
1、函数概述
raise
函数用于向当前进程发送指定的信号,即进程自己给自己发送信号。其函数原型如下:
#include <signal.h>
int raise(int sig);
2、参数与返回值
参数:
sig
:要发送的信号编号(如SIGINT、SIGTERM等)返回值:
成功发送信号时返回0
失败时返回非零值
3、功能说明
raise
函数相当于对当前进程调用kill(getpid(), sig)
,其主要特点包括:
同步发送信号:信号会立即发送给当前进程
信号处理:发送的信号会按照当前进程设置的信号处理方式(默认、忽略或捕获)进行处理
线程安全:在多线程环境中,
raise
会向调用线程而不是整个进程发送信号
4、示例代码分析
下面是一个使用raise
函数的完整示例,展示如何周期性地向自身发送信号:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
// 信号处理函数
void handler(int signo) {
printf("Received signal: %d\n", signo);
}
int main() {
// 设置信号处理函数(捕获SIGINT信号,即2号信号)
signal(SIGINT, handler);
printf("Process started (PID: %d)\n", getpid());
printf("Sending SIGINT (2) every second...\n");
while (1) {
sleep(1); // 休眠1秒
raise(SIGINT); // 向自身发送SIGINT信号
}
return 0;
}
运行结果说明
程序运行后会输出以下内容:
程序会每秒收到并处理一次SIGINT信号(2号信号),直到被手动终止。
5、注意事项
如果未设置信号处理函数,某些信号(如SIGINT)可能导致进程终止
在多线程程序中,
raise
只影响调用线程某些信号可能被系统阻塞或忽略,取决于当前进程的信号掩码设置
频繁发送信号可能影响程序性能,应考虑其他进程间通信方式
6、相关函数(了解即可)
kill()
:向指定进程发送信号signal()
:设置信号处理函数sigaction()
:更强大的信号处理设置函数
第三个:abort函数详解
1、函数概述
abort
函数用于使当前进程异常终止,它会向进程发送SIGABRT信号(信号编号6)。其函数原型如下:
#include <stdlib.h>
void abort(void);
2、函数特性
无参数无返回值:
abort
函数不接受任何参数,也不返回任何值终止保证:调用
abort
总会导致进程终止,除非SIGABRT信号被捕获且信号处理函数没有退出核心转储:通常会产生核心转储文件(core dump)用于调试,前提你要打开core dump才能看到
资源清理:不执行atexit()注册的函数,但会执行基本的I/O缓冲区刷新(现在先了解)
3、与相关函数的对比
特性 | abort() | exit() | raise(SIGABRT) |
---|---|---|---|
终止类型 | 异常终止 | 正常终止 | 依赖信号处理 |
保证终止 | 总是终止 | 可能失败 | 可被捕获 |
清理操作 | 基本清理 | 完全清理 | 依赖处理函数 |
核心转储 | 通常产生 | 不产生 | 依赖默认处理 |
4、示例代码分析
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
// 信号处理函数
void handler(int signo) {
printf("Received signal: %d (SIGABRT)\n", signo);
// 注意:即使捕获了信号,abort()仍会导致进程终止
}
int main() {
// 设置SIGABRT信号处理函数
signal(SIGABRT, handler);
printf("Process started (PID: %d)\n", getpid());
printf("Calling abort() every second...\n");
int count = 0;
while (1) {
sleep(1);
printf("About to call abort() (count: %d)\n", ++count);
abort(); // 终止进程
// 这行代码永远不会执行
printf("This line will never be printed\n");
}
return 0;
}
这段代码演示了如何使用 abort()
函数终止进程,并尝试捕获 SIGABRT
信号。
代码结构
头文件引入:
<stdio.h>
:标准输入输出<stdlib.h>
:包含abort()
函数声明<unistd.h>
:提供getpid()
和sleep()
函数<signal.h>
:信号处理相关函数
信号处理函数
handler
:当收到
SIGABRT
信号时被调用打印接收到的信号编号
注意:即使捕获了信号,
abort()
仍会导致进程终止
主函数
main
:设置
SIGABRT
信号的处理函数为handler
打印进程ID
进入无限循环,每秒调用一次
abort()
关键点
abort() 函数:
导致程序异常终止
会发送
SIGABRT
信号给当前进程即使捕获了信号,进程仍会终止(不同于其他可被完全捕获的信号)
信号处理:
signal(SIGABRT, handler)
设置了信号处理函数每次调用
abort()
时,handler
函数会被调用但处理完成后进程仍会终止
程序流程:
每秒打印一次计数并调用
abort()
abort()
后面的代码永远不会执行每次
abort()
都会终止进程,但由于是无限循环,实际只会执行一次
实际运行效果
运行该程序时,你会看到:
打印进程ID
打印即将调用
abort()
的信息信号处理函数打印接收到的信号
进程终止(通常会产生核心转储)
运行结果说明
程序运行后会输出类似以下内容:
关键观察点:
虽然捕获了SIGABRT信号并执行了处理函数
但进程仍然会异常终止
可能生成核心转储文件(取决于系统配置)
5、深入理解
不可阻止性:即使捕获了SIGABRT信号,大多数实现仍会在信号处理函数返回后终止进程
终止机制:
首先发送SIGABRT信号
如果信号被捕获且处理函数返回,则abort()会再次确保进程终止
使用场景:
程序检测到不可恢复的错误时
调试期间快速终止程序
测试异常处理逻辑
6、注意事项
资源泄漏:abort()不会调用atexit()注册的函数,可能导致资源泄漏
信号屏蔽:即使SIGABRT被屏蔽(即使捕获了
SIGABRT
),abort()仍能终止进程多线程:在多线程环境中,abort()会终止整个进程而不仅是当前线程
替代方案:在需要更可控的终止时,考虑使用exit()或异常处理机制
abort()
设计用于异常终止程序,不应作为正常的程序退出方式在生产代码中应谨慎使用
abort()
,通常用于严重错误情况
八、由软件条件产生信号
1、SIGPIPE信号
信号产生机制
SIGPIPE信号是一种由软件条件产生的信号,它发生在进程间通过管道(pipe)或套接字(socket)进行通信时的特定场景下。当满足以下条件时,写端进程会收到SIGPIPE信号:
通信的读端进程已经关闭了它的读取端
写端进程仍在尝试向管道或套接字写入数据
此时操作系统会向写端进程发送SIGPIPE信号,默认情况下该信号会导致进程终止。
代码示例分析
下面是一个演示SIGPIPE信号的完整示例代码,展示了父子进程通过匿名管道通信时SIGPIPE信号的产生:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int fd[2] = { 0 };
// 创建匿名管道
if (pipe(fd) < 0) {
perror("pipe");
return 1;
}
// 创建子进程
pid_t id = fork();
if (id == 0) { // 子进程 - 写端
close(fd[0]); // 关闭读端
const char* msg = "hello father, I am child...";
int count = 10;
// 子进程持续向管道写入数据
while (count--) {
ssize_t ret = write(fd[1], msg, strlen(msg));
if (ret == -1) {
perror("write");
break;
}
printf("child write success, count=%d\n", count);
sleep(1);
}
close(fd[1]); // 关闭写端
exit(0);
}
else { // 父进程 - 读端
close(fd[1]); // 关闭写端
close(fd[0]); // 立即关闭读端(这将导致子进程收到SIGPIPE信号)
// 等待子进程结束
int status = 0;
waitpid(id, &status, 0);
// 打印子进程退出状态
if (WIFEXITED(status)) {
printf("child exit normally, exit code:%d\n", WEXITSTATUS(status));
} else {
printf("child terminated by signal:%d\n", WTERMSIG(status));
}
return 0;
}
}
这段代码用于检查并打印子进程的退出状态,主要使用了几个与进程状态相关的宏:
WIFEXITED(status)
:这个宏用于判断子进程是否正常退出。如果返回true,表示子进程通过调用exit()
或从main()
函数返回而正常终止。WEXITSTATUS(status)
:当WIFEXITED
为true时,这个宏可以获取子进程的退出状态码(即exit()
函数或main()
函数return的参数)。WTERMSIG(status)
:当WIFEXITED
为false时(即子进程是被信号终止的),这个宏可以获取导致子进程终止的信号编号。
代码逻辑:
如果子进程正常退出,打印"child exit normally"以及退出状态码
如果子进程是被信号终止的,打印"child terminated by signal"以及信号编号
运行结果分析
当运行上述程序时,可以观察到以下行为:
父进程立即关闭了管道的读端
子进程尝试第一次写入时会成功(因为管道缓冲区可能还有空间)
当子进程继续尝试写入时,由于读端已关闭,操作系统会发送SIGPIPE信号(13)给子进程
子进程被终止,父进程通过
waitpid
获取到子进程是被信号终止的
程序输出会显示子进程是由于信号13(SIGPIPE)而终止的,这验证了SIGPIPE信号的产生机制:
实际应用中的处理
在实际编程中,我们通常需要处理SIGPIPE信号,避免程序意外终止。常见的处理方式包括:
忽略SIGPIPE信号:
signal(SIGPIPE, SIG_IGN);
捕获并处理SIGPIPE信号
检查
write
系统调用的返回值,当返回EPIPE错误时进行适当处理(EPIPE:文件描述符(fd)连接到一个管道(pipe)或套接字(socket),但其读取端已被关闭。当这种情况发生时,写入进程还会收到一个 SIGPIPE 信号。(因此,只有在程序捕获(catch)、阻塞(block)或忽略(ignore)该信号时,才能观察到write
的返回值。))
在网络编程中,正确处理SIGPIPE信号尤为重要,因为网络连接可能随时被对方关闭。
2、 SIGALRM信号
alarm函数介绍
alarm
函数是Unix/Linux系统中用于设置定时器的重要函数,其原型如下:
unsigned int alarm(unsigned int seconds);
功能说明
该函数的作用是让操作系统在指定的seconds
秒之后向当前进程发送SIGALRM信号。需要注意的是:
SIGALRM信号的默认处理动作是终止进程
每个进程只能有一个活动的alarm定时器
定时器是异步触发的,精确度受系统调度影响
当参数为
0
时(即alarm(0)
):取消之前设置的所有闹钟定时器、立即终止任何未触发的定时信号、返回剩余未触发的时间(秒数),若之前未设置闹钟则返回0
返回值特性
alarm函数的返回值具有以下特点:
之前有设置闹钟:返回上一个闹钟时间的剩余秒数,并且新设置的闹钟会覆盖之前的设置
之前无闹钟设置:返回值为0
实际应用示例
基础测试示例
下面是一个简单的测试代码,用于观察1秒内变量能累加多少次:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main()
{
long long count = 0;
alarm(1);
while (1)
{
count++;
printf("count: %lld\n", count);
}
return 0;
}
运行现象:在云服务器上运行时,通常只能累加到约11.7万次左右。
性能分析
为什么实际结果远低于CPU的真实计算能力?主要原因包括:
I/O操作开销:每次循环都执行
printf
,而I/O操作(特别是终端输出)耗时远高于单纯的累加运算网络延迟:在云服务器场景下,输出结果需要通过网络传输到客户端显示
上下文切换:频繁的I/O操作导致大量的用户态-内核态切换
优化后的测试方案
为了更准确地测量CPU的计算能力,可以改为在信号处理函数中输出最终结果:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
long long count = 0;
void handler(int signo)
{
printf("Received signal: %d\n", signo);
printf("Final count: %lld\n", count);
exit(0);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (1)
{
count++;
}
return 0;
}
优化效果:在同样的云服务器上,这个版本可以在一秒内累加超过31亿次,充分展示了:
纯计算操作的极高速度
I/O操作相对于计算操作的巨大性能差距
合理设计性能测试方法的重要性
深入理解
定时器精度:alarm的定时精度受系统时钟粒度(在Linux系统中,时钟粒度(Clock Granularity) 指的是操作系统能够分辨或处理的最小时间间隔。它直接影响计时精度、任务调度、睡眠延迟等与时间相关的操作。)限制,通常为10ms左右
信号处理时机:信号可能在任意两条指令之间被处理,不保证精确的时序
多线程影响:在多线程程序中,信号处理存在更多复杂性
通过这个案例,我们可以深刻理解计算机系统中计算与I/O的性能差异,以及如何设计更准确的性能测试方案。
3、pause函数
pause()
是Linux/Unix系统编程中一个重要的系统调用函数,它用于使调用进程挂起(睡眠),直到接收到一个信号为止。
函数原型
#include <unistd.h>
int pause(void);
功能描述
pause()
函数的主要功能是:
使调用进程挂起(进入睡眠状态)
直到进程捕获到一个信号并从信号处理函数返回时,
pause()
才返回如果信号处理程序终止了进程,
pause()
就不会返回
返回值
正常情况下,
pause()
只有在信号处理程序执行并返回后才会返回返回值为-1,并设置errno为EINTR(表示被信号中断)
工作原理
当调用
pause()
时,内核会将进程置于可中断的等待状态(TASK_INTERRUPTIBLE)进程会一直保持这个状态,直到有信号到达
如果信号的处置是终止进程,则进程终止,
pause()
不返回如果信号的处置是忽略,则
pause()
不返回,进程继续挂起如果信号被捕获(即有信号处理函数),则在信号处理函数返回后,
pause()
返回-1,errno设置为EINTR
典型用法
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
void sig_handler(int signo)
{
printf("Received signal %d\n", signo);
}
int main()
{
signal(SIGINT, sig_handler); // 设置SIGINT的信号处理函数
printf("Waiting for signal...\n");
pause(); // 挂起进程,等待信号
printf("After pause\n");
return 0;
}
注意事项
竞态条件:在多线程环境中使用
pause()
需要注意竞态条件问题。在调用pause()
之前可能会有信号已经到达,这可能导致pause()
永远挂起。可移植性:
pause()
在所有POSIX兼容系统上都可用。替代方案:现代编程中,
pause()
的使用较少,更多使用sigsuspend()
、select()
、poll()
或epoll()
等函数,因为它们提供了更好的控制和灵活性。信号屏蔽:
pause()
不会改变进程的信号掩码。如果需要原子性地解除信号阻塞并等待信号,应该使用sigsuspend()
。
与sleep的区别
sleep()
会在指定的时间后返回,或者被信号中断pause()
则会一直等待,直到有信号到达
4、设置重复闹钟的实现与理解
重复闹钟的实现机制
下面是一个实现重复闹钟的示例代码,通过信号处理函数中重新设置闹钟来实现周期性触发:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>
using func_t = std::function<void()>;
int gcount = 0;
std::vector<func_t> gfuncs;
// 信号处理函数
void handler(int signo)
{
// 执行所有注册的回调函数
for (auto &f : gfuncs)
{
f();
}
std::cout << "当前计数: " << gcount << std::endl;
// 重新设置闹钟,返回上一次闹钟的剩余时间
int remaining = alarm(1);
std::cout << "上次闹钟剩余时间: " << remaining << "秒" << std::endl;
}
int main()
{
// 注册一些周期性任务(示例)
// gfuncs.push_back([](){ std::cout << "执行内核刷新操作" << std::endl; });
// gfuncs.push_back([](){ std::cout << "检查进程时间片" << std::endl; });
// gfuncs.push_back([](){ std::cout << "执行内存碎片整理" << std::endl; });
// 设置初始闹钟(1秒后触发)
alarm(1);
// 注册信号处理函数
signal(SIGALRM, handler);
while (true)
{
pause(); // 等待信号
std::cout << "进程被唤醒..." << std::endl;
gcount++;
}
return 0;
}
这段代码实现了一个基于信号的简单周期性任务调度系统,主要使用了UNIX信号机制。下面详细解释代码的各个部分:
1. 头文件和类型定义
包含了必要的头文件:IO操作、UNIX系统调用、信号处理和STL容器
定义了
func_t
类型,这是一个返回void、无参数的函数对象类型
2. 全局变量
gcount
: 全局计数器,记录进程被唤醒的次数gfuncs
: 存储所有注册的回调函数的向量
3. 信号处理函数
当接收到
SIGALRM
信号时被调用遍历并执行所有注册的回调函数
打印当前计数器值
重新设置1秒后的闹钟,并打印上次闹钟的剩余时间
4. 主函数
初始化部分:
可以注册多个回调函数(示例中被注释掉了)
设置1秒后触发的闹钟
注册
SIGALRM
信号的处理函数
主循环:
pause()
挂起进程直到收到信号被信号唤醒后打印消息并增加计数器
工作原理
程序启动后设置1秒的定时器
1秒后,内核发送
SIGALRM
信号信号处理函数
handler
被调用:执行所有注册的回调函数
打印当前计数
重新设置1秒定时器
主循环继续等待下一个信号
关键点
alarm(1)
设置1秒的定时器,返回上次定时器的剩余时间signal(SIGALRM, handler)
注册信号处理函数pause()
使进程挂起直到收到信号每次信号处理后定时器会重新设置,形成周期性触发
关键点说明
pause()函数:
使调用进程挂起,直到捕获到一个信号
只有信号处理函数返回后,pause()才会返回
返回值总是-1,errno设置为EINTR
重复闹钟实现原理:
在信号处理函数中再次调用alarm(1)重置闹钟
形成"触发-处理-重置"的循环
每次返回上一次闹钟的剩余时间
测试方法:我们放开主函数的注册一些周期性任务注释,然后进行测试
5、软件条件的理解
在操作系统中,软件条件信号指的是由软件内部状态或特定操作触发的信号产生机制,包括:
定时器超时:如alarm()设置的定时器到期产生SIGALRM
软件异常:
向已关闭的管道写数据产生SIGPIPE
子进程终止产生SIGCHLD
进程间通信:如消息队列到达等
这些条件满足时,操作系统会向相关进程发送相应信号,通知进程进行处理。
6、系统闹钟的本质理解
系统闹钟的实现依赖于操作系统内部的定时功能,现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。关键点包括:
内核定时器管理:
struct timer_list { struct list_head entry; // 链表节点 unsigned long expires; // 到期时间 void (*function)(unsigned long); // 回调函数 unsigned long data; // 回调数据 struct tvec_t_base_s *base; // 所属时间轮 };
组织方式:
通常采用时间轮算法高效管理大量定时器
可以简化为堆结构理解(按到期时间排序)
用户接口:
alarm()系统调用是用户空间访问定时功能的简单接口
更精确的定时可使用setitimer()或timer_create()
7、闹钟特性总结
特性 | 说明 |
---|---|
单次触发 | 默认只生效一次 |
重复设置 | 需在信号处理中重新设置 |
取消方法 | alarm(0)取消之前设置的闹钟 |
精度限制 | 受系统时钟粒度影响(通常10ms) |
通过这种机制,操作系统能够为进程提供定时功能,支持各种定时任务和超时处理需求。
九、由硬件异常到程序崩溃:C/C++程序崩溃机制解析
1、模拟除零异常
以下代码演示了除零异常的产生和信号处理:
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("捕获到信号: %d\n", sig); // SIGFPE信号值为8
}
int main()
{
// signal(SIGFPE, handler); // 注册SIGFPE信号处理函数(注释掉以观察默认行为)
sleep(1); // 延迟1秒以便观察进程
int a = 10;
a /= 0; // 故意触发除零异常
while (1)
; // 保持进程运行
return 0;
}
运行结果分析:
如果不注册信号处理函数,程序会因SIGFPE信号而终止,通常输出"Floating point exception"。
如果注册了处理函数,会观察到处理函数被重复调用,持续刷屏输出"捕获到信号: 8"。
2、模拟空指针访问
这是一个演示空指针解引用导致程序崩溃的C语言程序:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("I am running...\n"); // 打印运行信息
sleep(3); // 暂停3秒
int *p = NULL; // 定义一个空指针
*p = 100; // 尝试对空指针解引用并赋值
return 0;
}
代码行为分析
程序启动:首先打印"I am running..."消息
暂停3秒:调用sleep(3)让程序暂停3秒
空指针操作:
定义一个指向整型的指针p,并初始化为NULL
尝试对NULL指针解引用(*p)并赋值100
预期结果:
由于解引用NULL指针是非法内存访问
操作系统会发送SIGSEGV信号(段错误信号)给进程
导致程序崩溃终止
典型输出
这个程序清晰地演示了野指针/空指针解引用这类内存错误是如何导致程序崩溃的。
3、程序崩溃的本质原因
当C/C++程序中出现诸如除零错误、野指针访问或数组越界等错误时,程序会突然终止运行,这种现象通常被称为"程序崩溃"。从系统层面来看,崩溃的本质是进程在运行过程中收到了操作系统发送的终止信号。
4、硬件异常的信号传递机制
现代计算机系统中,硬件与操作系统协同工作来检测和处理程序错误:
CPU状态寄存器:CPU配备有专门的状态寄存器,用于记录指令执行结果的各种状态信息,包括进位、溢出等标志位。当发生除零等算术异常时,相关状态位会被置位。
异常检测:操作系统作为硬件资源的管理者,会持续监控这些状态标志。一旦检测到由程序错误导致的标志位置位,操作系统会:
确定引发异常的进程
将硬件错误转换为相应的信号(如SIGFPE对应算术异常)
通过修改进程的task_struct中的信号位图来传递信号
进程终止:接收到信号的进程会在适当时机被终止,表现为程序崩溃。
5、硬件异常产生信号
硬件异常是指当程序执行过程中出现非法操作时,由计算机硬件检测到并通知操作系统的事件。当硬件检测到异常情况后,会通过中断机制通知内核,内核随后会向引发异常的当前进程发送相应的信号。这种机制是操作系统处理硬件异常的标准方式。
常见硬件异常及对应的信号包括:
算术异常:当进程执行了除以零的指令时,CPU的算术逻辑单元(ALU)会产生异常,内核将此异常解释为SIGFPE(浮点异常)信号发送给进程。
内存访问异常:当进程访问了非法内存地址(如空指针解引用或越界访问)时,内存管理单元(MMU)会产生页面错误异常,内核将此异常解释为SIGSEGV(段错误)信号发送给进程。
内存相关错误(如野指针访问)的检测机制更为复杂:
地址转换机制:
进程访问变量需经过虚拟地址到物理地址的转换
页表提供软件映射关系
MMU(内存管理单元)硬件实际执行转换工作
MMU的异常检测:
当访问非法虚拟地址时,MMU转换失败
MMU会设置硬件状态信息记录错误
操作系统检测到MMU异常后,向进程发送SIGSEGV信号(段错误)
6、从软件错误到硬件响应
C/C++程序崩溃的根本原因在于:
程序中的各种错误最终都会在硬件层面产生可检测的表现
操作系统通过监控CPU和MMU等硬件单元的状态及时发现这些错误
操作系统将硬件异常转换为相应信号并终止问题进程
这种硬件-操作系统-应用程序的三层协作机制,既保证了系统稳定性,又为开发者提供了错误检测和处理的途径。理解这一机制对于调试程序和分析崩溃原因具有重要意义。
7、异常处理机制深入分析
通过上述实验可以确认,在C/C++中遇到的除零、内存越界等异常,在操作系统层面上都是通过信号机制处理的。
值得注意的现象:
信号重复产生:当注册了信号处理函数后,会观察到信号被重复捕获。这是因为:
CPU中有专门的控制和状态寄存器(如x86架构中的EFLAGS寄存器),其中的状态标志位会记录异常情况。
操作系统会持续检测这些异常状态标志位,只要异常条件仍然存在,就会重复触发信号。
异常上下文保留:当异常发生时,如果没有正确清理进程上下文(如寄存器状态、内存状态等),异常条件会持续存在。具体来说:
对于除零异常:CPU的除法错误标志位未被清除,导致操作系统重复检测到该异常。
对于内存访问异常:非法的内存访问指令会不断重试,导致MMU重复产生页面错误异常。
处理建议:在实际编程中,对于这类严重错误信号,通常不建议简单地捕获并继续执行,而应该:
在信号处理函数中进行必要的清理工作
记录错误信息
然后正常终止程序
这样可以避免不可预测的程序行为和数据损坏。
这种硬件异常到信号的转换机制,是操作系统实现错误处理和进程管理的重要基础,它使得用户态程序能够以统一的方式处理各种硬件异常情况。
十、信号机制总结与思考(重要!!!后面学习一定要回头看这个!!!)
1、核心问题分析
1. 操作系统的主导作用
所有信号的产生和传递最终都由操作系统执行,因为:
操作系统是系统资源的唯一管理者,只有内核具备硬件访问权限
信号本质是进程间通信机制(IPC),需要内核作为可信中介
进程管理是OS核心职能,包括信号处理在内的进程控制必须通过内核
2. 信号处理的时序特性
信号处理具有异步性,但并非即时处理:
立即处理场景:当目标进程处于用户态且未阻塞该信号时
延迟处理场景:当进程正在执行系统调用或处理其他信号时
内核会在进程从内核态返回用户态前检查待处理信号(在
TASK_RUNNING
状态下)
3. 信号的暂存机制
未立即处理的信号需要可靠存储:
存储位置:进程PCB中的信号位图(
pending
位图)设计考量:
位图结构节省空间(每个信号1bit)
支持信号去重(相同信号未处理前只记录一次)
原子操作保证线程安全
4. 信号处理预案
进程预先知道如何处理信号:
通过
signal()
/sigaction()
注册处理函数默认处理方式存储在PCB的
struct sigaction
数组中三种标准处理方式:忽略(SIG_IGN)、默认(SIG_DFL,通常是终止/停止/继续)、自定义处理函数
2、信号生命周期详解
发送阶段
内核级发送:
// 内核内部实现示例 void send_signal(task_struct *task, int sig) { spin_lock(&task->sighand->siglock); sigaddset(&task->pending.signal, sig); // 设置pending位图 wake_up_process(task); // 唤醒目标进程(若可中断睡眠) spin_unlock(&task->sighand->siglock); }
用户级触发:
kill -SIGTERM 1234 # 通过kill命令发送
传递阶段
内核在以下时机检查信号:
系统调用返回路径(
syscall_exit_to_user_mode
)中断/异常处理返回用户空间前
进程从睡眠状态被唤醒时
处理阶段
内核处理流程:
从
pending
中取出最高优先级信号检查
sigaction
中的处理方式若为自定义处理函数:
构建用户态栈帧(保存原执行上下文)
修改IP指向处理函数
返回用户态执行处理程序
3、关键数据结构
// Linux内核简化示例
struct task_struct {
// ...
struct sigpending pending; // 待处理信号集
struct sighand_struct *sighand; // 信号处理程序指针
sigset_t blocked; // 阻塞信号掩码
};
struct sigpending {
struct list_head list; // 实时信号队列
sigset_t signal; // 非实时信号位图
};
struct sigaction {
__sighandler_t sa_handler; // 处理函数指针
sigset_t sa_mask; // 执行时的阻塞掩码
int sa_flags; // 特殊行为标志
};
4、典型处理场景(了解即可)
信号中断系统调用
慢系统调用(如read/wait)可能被信号中断
通过
SA_RESTART
标志可自动重启被中断的调用
信号处理嵌套
默认情况下,处理函数执行期间自动阻塞同类型信号
可通过
sa_mask
指定额外需要阻塞的信号
多线程信号处理
共享信号动作:所有线程共享
sighand
结构体独立信号掩码:每个线程有独立的
blocked
掩码目标选择:
kill()
发送到进程,pthread_kill()
发送到特定线程