当前位置: 首页 > java >正文

Linux《进程信号(上)》

在之前的篇章当中我们学习了Linux当中是如何进行进程间的通信的,那么接下来在本篇当中将进入到Linux当中新的一个专题——信号,信号将又是我们Linux学习当中的一座大山,但是通过本篇的学习将会让我们对Linux操作系统运行的原理有更深的理解。在本篇当中我们将了解到信号的概念以及了解到信号是如何产生的;产生的条件有哪些,产生之后的信号又是被保存到哪里。而在下一篇当中又将讲解被保存的信号是什么时候被处理的,以及了解一些中断相关的概念。接下来就开始本篇的学习吧!!!


1. 信号快速理解

在此直接了解进程信号的概念是较为晦涩的,那么就通过生活当中的例子来带出信号的概念。

例如早上你还在睡觉当你听到闹钟的铃声就会按时起床,又或者你在家的时候有人敲门你就会去打开门,又例如在古代当中当士兵看到狼烟的时候就会立即集合。这些声音或者现象都是生活当中的信号,那么当这些信号产生的时候,人们是为什么知道接下来要做什么呢?

其实在我们收到这些消息之前就已经在我们的大脑当中存储了接收到这些消息之后要进行处理的方式,这就可以使得我在得到对应的信号之后可以进行对应的动作。还例如当在学校当中上课的时候张三去厕所了,那么老师是可以选择等张三回来之后接着继续讲课但也可以选择不管张三继续讲课,那么在此等张三回来就是一种同步机制,继续讲下去就是异步机制。
而实际上对于我们来说信号就是中断正在做的事,是一种异步机制。

实际上在操作系统当中和我们生活当中接受到消息也是很类似的,只不过在此接收信号的就是进程。实际上在操作系统当中的信号就是给进程发送的,用来进行事件的异步通知机制。

那么在通过以上的知识之后接下来就可以来就可以得到以下的基本结论:
1.信号处理,进程在信号没有产生的时候早就知道了信号该如何处理了。
2.信号的处理,不是立即处理,而是可以过一会,等到合适的时候再进行信号的处理。
3.对于进程来说早就内置对于信号的识别和处理方式。
4.能产生给进程的信号源非常多。

2.信号基本理解

实际上在操作系统当中产生信号的方式有非常多种,在之前我们就已经使用到了信号,只不过我们没有感觉而已,当运行起来一个进程之后接下来在键盘当中点击Ctrl+c就可以将进程停止。在此键盘实际上就是将信号发给了进程。

那么发送的信号究竟是什么呢?
在Linux当中使用kill -l 指令就可以查看系统当中存在的所有信号类型。

在此就可以看到信号实际上是有64种的,但是我们只需要了解到34之后的都是实时信号一般不会使用,一般我们使用的都是34之前的普通信号

以上我们使用Ctrl+c进行操作时实际上就是向对应的进程发送2号信号,而不同信号本质上就是不同数字定义出来的宏。

那么以上了解到了Ctrl+c 是给目标进程发送信号,那么是不是说一部分的信号的默认处理动作就是让进程终止呢?

确实是这样的,当一个进程接收到对应的信号之后实际上,处理信号是有以下的三种方式的:
1.执行默认处理动作
2.自定义处理动作
3.忽略处理

那么平时我们在使用Ctrl+c 的时候本质上进程执行的就是默认的处理动作也就是将进程杀掉

3.信号产生

实际上在操作系统当中产生信号的方式是有很多种的,那么接下来我们就来依次的了解。

3.1 键盘产生信号

实际上键盘输入就是一个非常典型的产生信号的方式,但是现在我们的问题是Ctrl+c 究竟产生什么信号呢?

以上我们了解到了信号的一些概念,但是目前的问题是如果想看到进程确实是接收到信号的,那么接下来就需要来了解信号相关的系统调用signal。

#include <signal.h>typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);参数:
signum:信号编号,指定要捕捉或忽略的信号类型(如 SIGINT、SIGSEGV 等)。
handler:一个函数指针,指向处理信号的函数。这个函数将在信号到达时执行。如果 handler 被设置为 SIG_DFL,则使用默认的信号处理行为(如终止进程)。
如果 handler 被设置为 SIG_IGN,则忽略该信号。

通过以上就可以看出signal系统调用的作用就是将对应的信号进行自定义的捕捉,之后就会执行对应的捕捉方法。

接下来来看以下的代码:

#include<iostream>
#include<signal.h>void handler(int signo)
{std::cout<<signo<<"信号执行"<<std::endl;exit(1);
}int main()
{signal(SIGINT,handler);while(1){printf("进程正在运行\n");}return 0;
}

以上代码编译为可执行程序之后接下来就会死循环的打印,那么这这是从键盘当中输入Ctrl+c 就会发现进程终止了

以上确实就证实了键盘是能产生信号的,那么接下来在了解信号的同时再来了解前台进程和后台进程的相关概念。

实际上之前我们运行起来的进程都是前台进程,运行的指令就是./XXX 那么这时前台进程最显著的特征就是能继续从标准输入当中读取,就明显的前台进程实际上就是命令行Shell,当操作系统一运行起来的时候Shell就运行了,那么这时Shell是可以获取到用户键盘输入的指令的。

那么这是我们就了解了前台进程,那么后台进程又是什么样的呢?

实际上后台进程和前台进程非常的类似,只不过相比气态进程,后台进程是无法从标准输入当中获取数据,那么一般标准输入都是从键盘当中获取的,那么这就使得后台进程无法从键盘当中读取数据。

结合以上的知识就可以得出在操作系统当中是无法将键盘产生的信号发给后台进程的,只有前台进程才能获取到。

但是问题就来了,当运行一个程序的时候进程默认是前台进程,那么怎么样才能以后台进程的方式运行呢?
实际上解决的方法很简单,只需要在执行进程的时候在后面加上&即可,执行格式就变为了./XXX &

接下来我们就要思考为什么后台进程无法从标准输入当中获取数据呢?
实际上这是键盘是只有一个的,那么这就使得输入的数据一定是要给一个确定的进程的,那么这就使得
前台进程必须只有一个,因此前台进程的本质就是要从键盘当中获取数据。而后台进程没这个要求就可以有多个。

例如以上的代码让其运行为后台进程就会发现使用Ctrl+c 无法将进程杀掉。

那么这时候是不是就没办法让进程停止下来了呢,其实还是有办法的,只需要使用到kill -9 进程号即可,该指令详细的原理接下来会进行讲解。

实际上在之前的学习当中我们就已经遇到了后台进程的情况,就是在我们学习进程时当父进程创建子进程之后如果父进程先退出之后,那么接下来若父进程先退出那么这是子进程就会变为孤儿进程并且被1号进程领养,这时就会发现子进程无法使用Ctrl+c杀掉了,那么这时其实子进程时变为了后台进程才无法接收到键盘的指令的。

接下来再来了解几个关于前后台进程的指令:
1.在此要查看当前Shell下的所有后台进程就可以使用到
jobs指令

2.要将后台进程提到前台就可以使用到fg 任务号 

那么这时进程就可以被Ctrl+c 杀掉了

3.使用Ctrl+z 可以将进程从前台提到后台并暂停,如果要让该进程在后台从新运行可以使用bg 任务号来实现

3.2 函数产生信号

在操作系统当中实际上也提供了对应的系统调用来实现信号的产生

kill

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

在代码使用kill系统调用就可以将指定的信号传给对应的进程,该系统调用的第一个参数是对应的进程pid,第二个参数是是需要传输的信号。

在此我们就可以使用kill系统调用来实现一个自己的kill指令

代码如下所示:

#include<iostream>
#include<sys/types.h>
#include<signal.h>//./kill -9 pid
int main(int argc,char* argv[])
{if(argc!=3){std::cerr<<"please input three par"<<std::endl;}int sig=std::stoi(argv[1]+1);pid_t pid=std::stoi(argv[2]);int n=kill(pid,sig);return n;}

以上的代码当中就通过用户输入的进程和需要传输的信号给指定的进程传输对应的信号,那么接下来将以上的代码编译之后运行之后再将一个进程运行起来之后得到该进程的pid,之后使用我们以上生成的程序将9好信号发给对应的进程。

通过使用ps指令就可以看出我们创建的kill指令确实是能实现要求的。

raise

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

在此再C库当中提供了raise函数来实现给当前所在的进程发信号,该函数的参数就是要进行发送的信号,本质上raise是封装了kill系统调用实现的。

使用例如以下代码:

#include<iostream>
#include<sys/types.h>
#include<signal.h>int main()
{int cnt=0;while(1){printf("hello\n");cnt++;if(cnt==5)raise(SIGINT);}return 0;
}

以上代码在运行到cnt等于5的时候就给当前的进程发送2号信号,那么接下来该进程就会被停止。

abort

#include <stdlib.h>void abort(void);

以上就是abort函数,该函数无参数也无返回值,该函数的作用是将当前的进程异常退出,实际上底层实现的方法就是给当前的进程发送对应的信号来让进程结束。

那么这时你可能就会好奇了,该函数和之前我们学习的exit有什么区别呢,看起来这两个函数实现的功能是很类似的,但是还是有很大的区别的如下所示:

本质上exit是用于进程正常的退出,若是子进程会将退出之后会将对应的提出码存储下来,其父进程就可以在进程等待的时候得到对应的退出信息,并且退出的时候会将程序的缓冲区进行刷新;而abort退出时直接让进程终止,不会进行程序缓冲区的刷新。

3.3 使用系统命令发送信号

在产生信号的系统的命令当中最常使用到就是kill命令,在之前我们进程的学习当中我就已经开始使用该指令来解决将指定的进程杀掉,具体的使用形式如下所示:

kill -[信号号] [进程pid]

3.4 硬件产生信号

当某些遗产产生的时候对应的硬件会检测到之后并通知内核,那么接下来内核就会对相应的进程进行处理,例如野指针、除零等异常.

首先来看以下的代码:

#include<iostream>
#include<sys/types.h>
#include<signal.h>void handler(int args)
{std::cout<<"收到"<<args<<"信号"<<std::endl;exit(1);
}int main()
{for(int i=1;i<34;i++)signal(i,handler);int a=5;a/=0;while(1);return 0;
}

将以上的代码编译为可执行程序之后,运行结果如下所示:

那么这时就可以说明当出现除零时确实进程确实会收到对应的信号。

实际上8号信号STGFPE的全称就是Floating Point Exception,表示的就是浮点异常

除了以上的除零以外,野指针的解引用也是会让进程接收到对应的信号的,来看以下的代码:

#include<iostream>
#include<sys/types.h>
#include<signal.h>void handler(int args)
{std::cout<<"收到"<<args<<"信号"<<std::endl;exit(1);
}int main()
{for(int i=1;i<34;i++)signal(i,handler);int* ptr=NULL;*ptr=100;while(1);return 0;
}

以上的代码编译为程序之后,执行输出的结果如下所示:

那么这就说明在出现野指针的解引用时对应的进程是会收到信号来将进程终止的,收到的信号是11号信号SIGSEGCV,全称为Segmentation Violation,表示的就是段错误。

那么在了解了以上有哪些硬件是能产生对应的异常之后,那么接下来就要思考程序当中接收到的信号是如何产生的呢?

实际上所有的信号无论是哪种都是由操作系统产生的,那么操作系统由于操作系统是软硬件资源的管理者,那么操作系统就会由对应的状态寄存器来检测是否有硬件出现了异常,检测到之后就会进行处理。而具体的流程要等到下一篇当中的中断时我们才能理解操作系统运转的真正原理。

在此我们还需要来了解一下core dump相关的概念

当我们使用man手册查询signal的时候就会发现有以下的内容

这时就发现不同的信号的action是有Core也有Term,那么这两种模式实际上有什么区别呢?

到目前我们了解到的大多数的信号的默认处理动作都是将对应的进程停止,这就是Term模式下会进行对的,但Core模式相比Term模式实际上还会在使得进程终止之前做以下的动作:
会在当前进程的路径下生成一个文件,当进程异常退出的时候会将进程在内存当中的核心数据从内存拷贝到磁盘,这样就方便进行debug。

但是之前我们怎么都没有见到这这种文件呢?

实际上这时因为在我们的云服务器当中core dump默认时被禁用的,这是因为产生的core dump文件是和进程占用内存的大小差不多的,那么这时如果在云服务器当中存在大量的core dump文件就会将磁盘占满,而云服务器是生产环境,core dump的操作最好就在开发环境下进行。

但是如果就要在云服务器当中开启core dump可以通过以下的方式进行

使用ulimit -a就可以看到当前的core size默认是0的

那么接下来使用ulimit -c就可以将core size修改为指定的大小

那么这时在将以上除零的代码重新运行就会发现产生了以下的core文件

在此你在使用的时候可能会没生成对应的文件,这可能是 Ubuntu 的 apport 工具来处理 core dump,而不是直接生成 core 文件。可以使用以下的指令来解决:

sudo sysctl -w kernel.core_pattern=core

那么有了core文件之后相比原来直接进行debug有什么优势呢?

有了core文件,那么这时就可以在gdb进行调试的时候使用core-file core 直接帮我们定位到出错的行。

实际上在之前学习进程等待的时候我们就已经有接触到过core dump,那时是在使用waitpid当中的输出型参数status当中存在core dump。

来看以下的代码:

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include<sys/wait.h>int main()
{pid_t pid=fork();if(pid==0){int a=1;printf("我是子进程!\n");a/=0;exit(1);}int status=0;int ret=waitpid(pid,&status,0);if(ret>0){printf("进程退出码:%d,core dump:%d,退出信号%d\n",(status>>8)&0xFF,(status>>7)&1,status&0x7F);}return 0;
}

以上代码输出的结果如下所示:

通过输出的结果就可以看到core dump的标志位为1,这就是因为我们将core dump打开了。

3.5 软件条件产生信号

实际上关于软件条件产生信号在之前的学习当中我们就已经有接触到,只不过在之前的我们不知道是该相关的概念;当我们使用命名管道实现进行两个进程之间的通信时,如果先将读端的进程关闭的话,那么就会出现写端的进程随后也会被杀掉。实际上写端的进程就是收到了对应的信号之后就会终止。事实上该信号就是13号信号SIGPIPE。

那么接下来再来了解alarm函数和SIGALRM系统调用

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

alarm函数的作用是当调用该函数的时候传入参数为闹钟倒计时的秒数,seconds秒之后内核就会给进程发一个SIGALRM信号,该信号默认处理动作就是结束当前的进程。

该函数的返回值是当前闹钟剩余的时间,例如当alarm(5),那么在两秒之后再设定一个闹钟用来第一个闹钟的返回值就是3。

接下来就试着来定一个闹钟来试试看,代码如下所示:

#include<iostream>
#include<unistd.h>int main()
{alarm(1);int cnt=0;while(1){printf("%d\n",cnt++);cnt++;}return 0;
}

#include<iostream>
#include<sys/types.h>
#include<signal.h>
#include<unistd.h>long long cnt=0;
void handler(int args)
{printf("%lld\n",cnt);exit(1);
}int main()
{signal(SIGALRM,handler);alarm(1);while(1){cnt++;}return 0;
}

通过以上的两段代码就可以看到alarm确实能实现闹钟,并且我们还会发现当没有进行IO时候相比一直进行IO效率的差别是非常高的。

接下来再来看以下的代码,我知道当一个闹钟停止的时候是会向当前的进程发送对应的信号,那么这时如果我们对该信号进行了自定义的捕捉,那么在自定义处理当中再创建一个闹钟不就能实现一直的创建闹钟、闹钟等待了吗?

并且在此还要来了解一个系统调用 pause

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

pause是Linux当中一个将进程阻塞住的系统调用,只有等到接收到对应的信号之后才会使得进程继续运行下去。

#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>long long cnt = 0;
void handler(int args)
{std::cout << "闹钟结束" << std::endl;alarm(1);
}int main()
{signal(SIGALRM, handler);alarm(1);while (1){pause();}return 0;
}

以上的代码就是先将主进程使用pause阻塞住,之后再使用闹钟给进程在一秒之后发送对应的信号,并且将信号的处理动作改为了创建一个闹钟。

运行程序输出结果如下所示:

#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <functional>
#include <vector>using fun_c = std::function<void()>;void Print()
{std::cout << "我是一个打印任务" << std::endl;
}void Uplog()
{std::cout << "我是一个上传任务" << std::endl;
}void download()
{std::cout << "我是一个上传任务" << std::endl;
}std::vector<fun_c> vt;long long cnt = 0;
void handler(int args)
{std::cout << "############################################" << std::endl;for(auto& x:vt){x();}std::cout << "############################################" << std::endl;std::cout << "闹钟结束" << std::endl;alarm(1);
}int main()
{vt.push_back(Print);vt.push_back(Uplog);vt.push_back(download);signal(SIGALRM, handler);alarm(1);while (1){pause();}return 0;
}

再来看以上的代码在此就在每个信号自定义的处理当中添加了相关的任务,那么这时你可能就会好奇了以上这样做不是多此一举吗,直接使用循环不是也是可以实现以上的效果吗?

实际上实现以上的代码的根本目的不是循环的展示对应的内容,是想让你感受到实际上操作系统就是一个阻塞的死循环,只有当真正的任务到达的时候才会运行起来。只不过是平时操作系统运转程序的时间较长,所以在用户的视角上就会认为操作系统是一直运行的。

以上我们已经了解了闹钟的概念以及如何使用闹钟,那么接下来就再来理解闹钟的本质

当系统当中同时存在多个闹钟的时候,那么这时候就需要对这些闹钟进行管理,管理的方式就是经典的先描述再组织,因此在内核当中是存在描述闹钟的结构体的。

struct timer_list {struct list_head entry;unsigned long expires;void (*function)(unsigned long);unsigned long data;struct tvec_t_base_s *base;
}

以上结构体当中就存储了闹钟结束之后要执行的函数方法,在操作系统当中是会将存在的闹钟节点组织类似为堆的形式。

4.信号的保存

以上我们了解了信号是可以由哪些方式产生的,那么接下来机来继续的了解产生的信号会保存在什么位置,并且再来了解信号保存的流程是什么样的。

不过在此我们需要再来先来了解一些信号的概念。
1.实际执行信号的处理当中称为信号抵达
2.信号从产生到抵达之间的状态称为信号未决
3.进程可以阻塞某个信号
4.被阻塞的信号会一直保持未决的状态,直到阻塞结束才会进入到抵达状态
5.阻塞和忽略是两种不同的状态,阻塞时候还是处于未决的状态,而忽略此时的信号已经进入到抵达,忽略是抵达当中的一种方式。

本质上信号的保存就是对应进程当中task_struct当中的一个位图,保存信号就是将对应位图的位置从0修改为1,那么这时就意味当前信号成功的保存了下来,这些操作都是操作系统自己进行的,用户是无权限修改进程对应信号的位图的,但是这时问题就来了如果只存在一张的位图,那么怎么样能表示阻塞、抵达等状态呢?

实际上在内核当中对于信号的保存是存在三张表的,如下所示分别是Handler表、Pending表、Block表

本质上每个表就是一张位图,在block表当中当对于位置下标元素为1的时候就表示当前信号要被阻塞,反之就不阻塞;peding表当中当对于的下标位置的元素为1的时就表示收到当前信号,反之就未收到,紧接着的handler表就是表示信号的处理动作是什么,当位图内的元素值为SIG_DFL就表示信号处理动作为自定义SIG_IGN就为忽略,若都不是这两个就是自定义处理。

那么这时我们就知道了信号的保存等处理动作都是操作系统来完成的,那么用户是不是就无法来进行信号的操作了呢?

实际上在操作系统当中是提供了对于的系统调用来让用户实现信号相关的操作的,就和之前学习的进程类似。在此操作系统当中引入了sigset_t数据类型,该数据类型就可以表示一个信号集,本质上底层就是一个bitmap的位图结构。同时还提供了以下的系统调用接口来批量化或者指定的对位图当中的信号进行处理。

int sigemptyset(sigset_t *set);   // 清空信号集
int sigfillset(sigset_t *set);    // 把所有信号加入信号集
int sigaddset(sigset_t *set, int signum);   // 添加一个信号
int sigdelset(sigset_t *set, int signum);   // 删除一个信号
int sigismember(const sigset_t *set, int signum); // 判断信号是否在集合中

同时除了以上的对sigset_t变量处理的函数之外操作系统当中还通过以下的信号集的操作函数。

1.修改进程当中Blocked表当中的内容

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1参数:
how:
SIG_BLOCK:把 set 里的信号加入 Blocked 集(阻塞它们)。
SIG_UNBLOCK:把 set 里的信号从 Blocked 集移除(解除阻塞)。
SIG_SETMASK:直接用 set 替换整个 Blocked 集。oldset:可选,返回调用前的 Blocked 集。set:需要修改之后的Block集。

how标志位不同的参数实现的具体效果如下所示:

2.读取当前进程的未决信号集

#include <signal.h>
int sigpending(sigset_t *set);

以上系统调用的作用就是将当前进程当中Peding表当中的信号集通过一个输出型参数set取出来。

在了解了以上接口之后,那么就可以试着使用接口来实现一个demo代码,在代码当中我们要实现的是将2号信号屏蔽之后每隔一秒打印当前进程Peding表的情况,10秒之后再将2号信号解除阻塞,并且要对2号信号实现自定义捕捉,代码实现如下所示:

#include <iostream>
#include <signal.h>
#include <sys/types.h>void PrintPeding(sigset_t &s)
{std::cout << "当前进程pid:" << getpid() << "进程Pending表:";for (int i = 31; i >= 1; i--){if (sigismember(&s, i)){std::cout << 1;}else{std::cout << 0;}}std::cout << std::endl;
}void handler(int args)
{std::cout << "######################################" << std::endl;std::cout << "当前进程进入递达状态" << std::endl;sigset_t s;sigpending(&s);PrintPeding(s);std::cout << "######################################" << std::endl;exit(1);
}int main()
{signal(SIGINT, handler);sigset_t sig, old;sigemptyset(&sig);sigemptyset(&old);sigaddset(&sig, SIGINT);sigprocmask(SIG_BLOCK, &sig, &old);int cnt = 10;sigset_t s;sigemptyset(&s);while (1){sigpending(&s);PrintPeding(s);cnt--;sleep(1);if (cnt == 0){std::cout << "解除信号的屏蔽" << std::endl;sigprocmask(SIG_SETMASK, &old, &sig);}}return 0;
}

运行程序就之后使用键盘发送2号信号接下来就会发现peding表的值改变,10秒之后对2号信号进行递达。

通过以上的输出就会发现在信号进行递达之前2号信号就的peding位图当中就已经被修改了

那么通过以上的示例就可以总结出当进程中的信号递达的之前会将Peding表中对应位置由1变为0

注:当出现进程信号在处于阻塞状态的时候,如果该进程收到了多个该信号,那么实际上是只会将该信号对应的Pending表修改一次,之后是会对收到的信号忽略。

以上就是本篇的所有内容了,接下来在信号(下)当中我们将继续学习信号处理等,未完待续……

http://www.xdnf.cn/news/19708.html

相关文章:

  • .NET技术深度解析:现代企业级开发指南
  • 从零开始的云计算生活——第五十七天,蓄势待发,DevOps模块
  • 用 map() + reduce() 搞定咖啡店订单结算:从发票到报表的 Python 实战
  • 【Stream API】高效简化集合处理
  • Python 2025:量子计算、区块链与边缘计算的新前沿
  • 量子计算+AI成竞争关键领域,谷歌/微软/微美全息追赶布局步入冲刺拐点!
  • 【音视频】 WebRTC GCC 拥塞控制算法
  • 整理期初数据用到的EXCEL里面的函数操作
  • 【专栏升级】大模型应用实战并收录RAG专题,Agent专题,LLM重构数据科学流程专题,端侧AI专题,累计63篇文章
  • Xcode 编译速度慢是什么原因?如何提高编译速度?
  • MyBatis-Plus 实现用户分页查询(支持复杂条件)
  • Ansible循环与判断实战指南
  • SQL Server--提取性能最差的查询
  • Redisson分布式锁会发生死锁问题吗?怎么发生的?
  • 嵌入式系统与51单片机全解析
  • 20.Linux进程信号(一)
  • 深入浅出 RabbitMQ - SpringBoot2.X整合RabbitMQ实战
  • 数据结构——顺序表和单向链表(1)
  • WPF 开发必备技巧:TreeView 自动展开全攻略
  • 豪华酒店品牌自营APP差异对比分析到产品重构
  • Qt6实现绘图工具:12种绘图工具全家桶!这个项目满足全部2D场景
  • 国产化部署的it运维平台:功能全面,操作便捷
  • OpenCV Python
  • 新手也能轻松选!秒出PPT和豆包AI PPT优缺点解析
  • 《Python Flask 实战:构建一个可交互的 Web 应用,从用户输入到智能响应》
  • 企业如何实现零工用工零风险?盖雅全自动化合规管控
  • 2024 年 AI 产业格局复盘:头部企业竞逐方向与中小玩家生存破局点
  • K8s HPA自动扩缩容实战指南
  • 广东某地非金属矿山自动化监测服务项目
  • Android 16k页面大小适配