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

Linux·进程控制

1. 进程创建

        我们之前已经懂了如何用系统调用fork()来创建一个进程,这里就不再重复了,这块我们说一下写时拷贝的问题

        当我们fork创建一个新进程之后子进程会继承父进程的PCB,PCB中又包含着环境变量,虚拟地址空间等进程相关属性,虚拟地址空间中记录着程序的数据和代码。

        以此在子进程继承的时候,也将父进程的虚拟地址空间也一并拿下来了,刚拿下来的时候两个进程的代码和数据都是完全一样的。并且其虚拟地址及映射后的真实物理内存都是同一块空间。同时操作系统会在继承的时候将数据段和代码段的内容都设置为只读的,即使数据段之前的权限可能是可读可写的。

        然后子进程在运行过程中要写入数据,像操作系统申请写入,操作系统发现要写入的内存只有读权限,于是出发系统错误,操作系统触发缺页中断,然后开始系统检测,判定这个内存本来就是可读可写的,于是发生写时拷贝,再开一块物理内存空间将原数据段拷贝至新开空间,并修改子进程页表的映射方案。

2. 进程终止

        在我们之前的程序中,main函数的返回值我们都用的是0甚至根本不写,但实际上main函数的返回值代表的是一个进程的退出码

                        

                        

        我们可以通过命令 echo $? 命令查看最近一个进程的退出码。

        进程的退出码 0 代表进程正常退出,非0代表错误退出,这种数字也叫错误码。错误码我们可以自己约定,或者使用C/C++提供的错误码。比如errno,strerror(errno)可以打印出错误码的描述。

        每个操作系统提供的错误码个数和含义都是不一样的,我们可以打印一下Linux的错误码看看

                        

                                

        我这里就展示几个错误码,实际Linux下错误码一共有134个

2.1 进程终止方式 exit _exit

        进程终止一共有3中方式:1.main函数return进程自然终止。 2.exit()代码间进程终止。 3.-exit进程终止。

        exit()函数就是在进程跑的时候,看到这个函数直接就直接退出进程,返回这个函数的此参数作为错误码。无论这个函数是在main函数中还是在子函数中,进程跑的时候看到1这个函数就终止。

                                

        _exit()函数偏系统一点,但是用法和exit()是一样的

                

        _exit使用时与exit有一点点小区别

        第一,我们在man查手册的时候,exit是在2号手册,-exit是在3号手册,也就是说exit是语言级别的函数,-exit是系统调用

        第二,我们知道在打印字符到屏幕上的时候\n是刷新缓冲区的作用。exit在调用的时候会自动将缓冲区的东西刷新出去,但是_exit不会刷新缓冲区。这里要说一下,我们一直说的缓冲区是不在操作系统中存储的,而是C/C++库中提供的缓冲区。

2.2 进程等待

        我们写这样一段代码:

                

        其效果就是创建一个子进程,如果失败了就返回错误码和错误信息,成功了就跑10秒子进程后退出,父进程一直在跑,然后我们编译运行一下这个程序。

        然后打开另一个终端,敲这样一段脚本 while :;do ps axj | head -1; ps axj | grep code; sleep 1; done

        编译运行后一段时间,子进程结束,我们可以很清楚的看到子进程进入了僵尸进程的状态。

        我们之前只关注进程的运行过程,这次我们谈谈父进程到底该怎么回收子进程。

        一般来说父进程创建子进程,父进程就要堆子进程负责,他要等待子进程,直到子进程结束,它要回收子进程的僵尸状态

        我们用到的函数有两个 wait()waitpid()

        这两个函数的功能就是让父进程等待子进程,直到子进程改变它的状态,如果子进程一直不退出那父进程就一直阻塞在wait函数里头。

2.2.1 wait函数

        其作用是等待任意一个子进程

        其返回值如果大于0说明回收进程成功了,小于0说明回收失败了,参数我们在waitpid中讲解

                                 ​​​​​​​

        我们在子进程中加入一段wait的逻辑

        通过刚才的监视脚本,可以看到子进程先运行,父进程等待,子进程运行结束,直接被父进程回收,没有出现僵尸进程的状态了,然后父进程才开始运行。

2.2.2 waitpid函数

        wait函数无法看出子进程任务完成的程度,也就是子进程的任务码,所以为了支撑更复杂的调用我们选择使用更先进的waitpid函数。

        第一个参数 pid 代表等待哪一个子进程。如果pid > 0代表指定等待的子进程,pid < 0 (pid==-1)代表等待任意一个子进程

        第二个参数 status 表示子进程的退出信息,这是一个输出型参数,需要自己做一个int型变量取地址放在这个参数的位置上,当waitpid函数结束的时候自然就能通过这个自己做的变量取到子进程的退出码了。

        status中并不仅仅包含了退出码信息,它是一个32个比特位的位图,我们只考虑它的低16位,其中从第8位到第15位这8位中才包含了进程的退出码。如果想要打出子进程的退出码,可以位操作来取到。

        也就是说代码跑完,返回 0 说明结果正确,返回 !0 说明结果错误。

        如果进程异常退出了,是因为OS发现代码中出现了严重错误于是用信号终止该进程,比如除0、野指针等,进程退出信息中会记录下来退出信号,也就是退出信息的低7位。

        退出信息中的第8位,也就是下标位7的位置是core dump标志。

        当然,如果只靠位运算来提取退出码和退出信号的话就有点太不优雅了,于是我们可以借用两个宏来获取。

        第三个参数 option 表示等待的方案,如果使用默认的0做参数的话就是阻塞等待。或者选择宏 WNOHANG (wait no hang)做参数执行非阻塞等待,非阻塞等待的好处是可以让父进程去做一些别的事情,但是如果选择这么做的话就要写代码不停的检测子进程是否完成了任务。

        返回值是一个pid_t类型的数,如果子进程正常退出返回子进程pid,如果返回值为0表示子进程还在运行,小于0是其他异常错误访问不到子进程了。

                                

3. 进程程序替换

        之前我们的子进程都是在父进程代码的框架下执行的,那如何让子进程跳出父进程代码区执行别的文件中的代码呢,此时就要用到程序替换了。

        我们先看一看进程程序替换的效果

        ​​​​​​​        ​​​​​​​        

        我们代码中没写代码,而是直接去使用别的程序的代码了,这就是从运行这个代码到运行那个代码的效果。

        进程程序替换的相关函数有这些:

        进程替换的原理简单来讲就是当调用execl这个接口之后,直接将execl接口中给定的路径下的代码直接拿来覆盖到当前进程的代码和数据上,完成替换,也就是说进程替换是不产生新进程的,而是用新代码和数据,直接在物理内存上覆盖旧代码和数据。

        现在我们好好看看execl接口,这个函数并不是系统调用,可以看到是 man 3 中的内容。它的第一个参数是要执行的代码的路径,之后是一个字符串类型的可变参数模板,可以在这个模板中写参数用来调用那个代码的main函数,因为我们知道main函数也有argv表的嘛,可变参数的最后一位必须用空指针表示参数列表的结束。

        execl在成功执行的时候是不会有返回值的,因为代码和数据都被覆盖了,没地方返回了。如果执行失败了就返回 -1 其实只要execl返回值了就说明失败了。

        但是如果使用进程程序替换的话就会覆盖掉源代码,有没有办法不覆盖源代码。当然有!创建一个子进程,让这个子进程去execl,父进程等待子进程运行完在继续运行就好了。如此一想,这不就是shell命令行解释器的运行原理嘛!

3.1 进程替换的接口

        Linux下进程替换的接口一共有7个

3.1.1 execl 于 execv

        exel不说了,前面已经用过了

        execv这个函数和execl几乎完全一致,只不过execl传参数列表,execv传一个数组,我们试用一下

        ​​​​​​​        ​​​​​​​        ​​​​​​​    ​​​​​​​        

        可以看到效果是完全一样的,其实我们在用命令行的时候就是通过 argv[ ] 表将选项存起来,然后由shell创建子进程,将argv[ ]中的参数丢给main函数,于是某个程序的代码就跑起来了。

        至于execl和execv两个参数是完全没区别的,如果说的话,l 代表list,v 代表vector,这就是它们参不同的标志。

3.1.2 execlp execvp

        这两个个函数可以不写要运行的文件路径,其他和前两个的用法是一样的。

        可以看到这个接口最后有个p,这代表这个接口会上环境变量PATH的各个路径中先找一下有没有文件。

        ​​​​​​​        ​​​​​​​        ​​​​​​​        

        可以看到参数列表中我写了两次 ls ,这并不冗余,第一个ls表示文件名,第二个ls及后面的东西表示我要怎么执行这个文件。

3.1.3 execvpe

        其实这几个接口的功能都是一样的,我们只需要从它们的命名中知道其用法就行,这里我们就看一个带 e 的接口,其他的都是同理的

        通过参数的名称也能看出 e 代表环境变量,如果使用这个参数的话相当于给新进程一套全新的环境变量。

        如果不想重新给环境变量可以使用putenv接口来在当前进程新增环境变量

        其实这个接口没啥用,除非想用一套新的环境变量

3.1.4 execve

        前面可以看到这个接口和别的接口我是分开截图的,因为它们根本就不在一个手册中,别的接口都是在3号手册,并不是系统调用。

        只有execve在2号手册,是系统调用

        所以那些别的接口都是由C标准库封装了这个接口的而出来的,只有execve是真正的系统调用。

4. 手搓shell

        前面我们已经学习完了进程的创建,进程的终止,进程等待和进程程序替换,并且穿插着理解了shell的运行原理,不过是以shell为父进程新启动一个进程来执行别的程序,以及如何给命令行加上选项也就是main参数。

        下面我们就基于前面学到的这些知识来完成一个简单的shell命令行解释器。

        首先我们要明确命令行解释器要有四大功能:打印命令行提示符,获取用户命令,分析用户命令,执行用户命令。

        ​​​​​​​        ​​​​​​​        ​​​​​​​        

4.1 打印命令行提示符

        我们知道命令行提示符是由 用户名@主机名 当前工作路径 组成的,而这些东西我们都可以从环境变量中获取到。

        ​​​​​​​        ​​​​​​​        ​​​​​​​

        首先我们将获取这些内容的方法都写出来,然后我们需要一个函数MakeCommandLine将它们拼接起来,这个函数我们用 C 的字符串风格来写,主要是想使用一个新的函数snprintf

snprintf

        这个函数可以将许多内容通过 format 格式化拼接到 s 缓冲区中,字符串的长度不会超过n-1.如果超了就把超过的丢弃,第n为会自动补 \0 作为字符串结束标志。

        返回值是写入的字符串长度,不包括\0

        因此我们可以借用这个函数对命令行提示符中的各个元素进行拼接,同时还可以个性化定制命令行提示符的风格。

        最后我们将 PrintCommandLine() 函数补充完整

​​​​​​​

        这里要注意因为我们前面那些字符串用的都是C++中的string,因此在用C风格输出的时候要取出C风格的字符串。

        同时因为命令行提示符没有换行符,也就是说它不能自动刷新出来,因此我们要用fflush()将缓冲区刷新。

        但是因为PWD环境变量是完整路径,后面我们再弄成最终路径。

4.2 获取用户输入

        用户输入的所有内容都是一个字符串,这个接口就是用来获取这个字符串的

        我们建立一个 command_buffer 数组专门用来存用户输入的指令,这类似于前面waitpid中的status参数,它们都是输出型参数

        我们认为用户输入的命令行是一个完整的字符串,也就是命令、空格、选项我们都要一起存到数组中去,因此我们不使用cin、scanf这种函数它们会把空格当成分隔符结束获取。

        因此我们可以选择 getline ,但这次我们用 fgets 函数

fgets

        它的使用方法和snprintf很像,第一个参数是要存储的位置,第二个参数是存储个数,第三个参数是从哪个流获取。

        这个函数将读取到的字符串存进str中去,遇到 \n 才停止读取,并且把 \n 也读进来,同时它也会在最后自动加上 \0

        如果用户只输入一个换行符那也就重新再打印命令行提示符

        因为fgets函数会把 \n 也存起来因此我们要在获取成功之后处理一下换行符,这里我直接选择将它置为\0

4.3 解析用户输入

        因为后面无论是execl或execv都需要若干个字符串才能使用,因此在这个函数中我们要把刚才的comman_buffer打散成一个字符串表,也就是char* argv[ ]

        ​​​​​​​        ​​​​​​​        

        我们直接将这个表维护成全局的,g代表全局

        那么我们切字符串的话用一下C阶段的一个函数strtok

        char * strtok ( char * str, const char * delimiters ); 

        strtok函数的作用是找到字符串中的下一个分隔符,然后将分隔符改成 \0 ,同时记住这个位置便于下一次进入字符串,并返回本进入次字符串位置的地址。当strtok函数的第一个参数str为NULL时,会从上次保存的位置开始,找下一个\0,并重复修改和保存的动作。 当字符串剩余中没有分隔符了,就返回NULL

        使用方法详见这篇字符串操作的讲解

C语言·字符函数和字符串函数-CSDN博客文章浏览阅读910次,点赞27次,收藏15次。本节讲解了有字符和字符串的各种函数,包含一系列字符分类和转换函数。各种字符串函数strlen strcpy strcat strcmp strncpy strncat strncmp strstr strtok strerror perrorhttps://blog.csdn.net/atlanteep/article/details/134565122

        这里while的报警不用管,类型匹配上的问题。

4.4 执行用户命令

        前面我们的shell都是单进程的,但是到现在就要进入多进程了,因为如果一旦用户调用的程序有问题的话导致这个shell进程挂掉那就真坏了。

        shell根据用户需求启用子进,子进程只用做两个事情,执行命令、退出。此时父进程也就是shell要进行阻塞等待子进程。

        下面我们在7个进程替换接口中应该选择哪个呢?因为已经有了 argv[ ] 表了因此一定选择v,又因为命令最好能支持PATH路径搜索,因此再选个带p的。综合下来我们选择 execvp 至于e选不选都行,我们就不选了。

        ​​​​​​​        ​​​​​​​        

        此时我们编译运行,自己写的shell就差不多有那意思了

4.5 内建命令

        但是我们这个shell还有一点问题,比如cd目录的时候

        可以看到这样完成不了返回上级目录的操作,因为cd命令是在子进程上跑的,它只更改了子进程的当前工作路径,但是子进程跑完就退出了,并不能影响到父进程。

        换句话说cd命令必须要在父进程上跑,这种必须在shell上跑的命令称为 内建命令

        也就是说我们在完成分析命令之后要搞一个判断是否是内建命令的逻辑

        ​​​​​​​        

        我们利用判断如果是一个内建命令就在shell中直接调用所需的函数,比如 cd 要改工作路径,那就调用对应的 chdir() 函数,运行完内建命令之后直接进入下一次循环。

        编译运行之后我们发现因为shell能更改自身的工作路径了,因此cd命令终于其效果了。

        但是这时又发现一个问题,就是命令行提示符中的路径没有变化,这是因为这里用的是环境变量PWD来展示当前工作目录的,但是我们使用chdir虽然更改了当前工作路径,但是并没有更改环境变量中的当前工作路径。换句话说,环境变量是要我们自己去维护的,我们在事实上使用chdir更改了进程PCB的cwd当前工作路径,但是环境变量env中的PWD当前工作路径没有改变。

        我们维护的方案就是在执行内建命令cd中 chdir 的时候顺便把环境变量中的工作路径更新同步成进程PCB中的真实工作路径

        在修改前我们先了解两个相关函数

getcwd

        ​​​​​​​        

        getcwd()会将PCB中的当前工作目录的绝对路径复制到 buf 数组中去,第二个参数size是buf数组的大小。如果获取当前工作路径失败的话返回NULL

putenv

        我们在环境变量那一节中学到了getenv可以获取某个环境变量的描述字符串,与之对应的,可以使用putenv新增或修改某个环境变量的描述字符串

        ​​​​​​​        ​​​​​​​        

        putenv()的参数string格式为 环境变量=value ,如果改环境变量原先就存在,则会被替换成新设置的value,如果之前没有该环境变量则会新增。

        其返回值,当修改或新增成功返回0,否则返回 -1

        ​​​​​​​        ​​​​​​​        ​​​​​​​        

        我们设置全局的数组方便对PCB和环境变量中的当前工作路径进行存取和修改

        我们在更改当前工作路径之后立刻获取并修改环境变量中的PWD

        可以看到现在的命令行提示符就正常了。

4.6 环境变量表的维护

        现在如果想用echo查某个环境变量,我们发现查不了,这是因为我们的shell中并没有维护环境变量表,而echo、export作为内建命令需要环境变量表才能获取或修改信息。

        ​​​​​​​        ​​​​​​​        

        我们之前讲过shell在启动的时候会先从系统的配置文件中读取并生成一张环境变量表,但是我们自己写的这个shell的环境变量表却是从操作系统的shell继承过来的,不过这也无所谓了,我们就当这个继承的过程就相当于在读取系统配置文件。

        下面我们把这张环境变量表在我们的代码中实体化维护起来,就是说创建 env[ ] 表。

        ​​​​​​​        ​​​​​​​        

        首先还是把表的大小和实体都构建成全局变量

        注意strlen()函数在计算长度的时候不计算\0,因此我们要+1把结束符那一位补上

        我们加载环境变量就直接从父进程继承过来的全局变量environ中导到我们自己的shell的环境变量表中,这一过程模拟从配置文件中读取环境变量的过程。

4.6.1 模拟export

        export导环境变量一定是一个内建命令,因为如果这个任务交给子进程做,子进程只能在自己的环境变量表中添加,之后子进程退出,但是并没有在真正的shell中添加到了环境变量。

        因为g_argv[ ]表中的内容都是临时的,因此我们在新增环境变量的时候要进行深拷贝。

4.6.2 模拟env

        因为我们已经有了shell环境变量表了,因此可以让这个命令来打印自己的环境变量表,而不是打印继承的父进程的环境变量表

        把export和env这两个命令组合起来检验一下

        发现可以查到新增的环境变量,符合预期。

4.6.3 让子进程继承手搓shell的环境变量表

        既然我们是shell,那由我们这个shell进程启动的所有子进程就应该继承我们刚实体化出来的环境变量表,这一操作很简单,在建立子进程的时候使用execvpe函数

        ​​​​​​​        

        我们将刚才的代码稍加修改即可。

4.6.4 保存上次子进程的退出码

        这里我们要先设置一个全局变量方便存储上次子进程的退出码,事实上内建命令的执行情况我们也要记录到这个全局变量中,但这里我就只展示存贮子进程的退出码,内建命令的统一到后面的完整代码中。

                                

        不管子进程任务执行的怎样,它是正常退出的而不是崩出来的,那就取出子进程的退出码,这里用宏直接取出。

4.6.5 模拟echo

        echo是一个内建命令,因为echo可以查到本地变量,而本地变量表一定只存在于当前进程,如果开一个子进程echo的话是查不到本地变量表的。

                

        这里我们就写了 echo $? 的变量查询形式,像查环境变量的需要去进一步匹配识别,还有本地变量也是,而且本地变量表我们也没有在自己的shell中维护,因此这些功能还需要进一步维护,但都不是什么技术问题了,这里就不写了。

        我们再编译运行起自己的shell,发现ls的退出码可以获取到,我们自己定义的退出码也可以获取到,不过echo命令执行的时候应该将双引号看成字符串提示符,而不是把双引号打印出来,这里我们也不改了,要是都改的话就太繁琐了,有点偏离我们手写shell的目的。

5. 完整代码

        命令行提示符的路径太长了,又改了一下,只打印出最后一段路径

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

const int basesize = 1024;

//上次子进程退出码
int lastcode = 0;

//全局数据
const int argvnum = 64;
char* g_argv[argvnum];
int g_argc = 0;

//环境变量表
const int envnum = 64;
char* g_env[envnum];

//全局的当前工作路径
char cwd[basesize];
char pwd[basesize];

string GetUserName()
{
    string name = getenv("USER");
    return name.empty() ? "None" : name;
}
string GetHostName()
{
    string hostname = getenv("HOSTNAME");
    return hostname.empty() ? "None" : hostname;
}

string GetPwd()
{
    string pwd = getenv("PWD");
    return pwd.empty() ? "None" : pwd;
}

string LastDir()//取出路径的最后一块
{
    string curr = GetPwd();
    if(curr == "/" || curr == "None") return curr;
    size_t pos = curr.rfind('/');
    return curr.substr(pos+1);
}


string MakeCommandLine()    //构建提示符格式
{
    //[atlanteep@hcss-ecs-9f5c lesson12-myshell]$
    char command_line[basesize];
    snprintf(command_line, basesize, "{%s@%s %s}:) ", \
            GetUserName().c_str(), GetHostName().c_str(), LastDir().c_str()); 
    return command_line;
}

void PrintCommandLine() //1. 命令行提示符
{
    printf("%s", MakeCommandLine().c_str());
    fflush(stdout);
}

bool GetCommandLine(char command_buffer[], int size)   //2. 获取用户命令
{
    char* result = fgets(command_buffer, size, stdin);
    if(!result)
    {
        return false;
    }
    command_buffer[strlen(command_buffer)-1] = '\0';

    if(strlen(command_buffer) == 0) return false;

    return true;
}

void ParseCommandLine(char command_buffer[]) //3. 分析命令
{
    memset(g_argv, '\0', sizeof(g_argv));
    g_argc = 0;
   const char *sep = " ";
   g_argv[g_argc++] = strtok(command_buffer, sep);

    while(g_argv[g_argc++] = strtok(nullptr, sep));
    g_argc--;
}

bool ExecuteCommand()   //4. 执行命令
{
    pid_t id = fork();
    if(id < 0) return false;    //创建子进程失败
    else if(id == 0)
    {
        //子进程
        execvpe(g_argv[0], g_argv, g_env);
        
        //子进程返回说明失败了
        exit(1);
    }

    //父进程shell
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
        if(WIFEXITED(status))
        {
            lastcode = WEXITSTATUS(status);
        }
        else
        {
            lastcode = 100;
        }
        return true;
    }
    return false;
}

void AddEnv(const char* item)
{
    int index = 0;
    while(g_env[index]) index++;
    //此时找到环境变量表的结尾
    g_env[index] = (char*)malloc(strlen(item) + 1);
    strncpy(g_env[index], item, strlen(item) + 1);
    g_env[++index] = nullptr;
}

bool CheckAndExecBuiltCommand() //检测并执行内建命令
{
   if(strcmp(g_argv[0], "cd") == 0)
   {
       if(g_argc == 2)
       {
            chdir(g_argv[1]);
            getcwd(cwd, basesize);//获取PCB中当前工作路径
            //更新环境变量
            snprintf(pwd, basesize, "PWD=%s", cwd);
            putenv(pwd);
            lastcode = 0;
       }
       else
       {
           lastcode = 1;//cd后面没路径
       }
       return true;
   }
   else if(strcmp(g_argv[0], "export") == 0)
   {
       if(g_argc == 2)
       {
           AddEnv(g_argv[1]);
           lastcode = 0;
       }
       else
       {
           lastcode = 2;//export后面没变量
       }
       return true;
   }
   else if(strcmp(g_argv[0], "env") == 0)
   {
       for(int i = 0; g_env[i]; i++)
       {
           printf("%s\n", g_env[i]);
       }
       lastcode = 0;//envn不会出错
       return true;
   }
   else if(strcmp(g_argv[0], "echo") == 0)
   {
        if(g_argc == 2)
        {
            if(g_argv[1][0] == '$')//echo $...
            {
                if(g_argv[1][1] == '?')
                {
                    printf("%d\n", lastcode);
                    lastcode = 0;
                }
                //else{处理其它变量 }
            }
            else
            {
                printf("%s\n", g_argv[1]);
                lastcode = 0;
            }
        }
        else 
        {
            lastcode = 3;
        }
        return true;
   }
    return false;
}

void InitEnv()
{
    extern char** environ;  //假装这个是解析好的环境变量配置文件
    int index = 0;
    while(environ[index])
    {
        g_env[index] = (char*)malloc(strlen(environ[index]) + 1);
        strncpy(g_env[index], environ[index], strlen(environ[index]) + 1);
        index++;
    }
    g_env[index] = nullptr;
}

int main()
{
    InitEnv();
    char command_buffer[basesize];
    while(true)
    {
        PrintCommandLine(); //1. 命令行提示符

        if( !GetCommandLine(command_buffer, basesize) )   //2. 获取用户命令
        {
            continue;
        }

        ParseCommandLine(command_buffer); //3. 分析命令

        if( CheckAndExecBuiltCommand() ) //检测并执行内建命令
        {
            continue;
        }

        ExecuteCommand();   //4. 执行命令
    }

  return 0;
}


http://www.kler.cn/news/354575.html

相关文章:

  • 【贪心算法】(第一篇)
  • OpenShift 4 - 云原生备份容灾 - Velero 和 OADP 基础篇
  • 《案例》—— OpenCV 实现2B铅笔填涂的答题卡答案识别
  • MeshGS: Adaptive Mesh-Aligned GaussianSplatting for High-Quality Rendering 论文解读
  • 公司新来一个同事,把枚举运用得炉火纯青...
  • 【Flutter】Dart:库
  • 文章解读与仿真程序复现思路——电网技术EI\CSCD\北大核心《计及配电线路脆弱性的电动汽车充放电时空分布优化策略》
  • day46|72. 编辑距离647. 回文子串516.最长回文子序列 5 最长回文子串
  • vue3 使用 Vue Router实现前端路由控制
  • 【Echarts动态排序图,series使用背景色更新动画,背景底色不同步跟随柱子动画】大家有没有解决方案
  • 遥感技术助力生态系统碳储量、碳收支、碳循环等多领域监测与模拟:森林碳储量,城市扩张,夜间灯光数据,陆地生态系统,大气温室气体监测等
  • 轻量级可视化数据分析报表,分组汇总表!
  • MySQL—关于数据库的CRUD—(增删改查)
  • Vue——Uniapp回到顶部悬浮按钮
  • TS和JS中,string与String的区别
  • 【VUE】Vue中的data属性为什么是一个函数而不是一个对象
  • 机器学习:opencv--光流估计
  • 一文探索RareShop:首个面向消费者的RWA NFT商品发售平台
  • Spring Boot 2.6=>2.7 升级整理
  • 域1:安全与风险管理 第3章(BCP)和第4章(Laws, regulations, and compliance)