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

Linux->自定义shell

目录

引入:

1:shell是什么?

2:命令行提示符是什么?

3:xshell是什么?

一:命令行提示符  

二: 获取用户输入

三:分割字符串

四:执行命令

五:内建处理

1:内建指令-->cd

2:内建指令-->export

3:内建指令-->echo

六:重定向

1:先遍历查找到重定向符号

2:赋值 redir_type 和 将重定向符号置为'\0'

3:获取到文件名字

七:上色

八:总代码

1:makefile

2:myshell.c 

九:Ubuntuhb版本下的差异

1:获取主机名的方式

2:open函数的权限


引入:

1:shell是什么?

Shell 是操作系统内核(Kernel)与用户之间的接口,负责接收用户输入的命令,解释并传递给操作系统执行,同时返回结果。它是操作系统的“外壳”!既然你是一个接口,就代表你是一个程序!

2:命令行提示符是什么?

是使用 Shell 的文本交互界面,也就是我们一直输入指令的地方。所以我们不断的输入指令,其实本质就是不断的在调用shell这个程序,来对我们的指令作解析;所以shell内部一定自启动的同时还是自循环的,否则我们怎么可以不断的只用写指令,还从来没有显式的调用这个程序?

3:xshell是什么?

Xshell 是一款终端模拟器软件(Windows 平台),用于远程连接和管理服务器;其作用是提供一个图形化界面,让用户通过命令行与远程服务器交互!本质上是一个“桥梁”,将本地输入的命令传递给远程服务器的 Shell 执行!

类比说明:

  • Shell 像“翻译官”:负责将你的命令翻译成系统能理解的操作。

  • 命令行提示符 像“对话的纸张”:是你写命令和看结果的媒介。

  • Xshell 像“电话”:让你(本地)能远程和另一台计算机的“翻译官”(Shell)对话。

Q1:所以自定义shell的本质是什么?

A1:其实就是实现shell外壳罢了,也就是写一个接口(程序),让其能够对指令做出正确的反应,我们不在对自带的shell外壳发送指令,而是要对我们自己的写的程序发送指令,其也能做出正确反应!所以我们要知道的是,我们的命令行提示符也是shell程序的一部分,因为shell程序运行了起来,给我们显示了命令行提示符,我们才知道在哪里输入指令!

所以现在需要明白的是,即使OS都是Linux,但是在不同的发行版下,命令行提示符会有所差距,所以针对不对的发行版的shell自定义就会有所差距!

Q2:发行版是什么意思?

A2:就像同样使用汽油发动机,但丰田和宝马的发动机的设计不同;所以同时是Linux,但是CentOS和Ubuntuh的设计不同,CentOS 更适合企业使用,Ubuntuh更适合个人使用,当然区别不止这一点,我们只用知道二者的命令行提示符有区别,所以其的自定义shell就有区别,所以我们可以先见识一下二者的命令行提示符的区别:

注:二者都是wtt1这个普通用户在/home/wtt1/dir1下的命令行提示符!

①:CentOS的命令行提示符

[wtt1@hcss-ecs-1a2a dir1]$
部分说明
[wtt1 或 wtt1当前登录用户名(wtt1),方括号 [ ] 是 CentOS 的常见风格。
@hcss-ecs-1a2a主机名(假设与之前示例一致)。
dir1当前目录名(位于 /home/wtt1/dir1 下,~ 被替换为实际目录名)。
]$ 或 $提示符符号:$ 表示普通用户,# 表示 root 用户。

②:Ubuntuh的命令行提示符

wtt1@hcss-ecs-1a2a:~/dir1$
  • wtt1:当前登录的用户名(这里是 wtt1)。

  • @:分隔符,表示“在”哪台主机上。

  • hcss-ecs-1a2a:主机名(Hostname),即这台计算机的名称。

  • ::分隔符。

  • ~:当前工作目录(~ 是用户主目录的简写,完整路径通常是 /home/wtt1)。

  • $:提示符符号,表示当前是普通用户身份。如果是 #,则表示超级用户(root)

 

解释:可以看出二者的差距不大,我们实现的是CentOS下的shell!当然,自带的shell程序时非常强大的,其对任何指令都能做出正确反应,我们是肯定做不到的,因为我们连指令都没有全部见过,所以此篇自定义shell只是对常见的指令做出反应,重点是在实现的过程中体会shell的本质!

注:博客会涉及到很多知识,所以务必先看这两篇博客:

Linux->进程控制(精讲)-CSDN博客(先看)

Linux->基础IO-CSDN博客

一:命令行提示符  

第一步肯定是向用户打印出命令行提示符,所以我们需要获取到三个环境变量,USER(用户名),PWD(当前工作目录),以及HOSTNAME(主机名),我在实现的时候,不会像引入中标准的命令行提示符那样省去部分路径,而是直接打印绝对路径,一是因为这样会简化代码,二是在后面能够通过绝对路径反应出代码的不足,所以望周知!

代码如下:

#include <stdio.h>
#include <stdlib.h>//getenv();//获取环境变量-->主机名
const char* HostName()
{char *hostname = getenv("HostName");if(hostname) return hostname;//对获取失败的处理else return "None";
}//获取环境变量-->用户名
const char* UserName()
{char *hostname = getenv("USER");if(hostname) return hostname;//对获取失败的处理else return "None";
}//获取环境变量-->当前工作目录
const char *CurrentWorkDir()
{char *hostname = getenv("PWD");if(hostname) return hostname;//对获取失败的处理else return "None";
}int main()
{// 1. 打印命令行提示符printf("[%s@%s %s]$ ",UserName(),HostName(), CurrentWorkDir());printf("\n");return 0;
}

运行效果:

代码很简单,获取三个环境变量然后在通过指定的排版和格式打印出来我们的命令行提示符

二: 获取用户输入

给用户展示了命令行提示符,那么用户就开始输入其指令了,用户输入的指令本质是字符串,如果指令带有选项,则是一个带有空格字符的字符串,所以我们现在要先获取到用户输入的指令字符串

不能用scanf来获取,scanf默认读取到空格就会停止,所以我们采取fgets来获取,为什么用fgets来获取,一是因为其能够读取到含有空格的整个字符串,而是因为其是C的接口,我们知道Linux的底层代码99%是C,所以我们不用getline这种C++接口

代码如下:

#include <stdio.h>
#include <stdlib.h>//getenv();//上方部分代码省略 .....//交互函数
//打印命令行提示符+获取用户输入的指令字符串
void Interactive(char out[], int size)
{//输出提示符printf("[%s@%s %s]$ ", UserName(), HostName(), CurrentWorkDir());//获取用户输入的命令字符串"ls -a -l"fgets(out, size, stdin);}int main()
{   // 1. 打印命令行提示符,获取用户输入的命令字符串char commandline[SIZE];Interactive(commandline, SIZE);return 0;
}

解释:我们创建了一个译为交互的函数,其把打印命令行提示符和获取用户输入的命令字符串这两件事情一起做了!

fgets函数:

char *fgets(char *str, int size, FILE *stream);

参数: 

  • str:存储读取数据的字符数组(缓冲区)。

  • size:最多读取的字符数(包括结尾的 \0)。

  • stream:输入流(如 stdin、文件指针等)。

注:所以size一般就会设定为str的大小,这样可以防止缓冲区溢出

返回值

  • 成功:返回 str(即传入的缓冲区地址)。
  • 失败或到达文件末尾:返回 NULL

fgets的特点:

fgetsh函数始终会在读取的字符序列末尾,自动添加 '\0',确保其输出是一个合法的C风格字符串

所以当我们接收到指令后再打印一下接收到的指令,其效果如下:

// 打印命令行提示符+获取用户输入的指令字符串
void Interactive(char out[], int size)
{printf("[%s@%s %s]$ ", UserName(), HostName(), CurrentWorkDir());fgets(out, size, stdin);printf("%s\n",out);
}

乍一看没有错,其实已经错了,printf只有一次换行,为什么结果却有两次?

Q:首先是肯定已经读取到了,但是为什么中间会有一个空行呢?

A:是因为我们输入指令的时候 末尾字符a的后面 我们还会输入一个'\n',也就是回车,fgets会保留读取到整个字符串,包括回车'\n',然后再在其最后面加上'\0',所以才会显示一个空行,那么现在我们就要手动的将'\n'位置直接置为'\0'即可解决问题!

代码如下:


//上方部分代码省略 .....//打印命令行提示符+获取用户输入的指令字符串
void Interactive(char out[], int size)
{printf("[%s@%s %s]$ ", UserName(), HostName(), CurrentWorkDir());// 输出提示符并获取用户输入的命令字符串"ls -a -l"fgets(out, size, stdin);out[strlen(out)-1] = 0; //将'\n'置为'\0'printf("%s\n",out);}int main()
{   // 1. 打印命令行提示符,获取用户输入的命令字符串char commandline[SIZE];Interactive(commandline, SIZE);return 0;
}

解释:字符串有5个字符,则strlen返回5,所以4就是'\n'所处的位置,将其置为'\0'即可!

效果如下:

这就对了,只有一次printf里面的换行!

三:分割字符串

现在我们获取到了一整个字符串,所以我们下一步要做的就是分割字符串,为什么要分割,本质就是你分割了,你才能够再下一步执行指令的时候,直到哪部分是指令本身,哪部分是指令的选项,比如"ls -a",就应该分割成"ls"和"-a"才对!

现在要明白的是,假设已经分割完成了,我们要去执行指令,其实本质一定是要进行进程替换的,不然你怎么能执行指令对应的程序呢,既然是进程替换,那么就一定要使用进程替换的接口,而在之前的博客中已经介绍过了,程序替换的接口不管你是采用数组还是列表的形式传参,你的最后一个元素一定是NULL

举个例子:

所以切割字符串我们采取的C接口是strtok

char *strtok(char *str, const char *delim);
  • 功能:将字符串 str 按分隔符 delim 切割成多个子字符串(token)。

  • 首次调用:传入待切割的字符串 str

  • 后续调用:传入 NULL,继续切割同一字符串。

  • 返回值

    • 成功:返回下一个子字符串的指针。

    • 失败/无更多子字符串:返回 NULL

解释:

所以这个函数挺有意思的,第二次往后你再想对同一个字符串调用,你的第一次参数必须为NULL!其次当切割完最后一次的时候,再往下切割,其就会失败,会返回NULL,这个NULL正好可以被利用起来,作为我们数组的最后一个元素,这样调用进程替换的接口,我们直接可以将数组传过去,方便又省事!

代码如下:

//省略部分代码....#define SIZE 1024
#define MAX_ARGC 64
#define SEP " "char *argv[MAX_ARGC];//设置一个上限,指令最多被切割成64份 已经完全够用了//分割字符串
void Split(char in[])
{int i = 0;argv[i++] = strtok(in, SEP); // "ls -a -l"while(argv[i++] = strtok(NULL, SEP)); // 故意将== 写成 =}int main()
{// 1. 打印命令行提示符,获取用户输入的命令字符串char commandline[SIZE];Interactive(commandline, SIZE);// 2. 对命令行字符串进行切割Split(commandline);return 0;
}

解释:

由于strtok函数的特性,所以其第一次一定是在while循环外面的,另外while条件的这种写法,可以简单完美的完成切割且赋值给数组,并且最后一次strtok函数返回NULL的时候,也会赋给数组,最后整个表达式的结果为NULL,while循环退出,这代码才叫优雅~

下面我们在Split函数里面打印一下数组内容,看下是否正确完成了切割:

完美~

其次我们在main中加入一个while死循环,因为自带的shell就是一直循环等待输入的

int main()
{while (1){// 1. 打印命令行提示符,获取用户输入的命令字符串char commandline[SIZE];Interactive(commandline, SIZE);// 2. 对命令行字符串进行切割Split(commandline);}return 0;
}

目前的总代码:

#include <stdio.h>
#include <stdlib.h> //getenv();
#include <string.h> //strlen();#define SIZE 1024
#define MAX_ARGC 64
#define SEP " "char *argv[MAX_ARGC]; // 设置一个上限,指令最多被切割成64份 已经完全够用了//获取环境变量-->主机名const char* HostName(){char *hostname = getenv("HostName");if(hostname) return hostname;else return "None";}// 获取环境变量-->用户名
const char *UserName()
{char *hostname = getenv("USER");if (hostname)return hostname;elsereturn "None";
}// 获取环境变量-->当前工作目录
const char *CurrentWorkDir()
{char *hostname = getenv("PWD");if (hostname)return hostname;elsereturn "None";
}// 打印命令行提示符+获取用户输入的指令字符串
void Interactive(char out[], int size)
{printf("[%s@%s %s]$ ", UserName(), HostName(), CurrentWorkDir());fgets(out, size, stdin);out[strlen(out) - 1] = 0; // 将'\n'置为'\0'}// 分割字符串
void Split(char in[])
{int i = 0;argv[i++] = strtok(in, SEP); // "ls -a -l"while (argv[i++] = strtok(NULL, SEP));//打印一下切割字符串for (int i = 0; argv[i]; i++){printf("argv[%d]: %s\n", i, argv[i]);}
}int main()
{while (1){// 1. 打印命令行提示符,获取用户输入的命令字符串char commandline[SIZE];Interactive(commandline, SIZE);// 2. 对命令行字符串进行切割Split(commandline);}return 0;
}

效果:

 

解释:循环起来才是对的,符合自带的shell,虽然现在还没有开始执行命令,但是有点那味了

四:执行命令

已经完成了对字符串的切割,下一步就是用进程替换接口执行命令, 但是现在有一个问题,直接在当前进程下进行进程替换吗?那是肯定不可以的,一是因为我们自带的shell,其实可以一直循环使用的,二是因为执行指令不是一个安全的操作,所以我们肯定是fork创建子进程,让子进程去进行进程替换的!

Q:可是上面不是已经在mian中加入了死循环吗?为什么在当前进程直接进程替换会不循环了?

A:进程替换的特点就是,会直接忽略掉当前进程开始替换那一行的后面的所有代码,所以不要被main中死循环误导了,进程都替换了,代码和数组全部都替换了,谁还管这个死循环?

所以执行命令这个接口代码如下:

void Execute()
{pid_t id = fork();if(id == 0){execvp(argv[0], argv);exit(1);}waitpid(id, NULL, 0);
}

解释:非常简答,创建子进程后,在子进程中进行进程替换,而我们为什么选择进程替换众多接口中的execvp接口呢,因为我们已经有了数组,所以选择这个接口是传参最轻松的!

效果如下:

解释:循环的执行我们的指令,和shell类似了!但是其还是有很多的问题!我们知道:

[wtt1@hcss-ecs-1a2a /home/wtt1/lesson2]$

这行代码中当前工作目录部分,是会随着我们pwd而改变的,那我们cd到另一个路径,其回想我们预期的那样改变吗?

先看下Ubuntuh下cd对命令行提示符的影响:

再看我们的:

很明显,自带shell的cd能够影响到命令行提示符,但是我们的自定义shell影响不到!先想一下,我们每次循环的时候,都会先去获取环境变量去打印命令行提示符,所以不是因为环境变量是之前的环境变量这个原因!其次我们的cd压根没有影响到pwd!也就是说当前进程的pwd是没变的!

所以原因是因为我们的所有指令目前都是子进程去进行进程替换执行的,这一步当然没问题,但是如果cd这种指令也是子进程去执行,那么改变的只会是子进程的路径和其的环境变量!!而我们的命令行提示符中的环境变量是父进程的,所以根据父进程的环境变量打印出来的命令行提示符当然不会改变了,其次连环境变量都没变,pwd指令得到的路径当然也不会变了!

所以有些指令是不能让子进程去进行进程替换执行的,这种指令叫作内建指令

所以对于内建指令,我们需要一个单独的函数 --->内建函数来进行处理!

五:内建处理

所以我们在第四步执行命令之前,应该去判断一个指令是否属于内建指令,如果是,则在内建函数中直接进行处理了,而不会再有执行命令函数来执行!

所以main中的逻辑如下:

//内建指令处理
int BuildinCmd()
{int ret = 0;//检测是否是内建命令, 是 1, 否 0//处理指令......//返回retreturn ret;
}int main()
{while(1){char commandline[SIZE];// 1. 打印命令行提示符,获取用户输入的命令字符串Interactive(commandline, SIZE);// 2. 对命令行字符串进行切割Split(commandline);// 3. 处理内建命令n = BuildinCmd();if(n) continue;// 4. 执行这个命令Execute();}return 0;
}

解释:当 BuildinCmd 中检测到指令是内建指令,则会返回1,main中接收到1,则continue会跳过第4步,回到循环最开始

至于如何检测一个指令是不是内建指令,很简单的方法,穷举即可,是的,我们只能判断,这就证明了我们的自定义shell注定了不完整,因为我们不可能将所有的内建指令都写进去,一是没学过,二是太麻烦,我们只是为了感受shell的本质

1:内建指令-->cd

代码如下:

//新增的全局变量
char pwd[SIZE];int BuildinCmd()
{int ret = 0;// 1. 检测是否是内建命令, 是 1, 否 0if(strcmp("cd", argv[0]) == 0){// 2. 执行ret = 1;char *target = argv[1]; //获取到cd后的路径if(!target) target = Home();//单独cd指令的特判chdir(target);//用cd后面的路径更改当前目录}return ret;
}

解释:

用strcmp函数来对比一下数组中的第一个字符串是否和"cd"一致,是则进入if,先置ret为1,这样main中就会continue跳过Execute()!然后既然是cd指令 则需要先取到数组中的第二个元素,此时需注意 如果第二个元素为空 则代表指令仅仅为cd,cd后面不跟路径也是一个指令,其会直接回到家目录,所以我们相当于特判了一下

那现在会成功运行吗?效果如下:

解释:

仍未正确,这就是shell对于cd指令的本质了,当我们以为我们用chdir函数来修改了当前工作目录的时候,会直接影响到当前进程的环境变量,但实际根本影响不了,不是我们的方法不对,而是shell对于cd指令也会这样,本质是因为没有我们想的这么多自动,其实shell跟我们一样,也是chdir了当前工作目录,但是其紧接着会用一个putenv接口,来更新当前进程的环境变量,所以我们需要跟它一样,手动的更新环境变量!

代码如下:

int BuildinCmd()
{int ret = 0;// 1. 检测是否是内建命令, 是 1, 否 0if(strcmp("cd", argv[0]) == 0){// 2. 执行ret = 1;char *target = argv[1]; //获取到cd后的路径if(!target) target = Home();//单独cd指令的特判chdir(target);//用cd后面的路径更改当前目录snprintf(pwd, SIZE, "PWD=%s", target);//拼接字符串为kv型putenv(pwd);}return ret;
}

效果如下:

解释:

现在不仅pwd正确了,命令行也正确了;其次为什么使用snprintf函数,因为我们虽然有了target变量,里面存放就是cd后面的路径,但是要想更新环境变量,需要putenv接口的同时,参数还要是环境变量对应的kv类型,所以我们使用snprintfp拼接一下!结果放在了pwd!

但是此时还有问题,当我们试图cd ..的时候,会出现以下效果

解释:

从未见过cd .. 后,命令行里面的路径直接变成了..,但是为什么会这样呢,不正是因为我们的函数里面就是这样的逻辑吗,我们的target变量就是"..",所以"PWD=".."" 。只能说,我们的代码不够完善,shell里面是怎么做的呢?很简单其不会直接的把target作为环境变量,而是只将target用来chdir,之后再用getcwd函数来获取被chdir(target)后的新路径,这样就是你是cd .. ,那么你getcwd函数获取到的就会是一个绝对路径,而不是..,然后再将getcwd的结果用spprintf函数拼装后,给putenv函数更新正确的环境变量!

代码如下:

int BuildinCmd()
{int ret = 0;// 1. 检测是否是内建命令, 是 1, 否 0if(strcmp("cd", argv[0]) == 0){// 2. 执行ret = 1;char *target = argv[1]; //获取到cd后的路径if(!target) target = Home();//单独cd指令的特判chdir(target);//用cd后面的路径更改当前目录char temp[1024];getcwd(temp, 1024); // 获取正确的当前路径snprintf(pwd, SIZE, "PWD=%s", temp);putenv(pwd);}return ret;
}

效果如下:

完美~

但是现在有一个很小的报错,当我们直接输入回车的时候,会报错段错误

很有意思的是,当你没实现BuildinCmd()函数的时候,你只输入回车,并不会报错!因为在我们没有实现BuildinCmd()函数之前,此时输入回车,唯一会影响的就是Execute()函数中的execvp(NULL, argv),但是会被忽略,因为execvp接口对 NULL 检查不严格,绝对不要依赖这种行为!这是未定义行为!所以即使没有报错,我们也要知道有这种错误!

而实现BuildinCmd()函数后会报错的原因如下:先执行的BuildinCmd()函数中的strcmp(NULL, "cd"),必然会崩溃报错,因为:strcmp 的规范:该函数要求两个参数均为有效字符串指针(以 \0 结尾)。传入 NULL 会尝试访问非法内存地址(地址 0x0),触发段错误。

所以,我们要修复这个报错,很简单,我们只需要在Interactivej接口中检测一下用户输入的字符串长度,如果为0,直接在main中continue即可,这样就会回到循环一开始!

代码改动如下:

// 打印命令行提示符+获取用户输入的指令字符串
int Interactive(char out[], int size)
{printf("[%s@%s %s]$ ", UserName(), HostName(), CurrentWorkDir());fgets(out, size, stdin);out[strlen(out)-1] = 0; // 将'\n'置为'\0'return strlen(out);//新增代码  返回字符串字符数
}int main()
{while (1){char commandline[SIZE];// 1. 打印命令行提示符,获取用户输入的命令字符串int n = Interactive(commandline, SIZE);if(n == 0) continue;//空串 则continue// 2. 对命令行字符串进行切割Split(commandline);// 3. 处理内建命令n = BuildinCmd();if (n)continue;// 4. 执行这个命令Execute();}return 0;
}

效果:

现在一致只输入回车也没事了!

2:内建指令-->export

emport指令使用来 设置已有的环境变量或导入新的环境变量的指令,那为什么其要被作为内建指令呢?

当我们在原生shell中输入指令:

export myval=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

此时我们通过env指令可以找到这个指令:

但是在我们自定义的shell中执行了一样的指令,但是也找不到,为什么?

原因就是我们是子进程去进行了export ,影响到的是子进程的环境变量,而子进程的环境变量怎么可能影响到父进程呢?所以export应该作为内建指令来处理!

所以我们在BuildinCmd()函数中又要新增一个if分支了,我们判断到指令是export之后,我们就要获取argv数组中的第二个元素了,也就是kv类型的环境变量的定义 ,比如我们执行指令 :export myval=aaa ,则数组的第二个元素就是myval=aaa

所以代码应该如下:

int BuildinCmd()
{int ret = 0;// 1. 检测是否是内建命令cd, 是 1, 否 0if (strcmp("cd", argv[0]) == 0){// 2. 执行ret = 1;char *target = argv[1]; // cd XXX or cdif (!target)target = Home();chdir(target);char temp[1024];getcwd(temp, 1024); // 获取正确的当前路径snprintf(pwd, SIZE, "PWD=%s", temp);putenv(pwd);}//2.检测是否是内建命令export, 是 1, 否 0else if(strcmp("export", argv[0]) == 0){ret = 1;if(argv[1]){putenv(argv[1]);}}return ret;
}

此时我们再执行export指令试试:

再通过env指令查看是否找得到:

解释:符合预期,我们自定义的shell也能正确执行export指令了!

但是我们知道,环境变量更改后,只有你手动删除环境变量,或者重启客户端,其才会消失;但是现在我们导入环境变量之后,我们去随便执行一个带选项的指令,比如 ls -l,会发现效果如下:

执行完ls -l指令,我们执行env发现找不到我们的myval环境变量了:

这是为什么呢?这是因为你的argv数组是一个字符指针数组,我们的环境变量是其中的argv[1];但同时别忘了,argv数组是用来存放分割后的指令的数组,所以这代表着当你下次输入指令的时候,比如"ls -l",你的argv[1]会变成 "-l"  ,也就是说你的环境变量的内容是argv[1]中的地址指向的内容,但是现在argv[1]中地址指向的被下一次指令切割后的第二个元素覆盖了!所以你找不到原有的环境变量了!其次,你还能在env指令下找到"-l"这个环境变量!

如下:

那如何解决呢?方法很简单,我把当前的环境变量的内容拷贝到一个简单的数组里面不就行了,然后把这个数组进行putenv即可,普通数组根本不会被修改,所以环境变量不可能被覆盖!

代码如下:

char env[SIZE];//存储导入的环境变量 避免使用argv[1],然后被覆盖else if(strcmp("export", argv[0]) == 0){ret = 1;if(argv[1]){strcpy(env, argv[1]);//将其拷贝到一个普通的数组中putenv(env);//然后再导入}}

此时我们再执行之前的代码,会发现依然能够找得到了!

截图如下:

执行任何指令之后: 

解释:不再影响到我们导入的环境变量

3:内建指令-->echo

为什么echo也是一个内建指令?因为echo $PATH 这种用法,就需要获取到环境变量,而我们在原生的shell输入这种指令的时候,我们肯定是想获取到当前进程的环境变量,而不是创建子进程,去获取到子进程的环境变量,所以echo是一个内建指令。

但是有一个比较特殊的指令echo $? ,我们只知道其是获取最近一个进程的退出码的指令,那这个指令是内建指令吗?

我们在原生shell中执行echo $?指令:

再在我们自定义shell中执行echo $?指令:

很显然,上面是正确的,而下面却是打印出了"$?",这就侧面说明了echo $?指令没这么简单,其实echo $?指令中的$?是 Shell 进程内部的临时变量,存储上一条命令的退出状态码,当 Shell 启动子进程时,子进程会继承父进程的环境变量副本,但 $? 不属于环境变量,这就是为什么其要当做内建指令来执行!

所以想要保存一个退出码,很简单,但是要理解为什么子进程无法获取父进程的$?,因为其不是环境变量,所以无法继承,所以我们执行echo $?要内建执行

所以代码如下:

int lastcode = 0;//新增全局变量else if(strcmp("echo", argv[0]) == 0){ret = 1;if(argv[1] == NULL) //echo{printf("\n");}else{if(argv[1][0] == '$'){if(argv[1][1] == '?')//echo $?{printf("%d\n", lastcode);//打印上次退出的进程的退出码lastcode = 0;//更新退出码为0}else//echo $环境变量{char *e = getenv(argv[1]+1);if(e) printf("%s\n", e);}}else//echo+打印的内容{printf("%s\n", argv[1]);}}}

解释:逻辑如下:

值得注意的是,当你没把echo当做内建指令来写的时候,你在自定义shell中执行echo指令,不带其他选项的时候,此时效果如下:

首先,这是正确的现象,因为原生shell也是这样:

但是打印换行不是因为我们输入了echo和'\n',其识别到换行,所以才打印换行,而是此时子进程进行进程替换之后,去调用echo程序,此时echo程序的源码有一点类似如下:

if (无参数) {输出换行符 "\n";  // 这是 POSIX 标准规定的行为
}

所以其才会有换行的效果,切记不是因为我们输入的回车!

所以问题来了,为什么我们内建函数对于echo的代码,中argv[1]为NULL的时候,此时我们要手动的打印换行?因为我们压根就没有去程序替换调用echo程序,而是我们单独的将echo判断为了内建指令,所以任何的效果都要我们自己实现,所以我们才要手动的打印换行!

其余if分支已经很清晰明了,不再赘述....

总结:博主只手动写了三个内建指令,但是真正的shell有更多的内建指令,并且博主写的echo内建逻辑也是简化版的,因为解释即使你索引到[1][0],但不一定能索引到[1][1],所以还是那句话,在实现的过程中体会shell的本质即可!

而关于echo $?获取最新退出进程的退出码,我们除了在内建函数中要赋值给lastcode,我们还要在Execute(),也就是执行函数中,我们要把waitpid函数利用起来啦!我们要获取到退出码!所以当当我们执行任何指令的时候,子进程去进程进程替换后,进程执行完成后,我们能够回收到子进程的退出码,将其赋给了lastcode,这意味着当你下次调用echo $?指令的时候,就会直接打印出上次进程的退出码了!逻辑紧扣!

void Execute()
{pid_t id = fork();if(id == 0){// 让子进程执行命令execvp(argv[0], argv);exit(1);}int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid == id) lastcode = WEXITSTATUS(status); 
}

截止目前,总代码如下:

#include <stdio.h>     //C头文件 snprintf();
#include <stdlib.h>    //getenv();putenv();
#include <string.h>    //strlen();
#include <sys/types.h> //fork();waitid();
#include <sys/wait.h>  //waitpid();
#include <unistd.h>    //fork();getcwd();chdir();#define SIZE 1050
#define MAX_ARGC 64
#define SEP " "char pwd[SIZE];
char *argv[MAX_ARGC]; // 设置一个上限,指令最多被切割成64份 已经完全够用了
char env[SIZE];//存储导入的环境变量 避免使用argv[1],然后被覆盖int lastcode = 0;//存储退出码//获取环境变量-->主机名// centos环境下
//  const char* HostName()
//  {
//      char *hostname = getenv("HostName");
//      if(hostname) return hostname;
//      else return "None";
//  }// Ubuntuh环境下
const char *HostName()
{FILE *fp = popen("hostname", "r"); // 执行 hostname 命令if (fp == NULL)return "None";static char buf[256];if (fgets(buf, sizeof(buf), fp) != NULL){                                   // 修复:补全括号并检查返回值buf[strcspn(buf, "\n")] = '\0'; // 去除换行符pclose(fp);return buf;}pclose(fp);return "None";
}// 获取环境变量-->用户名
const char *UserName()
{char *hostname = getenv("USER");if (hostname)return hostname;elsereturn "None";
}// 获取环境变量-->当前工作目录
const char *CurrentWorkDir()
{char *hostname = getenv("PWD");if (hostname)return hostname;elsereturn "None";
}char *Home()
{return getenv("HOME");
}// 打印命令行提示符+获取用户输入的指令字符串
int Interactive(char out[], int size)
{printf("[%s@%s %s]$ ", UserName(), HostName(), CurrentWorkDir());fgets(out, size, stdin);out[strlen(out)-1] = 0; // 将'\n'置为'\0'return strlen(out);
}// 分割字符串
void Split(char in[])
{int i = 0;argv[i++] = strtok(in, SEP); // "ls -a -l"while (argv[i++] = strtok(NULL, SEP));// 打印一下切割字符串// for (int i = 0; argv[i]; i++)// {//     printf("argv[%d]: %s\n", i, argv[i]);// }
}
void Execute()
{pid_t id = fork();if (id == 0){execvp(argv[0], argv);exit(1);}int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid == id) lastcode = WEXITSTATUS(status); }int BuildinCmd()
{int ret = 0;// 1. 检测是否是内建命令cd, 是 1, 否 0if (strcmp("cd", argv[0]) == 0){// 2. 执行ret = 1;char *target = argv[1]; // cd XXX or cdif (!target)target = Home();chdir(target);char temp[1024];getcwd(temp, 1024); // 获取正确的当前路径snprintf(pwd, SIZE, "PWD=%s", temp);putenv(pwd);}//2.检测是否是内建命令export, 是 1, 否 0else if(strcmp("export", argv[0]) == 0){ret = 1;if(argv[1]){strcpy(env, argv[1]);putenv(env);}}else if(strcmp("echo", argv[0]) == 0){ret = 1;if(argv[1] == NULL) {//printf("\n");}else{if(argv[1][0] == '$'){if(argv[1][1] == '?'){printf("%d\n", lastcode);//打印上次退出的进程的退出码lastcode = 0;//更新退出码为0}}else{char *e = getenv(argv[1]+1);if(e) printf("%s\n", e);}}else{printf("%s\n", argv[1]);}}}return ret;
}int main()
{while (1){char commandline[SIZE]; // export myval=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb// 1. 打印命令行提示符,获取用户输入的命令字符串int n = Interactive(commandline, SIZE);if(n == 0) continue;// 2. 对命令行字符串进行切割Split(commandline);// 3. 处理内建命令n = BuildinCmd();if (n)continue;// 4. 执行这个命令Execute();}return 0;
}

六:重定向

但是我们还有一个非常重要的功能没有实现,那就是重定向!我们的自定义shell根本不认识
> ,< ,>>,这三个符号!

而重定向肯定不是内建指令,其往往是更改默认输出流或者默认输入流,所以其单独在一个函数里面,其次第六大点是要先看此篇博客:Linux->基础IO-CSDN博客,就能理解重定向相关知识

所以现在如果输入了一个指令:

ls -l > file.txt

我们肯定是无法执行的,所以我们要新增一个函数CheckRedir,来检测是哪种重定向,比如此指令就是输出重定向,然后我们要先把>置为'\0',此时这个字符串就变成了"ls -l '\0' file.txt",为什么要将其置为'\0'呢?因为此时我们就可以再把置为'\0'之后的字符串传给分割函数了,此时分割函数就只会过去到重定向符号的前面的干净的指令!

所以如果是没有重定向的指令,则我们CheckRedir函数什么都不做,反之有的话,CheckRedir则需要判断出是哪种重定向的同时,还要将重定向符号置为'\0'!

首先我们直接宏定义出几种重定向和文件名和重定向类型:

#define NoneRedir  -1 //无重定向
#define StdinRedir  0 //输入重定向
#define StdoutRedir 1 //输出重定向
#define AppendRedir 2 //追加重定向int redir_type = NoneRedir; //重定向类型(初始化为无重定向)
char *filename = NULL; //文件名(初始化为NULL)

然后在用户输入的字符串数组在被分割函数分割前,我们要先让其进行CheckRedir函数!

void Split(char in[])
{CheckRedir(in);//先判断是否有重定向int i = 0;argv[i++] = strtok(in, SEP); while(argv[i++] = strtok(NULL, SEP)); // 间接写法}

然后我们的 CheckRedir函数代码如下:

1:先遍历查找到重定向符号

// ls -a -l
// ls -a -l > log.txt
// ls -a -l >> log.txt
// cat < log.txt
void CheckRedir(char in[])
{int pos = strlen(in) - 1;//倒着遍历用户输入的字符串 从'\0'的前一个开始向前遍历while( pos >= 0 ){if(in[pos] == '>')//可能是输出重定向或追加重定向{if(in[pos-1] == '>')//追加重定向{}else//输出重定向{}}else if(in[pos] == '<')//输入重定向{}else{pos--;//pos遍历}}
}

解释:倒着遍历和正着遍历都可以,只是倒着遍历在后面会稍微简单一点

2:赋值 redir_type 和 将重定向符号置为'\0'

再说一遍为什么要把重定向符号置为'\0',这样才能在后面的分割函数中,让分割函数得到干净的指令,而不包括重定向符合和文件名,否则放进argv数组中,是执行不了的!

代码如下:

#define STREND '\0'//新增的宏// ls -a -l
// ls -a -l > log.txt
// ls -a -l >> log.txt
// cat < log.txt
void CheckRedir(char in[])
{int pos = strlen(in) - 1;//倒着遍历用户输入的字符串 从'\0'的前一个开始向前遍历while( pos >= 0 ){if(in[pos] == '>')//可能是输出重定向或追加重定向{if(in[pos-1] == '>')//追加重定向{redir_type = AppendRedir;//根据重定向符号 赋值对应的宏给redir_typein[pos-1] = STREND;//将重定向符号置为'\0'break;}else//输出重定向{redir_type = StdoutRedir;in[pos] = STREND;break;}}else if(in[pos] == '<')//输入重定向{redir_type = StdinRedir;in[pos] = STREND;break;}else{pos--;//pos遍历}}
}

解释:唯一需要注意的是 追加重定向我们要把从左往右数的第一个>置为'\0',如果置的是第二个>,则分割函数或获取到一个>作为argv数组的元素,所以我们要倒退回去(pos-1)置为'\0'!

3:获取到文件名字

现在我们做完了以上的两步,我们下一步就是要获取到指令中的文件名字了,所以我们需要跳过宫格去找到文件名字,然后把文件名字赋给全局定义的filename

代码如下:

#include <ctype.h>     //isspace();#define IgnSpace(buf,pos) do{ while(isspace(buf[pos])) pos++; }while(0)void CheckRedir(char in[])
{// ls -a -l// ls -a -l > log.txt// ls -a -l >> log.txt// cat < log.txtredir_type = NoneRedir;filename = NULL;int pos = strlen(in) - 1;while( pos >= 0 ){if(in[pos] == '>'){if(in[pos-1] == '>'){redir_type = AppendRedir;in[pos-1] = STREND;pos++;IgnSpace(in, pos);//跳过空格filename = in+pos;//数组名+偏移量获取到起始字符地址给filenamebreak;}else{redir_type = StdoutRedir;in[pos++] = STREND;IgnSpace(in, pos);//同理filename = in+pos;//同理break;}}else if(in[pos] == '<'){redir_type = StdinRedir;in[pos++] = STREND;IgnSpace(in, pos);//同理filename = in+pos;//同理break;}else{pos--;}}
}

解释:在使用IgnSpaceh函数之前,需要++跳过我们手动置的'\0',因为IgnSpace函数里面的isspace函数遇到'\0'就会停止判断;其次IgnSpace函数的写法:

#define IgnSpace(buf,pos) do{ while(isspace(buf[pos])) pos++; }while(0)
  • 功能:从 buf[pos] 开始,向右移动 pos 直到遇到非空白字符。

  • 组成部分

    • isspace(buf[pos]):检测当前字符是否为空格

    • pos++:位置指针右移

    • do{...}while(0):将多语句组合成宏的标准技巧

这是一个装逼的写法昂,说白了等效下面这种写法:

            if(in[pos-1] == '>'){redir_type = AppendRedir;in[pos-1] = STREND;pos++;do{ while(isspace(in[pos])) pos++; }while(0);filename = in+pos;break;}

但其的确可以让我们的if内部 不那么的冗余 !

开头加的那两句很重要,我们要覆盖掉上次判断带来的影响,所以要赋值一下!

redir_type = NoneRedir;
filename = NULL;

所以至此我们的CheckRedir函数就完成了,现在我们知道了是哪种重定向了,并且不影响分割函数的正常运行,同时还获取到了文件名字,所以下一班,我们的执行函数的逻辑也要做处修改了,当我们需要重定向的时候,会有具体的做法了

代码如下:

/执行命令函数
//负责创建子进程 去进程进程替换
void Execute()
{pid_t id = fork();if(id == 0){//重定向的处理int fd = -1;//先定义一个fdif(redir_type == StdinRedir)//输入重定向{fd = open(filename, O_RDONLY);dup2(fd, 0);//将fd的内容拷贝到0下标的元素中}else if(redir_type == StdoutRedir)//输出重定向{fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC);dup2(fd, 1);//将fd的内容拷贝到1下标的元素中}else if(redir_type == AppendRedir)//追加重定向{fd = open(filename, O_CREAT | O_WRONLY | O_APPEND);dup2(fd, 1);//将fd的内容拷贝到1下标的元素中}else{// do nothing}// 让子进程执行命名execvp(argv[0], argv);exit(1);}int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid == id) lastcode = WEXITSTATUS(status); }

解释:dump2函数,以及open函数都是在Linux->基础IO-CSDN博客中详细讲过!

展示重定向的效果:

解释:我们的重定向正常工作了,但是如果你使用echo和重定向结合在一起,则不行,因为我们没写echo的重定向的分支

七:上色

现在我们的自定义shell只差最后一步了,就是上色,我们原生shell展示的内容是有颜色的:

但是我们的自定义shell:

其实原生的shell上色的本质就是ls指令自带了color选项的,所以我们只需在分割函数中,判断一下argv[0]是否是ls,是的话,我们就手动的把数组的最后一个元素NULL置为color选项,然后再在后面加一个NULL元素即可

代码如下:

void Split(char in[])
{CheckRedir(in);int i = 0;argv[i++] = strtok(in, SEP); // "ls -a -l"while(argv[i++] = strtok(NULL, SEP)); // 故意将== 写成 =if(strcmp(argv[0], "ls") ==0){argv[i-1] = (char*)"--color";argv[i] = NULL;}
}

效果:

八:总代码

由于代码众多,在之前的很多地方都是只展示了相关的代码,所以下面汇总一份代码!

1:makefile

myshell:myshell.cgcc -o $@ $^
.PHONY:clean
clean:rm -f myshell

2:myshell.c 

#include <stdio.h>//C头文件 snprintf();
#include <stdlib.h>//getenv();putenv();
#include <string.h>//strlen();
#include <unistd.h>//fork();getcwd();chdir();
#include <ctype.h>//isspace();
#include <sys/types.h>//fork();waitid();//open();
#include <sys/stat.h>//open();
#include <fcntl.h>//open();
#include <sys/wait.h>//waitpid();//宏的定义
#define SIZE 1064//一般作为数组的大小
#define MAX_ARGC 64//作为argv数组的大小 已经完全够用
#define SEP " "//作为strtok函数的第二个参数 遇见空格则分割
#define STREND '\0'//作为将重定向置为'\0' 中'\0'的宏// 下面的都和重定向有关
#define NoneRedir  -1 //无重定向
#define StdinRedir  0 //输入重定向
#define StdoutRedir 1 //输出重定向
#define AppendRedir 2 //追加重定向//跳过空格的代码的宏定义
#define IgnSpace(buf,pos) do{ while(isspace(buf[pos])) pos++; }while(0)int redir_type = NoneRedir;//redir_type来接收重定向的类型 初始化为无重定向
char *filename = NULL;//filename用来接收文件名 可能打开该文件 也可能创建该文件char *argv[MAX_ARGC];//定义一个argv数组 其存放的指令分割后的各个元素 作为execvp函数的参数 进行进程替换
char pwd[SIZE];//存放cd指令中导入的kv类型的环境变量
char env[SIZE]; //临时数组来存储导入的环境变量,避免环境变量被下一次的argv[1]所覆盖 
int lastcode = 0;//退出码的保存//CenTos版本下获取主机名
const char* HostName()
{char *hostname = getenv("HOSTNAME");if(hostname) return hostname;else return "None";
}// Ubuntuh环境下获取主机名
// const char *HostName()
// {
//     FILE *fp = popen("hostname", "r"); // 执行 hostname 命令
//     if (fp == NULL)
//         return "None";//     static char buf[256];
//     if (fgets(buf, sizeof(buf), fp) != NULL)
//     {                                   // 修复:补全括号并检查返回值
//         buf[strcspn(buf, "\n")] = '\0'; // 去除换行符
//         pclose(fp);
//         return buf;
//     }
//     pclose(fp);
//     return "None";
// }//获取用户名
const char* UserName()
{char *hostname = getenv("USER");if(hostname) return hostname;else return "None";
}//获取当前工作目录
const char *CurrentWorkDir()
{char *hostname = getenv("PWD");if(hostname) return hostname;else return "None";
}//获取用户主目录路径
char *Home()
{return getenv("HOME");
}//交互函数
//打印命令行提示符+获取用户输入的指令字符串
int Interactive(char out[], int size)
{printf("[%s@%s %s]$ ", UserName(), HostName(), CurrentWorkDir());fgets(out, size, stdin);out[strlen(out)-1] = 0; //将用户输入的回车置为'\0'return strlen(out);//防止用户仅输入回车
}//检查重定向函数
//确定重定向类型+把重定向符号置为'\0'+获取到文件名
void CheckRedir(char in[])
{// ls -a -l// ls -a -l > log.txt// ls -a -l >> log.txt// cat < log.txt//更新两个量 不要被之前影响redir_type = NoneRedir;filename = NULL;int pos = strlen(in) - 1;while( pos >= 0 ){if(in[pos] == '>'){if(in[pos-1] == '>'){redir_type = AppendRedir;in[pos-1] = STREND;pos++;//跳过'\0'IgnSpace(in, pos);filename = in+pos;break;}else{redir_type = StdoutRedir;in[pos++] = STREND;//跳过'\0'IgnSpace(in, pos);filename = in+pos;//printf("debug: %s, %d\n", filename, redir_type);break;}}else if(in[pos] == '<'){redir_type = StdinRedir;in[pos++] = STREND;//跳过'\0'IgnSpace(in, pos);filename = in+pos;//printf("debug: %s, %d\n", filename, redir_type);break;}else{pos--;}}
}//分割函数
//将一整个字符串分割为多个子串放进字符指针数组argv数组中
void Split(char in[])
{CheckRedir(in);int i = 0;argv[i++] = strtok(in, SEP); // "ls -a -l"while(argv[i++] = strtok(NULL, SEP)); // 故意将== 写成 =//对ls指令进行上色if(strcmp(argv[0], "ls") ==0){argv[i-1] = (char*)"--color";//原先的NULL被换成了上色选项argv[i] = NULL;//所以在后面新增一个NULL  才符合argv的性质}
}//执行命令函数
//判断是否重定向+创建子进程进行进程替换+等待子进程获取其退出码去更新lastcode
void Execute()
{pid_t id = fork();//创建子进程if(id == 0){int fd = -1;if(redir_type == StdinRedir)//输入重定向{fd = open(filename, O_RDONLY);dup2(fd, 0);}else if(redir_type == StdoutRedir)//输出重定向{fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC,0666);dup2(fd, 1);}else if(redir_type == AppendRedir)//追加重定向{fd = open(filename, O_CREAT | O_WRONLY | O_APPEND,0666);dup2(fd, 1);}else{// do nothing}// 让子进程执行命令execvp(argv[0], argv);exit(1);}int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid == id) lastcode = WEXITSTATUS(status); //更新退出码//printf("run done, rid: %d\n", rid);
}//内建函数
//cd + export + echo 的实现
int BuildinCmd()
{int ret = 0;// 1. cd指令的实现if(strcmp("cd", argv[0]) == 0){// 2. 执行ret = 1;char *target = argv[1]; //获取到路径if(!target) target = Home();//路径为空 代表为echo指令 则直接回到用户家目录chdir(target);//改变父进程目录char temp[1024];getcwd(temp, 1024);//获取当前目录放进temp数组中snprintf(pwd, SIZE, "PWD=%s", temp);//拼接成一个kv类型 方便导入环境变量putenv(pwd);//导入环境变量}//2.export指令的实现else if(strcmp("export", argv[0]) == 0){ret = 1;if(argv[1]){strcpy(env, argv[1]);//环境变量要存放到临时数组中 避免被下一次的argv[1]直接覆盖putenv(env);//导入环境变量}}//3.echo指令的实现else if(strcmp("echo", argv[0]) == 0){ret = 1;if(argv[1] == NULL) {printf("\n");//模拟echo指令的动作 其会换行 所以手动换}else{if(argv[1][0] == '$')//两种可能{if(argv[1][1] == '?')//echo $?{printf("%d\n", lastcode);lastcode = 0;}else{//echo $环境变量char *e = getenv(argv[1]+1);if(e) printf("%s\n", e);}}else{//echo+打印的内容printf("%s\n", argv[1]);}}}return ret;
}//主函数
//代码逻辑框架
int main()
{while(1){char commandline[SIZE];// 1. 打印命令行提示符,获取用户输入的命令字符串int n = Interactive(commandline, SIZE);if(n == 0) continue;// 2. 对命令行字符串进行切割Split(commandline);// 3. 处理内建命令n = BuildinCmd();if(n) continue;// 4. 执行这个命令Execute();}return 0;
}

九:Ubuntuhb版本下的差异

博主是在Ubuntuh版本去实现centos版本的shell,总结出了几个值得注意的差异之处:

1:获取主机名的方式

//Ubuntuh环境下
const char* HostName() {FILE *fp = popen("hostname", "r");  // 执行 hostname 命令if (fp == NULL) return "None";static char buf[256];if (fgets(buf, sizeof(buf), fp) != NULL) {  // 修复:补全括号并检查返回值buf[strcspn(buf, "\n")] = '\0';  // 去除换行符pclose(fp);return buf;}pclose(fp);return "None";
}

解释:如果你是 Ubuntuhb版本则你需要像这样获取到主机名

2:open函数的权限

void Execute()
{pid_t id = fork();//创建子进程if(id == 0){int fd = -1;if(redir_type == StdinRedir)//输入重定向{fd = open(filename, O_RDONLY);dup2(fd, 0);}else if(redir_type == StdoutRedir)//输出重定向{fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC);dup2(fd, 1);}else if(redir_type == AppendRedir)//追加重定向{fd = open(filename, O_CREAT | O_WRONLY | O_APPEND);dup2(fd, 1);}else{// do nothing}// 让子进程执行命令execvp(argv[0], argv);exit(1);}int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid == id) lastcode = WEXITSTATUS(status); //更新退出码//printf("run done, rid: %d\n", rid);
}

如果你是Centos,你可以像上面这样不传open函数的第三个参数,这和你的权限掩码有关,但是Ubuntuhb版本下的vscode必须写第三个参数,否则会像下面这样:

至此,结束~

http://www.dtcms.com/a/301194.html

相关文章:

  • Acrobat 文件夹级脚本扩展表单功能
  • 【奔跑吧!Linux 内核(第二版)】第4章:内核编译和调试
  • 物联网安装调试-物联网网关
  • Python数据分析基础(二)
  • 两个函数的卷积
  • Kafka——消费者组消费进度监控都怎么实现?
  • 数字签名与数字证书
  • [leetcode] 图论算法(DFS和BFS)
  • Java“class file contains wrong class”解决
  • NX868NX872美光固态闪存NX873NX876
  • 疯狂星期四文案网第21天运营日记
  • 10.模块与包:站在巨人的肩膀上
  • 去除视频字幕 5: 使用 ProPainter, 记录探索过程
  • red靶机
  • MCU 通用AT指令处理框架
  • 洛谷 P2114 [NOI2014] 起床困难综合症-普及+/提高
  • AutoLabelImg:高效的数据自动化标注工具和下载
  • 风光氢系统仿真与容量扩展设计
  • 飞牛NAS本地化部署n8n打造个人AI工作流中心
  • 识别身份证用证件号或姓名改名,如何ocr识别身份证复印件并导出至excel表格?身份证读取软件导出到Excel乱码怎么解决?
  • LLM 多语言数据集
  • 华为OD机试_2025 B卷_书籍叠放(Python,200分)(附详细解题思路)
  • Coze Studio概览(一)
  • 力扣131:分割回文串
  • 详解赛灵思SRIO IP并提供一种FIFO封装SRIO的收发控制器仿真验证
  • 2025年Agent创业实战指南:从0到1打造高增长AI智能体项目
  • FPGA IP升级
  • input_handler和input_dev详解
  • 【AI阅读】20250717阅读输入
  • 深度学习在计算机视觉中的应用:对象检测