Linux学习:进程信号

前面我们已经学习过Linux的进程之间的通信机制。但是Linux的进程通信会导致异步和互斥的问题。这个时候就需要进程信号来提醒。

本期相关的代码已经上传至作者的个人gitee:楼田莉子/Linux学习喜欢请点个赞谢谢

目录

认识信号

信号的概念

信号的生命周期

信号的系统调用

信号的产生

键盘产生的信号

信号产生的系统调用

kill

raise

abort

代码示例

软件条件产生信号

代码验证

硬件条件产生信号

[Core Dump](#Core Dump)

信号的保存

信号的其他概念

信号在内核的表示

sigset_t

信号集操作函数调用

sigemptyset函数

sigfillset函数

sigaddset函数

sigdelset函数

sigismember函数

sigprocmask函数

sigpending函数

补充:

代码案例

信号的捕捉

捕捉流程

系统调用函数

可重入函数

核心定义

关键特性

技术实现要求

应用场景

示例分析

与线程安全的关系

volatite

本义

应用场景

与进程间信号的联系

误区

规范

现代C/C++中的替代方案

现代信号处理范式

SIGCHLD信号

触发条件

处理机制

僵尸进程与防止僵尸进程


认识信号

信号的概念

信号是外部或其他人或硬件向OS内核向进程发送的一种异步事件通知机制。 它的核心作用是 通知进程某个特定事件已经发生,并要求进程以预定义的方式对该事件做出响应。

信号的处理通常分为三种:默认、忽略、自定义

信号的生命周期

信号的生命周期分为三大部分:信号产生、信号保存、信号处理

以如下的代码为例:

cpp 复制代码
#include <iostream>
#include <unistd.h>
int main()
{
    while(true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

参考以下结果:

用户启动了一个前台进程,随后按下了Ctrl+C,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出

信号的产生由键盘输入或kill指令进行,但是键盘智能控制前台进程不能控制后台进程------因为只有前台进程能用键盘输入

信号的系统调用

信号处理函数 signal() :用于设置进程对特定信号的响应方式

cpp 复制代码
#include <signal.h>

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

参数说明

signum :信号编号,用于指定要处理的信号。常见信号如:

SIGINT (2) - 中断信号(通常由 Ctrl+C 产生)

SIGSEGV (11) - 段错误信号

SIGTERM (15) - 终止信号

等。具体信号编号因系统而异,建议使用宏名称而非直接数字。

handler : 函数指针,类型为 sighandler_t,指向用户定义的信号处理函数。

该函数接受一个 int 类型参数(信号编号),无返回值。

也可使用以下特殊值:

SIG_IGN - 忽略该信号

SIG_DFL - 恢复系统默认处理方式

返回值

成功时返回先前注册的信号处理函数指针;

失败时返回 SIG_ERR,并设置 errno。

接下来我们改进一下代码:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
//收到了什么信号来执行
void handler(int signo)
{
    std::cout<<"收到了信号"<<signo<<std::endl;
}
int main()
{
    signal(SIGINT,handler);
    while(true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        std::cout<<"pid="<<getpid()<<std::endl;
        sleep(3);
    }
}

为什么会发现一个问题:命名已经用CTRL+c暂停了为什么进程却没有终止呢?还需要kill进程才能终止

细节内容:

1、如果我将所有的信号自定义捕捉不退出的话,也不能保证进程永远不退出,因为:

(1)SIGKILL 和 SIGSTOP 无法防御

(2)程序自身可能调用退出函数

(3)信号处理不当可能导致崩溃

(4)其他资源问题(内存耗尽、权限问题等)也会导致退出

2、信号处理由进程自己处理

3、如果我们的程序遇到除0和野指针就会崩溃,那么它们为什么会崩溃呢?因为程序遇到了异常,导致进程收到了信号强行终止的。

4、信号保存在进程结构体的位图之中,位图位置就是信号编号,发送信号本质就是将位图中0和1。向目标进程发送信号就是修改进程中位图的01内容

那么为什么会收到信号?OS怎么知道程序遭遇了除0和野指针异常?

因为除0本质是CPU错误,野指针本质是MMU和页表异常报错,最终信号都会传送到OS中,由OS向目标进程写入信号进行系统调用。

信号的产生

键盘产生的信号

(1)ctrl+c是向目标进程发送信号 2号信号------默认动作终止进程

(2)ctrl+\是向目标进程发送信号 3号信号------默认动作终止进程并生成便于调试的core dump文件

(3) ctrl+z是向目标进程发送信号 19号信号------默认动作暂停进程,将前台进程挂起到后台进程

信号产生的系统调用

kill

作用:向指定进程发送信号

表达式:

cpp 复制代码
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

返回值:成功时(至少一个信号被发送),返回 0;失败时返回 -1,并设置相应的 errno

raise

作用:生成 SIGABRT 信号,导致进程异常终止

表达式:

cpp 复制代码
#include <stdlib.h>

void abort(void);

返回值:

abort 函数不返回,进程会收到 SIGABRT 信号并默认终止,同时可能生成核心转储文件。

abort

作用:向当前进程发送指定信号

表达式:

cpp 复制代码
#include <signal.h>

int raise(int sig);

返回值:成功时返回 0,失败时返回非零值。

代码示例

Mykill.cpp

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>

// 用法: mykill -signumber pid
int main(int argc, char* argv[]) 
{
    if (argc != 3) 
    {
        std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
        exit(1);
    }
    
    int number = std::stoi(argv[1] + 1);  // 去掉开头的 '-'
    pid_t pid = std::stoi(argv[2]);
    
    int result = kill(pid, number);
    return result;
}

looppid.cpp

cpp 复制代码
#include<iostream>
#include<unistd.h>

int main()
{
    while(1)
    {
        std::cout<<"进程pid:"<<getpid()<<std::endl;
        sleep(3);
    }
    return 0;
}

结果为:

软件条件产生信号

软件条件产生的信号主要有两种方式:alarm函数和SIGALRM信号。接下来我们来介绍alarm函数。

作用:设置一个定时器,在指定的秒数后向调用进程发送SIGALRM信号

表达式

cpp 复制代码
#include <unistd.h>

unsigned int alarm(unsigned int seconds);

参数seconds: 定时器的时间(以秒为单位)

  • 如果seconds为0,则取消之前设置的任何未触发的定时器

  • 如果seconds为正数,则设置一个新的定时器,或替换现有的定时器

返回值:

  • 返回之前设置的定时器剩余的秒数

  • 如果之前没有设置定时器,则返回0

注意

  1. 信号行为 :默认情况下,SIGALRM信号会终止进程。但可以通过signal()sigaction()函数来捕获该信号,并执行自定义的处理函数。

  2. 定时器重置 :调用alarm()会取消任何先前设置的未触发的定时器,并用新的定时器替换它。

  3. 精度限制:定时器的精度以秒为单位,实际触发时间可能会有轻微延迟,取决于系统调度。

  4. 单次触发alarm()设置的定时器只触发一次。如果需要周期性定时,可以在信号处理函数中再次调用alarm()

代码验证

cpp 复制代码
//IO多
#include <iostream>
#include <unistd.h>
#include <signal.h>

int main() 
{
    int count = 0;
    
    // 设置1秒后发送SIGALRM信号
    alarm(1);
    
    // 无限循环,每秒会收到SIGALRM信号并终止程序
    while (true) 
    {
        std::cout << "count: " << count << std::endl;
        count++;
        sleep(3);
    }
    
    return 0;  // 实际上不会执行到这里,因为程序会被SIGALRM信号终止
}
//IO少
#include <iostream>
#include <unistd.h>
#include <signal.h>

int count = 0;

void handler(int signumber) 
{
    std::cout << "count: " << count << std::endl;
    exit(0);
}

int main() 
{
    // 注册SIGALRM信号的处理函数
    signal(SIGALRM, handler);
    
    // 设置1秒后发送SIGALRM信号
    alarm(1);
    
    // 无限循环递增计数
    while (true) 
    {
        count++;
    }
    
    return 0;  // 实际上不会执行到这里,因为程序会在1秒后通过信号处理函数退出
}

结果为:

不要在循环里设置闹钟。因为循环中会重置定时器信息。

**如何理解alarm?**alarm本质是定时器,OS要管理所有的定时器。

结论:闹钟会响⼀次,默认终⽌进程。有IO效率低

设置重复的闹钟

cpp 复制代码
#include <iostream>
#include <csignal>
#include <unistd.h>
#include <cstdlib>

int count = 0;

// 信号处理函数
void alarm_handler(int signum) 
{
    std::cout << "收到闹钟信号 #" << ++count << std::endl;
    
    // 重新设置闹钟,实现重复触发
    alarm(1);
    
    // 注意:在信号处理函数中只能使用异步信号安全的函数
    // printf/puts比cout更安全,但这里简单演示
    if (count >= 5) {
        std::cout << "完成5次闹钟,退出程序" << std::endl;
        exit(0);
    }
}

int main() 
{
    std::cout << "程序开始,将每秒触发一次闹钟" << std::endl;
    
    // 设置信号处理函数
    signal(SIGALRM, alarm_handler);
    
    // 设置第一次闹钟(1秒后触发)
    alarm(1);
    
    // 主循环等待信号
    while (true) {
        pause();  // 等待信号
        // 或者可以做其他事情,但不要在这里调用alarm()
    }
    
    return 0;
}

硬件条件产生信号

硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执⾏了除以0的指令, CPU的运算单元会产⽣异常, 内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了⾮法内存地址, MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送给进程。

我们在C/C++当中除零,内存越界等异常,在系统层⾯上,是被当成信号处理的。

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>

int main() {
    // 创建子进程
    if (fork() == 0) {
        // 子进程:休眠1秒后触发除以0的异常
        sleep(1);
        int a = 10;
        a /= 0;  // 这将触发SIGFPE信号(浮点异常)
        exit(0);
    }
    
    // 父进程:等待子进程结束
    int status = 0;
    waitpid(-1, &status, 0);
    
    // 输出子进程的退出信息
    printf("exit signal: %d, core dump: %d\n", status & 0x7F, (status >> 7) & 1);
    
    return 0;
}

Core Dump

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。

进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做 Post-mortem Debug (事后调试)。

一个进程允许 产生多大的 core 文件取决于进程的 Resource Limit (这个信息保存在PCB中)。默认是不允许产生 core 文件的, 因为 core 文件中可能包含用户密码等敏感信息,不安全。

在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。 首先用 ulimit 命令改变 Shell 进程的 Resource Limit ,如允许 core 文件最大为 1024K: $ ulimit -c 1024

信号的保存

信号在产生之后处理之前有时间窗口,所以就需要对信号进行保存

信号的其他概念

实际执行信号的处理动作称为信号递达(Delivery)

信号从产生到递达之间的状态,称为信号未决(Pending)

进程可以选择阻塞 (Block ) 某个信号。

被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

**注意:**阻塞和忽略是不同的。阻塞不是信号处理的方式,忽略是接收信号后的一种处理方式。只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

信号在内核的表示

pending表的位置就是在进程的位图中,其0和1的内容表示是否收到。

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。

SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。

SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。

如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

cpp 复制代码
/*
 * 内核结构 2.6.18
 * Linux内核进程描述符,包含信号处理相关信息
 */

/* 任务结构体(进程描述符) */
struct task_struct {
    /* ... 其他字段 ... */
    
    /* 信号处理相关 */
    struct sighand_struct *sighand;   /* 信号处理程序结构 */
    sigset_t blocked;                  /* 被阻塞的信号集 */
    struct sigpending pending;         /* 待处理信号 */
    
    /* ... 其他字段 ... */
};

/* 信号处理程序结构 */
struct sighand_struct {
    atomic_t count;                      /* 引用计数 */
    struct k_sigaction action[_NSIG];    /* 信号处理动作数组,_NSIG=64 */
    spinlock_t siglock;                  /* 保护锁 */
};

/* 新的信号动作结构 */
struct __new_sigaction {
    __sighandler_t sa_handler;           /* 信号处理函数指针 */
    unsigned long sa_flags;              /* 信号标志 */
    void (*sa_restorer)(void);           /* 恢复函数(Linux/SPARC未使用) */
    __new_sigset_t sa_mask;              /* 信号掩码 */
};

/* 内核信号动作结构(包装结构) */
struct k_sigaction {
    struct __new_sigaction sa;           /* 信号动作 */
    void __user *ka_restorer;            /* 用户空间恢复函数指针 */
};

/* 信号处理函数类型定义 */
typedef void (*__sighandler_t)(int);     /* 信号处理函数原型:接受int参数,返回void */

/* 待处理信号结构 */
struct sigpending {
    struct list_head list;               /* 链表头 */
    sigset_t signal;                     /* 待处理信号集 */
};

sigset_t

从以上代码中,我们可以发现:每个信号只有⼀个bit的未决标志 , ⾮0即1, 不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。因此, 未决和阻塞标志 可以⽤相同的数据类型sigset_t 来存储, , 这个类型可以表示每个信号的"有效"或"⽆效"状态, 在阻塞信号集中"有效"和"⽆效"的含义是该信号是否被阻塞, ⽽在未决信号集中"有 效"和"⽆效"的含义是该信号是否处于未决状态。接下来将详细介绍信号集的各种操作。

阻塞信号集也叫做当前进程的信号屏蔽字。这⾥的"屏蔽"应该理解为阻塞⽽不是忽略。

类似于权限之中的umask

信号集操作函数调用

sigset_t类型对于每种信号用一个bit表示"有效"或"无效"状态, 至于这个类型内部如何存储这些bit则依赖于系统实现, 从使用者的角度是不必关心的, 使用者只能调用以下函数来操作sigset_ t变量, 而不应该对它的内部数据做任何解释, 比如用printf直接打印sigset_t变量是没有意义的。

sigemptyset函数

作用:初始化一个空的信号集,不包含任何信号

表达式

cpp 复制代码
int sigemptyset(sigset_t *set);

参数

  • set:指向要初始化的信号集的指针

返回值

  • 成功:返回 0

  • 失败:返回 -1,并设置 errno

sigfillset函数

作用:初始化一个包含所有可用信号的信号集

表达式

cpp 复制代码
int sigfillset(sigset_t *set);

参数

  • set:指向要初始化的信号集的指针

返回值

  • 成功:返回 0

  • 失败:返回 -1,并设置 errno

sigaddset函数

作用:将指定的信号添加到信号集中

表达式

cpp 复制代码
int sigaddset(sigset_t *set, int signum);

参数

  • set:指向信号集的指针

  • signum:要添加的信号编号(如SIGINTSIGTERM等)

返回值

  • 成功:返回 0

  • 失败:返回 -1,并设置 errno

sigdelset函数

作用:从信号集中删除指定的信号

表达式

cpp 复制代码
int sigdelset(sigset_t *set, int signum);

参数

  • set:指向信号集的指针

  • signum:要删除的信号编号

返回值

  • 成功:返回 0

  • 失败:返回 -1,并设置 errno

sigismember函数

作用:测试指定的信号是否在信号集中

表达式

cpp 复制代码
int sigismember(const sigset_t *set, int signum);

参数

  • set:指向要检查的信号集的指针

  • signum:要检查的信号编号

返回值

  • 如果信号在集合中:返回 1

  • 如果信号不在集合中:返回 0

  • 出错:返回 -1,并设置 errno

sigprocmask函数

作用 :检查或更改(或同时进行)进程的信号屏蔽字。信号屏蔽字决定了哪些信号当前被阻塞(blocked),即不会传递给进程,直到解除阻塞。

表达式:

cpp 复制代码
#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数

how - 操作方式

指定如何修改当前信号屏蔽字:

含义 说明
SIG_BLOCK 阻塞信号 set 中的信号添加到当前阻塞信号集中
SIG_UNBLOCK 解除阻塞 set 中的信号 当前阻塞信号集中移除
SIG_SETMASK 直接设置 将当前阻塞信号集替换为 set 中的信号集

set - 新的信号集

  • 指向 sigset_t 类型信号集的指针

  • 如果为 NULL,则不改变当前信号屏蔽字(只获取当前状态)

  • 包含希望阻塞或解除阻塞的信号集合

oldset - 旧的信号集

  • 指向 sigset_t 类型的指针,用于保存之前的信号屏蔽字

  • 如果为 NULL,则不保存之前的屏蔽字状态

  • 通常用于临时修改后恢复原状态

返回值

  • 成功:返回 0

  • 失败 :返回 -1,并设置 errno

sigpending函数

作用:获取当前进程中被阻塞而处于未决状态(pending)的信号集合。未决信号是已经发生但尚未被处理的信号(通常因为被阻塞)。

表达式:

cpp 复制代码
#include <signal.h>

int sigpending(sigset_t *set);

参数:

  • set:指向 sigset_t 类型的指针,用于存储当前未决信号集

  • 函数会将当前所有未决信号填入该集合

返回值:

  • 成功:返回 0

  • 失败 :返回 -1,并设置 errno

补充:

函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。

函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。

注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化 ,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

一旦我们解除了对某个信号的阻塞,该信号就会被立刻递达

代码案例

cpp 复制代码
#include<iostream>
#include<signal.h>

void print(sigset_t&pending)
{
    //低位置开始打印要用:for(int signio=1;signio<=32;++signio)
    //高位置开始打印要用:
    for(int signio=31;signio>=0;--signio)
    {
        if(sigismember(&pending,signio))
        {
            std::cout<<"1";
        }
        else
        {
            std::cout<<"0";
        }
        
    }
    std::cout<<std::endl;
}

int main()
{
    //屏蔽2号信号
    sigset_t oldset,blockset;
    sigemptyset(&oldset);
    sigemptyset(&blockset);
    sigaddset(&blockset,SIGINT);
    int n=sigprocmask(SIG_SETMASK,&oldset,&blockset);
    (void)n;
    std::cout<<"pid:"<<getpid()<<std::endl;
    int cnt=1;
    while(1)
    {
        sigset_t pending;
        sigemptyset(&pending);
        //获取pending表
        sigpending(&pending);
        //打印pending表
        print(pending);
        if(cnt==20)
        {
            //解除对2号的限制
            std::cout<<"解除对2号的限制"<<std::endl;
            int n=sigprocmask(SIG_SETMASK,&oldset,nullptr);
            (void)n;
        }
        ++cnt;
        sleep(3);
        
    }
    

    return 0;
}

结果为:

继续改进代码

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

void PrintPending(sigset_t& pending) 
{
    std::cout << "当前进程[" << getpid() << "]的pending信号集: ";
    for (int signo = 31; signo >= 1; signo--) 
    {
        if (sigismember(&pending, signo)) 
        {
            std::cout << 1;
        } 
        else 
        {
            std::cout << 0;
        }
    }
    std::cout << "\n";
}

void handler(int signo) 
{
    std::cout << signo << " 号信号被递达!!!" << std::endl;
    std::cout << "-------------------------------" << std::endl;
    
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    
    std::cout << "-------------------------------" << std::endl;
}

int main() 
{
    // 0. 捕捉2号信号
    signal(2, handler);      // 自定义捕捉
    // signal(2, SIG_IGN);   // 忽略一个信号
    // signal(2, SIG_DFL);   // 信号的默认处理动作
    
    // 1. 屏蔽2号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set, SIGINT);  // 将2号信号(SIGINT)加入阻塞集
    
    // 1.1 设置进入进程的Block表中
    sigprocmask(SIG_BLOCK, &block_set, &old_set);  // 修改当前进程的block表,完成对2号信号的屏蔽
    
    int cnt = 15;
    while (true) 
    {
        // 2. 获取当前进程的pending信号集
        sigset_t pending;
        sigpending(&pending);
        
        // 3. 打印pending信号集
        PrintPending(pending);
        
        cnt--;
        
        // 4. 解除对2号信号的屏蔽
        if (cnt == 0) 
        {
            std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
            sigprocmask(SIG_SETMASK, &old_set, &block_set);
        }
        
        sleep(1);
    }
    
    return 0;
}

结果为:

信号的捕捉

到了信号处理的时候,我们可能在做优先级更高的事情?那么我们何时处理信号呢?

捕捉流程

两条线交界的地方就是检查pending表的时机

系统调用函数

作用sigaction 函数用于检查和/或更改指定信号的处理方式。它是 POSIX 标准中推荐的信号处理接口,比传统的 signal() 函数更强大和灵活,提供了更精细的控制。

表达式

cpp 复制代码
#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数

signum - 信号编号

  • 指定要操作处理的信号编号(如 SIGINT, SIGTERM 等)

  • 例外 :不能用于 SIGKILLSIGSTOP,这两个信号不能被捕获或忽略

act - 新的信号处理动作

  • 指向 struct sigaction 结构的指针,用于设置新的信号处理方式

  • 如果为 NULL,则不改变当前信号的处理方式(只用于获取当前设置)

oldact - 旧的信号处理动作

  • 指向 struct sigaction 结构的指针,用于保存信号之前的处理方式

  • 如果为 NULL,则不保存之前的处理方式

struct sigaction 结构体详解

cpp 复制代码
struct sigaction {
    // 信号处理函数指针(两种形式,二选一)
    union {
        // 简单形式:与signal()函数使用的处理函数相同
        void (*sa_handler)(int);
        
        // 增强形式:可以获取更多信号信息
        void (*sa_sigaction)(int, siginfo_t *, void *);
    } __sigaction_handler;
    
    // 信号屏蔽字:指定在信号处理函数执行期间要阻塞的信号集
    sigset_t sa_mask;
    
    // 标志位:控制信号行为的各种选项
    int sa_flags;
    
    // 已废弃,不使用
    void (*sa_restorer)(void);
};

信号处理函数的两种形式

  1. sa_handler(简单形式)
cpp 复制代码
void handler_function(int signum);

//与传统的 signal() 函数使用的处理函数相同

//只能获取信号编号,不能获取额外信息

//当 sa_flags 不包含 SA_SIGINFO 时使用
  1. sa_sigaction(增强形式)
cpp 复制代码
void handler_function(int signum, siginfo_t *info, void *context);
  • 可以获取关于信号的详细信息

  • 需要将 sa_flags 设置为 SA_SIGINFO

  • 参数说明:

    • signum:信号编号

    • info:指向 siginfo_t 结构体的指针,包含信号的详细信息

    • context:指向 ucontext_t 结构体的指针,包含信号发生时的上下文信息

sa_mask - 信号屏蔽字

  • 指定在信号处理函数执行期间要阻塞的信号集合

  • 注意 :当前正在处理的信号会自动被阻塞(除非设置了 SA_NODEFER 标志)

  • 使用 sigemptyset(), sigaddset() 等函数设置

sa_flags - 标志位

标志 说明
SA_NOCLDSTOP 0x00000001 如果 signumSIGCHLD,则当子进程停止时不发送 SIGCHLD 信号
SA_NOCLDWAIT 0x00000002 如果 signumSIGCHLD,则子进程结束时不会变成僵尸进程
SA_SIGINFO 0x00000004 使用 sa_sigaction 而不是 sa_handler
SA_RESTART 0x10000000 如果信号中断了系统调用,则自动重启该系统调用
SA_NODEFER 0x40000000 在信号处理函数执行期间,不自动阻塞当前信号
SA_RESETHAND 0x80000000 信号处理函数执行一次后,重置为默认动作(SIG_DFL
SA_ONSTACK 0x08000000 使用可选的信号栈(通过 sigaltstack 设置)

常用标志组合:

  • 0:默认行为,使用 sa_handler

  • SA_RESTART:系统调用被中断后自动重启(对交互式程序很有用)

  • SA_SIGINFO | SA_RESTART:使用增强型处理函数并自动重启系统调用

返回值:

  • 成功:返回 0

  • 失败 :返回 -1,并设置 errno

sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。

signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:

将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字;当信号处理函数返回时,自动恢复原来的信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 `sa_mask` 字段说明这些需要额外屏蔽的信号;当信号处理函数返回时,这些额外屏蔽的信号也会自动恢复原来的屏蔽状态。`sa_flags` 字段包含一些选项,用于控制信号处理的其他行为。

可重入函数

核心定义

可重入函数 是指一个函数在执行过程中可以被中断 ,并在中断期间再次被安全调用,且每次调用都能产生正确结果的函数。这种特性在多任务、中断处理、信号处理和递归调用等场景中至关重要。

关键特性

  1. 数据隔离性

    • 仅使用局部变量(存储在栈中)

    • 若使用全局/静态数据,必须通过同步机制保护或确保只读访问

    • 不依赖静态内存或全局缓冲区

  2. 行为确定性

    • 相同的输入必然产生相同的输出

    • 执行结果不依赖于外部状态或历史调用

  3. 无副作用约束

    • 不修改自身的代码(在哈佛架构中尤为重要)

    • 不调用其他非可重入函数

技术实现要求

必要条件

  1. 不使用静态/全局变量(除非是常量)

  2. 不返回指向静态数据的指针

  3. 局部数据均由调用者提供

  4. 不调用非可重入函数

内存使用模式对比

特征 可重入函数 非可重入函数
变量存储 栈内存/寄存器 可能使用静态存储区
缓冲区来源 调用者提供 可能使用内部静态缓冲区
状态保持 无状态或状态由参数传递 依赖内部静态状态

应用场景

  1. 中断服务程序(ISR)

    • 主程序执行函数时被中断,ISR中再次调用同一函数

    • 示例:嵌入式系统中的信号处理

  2. 多线程/多任务环境

    • 多个线程同时调用同一函数(需结合线程安全机制)

    • 可重入是线程安全的必要条件但非充分条件

  3. 递归调用

    • 函数直接或间接调用自身
  4. 信号处理函数

    • POSIX要求信号处理函数必须是可重入的

示例分析

不可重入函数示例

cpp 复制代码
char *strtok(char *str, const char *delim) {
    static char *buffer;  // 静态变量!
    // ... 实现使用buffer保存状态
}

问题:使用内部静态缓冲区,连续调用会破坏前一次调用的状态。

可重入替代方案

cpp 复制代码
char *strtok_r(char *str, const char *delim, char **saveptr) {
    // 状态通过saveptr参数传递,由调用者管理
}

可重入函数实现示例

cpp 复制代码
// 可重入的快速排序
void qsort_r(void *base, size_t nmemb, size_t size,
             int (*compar)(const void *, const void *, void *),
             void *arg) {
    // compar函数通过arg参数传递上下文,避免全局数据
}

与线程安全的关系

概念 定义 关系说明
可重入 单线程内可中断并重入 更严格的条件,针对执行流中断
线程安全 多线程并发调用时行为正确 更广泛的概念,通常需要同步机制

重要结论

  • 所有可重入函数都是线程安全的(在无锁情况下)

  • 线程安全函数不一定是可重入的(可能使用互斥锁)

  • 在中断上下文中,必须使用可重入函数(通常不能使用锁)

如果⼀个函数符合以下条件之⼀则是不可重⼊的:

• 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。

• 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。

volatite

本义

核心定义

volatile是一种类型限定符 ,向编译器声明被修饰的变量可能在其程序流之外 被改变,从而阻止编译器对该变量的优化假设

作用:

  1. 阻止编译器优化:禁止对变量进行寄存器缓存、冗余加载消除、指令重排等优化

  2. 强制内存访问:确保每次读写都直接访问内存,而非使用寄存器副本

  3. 保证操作顺序 :建立"发生在之前"(happens-before)关系,但不保证原子性

示例:

无volatite的情况下编译器会默认优化

cpp 复制代码
int flag = 0;

void wait_for_flag() {
    while (flag == 0) {
        // 编译器可能优化为:
        // 1. 将flag加载到寄存器
        // 2. 无限循环检查寄存器值
        // 3. 永远不会重新从内存加载flag
    }
}

有volatite的情况下编译器不会优化

cpp 复制代码
volatile int flag = 0;  // 告诉编译器flag可能被外部修改

void wait_for_flag() {
    while (flag == 0) {
        // 每次循环都会从内存重新读取flag
    }
}

volatite有三大保障:

1、禁止寄存器缓存

cpp 复制代码
volatile int sensor_value;

int read_sensor() {
    // 没有volatile:编译器可能只读取一次,缓存到寄存器
    // 有volatile:每次都会从内存读取最新值
    return sensor_value;
}

2、禁止指令重排

cpp 复制代码
// 没有volatile,编译器可能重排指令
int ready = 0;
int data = 0;

void producer() {
    data = 42;
    ready = 1;  // 可能被重排到data=42之前执行
}

// 有volatile,保证执行顺序
volatile int ready = 0;
int data = 0;

void producer() {
    data = 42;      // 保证先执行
    ready = 1;      // 保证后执行
}

3、保证内存可见

cpp 复制代码
volatile bool shutdown = false;

// 线程A
void monitor_thread() {
    while (!shutdown) {  // 每次循环都从内存读取
        // 监控任务
    }
}

// 线程B(或信号处理程序)
void set_shutdown() {
    shutdown = true;  // 写入立即对线程A可见
}

应用场景

  1. 内存映射I/O寄存器

    cpp 复制代码
    // 硬件寄存器地址映射
    volatile uint32_t* const HW_REGISTER = (uint32_t*)0x40021000;
    
    void configure_hardware() {
        *HW_REGISTER = 0x01;      // 写操作不能被优化掉
        uint32_t status = *HW_REGISTER;  // 必须重新读取
    }
  2. 信号处理程序共享变量

    cpp 复制代码
    volatile sig_atomic_t signal_received = 0;
  3. 多线程间的共享标志(仅限简单标志,不用于复杂同步)

    cpp 复制代码
    // 仅作为通知标志,不用于数据传递
    volatile bool data_ready = false;

与进程间信号的联系

信号处理中的内存可见性问题

当信号处理程序修改全局变量时,主程序可能看不到更改,因为:

  1. 编译器可能将变量缓存在寄存器中

  2. 编译器可能进行死代码消除

  3. 现代CPU的多级缓存可能导致可见性问题

信号安全编程模式

cpp 复制代码
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <stdatomic.h>

// ✅ 推荐:使用sig_atomic_t + volatile
volatile sig_atomic_t g_signal_flag = 0;

// ✅ 更安全:C11原子操作
_Atomic int g_atomic_signal_flag = 0;

void signal_handler(int sig) {
    // 在信号处理程序中只能使用异步信号安全函数
    // 且只能访问volatile sig_atomic_t或_Atomic类型
    
    (void)sig;  // 避免未使用参数警告
    g_signal_flag = 1;
    
    // 或使用原子操作(C11或C++11)
    atomic_store_explicit(&g_atomic_signal_flag, 1, memory_order_relaxed);
}

void setup_signal_handler() {
    struct sigaction sa;
    sa.sa_handler = signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    
    sigaction(SIGUSR1, &sa, NULL);
}

完整示例:

cpp 复制代码
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <stdbool.h>
#include <stdatomic.h>

// 方案1:传统volatile方式
static volatile sig_atomic_t g_stop_requested = 0;

// 方案2:C11原子方式
static atomic_bool g_atomic_stop = false;

void handle_stop_signal(int signum) 
{
    (void)signum;
    g_stop_requested = 1;
    atomic_store(&g_atomic_stop, true);
}

// 方案3:使用sig_atomic_t的正确模式
static volatile sig_atomic_t g_signal_pending = 0;
static int g_signal_type = 0;

void handle_multiple_signals(int signum) 
{
    // 仅设置标志,在主循环中处理具体逻辑
    g_signal_type = signum;
    g_signal_pending = 1;
}

void process_signals() 
{
    if (g_signal_pending) 
    {
        int sig = g_signal_type;
        g_signal_pending = 0;  // 清除标志
        
        switch (sig) 
        {
            case SIGUSR1:
                write(STDOUT_FILENO, "USR1 received\n", 14);
                break;
            case SIGUSR2:
                write(STDOUT_FILENO, "USR2 received\n", 14);
                break;
        }
    }
}

int main() 
{
    // 设置信号处理器
    struct sigaction sa;
    sa.sa_handler = handle_multiple_signals;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;  // 自动重启被中断的系统调用
    
    sigaction(SIGUSR1, &sa, NULL);
    sigaction(SIGUSR2, &sa, NULL);
    
    printf("Process %d running. Send signals:\n", getpid());
    printf("  kill -USR1 %d\n", getpid());
    printf("  kill -USR2 %d\n", getpid());
    
    // 主循环 - 正确检查volatile变量
    while (!g_stop_requested) 
    {
        // 执行主任务
        usleep(500000);  // 500ms
        
        // 检查并处理信号
        process_signals();
        
        // 也检查原子标志
        if (atomic_load(&g_atomic_stop)) 
        {
            write(STDOUT_FILENO, "Atomic stop flag set\n", 21);
            break;
        }
    }
    
    write(STDOUT_FILENO, "Clean shutdown\n", 15);
    return 0;
}

误区

1、volatite并非万能

cpp 复制代码
// ❌ 危险:认为volatile解决所有同步问题

volatile int buffer[100];
volatile int read_idx = 0;
volatile int write_idx = 0;

// 生产者-消费者问题不能仅用volatile解决
void producer() {
    // 缺乏互斥和内存屏障
    buffer[write_idx] = data;
    write_idx = (write_idx + 1) % 100;  // 可能发生竞态条件
}

// ✅ 正确:使用适当的同步原语
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int safe_buffer[100];

2、内存序问题

cpp 复制代码
// ❌ 错误:volatile不保证内存序
volatile bool ready = false;
volatile int data = 0;

void thread_a() {
    data = 42;          // 可能被重排到ready设置之后
    ready = true;       // 写操作可能先于data=42执行
}

void thread_b() {
    if (ready) {
        // 可能看到ready=true,但data仍为0
        printf("%d\n", data);
    }
}

// ✅ 正确:使用内存屏障或原子操作
#include <stdatomic.h>
atomic_bool atomic_ready = false;
atomic_int atomic_data = 0;

void correct_thread_a() {
    atomic_store(&atomic_data, 42, memory_order_relaxed);
    atomic_store(&atomic_data, &atomic_ready, true, memory_order_release);
}

void correct_thread_b() {
    if (atomic_load(&atomic_ready, memory_order_acquire)) {
        printf("%d\n", atomic_load(&atomic_data, memory_order_relaxed));
    }
}

规范

现代C/C++中的替代方案

场景 传统方案 现代方案
信号处理 volatile sig_atomic_t _Atomic (C11) / std::atomic (C++11)
多线程标志 volatile bool + 内存屏障 std::atomic<bool>
硬件访问 volatile volatile + 编译器屏障
避免优化 volatile std::atomic_signal_fence()

现代信号处理范式

cpp 复制代码
#include <signal.h>
#include <stdatomic.h>
#include <stdint.h>

// ✅ 推荐模式
class SignalHandler {
private:
    // 使用C11原子类型
    static atomic_uint_fast32_t g_signal_mask;
    
    // 或使用C++11原子
    // static std::atomic<uint32_t> g_signal_mask;
    
public:
    static void handler(int sig) {
        // 仅设置标志位
        atomic_fetch_or(&g_signal_mask, 1u << (sig - 1));
    }
    
    static uint32_t get_pending_signals() {
        return atomic_exchange(&g_signal_mask, 0u);  // 读取并清零
    }
};

// ✅ 编译器屏障示例
#define COMPILER_BARRIER() asm volatile("" ::: "memory")

void critical_signal_section() {
    volatile int important_flag = 0;
    
    // 确保编译器不重排
    COMPILER_BARRIER();
    important_flag = 1;
    COMPILER_BARRIER();
}

SIGCHLD信号

SIGCHLD (信号值:17,在某些系统上是20)是UNIX/Linux系统中一个特殊的信号,用于通知父进程其子进程的状态已发生变化 。这个信号是异步进程管理的基石,使得父进程能够及时了解子进程的终止、暂停或恢复状态。

触发条件

1、子进程终止

cpp 复制代码
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程
        printf("Child PID: %d\n", getpid());
        sleep(2);      // 模拟工作
        _exit(42);     // 子进程终止,SIGCHLD将被发送给父进程
    } else if (pid > 0) {
        // 父进程
        printf("Parent PID: %d waiting...\n", getpid());
        sleep(5);      // 等待SIGCHLD信号
    }
    return 0;
}

2、子进程被信号暂停

cpp 复制代码
#include <signal.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程
        printf("Child running...\n");
        while (1) {
            pause();  // 等待信号
        }
    } else {
        // 父进程稍后发送SIGSTOP给子进程
        sleep(1);
        kill(pid, SIGSTOP);  // 子进程暂停,父进程可能收到SIGCHLD
        printf("Child stopped\n");
    }
    return 0;
}

3、被暂停的子进程恢复了

cpp 复制代码
// 接上例
sleep(2);
kill(pid, SIGCONT);  // 子进程恢复,父进程可能收到SIGCHLD
printf("Child continued\n");
子进程状态变化 是否产生SIGCHLD WIF宏的返回值
正常终止(exit/_exit) WIFEXITED(status) == true
信号终止(kill -9) WIFSIGNALED(status) == true
被暂停(SIGSTOP, SIGTSTP) 可配置 WIFSTOPPED(status) == true
恢复执行(SIGCONT) 可配置 WIFCONTINUED(status) == true
核心转储终止 WIFSIGNALED(status) && WCOREDUMP(status)

处理机制

基本模式

cpp 复制代码
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

// 全局变量记录子进程信息
volatile sig_atomic_t child_count = 0;

// SIGCHLD信号处理函数
void sigchld_handler(int sig) {
    pid_t pid;
    int status;
    
    // 注意:必须使用循环,因为多个子进程可能同时终止
    while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED | WCONTINUED)) > 0) {
        child_count++;
        
        if (WIFEXITED(status)) {
            printf("Child %d exited with status %d\n", 
                   pid, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("Child %d killed by signal %d%s\n", 
                   pid, WTERMSIG(status),
                   WCOREDUMP(status) ? " (core dumped)" : "");
        } else if (WIFSTOPPED(status)) {
            printf("Child %d stopped by signal %d\n", 
                   pid, WSTOPSIG(status));
        } else if (WIFCONTINUED(status)) {
            printf("Child %d continued\n", pid);
        }
    }
    
    // 处理错误情况
    if (pid == -1 && errno != ECHILD) {
        perror("waitpid");
    }
}

// 更健壮的处理函数,避免在信号处理中调用非异步信号安全函数
void sigchld_handler_safe(int sig) {
    // 只设置标志,在主循环中处理
    // 使用write()而不是printf,因为write是异步信号安全的
    const char msg[] = "SIGCHLD received\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}

sigaction的高级配置

cpp 复制代码
#include <signal.h>

void setup_sigchld_handler() {
    struct sigaction sa;
    
    // 初始化结构体
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    
    // 重要标志位配置
    sa.sa_flags = SA_RESTART     |  // 被中断的系统调用自动重启
                  SA_NOCLDSTOP   |  // 子进程暂停时不发送SIGCHLD
                  SA_NOCLDWAIT   |  // 不创建僵尸进程(BSD扩展)
                  SA_NODEFER     |  // 不阻塞当前信号
                  SA_SIGINFO;       // 使用sa_sigaction而不是sa_handler
    
    // 使用sa_sigaction获取更多信息
    sa.sa_sigaction = sigchld_handler_info;
    
    // 安装信号处理器
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        perror("sigaction");
        exit(EXIT_FAILURE);
    }
}

// 使用siginfo_t获取更详细的信息
void sigchld_handler_info(int sig, siginfo_t *info, void *context) {
    (void)context;  // 避免未使用参数警告
    
    // info结构体包含丰富的信息
    printf("Signal: %d\n", sig);
    printf("Child PID: %d\n", info->si_pid);
    printf("User ID: %d\n", info->si_uid);
    printf("Status: %d\n", info->si_status);
    printf("Code: %d\n", info->si_code);  // 状态变化的原因
    
    // si_code的可能值
    switch (info->si_code) {
        case CLD_EXITED:
            printf("Child exited normally\n");
            break;
        case CLD_KILLED:
            printf("Child killed by signal\n");
            break;
        case CLD_DUMPED:
            printf("Child killed and dumped core\n");
            break;
        case CLD_STOPPED:
            printf("Child stopped by signal\n");
            break;
        case CLD_TRAPPED:
            printf("Child trapped by signal\n");
            break;
        case CLD_CONTINUED:
            printf("Child continued\n");
            break;
    }
}

僵尸进程与防止僵尸进程

僵尸进程的产生

cpp 复制代码
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>

void create_zombie() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程立即退出
        printf("Child (PID=%d) exiting\n", getpid());
        _exit(0);
    } else {
        // 父进程不调用wait(),创建僵尸进程
        printf("Parent (PID=%d) created child (PID=%d)\n", 
               getpid(), pid);
        printf("Child is now a zombie for 30 seconds\n");
        sleep(30);  // 在此期间,子进程是僵尸进程
        
        // 30秒后回收
        wait(NULL);
        printf("Zombie reaped\n");
    }
}

// 检查僵尸进程:在另一个终端运行 'ps aux | grep defunct'

防止僵尸进程产生

cpp 复制代码
#include <signal.h>
#include <stdlib.h>

// 方法1:显式忽略SIGCHLD(最简单,但丢失状态信息)
void ignore_sigchld() {
    struct sigaction sa;
    sa.sa_handler = SIG_IGN;  // 显式忽略
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    
    sigaction(SIGCHLD, &sa, NULL);
    
    // 注意:SIG_IGN与默认忽略不同
    // SIG_IGN会立即清理子进程,不创建僵尸进程
}

// 方法2:使用SA_NOCLDWAIT标志(BSD/Linux扩展)
void nocldwait() {
    struct sigaction sa;
    sa.sa_handler = SIG_DFL;  // 恢复默认处理
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_NOCLDWAIT;  // 关键标志
    
    sigaction(SIGCHLD, &sa, NULL);
    // 子进程终止时立即清理,不生成僵尸进程
}

// 方法3:设置SIGCHLD处理器并调用waitpid
void reap_children_properly() {
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    
    sigaction(SIGCHLD, &sa, NULL);
}

本期内容就到这里了,喜欢的话请点个赞谢谢

封面图自取:

相关推荐
D.不吃西红柿2 小时前
CPM.cmake轻量级包管理器
c++·cmake·cpm.cmake
●VON2 小时前
React Native for OpenHarmony:井字棋游戏的开发与跨平台适配实践
学习·react native·react.js·游戏·性能优化·交互
盐焗西兰花2 小时前
鸿蒙学习实战之路-Reader Kit获取目录列表最佳实践
学习·华为·harmonyos
kabcko2 小时前
Windows10安装Docker
运维·docker·容器
AI视觉网奇2 小时前
ue 安装报错MD-DL ue 安装笔记
笔记·学习·ue5
绿浪19842 小时前
C#与C++高效互操作指南
c++·c#
CSDN_RTKLIB2 小时前
std::string打印原始字节查看是否乱码
c++
KeeBoom2 小时前
嵌入式 Linux 应用开发完全手册——阅读笔记14
linux·笔记
看世界的小gui2 小时前
Jenkins通过CAS接入Maxkey单点登陆
运维·jenkins