目录
1.命令行解释器的概念
命令行解释器(CLI Shell) 是操作系统提供的一种文本交互界面,它接收用户输入的命令,解释并执行,然后将结果返回给用户 。它是用户与操作系统内核之间的"翻译官"。
2.简述流程
[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由标识为bash的进程方块代表,它随着时间的流逝从左向右移动。bash从用户读入字符串"ls"并创建子进程,由子进程运行ls程序,bash进程等待子进程结束
shell是命令行解释器的统称,bash是具体实现的一种

然后bash 读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。
所以要写一个 shell ,需要循环以下过程 :
- 获取命令行
- 解析命令行
- 建立一个子进程(fork)
- 替换子进程(execvp)
- 父进程等待子进程退出(wait)
3.实现
3.1预览框架
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
//使用宏管理数值
#define LEFT "["
#define RIGHT "]"
#define LABEL "#"
#define LEN_MAX 1024
#define ARGC_SIZE 32
#define DELIM " \t"
#define EXIT_SUCCESS 0
#define EXIT_FAILURE 1
//因为只用了一个文件
//全局变量更容易初始化和调用
char commandline[LEN_MAX];//存储用户输入的字符串
char* argv[ARGC_SIZE];//存储命令行参数
int argc = 0;//命令行参数的个数
int lastcode = 0;//进程退出码
char myenv[LEN_MAX][LEN_MAX];//自定义环境变量表
//自定义本地变量表
int numsOfEnv = 0;//新增环境变量的个数
char pwd[LEN_MAX];//当前工作路径
//命令行与用户进行交互
void interact();
//将读取的字符串解析为命令行参数
void splitString(char cline[]);
//执行内建命令
//执行内建命令返回1,执行普通命令返回0
int buildCommand(char* argv[]);
//执行普通命令
void normalExecute(char* argv[]);
int main()
{
while(1)//持续等待用户的命令
{
//1.命令行解释器与用户进行交互
interact();
//2.解析命令行字符串
splitString(commandline);
//3.执行内建命令
int ret = buildCommand(argv);
//4.执行普通命令
if(ret == 0) normalExecute(argv);
}
return 0;
}
命令行解释器循环执行以下操作 打印提示符->接收命令行字符串->解析命令行字符串->执行命令
3.2interact
void interact()
{
//1.打印命令行
getcwd(pwd, sizeof(pwd));
printf(LEFT"%s@%s %s"RIGHT""LABEL" ", getenv("USER"), getenv("HOSTNAME"), pwd);
//等待用户输入
fgets(commandline, sizeof(commandline), stdin);
//fgets读取到n-1个字符、文件末尾或者遇到\n时停止读取
//因为命令行参数一定小于1024个字节,所以所读取的字符串中会多\n,即
//"ls -a -l\n\0",命令行参数不包括\n,所以需要处理\n
commandline[strlen(commandline)-1] = '\0';
}
- 调用getcwd函数将当前工作路径保存到字符数组pwd中
- 由于相邻字符串之间具有自动连接的特点,同时为了自定义形式,我将方框、标签使用宏定义
- 这里的用户名、主机名可通过 环境变量获取
- 使用fgets函数进行整行读取,因为至少会读取换行符且后续的命令行参数不存在换行符,所以读取之后需要用\0覆盖\n
|----------|-----------------------------------------|
| 函数原型 | char *getcwd(char *buf, size_t size); |
| 头文件 | <unistd.h> |
| 功能 | 获取当前工作目录的绝对路径 |
| 返回值 | 成功:返回 buf 失败:返回 NULL,设置 errno |
3.3splitString
void splitString(char cline[])
{
int i = 0;
argv[i++] = strtok(cline, DELIM);
while(argv[i++] = strtok(NULL, DELIM));
argc = i -1;
}
|----------|-----------------------------------------------|
| 函数原型 | char *strtok(char *str, const char *delim); |
| 头文件 | <string.h> |
| 功能 | 分割字符串为一系列标记(token) |
|----------|--------------------------------|------------|
| 首次调用 | token = strtok(str, delim); | 传入待分割字符串 |
| 后续调用 | token = strtok(NULL, delim); | 传入NULL继续分割 |
| 结束条件 | 当返回NULL时表示分割完成 |
- 调用strtok函数以空格为分隔符,从而将命令行字符串解析为命令行参数并存储进argv数组中
- 注意while循环当中是赋值符号,这里巧妙地运用了strtok返回值:命令行参数的地址不为NULL,分割完成返回NULL
- 当前 i 包含了 NULL在内的所有参数,因为命令行参数不含NULL,所以 argc = i -1
3.4buildCommand
-
内建命令实际上是命令行解释器内部的一个函数,由命令行解释器自己执行
-
内建命令通过判断、罗列执行,这里只简写了echo、cd、export三种内建命令
-
同时,执行完内建命令返回1,否则返回0,通过返回值可以决定后续是否执行普通命令
int buildCommand(char* argv[])
{
//通过判断执行内建命令
//内建命令实质上是命令行解释器内部的一个函数
if(argc == 2 && strcmp(argv[0], "echo") == 0)
{
if(strcmp(argv[1], "?") == 0) { printf("%d\n", lastcode); lastcode = 0; } else if(*argv[1] == '')
{
printf("%s\n", getenv(argv[1]+1));
}
else printf("%s\n", argv[1]);return 1; } else if(argc == 2 && strcmp(argv[0], "cd") == 0) { chdir(argv[1]);//修改工作目录 // setenv("PWD", argv[1], 1);//修改环境变量,setenv深拷贝,后续不会随着造成argv[1]的更改而更改,但是argv[1]可能是相对路径 getcwd(pwd, sizeof(pwd)); setenv("PWD", pwd, 1); return 1; } else if(argc == 2 && strcmp(argv[0], "export") == 0) { //将新增环境变量添加到环境变量表中,这样后续不会随着argv[1]的更改而更改 // myenv[numsOfEnv] = argv[1]+1; 浅拷贝只是拷贝了指针 strcpy(myenv[numsOfEnv], argv[1]+1); putenv(myenv[numsOfEnv++]); return 1; } if(strcmp(argv[0], "ls") == 0) { argv[argc++] = "--color"; argv[argc] = NULL; } return 0;}
-
使用 lastcode 记录最近一次退出码,打印最近一次退出码之后,需要将其置零,表示echo命令执行成功
-
执行cd命令时,使用系统调用chdir修改当前工作目录,同时需要修改环境变量PWD与OLDPWD的值,为了简单只修改了PWD
- 首先调用getcwd获取当前工作目录,然后使用setenv修改环境变量的值。使用putenv存在风险,因为putenv是浅拷贝,直接让环境变量表存放当前 指针的值,如果后续指针所指向的内容被修改,环境变量的值也对应被修改了;setenv会开辟内存存储当前字符串的内容,然后再让环境变量表存放新开辟内存的地址,这样就保证了不可修改
- 使用myenv存储新增的环境变量,调用strcpy拷贝环境变量的内容,因为myenv只有在进程终止后才被销毁,使用putenv让环境变量表存放当前环境变量的地址就不会有丢失不见的问题
- --color是为了让ls指令展示的内容带颜色
3.5normalExecute
普通命令实质上由命令行解释器创建的子进程通过进程替换去执行,命令行解释器等待子进程
void normalExecute(char* argv[])
{
//创建子进程执行普通命令
pid_t id = fork();
if(id == 0)
{
//子进程执行普通命令
//参数1程序名,参数2命令行参数数组
execvp(argv[0], argv);
exit(EXIT_FAILURE);//替换失败返回错误码
}
else if(id > 0)
{
//父进程阻塞等待
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)//等待成功
{ }
else//等待失败
{ }
}
else
{
perror("fork fail\n");
exit(EXIT_FAILURE);//创建子进程失败
}
}
- 这里错误检查比较简陋
3.6完整实现
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
//使用宏管理数值
#define LEFT "["
#define RIGHT "]"
#define LABEL "#"
#define LEN_MAX 1024
#define ARGC_SIZE 32
#define DELIM " \t"
#define EXIT_SUCCESS 0
#define EXIT_FAILURE 1
//因为只用了一个文件
//全局变量更容易初始化和调用
char commandline[LEN_MAX];//存储用户输入的字符串
char* argv[ARGC_SIZE];//存储命令行参数
int argc = 0;//命令行参数的个数
int lastcode = 0;//进程退出码
char myenv[LEN_MAX][LEN_MAX];//自定义环境变量表
//自定义本地变量表
int numsOfEnv = 0;//新增环境变量的个数
char pwd[LEN_MAX];//当前工作路径
//命令行与用户进行交互
void interact();
//将读取的字符串解析为命令行参数
void splitString(char cline[]);
//执行内建命令
//执行内建命令返回1,执行普通命令返回0
int buildCommand(char* argv[]);
//执行普通命令
void normalExecute(char* argv[]);
int main()
{
while(1)//持续等待用户的命令
{
//1.命令行解释器与用户进行交互
interact();
//2.解析命令行参数
splitString(commandline);
//3.执行内建命令
int ret = buildCommand(argv);
//4.执行普通命令
if(ret == 0) normalExecute(argv);
}
return 0;
}
void interact()
{
//1.打印命令行
getcwd(pwd, sizeof(pwd));
printf(LEFT"%s@%s %s"RIGHT""LABEL" ", getenv("USER"), getenv("HOSTNAME"), pwd);
//等待用户输入
fgets(commandline, sizeof(commandline), stdin);
//fgets读取到n-1个字符、文件末尾或者遇到\n时停止读取
//因为命令行参数一定小于1024个字节,所以所读取的字符串中会多\n,即
//"ls -a -l\n\0",命令行参数不包括\n,所以需要处理\n
commandline[strlen(commandline)-1] = '\0';
}
void splitString(char cline[])
{
int i = 0;
argv[i++] = strtok(cline, DELIM);
while(argv[i++] = strtok(NULL, DELIM));
argc = i -1;
}
int buildCommand(char* argv[])
{
//通过判断执行内建命令
//内建命令实质上是命令行解释器内部的一个函数
if(argc == 2 && strcmp(argv[0], "echo") == 0)
{
if(strcmp(argv[1], "$?") == 0)
{
printf("%d\n", lastcode);
lastcode = 0;
}
else if(*argv[1] == '$')
{
printf("%s\n", getenv(argv[1]+1));
}
else printf("%s\n", argv[1]);
return 1;
}
else if(argc == 2 && strcmp(argv[0], "cd") == 0)
{
chdir(argv[1]);//修改工作目录
// setenv("PWD", argv[1], 1);//修改环境变量,setenv深拷贝,后续不会随着造成argv[1]的更改而更改,但是argv[1]可能是相对路径
getcwd(pwd, sizeof(pwd));
setenv("PWD", pwd, 1);
return 1;
}
else if(argc == 2 && strcmp(argv[0], "export") == 0)
{
//将新增环境变量添加到环境变量表中,这样后续不会随着argv[1]的更改而更改
// myenv[numsOfEnv] = argv[1]+1; 浅拷贝只是拷贝了指针
strcpy(myenv[numsOfEnv], argv[1]+1);
putenv(myenv[numsOfEnv++]);
return 1;
}
if(strcmp(argv[0], "ls") == 0)
{
argv[argc++] = "--color";
argv[argc] = NULL;
}
return 0;
}
void normalExecute(char* argv[])
{
//创建子进程执行普通命令
pid_t id = fork();
if(id == 0)
{
//子进程执行普通命令
//参数1程序名,参数2命令行参数数组
execvp(argv[0], argv);
exit(EXIT_FAILURE);//替换失败返回错误码
}
else if(id > 0)
{
//父进程阻塞等待
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)//等待成功
{ }
else//等待失败
{ }
}
else
{
perror("fork fail\n");
exit(EXIT_FAILURE);//创建子进程败
}
}
4.函数和进程之间的相似性
exec/exit 就像 call/return
一个 C 程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过 call/return 系统进行通信。
这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。
Linux 鼓励将这种应用于程序之内的模式扩展到程序之间。如下图

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