终端里把grep敲成gerp,程序可以猜一个相似单词,却不能据此断定用户想做什么。这个差别很小,也很容易被“智能Shell”四个字盖过去。
这次只做一个窄原型:按空白拆参数,启动一个前台子进程,等待它退出;找不到程序时给出静态词典中的拼写建议。它不支持管道、引号、变量展开、重定向和后台作业。遇到这些语法直接拒绝,避免把半套解析器交给用户误用。
先把Ctrl+C的归属讲清楚
在交互终端中,Ctrl+C通常向前台进程组发送SIGINT。本原型不实现完整作业控制,父Shell与命令仍在同一前台组:父进程忽略SIGINT,子进程在execvp前恢复默认处置。子进程还显式清空信号屏蔽字。
这里有两个不同动作:sigaction设置处置方式,sigprocmask设置哪些信号暂时不递送。SIG_DFL是默认处置,不是“默认屏蔽字”。忽略信号的处置可能跨exec保留,因此不能指望启动新程序后自动恢复。Linux signal(7)
图中waitpid对EINTR重试是一般防御;如果某个信号被忽略,它本身并不必然造成EINTR。完整Shell还需要处理进程组、终端控制权、停止与继续,这里没有实现。
一个可以编译的原型
保存成minish.c:
#define _POSIX_C_SOURCE 200809L #include <errno.h> #include <signal.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> static int min3(int a, int b, int c) { int m = a < b ? a : b; return m < c ? m : c; } /* Optimal string alignment distance: not unrestricted Damerau-Levenshtein. */ static int osa(const char *a, const char *b) { size_t n = strlen(a), m = strlen(b); if (n > 63 || m > 63) return 64; int d[64][64]; for (size_t i = 0; i <= n; ++i) d[i][0] = (int)i; for (size_t j = 0; j <= m; ++j) d[0][j] = (int)j; for (size_t i = 1; i <= n; ++i) { for (size_t j = 1; j <= m; ++j) { d[i][j] = min3(d[i-1][j] + 1, d[i][j-1] + 1, d[i-1][j-1] + (a[i-1] != b[j-1])); if (i > 1 && j > 1 && a[i-1] == b[j-2] && a[i-2] == b[j-1] && d[i-2][j-2] + 1 < d[i][j]) d[i][j] = d[i-2][j-2] + 1; } } return d[n][m]; } static void suggest(const char *cmd) { const char *dict[] = {"grep", "cat", "ls", "pwd", NULL}; for (size_t i = 0; dict[i]; ++i) if (osa(cmd, dict[i]) <= 1) fprintf(stderr, "suggest only: %s\n", dict[i]); } static int disposition(int sig, void (*handler)(int)) { struct sigaction sa; memset(&sa, 0, sizeof sa); sa.sa_handler = handler; sigemptyset(&sa.sa_mask); return sigaction(sig, &sa, NULL); } int main(void) { if (disposition(SIGINT, SIG_IGN) || disposition(SIGQUIT, SIG_IGN)) return 1; char line[4096]; int last = 0; for (;;) { if (isatty(STDIN_FILENO)) { fputs("mini> ", stdout); fflush(stdout); } if (!fgets(line, sizeof line, stdin)) break; if (!strchr(line, '\n') && !feof(stdin)) { int ch; while ((ch = getchar()) != '\n' && ch != EOF) {} fputs("line too long\n", stderr); last = 2; continue; } if (strpbrk(line, "|;&$><\\\"'`()")) { fputs("unsupported syntax\n", stderr); last = 2; continue; } char *argv[64], *save = NULL; size_t n = 0; int overflow = 0; for (char *t = strtok_r(line, " \t\r\n", &save); t; t = strtok_r(NULL, " \t\r\n", &save)) { if (n == 63) { overflow = 1; break; } argv[n++] = t; } if (overflow) { fputs("too many arguments\n", stderr); last = 2; continue; } if (!n) continue; argv[n] = NULL; if (!strcmp(argv[0], "exit")) { if (n != 1) { fputs("exit takes no arguments\n", stderr); last = 2; continue; } return last; } pid_t pid = fork(); if (pid < 0) { perror("fork"); last = 1; continue; } if (!pid) { sigset_t empty; sigemptyset(&empty); if (disposition(SIGINT, SIG_DFL) || disposition(SIGQUIT, SIG_DFL) || sigprocmask(SIG_SETMASK, &empty, NULL)) _exit(126); execvp(argv[0], argv); int e = errno; if (e == ENOENT) suggest(argv[0]); fprintf(stderr, "%s: %s\n", argv[0], strerror(e)); _exit(e == ENOENT ? 127 : 126); } int st; pid_t w; do { w = waitpid(pid, &st, 0); } while (w < 0 && errno == EINTR); if (w < 0) { perror("waitpid"); last = 1; } else if (WIFEXITED(st)) last = WEXITSTATUS(st); else if (WIFSIGNALED(st)) last = 128 + WTERMSIG(st); else last = 1; } return last; }编译命令:
cc -std=c11 -Wall -Wextra -Werror -O2 minish.c -o minish ./minish纠错函数使用的是optimal string alignment distance(OSA),支持受限的相邻换位。它不是完整的Damerau–Levenshtein算法,更不是语义理解。距离近的命令可能完全不同,所以程序只向stderr输出候选,不会执行候选。
fork后的地址空间、文件描述符和信号存在明确继承规则;多线程程序在子进程执行exec前能安全做的操作更受限制。此例是单线程教学程序,不能直接塞入已有多线程终端宿主。fork(2)
用失败输入检查边界
示例在macOS arm64、Apple Clang 21.0.0上以以上严格编译选项通过。运行记录包括:
| 输入或动作 | 观察到的结果 |
|---|---|
true、false、exit | 最后返回1 |
gerp、exit | stderr给出grep建议,返回127 |
echo a|cat | 拒绝未支持语法,返回2 |
| 超长行、过多参数 | 明确拒绝,返回2 |
| PATH中不可执行的测试文件 | 返回126,没有拼写建议 |
| 子进程执行sleep时向测试进程组发SIGINT | 子进程终止,父进程继续读exit,最终返回130 |
这些是本机用户态结果,不是Linux发行版兼容性证明。SIGINT测试使用独立测试进程组,没有给日常终端或其他进程发信号。
还要小心ENOENT:它也可能来自可执行文件依赖的解释器或加载器缺失,不总意味着命令名字拼错。建议前缀因此写成suggest only。静态词典也可能建议一个本机没有安装的程序;若未来扫描PATH,缓存更新、权限与同名程序选择都得重新设计。
这个原型的价值在于能说清自己做了什么:启动命令、等待退出、展示候选。它没有“自愈成功”的承诺,也不应该获得替用户执行修正命令的默认权力。2026年10月9日Anthropic披露的内部评测越界案例可以作为授权边界的背景,不能拿来证明本程序已经安全。原始报告
资料于2026年10月23日核对。封面和流程图为教学示意,图中的终端不是实测截图。