Linux学习之路 -- 进程篇 -- 自定义shell的编写

前面介绍了进程程序替换的相关知识,接下来,我将介绍如何基于前面的知识,编写一个简单的shell,另外本文的所展示的shell可能仅供参考。

目录

<1>获取用户的输入和打印命令行提示符

<2>切割字符串

<3>执行这个命令

<4>判断内建命令

<1>cd命令

<2>export

<3>echo命令

<5>全部代码


<1>获取用户的输入和打印命令行提示符

首先我们打开shell时,一般都会看到一个命令行提示符

目前光标卡在当前位置不动,就是在等待用户输入一段命令,这一段命令会被当成字符串。所以我们首先要做的工作就是获取命令行提示符和用户的输入。

在获取命令行提示符前,我们需要回顾一下命令行提示符的组成

虽然用户名、主机名和路径都能通过系统接口进行获取,但是我们也可以通过环境变量来获取这些数据。而我们可以通过getenv接口,就能获得特定环境变量的内容。

下面演示一下输出命令行提示符

#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>


const char* HostName()
{
    char* hostname = getenv("HOSTNAME");
    if(hostname)
    {
        return hostname;
    }
    else
    {
        return "NONE";
    }
}
const char* UserName()
{
    char* username = getenv("USER");
    if(username)
    {
        return username;
    }
    else
    {
        return "NONE";
    }
}
const char* Currentdir()
{
    char* dirname = getenv("PWD");
    if(dirname)
    {
        return dirname;
    }
    else
    {
        return "NONE";
    }
}
int main(int argc, char* argv[],char* env[])
{
    printf("[%s@%s -- %s]$",UserName(),HostName(),Currentdir());
    return 0;
}

这里需要注意的是,HOSTNAME在一些操作系统中可能没有,这可能是很多原因导致的。所以我们可以在获取HOSTNAME这个环境变量之前手动添加。

运行效果

接下来再解决一下用户输入问题

我们可以用一个字符数组先储存命令行参数。至于输入,我们可以使用fgetc,不能用scanf函数,scanf在读到空串时,会自动停止读取。所以我们使用fgetc从缓冲区里面读取(当然也可以使用其他的接口,这里我以fgets为例)。下面介绍一下fgetc接口

第一个参数表示,存放缓冲区数据的数组,第二个表示数组大小,第三个表示输入流指针(这个暂不做介绍,涉及文件系统内容,这里直接写stdin即可)。

下面演示一下代码

#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<string.h>
#define SIZE 1024

const char* HostName()
{
    char* hostname = getenv("HOSTNAME");
    if(hostname)
    {
        return hostname;
    }
    else
    {
        return "NONE";
    }
}
const char* UserName()
{
    char* username = getenv("USER");
    if(username)
    {
        return username;
    }
    else
    {
        return "NONE";
    }
}
const char* Currentdir()
{
    char* dirname = getenv("PWD");
    if(dirname)
    {
        return dirname;
    }
    else
    {
        return "NONE";
    }
}
int main(int argc, char* argv[],char* env[])
{
    char command[SIZE];
    printf("[%s@%s -- %s]$",UserName(),HostName(),Currentdir());
    fgets(command,SIZE,stdin);
    printf("command line: %s\n",command);
    return 0;
}

运行结果

这里我们会发现,中间printf打印完一条语句后,多了一行空白,这行空白是其实是因为我们在输入时也会敲回车键,所以实际在执行printf语句时,会有两个\n,这就会造成中间多了一行空白。这里我们只需要将command数组里面存储的最后一个字符由'\n'变成'\0'或0即可。所以只需在fgets语句后添加下面一条语句即可。

    command[strlen(command) - 1] = 0;

无需担心command长度为零的情况,因为无论如何你都要输入一个'\n'。所以command的长度至少也是1。但是我们需要对空串进行一下判断,如果是空串,后面的代码就不需要执行了(这条下面封装时会用到)。

调整后的结果

这里我们可以对命令行提示符的显示和用户输入功能封装一下。

<2>切割字符串

如果我们要执行一个命令,就必需调用程序替换的接口,而在之前的介绍里面,程序替换接口的参数都是没有空格的字符串,而且都是一个一个分开的。所以我们必需要将字符串切割成一个一个子串,然后传递给这些接口。所以第一步就是先分割子串,那我们该如何切割呢?下面介绍一个函数strtok

str参数表示要切割的串,delim表示以什么为分割符。需要注意的是,第一次调用该函数时,str参数传需要切割的字符串指针,第二次传NULL,delim不变,就是" "(空格)。

下面演示一下代码

#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<string.h>

#define SIZE 1024
#define argc 128
#define SYM  " "
char* argv[argc];



const char* HostName()
{
    char* hostname = getenv("HOSTNAME");
    if(hostname)
    {
        return hostname;
    }
    else
    {
        return "NONE";
    }
}
const char* UserName()
{
    char* username = getenv("USER");
    if(username)
    {
        return username;
    }
    else
    {
        return "NONE";
    }
}
const char* Currentdir()
{
    char* dirname = getenv("PWD");
    if(dirname)
    {
        return dirname;
    }
    else
    {
        return "NONE";
    }
}
int interactive(char* command)
{
    printf("[%s@%s -- %s]$",UserName(),HostName(),Currentdir());
    fgets(command,SIZE,stdin);
    command[strlen(command) - 1] = 0;
    return strlen(command);
}
void Split(char* command)
{
    int i = 0;
    argv[i++] = strtok(command,SYM);//argv为全局变量,用于存放字符变量
    while(argv[i++] = strtok(NULL, SYM));//SYM为空格,这里是个宏
}
int main()
{
    char command[SIZE];
    //1.获取用户指令并打出命令行提示符
    int ret = interactive(command);//如果是空串,则下面的代码就不必执行了
    if(!ret)
    {
    }
    else
    {
        //2.切割命令行
        Split(command);
        for(int i = 0; argv[i];i++)
        {
            printf("argv[%d]:%s\n",i,argv[i]);
        }
    }
    return 0;
}

Split函数中的while循环条件变成argv[i++] = strtok(NULL, SYM),可以直接把strtok切割的子串放进argv里面,而且在切完后,strtok会返回NULL,我们之前了解过,命令行参数列表的结尾就是NULL。同时我们argv[ i ]设为空后,条件判断也就不成立了,此时也就跳出循环并 i++。

运行结果

从结果上来看,上述的代码逻辑并没有什么问题。

<3>执行这个命令

在切割完命令行后,我们就需要依照argv来执行命令。在这里我们依照程序替换的方式来进行执行命令。不过在这之前,我们需要创建一个子进程。这是因为我们的shell需要关注用户和机器之间的交互,如果我们直接让shell执行命令,那交互性能就会变差。万一程序崩溃了,shell也会无法运行。所以这里我们就需要让子进程替我们执行命令。

在创建完子进程后,我们就可以让子进程执行对应的任务。执行任务的过程其实并不难,就是选择一个合适的程序替换接口即可。这里我们选择execvp这个接口是最好的,因为该接口的参数是最少的,并且中间不用做处理。在执行完后,我们需要等待回收子进程,这里我使用的是阻塞等待。

下面是演示的代码

#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<string.h>

#define SIZE 1024
#define argc 128
#define SYM  " "
char* argv[argc];



const char* HostName()
{
    char* hostname = getenv("HOSTNAME");
    if(hostname)
    {
        return hostname;
    }
    else
    {
        return "NONE";
    }
}
const char* UserName()
{
    char* username = getenv("USER");
    if(username)
    {
        return username;
    }
    else
    {
        return "NONE";
    }
}
const char* Currentdir()
{
    char* dirname = getenv("PWD");
    if(dirname)
    {
        return dirname;
    }
    else
    {
        return "NONE";
    }
}
int interactive(char* command)
{
    printf("[%s@%s -- %s]$",UserName(),HostName(),Currentdir());
    fgets(command,SIZE,stdin);
    command[strlen(command) - 1] = 0;
    return strlen(command);
}
void Split(char* command)
{
    int i = 0;
    argv[i++] = strtok(command,SYM);//argv为全局变量,用于存放字符变量
    while(argv[i++] = strtok(NULL, SYM));//SYM为空格,这里是个宏
}

void Execute()
{
    pid_t id = fork();
    if(id == 0)
    {
        execvp(argv[0],argv);
    }
    pid_t rid = waitpid(id,NULL,0);
    // if(rid > 0)
    // {
    //     printf("wait success, pid: %d\n",rid);
    // }
}
int main()
{
    while(1)//让shell持续运行
    {
        char command[SIZE];
        //1.获取用户指令并打出命令行提示符
        int ret = interactive(command);
        if(!ret)
        {
            continue;
        }
        //2.切割命令行
        Split(command);
        //3.执行命令
        Execute();
    }
    // for(int i = 0; argv[i];i++)
    // {
    //     printf("argv[%d]:%s\n",i,argv[i]);
    // }
    return 0;
}

运行结果:

此时,我们运行普通的命令已经没有什么问题了,但是一旦我们运行类似于cd .. 命令时,就会出现无法执行的情况。其实这是因为决定当前路径的父进程,这里我们使用子进程执行cd 命令,但是父进程的路径并没有改变,所以当我们执行cd命令后,再执行pwd命令,会发现路径并没有改变。像这样的情况还有很多,这些命令本就不应该交由子进程执行,而是让父进程直接执行。而这些命令叫做内建命令。我们在执行命令前要加一个步骤,那就是判断内建命令,并让父进程去执行这个命令。

<4>判断内建命令

<1>cd命令

由于一些命令是需要父进程自己执行的,所以我们就需要修改执行顺序。首先我们先以cd 命令为例,修改一下原来的shell。在此之前我们需要先了解一下chdir接口,这个接口是修改当前工作路径的。

path就是修改后的路径。使用这个命令的原因是因为cd 命令后可能是没有东西的,这会直接进入该用户的家目录。所以我们必需要通过这个接口来实现工作路径的切换。

在编写判断内建命令的函数时,一共分为两步,一是判断是否为内建命令,二是执行内建命令。如果是内建命令,我们需要执行并且在退出后跳过子进程执行的步骤。如果不是内建命令,那就直接退出函数,继续让子进程执行该命令。

下面演示一下代码(这里只截取部分代码,其实较上面的代码,就只是增加了Built-in-com()函数)

int Built_in_com()//用返回值判断是否为内建命令,如果是返回 1, 不是返回 0.
{
    int ret = 0;
    if(strcmp(argv[0],"cd") == 0)
    {
       ret = 1;
       char* home = argv[1];
       if(!home) home = Home(); 
       chdir(home);
    }
    return ret;
}
int main()
{
    while(1)//让shell持续运行
    {
        char command[SIZE];
        //1.获取用户指令并打出命令行提示符
        int ret = interactive(command);
        if(!ret)
        {
            continue;
        }
        //2.切割命令行
        Split(command);
        //3.处理内建命令
        ret = Built_in_com();
        if(ret)
        {
            continue;
        }
        //4.执行命令
        Execute();
    }
    // for(int i = 0; argv[i];i++)
    // {
    //     printf("argv[%d]:%s\n",i,argv[i]);
    // }
    return 0;
}

执行结果

我们可以发现,虽然命令行提示符的路径没有改变,但是pwd命令和cd命令确实是成功执行了。这里的命令行提示符是从环境变量中获取当前的工作路径的,命令行提示符路径没有改变,说明环境变量没有跟着chdir的改变而改变,所以我们在内建命令改变路径时,我们要手动对环境变量进行更新,以确保环境变量是正确的。

要修改环境变量,我们就不得不提到putenv这个接口了,这个接口常用于添加和修改环境变量。

这里的string参数就是要修改的环境变量参数,具体的参数形式:"USER=root"(例)。不过要获得修改后的环境变量参数,又要使用别的字符串函数。

要获得修改后的环境变量参数,就得先获取环境变量形式的字符串,而要获得这个字符串,我们可以通过很多种方式,可以使用strcat,strcpy等等,这里我使用snprintf。

printf就是把特定格式的内容写到显示器上,而sprintf就是把特定格式的内容写到一个str字符串里面,而snprintf就是把指定长度的内容写到一个str字符串里面。

我们可以定义一个全局变量数组pwd,里面存放修改后的环境变量字符串。通过snprintf我们就可以把对应的字符串写入pwd中,再putenv(pwd)即可。

下面演示一下代码(这里只对Built_in_com这个函数进行修改,其他的一律不做修改)

int Built_in_com()//用返回值判断是否为内建命令,如果是返回 1, 不是返回 0.
{
    int ret = 0;
    if(strcmp(argv[0],"cd") == 0)
    {
       ret = 1;
       char* home = argv[1];
       if(!home) home = Home(); 
       chdir(home);
       snprintf(pwd,SIZE,"PWD=%s",home);
       putenv(pwd);
    }
    return ret;
}

运行结果

除了cd .. 外,其他的命令都还正常,说明之前的代码总体逻辑是没有啥问题的,只不过cd .. 这个需要处理一下。这里出现 .. 路径是因为没有获得 " .. "代表的绝对路径。所以我们就需要通过特定的接口获得..路径的绝对路径 。我们可以通过getcwd函数获取".."的绝对路径。

getcwd函数就是获取当前的工作路径,具体的参数含义如下:

  1. buf:这是一个指向字符数组的指针,用于存储获取到的当前工作目录的路径。getcwd函数会将路径字符串写入到这个数组中。

  2. size:这个参数指定了buf数组的大小,也就是它能够存储的字符数量。这个大小应该至少能够容纳当前工作目录的路径加上一个终止的空字符('\0')。

既然可以通过此时,我们就可以直接把buf里面内容写到pwd中,再由pwd写入到环境变量表中。

下面演示一下代码

int Built_in_com()//用返回值判断是否为内建命令,如果是返回 1, 不是返回 0.
{
    int ret = 0;
    if(strcmp(argv[0],"cd") == 0)
    {
       ret = 1;
       char* home = argv[1];
       if(!home) home = Home(); 
       chdir(home);
       char word[512];
       getcwd(word,512);
       snprintf(pwd,SIZE,"PWD=%s",word);
       putenv(pwd);
    }
    return ret;
}

运行结果

其他的内建命令

内建命令当然不止只有cd,还有很多,下面列举一些供大家参考

  • cd - 改变当前工作目录
  • echo - 显示消息或变量的值
  • exit - 退出当前shell
  • export - 设置环境变量
  • history - 显示或操作命令历史
  • kill - 发送信号到特定进程
  • pwd - 显示当前工作目录的路径
  • set - 设置或显示shell特性或位置参数
  • source - 在当前shell执行脚本
  • unset - 删除变量或函数
  • wait - 等待后台进程结束

下面在原来shell基础上,再添加几个内建命令

<2>export

export命令导入环境变量也是内建命令,所以我们需要添加进Built_in_com 这个函数中。这个命令会相对简单一点,我们可以直接写代码

下面演示一下代码

bash 复制代码
int Built_in_com()//用返回值判断是否为内建命令,如果是返回 1, 不是返回 0.
{
    int ret = 0;
    if(strcmp(argv[0],"cd") == 0)
    {
       ret = 1;
       char* home = argv[1];
       if(!home) home = Home(); 
       chdir(home);
       char word[512];
       getcwd(word,512);
       snprintf(pwd,SIZE,"PWD=%s",word);//注意这里数字的大小不能越界
       putenv(pwd);
    }
    else if(strcmp(argv[0],"export") == 0) // 
    {
        ret = 1;
        if(argv[1]) putenv(argv[1]);
        
    }
    return ret;
}

运行结果

这个结果看似是非常正确的,但其实我们只要运行几次其他命令,再次查看环境变量。我们就会发现,我们新增的环境变量消失了。所以上述的代码其实是不完善的。这里的argv[ 1 ]是一个指针,指向command里面的一段内容,而我们每次输入新的命令,command就会被覆盖,而argv[ 1 ]指向的内容也会随之改变。 所以我们需要通过一个数组来存储特定环境变量,令其固定不变。这里为了方便演示,这里只用一个一维字符数组来存储一个新增的环境变量。这里严格意义上来说,是要通过一个环境变量表来存储环境变量的。

下面演示一下代码

bash 复制代码
    else if(strcmp(argv[0],"export") == 0)
    {
        ret = 1;
        if(argv[1]) 
        {
            strcpy(env,argv[1]);
            putenv(env);
        }
    }

由于运行结果过长,这里不变展示,读者可以自行测试。

<3>echo命令

echo命令也是一个内建命令,通常用于打印一些变量值,常见的就是"echo XXX",向显示器打印XXX;"echo $环境变量名",向显示器打印环境变量;"echo $?" 打印退出码。除此之外,echo还可以结合重定向进行操作,不过这里不做演示,这部分内容涉及文件系统的内容。这里主要演示上面所述的三个与echo有关的命令。

1.直接echo

echo后面不接内容,就是直接换行。

2.echo $?

该命令会显示上一个进程的退出码,这个退出码最好就用全局变量来保存。

3.echo $环境变量

该命令会显示对应环境变量的内容,不过需要判断这个环境变量是否存在。

4.echo XXX

该命令会直接打印XXX到显示器上

下面演示一下代码(只展示修改被修改部分的代码)

bash 复制代码
void Execute()
{
    pid_t id = fork();
    if(id == 0)
    {
        execvp(argv[0],argv);
    }
    int status = 0;
    pid_t rid = waitpid(id,&status,0);
    if(WIFEXITED(status))
    {
        lastcode = WEXITSTATUS(status);
    }
    // if(rid > 0)
    // {
    //     printf("wait success, pid: %d\n",rid);
    // }
}
int Built_in_com()//用返回值判断是否为内建命令,如果是返回 1, 不是返回 0.
{
    int ret = 0;
    if(strcmp(argv[0],"cd") == 0)
    {
       ret = 1;
       char* home = argv[1];
       if(!home) home = Home(); 
       chdir(home);
       char word[512];
       getcwd(word,512);
       snprintf(pwd,SIZE,"PWD=%s",word);//注意这里数字的大小不能越界
       putenv(pwd);
    }
    else if(strcmp(argv[0],"export") == 0)
    {
        ret = 1;
        if(argv[1]) 
        {
            strcpy(env,argv[1]);
            putenv(env);
        }
    }
    else if(strcmp(argv[0],"echo") == 0)
    {
        ret = 1;  
        if(argv[1] == NULL)
        {
            printf("\n");
        }
        else
        {
            if(argv[1][0] == '$')
            {
                if(argv[1][1] == '?')
                {
                    printf("%d\n",lastcode);
                    lastcode = 0;
                }
                else // echo $环境变量名
                {
                    char* n = getenv(argv[1]+1);
                    if(n)
                    {
                        printf("%s\n",n);
                    }
                    else
                    {
                        printf("The environment variable does not exist\n");
                    }
                }
            }
            else//echo XXXX 这里暂不考虑其他情况,例如echo和重定向符号结合
            {
                printf("%s\n",argv[1]);
            }
        }
        }
    return ret;
}

这里lastcode变量定义为全局变量。echo的其他搭配暂时不考虑。

运行结果

额外的配置:我们可以看见当我们使用ls命令时,打印出来的文件名是没有颜色的。如果我们想让文件名具有颜色,需要再Split函数里面进行修改,我们需要再命令行参数列表中加上一个"--color"字符即可。

下面演示一下代码

bash 复制代码
void Split(char* command)
{
    int i = 0;
    argv[i++] = strtok(command,SYM);//argv为全局变量,用于存放字符变量
    while(argv[i++] = strtok(NULL, SYM));//SYM为空格,这里是个宏
    if(strcmp(argv[0],"ls") == 0)
    {
        argv[i - 1] = "--color";
        argv[i] = NULL;
    }
}

运行结果

<5>全部代码

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

#define SIZE 1024
#define argc 128
#define SYM  " "
char* argv[argc];
char pwd[SIZE];
char env[SIZE];
int lastcode;

const char* HostName()
{
    putenv("HOSTNAME=iZuf6at4ih6u7gbg2vxumnZ");
    char* hostname = getenv("HOSTNAME");
    if(hostname)
    {
        return hostname;
    }
    else
    {
        return "NONE";
    }
}
const char* UserName()
{
    char* username = getenv("USER");
    if(username)
    {
        return username;
    }
    else
    {
        return "NONE";
    }
}
const char* Currentdir()
{
    char* dirname = getenv("PWD");
    if(dirname)
    {
        return dirname;
    }
    else
    {
        return "NONE";
    }
}
char* Home()
{
    char* home = getenv("HOME");
    if(home)
    {
        return home;
    }
    else
    {
        return NULL;
    }
}
int interactive(char* command)
{
    printf("[%s@%s -- %s]$",UserName(),HostName(),Currentdir());
    fgets(command,SIZE,stdin);
    command[strlen(command) - 1] = 0;
    return strlen(command);
}
void Split(char* command)
{
    int i = 0;
    argv[i++] = strtok(command,SYM);//argv为全局变量,用于存放字符变量
    while(argv[i++] = strtok(NULL, SYM));//SYM为空格,这里是个宏
    if(strcmp(argv[0],"ls") == 0)
    {
        argv[i - 1] = "--color";
        argv[i] = NULL;
    }
}

void Execute()
{
    pid_t id = fork();
    if(id == 0)
    {
        execvp(argv[0],argv);
    }
    int status = 0;
    pid_t rid = waitpid(id,&status,0);
    if(WIFEXITED(status))
    {
        lastcode = WEXITSTATUS(status);
    }
    // if(rid > 0)
    // {
    //     printf("wait success, pid: %d\n",rid);
    // }
}
int Built_in_com()//用返回值判断是否为内建命令,如果是返回 1, 不是返回 0.
{
    int ret = 0;
    if(strcmp(argv[0],"cd") == 0)
    {
       ret = 1;
       char* home = argv[1];
       if(!home) home = Home(); 
       chdir(home);
       char word[512];
       getcwd(word,512);
       snprintf(pwd,SIZE,"PWD=%s",word);//注意这里数字的大小不能越界
       putenv(pwd);
    }
    else if(strcmp(argv[0],"export") == 0)
    {
        ret = 1;
        if(argv[1]) 
        {
            strcpy(env,argv[1]);
            putenv(env);
        }
    }
    else if(strcmp(argv[0],"echo") == 0)
    {
        ret = 1;  
        if(argv[1] == NULL)
        {
            printf("\n");
        }
        else
        {
            if(argv[1][0] == '$')
            {
                if(argv[1][1] == '?')
                {
                    printf("%d\n",lastcode);
                    lastcode = 0;
                }
                else // echo $环境变量名
                {
                    char* n = getenv(argv[1]+1);
                    if(n)
                    {
                        printf("%s\n",n);
                    }
                    else
                    {
                        printf("The environment variable does not exist\n");
                    }
                }
            }
            else//echo XXXX 这里暂不考虑其他情况,例如echo和重定向符号结合
            {
                printf("%s\n",argv[1]);
            }
        }
        }
    return ret;
}
int main()
{
    while(1)//让shell持续运行
    {
        char command[SIZE];
        //1.获取用户指令并打出命令行提示符
        int ret = interactive(command);
        if(!ret)
        {
            continue;
        }
        //2.切割命令行
        Split(command);
        //3.处理内建命令
        ret = Built_in_com();
        if(ret)
        {
            continue;
        }
        //4.执行命令
        Execute();
    }
    // for(int i = 0; argv[i];i++)
    // {
    //     printf("argv[%d]:%s\n",i,argv[i]);
    // }
    return 0;
}

如有需要,可自行拓展,另外上述代码仅供参考,不是唯一的写法。

以上就是全部内容,文中如有不对之处,还望各位大佬指正,谢谢!!!

相关推荐
方竞2 分钟前
Linux空口抓包方法
linux·空口抓包
sun0077001 小时前
ubuntu dpkg 删除安装包
运维·服务器·ubuntu
Red Red1 小时前
网安基础知识|IDS入侵检测系统|IPS入侵防御系统|堡垒机|VPN|EDR|CC防御|云安全-VDC/VPC|安全服务
网络·笔记·学习·安全·web安全
海岛日记1 小时前
centos一键卸载docker脚本
linux·docker·centos
AttackingLin2 小时前
2024强网杯--babyheap house of apple2解法
linux·开发语言·python
Natural_yz3 小时前
大数据学习17之Spark-Core
大数据·学习·spark
qq_172805593 小时前
RUST学习教程-安装教程
开发语言·学习·rust·安装
吃肉不能购3 小时前
Label-studio-ml-backend 和YOLOV8 YOLO11自动化标注,目标检测,实例分割,图像分类,关键点估计,视频跟踪
运维·yolo·自动化