【Linux】进程替换与自定义 Shell:原理与实战

目录

一、进程程序替换

1、替换原理

2、替换函数

(1)函数解释

[① filename / pathname](#① filename / pathname)

[② 参数表传递](#② 参数表传递)

[③ 环境变量表传递](#③ 环境变量表传递)

(2)命名理解

二、自定义shell命令行解释器

1、实现原理

2、实现代码

(1)获取环境变量

(2)输出命令行提示符

(3)获取用户输入的命令

(4)命令行分析

(5)检测并执行内键命令

(6)执行命令

(7)完整代码

三、函数和进程之间的相似性


一、进程程序替换

fork()函数创建新的子进程后,子进程如果想执行一个全新的程序呢?进程的程序替换来完成这个功能!程序替换是通过特定的接口,加载磁盘上的一个全新程序(代码和程序),加载到调用进程的地址空间中!

1、替换原理

用 fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用 exec 并不创建新进程,所以调用 exec 前后该进程的ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。

示例:

cpp 复制代码
#include<stdio.h>
#include<unistd.h>

int main()
{
    printf("程序运行!\n");
    execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
    printf("程序运行完毕!\n");
    return 0;
}

$ ./proc # 原始代码中的第二个printf函数被替换,不执行!
程序运行!
total 32
drwxrwxr-x  2 zyt zyt  4096 Apr 18 17:13 .
drwxrwxr-x 16 zyt zyt  4096 Apr 18 17:07 ..
-rw-rw-r--  1 zyt zyt    58 Apr 18 17:12 Makefile
-rwxrwxr-x  1 zyt zyt 16000 Apr 18 17:13 proc
-rw-rw-r--  1 zyt zyt   190 Apr 18 17:13 test.c

一旦程序替换成功,就去执行新代码了,后续的原始代码也就不存在了(被覆盖了)。

exec* 函数只有失败返回值,没有成功返回值。所以对于exec函数不用对返回值做判断。

2、替换函数

● 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。

● 如果调用出错则返回-1,若成功,不返回。

cpp 复制代码
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
int fexecve(int fd, char *const argv[], char *const envp[]);

(1)函数解释

① filename / pathname

pathname就是取【路径名+程序】作为参数;filename就是文件名作为参数,当指定filename作为参数时:

● 如果filename中包含"/",则将其视为路径名。

● 否则就按照PATH环境变量,在它所指定的各目录中搜索可执行文件。【不知道PATH环境变量的话看前文:深入浅出:环境变量与进程地址空间的核心机制-CSDN博客

② 参数表传递

函数execl、execlp 和 execle 要求新程序的每一个命令行参数都说明一个单独的参数。这种参数表以空指针结尾。对execl、execlp 和 execle三个函数表示命令行参数的一般方法:

cpp 复制代码
char *arg(), char *argl, ..., char *argn, (char *)0

这种语法显示的说明了最后一个命令行参数之后跟了一个空指针 。如果用常量0来表示一个空指针,则必须将它强制转化成为一个指针:否则它将被解释成整形参数。如果一个整形数的长度与char*的长度不同,那么exec函数的实际参数将出错。

对于其余的4个函数,则应先构造一个指向各参数的指针数组,然后将该数组指针地址作为这4个函数的参数(建立argv)。

③ 环境变量表传递

以e结尾的三个函数(execle、execve、fexecve)可以传递一个指向环境字符串指针数组的指针。在ISO C原型(国际标准化组织制定的C语言标准)之前,execle的参数是:

cpp 复制代码
char *pathname, char *arg(), ..., char *argn, (char *)0, char *envp[]  

从中可见**,最后一个参数是指向环境字符串的各字符指针构成的数组的指针**。而在ISO C原型中,所有命令行参数、空指针和envp指针都是用省略号(...)表示。

其他四个函数,则使用调用进程中的environ变量为新程序复制现有的环境。通常,一个进程允许将其环境传播给其子进程,但有时也有这种情况,进程想为子进程指定某一个确定的环境,可以直接用putenv()函数【添加或修改环境变量,它接受一个形式为 "name=value" 的字符串,并将其添加到当前进程的环境变量列表中】;或者通过第三方变量environ。

(2)命名理解

这7个函数的参数很难记忆。函数名的字符会给我们一些帮助。

● 字母p表示该函数取filename作为参数,并且用PATH环境变量寻找可执行文件。

● 字母l表示该函数取一个参数表,参数以可变参数列表的形式传递。

字母v与字母l互斥,v表示该函数取了一个argv[]矢量,参数以字符指针数组的形式传递。

● 字母e表示该函数取envp[]数组,自己维护环境变量,而不使用当前环境。

调用示例如下:

cpp 复制代码
#include <unistd.h>

int main() {
    char *const argv[] = {"ps", "-ef", NULL};
    char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};

    // 使用 execl 执行 /bin/ps 命令,需要完整路径
    execl("/bin/ps", "ps", "-ef", NULL);

    // 使用 execlp 执行 ps 命令,带 p 的,可以使用环境变量 PATH,无需写全路径
    execlp("ps", "ps", "-ef", NULL);

    // 使用 execle 执行 ps 命令,带 e 的,需要自己组装环境变量
    execle("/bin/ps", "ps", "-ef", NULL, envp);

    // 使用 execv 执行 /bin/ps 命令,需要完整路径
    execv("/bin/ps", argv);

    // 使用 execvp 执行 ps 命令,带 p 的,可以使用环境变量 PATH,无需写全路径
    execvp("ps", argv);

    // 使用 execve 执行 /bin/ps 命令,带 e 的,需要自己组装环境变量
    execve("/bin/ps", argv, envp);

    exit(0);
}

在很多Unix的实现中,这7个函数中**只有execve是内核的系统调用。另外6个只是库函数,它们最终都要调用该系统调用。**这7个函数之间的关系如图:

二、自定义shell命令行解释器

1、实现原理

考虑下面这个与shell的互动:

bash 复制代码
[root@localhost epoll]# ls
client.cpp  readme.md  server.cpp  utility.h

[root@localhost epoll]# ps
  PID TTY      TIME     CMD
 3451 pts/0    00:00:00 bash
 3514 pts/0    00:00:00 ps

用下图的时间轴来表示事件的发生次序。其中时间从左向右,shell由标识为sh的方块表示,它随着时间的流逝从左向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。 然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序并等待这个进程结束。

所以,实现一个自定义shell需要循环以下过程:

(1)获取命令行

(2)解析命令行

(3)建立一个子进程(fork)

(4)替换子进程(execvp)

(5)父进程等待子进程退出(wait)

2、实现代码

(1)获取环境变量

要实现一个自定义 Shell,环境变量的处理是关键部分。正常情况shell启动要从系统中获取环境变量,而我们自定义shell的环境变量信息要从父shell中获取。

手动获取:在 C 中可以通过 extern char **environ 访问继承的环境变量。将父Shell的环境变量逐条复制到g_env中,然后将g_env中的变量添加到当前进程的环境变量表。【注意:(这里使用了putenv() 函数)此后g_env[i]的内存由系统管理,不能再手动free!Unix/Linux系统提供的全局变量,指向当前进程的环境变量表,】

cpp 复制代码
// 环境变量表
#define MAX_ENVS 1024
char *g_env[MAX_ENVS];
int g_envs = 0;

// 从父shell1获取环境变量表,本来是要从配置文件获取的
void InitEnv()
{
    extern char **environ;
    memset(g_env, 0, sizeof(g_env)); // 初始化
    g_envs = 0;

    // 1.获取环境变量
    for(int i = 0; environ[i]; i++)
    {
        // (1)申请空间
        g_env[i] = (char*)malloc(strlen(environ[i])+1);
        // (2)拷贝到我们的环境变量表
        strcpy(g_env[i], environ[i]);
        g_envs++;
    }
    // 测试:添加了一个新的环境变量
    g_env[g_envs++] = (char*)"HAHA=for_test";
    g_env[g_envs] = NULL; // 表的最后一个成员必须是NULL

    // 2.获取到的环境变量导入shell的表中
    for(int i = 0; g_env[i]; i++)
    {
        putenv(g_env[i]);
    }
}

(2)输出命令行提示符

提示符格式:[用户名@主机名:当前目录的基名]#

***① MakeCommandline()***中使用 snprintf() 格式化提示符:【format:格式化字符串,用于指定如何格式化后续的参数】【...:可变参数列表,根据 format 的要求提供相应的值】

cpp 复制代码
int snprintf(char *str, size_t size, const char *format, ...);

② PrintCommandline() 打印命令行提示符:fflush用来确保提示符立即显示(尤其在无换行符时)。

cpp 复制代码
#define COMMAND_SIZE 1024
#define HOSTNAME_MAX 1024
#define FORMAT "[%s@%s:%s]# "

// shell定义的全局变量

char cwd[1019];
char cwdenv[1024]; 

const char *GetUserName()
{
    const char * name = getenv("USER");
    return name == NULL ? "None" : name;
}

const char *GetHostName()
{
    char hostname[HOSTNAME_MAX];
    return (gethostname(hostname, HOSTNAME_MAX) == -1) ? "None" : hostname;
}

const char *GetPwd()
{
    //const char * pwd = getenv("PWD");
    const char *pwd = getcwd(cwd, sizeof(cwd)); // 通过系统调用获取
    if(pwd != NULL)
    {   // 把自己的环境变量导给进程
        snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
        putenv(cwdenv);
    }
    return pwd == NULL ? "None" : pwd;
}

// 对路径做包装,只显示当前位置
std::string DirName(const char *pwd)
{
#define SLASH "/"
    std::string dir = pwd;
    if(dir == SLASH) return SLASH;
    auto pos = dir.rfind(SLASH);
    if(pos == std::string::npos) return "BUG";
    return dir.substr(pos+1);
}

// 制作命令行提示符
void MakeCommandline(char cmd_prompt[], int size)
{
    // 设置式化输入
    snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
    //snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}

// 打印命令行提示符
void PrintCommandline()
{
    char prompt[COMMAND_SIZE];
    MakeCommandline(prompt, COMMAND_SIZE);
    printf("%s", prompt);
    fflush(stdout);
}

(3)获取用户输入的命令

GetCommandline() 从标准输入读取一行用户输入的命令。

fgets() 是一个在 C 语言中用于从文件流中读取字符串的函数,属于标准库 <stdio.h> 中的函数。它能够从指定的文件流中读取一行数据,并将其存储到目标缓冲区中,同时可以限制读取的最大字符数,从而避免缓冲区溢出。

cpp 复制代码
char* fgets(char* str, int size, FILE* stream);

但 fgets() 会保留输入中的换行符,所以调用后要清理换行符。

cpp 复制代码
// 获取用户输入命令
bool GetCommandline(char *out, int size)
{
    // 从标准输入流获取命令,其实就是字符串
    char* c = fgets(out, size, stdin);

    if(c == NULL) return 1;
    out[strlen(out)-1] = '\0'; // 清理\n, 至少会按一次回车,所以不会出错

    if(strlen(out) == 0) return false;// 什么都没输入
    return true;
}

(4)命令行分析

函数实现:命令行字符串的解析,将用户输入的命令行(如 "ls -a -l")拆分为 参数数组 g_argv,并记录参数个数 g_argc。

strtok() 是一个在 C 语言中用于字符串分割的函数,属于标准库 <string.h> 中的函数。它可以根据指定的分隔符将字符串分割成多个子字符串,并且在多次调用中逐个返回这些子字符串。

cpp 复制代码
char* strtok(char* str, const char* delim);

在第一次调用时,str 是指向要分割的原始字符串的指针。 在后续调用中,str 应该传入 NULL,表示继续分割上一次调用后剩余的部分。

cpp 复制代码
// 全局的命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;

// 3. 命令行分析
bool CommandParse(char *commandline)
{
    // "ls -a -l" -> "ls" "-a" "-l"
    // 字符串切割 strtok()函数
#define SEP " "
    g_argc = 0;
    // 第一次调用:传入要分割的字符串和分隔符
    g_argv[g_argc++] = strtok(commandline, SEP);
    // 后续调用:传入 NULL 和分隔符,继续分割同一字符串
    while(g_argv[g_argc++] = strtok(NULL, SEP));
    g_argc--; // 修正参数计数,因为while 循环中 g_argc++ 在赋值后执行的
    return g_argc > 0 ? true : false;
}

(5)检测并执行内键命令

CheckAndExecBuiltin() 检查是否为内建命令。***CD()***实现目录切换(cd 命令),Echo() 实现 echo 命令。

***chdir()***是一个在 C 语言中用于更改当前工作目录的函数,属于标准库 <unistd.h> 中的函数(在 POSIX 系统中)。它允许程序动态地改变当前进程的工作目录。

cpp 复制代码
// 最后一个程序退出码
int lastcode = 0;

// 全局的命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;

// 内键命令
bool CD()
{

    if(g_argc == 1) // 只有一个cd时
    {
        std::string home = GetHome();
         if(home.empty()) return true;
        chdir(home.c_str()); // 切换路径
     }
    else
    {
        std::string where = g_argv[1]; // 目标路径
        if(where == "-")
        {}
        else if(where == "~")
        {}
        else
        {
            chdir(where.c_str());
        }
    }
    return true;
}

bool Echo()
{
    if(g_argc == 2)
    {
        //
        std::string opt = g_argv[1];
        if(opt == "$?") // eg1: echo $? 打印退出码
        {
            std::cout << lastcode << std::endl;
            lastcode = 0;
            return true;
        }
        if(opt[0] == '$') // eg2: echo $PATH 打印环境变量
        {
            std::string env_name = opt.substr(1);
            const char * env_value = getenv(env_name.c_str()); // 获取环境变量的值
            if(env_value) 
                std::cout << env_value << std::endl;
        }
    }
    return false;
}

// 4. 检测并执行内键命令
bool CheckAndExecBuiltin()
{
    std::string cmd = g_argv[0];
    if(cmd == "cd")
    {
       return CD();
    }
    else if(cmd == "echo")
    {
       return Echo();
    }
    return false;
}

(6)执行命令

Execute() 实现了 Shell 中外部命令的执行,核心是通过 fork() 创建子进程,并在子进程中调用 execvp 执行目标命令(自定义shell),父进程则等待子进程结束并记录其退出状态。

cpp 复制代码
// 5.执行命令
int Execute()
{
    pid_t id = fork();
    if(id == 0)
    {
        // child, 程序替换
        execvp(g_argv[0], g_argv);
        exit(1);
    }
    // father
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
        lastcode = WEXITSTATUS(status);
    }
    return 1;
}

(7)完整代码

cpp 复制代码
#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>

#define COMMAND_SIZE 1024
#define HOSTNAME_MAX 1024
#define FORMAT "[%s@%s:%s]# "

// shell定义的全局变量

// 全局的命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0;

char cwd[1019];
char cwdenv[1024]; 

// 环境变量表
#define MAX_ENVS 1024
char *g_env[MAX_ENVS];
int g_envs = 0;

// 最后一个程序退出码
int lastcode = 0;

const char *GetUserName()
{
    const char * name = getenv("USER");
    return name == NULL ? "None" : name;
}

const char *GetHostName()
{
    char hostname[HOSTNAME_MAX];
    return (gethostname(hostname, HOSTNAME_MAX) == -1) ? "None" : hostname;
}

const char *GetPwd()
{
    //const char * pwd = getenv("PWD");
    const char *pwd = getcwd(cwd, sizeof(cwd)); // 通过系统调用获取
    if(pwd != NULL)
    {   // 把自己的环境变量导给进程
        snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);
        putenv(cwdenv);
    }
    return pwd == NULL ? "None" : pwd;
}

const char *GetHome()
{
    const char *home = getenv("HOME");
    return home == "" ? NULL : home;
}

// 对路径做包装
std::string DirName(const char *pwd)
{
#define SLASH "/"
    std::string dir = pwd;
    if(dir == SLASH) return SLASH;
    auto pos = dir.rfind(SLASH);
    if(pos == std::string::npos) return "BUG";
    return dir.substr(pos+1);
}

// 制作命令行提示符
void MakeCommandline(char cmd_prompt[], int size)
{
    // 设置式化输入
    snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
    //snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), GetPwd());
}

// 打印命令行提示符
void PrintCommandline()
{
    char prompt[COMMAND_SIZE];
    MakeCommandline(prompt, COMMAND_SIZE);
    printf("%s", prompt);
    fflush(stdout);
}

// 获取用户输入命令
bool GetCommandline(char *out, int size)
{
    // 从标准输入流获取命令,其实就是字符串
    char* c = fgets(out, size, stdin);

    if(c == NULL) return 1;
    out[strlen(out)-1] = '\0'; // 清理\n, 至少会按一次回车,所以不会出错

    if(strlen(out) == 0) return false;// 什么都没输入
    return true;
}

// 3. 命令行分析
bool CommandParse(char *commandline)
{
    // "ls -a -l" -> "ls" "-a" "-l"
    // 字符串切割 strtok()函数
#define SEP " "
    g_argc = 0;
    // 第一次调用:传入要分割的字符串和分隔符
    g_argv[g_argc++] = strtok(commandline, SEP);
    // 后续调用:传入 NULL 和分隔符,继续分割同一字符串
    while(g_argv[g_argc++] = strtok(NULL, SEP));
    g_argc--;
    return g_argc > 0 ? true : false;
}

void PrintArray()
{
    for(int i = 0; g_argv[i]; i++)
    {
        printf("argv[%d]->%s\n", i, g_argv[i]);
    }
    printf("%d\n",g_argc);
}

// 从父shell1获取环境变量表,本来是要从配置文件获取的
void InitEnv()
{
    extern char **environ;
    memset(g_env, 0, sizeof(g_env)); // 初始化
    g_envs = 0;

    // 1.获取环境变量
    for(int i = 0; environ[i]; i++)
    {
        // (1)申请空间
        g_env[i] = (char*)malloc(strlen(environ[i])+1);
        // (2)拷贝到我们的环境变量表
        strcpy(g_env[i], environ[i]);
        g_envs++;
    }
    // 测试:添加了一个新的环境变量
    g_env[g_envs++] = (char*)"HAHA=for_test";
    g_env[g_envs] = NULL; // 表的最后一个成员必须是NULL

    // 2.获取到的环境变量导入shell的表中
    for(int i = 0; g_env[i]; i++)
    {
        putenv(g_env[i]);
    }
}

// 内键命令
bool CD()
{

    if(g_argc == 1) // 只有一个cd时
    {
        std::string home = GetHome();
         if(home.empty()) return true;
        chdir(home.c_str());
     }
    else
    {
        std::string where = g_argv[1];
        if(where == "-")
        {}
        else if(where == "~")
        {}
        else
        {
            chdir(where.c_str());
        }
    }
    return true;
}

bool Echo()
{
    if(g_argc == 2)
    {
        //
        std::string opt = g_argv[1];
        if(opt == "$?") // eg1: echo $?
        {
            std::cout << lastcode << std::endl;
            lastcode = 0;
            return true;
        }
        if(opt[0] == '$') // eg2: echo $PATH
        {
            std::string env_name = opt.substr(1);
            const char * env_value = getenv(env_name.c_str()); // 获取环境变量的值
            if(env_value) 
                std::cout << env_value << std::endl;
        }
    }
    return false;
}

// 4. 检测并执行内键命令
bool CheckAndExecBuiltin()
{
    std::string cmd = g_argv[0];
    if(cmd == "cd")
    {
       return CD();
    }
    else if(cmd == "echo")
    {
       return Echo();
    }
    return false;
}

// 5.执行命令
int Execute()
{
    pid_t id = fork();
    if(id == 0)
    {
        // child, 程序替换
        execvp(g_argv[0], g_argv);
        exit(1);
    }
    // father
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if(rid > 0)
    {
        lastcode = WEXITSTATUS(status);
    }
    return 1;
}
// 释放通过malloc分配的环境变量内存
void FreeEnvMemory1()
{
    for(int i = 0; i < g_envs; i++) {
        if(g_env[i] != NULL) {
            free(g_env[i]);  // 释放每个环境变量字符串
            g_env[i] = NULL; // 置空指针
        }
    }
    g_envs = 0;  // 重置计数器
}

void FreeEnvMemory() {
    if (!g_used_putenv) {
        // 未调用 putenv,可以安全释放
        for (int i = 0; i < g_envs; i++) {
            free(g_env[i]);
            g_env[i] = NULL;
        }
    } else {
        // 调用过 putenv,只能释放数组指针(不释放字符串内存)
        for (int i = 0; i < g_envs; i++) {
            g_env[i] = NULL;  // 仅置空指针
        }
    }
    g_envs = 0;
}

int main()
{
    // shell启动要从系统中获取环境变量,而我们的环境变量信息要从父shell中获取
    InitEnv();
    // 当然是循环啦!
    while(true)
    {
        // 1、输出命令行提示符
        PrintCommandline();

        // 2. 获取输入的命令
        char commandline[COMMAND_SIZE];
        if(!GetCommandline(commandline, COMMAND_SIZE))
            continue;
        //printf("%s\n", commandline);

        // 3. 命令行分析, 字符串拆分成多个元素
        if(!CommandParse(commandline)) continue;
        //PrintArray();

        // 4.检测并执行内建命令
        if(CheckAndExecBuiltin()) continue;

        // 5. 执行命令
        Execute();
    }
    // 释放空间
    FreeEnvMemory();
    return 0;
}

三、函数和进程之间的相似性

一个C程序有很多函数组成。一个函数可以调用另一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过 call/return 系统进行通信。这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux 鼓励将这种应用于程序之内的模式扩展到程序之间。如下图:

一个C程序可以 fork/exec 另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过 exit(n) 来返回值。调用它的进程可以通过 wait(&ret) 来获取 exit 的返回值。

相关推荐
XINO5 分钟前
防火墙双机热备实践
运维·安全
神洛华16 分钟前
Docker概念详解
运维·docker·容器
四川合睿达自动化控制工程有限公司17 分钟前
管道位移自动化监测方案
运维·自动化
007php00719 分钟前
Docker Compose 安装Elasticsearch8和kibana和mysql8和redis5 并重置密码的经验与总结
大数据·运维·elasticsearch·搜索引擎·docker·容器·jenkins
城南已开97935 分钟前
vue部署到nginx服务器 启用gzip
服务器·vue.js·nginx
XINO37 分钟前
企业常见安全事故排查思路
运维·安全
林政硕(Cohen0415)41 分钟前
在ARM Linux应用层下驱动MFRC522
linux·mfrc522·ic-s50·m1卡
艾伦_耶格宇1 小时前
shell 脚本实验 -5 while循环
linux
独隅1 小时前
PyCharm 在 Linux 上的完整安装与使用指南
linux·ide·pycharm
想躺在地上晒成地瓜干1 小时前
树莓派超全系列教程文档--(38)config.txt视频配置
linux·音视频·树莓派·raspberrypi·树莓派教程