【Linux】自主 Shell

🔥 H.莓飛: 个人主页
✨ 心不畏死,踏屍前行。
📦 代码仓库:cocatrice_csdn_code
📖 博主简介:

概览
- shell、提示符、命令
- 参数表与外部命令
- cd 与 export 等内建命令
- 退出码与 $?
- 环境表、导出与继承
核心知识
一、shell是什么
前面三篇把进程创建、进程等待、程序替换拆开讲过了,fork 造出子进程,exec 把子进程换成新程序,wait 把退出码收回来。这三件事单独看都不复杂,麻烦的是把它们按顺序摆好,并且摆在正确的进程里。命令行解释器正好是这三件事凑在一起的地方,它自己不能换程序,也不能替子进程切目录,还得记住上一个命令跑成什么样。
这一篇从零写一个小 shell,功能不多,能跑通 ls、pwd 这类外部命令,也能处理 cd、export、env、echo、exit 这几个必须自己动手的内建命令。写完之后拿同一串命令和 bash 对一遍,差别在哪里就看得清楚了。

图 1 shell 的死循环骨架
从命令行上收到的每一次回车开始,shell 要做的就是四件事:把提示符打到屏幕上,等用户敲完一行,把那行切成一张参数表,然后执行。执行完之后回到开头接着打提示符,这个过程没有终点,除非读到输入结束或者用户敲了 exit,所以骨架就是一个死循环。
c
int main(void)
{
char line[MAX_LINE];
init_env();
while (1) {
print_prompt();
if (read_line(line, sizeof(line)) != 1)
continue;
if (parse_line(line) == 0)
continue;
if (check_builtin())
continue;
run_external();
}
return 0;
}
四个函数各管一段,读一行、切表、看是不是内建、不是内建就走外部命令那条路。真正干活的只有最后一步,前三步都在准备材料。
执行外部命令的时候,shell 自己留在原地不动,fork 出一个子进程去换程序,父进程用 waitpid 停在原地等。这一点可以在真机上看到,让这个小 shell 跑一条 sleep 2,另开一个会话用 ps 看这两个进程:
bash
$ ps -o pid,ppid,pgid,stat,wchan:20,comm -p 3841,3842
PID PPID PGID STAT WCHAN COMMAND
3841 3839 3839 S do_wait myshell_stage2
3842 3841 3841 S hrtimer_nanosleep sleep
父进程的状态是 S,蹲在内核的 do_wait 上,子进程同样不占 CPU,蹲在 hrtimer_nanosleep 上等定时器。父子两个的进程组号都是 3839,也就是它们和启动它的那个会话在同一组里。这两个读数说明一件事,shell 等命令的时候是睡着的,不烧 CPU。
二、提示符
提示符看起来是三个词拼起来的,用户名@主机名 目录#。用户名从 USER 这个环境变量里拿,主机名从 HOSTNAME 里拿,目录用 getcwd 取当前工作目录,再取最后一个斜杠之后的部分。
c
static const char *user_name(void)
{
char *v = getenv("USER");
return (v == NULL || v[0] == '\0') ? "None" : v;
}
static const char *host_name(void)
{
char *v = getenv("HOSTNAME");
return (v == NULL || v[0] == '\0') ? "None" : v;
}
static const char *last_dir(void)
{
const char *p = cur_dir();
const char *s;
if (strcmp(p, "/") == 0 || strcmp(p, "None") == 0)
return p;
s = strrchr(p, '/');
return (s == NULL) ? p : s + 1;
}
这里藏着一个真机上才看得见的问题,用 ssh 跑非交互命令的时候,环境表里根本没有 HOSTNAME 这一条:
bash
$ env | grep -c '^HOSTNAME='
0
$ bash -lc 'echo $HOSTNAME'
hcss-ecs-4cd1
$ bash -lc 'env | grep -c "^HOSTNAME="'
0
bash 自己的变量 HOSTNAME 是有值的,但它没有导出,所以子进程的 getenv 拿不到。最后拼出来的提示符就成了 cocatrice@None src#,主机名那一格是 None。这不是代码写错了,是提示符依赖了一个通常不存在的环境变量。要主机名的话,用 gethostname 更稳当,同一台机器上它给的是 hcss-ecs-4cd1。
提示符拼好之后必须刷出去,这一点在重定向到文件的时候特别明显。把 fflush(stdout) 那一行删掉,两个版本同时跑一条 sleep 3,标准输入用管道撑着不让它断:
bash
$ ( printf 'sleep 3\n'; sleep 8 ) | ./bin/myshell_withflush > out/with.txt
$ ( printf 'sleep 3\n'; sleep 8 ) | ./bin/myshell_noflush > out/no.txt
第 1 秒:with.txt 22 字节,no.txt 0 字节
第 4 秒:with.txt 82 字节,no.txt 0 字节
带 fflush 的那一份在第 1 秒就已经有内容了,不带的那一份要等到缓冲区攒满或者程序退出才吐出来。终端上两者看起来差不多,重定向到文件就变成一个看得见一个看不见。

图 2 提示符的三样东西
三、读入命令
读一行用 fgets,它读满一行就停,行尾的换行符会一起带进来,所以要把最后那个换行符改成结束符。听起来只有三步,真机上跑起来有四种情况要分开:正常读到一行、读到文件结尾、读到一个空行、读到一行长到装不下。
第一版的读法只有成与不成两种回音,读到结尾就回到循环开头,结果提示符开始疯狂刷屏。只喂一行 ls,两秒钟吐出来 25397770 字节,而 wc -l 数出来只有 2 行,因为那些提示符全挤在同一行里没有换行。把读到的结尾改成跳出循环,刷屏停了,但空行也被当成了收工信号,输完一个空白行 shell 就退出了。
c
/* 三种回音:-1 读到尾巴了,0 是空行或者一行长到装不下,1 是真有一行 */
static int read_line(char *buf, int size)
{
char *nl;
int c;
if (fgets(buf, size, stdin) == NULL)
return -1;
nl = strchr(buf, '\n');
if (nl == NULL && !feof(stdin)) {
while ((c = getchar()) != '\n' && c != EOF)
;
printf("这一行超过 %d 个字节,丢掉了\n", size - 1);
return 0;
}
if (nl != NULL)
*nl = '\0';
return (strlen(buf) == 0) ? 0 : 1;
}
分开之后这一趟就干净了,只喂一行 ls,输出 92 字节,shell 打完收工两个字退出,退出码 0。同样是这一行输入,前面那一版吐了 2500 多万字节。
换行符的处理还有一个边界,行尾没有换行符的时候,bufstrlen(buf) - 1 指向的是最后一个有效字符而不是换行符,那一刀砍下去就把命令的最后一个字符切没了。喂进去 ls -a,切出来的参数表里第二个词变成了一个减号:
bash
$ printf 'ls -a' | ./bin/myshell_stage1
argv[0] = [ls]
argv[1] = [-]
这不是拼错了命令,是行尾少了那个换行符,去掉换行的写法却照砍不误。改过的版本用 strchr 找换行符,找到了才替换成结束符,没找到就原样留着。
还有一件跟缓冲区有关的事,fgets 一次最多读 size - 1 个字节,超出的部分留在输入流里,下一次调用接着读。所以一行 1104 字节的命令会被切成两条:前面 1023 字节当一条命令,剩下的当另一条。修好的版本在发现这一行没读到换行符时把整行剩下的字符读走丢掉,这一行整条丢弃并说一声:
bash
$ python -c "print('echo ' + 'A'*1200)" > in_long.txt
$ printf 'echo 短句\n' >> in_long.txt
这一行超过 1023 个字节,丢掉了
短句

图 3 读一行的三条岔路
四、把一行切成一张表
切参数用的是 strtok,第一次调用传缓冲区,之后每传一个空指针就接着往下切。切出来的每个词是一个字符串,装进一张指针数组,这张表必须在末尾放一个空指针,因为 exec 系列函数就是靠这个空指针来判断参数到哪里结束的。
c
/* 空格和制表符都算分隔,返回切出几个词 */
static int parse_line(char *buf)
{
char *tok;
int n = 0;
memset(g_argv, 0, sizeof(g_argv));
for (tok = strtok(buf, " \t"); tok != NULL && n < MAX_ARG - 1; tok = strtok(NULL, " \t"))
g_argv[n++] = tok;
g_argc = n;
return n;
}
分隔符只写一个空格的时候,制表符就不算分隔。喂一行用制表符隔开的 ls 进去,切出来只有 1 个词,里面还夹着那个制表符:
bash
$ printf 'ls\t-a\t-l\n' | ./bin/myshell_stage1
argv[0] = [ls -a -l]
argc = 1
$ printf 'ls -a -l -n\n' | ./bin/myshell_stage1
argv[0] = [ls] argv[1] = [-a] argv[2] = [-l] argv[3] = [-n]
argc = 4
连续多个空格不算多切,strtok 会把连续的分隔符当成一个。喂四个空格隔开的两个词,切出来是 2 个,不是 5 个。
空行和全空格行要小心,strtok 在全是分隔符的串上返回空指针,如果直接把这个值当成命令名用,argv0 就是个空指针,再往下走一步程序就崩了。修好的版本让 parse_line 返回词的个数,主循环看到 0 就直接跳过。

图 4 切成以空指针收尾的参数表
五、执行外部命令
外部命令没法在 shell 自己身上执行,因为 exec 一执行成功,shell 这个进程就变成那条命令了,命令跑完进程结束,提示符再也不会出现。所以要先 fork,让子进程去换程序,父进程留在原地等它结束。
c
static void run_external(void)
{
pid_t id;
int status = 0;
id = fork();
if (id < 0) {
printf("fork 失败:%s\n", strerror(errno));
g_last = 1;
return;
}
if (id == 0) {
execvp(g_argv[0], g_argv);
printf("myshell: 找不到命令 %s\n", g_argv[0]);
fflush(stdout);
_exit(127);
}
if (waitpid(id, &status, 0) > 0) {
if (WIFEXITED(status))
g_last = WEXITSTATUS(status);
else if (WIFSIGNALED(status))
g_last = 128 + WTERMSIG(status);
else
g_last = 100;
}
}
用 strace 跟着看一遍,一条命令在系统调用层就是三行。第一条 clone 造出子进程,第二条 execve 把子进程换成新程序,第三条 wait4 收回结果:
bash
$ strace -f -e trace=clone,execve,wait4 ./bin/myshell_stage2
clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, ...) = 2976
wait4(2977, <unfinished ...>)
execve("/home/cocatrice/lab15/bin/argprint", ["argprint", "一"], 23 vars) = 0
wait4 resumed [{WIFEXITED(s) && WEXITSTATUS(s) == 0}] = 2977
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_status=0, si_utime=0, si_stime=0} ---
wait4 那一行的 unfinished 和 resumed 两段是同一个调用被打断过一次的痕迹,父进程在等的时候让出了 CPU。想要更细的现场,可以让 gdb 跟着子进程走,断在 exec 上:
bash
$ gdb -q -batch -ex "set follow-fork-mode child" -ex "catch exec" \
-ex "run < out/in_one_cmd.txt" ./bin/myshell_stage2
Attaching after process 3160 fork to child process 3160.
Catchpoint 1 (exec'd /usr/bin/ls), 0x00007ffff7ddc140 in _start () from /lib64/ld-linux-x86-64.so.2
停下来的时候已经在新程序里了,栈顶那个 _start 是动态链接器准备的入口,原来的 myshell 代码一行都不在了。
命令找不到的时候,子进程里 exec 失败会返回,所以需要一个兜底的出口。素材那一版直接在子进程里 exit(1),父进程收回来就是 1,看起来和命令真的失败没区别。bash 在这个场景上给的是 127,改过的版本跟着 127 走,并且在冒号后面把命令名打出来,方便对账。

图 5 执行一条外部命令
六、内建命令
有几条命令没法交给子进程,cd 改的是进程自己的工作目录,子进程 chdir 成功之后,改变的是子进程的记录,子进程一退出就没了,父进程还在原来的目录里。写一个小程序 cd_ext 试一次就很清楚:
bash
$ cd_ext /home
cd_ext: 我这个进程现在在 [/home]
[子进程 4831 退出,退出码 0]
$ /bin/pwd
/home/cocatrice/lab15/src
子进程确实到了 /home,父进程问下来还是原来那个目录。所以 cd 必须在 shell 这个进程里自己做,它是内建命令。同样的道理适用于 export、env、exit,它们改的都是 shell 自己的状态。
c
static int builtin_cd(void)
{
char buf[MAX_LINE];
char old[MAX_LINE];
char *p;
if (g_argc != 2) {
printf("用法:cd 目录\n");
g_last = 1;
return 1;
}
p = getcwd(old, sizeof(old));
if (chdir(g_argv[1]) != 0) {
printf("cd 失败:%s(errno = %d)\n", strerror(errno), errno);
g_last = 1;
return 1;
}
if (getcwd(g_pwd, sizeof(g_pwd)) == NULL) {
g_last = 1;
return 1;
}
if (p != NULL) {
snprintf(buf, sizeof(buf), "OLDPWD=%s", old);
set_env(buf);
}
snprintf(buf, sizeof(buf), "PWD=%s", g_pwd);
set_env(buf);
g_last = 0;
return 1;
}
这里比素材那一版多了两件事,chdir 的返回值要看,切换目录成功之后把 PWD 和 OLDPWD 同步进环境表。素材那一版 chdir 之后不看返回值,就算目录不存在也把退出码置成 0,试一次就露馅:
bash
$ cd /nonexistent
$ echo $?
0
$ /bin/pwd
/home/cocatrice/lab15/src
目录没换,退出码却是 0,脚本里用它做判断就会走错分支。改过之后这一趟给的是 cd 失败:No such file or directory(errno = 2),退出码 1。

图 6 内建命令与外部命令的分界
七、echo $?
上一条命令跑成什么样,shell 得记着,记的地方是一个全局的整数。每次外部命令结束,父进程从 waitpid 拿回来的状态里取出退出码存进去;每次内建命令结束,由它自己把这个数写好。echo $? 做的事就是把存的这个数打出来,然后把清零。
c
static int builtin_echo(void)
{
if (g_argc == 2) {
if (g_argv[1][0] == 36 && g_argv[1][1] == 63) { /* 那个词就是 $? */
printf("%d\n", g_last);
g_last = 0;
} else {
printf("%s\n", g_argv[1]);
g_last = 0;
}
} else {
printf("echo 这里一次只认一个词\n");
g_last = 3;
}
return 1;
}
这里有个容易想偏的地方,$? 不是内核提供的东西,它是 shell 自己的一个变量。子进程的退出码只有父进程通过 wait 能拿到,别的进程拿不到,所以想把上一个命令的结果告诉下一个命令,shell 只能自己记一份。
只认这一个 $- 开头的词,别的照原样打出来。四种写法在真机上的表现是这样的:
bash
$ echo hello
hello
$ echo $?
0
$ echo $PATH
$ echo a b
echo 这里一次只认一个词
$ echo $?
3
第二个 PATH 那一格什么都没有,因为代码只判断了这个词的头两个字符是不是 和 ?,是别的字母就既不展开也不打印。参数给了两个词的情况把退出码置成 3 并提示一句,这样 $? 有迹可循,不会静悄悄地什么也不做。
八、环境变量表
环境变量在这个小程序里有两条路可走,一条是直接改进程自己的 environ,另一条是自己拷一份数组,exec 的时候把这一份传下去,两条路都试过,各自的坑不一样。
先说不拷的那一路,putenv 的参数是指针,它不复制字符串,只是把指针塞进 environ 里。如果传进去的是一个循环里的局部缓冲区,下一条命令一执行,那块内存就被覆盖了,环境表里那一条也跟着变成别的东西:
bash
$ export MARK=hello
$ env_child MARK
env_child: MARK=(没有这个变量)
明明刚导出,子进程却问不到,原因就出在那块缓冲区上,第二条命令把里头的字覆盖掉之后,environ 里那一条已经不是完整的赋值语句了,getenv 按名字去匹配自然匹配不上。要避掉这个坑,set_env 里必须自己做一份拷贝,并且把拷贝的地址交给 putenv。
再说拷一份的那一路,素材那一版在开局把 environ 逐条拷进 g_env,导出新变量往 g_env 后面追加,exec 的时候用 execvpe 把 g_env 传下去。这条路能让子进程看见新导出的变量,实测是成功的:
bash
$ export MARK=hello
$ env_child MARK
env_child: MARK=hello
$ env_child PWD
env_child: PWD=/home/cocatrice/lab15/src
麻烦在于这份拷贝和 environ 是两份东西。putenv 改的是 environ,g_env 里那条不会跟着动。素材那一版的 cur_dir 里就用了 putenv 同步 PWD,结果 cd /home 之后 /bin/pwd 报的是 /home,而 env 内建打出来的 PWD 还是旧目录:
bash
$ cd /home
$ /bin/pwd
/home
$ env | grep PWD
PWD=/home/cocatrice/lab15/src
三处读数对不上,脚本里按环境变量判断目录就会走错。改过的版本改成先改自己的那一份,再把同一份拷贝交给 putenv,两边共用同一份拷贝:
c
static int set_env(const char *item)
{
char name[256];
const char *eq = strchr(item, '=');
size_t nlen;
int i;
if (eq == NULL) {
printf("export 要写成 名字=值 的样子\n");
return -1;
}
nlen = (size_t)(eq - item);
if (nlen >= sizeof(name))
nlen = sizeof(name) - 1;
memcpy(name, item, nlen);
name[nlen] = '\0';
for (i = 0; i < g_envcount; i++) {
if (strncmp(g_env[i], name, nlen) == 0 && g_env[i][nlen] == '=') {
free(g_env[i]);
g_env[i] = strdup(item);
putenv(g_env[i]);
printf("改掉了已有的 %s\n", name);
return 0;
}
}
if (g_envcount >= MAX_ENV - 1) {
printf("环境表只剩 %d 格,%s 塞不进去了\n", MAX_ENV - 1, name);
return -1;
}
g_env[g_envcount] = strdup(item);
putenv(g_env[g_envcount]);
g_envcount++;
g_env[g_envcount] = NULL;
printf("新加了一条 %s,现在 %d 条\n", name, g_envcount);
return 0;
}
这个函数比素材那一版多做三件事,同名变量先找出来覆盖,而不是一路往后追加,实测同名 export 两次之后 env 里 MARK 只有一条,值是两次里后一次的那个。加之前看表满不满,满了就打印还剩几格并且拒绝这一条,不再往下写。每次改动都把同一份拷贝交给 putenv,environ 与 g_env 从此同步。
边界那一段值得单独看一眼,素材那一版没有检查,往 64 格的表里塞 70 条 export,计数会从 23 一路涨到 93,越界写把数组后面的内存改了却没有立刻崩。改过的版本在同样的输入下新加 40 条、拒掉 30 条,从 V59 起提示环境表只剩 63 格:
bash
$ for i in $(seq 0 69); do echo "export V$i=x"; done > in_full.txt
$ ./bin/myshell_fixed < in_full.txt | grep -a '塞不进去' | head -3
环境表只剩 63 格,V59 塞不进去了
环境表只剩 63 格,V60 塞不进去了
环境表只剩 63 格,V61 塞不进去了
cd 之后三处读数也一致了,/bin/pwd 是 /home,env 里 OLDPWD 与 PWD 都是新值,子进程问到的 PWD 也是 /home。

图 7 环境变量表的两条路
九、export 与本地变量的分界
在 bash 里随手赋一个值,子进程是看不见的,加上 export 才看得见。这个小程序里没有变量这个概念,凡是交给 export 的都直接进环境表,所以这个分界要靠使用者自己守。拿同一个脚本在 bash 上跑一遍,两个读数的差别很清楚:
bash
$ cat local_var.sh
#!/bin/bash
LOCAL_ONLY=abc
export EXPORTED_ONLY=xyz
env_child LOCAL_ONLY
env_child EXPORTED_ONLY
$ ./local_var.sh
env_child: LOCAL_ONLY=(没有这个变量)
env_child: EXPORTED_ONLY=xyz
bash 里没导出的那个变量只活在 shell 自己的变量表里,导出的那个才会出现在子进程的环境表里。自己写的这个小 shell 只有环境表这一张表,谈不上本地变量,所以 export 和赋值在这里是同一件事。想要区分,得再加一张本地变量表,查找的时候先看本地再看环境,这个扩展留到后面。
十、完整源码逐段走
把前面这些拼起来是一份 298 行的程序,编译出来 18912 字节,零告警。文件从上到下的分段是清楚的,头部是宏和全局变量,接着是提示符相关的四个小函数,然后是读一行与切表,再然后是环境表那三个函数,接着是五条内建命令,最后是执行外部命令和主循环。
顶上的全局变量一共六个,两个数组分别是参数表和环境表,配套的计数跟上,再加上退出码和一块装当前目录的缓冲:
c
#define MAX_LINE 1024
#define MAX_ARG 64
#define MAX_ENV 64
static char *g_env[MAX_ENV];
static int g_envcount = 0;
static char *g_argv[MAX_ARG];
static int g_argc = 0;
static int g_last = 0;
static char g_pwd[MAX_LINE];
环境表这一段有三件套,init_env 开局把进程自己的 environ 拷进来,顺便把条数打出来,set_env 负责改和加,free_env 在程序退出时把申请过的每一块都还回去:
c
static void init_env(void)
{
int i = 0;
while (environ[i] != NULL && i < MAX_ENV - 1) {
g_env[i] = strdup(environ[i]);
i++;
}
g_env[i] = NULL;
g_envcount = i;
printf("g_env 里拷了 %d 条环境变量\n", g_envcount);
}
开局那句打印在真机上给的是 23 条,和 bash 的 env 数出来一样多,说明拷贝这一步没漏东西。释放这一块交给 atexit 注册,退出路径不论是 break 出去还是 exit 内建直接结束,都会走到:
c
init_env();
atexit(free_env);
主循环是整个程序的调度台,六行判断按顺序排好,从读一行到执行,每一步失败都回到开头。退出码为负表示读到输入结束了,这时候打印收工两个字再跳出循环:
c
int main(void)
{
char line[MAX_LINE];
int n;
init_env();
atexit(free_env);
while (1) {
print_prompt();
n = read_line(line, sizeof(line));
if (n < 0) {
printf("收工\n");
break;
}
if (n == 0)
continue;
if (parse_line(line) == 0)
continue;
if (check_builtin())
continue;
run_external();
}
return 0;
}
内建命令的分发是一个按名字比较的链表,顺序不影响结果,因为这几条名字互不相同。命中一条就执行它并返回 1,主循环看到非零就跳过后面的外部命令那一步:
c
static int check_builtin(void)
{
if (strcmp(g_argv[0], "cd") == 0)
return builtin_cd();
if (strcmp(g_argv[0], "export") == 0)
return builtin_export();
if (strcmp(g_argv[0], "env") == 0)
return builtin_env();
if (strcmp(g_argv[0], "echo") == 0)
return builtin_echo();
if (strcmp(g_argv[0], "exit") == 0)
return builtin_exit();
return 0;
}
整套源码在下面的实例代码一节里完整贴出,编译命令只有一行,不需要额外的库:
bash
$ gcc -o bin/myshell_fixed src/myshell_fixed.c
$ ls -l bin/myshell_fixed
-rwxr-xr-x. 1 cocatrice cocatrice 18912 bin/myshell_fixed
十一、和 bash 的差距
把同一串十二行命令分别喂给这个小 shell 和 bash,输出逐行对下来,绝大多数地方是一样的。这串命令里有列目录、打印、切目录、取退出码、导出变量、问子进程、故意敲一个不存在的命令:
bash
$ cat out/cmp_in.txt
pwd
echo hello
cd /home
pwd
echo $?
exit_code 5
echo $?
export MARK=hello
env_child MARK
nosuchcmd
echo $?
exit
两边最后那个 $? 都是 127,整串输出里只有三处不一样:提示符的形状不一样,命令跑完那一行提示只有自己写的这个版本才有,找不到命令时的说法不一样。bash 给的原话是 /home/cocatrice/lab15/out/cmp_in.txt: line 10: nosuchcmd: command not found,自己写的版本给的是 myshell: 找不到命令 nosuchcmd。
差别不止这三处,功能上的缺口列出来更清楚。这个 shell 不认管道与重定向,>>、| 这些符号会被当成普通参数传给命令;不认通配符,星号原样交给 exec;不认引号与转义,参数全靠空格和制表符切;没有命令历史、没有 Tab 补全、没有作业控制,Ctrl+C 直接把整个 shell 一起带走;变量展开只认 $? 这一个词;错误提示走的是标准输出而不是标准错误,重定向之后会混在一起。
这些缺口里有几个是能补的,补法就在前面的篇章里。重定向要 dup2 把描述符换掉,管道要 pipe 加两次 dup2,前后台要进程组和终端控制,引号与转义要一个真正的词法分析。素材后面那一版已经开始做重定向,把 > 与 < 从参数里挑出来,在 fork 之后、exec 之前打开文件并替换描述符。

图 8 和 bash 的差距
十二、还差什么
写到这里,这个 shell 已经能干活了,敲 ls 能列出文件,敲 cd 能换目录,敲 echo $? 能看到上一条命令的结果,敲 exit 能退出。它离一个能日常使用的 shell 还有距离,缺的东西按补起来的难度排一排,大概是这个顺序。
第一层是输入输出那几件,重定向与管道。这两个都建立在描述符上,fork 之后在子进程里把标准输入或者标准输出换掉,命令本身完全不知道。管道要多一步,先 pipe 拿到一对描述符,左边那条命令的标准输出接到写端,右边那条的标准输入接到读端。
第二层是命令行本身的解析,引号、转义、通配符。现在切参数只按空格和制表符,双引号里的空格会被切开,星号会被原样传给命令。这三件事都需要把这一行先做一遍词法分析,识别出引号的范围,再把通配符交给 glob 去展开。
第三层是交互体验,命令历史、方向键、Tab 补全,还有一条命令跑到一半按 Ctrl+C 只打断命令而不退出 shell。第三条要用到信号和前台进程组,shell 得把自己放到一个单独的进程组里,让终端把 Ctrl+C 送给前台那一组。
第四层是脚本能力,变量赋值与展开、条件判断、循环、函数。这一层做完之后,评测它的方式也跟着变了,因为它已经算得上一门小语言。
实例代码
工程结构
text
lab15/
├── src/ 源码
│ ├── myshell_stage1.c 第一段:提示符、读一行、切表
│ ├── myshell_stage1eof.c 第一段把 continue 换成 break 的对照版
│ ├── myshell_stage2.c 第二段:加上 fork 与 exec 与 waitpid
│ ├── myshell_stage3.c 第三段:加上 cd 与 export 与 env 与 echo 与 exit
│ ├── myshell_material.c 照素材那一版写全的版本,用来对照
│ ├── myshell_noflush.c 删掉 fflush 那一行的对照版
│ ├── myshell_fixed.c 改过边界与错误处理的定稿版,本篇主讲这一份
│ ├── argprint.c 打印自己的参数与环境表条数
│ ├── exit_code.c 按参数返回一个退出码
│ ├── env_child.c 问一个环境变量,顺带打出自己的 pid
│ ├── env_probe.c 打印 environ 指针与环境表条数
│ ├── cd_ext.c 在子进程里 chdir,用来证明 cd 不能外派
│ ├── prompt_test.c 打印提示符那三个要素各自的来路
│ ├── overflow_demo.c 环境表越界写的演示
│ ├── asan_bad.c 故意写坏堆内存,留给地址消毒器
│ └── asan_try.c 地址消毒器的编译试探
├── bin/ 编译出来的可执行文件
└── out/ 输入脚本、输出、日志、编译产物
编译每份源码都是一行 gcc,不需要额外的库:
bash
$ gcc -o ../bin/myshell_fixed myshell_fixed.c
$ gcc -o ../bin/argprint argprint.c
$ gcc -o ../bin/exit_code exit_code.c
$ gcc -o ../bin/env_child env_child.c
$ gcc -o ../bin/env_probe env_probe.c
$ gcc -o ../bin/cd_ext cd_ext.c
$ gcc -o ../bin/prompt_test prompt_test.c
完整代码
下面是定稿的那一份,298 行,编译出来 18912 字节,零告警:
c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <errno.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define MAX_LINE 1024
#define MAX_ARG 64
#define MAX_ENV 64
extern char **environ;
static char *g_env[MAX_ENV];
static int g_envcount = 0;
static char *g_argv[MAX_ARG];
static int g_argc = 0;
static int g_last = 0;
static char g_pwd[MAX_LINE];
static const char *user_name(void)
{
char *v = getenv("USER");
return (v == NULL || v[0] == '\0') ? "None" : v;
}
static const char *host_name(void)
{
char *v = getenv("HOSTNAME");
return (v == NULL || v[0] == '\0') ? "None" : v;
}
static const char *cur_dir(void)
{
if (getcwd(g_pwd, sizeof(g_pwd)) == NULL)
return "None";
return g_pwd;
}
static const char *last_dir(void)
{
const char *p = cur_dir();
const char *s;
if (strcmp(p, "/") == 0 || strcmp(p, "None") == 0)
return p;
s = strrchr(p, '/');
return (s == NULL) ? p : s + 1;
}
static void print_prompt(void)
{
printf("[%s@%s %s]# ", user_name(), host_name(), last_dir());
fflush(stdout);
}
/* 三种回音:-1 读到尾巴了,0 是空行或者一行长到装不下,1 是真有一行 */
static int read_line(char *buf, int size)
{
char *nl;
int c;
if (fgets(buf, size, stdin) == NULL)
return -1;
nl = strchr(buf, '\n');
if (nl == NULL && !feof(stdin)) {
while ((c = getchar()) != '\n' && c != EOF)
;
printf("这一行超过 %d 个字节,丢掉了\n", size - 1);
return 0;
}
if (nl != NULL)
*nl = '\0';
return (strlen(buf) == 0) ? 0 : 1;
}
/* 空格和制表符都算分隔,返回切出几个词 */
static int parse_line(char *buf)
{
char *tok;
int n = 0;
memset(g_argv, 0, sizeof(g_argv));
for (tok = strtok(buf, " \t"); tok != NULL && n < MAX_ARG - 1; tok = strtok(NULL, " \t"))
g_argv[n++] = tok;
g_argc = n;
return n;
}
static void free_env(void)
{
int i;
for (i = 0; i < g_envcount; i++) {
free(g_env[i]);
g_env[i] = NULL;
}
g_envcount = 0;
}
static void init_env(void)
{
int i = 0;
while (environ[i] != NULL && i < MAX_ENV - 1) {
g_env[i] = strdup(environ[i]);
i++;
}
g_env[i] = NULL;
g_envcount = i;
printf("g_env 里拷了 %d 条环境变量\n", g_envcount);
}
/* 同名就改那一条,表满了就报一声,绝不写到数组外面 */
static int set_env(const char *item)
{
char name[256];
const char *eq = strchr(item, '=');
size_t nlen;
int i;
if (eq == NULL) {
printf("export 要写成 名字=值 的样子\n");
return -1;
}
nlen = (size_t)(eq - item);
if (nlen >= sizeof(name))
nlen = sizeof(name) - 1;
memcpy(name, item, nlen);
name[nlen] = '\0';
for (i = 0; i < g_envcount; i++) {
if (strncmp(g_env[i], name, nlen) == 0 && g_env[i][nlen] == '=') {
free(g_env[i]);
g_env[i] = strdup(item);
putenv(g_env[i]);
printf("改掉了已有的 %s\n", name);
return 0;
}
}
if (g_envcount >= MAX_ENV - 1) {
printf("环境表只剩 %d 格,%s 塞不进去了\n", MAX_ENV - 1, name);
return -1;
}
g_env[g_envcount] = strdup(item);
putenv(g_env[g_envcount]);
g_envcount++;
g_env[g_envcount] = NULL;
printf("新加了一条 %s,现在 %d 条\n", name, g_envcount);
return 0;
}
static int builtin_cd(void)
{
char buf[MAX_LINE];
char old[MAX_LINE];
char *p;
if (g_argc != 2) {
printf("用法:cd 目录\n");
g_last = 1;
return 1;
}
p = getcwd(old, sizeof(old));
if (chdir(g_argv[1]) != 0) {
printf("cd 失败:%s(errno = %d)\n", strerror(errno), errno);
g_last = 1;
return 1;
}
if (getcwd(g_pwd, sizeof(g_pwd)) == NULL) {
g_last = 1;
return 1;
}
if (p != NULL) {
snprintf(buf, sizeof(buf), "OLDPWD=%s", old);
set_env(buf);
}
snprintf(buf, sizeof(buf), "PWD=%s", g_pwd);
set_env(buf);
g_last = 0;
return 1;
}
static int builtin_export(void)
{
if (g_argc != 2) {
printf("用法:export 名字=值\n");
g_last = 2;
return 1;
}
g_last = (set_env(g_argv[1]) == 0) ? 0 : 4;
return 1;
}
static int builtin_env(void)
{
int i;
for (i = 0; i < g_envcount; i++)
printf("%s\n", g_env[i]);
g_last = 0;
return 1;
}
static int builtin_echo(void)
{
if (g_argc == 2) {
if (g_argv[1][0] == 36 && g_argv[1][1] == 63) {
printf("%d\n", g_last);
g_last = 0;
} else {
printf("%s\n", g_argv[1]);
g_last = 0;
}
} else {
printf("echo 这里一次只认一个词\n");
g_last = 3;
}
return 1;
}
static int builtin_exit(void)
{
printf("收工\n");
exit(g_last);
}
static int check_builtin(void)
{
if (strcmp(g_argv[0], "cd") == 0)
return builtin_cd();
if (strcmp(g_argv[0], "export") == 0)
return builtin_export();
if (strcmp(g_argv[0], "env") == 0)
return builtin_env();
if (strcmp(g_argv[0], "echo") == 0)
return builtin_echo();
if (strcmp(g_argv[0], "exit") == 0)
return builtin_exit();
return 0;
}
static void run_external(void)
{
pid_t id;
int status = 0;
id = fork();
if (id < 0) {
printf("fork 失败:%s\n", strerror(errno));
g_last = 1;
return;
}
if (id == 0) {
execvp(g_argv[0], g_argv);
printf("myshell: 找不到命令 %s\n", g_argv[0]);
fflush(stdout);
_exit(127);
}
if (waitpid(id, &status, 0) > 0) {
if (WIFEXITED(status))
g_last = WEXITSTATUS(status);
else if (WIFSIGNALED(status))
g_last = 128 + WTERMSIG(status);
else
g_last = 100;
}
}
int main(void)
{
char line[MAX_LINE];
int n;
init_env();
atexit(free_env);
while (1) {
print_prompt();
n = read_line(line, sizeof(line));
if (n < 0) {
printf("收工\n");
break;
}
if (n == 0)
continue;
if (parse_line(line) == 0)
continue;
if (check_builtin())
continue;
run_external();
}
return 0;
}
正常情况
把一串命令写进一个文件,用重定向喂给这个 shell,就是一次完整的会话。开头它会报一句自己拷了多少条环境变量,之后每条命令的输出按顺序排下来,最后一行是收工:
bash
$ cat out/in_normal.txt
pwd
echo hello
ls -l /home/cocatrice/lab15/out/cmp_in.txt
cd /home
pwd
echo $?
cd /home/cocatrice/lab15
export MARK=demo
env_child MARK
env_probe
true
echo $?
nosuchcmd
echo $?
exit
$ ./bin/myshell_fixed < out/in_normal.txt
g_env 里拷了 23 条环境变量
/home/cocatrice/lab15
hello
-rw-rw-r-- 1 cocatrice cocatrice 110 Oct 11 22:15 /home/cocatrice/lab15/out/cmp_in.txt
改掉了已有的 OLDPWD
改掉了已有的 PWD
/home
0
改掉了已有的 OLDPWD
改掉了已有的 PWD
新加了一条 MARK,现在 24 条
env_child: pid = 6926,问的是 [MARK]
env_child: MARK = [demo]
env_child: PWD = [/home/cocatrice/lab15]
env_probe: environ 这个指针的值 = 0x7ffca5c507e8,pid = 6927
env_probe: 环境表里有 24 条
env_probe: 第一条是 [XDG_SESSION_ID=8150]
0
myshell: 找不到命令 nosuchcmd
127
收工
这一串里有几处值得留意,cd 到 /home 之后接着问 $? 得到 0,这一次命令真的成功了。子进程 env_child 问到的 PWD 是新目录,说明环境表那一份也跟着切过去了。env_probe 报出 24 条,比开局的 23 条多一条,多的那条就是刚导出的 MARK。最后两条命令分别是成功的 true 和找不到的 nosuchcmd,退出码 0 与 127 各自对得上。
常见报错
把最容易撞上的几种情况凑成一串,一次看完:
bash
$ cat out/in_err.txt
nosuchcmd
echo $?
cd /nonexistent
echo $?
export FOO
echo $?
echo a b
echo $?
exit
$ ./bin/myshell_fixed < out/in_err.txt
g_env 里拷了 23 条环境变量
myshell: 找不到命令 nosuchcmd
127
cd 失败:No such file or directory(errno = 2)
1
export 要写成 名字=值 的样子
4
echo 这里一次只认一个词
3
收工
四种情况的退出码各自是一个数,命令找不到是 127,目录切不过去是 1。这两个数跟 bash 对得上;export 没写等号给 4,echo 给两个词给 3,这两个是自己定的,约定成什么样都行,关键是别一声不吭地把错咽下去。
边界情况
边界那几处分散在前面的小节里,这里凑一段一次跑完。换成根目录,目录末段的取法走的是那个特判分支;接着送一行全是空格的行和一行带制表符的行,前面那种在旧版本里会让程序崩,现在被挡在切表那一步:
bash
$ cat out/in_edge.txt
cd /
/bin/pwd
ls -l
(一行三个空格)
(一个制表符)
echo 空格行没有把我怎么样
exit
$ ./bin/myshell_fixed < out/in_edge.txt
g_env 里拷了 23 条环境变量
改掉了已有的 OLDPWD
改掉了已有的 PWD
/
total 72
lrwxrwxrwx. 1 root root 7 Jul 26 2024 bin -> usr/bin
dr-xr-xr-x. 5 root root 4096 Dec 6 2025 boot
drwxr-xr-x 7 root root 4096 Jul 26 2024 CloudrResetPwdAgent
...
空格行没有把我怎么样
收工
切到根目录之后提示符那一格取的是第一个分支,因为目录末段没有斜杠可取,特判直接返回全串。做法是拿最后一段之前先比一次,串就是斜杠本身的时候不再往下走 strrchr,不然会取到空的一段。
超长行的处理也在这一层,fgets 一次最多读 1023 个字节,多出来的部分留在输入流里,下一轮会被当成一条新命令。修好的版本发现这一行没读到换行符时把剩下的字符读走丢掉,并且说一声,后面那条短命令照跑。
还有一处是表满,环境表固定在 64 格,最后留一格放结束的空指针,所以最多装 63 条。往 63 格的表里连塞 70 条 export,新加 40 条之后开始拒绝,一共拒掉 30 条:
bash
$ for i in $(seq 0 69); do echo "export V$i=x"; done > out/in_full2.txt
$ ./bin/myshell_fixed < out/in_full2.txt | grep -c '塞不进去了'
30
$ ./bin/myshell_fixed < out/in_full2.txt | grep '塞不进去了' | head -1
环境表只剩 63 格,V40 塞不进去了
$ ./bin/myshell_fixed < out/in_full2.txt | grep '塞不进去了' | tail -1
环境表只剩 63 格,V69 塞不进去了
被拒的每一条都有记录,退出码走 4,调用方知道这一次没成功。
实测数据
下面这张表是各个用例在真机上的读数,测试机是 CentOS 7 的 3.10 内核、gcc 4.8.5:
| 读数项 | 结果 |
|---|---|
| 开局环境表条数 | 23 条,与 bash 的 env 一致 |
| 提示符里的主机名 | None,非交互 ssh 的 HOSTNAME 没有导出 |
| 提示符带 fflush 的第 1 秒 | 22 字节 |
| 提示符不带 fflush 的第 1 秒 | 0 字节 |
| 只喂一行 ls(第一段) | 两秒吐 25397770 字节,wc -l 只有 2 行 |
| 只喂一行 ls(定稿版) | 92 字节,退出码 0 |
| 参数表 ls -a -l -n | argc = 4 |
| 制表符分隔的一行 | argc = 1,制表符留在词里 |
| 超长行 1206 字节 | 整行丢弃并提示,后面的短命令照跑 |
| 命令找不到的退出码 | 127 |
| ls /root 的退出码 | 2 |
| cd 到不存在的目录(定稿版) | errno = 2,退出码 1 |
| cd 到不存在的目录(素材版) | 目录没换,退出码 0 |
| 环境表塞 70 条 export | 新加 40 条,拒掉 30 条,首拒 V40 |
| 同名 export 两次 | env 里只剩一条,值是后一次那个 |
| 改后版本的 valgrind | in use at exit 0 bytes in 0 blocks |
| 素材版本的 valgrind | in use at exit 2,148 bytes in 25 blocks,definitely lost 0 |
| 全局数组越界写给 valgrind 看 | Invalid write 0 行,ERROR SUMMARY 0 errors |
| 越界写之后隔壁缓冲的头八个字节 | 90 c0 0d 01 00 00 00 00 |
| 结构体里三个成员的地址 | &g.count = 0x601080,&g.env = 0x601088,&g.guard = 0x6010a8 |
| guard 与 env 的间距 | 32 字节,正好是那个四格指针数组的大小 |
| 地址消毒器 | 用不了,链接期报 cannot find /usr/lib64/libasan.so.0.0.0 |
| bash 对照那一串十二行命令 | 输出几乎一致,最后那个 $? 两边都是 127 |
实例
拿这个 shell 自己编一个小程序并跑起来,从切目录一路做到取退出码,一条外部命令接一条外部命令:
bash
$ cat out/in_build.txt
cd /home/cocatrice/lab15/out
gcc -o hello hello.c
ls -l hello
./hello
echo $?
exit
$ ./bin/myshell_fixed < out/in_build.txt
g_env 里拷了 23 条环境变量
改掉了已有的 OLDPWD
改掉了已有的 PWD
-rwxrwxr-x 1 cocatrice cocatrice 8360 Oct 11 22:20 hello
你好,这里是被 shell 拉起来的程序
7
收工
一句一句对下来,切目录那一步换了 shell 自己的工作目录,所以后面 gcc 生成的 hello 落在 out 目录里。ls 看到的是刚编出来的那个文件,8360 字节。跑起来打印了程序里那句话,最后 echo $? 打出 7,就是小程序的 return 值。整条链路走通,说明这个 shell 已经能当命令的搬运工用了。
踩坑点
- 去换行的时候直接砍最后一个字符,行尾没有换行符的那一行会少掉最后一个字母,ls -a 变成只有一个减号。换成 strchr 找换行符,找到才替换。
- 读到文件结尾还用 continue 回到循环开头,提示符会疯狂刷屏,只喂一行 ls 两秒钟吐了 25397770 字节,wc -l 却只有 2 行。
- 把结尾当成空行处理也不行,输入一个空行 shell 就退出了。读一行要分成读到尾巴、空行、真有一行三种回音。
- 一行超过 1023 字节时,fgets 把剩下的留在输入流里,下一轮会当成一条新命令执行。要么读干净丢掉,要么加一块更大的缓冲。
- strtok 的分隔符表里只写空格,制表符就不算分隔,整行会当成一个词。分隔表写成两个字符就够了。
- 全是空格的行切出来一个词都没有,g_argv0 是空指针,拿它去 strcmp 或者 exec 就会崩。切完先看词的个数。
- 参数表末尾必须放一个空指针,exec 是靠它判断参数到哪里结束的。少放一个,内核会一路读下去,读到什么完全看运气。
- 子进程里 exec 失败之后要 _exit 而不是 exit,exit 会把从父进程继承来的标准输入输出缓冲区再刷一遍,同一句话打两遍。
- 子进程 exec 失败时的退出码定成 127,和 bash 对齐。定成 1 的话,命令不存在和执行失败在返回值上分不出来。
- cd 交给子进程去做等于白做,子进程 chdir 成功之后自己退出了,父进程还在原来的目录里。cd 与 export 与 exit 只能自己动手。
- chdir 的返回值不看,目录切不过去也把退出码置成 0,脚本里按这个数做判断就会走错分支。
- putenv 存的是指针不是内容,把一个循环里的局部缓冲传进去,下一条命令一执行,那块内存被覆盖,环境表里那一条也跟着坏掉,export 过的变量马上问不到。
- 自己拷一份 g_env 又把 putenv 当成同步手段,两份数据会分家,cd 之后 env 里打的 PWD 还是旧目录,子进程问到的也是旧的。
- 环境表没有边界检查时越界写不会立刻崩,64 格的表塞 70 条,计数能涨到 93,坏的是数组后面的内存。
- 全局数组越界写这件事,valgrind 报 0 errors,它只给堆上申请的内存设红区,数据段里的越界它不管。要证明越界,得把两张数组放进同一个结构体,再用 gdb 看地址。
- gdb 里没加 -g 编译的程序,类型信息不全,同一个表达式能给出莫名其妙的结果,sizeof 一个结构体给出 4 就是这么来的。
- 地址消毒器在这台机器上装不上,链接期报 cannot find /usr/lib64/libasan.so.0.0.0,只能退回去用 valgrind 加肉眼读数。
- 提示符拼好之后不 fflush,重定向到文件的时候什么都看不到,终端上看起来却是正常的。输出要落到文件或者管道里,这一行不能省。
- 非交互 ssh 会话里 HOSTNAME 不在环境表里,getenv 拿不到,提示符主机名那一格会显示 None。要主机名用 gethostname 更稳当。
- 没有前台进程组,跑一条会一直输出的命令,按 Ctrl+C 会把整个 shell 一起带走,命令和解释器同时结束。
本篇总结(模拟面试问题)
问:自己写一个 shell,最少需要哪几件事?
核心要点:一个循环把提示符、读一行、切参数、执行串起来。外部命令要靠 fork 加 exec 加 wait 三件事配合,内建命令在 shell 自己的进程里做。参数表必须以空指针收尾,退出码自己记一份,这两条决定了程序能不能跑对其他命令。
问:为什么 cd 必须是内建命令?
核心要点:chdir 改的是调用进程自己的工作目录记录。交给子进程执行,改变的只是子进程那一份,子进程退出之后什么都没留下。export、exit 这些改的也是 shell 自己的状态,同样只能自己做。
问:$? 是哪来的?
核心要点:它是 shell 自己的一个变量,不是内核提供的。子进程的退出码只由父进程通过 wait 拿得到,别的进程拿不到,所以 shell 收到之后存在自己的一个整数里,echo $? 读它的时候把它置回 0。
问:putenv 有什么坑?
核心要点:它只存指针,不复制字符串。参数必须是一块生命周期足够长的内存,否则那块内存被覆盖之后环境表里那条就坏了。同时它改的是进程自己的 environ,如果代码里另外维护了一份拷贝,两份就会分家。
问:环境变量有哪几种做法,各自的代价是什么?
核心要点:一种直接改进程自己的 environ,改动少但要求传进去的字符串一直有效;另一种自己拷一份数组,exec 的时候通过带 e 的接口传下去,可控但要自己维护同步与边界。写 shell 的时候两条路都得想清楚哪一份是准的。
问:参数表为什么必须以空指针收尾?
核心要点:exec 系列接口拿到的只是一个指针,数组长度不跟着传,判断结束只能靠末尾那个空指针。少一个空指针,内核会一直读下去。同理环境表也要以空指针收尾。
问:怎么验证一个越界写真的发生了?
核心要点:valgrind 只管堆上申请的内存,数据段里的全局数组越界它报不出来。可行的办法是把相关的数组放进同一个结构体,布局就固定了,再用 gdb 打出各成员的地址算出间距,越界那一刻写进去的内容在隔壁缓冲里能看见。
参考
- waitpid 的说明:退出状态怎么拆成退出码与信号编号。
- exec 系列接口的说明:带 p 的查 PATH,带 e 的自带环境表,末尾都要空指针。
- putenv 与 setenv 的说明:一个存指针,一个拷贝字符串,用的时候要分清。
- chdir 的说明:改的是调用进程自己的工作目录。
- getcwd 的说明:拿当前工作目录的绝对路径。
- strtok 的说明:分隔符表是字符串,可以写多个字符。
- fgets 的说明:读满或者读到换行就停,剩下的留在流里。
- GNU C 库手册里的环境变量一章:environ 的布局与几条改环境变量的接口。
- bash 手册里的内建命令一章:cd、export、exit 这些在 bash 里的完整行为。
- POSIX 里关于 shell 命令语言的一节:分词、引号、重定向的规范写法。
- POSIX 里关于 wait 的一节:状态宏的取值规则。
- GCC 关于插桩选项的一节:地址消毒器怎么开,需要哪个运行库。
- valgrind 的内存检查手册:它检查的是哪些内存,哪些地方它管不到。