马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
所谓 shell,就是外壳解释程序。我们日常看到的命令行提示符、远程登录的 SSH,本质都是 shell。本文带你在 CentOS 上从零写一个简化版 shell,模拟 bash 的核心行为,目标是搞清楚命令行是怎么被一行行读进来、拆开、 fork 出去执行的。过程中会用到进程替换、进程等待、进程退出码这些前面的基础知识,它们是写 shell 的铺垫而非主体。
一、命令行提示符部分
先看一眼平时看到的提示符长什么样:_lazy@VM-12-14-centos ~。开头的 _lazy 是当前用户名,@ 后面是主机名,波浪号代表当前所处目录。我们的目标就是把这副样子原样复刻出来。
从哪儿拿用户名、主机名和当前目录
环境变量这时候就该登场了。输入 env,能看到一长串环境变量,其中 HOSTNAME、PWD、USER 分别对应主机名、当前路径和当前用户名。
取这三种途径:environ 全局环境变量表、命令行参数表、getenv。这里用 getenv,比二级指针 environ 更好读。三个小函数这样写:
#define SIZE 512
char* GetUser()
{
char* user = getenv("USER");
if (user == NULL) return NULL;
return user;
}
char* Gethost()
{
char* host = getenv("HOSTNAME");
if (host == NULL) return NULL;
return host;
}
char* Getcwd()
{
char* cwd = getenv("PWD");
if (cwd == NULL) return NULL;
return cwd;
}
用 snprintf 拼出一整串
由于提示符后面还要接输入,不能拿换行当结束,用 snprintf 先把三段内容拼进一个字符数组,再打印这个数组。完整输出函数:
void OutputBash()
{
char line[SIZE];
char* username = GetUser();
char* hostname = Gethost();
char* cwd = Getcwd();
snprintf(line, sizeof(line), "[%s@%s %s]>", username, hostname, cwd);
printf("%s", line);
fflush(stdout);
}
这里的 fflush(stdout) 不能省:提示符要等命令执行完才换行,不强制刷新,屏幕就会卡住不动。
如果 man 手册不全,补一下即可:sudo yum install man-pages。
只显示当前目录名
上面把整个路径都打出来了,不够像。理想效果只显示最后一段目录名。办法是把指针移到最后一个斜杠处,用宏比用函数省事(函数要传二级指针):
#define SkipPath(p) do { p += (strlen(p) - 1); while (*p != '/') p--; } while (0)
但要兼容根目录的单个斜杠,条件判断写成这样更稳:
snprintf(line, sizeof(line), "[%s@%s %s]>", username, hostname, strlen(cwd) == 1 ? "/" : cwd + 1);
二、获取用户输入的命令行
接下来要接用户敲的命令。用 scanf 行不行?想想 ls -l -n -a 这种带参数的——scanf 遇空格或换行就断,只能拿到 ls 一个词。所以这里改用 fgets(gets 也行,但后面要接文件操作,fgets 更适合当缓冲区):
int GetUserCommand(char* usercommand, size_t n)
{
char* s = fgets(usercommand, n, stdin);
if (s == NULL) return -1;
return strlen(s);
}
这段代码有个隐患,第四部分会讲到。
三、拆分命令行参数
拿到整行命令不能直接执行。C 库函数 strtok 负责切分:第一个参数是待切字符串,第二个是分隔符集,切一次之后把第一个参数置 NULL 就能继续切。切完的结果存进数组,供后面的进程替换使用。
#define SEP " "
char* gArgv[SIZE];
void SplitCommand(char* usercommand)
{
gArgv[0] = strtok(usercommand, SEP);
int index = 1;
while ((gArgv[index++] = strtok(NULL, SEP)));
}
两个值得留意的细节:分隔符是双引号包起来的空格字符串,注意它是 const char* 而不是单个字符,别写成单引号;另外 strtok 切完返回 NULL 正好可以当数组结束标志,循环写得很省。
四、执行命令
先不管内建命令,直接执行试试。核心是进程替换:
void ExcuteCommand()
{
pid_t id = fork();
if (id < 0) Die();
else if (id == 0)
{
// child
execvp(gArgv[0], gArgv);
exit(1);
}
else
{
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
lastcode = WEXITSTATUS(status);
if (lastcode != 0) printf("%s:%s:%d\n", gArgv[0], strerror(lastcode), lastcode);
}
}
}
子进程 execvp 找不到命令就 exit(1);父进程 waitpid 收尸,用 WEXITSTATUS 取退出码存进 lastcode,非 0 就报一行错。代码里的比较运算符是小于与大于两个符号,写在 HTML 里必须转义成 < 与 >,否则浏览器会把它当成标签。
到此还跑不起来,因为 fgets 会把敲的那一下回车也读进来,命令末尾带着换行,execvp 自然失败。把末尾回车清掉就行,宏定义 ZERO 完成这一步。
回头看第二步 fgets 的隐患:如果用户输入超过缓冲区长度,剩下的内容会滞留在 stdin,下一次读取就会拼上一段脏数据。真要做得稳,得处理"读满但未读完"的情况。
五、判断内建命令
cd、echo 这类内建命令只能在父进程里做,不能 fork 子进程去执行。判断逻辑简单粗暴,strcmp 比字符串即可:
void Cd()
{
const char* path = gArgv[1];
if (path == NULL) path = Gethost();
// path 一定存在
chdir(path);
// 刷新环境变量
char temp[SIZE * 2];
getcwd(temp, sizeof(temp));
snprintf(cwd, sizeof(cwd), "PWD=%s", temp);
putenv(cwd);
}
int IsInorder()
{
int yes = 0;
const char* enter_cmd = gArgv[0];
if (strcmp(enter_cmd, "cd") == 0)
{
yes = 1;
Cd();
}
else if (strcmp(enter_cmd, "echo") == 0 && strcmp(gArgv[1], "$?") == 0)
{
yes = 1;
printf("%d\n", lastcode);
lastcode = 0;
}
return yes;
}
以 cd 为例:判断命中内建之后,在 Cd 里用 chdir 切当前工作目录,再用 getcwd 拿到新路径,拼成 PWD=... 后 putenv 写回环境变量。这样下一次提示符才能显示正确路径。
echo $? 是另一类内建:它要读父进程自己存的 lastcode,子进程里读不到父进程的上次退出码,所以也只能父进程执行。执行完顺手把 lastcode 清零,下一次才准。
六、一个完整 shell 该补齐什么
到这里,一个能显示提示符、能读命令、能切参数、能执行外部程序、能处理 cd 与 echo $? 的迷你 shell 就成型了。往真实 bash 再走,还差几块:
- 历史与行编辑:上下键翻历史、Tab 补全,需要 readline 库。
- 管道与重定向:解析
|、>、<,配合 dup2 做重定向。
- 环境变量展开:
$VAR 的替换与展开。
- 信号处理:Ctrl+C 不该让 shell 自己死掉,要交给子进程,用 signal 加 job control。
- 读满残留:处理第二步提到的输入缓冲问题。
每补一块,都对应一类系统调用。写 shell 最大的收获不是造出一个 bash,而是把"一条命令从敲下到跑起来"这件事彻底拆开看了一遍。
qidao123.com企服评测-应用市场 编辑提示
本文按命令行提示符、读取用户输入、拆分参数、进程替换执行、内建命令判断五个步骤,还原一个简化版 Linux shell 的实现过程,并说明 fgets 残留输入、回车未清、cd 与 echo 星号必须父进程执行等具体问题处。文中代码为教学示例,仅建议在自有测试环境的实验中运行,勿在生产机器上覆盖系统 shell;涉及 chdir、putenv 这类改动进程状态的调用,请先理解其作用范围再动手。 |