目录
一、键盘也能发信号
在Linux系统中,我们可以通过终端按键来产生信号,进而控制进程的行为。
1. 终端按键与信号
最常用的两种组合键是Ctrl+C和Ctrl+\。按Ctrl+C可以向进程发送2号信号(SIGINT),而按Ctrl+\则发送3号信号(SIGQUIT)。
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1){
printf("hello signal!\n"); // 每隔一秒打印一次
sleep(1);
}
return 0;
}
这两个信号的默认处理动作有所不同。SIGINT(2号信号)的默认动作是终止进程(Term),而SIGQUIT(3号信号)除了终止进程外,还会进行核心转储(Core Dump)。
2. 核心转储是什么?
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。
在云服务器中,核心转储功能默认是关闭的。可以通过ulimit -a命令来查看:
我们可以用 ulimit -c size
命令来改变Shell进程的Resource Limit,设置core文件的大小。例如,ulimit -c 1024
表示允许core文件最大为1024K。
运行程序后,按Ctrl+\会终止进程并生成core文件。
3. 核心转储的作用与调试应用
3.1 核心转储的核心价值
核心转储(Core Dump)是程序异常终止时生成的一个“程序崩溃快照”,记录了崩溃瞬间的内存状态、寄存器值、函数调用栈等关键信息。它的核心作用在于:
-
精准定位崩溃原因
当程序因段错误(如野指针)、算术错误(如除零)或未处理信号(如SIGSEGV)崩溃时,常规日志可能无法捕获具体位置。核心转储文件结合调试工具可直接回溯到崩溃时的代码位置。 -
事后调试(Post-mortem Debugging)
无需复现崩溃场景,通过分析转储文件即可还原崩溃现场,尤其适合生产环境中偶发问题的排查。 -
脱离实时调试的限制
对于复杂或长时间运行的程序,实时调试可能不现实,核心转储提供了一种离线分析手段。
3.2 如何利用核心转储调试程序
步骤示例:以除零错误为例
-
启用核心转储
在终端执行ulimit -c unlimited
,解除系统对核心文件大小的限制。ulimit -a # 查看当前限制(确认core file size是否为unlimited)
-
复现崩溃并生成core文件
运行存在问题的程序(例如以下代码):#include <stdio.h> int main() { sleep(3); // 模拟程序运行一段时间后崩溃 int a = 10 / 0; // 触发SIGFPE(8号信号) return 0; }
程序崩溃后会在当前目录生成
core
文件。 -
使用GDB分析core文件
GDB会直接定位到导致崩溃的代码行(如
int a = 10 / 0;
),并显示终止信号类型(此处为SIGFPE)。
3.3 Core Dump标志与进程状态
通过 waitpid
获取的子进程状态(status
)中隐藏了关键信息:
-
正常退出:次低8位(
(status >> 8) & 0xFF
)为退出码(如exit(5)
的5)。 -
信号终止:低7位(
status & 0x7F
)为终止信号编号,第8位((status >> 7) & 1
)标记是否生成core文件。
代码验证示例:
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
if (fork() == 0)
{
int *p = NULL;
*p = 100; // 触发SIGSEGV(11号信号)并生成core文件
exit(0);
}
int status;
waitpid(-1, &status, 0);
printf("ExitCode:%d, CoreDump:%d, Signal:%d\n",
(status >> 8) & 0xFF, (status >> 7) & 1, status & 0x7F);
return 0;
}
输出结果:
表明子进程因信号11终止并生成了core文件。
3.4 信号处理与特殊限制
-
不可捕获的信号
SIGKILL(9号)
和SIGSTOP(19号)
无法被自定义处理,确保系统管理员始终能强制终止失控进程。 -
组合键与信号映射
-
Ctrl+C
→SIGINT(2号)
:请求进程终止(可被捕获或忽略)。 -
Ctrl+\
→SIGQUIT(3号)
:强制终止并生成core文件。 -
Ctrl+Z
→SIGTSTP(20号)
:暂停进程并放入后台(可用fg
恢复)。
-
信号捕获实验
以下代码尝试捕获所有信号(实际部分信号无法捕获):
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("Caught signal: %d\n", sig);
}
int main()
{
for (int i = 1; i <= 31; i++)
signal(i, handler); // 忽略SIGKILL/SIGSTOP的报错
while (1)
pause();
return 0;
}
-
Ctrl+C
会输出Caught signal: 2
,但进程不会终止(因默认行为被覆盖)。 -
kill -9 PID
仍会立即终止进程,证明SIGKILL无法被捕获。
二、系统函数发信号
在Linux系统中,我们可以通过系统函数来发送信号,进而控制进程的行为。常见的系统函数有
kill
、raise
和abort
。
1. kill函数
kill
函数可以向指定的进程发送信号,成功返回0,错误返回-1。函数原型为:int kill(pid_t pid, int sig)
例如,
kill(1234, 2)
向进程ID为1234的进程发送2号信号。
首先在后台执行死循环程序,然后用kill命令给它发SIGSEGV信号.
#include <stdio.h>
int main()
{
while(1);
return 0;
}
- 128951是test进程的id。之所以要再次回车才显示 Segmentation fault ,是因为在128951进程终止掉之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用户的输入交错在一起,所以等用户输入命令之后才显示。
- 指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成 kill -SIGSEGV 128951 或 kill -11 128951 , 11是信号SIGSEGV的编号。以往遇到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。
2. raise函数
raise
函数用于给当前进程发送指定信号,即自己给自己发送信号。函数原型为
int raise(int sig)//成功返回0,错误返回-1。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
printf("get a signal:%d\n", signo); // 打印收到的信号编号
}
int main()
{
signal(2, handler); // 捕捉2号信号
while (1){
sleep(1);
raise(2); // 每隔一秒向自己发送2号信号
}
return 0;
}
3. abort函数
abort
函数使当前进程接收到信号而异常终止,它通过向进程发送SIGABRT信号来实现。
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
#include <stdlib.h>
void abort(void);
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void handler(int signo)
{
printf("get a signal:%d\n", signo); // 打印收到的信号编号
}
int main()
{
signal(6, handler); // 捕捉6号信号
while (1){
sleep(1);
abort(); // 向自己发送SIGABRT信号
}
return 0;
}
三、软件条件产生信号
1. SIGALRM信号与alarm函数
SIGALRM
信号是由alarm
函数产生的。alarm
函数用于设置一个定时器,当定时器超时时,会向进程发送一个SIGALRM
信号。这个信号的默认处理动作是终止进程,但我们可以自定义信号处理函数来执行特定的操作。
alarm函数的使用
alarm
函数的原型如下:
unsigned int alarm(unsigned int seconds);
-
seconds
:指定定时器的时间(以秒为单位)。 -
返回值:如果在调用
alarm
之前已经设置了定时器,则返回上一个定时器剩余的时间(以秒为单位);如果之前没有设置定时器,则返回0。
示例:使用alarm函数和SIGALRM信号
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 定义一个全局变量来记录计数
volatile int count = 0;
// SIGALRM信号处理函数
void handle_alarm(int signo)
{
if (signo == SIGALRM)
{
printf("\nAlarm signal received. Count: %d\n", count);
// 重新设置定时器,实现周期性触发
alarm(1);
}
}
int main()
{
// 注册SIGALRM信号处理函数
signal(SIGALRM, handle_alarm);
// 设置1秒的定时器
alarm(1);
printf("Waiting for alarm signal...\n");
// 在等待信号的过程中进行其他操作
while (1)
{
count++;
// 执行其他任务...
sleep(1); // 模拟其他任务的执行时间
}
return 0;
}
程序说明
-
程序中定义了一个全局变量
count
,用于记录循环的次数。 -
handle_alarm
函数是SIGALRM
信号的处理函数,当接收到SIGALRM
信号时,会打印当前的计数值,并重新设置一个1秒的定时器。 -
在
main
函数中,首先注册了SIGALRM
信号处理函数,然后设置了一个1秒的定时器。 -
程序进入一个无限循环,在循环中不断增加
count
的值,并模拟执行其他任务。 -
当定时器超时时,会触发
SIGALRM
信号,从而调用handle_alarm
函数。
运行结果
运行程序后,每秒钟会打印一次当前的计数值,直到程序被手动终止。示例输出如下:
在这个示例中,我们通过alarm
函数设置了一个周期性的定时器,每当定时器超时时,都会触发SIGALRM
信号,从而执行自定义的信号处理函数。这在需要周期性执行某些任务的场景中非常有用。
2. SIGPIPE信号
SIGPIPE
信号是在管道通信中产生的。当一个进程向管道的写端写入数据,而读端已经被关闭时,写进程会收到SIGPIPE
信号。这个信号的默认处理动作是终止进程。
示例:产生SIGPIPE信号
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/types.h>
int main()
{
int pipefd[2];
pid_t pid;
// 创建管道
if (pipe(pipefd) == -1)
{
perror("pipe");
exit(EXIT_FAILURE);
}
// 创建子进程
pid = fork();
if (pid == -1)
{
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0)
{
// 子进程:关闭读端,向写端写入数据
close(pipefd[0]);
printf("Child process is writing to the pipe.\n");
write(pipefd[1], "Hello, World!", 13);
close(pipefd[1]);
exit(EXIT_SUCCESS);
}
else
{
// 父进程:关闭写端,模拟读端关闭的情况
close(pipefd[1]);
printf("Parent process is closing the read end of the pipe.\n");
close(pipefd[0]);
wait(NULL); // 等待子进程结束
}
return 0;
}
程序说明
-
程序中创建了一个管道和一个子进程。
-
子进程关闭管道的读端,然后向写端写入数据。
-
父进程关闭管道的写端,然后关闭读端,模拟读端关闭的情况。
-
当子进程向管道的写端写入数据时,由于读端已经被关闭,子进程会收到
SIGPIPE
信号并被终止。
运行结果
运行程序后,输出如下:
在这个示例中,当父进程关闭管道的读端后,子进程向管道的写端写入数据时会触发SIGPIPE
信号,导致子进程被终止。这展示了SIGPIPE
信号的产生和处理机制。
3. 其他软件条件产生的信号
除了SIGALRM
和SIGPIPE
信号外,还有其他一些信号是由软件条件产生的。例如:
-
SIGUSR1
和SIGUSR2
:这两个信号是用户自定义的信号,可以用于进程间的通信。 -
SIGCHLD
:当子进程终止或停止时,父进程会收到这个信号。 -
SIGCONT
:当进程继续执行时(例如从暂停状态恢复),会收到这个信号。
这些信号都可以通过kill
函数或其他方式发送,并且可以自定义信号处理函数来实现特定的功能。
四、硬件异常产生信号
在Linux系统中,硬件异常产生的信号是由硬件检测到的异常情况触发的。当CPU或其他硬件设备检测到某种异常条件时,会通知内核,内核随后向当前进程发送相应的信号。
1. SIGFPE信号(浮点异常)
SIGFPE
信号通常是由算术运算中的异常情况触发的,例如除以零或溢出。当进程执行了这样的操作时,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE
信号并发送给进程。
示例:除以零触发SIGFPE信号
#include <stdio.h>
#include <signal.h>
// 信号处理函数
void handler(int sig)
{
if (sig == SIGFPE)
{
printf("Caught SIGFPE signal: Arithmetic exception occurred.\n");
}
}
int main()
{
// 注册SIGFPE信号处理函数
signal(SIGFPE, handler);
printf("Simulating division by zero...\n");
int a = 10;
int b = 0;
int result = a / b; // 触发除以零异常
printf("Result: %d\n", result);
return 0;
}
程序说明
-
程序中定义了一个信号处理函数
handler
,用于处理SIGFPE
信号。 -
在
main
函数中,注册了SIGFPE
信号处理函数。 -
程序模拟了除以零的操作,触发
SIGFPE
信号。 -
当信号被触发时,调用
handler
函数,打印异常信息。
运行结果
在这个示例中,当程序执行除以零的操作时,触发了SIGFPE
信号,信号处理函数捕获并处理了这个信号,避免了程序直接崩溃。
2. SIGSEGV信号(段错误)
SIGSEGV
信号是由非法内存访问触发的。当进程尝试访问未分配或不可访问的内存地址时,内存管理单元(MMU)会产生异常,内核将这个异常解释为SIGSEGV
信号并发送给进程。
示例:野指针访问触发SIGSEGV信号
#include <stdio.h>
#include <signal.h>
// 信号处理函数
void handler(int sig)
{
if (sig == SIGSEGV)
{
printf("Caught SIGSEGV signal: Invalid memory access occurred.\n");
}
}
int main()
{
// 注册SIGSEGV信号处理函数
signal(SIGSEGV, handler);
printf("Simulating invalid memory access...\n");
int *p = NULL; // 定义一个空指针
*p = 100; // 试图通过空指针访问内存,触发SIGSEGV信号
return 0;
}
程序说明
-
程序中定义了一个信号处理函数
handler
,用于处理SIGSEGV
信号。 -
在
main
函数中,注册了SIGSEGV
信号处理函数。 -
程序通过空指针访问内存,触发
SIGSEGV
信号。 -
当信号被触发时,调用
handler
函数,打印异常信息。
运行结果
在这个示例中,当程序通过空指针访问内存时,触发了SIGSEGV
信号,信号处理函数捕获并处理了这个信号,避免了程序直接崩溃。
3. 默认行为与自定义处理
如果不注册信号处理函数,进程在接收到SIGFPE
或SIGSEGV
信号时会执行默认动作,通常是终止进程。通过注册信号处理函数,我们可以自定义信号的处理逻辑,使程序在遇到异常时能够进行适当的处理,而不是直接崩溃。
示例:默认行为与自定义处理的对比
#include <stdio.h>
#include <signal.h>
// 信号处理函数
void handler(int sig)
{
if (sig == SIGSEGV)
{
printf("Caught SIGSEGV signal: Invalid memory access occurred.\n");
}
}
int main()
{
// 注册SIGSEGV信号处理函数
signal(SIGSEGV, handler);
printf("Simulating invalid memory access...\n");
int *p = NULL; // 定义一个空指针
*p = 100; // 试图通过空指针访问内存,触发SIGSEGV信号
return 0;
}
默认行为
如果不注册信号处理函数,程序在触发SIGSEGV
信号时会直接终止,并显示Segmentation fault (core dumped)
。
自定义处理
通过注册信号处理函数,程序在触发SIGSEGV
信号时会调用自定义的处理函数,打印异常信息,而不是直接终止。
五、总结思考
1. 为什么所有信号的产生最终都要OS来执行?
操作系统(OS)是计算机系统的的核心管理程序,负责管理和调度硬件资源以及系统中的所有进程。信号的产生和处理涉及到进程的状态变化和资源调度,因此必须由操作系统来统一管理和执行。具体原因如下:
进程管理:操作系统负责创建、调度和终止进程。信号的产生往往需要改变进程的执行状态,例如暂停、终止或执行特定的处理函数,这些操作需要操作系统的介入。
资源分配:操作系统管理着系统的硬件资源,如CPU、内存等。信号的处理可能涉及到资源的重新分配和调度,例如在处理
SIGALRM
信号时,需要重新设置定时器,这需要操作系统的资源管理功能。硬件抽象:操作系统提供了硬件抽象层,将硬件的具体细节封装起来。硬件异常(如除以零、非法内存访问)需要由操作系统转换为软件可识别的信号,因此信号的产生需要操作系统的介入。
2. 信号的处理是否是立即处理的?
信号的处理并不是立即执行的,而是在合适的时候进行处理。具体来说,当一个信号被发送到进程时,操作系统会将该信号记录在进程的信号队列中。进程在以下几种情况下会检查并处理信号:
进程从内核态返回用户态时:当进程执行系统调用返回用户态时,操作系统会检查是否有待处理的信号。
进程被调度时:当进程被操作系统调度程序选中准备运行时,会检查是否有信号需要处理。
进程主动检查信号:在某些情况下,进程可以通过系统调用(如
sigpending
)主动检查是否有未处理的信号。这种机制确保了信号处理的灵活性和效率,避免了信号处理对正常流程的频繁打断。
3. 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
如果信号不能立即处理,操作系统会将信号记录在进程的信号队列中。信号队列是操作系统为每个进程维护的一个数据结构,用于保存未处理的信号。当进程处于以下状态时,信号会被记录:
进程处于运行状态:如果信号不能立即处理,操作系统会将信号加入进程的信号队列。
进程处于阻塞状态:如果进程被阻塞,信号同样会被记录在信号队列中,等待进程被唤醒后处理。
信号队列的管理由操作系统负责,确保信号在适当的时候被处理。
4. 一个进程在没有收到信号的时候,能否知道,自己应该对合法信号作何处理呢?
进程在没有收到信号之前,可以通过以下方式预先定义对信号的处理方式:
默认信号处理:操作系统为每个信号定义了默认的处理动作。例如,
SIGINT
的默认动作是终止进程,SIGSEGV
的默认动作是生成核心转储并终止进程。自定义信号处理:进程可以在运行过程中通过系统调用(如
signal
或sigaction
)注册自定义的信号处理函数。这样,当信号到来时,操作系统会调用进程注册的处理函数而不是执行默认动作。通过这种方式,进程可以在信号到来之前就定义好如何处理各种信号。
5. 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
操作系统向进程发送信号是一个涉及多个步骤的过程,以下是完整的发送和处理流程:
信号产生:
硬件异常:当CPU检测到硬件异常(如除以零、非法内存访问),会通知操作系统。
软件条件:通过系统调用(如
alarm
)或程序逻辑(如管道通信)产生信号。用户操作:用户通过终端按键(如
Ctrl+C
)或系统命令(如kill
)发送信号。信号传递:
操作系统将信号传递给目标进程。这涉及到将信号信息记录在进程的信号队列中,并更新进程的状态。
信号处理:
信号检查:当进程从内核态返回用户态、被调度运行或主动检查信号时,操作系统会检查进程的信号队列。
信号分发:如果进程对信号注册了自定义处理函数,操作系统会调用该函数。否则,执行默认的处理动作。
进程响应:进程根据信号处理函数的逻辑或默认动作进行相应的响应,如终止、忽略或执行特定操作。
信号后续:
信号清除:信号处理完成后,操作系统从进程的信号队列中移除该信号。
进程恢复:进程继续执行正常流程或根据信号处理结果进入新的状态。