ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux系统与系统编程(13)——信号

Linux系统与系统编程(13)——信号

前言

欢迎观看Linux系列文章!!第13篇主要重点讲述了信号的产生、保存和处理,而且浅谈了内核态与用户态、可重入函数、volatile关键字以及SIGCHLD信号。

信号

前置概念

1.什么是信号?
信号是一种给进程发送的,用来进行时间异步通知的机制。信号的产生相对于进程的运行,是异步的

2.信号的处理
当信号还没有产生时,进程已经知道如何处理对应信号了。在设计时,OS已经内置了进程对信号的识别和处理方式产生信号后,进程不是立即处理信号,而是在合适的时候处理信号

3.信号源
能给进程产生信号的叫做信号源,信号源很多。

4.信号与信号源
两者没有任何关系不要混淆!

信号的产生

产生信号的方式很多:

1️⃣键盘产生信号

Ctrl + c就是一个例子,他会给目标进程发送信号,相当一部分的信号处理动作,就是终止当前进程。

a.信号有哪些?——发送指令 kill -l 可查看所有信号。

kill -l

它们以宏的方式存放在内核代码中。其中1-31是普通信号,34到64是实时信号。

其中 Ctrl+c 发送的是信号2,Ctrl+ \ 发送的是信号3,Ctrl+z 发送的是信号20.

指令man 7 signal可以查看所有信号的默认处理动作

man 7 signal

action动作有Core、Term、Cont和Ign,Core和Term终止、Cont继续执行、Ign忽略。

b.目标进程是什么?

进程有后台进程前台进程
当我们通过命令./XXX启动的进程是前台进程./XXX &启动的进程是后台进程。其中自行启动的shell是前台进程。

键盘信号只能发给前台进程,也就是说前台进程可以从键盘上获取标准输入,而后台进程无法从标准输入中获取内容。但是都可以像标准输出打印。

前台进程只能有一个,但是后台进程可以有多个。键盘只有一个,所以只能给一个确定的进程输入数据。所以前台进程的本质就是要从键盘获取数据

相关指令:

查看后台进程

jobs


后台启动一个进程,这里的1是任务号。

查到一个后台进程。

前台进程转后台

先Ctrl + z暂停前台进程。

bg [任务号]

指令bg(即background)后台启动指定进程。

后台进程转前台

fg [任务号]


此时该进程可以获取键盘输入。

2️⃣系统调用产生信号

由于收到信号后,进程不会立即处理,所以必须要记录下来信号。
收到的信号会记录在PCB中,以位图结构存储。

struct task_struct { ··· unsigned int sigs; ··· }

所以发送信号的本质是,向目标进程写信号,修改位图sigs。

PCB是OS内部的数据结构对象,所以发信号是修改内核数据的操作,因此,OS要提供发送信号的系统调用,在底层让OS来完成发送信号。

#include <signal.h> int kill(pid_t pid, int sig);

也可以通过系统调用自己给自己发信号。

#include <signal.h> int raise(int sig);

调用 abort() 时,进程会向自身发送 SIGABRT 信号,系统默认处理 SIGABRT 的方式是终止进程并生成核心转储。可以捕捉执行自定义处理,但是当处理函数返回后,依旧会执行一次默认处理。

#include <signal.h> int abort();

3️⃣硬件异常产生信号


如图,当出现以上错误时,程序会进行异常报错,操作系统会根据程序犯错的类型发送信号,终止进程。

比如除0错误,会发送信号8——SIGFPE(Floating-point exception);野指针错误,会发送信号11——SIGSEGV段错误(Invalid memory reference)。

操作系统是如何识别进程错误的呢?
这些错误本质是CPU在运行对应指令的时候出现的异常,就是硬件异常,然后产生一个异常向量。CPU 会根据异常向量号,强制跳转到操作系统在启动时预设好的中断描述符表(IDT)中的对应入口。

4️⃣软件产生信号

以下介绍其中一种情况:闹钟。

#include<unistd.h> unsigned int alarm(unsigned int seconds);

计时器函数,作用是告诉内核在seconds秒之后,给当前进程发送SIGALRM信号,该信号的默认处理动作是终止当前进程。
传入0时,为取消计时器。
返回值是上一个计时器剩余时间。

#include<unistd.h> int pause(void);

调用 pause() 的进程会进入阻塞状态(休眠),主动放弃 CPU,直到有信号递送给该进程。

示例代码:

#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <unistd.h> #include <vector> #include <functional> using namespace std; void Sched() { cout << "我是进程调度!" << endl; } void MemManger() { cout << "我是周期性内存管理,正在检查内存问题!" << endl; } void Fflush() { cout << "我是刷新程序,正在定期刷新内存数据到磁盘!" << endl; } using func_t = function<void()>; vector<func_t> funcs; void handlerSig(int sig) { cout<<"---------------------------------------------------------------"<<endl; for(auto f:funcs) { f(); } cout<<"---------------------------------------------------------------"<<endl; int n = alarm(1); } int main() { funcs.push_back(Sched); funcs.push_back(MemManger); funcs.push_back(Fflush); signal(SIGALRM, handlerSig); alarm(1); while (true) { pause(); } return 0; }

该程序可以模拟了如何操作系统根据信号来进行处理动作,受信号的驱动做出反应。这里的alarm模拟的是操作系统的系统闹钟。

操作系统内会同时存在很多的闹钟,所以一样要对这些闹钟进行管理。闹钟结构体内存放了闹钟过期时间、闹钟方法等,将闹钟过期时间以小堆方式把节点链接在一起。每当时间戳大于闹钟过期时间最小节点时,出堆嗲用闹钟方法。

信号的保存

因为信号不能够立即处理,而是要到合适的时候再处理,所以要再开始处理前保存信号。

·实际执行信号的处理动作,称为信号递达
·信号从产生到递达之间的状态,称为信号未决(信号还存在PCB的位图中)。
·进程可以选择阻塞(屏蔽)某个信号被阻塞的信号将保持在未决状态,直到进程解除对信号的阻塞,才能执行递达。
·阻塞不同于忽略,信号阻塞就不会被递达,忽略是递达的一种处理动作。

信号的保存在PCB中以三张表的方式体现,block和pending都是位图,handler是处理动作的函数指针数组。

阻塞表又叫做阻塞信号集或信号屏蔽字;未决表又叫做未决信号集或等待信号集。
两个信号集都已sigset_t类型存储。

信号屏蔽字默认全0。

信号集操作函数

#include <signal.h> int sigemptyset(sigset_t *set);//清空信号集 int sigfillset(sigset_t *set);//填满信号集 int sigaddset(sigset_t *set, int signo);//添加一个信号 int sigdelset(sigset_t *set, int signo);//删除一个信号 int sigismember(const sigset_t *set, int signo);//测试指定信号是否在信号集中
sigprocmask——获取或更改进程信号屏蔽字
#include<signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数:

how:有三个宏选项,SIG_BLOCK、SIG_UNBLOCK和SIG_SETMASK。
SIG_BLOCK:添加当前信号屏蔽字的信号,相当于mask = mask | set;
SIG_UNBOLCK:解除信号屏蔽子中阻塞的信号,相当于mask=mask&~set;
SIG_SETMASK:设置当前信号屏蔽字为set,相当于mask = set。

set:新信号集(输入型参数)。

oldset:旧信号集。非空时,向该信号集返回修改前的信号屏蔽字,用于事后恢复现场(输出型参数)。

返回值:成功返回0,失败返回-1并设置错误码。

sigpending——获取未决信号集
#include<signal.h> int sigpending(sigset_t *set);

参数:

set:未决信号集返回到set中(输出型参数)。

返回值:成功返回 0,失败返回 -1 并设置错误码。

示例代码:

#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <unistd.h> #include <vector> #include <functional> using namespace std; //打印pending表 void PrintPending(sigset_t pending) { cout << "我是一个进程" << getpid() << ",pending:"; for (int signo = 31; signo >= 1; signo--) { if (sigismember(&pending, signo)) { cout << "1"; } else { cout << "0"; } } cout << endl; } //自定义捕捉SIGINT void handler(int sig) { //准备递达时,首先将pending的对应信号位图置0. cout << "递达" << sig << "信号!" << endl; sigset_t pending; int m = sigpending(&pending); PrintPending(pending); } int main() { signal(SIGINT, handler); sigset_t block, oblock; sigemptyset(&block); sigemptyset(&oblock); sigaddset(&block, SIGINT); int n = sigprocmask(SIG_SETMASK, &block, &oblock); (void)n; int cnt = 0; while (true) { sigset_t pending; int m = sigpending(&pending); PrintPending(pending); if (cnt == 10) { sigprocmask(SIG_SETMASK, &oblock, nullptr); cout << "解除对信号SIGINIT的屏蔽" << endl; } sleep(1); cnt++; } }

1.解除屏蔽后,立刻执行信号的处理。
2.信号在准备递达前,就已经把pending表对应的位置置0了。

补充

信号有Core信号和Term信号。

Term信号:一般终止。

Core信号:终止进程时,在当前路径下形成一个文件,进程异常退出时,进程在内存中的核心数据,从内存拷贝到磁盘,形成一个文件核心转储
在云服务器上,core dump是被默认禁止的,因为云服务器一般是服务于生产的,当出现bug时,会保留下大量的core文件。

gdb <可执行文件路径> <core文件路径>

目的是进行调试,该文件支持debug,可以直接定位到错误行。

ulimit -c 102400

指令ulimit可以打开这个功能,102400时自行设置的文件大小,单位是KB。这个修改是临时的,重启系统又会恢复默认禁止状态。

退出码中可以通过core dump标志了解是否被core信号所杀。

信号的处理

收到信号之后,进程要根据对应信号进行处理。

处理方式:
1.默认处理动作。
2.自定义信号处理动作。
3.忽略处理。

进程收到信号后,会在合适的时候处理信号。

自定义信号捕捉接口

#include<signal.h> typedef void(*sighandler_t)(int);//函数指针类型,自定义处理函数 sighandler_t signal(int signum, sighandler_t handler);

捕捉后可以运行自定义的函数来代替默认处理动作。

为了防止恶意进程无法终止运行,信号9(杀死)和信号19(停止)是无法被自定义捕捉和阻塞的。

捕捉信号

信号在合适的时候处理,判断什么时候合适很重要。

如图,为处理信号时的步骤逻辑。可以简单理解为以下的简图。

sigaction——自定义捕捉处理方法

#include<signal.h> int sigaction(int signum, const struct sigaction* act, struct sigaction* oldact);

作用同signal,但是功能更多,而且不止能处理普通信号。

参数:

signum:信号编号

act:struct sigaction* act 结构体如下

struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t*, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); }

sa_handler需要传入一个自定义的处理方法。
sa_mask为额外信号屏蔽字,当某个信号的处理函数被调用时,内核会自动将当前信号加入到进程的信号屏蔽字中,当处理函数返回之后,自动恢复到原来的信号屏蔽字。如果除此之外还想要屏蔽其他一些信号,那就用sa_mask字段说明。可以用信号集处理函数进行设置。

演示代码:

#include <iostream> #include <unistd.h> #include <signal.h> #include <cstdlib> using namespace std; void handler(int signum) { cout << "hello signal:" << signum << endl; } int main() { struct sigaction act, oact; act.sa_handler = handler; sigaction(SIGINT, &act, &oact); while(true) { cout << "hello world" << endl; sleep(1); } return 0; }

内核态与用户态

硬件中断

图为硬件中断的简单流程简图。虽然实现方法不相同,但是逻辑和信号很类似,信号其实就是用软件来模拟硬件中断。

中断向量表本身是OS的一部分,在OS启动时,就直接加载到内存。

在中断到来之前,OS会一直暂停pause。OS不会轮询管理外设,当外设有需求的时候才会来找OS,OS再中断处理。操作系统,就是基于中断进行工作的软件。

时钟源的频率就是我们常说的CPU主频。

软件中断

也可以通过代码,让软件在CPU触发中断。

主动中断(陷阱 / Trap,也叫“访管指令”):
目的:用户程序主动“敲门”请求操作系统帮忙(比如读写文件、创建进程、申请内存)。
例子:你在代码里调用 printf 或 System.out.println(),底层会执行一条特殊指令(如 INT 0x80 、system_call 或 SYSENTER),触发中断,CPU从用户态切换到内核态,让操作系统去操作硬件屏幕。

当我们进行系统调用时,内核:1.把系统调用号放入CPU的eax寄存器中。2.从CPU寄存器中获取系统调用号n。3.调用系统调用方法(通过系统调用号n,在系统调用表中索引调用方法)

操作系统不提供任何系统调用接口,只提供系统调用号。我们使用的系统调用函数,其实是已经通过glibc封装过一层了。

#define SYS_ify(syscall_name) __NR_##syscall_name

一个宏定义,用于将系统调用的名称转换为对应的系统调用号。比如:SYS_ify(open) 会被展开为 __NR_open

而系统调用号,不是 glibc 提供的,是内核提供的,内核提供系统调用入口函数 man 2 syscall,或者直接提供汇编级别软中断命令 int or syscall,并提供对应的头文件或者开发入口,让上层语言的设计者使用系统调用号,完成系统调用过程

被动中断(异常 / Exception):
目的:程序运行时出了“岔子”,CPU无法继续执行,必须通知操作系统来处理。
例子:除零错误、访问空指针(缺页中断)、非法操作码。操作系统收到后通常会终止该进程,或通过“缺页中断”把硬盘数据换进内存让程序继续运行。

这里异常问题全部会被转换为CPU内部的软件中断,然后走中断处理例程,完成所有处理。

理解内核与用户态

·系统调用的过程,也是在进程空间上进行的,所有的函数调用,都是在地址空间之间调转,也就是从进程地址空间的代码区跳转到内核区。在代码区时,就是用户态;跳入内核区,就是内核态。

·在每台机器里,操作系统只有一个,每个进程都把操作系统映射到自己的PCB中,所以内核页表只有一份,而用户页表可以有很多份,一个进程一份用户页表。所以,无论我们如何进行进程调度,都能找到唯一的一个操作系统。

·因为用户和内核空间都在同一个4GB地址空间里,如果用户可以任意访问虚拟地址空间的所有位置,对于内核来说是很不安全的。所以OS为了保护自己,不会选择相信任何人,而是必须通过系统调用的方式来访问内核。

·因此要分开内核态和用户态。内核态下,以内核身份,去通过系统调用方式,访问OS;用户态下,以用户的身份,访问内核空间以外的内容。

·那么OS如何知道自己是处于内核态还是用户态呢?CPU提供了一个CS寄存器,cs=0 就是内核态;cs=3 就是用户态。当我们使用指令集int 0x80或者syscall时就会修改 cs 为3,让进程陷入内核。用户态下访问内核将被视为非法操作直接终止。

*可重入函数

可重入函数是指可以被多个执行流(如多个线程、中断处理程序或信号处理器)同时调用而不会产生数据错乱或逻辑错误的函数。它的核心特征是:函数的执行过程不依赖任何“共享的、可修改的”外部状态。这只是一种特点,可重入和不可重入函数要根据情况选择。

硬性条件

一个函数要做到可重入,通常必须满足以下几点:

1️⃣仅使用局部变量:所有数据都保存在栈空间或CPU寄存器中(每个调用流独立)。
2️⃣不修改全局/静态变量:不读写任何非const的全局变量或静态局部变量。
3️⃣不调用非可重入函数:如果内部调用了 malloc、printf 或标准I/O函数,则通常不可重入(因为这些函数内部使用了全局锁或静态缓冲区)。
4️⃣不持有静态锁:不使用互斥锁(Mutex)或自旋锁(Spinlock)去保护临界区(因为若持有锁时被中断,再次调用会死锁)。

*volatile

volatile关键字修饰过的变量,会保证其内存空间可见性,其核心是禁止编译器对涉及该对象的操作进行某些优化。

作用

1️⃣禁止优化掉访问:对volatile对象的每一次访问(无论是读还是写)都必须执行,不能被优化掉。例如,在微基准测试中,常用 volatile 来防止编译器将一个看似无用的循环优化掉。
2️⃣禁止重排序:在一个单一线程内,对volatile对象的访问不能被优化掉,也不能与其它“可见的副作用”(如I/O操作)进行重排序。

volatile 对象包括

·类型带有 volatile 限定的对象。
·volatile 对象的子对象(subobject)。
·const volatile 对象的 mutable 子对象。

*SIGCHLD信号

SIGCHLD 是Unix/Linux系统中一个非常重要的信号,它在子进程状态发生改变时,由系统内核自动发送给其父进程。

状态发生改变”具体指以下几种情况:

1.子进程终止(正常退出或异常结束)
2.子进程收到SIGSTOP信号而暂停
3.子进程从暂停状态恢复,继续运行

作用

父进程需要关注子进程的状态,主要为了回收子进程资源,避免产生僵尸进程(Zombie Process)。

默认行为是“忽略”:SIGCHLD信号的默认处理动作是SIG_DFL。这意味着如果父进程不主动处理,子进程终止后会变成僵尸进程,占用内核资源。

因此,父进程需要自定义 SIGCHLD 信号的处理函数。在信号处理函数中,调用 wait() 或 waitpid() 系统调用来回收子进程。

如果你显式地将 SIGCHLD 处理方法设为 SIG_IGN(即调用 signal(SIGCHLD, SIG_IGN)),在绝大多数 Linux 系统上,内核会开启优化,自动回收子进程,不会产生僵尸进程。这是显式设置忽略,它的处理逻辑和默认忽略不同。

❤~~本文完结!!感谢观看!!接下来更精彩!!欢迎来我博客做客~~❤

返回列表