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

C++/Linux小项目:自主shell命令解释器

        之前我们已经初步了解了Linux系统中shell命令的原理,本期我们就来模拟实现一下Linux系统中的shell命令解释器

        相关的代码已经上传至作者的个人gitee:楼田莉子/Linux学习喜欢请点个赞谢谢

目录

目标

实现原理

模拟实现(C语言版本)

        初始准备

        获取命令行

         解析命令

        执行命令

项目总结

源码(Cy语言版本)

源码(C++版本)

scanf 与 fgets 函数对比

        应用举例

        1. 读取带空格的字符串

        2. 缓冲区溢出风险

        3. 换行符处理问题

        4. fgets 包含换行符的问题

5. 最佳实践:fgets + sscanf 组合

总结表格对应的代码验证

getenv函数和putenv函数解析

代码示例说明

1. 基本用法对比

2. 内存管理危险示例

3. 实际应用场景对比

4. 综合工具函数示例

5. 错误处理对比

        关键总结

        getenv 特点:

        putenv 特点:

snprintf函数的解析

        函数原型

        基本作用

        参数说明

        返回值

        核心特性:安全截断

        与 sprintf 的对比

        详细用法示例

        1. 基本字符串格式化

        2. 缓冲区大小测试

        3. 返回值的重要性

        与相关函数对比

Linux内建命令表


目标

• 要能处理普通命令

• 要能处理内建命令

• 要能帮助我们理解内建命令/本地变量/环境变量这些概念

• 要能帮助我们理解shell的运行原理

实现原理

        shell本质上是一个死循环

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

        然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。所以要写一个shell,需要循环以下过程:

  1. 获取命令行

  2. 解析命令行

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

  4. 替换子进程(execvp)

  5. 父进程等待子进程退出(wait)
    根据这些思路,和我们前面学的技术,就可以自己来实现一个shell了

模拟实现(C语言版本)

        初始准备

        shell本质上是一个死循环所以我们先准备一个框架

#include<stdio.h>int main()
{//shell本质是一个死循环while(1){sleep(1);}return 0;
}

        获取命令行

        对于命令行我们需要获取当前主机的用户名、主机号、路径名。我们可以通过系统调用和从环境变量中获取。但是我们这里只做模拟所以从环境变量中获取对应的信息

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>//这里我们不做系统调用,只做模拟从环境变量中获取
const char* GetUserName()
{char*name=getenv("USER");if(name==NULL)return "None";return name;
}const char* GetHostName()
{char*hostname=getenv("HOSTNAME");if(hostname==NULL)return "None";return hostname;
}
const char* GetPwd()
{char*pwdname=getenv("PWD");if(pwdname==NULL)return "None";return pwdname;
}
void PrintCommandLine()
{printf("[%s@%s %s]#",GetUserName(),GetHostName(),GetPwd());//用户名@主机名 当前路径fflush(stdout);
}
int main()
{//shell本质是一个死循环while(1){//打印命令行PrintCommandLine();sleep(1);}return 0;
}

        随后我们要从命令行从获取对应的命令。

        因此我们需要先定义一个字符串数组

#define MAXSIZE 128char command_line[MAXSIZE]={0};

        输入我们可能会想到scanf,但是这里是不行的。因为scanf会把空格当作分隔符,而真正的shell命令解释器必须有空格(我们输入选项的时候以空格为分隔符),所以用scanf不合适。这里我们使用fgets函数

        这两个函数的区别我放好后面解释,这里不做赘述

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<string.h>
#define MAXSIZE 128
//这里我们不做系统调用,只做模拟从环境变量中获取
//之前的代码在这里省略:...
int main()
{char command_line[MAXSIZE]={0};//shell本质是一个死循环while(1){//打印命令行PrintCommandLine();//获取用户输入if(fgets(command_line,sizeof(command_line),stdin)==NULL)//不用scanf是因为它会默认以空格为分隔符,无法全部截取continue;//用户在输入的时候至少按一下回车command_line[strlen(command_line)-1]='\0';if(command_line[0]=='0') continue;//如果用户输入的是空串,继续输入// printf("%s\n",command_line);//测试用,现在不需要了sleep(1);}return 0;
}

       如果以上内容通过,那么就不需要printf的测试函数了。

        不过这么写显然很麻烦,所以我们要先做一个封装,修改判定条件

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<string.h>
#define MAXSIZE 128
//这里我们不做系统调用,只做模拟从环境变量中获取
//之前的代码在这里省略:...
int GetCommand(char*command_line,int size)
{if(fgets(command_line,size,stdin)==NULL)//不用scanf是因为它会默认以空格为分隔符,无法全部截取return 0;//用户在输入的时候至少按一下回车command_line[strlen(command_line)-1]='\0';//如果用户输入的是空串,继续输入return strlen(command_line);// printf("%s\n",command_line);//测试用,现在不需要了
}
int main()
{char command_line[MAXSIZE]={0};//shell本质是一个死循环while(1){//打印命令行PrintCommandLine();//获取用户输入if(GetCommand(command_line,sizeof(command_line))==0)//要么输入失败要么字符串为空continue;printf("%s\n",command_line);sleep(1);}return 0;
}

        结果为:

         解析命令

        对于shell来说,我们更需要对用户输入的命令做解释,就是对于像这样的命令

ls -a -l

        将其打散,重新组合在一起。

        命令行解析命令: bash来解析命令行,string -> argv && argc 

        对此我们需要先写出框架

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<string.h>
#define MAXSIZE 128
//这两个作用等同于main函数参数
char*myargv[MAXSIZE];//将打散的命令存储于此
int myargc;
//之前的代码在这里省略了
int main()
{char command_line[MAXSIZE]={0};//shell本质是一个死循环while(1){//1、打印命令行PrintCommandLine();//2、获取用户输入if(GetCommand(command_line,sizeof(command_line))==0)//要么输入失败要么字符串为空continue;printf("%s\n",command_line);//3、解析字符串。就是比如"ls -a -l"就要转化为"ls""-a""-l"命令解析器,对用户输入的命令做处理sleep(1);}return 0;
}

        相关思路如下:

        接下来我们对字符串切割函数strtok函数做简单使用讲解。具体的深入内容可以参考这个文章:https://blog.csdn.net/2401_89119815/article/details/147765173?fromshare=blogdetail&sharetype=blogdetail&sharerId=147765173&sharerefer=PC&sharesource=2401_89119815&sharefrom=from_link

        以下用代码示例:

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<string.h>
int main()
{char str[]="aaa bbb ccc ddd";const char* sep=" #: ";//切割第一次char *p=strtok(str,sep);printf("%s\n",p);while(p){printf("%s\n",p);p=strtok(NULL,sep);if(p==NULL)break;}//以下内容为使用示例//char *p1=strtok(str,sep);//printf("%s\n",p1);////如果要继续上一次的切割第一个参数要为NULL//char *p2=strtok(NULL,sep);//printf("%s\n",p2);return 0;
}

        结果为:

        那么我就用这个代码来实现这个功能

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<string.h>
#define MAXSIZE 128
//这两个作用等同于main函数参数
//shell自己维护的第一张表:命令行参数表
char*myargv[MAXSIZE];//将打散的命令存储于此
int myargc=0;
const char*sep=" ";
//其余代码在这里:...
int ParseCommand(char*command_line)
{myargc = 0;  memset(myargv,0,sizeof(myargv));//写法1//while((myargv[myargc++]=strtok(command_line,sep)));//写法2myargv[0]=strtok(command_line,sep);while((myargv[++myargc]=strtok(NULL,sep)));//测试代码//  printf("myargc:%d\n", myargc);//  for(int i = 0; i < myargc; ++i)//      printf("myargv[%d]:%s\n", i, myargv[i]);//  return myargc;
}
int main()
{char command_line[MAXSIZE]={0};//shell本质是一个死循环while(1){//1、打印命令行PrintCommandLine();//2、获取用户输入if(GetCommand(command_line,sizeof(command_line))==0)//要么输入失败要么字符串为空continue;printf("%s\n",command_line);//3、解析字符串。就是比如"ls -a -l"就要转化为"ls""-a""-l"命令解析器,对用户输入的命令做处理ParseCommand(command_line);sleep(1);//4、执行该命令ExectuCommand();        }return 0;
}

        执行命令

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<string.h>
#define MAXSIZE 128
int ExectuCommand()
{//不能让bash自己执行命令,必须创建子进程进行替换pid_t id=fork();if(id<0) return -1;else if(id==0){//子进程execvp(myargv[0],myargv);exit(0);        }else{int status=0;//父进程pid_t rid =waitpid(id,&status,0);if(rid>0){printf("等待成功!");}}return 0;
}
int main()
{char command_line[MAXSIZE]={0};//shell本质是一个死循环while(1){//1、打印命令行PrintCommandLine();//2、获取用户输入if(GetCommand(command_line,sizeof(command_line))==0)//要么输入失败要么字符串为空continue;printf("%s\n",command_line);//3、解析字符串。就是比如"ls -a -l"就要转化为"ls""-a""-l"命令解析器,对用户输入的命令做处理ParseCommand(command_line);sleep(1);//4、执行该命令ExectuCommand();        }return 0;
}

        但是我们会发现cd命令无效

        这是因为cd切换的是父进程(bash的路径)

        命令是bash的子进程,所有的子进程,会继承父进程当前工作路径!!!

        如果更改了bash的工作路径,就是更改了后续执行的所有指令(进程)的工作路径!!

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<string.h>
#define MAXSIZE 128
//这两个作用等同于main函数参数
//shell自己维护的第一张表:命令行参数表
char*myargv[MAXSIZE];//将打散的命令存储于此
int myargc=0;
const char*sep=" ";
//其余代码在这里
int ExecuteCommand()
{//不能让bash自己执行命令,必须创建子进程进行替换pid_t id=fork();if(id<0) return -1;else if(id==0){//子进程execvp(myargv[0],myargv);exit(0);        }else{int status=0;//父进程pid_t rid =waitpid(id,&status,0);if(rid>0){printf("等待成功!");}}return 0;
}
//1是内建命令或者已经执行完毕
//0不是内建命令
int CheckBuiltinExecute()
{if(strcmp(myargv[0],"cd")==0){//内建命令if(myargc==2){//新的路径为myargv[1]chdir(myargv[1]);}return 1;}return 0;
}
int main()
{char command_line[MAXSIZE]={0};//shell本质是一个死循环while(1){//1、打印命令行PrintCommandLine();//2、获取用户输入if(GetCommand(command_line,sizeof(command_line))==0)//要么输入失败要么字符串为空continue;printf("%s\n",command_line);//3、解析字符串。就是比如"ls -a -l"就要转化为"ls""-a""-l"命令解析器,对用户输入的命令做处理ParseCommand(command_line);sleep(1);//4、判断该命令由哪个进程执行if(CheckBuiltinExecute())continue;//5、子进程执行该命令ExecuteCommand();        }return 0;
}

        但是我们发现即使这样cd也没办法 以以下命令切换路径

 cd ..

        是因为我们的路径是根据环境变量来的(父进程)。操作系统不会自动更改环境变量

const char* GetHostName()
{char*hostname=getenv("HOSTNAME");if(hostname==NULL)return "None";return hostname;
}

        因此我们还要自行更新环境变量

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<string.h>
#define MAXSIZE 128//我们shell自己的工作路径
char cwd[MAXSIZE];
//这里我们不做系统调用,只做模拟从环境变量中获取
const char* GetPwd()
{//char*pwdname=getenv("PWD");char*pwdname=getcwd(cwd,sizeof(cwd));if(pwdname==NULL)return "None";return pwdname;
}
int main()
{char command_line[MAXSIZE]={0};//shell本质是一个死循环while(1){//1、打印命令行PrintCommandLine();//2、获取用户输入if(GetCommand(command_line,sizeof(command_line))==0)//要么输入失败要么字符串为空continue;printf("%s\n",command_line);//3、解析字符串。就是比如"ls -a -l"就要转化为"ls""-a""-l"命令解析器,对用户输入的命令做处理ParseCommand(command_line);sleep(1);//4、判断该命令由哪个进程执行if(CheckBuiltinExecute())continue;//5、子进程执行该命令ExecuteCommand();        }return 0;
}

        可以发现路径已经发生了变化

        如果我们相对环境变量进行修改来让其发生变化我们可以这样修改

const char* GetPwd()
{char*pwdname=getenv("PWD");//char*pwdname=getcwd(cwd,sizeof(cwd));if(pwdname==NULL)return "None";return pwdname;
}//1是内建命令或者已经执行完毕
//0不是内建命令
int CheckBuiltinExecute()
{if(strcmp(myargv[0],"cd")==0){//内建命令if(myargc==2){//新的路径为myargv[1]chdir(myargv[1]);char pwd[1024];getcwd(pwd,sizeof(pwd));//获取当前路径snprintf(cwd,sizeof(cwd),"PID:%s",pwd);putenv(cwd);}return 1;}return 0;
}

项目总结

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

源码(Cy语言版本)

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<string.h>
#define MAXSIZE 128
//这两个作用等同于main函数参数
//shell自己维护的第一张表:命令行参数表
char*myargv[MAXSIZE];//将打散的命令存储于此
int myargc=0;
//分隔符
const char*sep=" ";
//我们shell自己的工作路径
char cwd[MAXSIZE];
//这里我们不做系统调用,只做模拟从环境变量中获取
const char* GetUserName()
{char*name=getenv("USER");if(name==NULL)return "None";return name;
}const char* GetHostName()
{char*hostname=getenv("HOSTNAME");if(hostname==NULL)return "None";return hostname;
}
const char* GetPwd()
{char*pwdname=getenv("PWD");//char*pwdname=getcwd(cwd,sizeof(cwd));if(pwdname==NULL)return "None";return pwdname;
}
void PrintCommandLine()
{printf("[%s@%s %s]#",GetUserName(),GetHostName(),GetPwd());//用户名@主机名 当前路径fflush(stdout);
}
int GetCommand(char*command_line,int size)
{if(fgets(command_line,size,stdin)==NULL)//不用scanf是因为它会默认以空格为分隔符,无法全部截取return 0;//用户在输入的时候至少按一下回车command_line[strlen(command_line)-1]='\0';//如果用户输入的是空串,继续输入return strlen(command_line);// printf("%s\n",command_line);//测试用,现在不需要了
}
int ParseCommand(char*command_line)
{myargc = 0;  memset(myargv,0,sizeof(myargv));//写法1//while((myargv[myargc++]=strtok(command_line,sep)));//写法2myargv[0]=strtok(command_line,sep);while((myargv[++myargc]=strtok(NULL,sep)));//  printf("myargc:%d\n", myargc);//  for(int i = 0; i < myargc; ++i)//      printf("myargv[%d]:%s\n", i, myargv[i]);//  return myargc;
}
int ExecuteCommand()
{//不能让bash自己执行命令,必须创建子进程进行替换pid_t id=fork();if(id<0) return -1;else if(id==0){//子进程execvp(myargv[0],myargv);exit(0);        }else{int status=0;//父进程pid_t rid =waitpid(id,&status,0);if(rid>0){printf("等待成功!");}}return 0;
}
//1是内建命令或者已经执行完毕
//0不是内建命令
int CheckBuiltinExecute()
{if(strcmp(myargv[0],"cd")==0){//内建命令if(myargc==2){//新的路径为myargv[1]chdir(myargv[1]);char pwd[1024];getcwd(pwd,sizeof(pwd));//获取当前路径snprintf(cwd,sizeof(cwd),"PID:%s",pwd);putenv(cwd);}return 1;}return 0;
}
int main()
{char command_line[MAXSIZE]={0};//shell本质是一个死循环while(1){//1、打印命令行PrintCommandLine();//2、获取用户输入if(GetCommand(command_line,sizeof(command_line))==0)//要么输入失败要么字符串为空continue;printf("%s\n",command_line);//3、解析字符串。就是比如"ls -a -l"就要转化为"ls""-a""-l"命令解析器,对用户输入的命令做处理ParseCommand(command_line);sleep(1);//4、判断该命令由哪个进程执行if(CheckBuiltinExecute())continue;//5、子进程执行该命令ExecuteCommand();        }return 0;
}

源码(C++版本)

#include <stdio.h>
#include <ctype.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <iostream>
#include <string>#define MAXSIZE 128
#define MAXARGS 32
// shell自己内部维护的第一张表: 命令行参数表
// 故意设计成为全局的
// 命令行参数表
char *gargv[MAXARGS];
int gargc = 0;
const char *gsep = " ";// 环境变量表
char *genv[MAXARGS];
int genvc = 0;// 我们shell自己所处的工作路径
char cwd[MAXSIZE];// 最近一个命令执行完毕,退出码
int lastcode = 0;// vector<std::string> cmds; // 1000// ls -a -l > XX.txt -> "ls -a -l" && "XX.txt" && 重定向的方式
// 表明重定向的信息
#define NoneRedir   0
#define InputRedir  1
#define AppRedir    2
#define OutputRedir 3int redir_type = NoneRedir; // 记录正在执行的执行,重定向方式
char *filename = NULL;      // 保存重定向的目标文件// 空格空格空格filename.txt
#define TrimSpace(start) do{\while(isspace(*start)) start++;\
}while(0)void LoadEnv()
{// 正常情况,环境变量表内部是从配置文件来的// 今天我们从父进程拷贝extern char **environ;for(; environ[genvc]; genvc++){genv[genvc] = (char*)malloc(sizeof(char)*4096);strcpy(genv[genvc], environ[genvc]);}genv[genvc] = NULL;printf("Load env: \n");for(int i = 0; genv[i]; i++)printf("genv[%d]: %s\n", i, genv[i]);
}
static std::string rfindDir(const std::string &p)
{if(p == "/")return p;const std::string psep = "/";auto pos = p.rfind(psep);if(pos == std::string::npos)return std::string();return p.substr(pos+1); // /home/whb
}const char *GetUserName()
{char *name = getenv("USER");if(name == NULL)return "None";return name;
}const char *GetHostName()
{char *hostname = getenv("HOSTNAME");if(hostname == NULL)return "None";return hostname;
}
const char *GetPwd()
{char *pwd = getenv("PWD");//char *pwd = getcwd(cwd, sizeof(cwd));if(pwd == NULL)return "None";return pwd;
}void PrintCommandLine()
{printf("[%s@%s %s]# ", GetUserName(), GetHostName(), rfindDir(GetPwd()).c_str()); // 用户名 @ 主机名 当前路径fflush(stdout);
}int GetCommand(char commandline[], int size)
{if(NULL == fgets(commandline, size, stdin))return 0;// 2.1 用户输入的时候,至少会摁一下回车\n abcd\n ,\n '\0'commandline[strlen(commandline)-1] = '\0';return strlen(commandline);
}// ls -a -l >> filenamel.txt -> ls -a -l \0\0 filename.txt
// ls -a -l > XX.txt || ls -a -l >> XX.txt || cat < log.txt || ls -a -l
void ParseRedir(char commandline[])
{redir_type = NoneRedir;filename = NULL;char *start = commandline;char *end = commandline+strlen(commandline);while(start < end){if(*start == '>'){if(*(start+1) == '>'){// 追加重定向*start = '\0';start++;*start = '\0';start++;TrimSpace(start); // 去掉左半部分的空格redir_type = AppRedir;filename = start;break;}// 输出重定向*start = '\0';start++;TrimSpace(start);redir_type = OutputRedir;filename = start;break;}else if(*start == '<'){// 输入重定向*start = '\0';start++;TrimSpace(start);redir_type = InputRedir;filename = start;break;}else{// 没有重定向start++;}}
}int ParseCommand(char commandline[])
{gargc = 0;memset(gargv, 0, sizeof(gargv));// ls -a -l// 故意 commandline : lsgargv[0] = strtok(commandline, gsep);while((gargv[++gargc] = strtok(NULL, gsep)));//    printf("gargc: %d\n", gargc); // ?
//    int i = 0;
//    for(; gargv[i]; i++)
//        printf("gargv[%d]: %s\n", i, gargv[i]);return gargc;
}// retunr val:
// 0 : 不是内建命令
// 1 : 内建命令&&执行完毕
int CheckBuiltinExecute()
{if(strcmp(gargv[0], "cd") == 0){// 内建命令if(gargc == 2){// 新的目标路径: gargv[1]// 1. 更改进程内核中的路径chdir(gargv[1]);// 2. 更改环境变量char pwd[1024];getcwd(pwd, sizeof(pwd)); // /home/whbsnprintf(cwd, sizeof(cwd), "PWD=%s", pwd); // cwd: PWD=/home/homeputenv(cwd);lastcode = 0;}return 1;}else if(strcmp(gargv[0], "echo") == 0) // cd , echo , env , export 内建命令{if(gargc == 2){if(gargv[1][0] == '$'){// $? ? : 看做一个变量名字if(strcmp(gargv[1]+1, "?") == 0){printf("lastcode: %d\n", lastcode);}else if(strcmp(gargv[1]+1, "PATH") == 0){// 不准你用getenv和putenvprintf("%s\n", getenv("PATH")); // putenv 和 getenv 究竟是什么, 访问环境变量表!}lastcode = 0;}return 1;// echo helloworld// echo $?}}return 0;
}int ExecuteCommand()
{// 能不能让你的bash自己执行命令:ls -a -lpid_t id = fork();if(id < 0)return -1;else if(id == 0){//printf("我是子进程,我是exec启动前: %dp\n", getpid());// 子进程: 如何执行, gargv, gargc// ls -a -lint fd = -1;if(redir_type == NoneRedir){// Do Nothing}else if(redir_type == OutputRedir){// 子进程要进行输出重定向fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666);dup2(fd, 1);}else if(redir_type == AppRedir){fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666);dup2(fd, 1);}else if(redir_type == InputRedir){fd = open(filename, O_RDONLY);dup2(fd, 0);}else{//bug??}execvpe(gargv[0], gargv, genv);exit(1);}else{// 父进程int status = 0;pid_t rid = waitpid(id, &status, 0);if(rid > 0){lastcode = WEXITSTATUS(status);//printf("wait child process success!\n");}}return 0;
}int main()
{// 0. 从配置文件中获取环境变量填充环境变量表的//LoadEnv();char command_line[MAXSIZE] = {0};while(1){// 1. 打印命令行字符串PrintCommandLine();// 2. 获取用户输入if(0 == GetCommand(command_line, sizeof(command_line)))continue;//printf("%s\n", command_line);// ls -a -l > XX.txt || ls -a -l >> XX.txt || cat < log.txt || ls -a -l// ls -a -l > XX.txt -> "ls -a -l" && "XX.txt" && 重定向的方式ParseRedir(command_line);//printf("command: %s\n", command_line);//printf("redir type: %d\n", redir_type);//printf("filename: %s\n", filename);// 4. 解析字符串 -> "ls -a -l" -> "ls" "-a" "-l" 命令行解释器,就要对用户输入的命令字符串首先进行解析!ParseCommand(command_line);// 5. 这个命令,到底是让父进程bash自己执行(内建命令)?还是让子进程执行if(CheckBuiltinExecute()) // > 0{continue;}// 6. 让子进程执行这个命令ExecuteCommand();}return 0;
}

scanf 与 fgets 函数对比

对比角度scanf 函数fgets 函数说明与影响
基本功能与设计目的格式化输入,用于从标准输入(或文件流)中读取特定类型的数据(如整数、浮点数、字符串等)。行输入,用于从标准输入(或文件流)中读取一整行文本,包括空格。scanf 是“数据抽取器”,fgets 是“行收集器”。这是它们最根本的区别。
读取字符串时的区别使用 %s 格式符,遇到空白字符(空格、制表符、换行)即停止读取。读取字符直到遇到换行符('\n') 或达到指定数量减一(为'\0'留空间)或文件结尾(EOF)。scanf 的 %s 无法读取带空格的句子(如 "Hello World"),而 fgets 可以。
缓冲区溢出风险高危。如果使用 %s 而没有指定宽度,无法限制读取的字符数,极易导致缓冲区溢出,是严重的安全漏洞。安全。必须显式指定最大读取字符数 n,函数最多读取 n-1 个字符,并自动在末尾添加空字符('\0')。这是最重要的安全区别。在生产代码中,应绝对避免使用不指定宽度的 scanf("%s", buf)
换行符的处理留在输入缓冲区中scanf 在读取数字或字符串(%s)后,如果遇到换行符,会将其视为分隔符并留在输入缓冲区中。存入目标缓冲区中fgets 会将读取到的换行符 '\n' 作为字符串的一部分存入缓冲区。scanf 留下的换行符常导致后续的 getchar 或 fgets 读取到空行,需要手动清空缓冲区。fgets 读取后,可能需要手动去除末尾的换行符。
返回值返回一个整数,表示成功匹配并赋值的输入项的数量。如果失败或到达文件尾,则返回 EOF。成功时返回指向目标缓冲区的指针,失败或到达文件尾时返回 NULL 指针。通过检查 scanf 的返回值可以判断输入的数据类型是否正确。通过检查 fgets 是否为 NULL 可以判断是否发生错误或到达文件尾。
输入失败时的行为如果输入与指定格式不匹配,失败的数据会“放回”输入缓冲区,导致后续读取继续失败,造成“无限循环”等问题。通常是由于流错误或到达文件尾,不会因为数据格式不匹配而失败。scanf 输入失败后的清理工作比较麻烦,通常需要循环读取并丢弃错误数据。
灵活性与适用场景,适用于读取结构化的、类型已知的数据。例如,从文件或输入中读取“123, 3.14, John”这样的数据。较低但专注,最适合读取一整行文本,尤其是当文本内容未知或包含空格时。例如,读取用户的名字、地址、文件的一行等。对于混合输入(如先读数字再读字符串),使用 fgets 读取整行再用 sscanf 进行解析是更安全、更可控的做法。
典型用法示例scanf("%d", &num);
scanf("%4s", str); // 相对安全
scanf("%d %f", &a, &b);
fgets(buffer, sizeof(buffer), stdin);
// 然后手动去除可能的换行符:
buffer[strcspn(buffer, "\n")] = 0;
scanf 需要传递变量的地址(&),而 fgets 直接使用数组名(地址)。

        对于复杂的或要求健壮性的输入,推荐采用 fgets + sscanf 的组合:

  • 先用 fgets 安全地读取一整行到一个缓冲区。

  • 再用 sscanf 从这个缓冲区中安全地解析出需要的数据。

char buffer[100];
int age;
float salary;// 安全且健壮的输入方式
if (fgets(buffer, sizeof(buffer), stdin) != NULL) {// 尝试从缓冲区中解析数据if (sscanf(buffer, "%d %f", &age, &salary) == 2) {printf("Age: %d, Salary: %.2f\n", age, salary);} else {printf("Invalid input format.\n");}
}

        应用举例

        1. 读取带空格的字符串

        使用 scanf (遇到空格停止)

#include <stdio.h>int main() {char name[50];printf("请输入您的全名(包含空格): ");scanf("%s", name);printf("您输入的是: %s\n", name);return 0;
}

        运行结果:

请输入您的全名(包含空格): 张三 丰
您输入的是: 张三

        ❌ scanf 遇到空格就停止了,只读取了"张三"

        使用 fgets (读取整行包含空格)

#include <stdio.h>int main() {char name[50];printf("请输入您的全名(包含空格): ");fgets(name, sizeof(name), stdin);printf("您输入的是: %s\n", name);return 0;
}

        运行结果:

请输入您的全名(包含空格): 张三 丰
您输入的是: 张三 丰

        ✅ fgets 成功读取了包含空格的完整姓名

        2. 缓冲区溢出风险

危险的 scanf 用法

#include <stdio.h>int main() {char buffer[5]; // 只能容纳4个字符 + '\0'printf("请输入长字符串: ");scanf("%s", buffer); // 没有限制长度!printf("缓冲区内容: %s\n", buffer);return 0;
}

运行结果:

请输入长字符串: 这是一个很长的字符串
缓冲区内容: 这是一个很长的字符串

        💥 缓冲区溢出! 程序可能崩溃或产生不可预测行为

        安全的 fgets 用法

#include <stdio.h>int main() {char buffer[5]; // 只能容纳4个字符 + '\0'printf("请输入长字符串: ");fgets(buffer, sizeof(buffer), stdin); // 自动限制长度printf("缓冲区内容: %s\n", buffer);return 0;
}

运行结果:

请输入长字符串: 这是一个很长的字符串
缓冲区内容: 这是

        ✅ 安全! fgets 自动限制读取的字符数,防止溢出

        3. 换行符处理问题

scanf 留下的换行符陷阱

#include <stdio.h>int main() {int age;char name[50];printf("请输入年龄: ");scanf("%d", &age);printf("请输入姓名: ");fgets(name, sizeof(name), stdin); // 问题所在!printf("年龄: %d, 姓名: %s\n", age, name);return 0;
}

运行结果:

请输入年龄: 25
请输入姓名: 年龄: 25, 姓名: 

❌ fgets 立即读取了 scanf 留下的换行符,看起来像是被"跳过"了

解决方案:清空输入缓冲区

#include <stdio.h>int main() {int age;char name[50];printf("请输入年龄: ");scanf("%d", &age);// 清空输入缓冲区中的换行符while (getchar() != '\n');printf("请输入姓名: ");fgets(name, sizeof(name), stdin);printf("年龄: %d, 姓名: %s\n", age, name);return 0;
}

运行结果:

请输入年龄: 25
请输入姓名: 李四
年龄: 25, 姓名: 李四

✅ 正常工作了!

        4. fgets 包含换行符的问题

  fgets 会包含换行符

#include <stdio.h>
#include <string.h>int main() {char city[50];printf("请输入城市: ");fgets(city, sizeof(city), stdin);printf("城市长度: %zu\n", strlen(city));printf("城市内容: ");for(int i = 0; i < strlen(city); i++) {printf("%d ", city[i]); // 打印ASCII码}printf("\n");return 0;
}

运行结果(输入"北京"):

请输入城市: 北京
城市长度: 5
城市内容: 229 140 151 228 186 172 10 

🔍 可以看到最后一个字符是10(换行符 \n

去除 fgets 的换行符

#include <stdio.h>
#include <string.h>int main() {char city[50];printf("请输入城市: ");fgets(city, sizeof(city), stdin);// 去除换行符的方法city[strcspn(city, "\n")] = 0;// 或者:if (city[strlen(city)-1] == '\n') city[strlen(city)-1] = 0;printf("处理后长度: %zu\n", strlen(city));printf("城市: %s\n", city);return 0;
}

运行结果:

请输入城市: 北京
处理后长度: 4
城市: 北京

✅ 换行符被成功移除

5. 最佳实践:fgets + sscanf 组合

安全可靠的输入方法

#include <stdio.h>int main() {char input[100];int age;float height;char name[50];printf("请输入年龄、身高和姓名(用空格分隔): ");if (fgets(input, sizeof(input), stdin) != NULL) {// 从缓冲区安全解析数据int items = sscanf(input, "%d %f %49s", &age, &height, name);if (items == 3) {printf("解析成功:\n");printf("年龄: %d, 身高: %.2f, 姓名: %s\n", age, height, name);} else {printf("输入格式错误!期望3个数据,实际得到%d个\n", items);}} else {printf("读取输入失败!\n");}return 0;
}

运行结果:

请输入年龄、身高和姓名(用空格分隔): 25 1.75 王五
解析成功:
年龄: 25, 身高: 1.75, 姓名: 王五

总结表格对应的代码验证

区别点scanf 代码表现fgets 代码表现
空格处理scanf("%s") 遇到空格停止fgets 读取整行包含空格
安全性scanf("%s", buf) 可能溢出fgets(buf, size, stdin) 自动限制
换行符留在缓冲区,影响后续输入包含在读取的字符串中
适用场景适合读取结构化数据适合读取整行文本

getenv函数和putenv函数解析

对比角度getenv 函数putenv 函数
基本功能获取环境变量的值设置或修改环境变量
函数原型char *getenv(const char *name)int putenv(char *string)
参数形式环境变量名称字符串"name=value" 格式的完整字符串
返回值成功:指向环境变量值的指针
失败:NULL
成功:0
失败:非0值
内存管理安全:返回只读指针,用户无需管理内存危险:直接使用传入的字符串指针,不复制内容
线程安全是(只读操作)否(修改全局环境变量表)
对原始数据影响无影响,只读操作会修改进程的环境变量表
字符串格式单纯的名字,如 "PATH"必须为 "name=value" 格式
使用复杂度简单直接需要特别注意内存管理
典型应用场景读取配置、检查运行环境、获取系统信息临时修改环境、传递配置给子进程、动态设置参数

代码示例说明

1. 基本用法对比

#include <stdio.h>
#include <stdlib.h>
#include <string.h>void basic_usage_demo() {printf("=== getenv 基本用法 ===\n");// getenv: 简单安全的读取char *home = getenv("HOME");char *user = getenv("USER");char *path = getenv("PATH");printf("HOME: %s\n", home ? home : "(未设置)");printf("USER: %s\n", user ? user : "(未设置)");printf("PATH: %s\n", path ? path : "(未设置)");printf("\n=== putenv 基本用法 ===\n");// putenv: 必须使用持久存储的字符串static char my_var[] = "MY_APP=test_application";printf("设置前 MY_APP: %s\n", getenv("MY_APP") ? getenv("MY_APP") : "(未设置)");if (putenv(my_var) == 0) {printf("设置成功\n");printf("设置后 MY_APP: %s\n", getenv("MY_APP"));} else {printf("设置失败\n");}
}int main() {basic_usage_demo();return 0;
}

2. 内存管理危险示例

#include <stdio.h>
#include <stdlib.h>
#include <string.h>void dangerous_putenv() {printf("=== putenv 内存危险演示 ===\n");// 危险示例:使用局部变量char local_var[50] = "DANGEROUS_VAR=local_value";printf("设置前: %s\n", getenv("DANGEROUS_VAR") ? getenv("DANGEROUS_VAR") : "(未设置)");putenv(local_var);  // 危险!传入局部变量地址printf("设置后立即读取: %s\n", getenv("DANGEROUS_VAR"));// 如果函数返回,local_var 栈内存可能被重用// 环境变量指向的内存内容变得不可预测
}void safe_putenv() {printf("\n=== 安全的 putenv 用法 ===\n");// 方法1:使用静态存储static char static_var[] = "SAFE_VAR_STATIC=static_value";putenv(static_var);printf("静态存储: %s\n", getenv("SAFE_VAR_STATIC"));// 方法2:使用堆内存(但注意不要释放)char *heap_var = strdup("SAFE_VAR_HEAP=heap_value");putenv(heap_var);printf("堆内存: %s\n", getenv("SAFE_VAR_HEAP"));// 注意:heap_var 不能 free,因为 putenv 还在使用它
}int main() {dangerous_putenv();safe_putenv();return 0;
}

3. 实际应用场景对比

#include <stdio.h>
#include <stdlib.h>
#include <string.h>// getenv 应用:配置读取器
void read_app_config() {printf("=== getenv 应用:读取配置 ===\n");char *db_host = getenv("DB_HOST");char *db_port = getenv("DB_PORT"); char *debug_mode = getenv("DEBUG");char *log_level = getenv("LOG_LEVEL");printf("数据库配置:\n");printf("  主机: %s\n", db_host ? db_host : "localhost (默认)");printf("  端口: %s\n", db_port ? db_port : "5432 (默认)");printf("  调试模式: %s\n", debug_mode ? "开启" : "关闭");printf("  日志级别: %s\n", log_level ? log_level : "INFO (默认)");
}// putenv 应用:环境配置器  
void setup_development_environment() {printf("\n=== putenv 应用:环境配置 ===\n");// 开发环境配置static char dev_vars[][100] = {"APP_ENV=development","LOG_LEVEL=DEBUG", "CACHE_ENABLED=false","API_TIMEOUT=30"};for (int i = 0; i < 4; i++) {putenv(dev_vars[i]);printf("设置: %s\n", dev_vars[i]);}printf("\n验证配置:\n");printf("APP_ENV: %s\n", getenv("APP_ENV"));printf("LOG_LEVEL: %s\n", getenv("LOG_LEVEL"));printf("CACHE_ENABLED: %s\n", getenv("CACHE_ENABLED"));printf("API_TIMEOUT: %s\n", getenv("API_TIMEOUT"));
}int main() {read_app_config();setup_development_environment();return 0;
}

4. 综合工具函数示例

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>// 安全的 getenv 包装器,提供默认值
char *getenv_safe(const char *name, const char *default_value) {char *value = getenv(name);return value ? value : (char *)default_value;
}// 安全的 putenv 包装器,自动处理内存
bool putenv_safe(const char *name, const char *value) {// 计算需要的内存大小size_t len = strlen(name) + strlen(value) + 2; // +2 用于 '=' 和 '\0'char *env_string = malloc(len);if (!env_string) {return false;}// 构建 "name=value" 格式snprintf(env_string, len, "%s=%s", name, value);// 设置环境变量if (putenv(env_string) != 0) {free(env_string);return false;}// 注意:不要 free(env_string),putenv 会继续使用它return true;
}// 环境变量管理器
void environment_manager_demo() {printf("=== 环境变量管理器演示 ===\n");// 使用安全的 getenvprintf("读取配置(带默认值):\n");printf("  数据库主机: %s\n", getenv_safe("DB_HOST", "localhost"));printf("  数据库端口: %s\n", getenv_safe("DB_PORT", "5432"));printf("  日志级别: %s\n", getenv_safe("LOG_LEVEL", "INFO"));// 使用安全的 putenvprintf("\n设置新的环境变量:\n");if (putenv_safe("APP_NAME", "MyApplication")) {printf("  成功设置 APP_NAME\n");}if (putenv_safe("APP_VERSION", "1.0.0")) {printf("  成功设置 APP_VERSION\n");}printf("\n验证设置:\n");printf("  APP_NAME: %s\n", getenv("APP_NAME"));printf("  APP_VERSION: %s\n", getenv("APP_VERSION"));
}int main() {environment_manager_demo();return 0;
}

5. 错误处理对比

#include <stdio.h>
#include <stdlib.h>
#include <string.h>void error_handling_demo() {printf("=== 错误处理对比 ===\n");// getenv 的错误处理:检查返回值printf("getenv 错误处理:\n");char *nonexistent = getenv("NON_EXISTENT_VARIABLE_12345");if (nonexistent == NULL) {printf("  环境变量不存在,返回 NULL\n");} else {printf("  环境变量值: %s\n", nonexistent);}// putenv 的错误处理:检查返回值printf("\nputenv 错误处理:\n");// 测试无效格式static char invalid_format[] = "INVALID_FORMAT"; // 缺少 '='if (putenv(invalid_format) != 0) {printf("  错误:无效的环境变量格式\n");}// 测试正常设置static char valid_var[] = "TEST_VAR=valid_value";if (putenv(valid_var) == 0) {printf("  成功设置 TEST_VAR\n");} else {printf("  设置 TEST_VAR 失败\n");}// 验证设置结果printf("  TEST_VAR: %s\n", getenv("TEST_VAR") ? getenv("TEST_VAR") : "(未设置)");
}int main() {error_handling_demo();return 0;
}

        关键总结

        getenv 特点:

  • 只读操作,安全简单

  • 适合读取配置、检查环境

  • 返回值指向的环境变量值不应被修改

  • 线程安全

        putenv 特点:

  • 写入操作,需要特别小心

  • 参数必须是 "name=value" 格式

  • 不会复制字符串,必须保证传入的字符串持久存在

  • 适合临时修改环境、配置传递

  • 非线程安全

snprintf函数的解析

  snprintf 函数是 C 语言标准库中一个非常重要的安全字符串格式化函数,它解决了 sprintf 的缓冲区溢出问题。

        函数原型

int snprintf(char *str, size_t size, const char *format, ...);

        基本作用

        将格式化的数据写入字符串,但限制最大写入字符数,防止缓冲区溢出。

        参数说明

  • str:目标字符串缓冲区

  • size:缓冲区大小(包括结尾的 \0

  • format:格式化字符串

  • ...:可变参数列表

        返回值

  • 成功时:返回本应写入的字符数(不包括结尾的 \0

  • 失败时:返回负值

        核心特性:安全截断

        与 sprintf 的对比

#include <stdio.h>
#include <string.h>int main() {char buffer[10];// 危险的 sprintf - 可能缓冲区溢出// sprintf(buffer, "这是一个很长的字符串"); // 危险!// 安全的 snprintfint result = snprintf(buffer, sizeof(buffer), "这是一个很长的字符串");printf("缓冲区内容: '%s'\n", buffer);printf("snprintf 返回值: %d\n", result);printf("实际写入字符数: %zu\n", strlen(buffer));return 0;
}

输出:

缓冲区内容: '这是一个很'
snprintf 返回值: 24
实际写入字符数: 9

        详细用法示例

        1. 基本字符串格式化

#include <stdio.h>int main() {char buffer[50];char name[] = "张三";int age = 25;double salary = 8000.50;int needed = snprintf(buffer, sizeof(buffer), "姓名: %s, 年龄: %d, 工资: %.2f", name, age, salary);printf("格式化结果: %s\n", buffer);printf("需要空间: %d 字符\n", needed);printf("实际使用: %zu 字符\n", strlen(buffer));printf("剩余空间: %zu 字符\n", sizeof(buffer) - strlen(buffer) - 1);return 0;
}
 

        输出:

格式化结果: 姓名: 张三, 年龄: 25, 工资: 8000.50
需要空间: 36 字符
实际使用: 35 字符
剩余空间: 14 字符

        2. 缓冲区大小测试

#include <stdio.h>
#include <string.h>void test_buffer_size(const char *format, ...) {char small_buf[10];char large_buf[50];// 测试小缓冲区int result_small = snprintf(small_buf, sizeof(small_buf), format);printf("小缓冲区[%zu]: '%s'\n", sizeof(small_buf), small_buf);printf("需要字符数: %d\n", result_small);// 测试大缓冲区  int result_large = snprintf(large_buf, sizeof(large_buf), format);printf("大缓冲区[%zu]: '%s'\n", sizeof(large_buf), large_buf);printf("需要字符数: %d\n\n", result_large);
}int main() {test_buffer_size("Hello, World!");test_buffer_size("数字: %d, 字符串: %s", 12345, "这是一个测试");return 0;
}

        输出:

小缓冲区[10]: 'Hello, Wo'
需要字符数: 13
大缓冲区[50]: 'Hello, World!'
需要字符数: 13小缓冲区[10]: '数字: 123'
需要字符数: 30
大缓冲区[50]: '数字: 12345, 字符串: 这是一个测试'
需要字符数: 30
 

        3. 返回值的重要性

#include <stdio.h>
#include <stdlib.h>
#include <string.h>void safe_format(const char *format, ...) {char initial_buf[100];char *final_buf;int needed;// 第一次调用,确定需要多大空间needed = snprintf(initial_buf, sizeof(initial_buf), format);if (needed < 0) {printf("格式化错误!\n");return;}if (needed < sizeof(initial_buf)) {// 初始缓冲区足够大printf("结果: %s\n", initial_buf);} else {// 需要分配更大的缓冲区printf("初始缓冲区不足,需要 %d 字符\n", needed);final_buf = malloc(needed + 1); // +1 给 '\0'if (final_buf == NULL) {printf("内存分配失败!\n");return;}snprintf(final_buf, needed + 1, format);printf("最终结果: %s\n", final_buf);free(final_buf);}
}int main() {safe_format("短消息");safe_format("这是一个非常长的消息,需要比初始缓冲区大得多的空间来存储完整的格式化结果");return 0;
}
 

        与相关函数对比

函数安全性缓冲区大小检查返回值使用场景
sprintf不安全写入字符数已知输出不会溢出时
snprintf安全需要的字符数推荐:通用字符串格式化
vsnprintf安全需要的字符数可变参数已为 va_list 时

Linux内建命令表

命令类别命令名称功能描述使用示例
目录操作cd切换当前工作目录cd /home
pwd显示当前工作目录pwd
dirs显示目录栈dirs
pushd将目录压入栈并切换pushd /tmp
popd从目录栈弹出目录popd
变量操作set设置shell变量和选项set -o vi
unset删除变量或函数unset VAR
export设置环境变量export PATH=$PATH:/newdir
readonly设置只读变量readonly PI=3.14
declare声明变量属性declare -i number=5
local在函数内声明局部变量local var=value
作业控制jobs显示当前作业jobs
fg将作业切换到前台fg %1
bg将作业切换到后台bg %1
wait等待作业完成wait %1
disown从作业表中移除作业disown %1
历史命令history显示命令历史history
fc编辑并重新执行历史命令fc 100 105
别名管理alias创建命令别名alias ll='ls -l'
unalias删除别名unalias ll
流程控制exit退出shellexit
return从函数返回return 0
break退出循环break
continue继续循环下一次迭代continue
条件测试test条件测试test -f file.txt
[ ]条件测试(同test)[ -d /home ]
[[ ]]扩展条件测试(bash)[[ $var == pattern ]]
输入输出echo输出文本echo "Hello"
printf格式化输出printf "Name: %s\n" $name
read从标准输入读取read name
readarray读取输入到数组readarray lines
mapfile同readarraymapfile lines
命令执行exec执行命令并替换当前shellexec bash
eval执行参数作为命令eval "ls $dir"
command执行命令忽略函数command ls
builtin执行内建命令忽略函数builtin cd
type显示命令类型type cd
信号处理trap设置信号处理程序trap 'echo Exit' EXIT
suspend暂停shell执行suspend
资源限制ulimit设置或显示资源限制ulimit -n 1024
umask设置文件创建掩码umask 022
其他功能source在当前shell执行脚本source script.sh
.同source. script.sh
times显示shell及子进程时间times
help显示内建命令帮助help cd
shopt设置shell选项shopt -s extglob
enable启用/禁用内建命令enable -n echo
bind设置键盘绑定bind '"\C-l":clear-screen'
compgen生成补全匹配compgen -c
complete设置命令补全complete -F _cd cd
compopt修改补全选项compopt -o filenames
caller返回子程序调用上下文caller 0

        本期关于Linux的shell解释器的项目到此结束了。如果您喜欢这个项目,请点个赞谢谢

封面图自取:

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

相关文章:

  • MEMS振荡器MST8012抗冲击设计应对严苛振动环境
  • 【数据结构】常见的排序算法 -- 交换排序
  • Rust与主流编程语言的深度对比分析
  • NebulaChat 框架学习笔记:深入理解 Reactor 与多线程同步机制
  • 网站开发接口网站建设需要什么
  • 聚焦新“新双高计划”,高职学校如何进行数字化转型?
  • 全志V853视频输入驱动框架详解:从VIN模块到虚通道实战
  • 网站建设需要英语吗wordpress笑话主题模板
  • Azure OpenAI GPT-5 PTU 容量规划与弹性配置实践
  • [linux仓库]多线程同步:基于POSIX信号量实现生产者-消费者模型[线程·柒]
  • Linux 内核驱动加载机制
  • C语言编译软件 | 高效选择适合的C语言编译环境
  • 天津 网站策划微信、网站提成方案点做
  • 工业级部署指南:在西门子IOT2050(Debian 12)上搭建.NET 9.0环境与应用部署(进阶篇)
  • 食品网站建设网站定制开发做网站只买一个程序
  • 中小型项目前后端工时对比
  • C# 文件的输入与输出
  • Linux操作系统学习
  • idea创建javaweb项目
  • 【计网】基于OSPF 协议的局域网组建
  • 开发一个小程序花多少钱
  • Ansible入门详解
  • 一体化系统(一)智慧物业管理综合管理——东方仙盟
  • 买虚机送网站建设wordpress google ad
  • 2008 iis配置网站公司做网站需要注意些什么问题
  • vs2013编译C语言 | 探讨如何使用Visual Studio 2013进行C语言编译与调试
  • k8s上分离集群seatunnel部署(生产推荐)
  • 最新版idea2025 配置docker 打包spring-boot项目到生产服务器全流程,含期间遇到的坑
  • Python 处理 CSV 和 Excel 文件的全面指南
  • 小程序 scroll-view 触底事件不触发问题