文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

Linux进程信号

2023-09-06 13:47

关注

文章目录

信号入门

什么是linux信号?

我们输入命令,在Shell下启动一个进程迎来循环打印一个字符串。

int main(){while (1){printf("i am a process,i am waiting signal!\n");sleep(1);}return 0;}

我们可以使用kill -2 命令终止该进程。
在这里插入图片描述

信号处理的常见方式

查看系统定义的信号列表

我们可以通过kill -l命令查看linux中定义的信号列表,其中,1 - 31号信号为普通信号,34 - 64号信号为实时信号。
在这里插入图片描述
当然,我们可以使用 man 7 signal 查看各个信号的默认处理行为。

[yzh@yzh test1]$ kill -l

在这里插入图片描述

产生信号

通过终端按键产生信号

当我们执行以下死循环程序,我们可以通过CTRL + C来终止该进程。

int main(){while (1){printf("i am a process,i am waiting signal!\n");sleep(1);}return 0;}

信号时如何被进程保存?

如果一个进程接受到该信号,那么该信号是保存在该进程的PCB(进程控制块)中的信号位图字段中。
在这里插入图片描述
其中,信号的位置代表普通信号的编号,比特位0 or 1 代表信号是否被保存。以上图示中代表进程保存了3号信号。

信号发送的本质?

当一个进程收到信号,本质上修改PCB(进程控制块)中指定的位图结构,进而完成发送信号的过程。

使用signal函数捕捉信号

  sighandler_t signal(int signum, sighandler_t handler);

参数

注意
signal函数仅仅是修改进程特定信号的后续处理动作,不是直接调用对应的处理动作。

void catchSig( int signum ){    cout << "进程捕捉到了一个信号,正在处理中:  " << signum << " pid: " << getpid() << endl;}int main(){    signal(SIGINT,catchSig);  //捕捉2号信号    while( true )    {        cout << "我是一个进程,我正在运行... Pid: " << getpid() << endl;            sleep(1);    }    return 0;}

在这里插入图片描述
注意

核心转储

在云服务器中,核心转储默认是关闭的,当时我们可以使用 ulimit -a 命令查看当前资源配置情况。

如果第一行中core文件的大小为0,代表该云服务器的核心转储是关闭的。
在这里插入图片描述
我们可以使用 ulimit - c 命令来设置核心转储文件的大小。
在这里插入图片描述
运行.signal可执行程序,使用 kill -8 PID 命令终止目标进程即可在当前路径下生成对应的core文件。
在这里插入图片描述

core dump标记位

我们知道大,对于waipid函数,status可以用于获取子进程的退出状态。其中status不能简单的以一个整形判断,status的比特位便代表着一些退出信息。

  1. 如果进程是正常终止的,那么status的次低8位就表示进程的退出状态,返回退出码。
  2. 如果进程被信号所杀,那么status的低7位表示终止信号,而第8位比特位是core dump标志,如果为该比特位为1,就代表进程终止时是否进行了核心转储。

在这里插入图片描述

以下代码中,当子进程出现野指针问题,OS便会想子进程发送SIGFPE信号终止子进程,并且在core dump标志设置为1,留下core dump文件记录相关进程信息。

int main(){if (fork() == 0){//子进程cout << " 子进程正在运行 " << endl;int *p = NULL;*p = 100;exit(0);}//父进程int status = 0;waitpid(-1, &status, 0);cout << " coreDump:%d ",(status >> 7) & 1 << endl;return 0;}

结果如下:
在这里插入图片描述

利用核心转储进行调试

当进程出现异常的时候,OS会将当前进程在内存中的相关核心数据,转存在磁盘当中,也就是core文件,所以,我们可以利用core文件进行调试。

void catchSig( int signum ){    cout << "进程捕捉到了一个信号,正在处理中:  " << signum << endl;}int main(){    signal(SIGINT,catchSig);  //回调函数。    while( true )    {        cout << "我是一个进程,我正在运行... Pid: " << getpid() << endl;        int a = 100;a /= 0;    //除0错误        sleep(1);    }    return 0;}

首先,我们可以使用 gdb 可执行文件命令文件进行调试,然后再通过core file 命令加载core文件,既可以判断该进程在终止时收到了8号信号,并且定位到了程序产生错误时的一段代码。
在这里插入图片描述

注意

如果普通信号全部被捕捉,如何终止进程?

我们可以通过以下代码,将1 ~ 31号信号全部捕捉,如果收到其中信号,OS按理来说会执行我们所自定义的函数。

void handler(int signal){printf("get a signal:%d  pid:%d ", signal,getpid());printf("\n");}int main(){int sign;for (sign = 1; sign <= 31; sign++){signal(sign, handler);}while (1){sleep(1);}return 0;}

当我们使用一系列kill命令终止目标进程,发现OS接收到信号之后执行用户定义的自定义函数而无法终止进程。但是,如果使用 kill -9 命令,即时9号命令被捕捉,OS还是执行系统默认处理动作,终止目标进程。
在这里插入图片描述
所以有些信号无法被捕捉,例如Linux中的9号信号,因为如果所有信号都可以被捕捉的话,那么操作系统也将无法被终止。

调用系统函数向进程发送信号

在Shell中,实际上kill命令也是在调用了kill函数实现的。

kill函数可以给一个指定的进程发送指定的信号。

返回值: 调用成功返回0,失败返回-1。

int kill(pid_t pid, int sig);

所以,我们可以通过kill函数实现一个简单的kill命令。

static void Usage( char* proc){cout << "USage %s :  " << proc << endl;}int main(int argc,char* argv[] ){if( argc != 3 ){Usage(argv[0]);exit(1);}pid_t pid = atoi(argv[1]);int signal = atoi(argv[2]);kill(pid,signal);return 0;} 

结果如下
模拟实现的”kill“命令将sleep进程成功终止。
在这里插入图片描述

raise

raise函数可以给当前进程发送指定的信号(自己给自己发送6号信号 )。

返回值: 成功返回0,失败返回-1。

int raise(int sig);

当我们使用signal将6号信号捕捉,调用abort函数时。

static void hander( int number ){cout << " get a signal  " << number << endl;}int main(){     signal( 6,hander ); while( true ) {cout << "我开始运行了" << endl;sleep(1);abort();  // = raise(6) = kill(pid,6)  }   return 0; } 

结果如下
即使我们捕捉了6号信号,进程执行了我们自定义的函数,但是进程依旧被终止了。
在这里插入图片描述
注意
abort的作用是使当前进程收到信号而异常终止,生成core dump文件,exit是让当前进程正常终止。

如何理解调用系统接口?

用户调用系统接口 --> OS执行OS对应的系统调用代码 --> OS提取参数,或者设置特定的数值 --> OS向目标进程写信号–> 修改对应进程PCB中位图的标记位–进程后续处理信号–> 执行对应的处理方法。

由软件条件产生信号

SIGPIPE

调用一个alarm函数可以设定一个闹钟,也就是告诉操作系统在seconds秒之后给当前进程发送SIGALRM信号,该信号的默认处理动作为终止当前进程。

unsigned int alarm(unsigned int seconds);

返回值:

我们通过alarm闹钟设置1秒时count计算的总次数。

int main(){ alarm(1);      int count = 0;  while( true ) {          cout <<" count: " << count++ << endl;  }   return 0; } 

结果如下:
我们发现在通过vscode远程连接云服务器一秒钟计累加70163次。可是,实际上云服务器1秒钟累加次数远远大于该值,那么现在的结果远小于实际结果的原因是什么?
在这里插入图片描述

怎么单纯计算云服务器计算能力?

我们在通过ALARM函数设定1秒闹钟后,通过signal函数捕捉SIGALRM信号,即在收到SIGALRM信号之后进程将执行catchSig即获取1秒内count的累加值。

 int count = 0;void catchSig( int signum ){ cout << "final count " << count  << endl;      }int main(){  alarm(1); signal( SIGALRM,catchSig ); while( true ) {         count++; }   return 0; } 

结果如下:
此时云服务器中count累加大概为5亿次,并且alarm闹钟在触发一次就被移除了。
在这里插入图片描述

那么,如果我们想周期性1秒内计算count累加值,如何处理?

我们可以在自定义函中又设置一次alarm闹钟,进程执行完该函数后,又累加count,1秒后便再次触发闹钟执行该自定义函数,周期性不断循环。

long long int count = 0;void catchSig( int signum ){ cout << "final count " << count  << endl;     alarm(1);}int main(){  alarm(1); signal( SIGALRM,catchSig ); while( true ) {         count++; }   return 0; } 

结果如下:
在这里插入图片描述

//定时器待定。

如何理解软件条件给进程发送信号

硬件异常产生信号

当我们对SIGFPE(8号信号)进行捕捉,当遇到除零错误时,进程执行自定义函数。

void handler( int signum ){    sleep(1);    std::cout << "获得了一个2号信号: " << signum << std::endl;   // exit(0);                 //应该及时退出。} int main( ){    signal(SIGFPE,handler);    int a = 100;    a /= 0;    while(1) sleep(1);    return 0;}

结果如下:
可是,进程为什么会循环执行handler自定义函数呢?
在这里插入图片描述

我们先来理解除0错误。

综合以上结论,进程之所以循环执行自定义函数,因为寄存器中的异常一直没有被解决,当当前进程执行时切换到其他进程时,当前进程的上下文是保存在寄存器中的。当寄存器又重新切到当前进程,又识别到异常错误,操作系统就会不断重复得发送8号信号。所以一般有该异常并捕捉之后,一般需要及时退出。

如何理解野指针或者越界问题?

综合以上情况,无论是硬件问题还是软件问题擦还是产生信号,所有信号都有它的来源,但最终都是由OS所识别,解释,并发送的。

阻塞信号

阻塞信号相关常见概念

  1. 信号从产生到递达之间的状态成为信号未决。
  2. 进程实际执行信号的处理动作称为信号递达。
  3. 进程可以选择阻塞(Block)某个信号
  4. 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  5. 阻塞和忽略是不同的,只要信号被阻塞就不会被递达,而忽略是阻塞的之后的一种可选动作。

信号在内核中的表示

在这里插入图片描述

sigset_t

信号操作函数

sigset_t类型对于每种信号用于一个bit表示”有效“或者”无效“状态,至于在这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的并不关心。使用者只能调用以下函数来操作sigset_t变量。

#include 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

调用函数sigprocmask可以读取或更改进程的信号屏蔽子(阻塞信号集)。

#include int sigprocmask(int how, const sigset_t *set, sigset_t *oset);  

假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

howexplantion
SIG_BLOCKset包含了我们所希望添加到当前信号屏蔽字的信号,相当于mask = mask
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask = mask & ~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask =set

sigpending

sigpending函数用于读取进程的未决信号集,并通过set参数传出。
该函数调用成功返回0,出错返回-1。

int sigpending(sigset_t *set);

实践代码:

static void showPending( sigset_t &pending ) {for( int sig = 1; sig <= 31; ++sig ){if( sigismember(&pending,sig)) cout << "1";else cout << "0";}cout << endl;}int main(){ //定义信号集对象;sigset_t bset,obset;    sigset_t pending;     //用于保存信号集    //2.初始化sigemptyset( &bset );sigemptyset( &obset );sigemptyset( &pending );//3.添加要屏蔽的信号----2号信号    sigaddset(&bset,2); //其实只是在栈上作了修改。//4.设置set到内核中对应的进程内。(默认情况下进程不会对任何信号进行block) int n = sigprocmask( SIG_BLOCK,&bset,&obset ); assert( n == 0 ); (void)n; cout << "block 2 号信号成功 ...get: pid "<< getpid() << endl;    while( true ){//5.1获取当前进程的pending信号集。sigpending( &pending);//5.2显示pending信号集。showPending( pending );sleep(1);      //  signal( 2,handler ); }    return 0;} 

我们运行该可执行程序,如果该进程没有收到2号信号,那么penging信号集2号比特位就为0,当我们使用kill命令向该进程发送2号信号时,由于该进程的2号信号被屏蔽,所以该进程便一直处于pending(未决)状态。
在这里插入图片描述
如果我们想看到2号信号递达之后pending表的变化情况,我们设置20秒之后,将自动解除该进程的2号信号屏蔽状态,此时2号信号便会立即递达。并且我们再对2号进程进行捕捉。当处理2号信号时,进程会转而执行用户所写的自定义函数。

static void showPending( sigset_t &pending ) {for( int sig = 1; sig <= 31; ++sig ){if( sigismember(&pending,sig)) cout << " 1 ";else cout << " 0 ";}cout << endl;}static void handler( int signum ){    cout << "捕捉 信号: " << signum << endl;}int main(){ signal(2,handler);//定义信号集对象;sigset_t bset,obset;    sigset_t pending;    //2.初始化sigemptyset( &bset );sigemptyset( &obset );sigemptyset( &pending );//3.添加要屏蔽的信号    sigaddset(&bset,2); //其实只是在栈上作了修改。//4.设置set到内核中对应的进程内。(默认情况下进程不会对任何信号进行block) int n = sigprocmask( SIG_BLOCK,&bset,&obset ); assert( n == 0 ); (void)n; cout << "block 2 号信号成功 ...get: pid "<< getpid() << endl;//重复打印当前进程的pending信号集。int count = 0;    while( true ){//5.1获取当前进程的pending信号集。sigpending( &pending);//5.2显示pending信号集中没有被递达的信号。showPending( pending );sleep(1);++count;if( count == 20 ){sigprocmask(SIG_SETMASK,&obset,nullptr);   //恢复该进程的屏蔽字。cout << "开始解除对2号信号的屏蔽" << endl;}}      return 0;    } 

我们可以看到,当进程收到2号信号后,一段时间内该进程处于阻塞状态,如果解除对2号信号的屏蔽,此时2号信号便会立刻递达,转而执行我们的自定义方法。pending表中对应的比特位也由1变成了0。
在这里插入图片描述

信号捕捉

进一步了解地址空间

每一个进程都含有进程地址空间,进程地址空间由用户地址空间和内核地址空间构成。

在这里插入图片描述

内核态和用户态

内核态和用户态是如何进行转换的?

用户态与内核态之间互相主要为3种情况:

用户凭什么执行OS的代码?

CPU的寄存器有2套,一套可见,一套不可见。
其中便有一套CR3寄存器 ---- 表示当前CPU的执行权限, 其中有一个位图结构,例如 1 表示内核态,3表示用户态。
例如:当我们调用open系统函数时,有一行汇编指令 int 80 将用户态改为内核态,此时,因为CPU中保存了改进程的内核地址空间和用户地址空间及其对应的页表地址,进程便能通过CPU找到内核地址空间和内核级页表来执行OS代码和数据。

内核如何实现信号的捕捉

内核如何捕捉信号?

当我们执行主控制流程大的时候,某条命令可能因为某些情况进入内核(转换为内核态),当内核处理完异常情况前准备返回用户态时,就必须查看pengding表。

如果OS查看 pending表对应比特位由0变1,再查看block位图对应的比特位,如果为0,则执行OS默认终止进程操作不用转换为用户态,如果为1,则直接忽略该信号动作,并且转换为用户,从主控制流程中上次被中断的地方继续向下执行。

如果待处理信号为自定义捕捉时,。信号处理完毕之后调用系统sigreturn再次转换为内核态,再将pending表中对应的比特位清除,最后再返回用户态从上次被中断的地方继续向下执行。

当捕捉信号动作为自定义函数时,内核态能不能直接处理用户地址空间的代码?

可以,因为内核态是一种权限非常高的状态,但是OS不能这样设计。
因为OS如果用户在用户地址空间中写了一些非法操作,比如 rm命令删除内核数据时,这是非常危险的,进程并不能确保用户所写的代码是否安全合法,所以基础南横不能以内核态的状态执行与用户的代码。

我们可以利用以下例图巧记:
该图形与直线有几个交点就说明有该进程有几次状态切换,箭头的方向代表进程状态的切换方向,两个椭圆的交点(红色)表示内核信号检测,也就是pending表的检测。

在这里插入图片描述

sigaction

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

int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

参数
signo:表示指定信号的编号

act: 如果act指针非空,那么根据act修改该信号的处理动作。

oact: 如果oact指针非空,那么根据oact传出该信号原来的处理动作。

其中,参数act和oldact都是结构体变量,结构体定义如下:

struct sigaction {void(*sa_handler)(int);void(*sa_sigaction)(int, siginfo_t *, void *);  //不作解释sigset_t   sa_mask;int        sa_flags;                       //一般赋为0void(*sa_restorer)(void);                  //不做解释};

sa_handler

  1. 如果sa_handler = SIG_IGN,表示忽略信号
  2. 如果sa_handler = SIG_DFL,表示执行系统默认动作
  3. 如果sa_handler = 自定义函数指针, 表示使用自定义函数捕捉该信号。

注意
用户所写的自定义函数,返回值必须为void,参数为int,我们可以通过参数来获取当前被捕捉信号的编号。

sa_mask

例如:我们使用sigaction函数捕捉2号信号,并自动屏蔽3,4,5号信号,在进程处理自定义函数时打印信号集。

void showPending( sigset_t* pending ){    for( int sig = 1; sig <= 31; ++sig )    {            if( sigismember(  pending ,sig)) cout << " 1 ";            else cout << " 0 ";    }    cout << endl;}void handler( int num ){    cout << " 捕捉2号信号成功 pid:" << getpid() << endl;    cout << " 捕捉2号信号成功 pid:" << getpid() << endl;    cout << " 捕捉2号信号成功 pid:" << getpid() << endl;    cout << " 捕捉2号信号成功 pid:" << getpid() << endl;     sigset_t pending;    int c = 20;    while( true )    {        sigpending(&pending);                showPending(&pending);        c--;        if( !c ) break;        sleep(1);    }}int main( ){    //内核数据,用户栈定义的。    struct sigaction act,oact;       act.sa_flags = 0;     sigemptyset(&act.sa_mask);    act.sa_handler = handler;    sigaddset(&act.sa_mask,3);    sigaddset(&act.sa_mask,4);    sigaddset(&act.sa_mask,5);        sigaction(2,&act,&oact);        while( true ) sleep(1);    //设置进当前调用进程的pcb当中。    return 0;}

当进程捕捉2号信号时,自定义函数还有一段时间持续在返回。此时,我们通过kill命令向目标进程发送3,4,5号信号,此时便可以通过pending发现3,4,5号信号在该时间段内已经被屏蔽了。但是,一段时间后,因为自动恢复原来的信号屏蔽字,3,4,5信号之一开始递达终止进程。
在这里插入图片描述

可重入函数

在这里插入图片描述
如果我们在main函数中调用insert函数将结点1插入链表,但是insert函数共分为两步。当进程插入函数执行到第一步的时候(并没有执行完),
因为某些中断,异常到了等原因切换到内核态,再次返回时由检查pending表发现还有信号处理,所以进程执行sighandler函数。

在这里插入图片描述

当进程执行完sighandler函数,也是将node2结点头插到链表中。
在这里插入图片描述
但是,当进程执行完自定义函数并返回为用户态执行mian函数中insert调用异常时继续向下执行,则继续执行insert函数中的第一,二步执行完毕,完成头插。

在这里插入图片描述
但是,最终只有node1完成了头插,而node2因为结点地址丢失而找不到了进而无法处理,导致了内存泄露。

像这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称之为重入,insert访问一个全局链表,有可能因为重入而导致错乱,像这样的函数称为: 不可重入函数,反之的,如果一个函数只访问自己的局部变量或参数,则称为:可重入函数

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

volatile

volatile作用: 保持内存的可见性,告知编译器,该被关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。

以下代码中,我们在标准情况下,将2号信号进行捕捉后执行handler函数时让全局变量flag由0变1,当执行完毕后继续执行main函数中的代码此时应该跳出循环并打印flag值。

#include #include int flag = 0;void handler(int signo){cout << "change flag: " << flag;    flag = 1;        cout << " -> " << flag << endl;}int main(){signal(2, handler);while (!flag);cout << " 进程正常退出后 " << flag << endl;    return 0;}

结果如下
在这里插入图片描述
但是在gcc中,也有对应的优化机制,当我们使用最高优化级别”O3“进行编译,并运行该可执行程序时.
在这里插入图片描述
此时,尽管在handler函数中全局变量flag由0变成了1,但是在主函数中while循环依旧符合条件。

在这里插入图片描述
因为,在gcc优化之前,每一次访问全局变量flag都会从物理内存读取flag到寄存器中检测。可是编译器优化过后,编译器认为main函数中的全局变量flag并没有被修改。所以编译器在第一次加载flag时,便读取到寄存器edx中,而在handler修改flag时只是在内存中修改,之后,进程执行到mian函数中的flag那一行代码,便会从寄存器中检测。
在这里插入图片描述
所以,对全局变量以volatile关键字修饰,让编译器对其保持对内存的可见性。

#include #include volatile int flag = 0;void handler(int signo){cout << "change flag: " << flag;    flag = 1;        cout << " -> " << flag << endl;}int main(){signal(2, handler);while (!flag);cout << " 进程正常退出后 " << flag << endl;return 0;}

注意
OS不关心执行代码,编译器优化在编译期间便把代码优化编译了,CPU按照编译后的代码执行。

SIGCHLD信号

用户为了避免僵尸进程,父进程需要使用wait和waitpid来处理僵尸进程,但是有两种处理方式:

  1. 父进程以阻塞的方式等待子进程结束
  2. 父进程在处理工作的同时还必须轮询一下是否有子进程退出,程序实现复杂。

父进程fork出子进程,子进程调用exit(0)终止,父进程自定 义SIGCHLD信号的处理函数,在其中调用waitpid以非阻塞的方式获得子进程的退出状态并打印。

void handler( int signum ){    pid_t id = 0;    cout << ": 子进程退出" << signum << " father " << getpid() << endl;        while( id = waitpid(-1,NULL,WNOHANG) > 0 )    {        cout << "wait child success " << endl;    }}int main(){    signal( SIGCHLD,handler );         if( fork() == 0 )    {        cout << "child pid: " << getpid() << endl;        sleep(1);                exit(0);    }    while( true )     {        sleep(3);        cout << " 正在执行父进程 " << endl;    }}

结果如下:
由于OS并不知道有多少个子进程退出,所以我们需要采用while循环调用waitpid函数以非阻塞方式清理僵尸进程,如果以阻塞方式等待,那么父进程再下一轮查询子进程状态时该子进程恰好不退出的话,那么父进程就会阻塞等待,无法继续执行下面的代码。
在这里插入图片描述
如果我们不想等待子进程,并且还想让子进程退出之后自动释放僵尸进程,我们可以通过signal函数默认对子进程忽略。

 int main(){signal(SIGCHLD,SIG_IGN);   if( fork() == 0 )    {        cout << "child pid: " << getpid() << endl;        sleep(5);                exit(0);    }    while( true)    {        cout << "parent 正在运行 "<< endl;        sleep(1);     }}

这种忽略的含义就是用户告诉OS退出并默认清理僵尸进程。

在这里插入图片描述

来源地址:https://blog.csdn.net/m0_63300413/article/details/132453677

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-人工智能
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯