欢迎光临
我们一直在努力

【Linux】Ctrl+C 到底做了什么?从键盘、kill 到 alarm,一次讲清信号的产生

封面

🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++ 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

在这里插入图片描述


🏠博主简介 在这里插入图片描述

文章目录

  • 前言:先解决一个混淆度拉满的问题
  • 一、键盘产生信号:Ctrl+C
    • 1.1 信号都有哪些?
  • 二、进程收到信号的三种处理动作
    • 2.1 那么多信号默认动作都是终止,为什么还要分那么多种?
    • 2.2 为什么有的程序不能说死就死
  • 三、signal——更改进程的默认处理动作
  • 四、前台进程与后台进程
    • 4.1 jobs、fg、bg、Ctrl+Z
  • 五、发送信号的本质:OS 修改位图
    • 5.1 信号和 IPC 有关系吗?
    • 5.2 自己写个 mykill
    • 5.3 9 号和 19 号:捕捉不掉的底线
  • 六、raise 与 abort:给自己发信号
  • 七、硬件异常产生信号
  • 八、软件条件产生信号
    • 8.1 alarm:设置闹钟
    • 8.2 用 alarm 掂一掂 IO 的斤两
    • 8.3 闹钟驱动:每隔一秒执行任务
    • 8.4 pause 与"信号驱动的进程"
    • 8.5 内核怎么管理一堆闹钟
  • 总结

前言:先解决一个混淆度拉满的问题

信号和信号量有关系吗? 没有任何关系,就是"老婆"和"老婆饼"的区别。信号量是进程同步互斥那套机制,而信号,解决的是另一件事:事件的异步通知。

生活里的信号到处都是:闹钟、红绿灯、上课铃声、狼烟。你在过马路,红灯亮了,要停下来等待——人相当于进程,正在执行"过马路"这段代码时,信号来了,就要中断当前正在做的事。

什么叫做信号:是一种事件的异步通知机制,是发给进程的。

异步怎么理解?用小明取快递说清楚:

  • 老师正在讲课,快递员打电话说快递到了,老师让全班自习,等小明回来再继续讲——这是同步,取快递把讲课"卡住"了;
  • 老师继续讲课,小明自己去取,讲课和取快递同一时刻同时发生,互不干扰——这是异步。

小明在睡觉,闹钟同时在计时,两者互不干扰,闹钟响了才通知小明。所以信号的产生相对于进程的运行,是异步的,而且信号是发给进程的。

还有两个更关键的点。小明闹钟还没响的时候,他就已经知道"闹钟响了要起床";还没打上课铃,就知道铃响了要上课;红绿灯还没变红,就知道变红不能过马路。这些是谁教的?父母、老师、之前的经验。进程也一样——进程是 OS 程序员设计的,识别和处理信号的方式早就内置在进程里了。

闹钟响了但起不来,可以等会再起;外卖小哥打电话,我正在打游戏出不去,可以等会去拿。

所以基本结论四条:

  • 信号的处理,进程在信号还没产生的时候,早就知道该如何处理了;
  • 信号的处理不是立即处理,可以等一会,在合适的时候处理;
  • 进程能识别信号,是提前被"教育"过的——内置的识别和处理方式;
  • 信号源非常多,给进程产生信号的途径不止一种。
  • 这一篇就回答一个问题:信号从哪里来。从最熟悉的 Ctrl+C 开始,一路过掉 signal、前后台进程、kill、raise、abort、硬件异常、alarm,最后看看内核怎么管理一堆闹钟。


    一、键盘产生信号:Ctrl+C

    先写个最简单的死循环:

    int main()
    {
    int cnt = 0;
    while (true)
    {
    std::cout << "hello world," << cnt++ << std::endl;
    sleep(1);
    }
    }

    程序会一直跑,我们在命令行按 Ctrl+C 直接把它中断。

    在这里插入图片描述

    Ctrl+C 就是给目标进程发送信号。 键盘就相当于闹钟,我们按下 Ctrl+C 相当于闹钟响了;进程收到信号,默认处理动作就是让自己终止——就像我听到闹钟默认动作是起床。

    相当一部分信号的处理动作都是终止,还有一些是让进程暂停。

    1.1 信号都有哪些?

    kill -l

    在这里插入图片描述

    注意看编号:31 之后直接跳到 34,少了 32 和 33,所以 Linux 一共 64 – 2 = 62 个信号。其中:

    • 1-31:普通信号,我们只讨论这些;
    • 34-64:实时信号,本文不考虑。

    什么是普通信号?可以不被立即处理的信号——就像外卖到了等会去拿。实时信号就是需要立即处理的信号。

    信号在计算机里就是一个整数。发信号可以直接发数字,但数字可读性差,所以系统把这些数字定义成了大写的宏,宏对应的值就是数字,比如 SIGINT 就是 2。写代码时用宏,别裸写数字。


    二、进程收到信号的三种处理动作

  • 默认处理动作:比如终止进程;
  • 自定义处理动作:执行用户自己写的方法。就像小红和朋友打赌输了,约定"红绿灯变红时在原地跳舞"——红灯亮了,别人在等待,小红在跳舞;
  • 忽略处理:红灯亮了,小明不管,继续过马路,赌车不敢撞。
  • 再比如肚子饿了(信号产生):立马去吃饭是默认;听说喝西北风能喝饱、去喝西北风是自定义;肚子叫了不管它、继续做事是忽略。

    一个术语要先说清:我们把信号处理的过程整体叫做信号捕捉(信号处理)。不只是自定义才叫捕捉——默认处理和忽略处理也都是信号捕捉!

    2.1 那么多信号默认动作都是终止,为什么还要分那么多种?

    虽然很多信号默认动作都是终止,但它们触发的来源、代表的事件完全不一样。信号不是为了"杀进程"而设计的,本质是内核给进程传递不同类型事件的通知机制,终止只是事件发生后的默认结局。

    类比医院的三种警报:

    • 火警警报 → 默认行为:全员撤离(终止)
    • 毒气泄漏警报 → 默认行为:全员撤离(终止)
    • 爆炸警报 → 默认行为:全员撤离(终止)

    结局都是撤离,但三种警报代表的事故完全不同。

    信号触发事件默认动作
    SIGINT 2 用户按 Ctrl+C,终端主动打断 终止
    SIGKILL 9 外部 kill -9 强制杀掉 终止
    SIGTERM 15 普通 kill 命令,礼貌请求退出 终止
    SIGALRM 14 alarm 闹钟时间到 终止
    SIGPIPE 13 往已关闭读端的管道写数据 终止

    默认行为只是兜底方案,真正价值是把"发生了什么事"通知进程。

    2.2 为什么有的程序不能说死就死

    想象一个程序:打开了文件正在写磁盘、内存里有缓冲区没刷下去、创建了子进程、申请了锁和共享内存等 IPC 资源。这时候直接执行默认终止——进程瞬间消失:

    • 内存缓冲区的数据直接丢,文件损坏;
    • 子进程变孤儿;
    • IPC 锁没人释放,别的进程拿不到锁直接卡死。

    所以大量程序需要自定义 SIGINT 处理:捕获 2 号信号,不立刻死,先刷缓存、关文件、释放锁、回收子进程,做完所有善后再自己 exit 退出。


    三、signal——更改进程的默认处理动作

    Ctrl+C 发的是几号信号?怎么证明?改掉进程对这个信号的默认处理动作,看它收到的是几号。用系统调用 signal:

    sighandler_t signal(int signum, sighandler_t handler);

    • 参数 1 signum:普通信号对应的数字或宏;
    • 参数 2:typedef 出来的函数指针类型,传入对应的方法。

    在这里插入图片描述

    把 SIGINT 转到定义,会发现它确实就是个宏:

    在这里插入图片描述

    代码演示:

    // 传入的参数是执行该函数时进程收到的信号值
    // signal 接口会将信号编号传给这个 sig 参数
    void handlerSig(int sig)
    {
    std::cout << "获得了一个信号:" << sig << std::endl;
    }

    int main()
    {
    // 注意:只需要调用一次,不需要循环调用
    signal(SIGINT, handlerSig);

    int cnt = 0;
    while (true)
    {
    std::cout << "hello world," << cnt++ << std::endl;
    sleep(1);
    }
    }

    在这里插入图片描述

    一直 Ctrl+C,进程不再终止,而是反复执行 handlerSig——这就证明了 Ctrl+C 是给进程发送 2 号信号。

    两个细节:

    • 如果这种写法导致进程退不出去,用 Ctrl+\\ 终止,它发的是 3 号信号;
    • signal 只是提前注册方法,如果信号一直不来,这个函数永远不会被调用。

    那几十个信号的默认动作怎么查?

    man 7 signal

    在这里插入图片描述

    Action 列就是处理动作:Term 终止、Core 终止并 core dump、Ign 忽略、Stop 暂停、Cont 从暂停处继续。Core 的特殊性下一篇细说。


    四、前台进程与后台进程

    跑着上面那个死循环时,你会发现输入 ls、pwd 没有任何反应:

    在这里插入图片描述

    给进程后面加个 &:

    ./testsig &

    这时 Ctrl+C 杀不掉它了,但 ls、pwd 又能正常执行:

    在这里插入图片描述

    • 命令行直接 ./xxx:前台进程;
    • ./yyy &:后台进程。

    为什么?我们登录 Linux 时,系统创建的 bash 进程默认在前台等着接键盘输入。运行命令时 bash 创建子进程,子进程默认成为前台进程,bash 自动被放到后台。所以我们的程序在前台时,输入的 ls、pwd 是交给它的——它没实现这些指令,自然没反应。

    键盘只有一个,输入数据必须给一个确定的进程。bash 要求前台进程只能有一个,后台可以有多个。前台进程的本质就是从键盘获取数据。 而无论前台后台,都可以向显示器打印。

    键盘产生的信号,只能发给前台进程,所以 Ctrl+C 对后台进程无效。后台进程用命令杀:

    ps ajx | head -1 && ps ajx | grep testSig | grep -v grep
    kill -9 PID

    在这里插入图片描述 在这里插入图片描述

    9 号信号就是 SIGKILL,默认动作终止进程。

    孤儿进程的结论也能对上了:父进程在时,父子都在前台,Ctrl+C 杀得掉;父进程退出后子进程被 1 号进程领养,所以孤儿进程 Ctrl+C 杀不掉。

    4.1 jobs、fg、bg、Ctrl+Z

    jobs # 查看后台任务
    fg 任务号 # 把后台任务提到前台
    bg 任务号 # 让后台停止的任务恢复运行

    Ctrl+Z 的本质要搞清楚:不是"把进程放到后台",而是给前台进程发 SIGTSTP 信号让它暂停。前台进程不能被暂停——前台永远要接收用户输入,一旦暂停用户按键盘就没反应了——所以进程一暂停,就被自动提到后台,Shell 重新接管命令行。

    在这里插入图片描述 在这里插入图片描述

    到这里,"Ctrl+C 是给目标进程发送信号"里的目标进程指什么就很清楚了:前台进程。


    五、发送信号的本质:OS 修改位图

    信号可以等一会再处理——外卖到了在打游戏,等会去取。但如果忘了呢?那就永远不处理了。所以人必须把要处理的事记录下来,进程也必须把信号记录下来。

    还有一个硬性规则:进程不能收到信号立刻处理,只能在"内核态→用户态切换的时机"处理,这是 Linux 内核定的,第三篇细讲。

    信号记录在哪?信号是发给进程的,自然存在进程的控制块 task_struct 里。多个信号怎么存?位图:比特位的位置是信号编号,内容 0/1 表示是否收到。

    struct task_struct
    {
    unsigned int sigs; // 教学模型:一个位图
    }

    于是发送信号的本质浮出水面:向目标进程写信号 = 修改位图。修改位图需要 pid + 信号编号:前台进程唯一确定,只要编号;后台进程就是 kill -9 PID 这种编号加 pid。

    关键来了:task_struct 是 OS 内核的数据结构,写信号就是修改内核数据。普通用户不能改内核数据,只有 OS 自己能改。 所以不管产生信号的方式有多少种,底层必须由 OS 来发送——操作系统必须提供发送信号的系统调用,这就是 kill 存在的原因:

    int kill(pid_t pid, int sig);

    在这里插入图片描述

    键盘按 Ctrl+C 同理:OS 是硬件的管理者,组合键一定先被 OS 识别,再由 OS 修改前台进程的位图。

    5.1 信号和 IPC 有关系吗?

    狭义上,IPC 是进程间通信,数据从用户到用户;信号是 OS 和进程之间的关系。但广义上,信号传递了事件信息,可以归入通信范畴。

    5.2 自己写个 mykill

    产生信号的第二种方式:系统调用。写个简化版 kill 命令:

    // 使用方式:./myskill signumber pid
    int main(int argc, char* argv[])
    {
    if (argc != 3)
    {
    std::cout << "格式应为:./myskill signumber pid" << std::endl;
    return 1;
    }
    int signum = std::stoi(argv[1]); // 信号编号:字符转整数
    pid_t target = std::stoi(argv[2]); // pid:字符转 pid_t

    int n = kill(target, signum);
    if (n == 0)
    std::cout << "发送" << signum << "给" << target << "成功了!" << std::endl;
    return 0;
    }

    在这里插入图片描述

    kill 命令底层调用的就是这个 kill 函数——命令和系统调用接口是两回事,一个是命令行工具,一个是函数。

    5.3 9 号和 19 号:捕捉不掉的底线

    把 1-31 号信号的默认动作全改掉:

    void handlerSig(int sig)
    {
    std::cout << "获得了一个信号:" << sig << std::endl;
    }

    for (int i = 1; i < 32; i++)
    signal(i, handlerSig);

    键盘、系统调用都杀不掉它了。要是病毒也这么干,电脑不就瘫痪了?kill -9 依然能杀:

    在这里插入图片描述 在这里插入图片描述

    大部分信号可以被自定义捕捉,但 9 号(SIGKILL)和 19 号(SIGSTOP)不能被自定义捕捉——这是系统防止恶意进程的底线。


    六、raise 与 abort:给自己发信号

    raise:给当前进程发送指定信号(自己给自己发)。

    int raise(int sig);

    在这里插入图片描述

    for (int i = 1; i < 32; i++)
    {
    sleep(1);
    raise(i);
    }

    在这里插入图片描述

    abort:使当前进程异常终止,相当于给自己发 6 号信号(SIGABRT)。

    void abort(void);

    在这里插入图片描述

    奇怪的事来了:前面 raise 循环时 6 号信号被自定义捕捉了,进程没退出;换成 abort,即使捕捉了 SIGABRT,进程还是退出了(Aborted):

    在这里插入图片描述

    abort 的特殊之处:它要求进程必须处理这个信号,内部会把自定义捕捉恢复成默认,保证最终形成异常终止语义。


    七、硬件异常产生信号

    程序崩掉的两大常客:除 0 和野指针。

    void handlerSig(int sig)
    {
    std::cout << "获得了一个信号:" << sig << std::endl;
    exit(13);
    }

    int main()
    {
    for (int i = 1; i < 32; i++)
    signal(i, handlerSig);

    int cnt = 0;
    while (true)
    {
    sleep(1);
    std::cout << "hello world," << cnt++ << ",pid:" << getpid() << std::endl;
    int a = 10;
    a /= 0; // 除 0 错误
    }
    }

    进程收到 8 号信号(SIGFPE,浮点数错误),挂掉了:

    在这里插入图片描述

    换野指针,收到 11 号信号(SIGSEGV,段错误):

    int *p = nullptr;
    *p = 100;

    在这里插入图片描述

    这就解释了程序为什么会"崩"——因为进程收到了信号。而且再次验证:信号全部由 OS 发送,OS 识别出进程犯错的类型,给目标进程发对应信号。

    那 OS 怎么知道进程犯错了?

    除 0:计算在 CPU 上跑,CPU 有各类寄存器,其中**状态/标志寄存器(EFLAGS)**由 32/64 个比特位组成,有一个比特位专门表示当前计算是否溢出。CPU 寄存器保存的是当前进程的上下文,CPU 是硬件,OS 是软硬件资源的管理者——程序出错,硬件上先体现为溢出标志被置位,OS 识别到,给目标进程发 8 号信号。

    野指针:访问空指针就是访问 0 号虚拟地址,0 号地址在页表里不存在映射。CPU 寄存器拿到的都是虚拟地址,CR3 寄存器记录当前进程页表的起始地址;CPU 内部有 MMU 硬件单元,虚拟地址交给 MMU、CR3 的内容也交给 MMU,做虚拟到物理的转换。MMU 转换失败(查页表查不到),硬件报错,OS 中断它,给进程发 11 号信号。


    八、软件条件产生信号

    进程间管道通信:A 进程往管道写,B 进程不但不读,还把读端关了——OS 识别到这种软件层面的错误,给进程发 SIGPIPE。OS 不做任何浪费时间和空间的事情:读端都没了,写端还留着干嘛。

    软件条件里更重要的是 alarm。

    8.1 alarm:设置闹钟

    unsigned int alarm(unsigned int seconds);

    告诉内核 seconds 秒之后给当前进程发 SIGALRM(默认终止当前进程)。

    在这里插入图片描述

    返回值是最容易搞混的点:返回 0 或以前设定的闹钟还余下的秒数。

    打个比方:某人小睡,定闹钟 30 分钟后响;20 分钟后被人吵醒,想再睡会,重新设了 15 分钟——"以前设定的闹钟还余下的时间"就是 10 分钟。如果 seconds 为 0,表示取消以前设定的闹钟,返回值仍然是之前闹钟的余下秒数。

    • 第一次调 alarm(5):返回 0;
    • 3 秒后再调 alarm(10):返回 2(上一个闹钟还剩 2 秒)。

    8.2 用 alarm 掂一掂 IO 的斤两

    void handlerSig(int sig)
    {
    std::cout << "获得了一个信号:" << sig << std::endl;
    exit(13);
    }

    int main()
    {
    for (int i = 1; i < 32; i++)
    signal(i, handlerSig);

    alarm(1); // 1 秒后收到信号
    int cnt = 0;
    while (true)
    {
    std::cout << "count:" << cnt++ << std::endl;
    }
    }

    在这里插入图片描述

    1 秒打印 4 万次左右,效率很低。因为打印本质是 IO:xshell 获取命令到云服务器运行,运行结果还要通过网络发回显示端。

    把打印换成纯计算:

    int cnt = 0;

    void handlerSig(int sig)
    {
    std::cout << "获得了一个信号:" << sig << "cnt:" << cnt << std::endl;
    exit(13);
    }

    int main()
    {
    signal(SIGALRM, handlerSig);
    alarm(1);
    while (true) cnt++;
    }

    在这里插入图片描述

    这是 5 亿次。 纯计算用 CPU,打印要走外设、走网络——冯诺依曼体系决定的,外设效率远低于 CPU。这两张图放一起,IO 慢在哪里不用背了。

    8.3 闹钟驱动:每隔一秒执行任务

    handler 里重新 alarm(1),闹钟就变成周期性的:

    void handlerSig(int sig)
    {
    std::cout << "获得了一个信号:" << sig << "pid:" << getpid() << std::endl;
    alarm(1); // 重新设闹钟,周期触发
    }

    int main()
    {
    signal(SIGALRM, handlerSig);
    alarm(1);
    while (true)
    {
    std::cout << ".," << "pid:" << getpid() << std::endl;
    sleep(1);
    }
    }

    在这里插入图片描述

    pid 一直是同一个——同一个进程每隔一秒收到信号,执行自定义捕捉。

    8.4 pause 与"信号驱动的进程"

    想让进程平时什么都不做,一来信号就被唤醒执行方法?pause:等待信号,没有信号时进程暂停。

    int pause(void);

    在这里插入图片描述

    把一组周期任务挂到闹钟上:

    void Sched() { std::cout << "我是一个进程调度" << std::endl; }
    void MemManger() { std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl; }
    void Fflush() { std::cout << "我是刷新程序,我在定期刷新内存数据到磁盘" << std::endl; }

    using func_t = std::function<void()>;
    std::vector<func_t> funcs;

    void handlerSig(int sig)
    {
    std::cout << "############################" << std::endl;
    for (auto f : funcs) f();
    std::cout << "#############################" << std::endl;
    alarm(1);
    }

    int main()
    {
    funcs.push_back(Sched);
    funcs.push_back(MemManger);
    funcs.push_back(Fflush);

    signal(SIGALRM, handlerSig);
    alarm(1);

    while (true) pause(); // 平时暂停,被信号驱动
    }

    进程平时暂停,在外部的催促下每隔一秒被信号唤醒执行任务——这就是操作系统的样子。OS 一开机就是个死循环,它不是自己主动跑起来的,而是不断被外部刺激(时钟中断)催着,定期执行自己注册的方法。

    8.5 内核怎么管理一堆闹钟

    进程被 OS 调度,那 OS 自己被谁驱动?定期的外部刺激:时钟中断。OS 内部有大量闹钟,既然多,就得先描述、再组织:

    struct timer_list {
    struct list_head entry;
    unsigned long expires; // 过期时间
    void (*function)(unsigned long); // 闹钟要执行的方法
    unsigned long data;
    struct tvec_t_base_s *base;
    };

    组织方式可以按最小堆理解:堆顶就是超时时间最近的闹钟。OS 只拿堆顶的超时时间和当前时间比,当前时间一到,取出堆顶、堆自动调整,同时执行函数指针对应的方法——向目标进程发送 SIGALRM。

    闹钟是软件实现的,超时就是软件条件,这就是"软件条件产生信号"。OS 的时间戳可以理解成一个计数器,被时钟中断刺激一次加一次,计数器的值就代表开机到现在多久了。


    总结

    信号产生的五种方式,总结收尾:

    键盘(Ctrl+C/Ctrl+\\)
    系统调用(kill/raise/abort)
    系统命令(kill -9)
    硬件异常(除0 → SIGFPE,野指针 → SIGSEGV)
    软件条件(SIGPIPE/alarm 超时)

    全部由 OS 发送

    本质:修改目标进程 task_struct 里的位图

    不管来源是什么,发信号的动作永远由 OS 完成,本质都是修改比特位。进程收到后先记下来,合适的时候再处理。

    那"记下来"具体记在哪、怎么记?为什么信号还能被"阻塞"?SIGSEGV 为什么有的机器上会生成 core 文件?下一篇从 pending、block 讲到 Core Dump。

    觉得有帮助的话点个赞收藏,评论区聊聊你第一次见 abort 捕捉后还是退出时的表情。

    资源分享: 【Linux】共享内存为什么快?System V 的 key、shmget、shmat 与进程通信实战 【Linux】两个毫无关系的进程怎么通信?命名管道 FIFO 从原理到 Server/Client 实战 【Linux】从匿名管道到进程池:任务派发、fd 继承 Bug 与完整实现

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux】Ctrl+C 到底做了什么?从键盘、kill 到 alarm,一次讲清信号的产生
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址