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

Linux 自定义shell命令解释器

本章的目的是:

1.模块化实现一个具备基本命令行解释功能的自定义bash。

2.通过实现自定义bash串讲先前的重要知识,尤其是环境变量和命令行参数的理解。

首先我们对大致的变量和核心功能做一个大概介绍。

一.功能概览

1. 命令行参数相关

#define MAXARGC 128
char *g_argv[MAXARGC];  // 存储解析后的命令行参数数组
int g_argc = 0;         // 参数个数计数器
  • 功能:存储用户输入命令解析后的各个参数

  • 示例:输入 ls -l /home 会被解析为 g_argv[0]="ls"g_argv[1]="-l"g_argv[2]="/home"

2. 环境变量相关

#define MAX_ENVS 100
char *g_env[MAX_ENVS];  // 环境变量存储数组
int g_envs = 0;         // 环境变量计数器
  • 功能:存储和管理 shell 的环境变量

  • 作用:为子进程提供执行环境

3.命令行参数表和环境变量表性质详解

特性环境变量表 (Environment Variables)命令行参数表 (Command-line Arguments)
级别进程级进程级
存储形式KEY=VALUE 字符串数组字符串指针数组
终止标记以 NULL 指针结尾以 NULL 指针结尾
继承性子进程继承父进程的环境变量子进程不继承父进程的参数,需要显式传递
修改性运行时可以动态修改通常在进程启动后只读

1.环境变量实际上是进程级的概念,但可以通过继承机制实现“系统级”的效果。

// 环境变量的继承链
系统启动 → init进程 → 登录shell → 当前shell → 子进程

2.环境变量表的内存布局

// 环境变量表在内存中的结构
char *environ[] = {"PATH=/usr/bin:/bin","HOME=/home/user", "USER=john","SHELL=/bin/bash",NULL  // 结束标记
};

3.本次实现bash中,对于初始化环境变量表的操作

#define MAX_ENVS 100
char *g_env[MAX_ENVS];  // 自定义环境变量表
int g_envs = 0;         // 环境变量计数器void InitEnv()
{extern char **environ;  // 系统全局环境变量表指针// 从父进程复制环境变量for(int i = 0; environ[i]; i++){g_env[i] = (char*)malloc(strlen(environ[i])+1);strcpy(g_env[i], environ[i]);  // 深拷贝g_envs++;}g_env[g_envs] = NULL;  // 必须的结束标记// 注册到系统for(int i = 0; g_env[i]; i++){putenv(g_env[i]);}environ = g_env;  // 重定向全局指针
}
  1. 深拷贝:避免直接使用父进程的环境变量指针

  2. 内存管理:需要手动管理 g_env 中字符串的内存

  3. 指针重定向:修改全局 environ 指向自定义表

对于之后要讲解的路径获取函数GetPwd,我们想实现实时更新系统环境变量中路径的值,就需要用到之前讲解的putenv进行修改,具体操作:

const char *GetPwd()
{const char *pwd = getcwd(cwd, sizeof(cwd));if(pwd != NULL){// 更新 PWD 环境变量snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd);putenv(cwdenv);  // 动态修改环境变量表}return pwd;
}

4.命令行参数表的特性:命令行参数表一般由一个参数计数器argc和参数表argv组成。前者记录表中的参数个数(包含命令和选项),后者则是存放具体的命令行参数。

// 命令行参数表的结构示例
// 用户输入: ls -l /home
char *argv[] = {"ls",      // 程序名/命令名"-l",      // 参数1"/home",   // 参数2  NULL       // 结束标记
};
int argc = 3;  // 参数个数

5.命令行参数表我们在进行命令行指令的解析和执行时会重点使用。我们使用strtok函数将命令行指令以字符串形式按空格分割,然后以循环方式依次解析指令。

#define MAXARGC 128
char *g_argv[MAXARGC];  // 命令行参数表
int g_argc = 0;         // 参数计数器bool CommandParse(char *commandline)
{g_argc = 0;// 使用strtok解析命令行字符串g_argv[g_argc++] = strtok(commandline, " ");// 循环解析所有参数while((g_argv[g_argc] = strtok(nullptr, " ")) != nullptr){g_argc++;if(g_argc >= MAXARGC - 1) break;  // 防止溢出}g_argv[g_argc] = NULL;  // 必须的结束标记return g_argc > 0;
}

6.在实现bash过程中,重点应用了环境变量的读取+设置+传递子进程,以及命令行参数的别名展开+命令行解析+命令传递。


4.别名映射表

在解析命令前,先在别名表中以key-value形式查看是否存在指令别名

std::unordered_map<std::string, std::string> alias_list;

5.辅助变量

char cwd[1024];     // 当前工作目录缓冲区
char cwdenv[1024];  // 环境变量格式的工作目录
int lastcode = 0;   // 上一个命令的退出状态码

6.核心功能模块

1. 环境信息获取函数

这些接口主要用于获取环境变量,并模仿系统bash中打印用户名-当前主机-当前工作目录的行为。

const char* GetUserName()    // 获取当前用户名
const char* GetHostName()    // 获取主机名  
const char* GetPwd()         // 获取当前工作目录
const char* GetHome()        // 获取家目录路径

2. 环境初始化模块

void InitEnv()
  • 功能

    • 从父进程继承环境变量

    • 复制到自定义环境变量数组

    • 添加测试环境变量 "HAHA=for_test"

    • 设置全局环境变量表

3. 内建命令实现

我们在这里展开什么叫内建命令。先来说什么不是内建命令:之后的代码中大家可以观察到,我们在除了内建命令以外的其他命令(如ls等等)都没有采用创建新的子进程+程序替换的方式执行指令:

那么为什么所谓内建命令不采用这种方式呢?

我们可以试想:初始时我们有bash进程,此时输入了命令cd,功能大家都清楚——更改当前工作路径并回显。但是我们会发现实际上路径并不会改变,这时为什么?因为把cd这样的内建命令交给子进程做,都只会对子进程产生修改,而父进程bash的工作目录并不会改变。其他的一些内建命令也类似如此的理由设计为内建命令。

4. 命令行界面模块

提示符生成

void MakeCommandLine()    // 生成格式化的提示符
void PrintCommandPrompt() // 打印提示符
  • 格式[用户名@主机名 当前目录名]#

  • 示例[user@localhost ~]#

命令输入处理

bool GetCommandLine()  // 读取用户输入的命令行
bool CommandParse()    // 解析命令行为参数数组
void PrintArgv()       // 调试用:打印解析后的参数

5. 命令执行模块

内建命令检查
bool CheckAndExecBuiltin()
  • 功能:检查是否为内建命令,如果是则直接执行

  • 支持的内建命令:cdechoexportalias

  • 返回 true 表示已处理,无需创建子进程

外部命令执行
int Execute()
  • 流程

    1. fork() 创建子进程

    2. 子进程:execvp() 发生程序替换,执行外部命令

    3. 父进程:waitpid() 等待子进程结束

    4. 记录退出状态码到 lastcode

6. 辅助函数

std::string DirName(const char *pwd)  // 提取路径的最后一个目录名

主程序流程

int main()
  1. 初始化:调用 InitEnv() 设置环境变量

  2. 主循环

    • 打印提示符

    • 读取命令

    • 解析命令

    • 检查内建命令

    • 执行外部命令

  3. 循环继续直到用户退出

二.模块内部逻辑详细讲解

1.环境初始化模块(InitEnv)

void InitEnv()
{extern char **environ;  // 引用外部全局环境变量表memset(g_env, 0, sizeof(g_env));  // 清空自定义环境变量数组g_envs = 0;// 复制父进程的环境变量for(int i = 0; environ[i]; i++){// 为每个环境变量字符串分配内存g_env[i] = (char*)malloc(strlen(environ[i])+1);strcpy(g_env[i], environ[i]);  // 复制字符串内容g_envs++;}// 添加自定义测试环境变量g_env[g_envs++] = (char*)"HAHA=for_test";g_env[g_envs] = NULL;  // 环境变量表必须以NULL结尾// 将自定义环境变量设置到进程环境for(int i = 0; g_env[i]; i++){putenv(g_env[i]);  // 设置到系统环境变量}environ = g_env;  // 重定向全局环境变量指针
}

逻辑详解

  • 首先获取外部全局的 environ 变量(这是一个指向系统环境变量字符串数组的指针)。

  • 将 g_env 数组清零,并重置计数器 g_envs 为0。

  • 遍历 environ 数组,将每个环境变量字符串拷贝到 g_env 中(使用动态分配内存)。

  • 添加一个测试环境变量 "HAHA=for_test",注意g_env的结尾用NULL来表示参数传递完毕。

  • 然后遍历 g_env 数组,使用 putenv 将每个环境变量设置到当前进程的环境中。

  • 最后将全局的 environ 指针指向我们的 g_env,这样后续的环境变量查找都会使用我们自定义的表

注意:这里有一个潜在问题,因为 putenv 的参数是字符串指针,而我们将动态分配的字符串指针传递给它,这些指针在程序运行期间一直有效(因为不会释放),所以没有问题。但是,如果后续要修改环境变量,需要小心处理。

2.内建命令模块

该模块是我们检测到位内建命令之后,不创建子进程而由bash直接执行的命令。

cd指令

在这里我们仅对默认的cd和具体的路径做了处理,对于特殊字符例如回到上一级目录,回到根目录并没有实现。

bool Cd()
{// cd argc = 1 表示只有"cd"命令,没有参数if(g_argc == 1){std::string home = GetHome();if(home.empty()) return true;  // 家目录为空则直接返回chdir(home.c_str());  // 切换到家目录}else  // 有参数的情况:cd <directory>{std::string where = g_argv[1];  // 获取目标目录参数// 特殊目录处理(目前未实现)if(where == "-"){// TODO: 应该切换到上一个工作目录// 需要维护一个previous_dir变量}else if(where == "~"){// TODO: 应该展开为用户家目录}else{chdir(where.c_str());  // 切换到指定目录}}return true;
}
  • 如果没有参数(g_argc==1,因为命令名是第一个参数,所以只有命令名时参数个数为1),则切换到家目录。

  • 如果有参数,检查第一个参数(g_argv[1]):

    • 如果是 -,表示切换到上一个目录(未实现)。

    • 如果是 ~,表示切换到家目录(未实现)。

    • 否则,切换到参数指定的目录。

注意chdir 系统调用成功返回0,失败返回-1,但这里没有处理错误

echo指令

我们这里对两个参数的情况(echo ***)做了处理。分别做了查看最近一个可执行程序的退出码,查看环境变量以及普通的printf逻辑,没有对重定向等操作定义和完善。

void Echo()
{if(g_argc == 2)  // 只处理单个参数的情况{std::string opt = g_argv[1];// 情况1: 输出上一条命令的退出码if(opt == "$?"){std::cout << lastcode << std::endl;lastcode = 0;  // 重置退出码}// 情况2: 输出环境变量值else if(opt[0] == '$'){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;}// 情况3: 直接输出字符串else{std::cout << opt << std::endl;}}// 注意:当前实现不支持多个参数,如"echo hello world"
}

逻辑

  • 目前只处理一个参数的情况(即 echo 后面只有一个字符串)。

  • 如果参数是 $?,则打印上一个命令的退出码 lastcode,然后将其重置为0。

  • 如果参数以 $ 开头,则将其视为环境变量名,获取并打印该环境变量的值。

  • 否则,直接打印参数字符串。

局限:目前只能处理一个参数,例如 echo hello world 会被解析为两个参数,而该函数只处理第二个参数(即hello),忽略其余参数。

3.命令行解析模块(CommandParse)

bool CommandParse(char *commandline)
{
#define SEP " "  // 使用空格作为分隔符g_argc = 0;// 第一次调用strtok,获取第一个token(命令名)g_argv[g_argc++] = strtok(commandline, SEP);// 循环获取后续所有参数,直到返回NULLwhile((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));g_argc--;  // 修正计数器(因为最后一次循环会多计数)return g_argc > 0 ? true:false;  // 至少有一个token才返回true
}

在这里,我们定义了分隔符为SEP(这里是空格)进行命令行参数的分割。

逻辑

  • 使用 strtok 函数以空格为分隔符将命令行字符串分割成多个令牌。

  • 第一个令牌通过 strtok(commandline, SEP) 获取,后续令牌通过 strtok(nullptr, SEP) 获取。

  • 每个令牌的指针被存入 g_argv 数组,并增加 g_argc 计数。

  • 当 strtok 返回 NULL 时停止,此时 g_argc 多计了一次,所以减一。

  • 最后返回是否解析到至少一个参数(即命令名)。

示例:

  • 输入:"ls -l /home/user"

  • 处理过程:

    1. strtok(commandline, " ") → "ls" → g_argv[0]

    2. strtok(nullptr, " ") → "-l" → g_argv[1]

    3. strtok(nullptr, " ") → "/home/user" → g_argv[2]

    4. strtok(nullptr, " ") → NULL → 循环结束

  • 结果:g_argc = 3g_argv = ["ls", "-l", "/home/user", NULL]

注意strtok 会修改原始字符串,将分隔符替换为 \0,因此原始命令字符串会被破坏。

4.内建命令检查模块(CheckAndExecBuiltin)

bool CheckAndExecBuiltin()
{std::string cmd = g_argv[0];  // 获取命令名// 命令分发逻辑if(cmd == "cd"){Cd();        // 内建命令,直接在当前进程执行return true; // 返回true表示已处理,无需fork}else if(cmd == "echo"){Echo();      // 内建命令return true;}else if(cmd == "export"){// TODO: 设置环境变量功能return true;}else if(cmd == "alias"){// TODO: 别名设置功能// 如:alias ll='ls -l'return true;}return false;  // 返回false表示不是内建命令,需要外部执行
}

逻辑

  • 将第一个参数(命令名)转换为字符串。

  • 与已知内建命令比较,如果匹配则调用相应的函数,并返回 true 表示已处理。

  • 如果不是内建命令,返回 false,以便后续执行外部命令。

5.外部命令执行模块 (Execute)

在经过命令行解析之后,我们需要对解析之后的指令进行执行(这里按逻辑已经确认是外部命令)。

int Execute()
{// 步骤1: 创建子进程pid_t id = fork();if(id == 0)  // 子进程分支{// 在子进程中执行外部命令execvp(g_argv[0], g_argv);  // 执行命令,替换当前进程映像// 如果execvp失败,执行到这里exit(1);  // 以错误码退出子进程}// 父进程分支int status = 0;// 等待子进程结束pid_t rid = waitpid(id, &status, 0);if(rid > 0)  // 成功等待到子进程结束{// 提取子进程的退出状态码lastcode = WEXITSTATUS(status);}return 0;
}
  • 使用 fork 创建子进程。

  • 在子进程中,调用 execvp 执行命令,如果执行失败则退出子进程(退出码1)。

  • 在父进程中,使用 waitpid 等待子进程结束,并获取退出状态。

  • 使用 WEXITSTATUS 宏从状态中提取退出码,并记录到 lastcode 中。

6.主循环(main)

为了不读取一个指令就退出bash程序,我们把bash设计为一个死循环,以便循环读取指令并执行。

int main()
{InitEnv();  // 一次性环境初始化while(true)  // 无限命令循环{// 阶段1: 显示提示符PrintCommandPrompt();  // 显示[user@host dir]# // 阶段2: 读取命令char commandline[COMMAND_SIZE];if(!GetCommandLine(commandline, sizeof(commandline)))continue;  // 读取失败或空命令,重新循环// 阶段3: 解析命令if(!CommandParse(commandline))continue;  // 解析失败,重新循环// 阶段4: 检查内建命令if(CheckAndExecBuiltin())continue;  // 如果是内建命令且已执行,跳过外部执行// 阶段5: 执行外部命令Execute();  // 创建子进程执行}return 0;
}

逻辑

  • 初始化环境变量。

  • 进入无限循环:

    1. 打印命令提示符。

    2. 读取用户输入的命令行。

    3. 如果读取失败(如EOF)则跳过本次循环。

    4. 解析命令行,如果解析失败(空命令)则跳过。

    5. 检查是否为内建命令,如果是则执行并跳过后续步骤(外部命令执行)。

    6. 如果不是内建命令,则创建子进程执行外部命令。

7.路径显示处理(DirName)

我们发现以上的打印结果会把完整的当前路径打出,而系统的bash是只取当前的工作目录名。所以我们需要把路径进行处理。

std::string DirName(const char *pwd)
{std::string dir = pwd;// 特殊情况:根目录if(dir == "/") return "/";// 查找最后一个斜杠位置auto pos = dir.rfind("/");if(pos == std::string::npos) return "BUG?";  // 理论上不应该出现// 返回最后一个斜杠后的部分return dir.substr(pos+1);
}

逻辑

  • 如果当前目录是根目录 "/",则返回 "/"。

  • 否则,查找最后一个 "/" 的位置,返回该位置之后的子字符串(即最后一个目录名)。

  • 如果找不到 "/",返回 "BUG?"。

示例

  • /home/user → rfind("/")找到位置5 → substr(6) → "user"

  • /usr/local/bin → 找到位置10 → substr(11) → "bin"

8.提示符生成 (MakeCommandLine)

void MakeCommandLine(char cmd_prompt[], int size)
{snprintf(cmd_prompt, size, FORMAT, GetUserName(), GetHostName(), DirName(GetPwd()).c_str());
}

逻辑

  • 使用 snprintf 格式化提示符字符串,格式为 [用户名@主机名 当前目录名]#

  • 其中当前目录名只显示最后一级目录。

三.完整源码与效果展示

源码如下:

#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#include <unordered_map>#define COMMAND_SIZE 1024
#define FORMAT "[%s@%s %s]# "// 下面是shell定义的全局数据// 1. 命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC];
int g_argc = 0; // 2. 环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS];
int g_envs = 0;// 3. 别名映射表
std::unordered_map<std::string, std::string> alias_list;// for test
char cwd[1024];
char cwdenv[1024];// last exit code
int lastcode = 0;const char *GetUserName()
{const char *name = getenv("USER");return name == NULL ? "None" : name;
}const char *GetHostName()
{const char *hostname = getenv("HOSTNAME");return hostname == NULL ? "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;
}void InitEnv()
{extern char **environ;memset(g_env, 0, sizeof(g_env));g_envs = 0;//本来要从配置文件来//1. 获取环境变量for(int i = 0; environ[i]; i++){// 1.1 申请空间g_env[i] = (char*)malloc(strlen(environ[i])+1);strcpy(g_env[i], environ[i]);g_envs++;}g_env[g_envs++] = (char*)"HAHA=for_test"; //for_testg_env[g_envs] = NULL;//2. 导成环境变量for(int i = 0; g_env[i]; i++){putenv(g_env[i]);}environ = g_env;
}//command
bool Cd()
{// cd argc = 1if(g_argc == 1){std::string home = GetHome();if(home.empty()) return true;chdir(home.c_str());}else{std::string where = g_argv[1];// cd - / cd ~if(where == "-"){// Todu}else if(where == "~"){// Todu}else{chdir(where.c_str());}}return true;
}void Echo()
{if(g_argc == 2){// echo "hello world"// echo $?// echo $PATHstd::string opt = g_argv[1];if(opt == "$?"){std::cout << lastcode << std::endl;lastcode = 0;}else if(opt[0] == '$'){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;}else{std::cout << opt << std::endl;}}
}// / /a/b/c
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 PrintCommandPrompt()
{char prompt[COMMAND_SIZE];MakeCommandLine(prompt, sizeof(prompt));printf("%s", prompt);fflush(stdout);
}bool GetCommandLine(char *out, int size)
{// ls -a -l => "ls -a -l\n" 字符串char *c = fgets(out, size, stdin);if(c == NULL) return false;out[strlen(out)-1] = 0; // 清理\nif(strlen(out) == 0) return false;return true;
}// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"
bool CommandParse(char *commandline)
{
#define SEP " "g_argc = 0;// 命令行分析 "ls -a -l" -> "ls" "-a" "-l"g_argv[g_argc++] = strtok(commandline, SEP);while((bool)(g_argv[g_argc++] = strtok(nullptr, SEP)));g_argc--;return g_argc > 0 ? true:false;
}void PrintArgv()
{for(int i = 0; g_argv[i]; i++){printf("argv[%d]->%s\n", i, g_argv[i]);}printf("argc: %d\n", g_argc);
}bool CheckAndExecBuiltin()
{std::string cmd = g_argv[0];if(cmd == "cd"){Cd();return true;}else if(cmd == "echo"){Echo();return true;}else if(cmd == "export"){}else if(cmd == "alias"){// std::string nickname = g_argv[1];// alias_list.insert(k, v);}return false;
}int Execute()
{pid_t id = fork();if(id == 0){//childexecvp(g_argv[0], g_argv);exit(1);}int status = 0;// fatherpid_t rid = waitpid(id, &status, 0);if(rid > 0){lastcode = WEXITSTATUS(status);}return 0;
}int main()
{// shell 启动的时候,从系统中获取环境变量// 我们的环境变量信息应该从父shell统一来InitEnv();while(true){// 1. 输出命令行提示符PrintCommandPrompt();// 2. 获取用户输入的命令char commandline[COMMAND_SIZE];if(!GetCommandLine(commandline, sizeof(commandline)))continue;// 3. 命令行分析 "ls -a -l" -> "ls" "-a" "-l"if(!CommandParse(commandline))continue;//PrintArgv();// 检测别名// 4. 检测并处理内键命令if(CheckAndExecBuiltin())continue;// 5. 执行命令Execute();}//cleanup();return 0;
}

效果如下:

wujiahao@VM-12-14-ubuntu:~/process_test$ ./myshell
[wujiahao@None process_test]# echo hello
hello
[wujiahao@None process_test]# ls
Makefile  myshell  myshell.cc  other.cc  proc.c

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

相关文章:

  • 陕西科强建设工程有限公司官方网站重庆企业建站系统模板
  • 【RabbitMQ】原理解析
  • Spring的IoC与DI
  • 做家装的网站有哪些安徽建工集团网站
  • 零知IDE——基于STM32F407VET6和雨滴传感器的多界面TFT降雨监测显示系统
  • 轻松在家构建AI集群,开启智能生活
  • 从PHP入门到公网部署:Web开发与服务器运维实战指南
  • 产品展示网站系统深圳app搭建
  • 40 dubbo和springcloud
  • (26)ASP.NET Core2.2 EF保存(基本保存、保存相关数据、级联删除、使用事务)
  • 西昌新站seo太原网站建设方案开发
  • 永久个人网站网站开发 设计文档
  • 天拓四方集团IoT平台在金属表面处理行业的智能化转型实践
  • 00-1-正则表达式学习心得:从入门到上瘾,再到克制
  • 【性能测试之正则表达式】正则表达式零基础入门:从“抄”到“写”,附性能测试实战案例
  • python-poppler - PDF文档处理Python绑定库
  • Android开发-Handler消息机制记录
  • 通信专业知识图谱​
  • 网站建设的页面要求一级域名二级域名
  • 基础镜像清理策略在VPS环境存储优化中的维护规范
  • The 2025 ICPC South America - Brazil First Phase
  • 开源 C# 快速开发(六)自定义控件--圆环
  • Calico 网络插件在 K8s 集群的作用
  • 蓝桥杯13届省题
  • 手机网站开发+图库类怎样在手机上建设网站
  • MySQL三层架构:从连接管理到数据存储
  • 嵌入式硬件——IMX6ULL时钟配置
  • 【用androidx.camera拍摄景深合成照片】
  • linux安装google chrome 谷歌浏览器
  • 从零起步学习Redis || 第二章:Cache Aside Pattern(旁路缓存模式)以及优化策略