• Linux下的进程控制


    🌲进程地址空间复习和补充

    • 进程地址空间本质上是进程看待内存的方式,是抽象出来的一个概念,在内核中就是一个结构体mm_struct(内存描述符)

    进程都认为自己独占系统内存资源,操作系统给进程画了一张饼,这张饼就是进程地址空间(操作系统让进程以为自己拥有整个物理内存),实际上进程拥有的只是虚拟地址。

    进程地址空间里有区域划分,那我们在mm_struct可以怎么实现?我们就只需要控制区域的上下界即可,即我们只要维护两个边界。如下所示的方式就可以实现区域的划分

    struct mm_struct
    {
        unsigned long code_start;
        unsigned long code_end;
    
        unsigned long init_start;
        unsigned long init_end;
        
        unsigned long uninit_start;
        unsigned long uninit_end;
            ......          
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 区域划分本质:将线性地址空间划分成一个又一个的area[start,end];start和end之间的地址就是线性地址

    我们常常把进程地址空间比作一把尺子,根据尺子刻度来划分空间

    image-20220701000747756

    • PCB和进程地址空间可以通过指针进行联系(比如task_struct里面存一个指向mm_struct的指针)

    PCB是进程描述符,在linux下的具体表现就是task_struct

    • 页表:完成虚拟地址到物理地址之间的映射(需要配合硬件)

    虚拟地址可以完全一样,映射到的物理内存可能不同。

    • 写时拷贝,父子进程共享代码和数据,如果子进程修改数据就要开一块新的空间来存放新的数据。

    代码一般都是只读的不更改。

    • 虚拟地址的必要性:如果进程直接访问物理内存,那我们看到的地址就是物理内存的地址,就可能会导致越界,同时无法保证进程的独立性。

    如果有一些野指针强行修改了一些内存数据,就可能造成一些未知的错误。

    • OS的主要功能有进程管理,内存管理,设备管理,文件管理,作业管理,其中内存管理只需要知道哪些区域是有效的,哪些区域是无效的(比如释放一块空间时将这块空间置为无效的即可,把这块空间全部置为0是一种效率不高的方式)

    显然,将操作一块空间的内容和标识一块空间的状态进行对比,显然后者效率更高。

    • 4GB能不能运行16GB的游戏?

    可以,需要一段就加载一段。(并不需要一次性全部加载完毕,但可能有点卡…)

    • image-20220629143341992

    小知识:可执行程序,本身就已经划分成为了一个个的区域,这样做利于链接,且划分大小都是4KB的整数倍,利于系统操作。

    • 进程和程序的区别

    进程是动态的,程序是静态的,且进程有生命周期,而程序没有。

    一个程序可以有多个进程,而一个进程只能对应一个程序。(比如一个程序可以创建父子进程,但父子进程都对应同一个程序)

    组成上,进程包括了程序,而程序是指令的集合

    🌲进程控制

    🌴进程的创建–fork()

    进程的创建有两种方式,分别是命令行启动命令和通过程序fork创建子进程。

    学习前先了解 写时拷贝

    fork()是操作系统给我们创建进程的接口

    让我们先问问linux里的man

    命令:man fork

    image-20220629150550228

    🌵原理

    调用fork后,内核会分配新的内存块和内核数据给子进程,将父进程中的部分数据结构(比如PCB)拷贝给子进程。子进程假如到系统的进程列表中,fork返回,调度器开始调度。

    简单来说,子进程以父进程为模板拷贝了一些必要的数据结构,并且加入了系统的进程列表等待调度。

    🌵返回值

    fork之后父子进程都有自己的pid,因为父子进程的返回值不同会发生写时拷贝,但因为虚拟地址没变,导致看起来像有两个返回值

    image-20220629150948722

    我们可以看到fork有“两个”返回值“,并不是return了两次,而是值的写入引起了写时拷贝 (实际上页表映射的物理内存不是同一块)

    📄理解

    观察下面的if和else if同时执行的现象

     #include<unistd.h>
     #include<stdio.h>
     int main()
     {
       int ret=fork();
       while(1)
       {
         if(ret==0)
         {
           //child
           printf("i am child ,ret=%d ,addr=%p\n",ret,&ret);
           sleep(1);
         }
         else if(ret>0)
         {
           //father
           printf("i am father,ret=%d ,addr=%p\n",ret,&ret);                                                                                     
           sleep(1);
         }
         else
         {
           //fork err
           perror("fork");
         }
       } 
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    image-20220629161806565

    解释我们看到的地址是虚拟地址,实际上的物理地址是不同的,这是因为发生了写时拷贝。

    fork()创建子进程后子进程返回了一个值,返回一个值等同于子进程修改了数据,要发生写时拷贝,所以看起来就有了一个变量存储了两个值(fork返回两个值)的现象。

    这里值得注意的是:fork函数先创建的进程再返回一个值,有了父子进程才有了后来的写时拷贝。

    🌵子进程

    • 子进程会以父进程为模板拷贝代码和数据,子进程更改数据就会发生写时拷贝。(写时拷贝维护了父子进程的独立性,利于运行多进程)

    • 创建子进程本质是系统多了一个进程(进了调度列表),也就是多了一套进程相关的数据结构(如PCB)

    • 在不写入的情况下,用户的代码和数据是父子共享的

    🌵写时拷贝

    写这里指修改

    image-20220629154245577

    image-20220629154510039

    • 为什么要进行写时拷贝,我在创建子进程时直接将数据和代码全部拷贝一份不就行了?

    写时拷贝可以保证进程的独立性。子进程如果没用到父进程所有的资源却拷贝了父进程所有的资源,这就是浪费,如果这么做了还会导致fork()效率的降低,提高fork失败的概率(比以前需要申请更多的资源,自然更容易失败)

    • 上面的写时拷贝是针对数据的,那有没有可能发生代码的写时拷贝呢?

    有可能,比如后面会提到的进程替换。

    🌵fork失败的原因

    • 进程数超出限制
    • 内存不足

    🌴进程的终止

    🌵结束场景

    • 代码运行完,结果正确。

    • 代码运行完,结果不正确。

    • 代码没运行完就结束。(异常终止,如野指针导致的越界问题等)

    前两种属于进程正常终止,第三种属于非正常终止。

    进程的正常退出会有退出码,非正常退出一般与代码错误和信号有关,比如kill信号导致进程直接终止。

    以下只讨论正常退出的情况,非正常退出会在信号那节提到。

    🌵进程的退出码

    系统通过接口调用main函数,main函数执行完后要给系统一个执行完成的信息,main函数的return 0就充当了这个功能,return 0告诉系统正常结束且结果正确,这里的0就是退出码。(非0一般表示结果不正确)

    echo $? 查看最近一次执行程序的退出码

    image-20220629201753866

    例子

    image-20220629202254785

    随笔记录:如make clean;make ,中间用分号隔开,一次可以运行多条命令

    退出码可以人为定义,也可以用系统或者库里提供的错误码列表。

    比如C语言提供的strerror(在vim界面下怎么查询函数)

    image-20220629203105619

    🌵退出码在进程里的作用

    我们为什么要创建子进程?因为我们需要子进程完成我们给它的任务。

    父进程想知道子进程有没有完成任务,就得借助退出码了。

    🌵进程退出的操作

    • main函数return

    • 任何函数exit。

    非main函数return只是结束函数,而不是结束进程

    举个栗子

    #include<stdio.h>
    #include<string.h>
    #include<stdlib.h>
    void show()
    {
      printf("i am show\n");
      exit(10);
    }
    int main()
    {
      show();
      printf("i am main\n");
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    image-20220629204417203

    所有的进程都是操作系统创建的,fork不过是一个接口。

    🌵exit和_exit

    两者的区别在于,exit会在进程结束做一些收尾工作(比如刷新缓冲区),而_exit不会,看下面的对比。

    exit函数

    image-20220629204839008

    _exit函数

    image-20220629205036587

    🌴进程的等待

    为什么要等待?

    • 回收僵尸进程,解决内存泄漏(僵尸进程是杀不死了,kill -9不能干掉僵尸进程)

    • 获取子进程的运行结束状态(交给子进程的工作做的怎么样了)

    • 父进程晚于子进程退出,规范化的进行资源回收。

    等待的本质也是管理的一种方式,OS的核心就在于管理二字。

    简而言之,进程等待的原因:获取子进程的信息和防止内存泄漏

    🌵等待函数

    wait和waitpid函数

    image-20220629205630505

    头文件是sys/types.h和sys/wait.h

    wait的返回值:等待成功返回子进程的id,等待失败返回-1

    waitpid的返回值:等待成功返回子进程的id,如果指定了WNOHANG选项,且子进程正在运行(没有已退出的子进程可收集)则返回0,等待失败返回-1

    image-20220629214228247

    🌵例子

    例子1:wait的简单使用

    pid_t wait(int*status); wait参数列表里有一个参数status,这个参数的作用和waitpid参数列表中的status作用相同

    wait里的参数设为NULL,表示等待任意一个子进程。如果不设置为空可以带出子进程的状态,比如是否是正常退出(后面会举例)

    #include<stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include<sys/types.h>
    #include<sys/wait.h>
    int main()
    {
      pid_t id=fork();
      if(id<0)
      {
        perror("fork");
      }
      else if(id==0)
      {
        //child
        int cnt=5;
        while(cnt--)
        {
          printf("child is running,pid:%d\n",getpid());
          sleep(1);
        }
        printf("child finished...\n");
        exit(0);
      }
      else 
      {
        //father
        printf("father is waiting\n");
        pid_t ret=wait(NULL);
        printf("father is wait done,ret:%d\n",ret);
      }
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    image-20220629215817574

    例子2:waitpid的简单使用

    pid_ t waitpid(pid_t pid, int *status, int options);

    waitpid有三个参数,第一个是等待的子进程id,第二个是子进程返回的状态,第三个是等待的方式。其中第二个参数status也被称为输出型参数。

    第三个参数option一般是一些宏,比如image-20220630000043919

    不关心子进程的返回状态则用NULL,option如果不想用提供的宏就设为0

    #include<stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include<sys/types.h>
    #include<sys/wait.h>
    int main()
    {
      pid_t id=fork();
      if(id<0)
      {
        perror("fork");
      }
      else if(id==0)
      {
        //child
        int cnt=5;
        while(cnt--)
        {
          printf("child is running,pid:%d\n",getpid());
          sleep(1);
        }
        printf("child finished...\n");
        exit(0);
      }
      else 
      {
        //father
        printf("father is waiting\n");
        pid_t ret=waitpid(id,NULL,0);//waitpid
        printf("father is wait done,ret:%d\n",ret);
      }
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    image-20220629221736842

    例子3:waitpid参数中status的使用

    status的组成

    image-20220629225009388

    #include<stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include<sys/types.h>
    #include<sys/wait.h>
    int main()
    {
      pid_t id=fork();
      if(id<0)
      {
        perror("fork");
      }
      else if(id==0)
      {
        //child
        int cnt=5;
        while(cnt--)
        {
          printf("child is running,pid:%d\n",getpid());
          sleep(1);
        }
        printf("child finished...\n");
        exit(10);
      }
      else 
      {
        //father
        int status=0;
        printf("father is waiting\n");
        pid_t ret=waitpid(id,&status,0);
        if(ret>0&&(status&0x7f)==0)
        {
          printf("正常退出\n");
        }
        else 
        {
          printf("被信号所杀\n");
        }
        printf("father is wait done,ret:%d,\n",ret);
      }
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    正常运行

    image-20220629230302806

    运行中杀掉进程

    image-20220629230043794

    日常中我们并不用位运算去拿到status,而是调用宏

    比如WEXITSTATUS(status)就可以拿到status的退出码

    image-20220630000241818

    🌵小问题

    为什么要通过waitpid拿到子进程的结果,能不能用全局变量?

    不可以,每个进程都有自己独立的进程地址空间,如果用了全局变量会导致虚拟地址看起来一样但是实际指向的物理内存不同(因为全局变量更改时发生了写时拷贝)

    那waitpid里的status是从哪里拿到的呢?这是个系统接口,自然是从task_struct里面

    一般进程提前终止是收到了OS发送的信号。

    退出码有效的前提是正常终止,即正常终止才去看退出码,异常终止看信号(排除掉代码错误后)

    🌵阻塞与非阻塞

    阻塞等待,注意等待的主体是父进程,是父进程等子进程。阻塞等待就是父进程一直在那等啥事也不干,等到子进程干完了自己的事父进程再去干自己的事,比如我们将wait和waitpid的选项设置为0就是阻塞等待。

    非阻塞等待:就是父进程在等待的同时也在做自己的事,在子进程退出后再去读取子进程的退出信息。

    实现非阻塞等待,把waitpid里的第三个参数设置为WNOHANG即可

    理解记忆:计算机资源在即将被吃完的时候,我们称其为hang住了,NOHANG就是别HANG住的意思

    image-20220630002816893

    image-20220630003117308

    非阻塞轮询方案:采用多次检测,过一段时间就检测一次子进程的状态,直到检测到子进程完成自己的任务。

    //非阻塞轮询方案
    #include<stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include<sys/types.h>
    #include<sys/wait.h>
    int main()
    {
      pid_t id=fork();
      if(id<0)
      {
        perror("fork");
      }
      else if(id==0)
      {
        //child
        int cnt=5;
        while(cnt--)
        {
          printf("child is running,pid:%d\n",getpid());
          sleep(2);
        }
        printf("child finished...\n");
        exit(10);
      }
      else 
      {
        //father
        while(1)//一直检测
        {
          int status=0;
          printf("father is waiting\n");
          pid_t ret=waitpid(id,&status,WNOHANG);//根据返回值来确定是否等待成功
          if(ret==0)
          {
            printf("father does other things\n");
            sleep(1);
          }
          else if (ret>0)
          {
            printf("father wait success\n");
            printf("正常结束\n");
            return 0;
          }
          else 
          {
            printf("wait err\n");
            break;
          }
        }
      }
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53

    image-20220630002325979

    待了解:等待队列

    父进程等待子进程时被放进等待队列,等待队列中的进程都处于阻塞状态,之后等子进程完成任务后再唤醒父进程。–结合网上给出的理解…

    🌴进程替换

    为什么需要进程替换?

    我们创建子进程肯定让子进程来执行我们的任务,这个任务在没讲进程替换之前都是父进程给它的,那如果现在我们需要执行别的程序呢?此时就需要进程替换了。

    比如我们想要在我们的程序里面执行ls,top等命令

    之前我们说过,一般情况下写时拷贝是针对数据的,一般不会改变代码,但我们说的是一般情况下。进程替换下就可能更改了代码,进而引发代码的写时拷贝。

    🌵原理

    下面简单将程序理解为代码和数据。

    进程替换并不是创建了新的进程,而是一种替换。以磁盘上的一个程序去替代子进程为例,是将磁盘上程序的代码和数据去替代子进程的代码和数据,此时就会有写时拷贝。

    image-20220630223257446

    值得注意的一点是,因为进程替换并没有创建进程,所以该进程的pid和数据结构都是不变的。

    🌵操作

    实现进程替换需要通过exec系列的库函数。

    让我们问问linux的man。

    man 2 execl

    image-20220630223332962

    这几个函数都是对execve的封装,因为系统真正调用的execve函数,execve函数与这几个函数是同一个头文件

    image-20220630122610731

    因为进程替换的原因,我们一般不用考虑这几个函数的返回值,因为替换后直接从替换的代码开始执行,现有的这个进程就被替换掉了,一旦返回就说明替换失败,函数出错了,此时会返回-1。

    🌵例子

    execl

    int execl(const char *path, const char *arg, …);

    参数列表末尾的…表示可变参数列表,说明可以有任意多个参数,以NULL结尾

    which ls可以知道ls命令的路径,所以which常与exec系列的函数连用

    path表示替换程序的路径,arg表示你要如何执行这个程序,以ls -a -l为例

    execl(“/usr/bin/ls”,“ls”,“-a”,“-l”,NULL);

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
      execl("/usr/bin/ls","ls","-a","-l",NULL);
      printf("hello world\n");
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    image-20220630222000583

    execlp

    int execlp(const char *file, const char *arg, …);

    file表示要执行的程序,arg表示要如何执行,与execl的不同点在于这个函数会在PATH环境变量下自动寻找程序。

    与execl相比函数名多了一个p,这个p表示path。

    image-20220630223715147

    execle

    int execle(const char *path, const char *arg, …, char *const envp[]);

    与execl相比多了最后一个参数,envp这个字符指针数组表示我们自己定义的环境变量。

    作用是传入默认或自定义的环境变量给目标可执行程序

    传入的环境变量是不是永久的,只是在当前可用


    下面的例子用mycmd程序替代当前进程,并且传入环境变量。

    mycmd.c

    #include <stdio.h>
    #include <stdlib.h>
    int main()
    {
      for(int i=0;i<5;i++)
      {
        printf("cmd:%d\n",i);
      }
      printf("获取自己定义的环境变量:%s\n",getenv("MYENV"));
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    getenv是一个库函数,获取这个环境变量的值,MYENV是我们自己定义的环境变量,系统里找不到,此时要用到我们自己定义的环境变量就用execle,换句话说execle可以传递环境变量

    replace.c

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
      char* const envp[]={"MYENV=11",NULL};
      execle("./mycmd","mycmd",NULL,envp);
      printf("hello world\n");
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    现在我们有两个文件,怎么在Makefile里面一次性编译多个程序?写法如下

    .PHONY:all
    all:replace mycmd
    replace:replace.c
    	gcc -o $@ $^ --std=c99
    mycmd:mycmd.c
    	gcc -o $@ $^ --std=c99
    
    .PHONY:clean
    clean:
    	rm -f replace mycmd
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    运行结果

    image-20220630230905425

    execv

    int execv(const char *path, char *const argv[]);

    与execl相比,execl的arg表示要怎么直线这个程序,execv则是通过数组实现相同的功能,在字符指针数组里以字符串来表示要执行的选项,也是以NULL结尾。

    #include <stdio.h>
    #include <unistd.h>
    int main()
    {
      char* const argv[]={"ls","-a","-l",NULL};
      execv("/usr/bin/ls",argv);
      printf("hello world\n");
      return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    运行结果

    image-20220630231212493

    execve和execvp

    这两个函数与execv相比,一个多了p(在PATH下自动找程序),一个多了e(传递需要的环境变量信息),他们的区别就是execl和execle,execlp的区别。这里就不举例了

    我们知道程序需要运行前需要先通过加载器加载到内存,exec就像一个特殊的加载器加载程序达到替换的效果。

    🌴简易Shell的实现

    这个小功能整合了以上许多的知识点。

    真正的实现一个Shell需要很多字符操作,这里简易版本只涉及简单的命令解析。

    image-20220630232551158

    这个Shell的功能

    • 获取命令行
    • 解析命令行
    • 建立子进程
    • 替换子进程
    • 父进程等待子进程退出

    🌵代码实现

    #include <stdio.h>
    #include <unistd.h>
    #include <string.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <stdlib.h>
    
    #define NUM 128
    #define SIZE 32
    
    char command_line[NUM];//命令行
    char* command_parse[SIZE];//切分命令行
    int main()
    {
      while(1)
      {
        memset(command_line,'\0',sizeof(command_line));
        printf("[我的Shell]$ ");
        fflush(stdout);
        if(fgets(command_line,NUM-1,stdin))
        {
          command_line[strlen(command_line)-1]='\0';//处理\n
          int index=0;
          command_parse[index]=strtok(command_line," ");
          while(1)
          {
            index++;
            command_parse[index]=strtok(NULL," ");
            if(command_parse[index]==NULL)
            {
              break;
            }
          }
          if(fork()==0)
          {
            execvp(command_parse[0],command_parse);
            exit(1);//进程替换失败  成功这句代码会被替换掉     
          }
          int status=0;
          pid_t ret=waitpid(-1,&status,0);
          if(ret>0 && WIFEXITED(status))
          {
            printf("EXIT Code:%d\n",WEXITSTATUS(status));
          }
        }
      }
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    image-20220630235518436

    bug:如cd …的功能为什么不能在子进程中实现?cd …的主体是父进程,父子进程是独立的,子进程去更改并不能去影响父进程,需要在子进程里影响到父进程肯定不是我们能做的,我们需要系统提供给我们的一些接口才能完成,即我们需要调用系统接口来实现cd…。echo $PATH也是一个道理
    此外这个Shell不能输入特殊字符,比如backspace

    调试可以结合/proc目录,/proc目录下有着进程的相关信息。

  • 相关阅读:
    Leetcode面试经典150题-148.排序链表
    计算机毕设 大数据B站数据分析与可视化 - python 数据分析 大数据
    VsCode实用插件分享
    RSA加密的使用(前后端)
    记笔记!国内怎么操作好现货白银
    TStor OneCOS 技术专栏——轻松单桶万亿
    第3周学习:ResNet+ResNeXt
    微信小程序OA会议系统个人中心授权登入
    uniapp 一次性上传多条视频 u-upload accept=“video“ uni.chooseMedia uni.uploadFile
    VL53L5CX驱动开发(5)----运动阈值检测
  • 原文地址:https://blog.csdn.net/m0_53005929/article/details/125551796