在这个文章中,我们将用C语言从零开始写一个属于自己的简单的Linux Shell(命令解释器)。对于系统程序员、内核爱好者或任何想深入掌握计算机底层的人来说,自己动手写一个 Shell 的意义,绝不仅仅是为了重复造一个 bash 的轮子。Shell的真正价值在于,它是连接“用户态纯文本”与“内核态系统调用”之间最完美的桥梁。也许这个Shell的功能远远不如工业级的bash,但是在实现Shell的过程中,我们会学到很多很多。
前言
对于每一位系统程序员或 Linux 爱好者来说,终端都是一个神奇的“黑盒”。我们每天都会输入无数像这样的命令:
1 | ls -l | grep ".c" > output.txt && cat output.txt |
键盘上的回车键就像一道咒语,按下它的瞬间,屏幕闪烁,复杂的流水线、重定向和逻辑判断便在顷刻间完成。你是否曾想过:
- 终端如何解析这串冗长而复杂的符号?
|(流水线)背后的内核级数据流是什么?&&和||如何在进程级实现条件短路?
学习最有效的方法就是自己动手。本系列博客将从零开始,指导你使用纯 C 语言实现一个现代简易命令解释器。
一些简单的准备工作:磨刀不误砍柴工
由于我们将大量使用 Linux 系统调用(如 fork、pipe 等),因此原生 Linux 环境必不可少(我使用的是 Arch Linux,本系列中的所有代码均已在 Arch Linux 上经过测试)。如果您使用的是 Windows,请务必配置好 WSL2。此外,您还需要 gcc 编译器和 make 构建工具。除了 gcc 和 make,我们还将使用符合 POSIX.1-2008 标准的 API(例如 getline),因此原生 Linux 环境能让您免去处理跨平台兼容性问题的诸多麻烦。代码本身采用纯 C 语言编写,您可以使用 Vim、Neovim 或 VS Code 等经典编辑器进行开发。
核心本质:Shell 本质上就是一个永不停息的循环。
剥离复杂的语法解析和进程控制后,任何 shell 的底层框架都非常简单。它的本质可以概括为一个无限循环(通常称为 REPL:读取-求值-打印循环)。
在我们即将实现的 shell 中,主函数中的这个循环如下所示:
1 | while (1) { |
架构设计:四大模块之间明确的分工
为了确保系统稳健且易于维护,我采用了 KISS 原则(保持简单)作为核心指导方针,将复杂的命令处理流水线解耦为四个职责单一的独立经典模块:读取器、词法分析器、语法分析器和执行器。该项目的核心架构设计如下所示:
1 | [User Input] |
本系列文章旨在通过模块化设计,帮助读者理解 Linux 的底层机制。受限于博客篇幅,文章将重点讲解核心逻辑与代码演变过程。如需获取包含详细注释的完整代码,请访问项目的 GitHub 仓库:https://github.com/Alexander-Hou/hsh 。建议您在阅读各章节内容时,同步查阅该仓库中的相关代码。
第一章:读取器——捕获字符串
虽然reader.c(读取器)只有几十行代码,但它是 Shell 操作的“基础”。看似简单的读取操作,实际上需要系统级编程中考虑诸多细节。本章将阐述我编写这个读取器的过程。
选择 getline 的原因
读取器的主要功能是完整捕获用户输入的整行命令。初学 C 语言时,我们都用过 scanf;但在编写成熟的 Shell 脚本时,scanf 却往往令人头疼,因为它会将空格视为输入结束的标志。这意味着当我们输入 ls -l 时,它只会读取到 “ls”。此外,老旧的 gets 函数无法限制输入长度,容易引发缓冲区溢出——这可是众所周知的安全漏洞隐患。显然,这两个函数都不适合我们的 Shell。
为了完整捕获用户输入的整行命令(包括空格和制表符等特殊字符)且不限制命令长度,我们必须使用 POSIX 标准中的现代工具:getline。让我们来看看 getline 的函数原型:
1 |
|
它的精妙之处在于动态调整内存大小。如果传递一个值为 NULL 的指针,它会在底层使用 malloc 自动分配内存;如果用户输入的命令异常长,它也会自动调用 realloc 来调整内存大小。这意味着我们的 shell 从本质上避免了“固定缓冲区溢出”的问题。
这样,我们就可以编写 read_line 函数的初始版本了。
1 | char *read_line(void) |
这就是 getline 的神奇之处:无需编写复杂的循环来重新配置缓冲区,只需一行代码,它就能在堆上分配内存并容纳整行输入。如果理想情况总是完美无缺,我们的读取器写到这里也就大功告成了。但作为系统程序员,我们必须面对残酷的现实——万一用户不按常理出牌呢?
处理 Ctrl+D (EOF)
第一个实际挑战来自用户的退出意图。当用户在终端按下 Ctrl+D 时,他们并未输入任何字符,而是关闭了标准输入流(即触发了 EOF)。在之前的极简代码中,getline 遇到 EOF 时会返回 -1;然而,我们的代码对此毫无准备,会直接向外部解析器返回一个空的 line 指针,这必然导致段错误(segmentation fault)。因此,我们增加了第一道防线:
1 | char *read_line(void) |
此外,我来解释一下 isatty 的作用。它用于判断当前的标准输入是否为真正的交互式终端。如果用户在终端上手动输入命令并按下 Ctrl+D 退出,打印换行符(\n)能保持界面整洁;反之,如果我们的 shell 正在执行脚本文件(即重定向输入,如 ./hsh < script.sh),isatty 的结果将为假(false),因此我们无需向标准输出添加多余的换行符。这体现了软件设计中的考究之处。
经过这一演进,我们的 shell 现已具备了“优雅退出”的能力。当检测到 feof(stdin) 时,我们会释放内存并返回 NULL;随后,只要返回值是 NULL,外层主循环便能干净利落地结束程序。
处理 Ctrl+C
接下来,我们将面对最棘手的对手——Linux 信号中断。试想这样一个场景:我们的 shell 正停留在 getline 处等待用户输入。突然,用户意识到命令输错了,于是手忙脚乱地按下了 Ctrl+C。此时,操作系统内核会向我们的 shell 发送一个 SIGINT 信号。默认情况下,该信号会导致进程终止。但我们要打造的是一个“宽宏大量”的 shell,希望它能像暴风雨中坚韧的小草一样——虽受干扰,却不折断。当信号触发并被捕获(或者系统调用被中断)时,getline 会无奈地返回 -1。不过此时,feof 的值为假,且全局变量 errno 会被标记为特定标志:EINTR(表示系统调用被中断)。为了在中断后恢复运行,我们必须将整个读取逻辑包裹在一个 while(1) 循环中,并清除错误状态:
1 | char *read_line(void) |
从这三个版本的演变可以看出,在编写生产级系统级 C 代码时,真正的核心逻辑往往只占代码总量的 20%,而其余 80% 的精力都花在了应对各种棘手的异常、信号及内存边界问题上。正是这 80% 的“防御性代码”,才会使我们的命令行解释器更加健壮。
第二章:词法分析器——词法分析与状态机
编写好读取器之后,我们已经能够安全且完整地将用户输入的一行内容捕获到堆内存中。然而,对于操作系统而言,这串字符仅仅是“死板的纯文本”。在本章中,我们将化身为“字符串拆解大师”,深入剖析命令解释器的第二个核心模块——lexer.c(词法分析器)。我们将展示如何利用有限状态机(FSM)的概念,将杂乱无章的原始字符流精确地分解为具有独立语义的“记号”(token)。为了避免读者在面对具体的解析状态转换时迷失方向,我们将首先从 lexer.h 入手,剖析整个词法分析过程的“顶层设计”与“骨架结构”。
引言:什么是词法分析?
当我们输入像这样的命令时:
1 | ls -l | grep "txt" && echo 'done' |
人的眼睛会自动将 ls 识别为命令,-l 识别为参数,| 识别为管道,并将 "txt" 识别为一个由双引号括起来的整体。但在计算机眼中,它看到的只是一串简单、连续的字符:
['l', 's', ' ', '-', 'l', ' ', '|', ' ', ...]
词法分析器的核心职责是从左向右扫描该数组——就像切香肠一样——滤除无意义的空格,并将字符重新组合成一系列带有标记的语义单元(token)。
经过 lexer.c 处理后,上述命令将被转换为一个记号(token)数组:
1 | [TOKEN_WORD("ls"), TOKEN_WORD("-l"), TOKEN_PIPE, TOKEN_WORD("txt"), TOKEN_AND_IF, TOKEN_WORD("done")] |
核心数据结构
在编写具体的分割代码之前,我们需要确立“游戏规则”。请打开头文件 lexer.h,其中定义了词法分析器的三大支柱:Token 类型、Token 实体,以及支撑状态机运行的上下文。
首先,我们需要为 Shell 中可能出现的所有基本符号定义“身份证类型”。我使用了枚举(enumeration)来定义 TokenType。
1 | enum TokenType |
在定义好类型之后,下一步是将“类型”与“原始字符串内容”封装在一个结构体中,从而定义 Token 的载体:struct Token。
1 | struct Token |
许多编写词法分析代码的初学者往往使用极其冗长的函数参数,需要不断传递 char buf、int *pos 和 int *count。这种做法不仅容易出错,还会导致代码显得杂乱难看。为了保持代码的优雅与简洁,我将词法分析过程中需要维护的所有局部变量封装进了一个名为 LexContext 的上下文结构体中。这样一来,在编写处理各种特殊符号的函数时,只需传递一个上下文指针即可。这种针对 LexContext 的面向对象设计将极大地简化后续代码的编写工作。
1 | struct LexContext |
有限状态机(FSM)简介
在深入研究代码之前,我们先来探讨一下编译器理论中一个著名的数学模型——有限状态机(FSM)。这个概念听起来很高深,但实际上它在我们的生活中随处可见。以我们每天都会用到的自动售货机为例:它最初处于“待机/未投币”状态;当你投入硬币时,它会转变为“已投币/等待选择”状态;如果你按下商品按钮且余额充足,它就会出货并返回“待机”状态。
在编译器理论的词法分析阶段,状态机发挥着同样的作用。它的核心由三个要素构成:第一是“状态”——即程序的当前状况(例如,正在读取普通文本,还是读取双引号内的文本);第二是“输入”——即扫描指针当前正在处理的具体字符(例如空格、字母或反斜杠);第三是“转换”——即当程序处于某种“状态”并遇到某种“输入”时,应切换到哪种“新状态”。
那么,为什么 Shell 必须使用状态机呢?如果只是处理由空格分隔的单词,我们完全可以使用 C 语言内置的 strtok 函数轻松解决。但 Shell 的复杂之处在于,某些特殊字符可能会改变其他字符的处理方式,而这一切都取决于当前的“上下文(状态)”。让我们来看一个典型的例子。假设用户输入了以下命令:
1 | echo "hello | world" |
如果没有状态机,我们的程序会从左到右扫描。当它看到管道符 | 时,会兴奋地喊道:“哈哈!我找到一个管道符(TOKEN_PIPE)!”——但这完全错误!因为这个管道符被双引号包裹着,它实际上变成了一个普通的字符串(TOKEN_WORD)。
为了让程序具备这种“上下文感知”能力,我们需要在 lexer.h 中为解析器定义 5 种状态:
1 | enum LexState |
基于编译器原理的这一理论框架,字符解析规则变得一目了然:处于 STATE_NORMAL 状态时,遇到 | 会被视为管道元字符;处于 STATE_IN_DOUBLE_QUOTE 状态时,遇到 | 则仅被视为普通英文字符并进行收集;处于 STATE_NORMAL 状态时,遇到 " 不会被收集,而是触发状态平滑转换为 STATE_IN_DOUBLE_QUOTE。这种由输入字符驱动状态转换的设计,不仅使代码极其优雅,而且在逻辑上无懈可击——这正是编译器原理中自动机模型的魅力所在。
第三章:词法分析器——状态机的核心实现
在上一章中,我们确定了统一的上下文结构体 LexContext 以及五种核心的解析状态。本章我们将深入 lexer.c 的核心实现,详细拆解 tokenize() 函数是如何通过有限状态机(FSM)处理各种复杂的边界条件,并介绍如何利用“原位修改(In-place Modification)”的双指针技巧,高效清洗整条命令行字符串。
tokenize 主循环的架构
tokenize() 函数的本质是一个由当前状态(ctx.state)驱动的选择结构。在展开庞大的具体分支前,我们可以先观察其底层骨架:
1 | Token *tokenize(char *buf) |
在每一轮循环中,主循环只做一件事:观察当前扫描到的字符 ctx.buf[ctx.i]。但同样的字符在不同的状态(ctx.state)下,其解析规则是完全不同的。状态机通过这种分层判断,将原本交织在一起的符号解析逻辑进行了解耦。
双指针实现原理:i 与 j 的原位清洗
在处理命令行时,用户经常会输入引号或转义反斜杠(例如 echo "hello \"world\"")。对于 Shell 而言,外部的双引号、用于转义的反斜杠都属于“语法外壳”,我们最终需要的 TOKEN_WORD 纯文本应当是 hello "world"。
为了过滤掉这些多余的字符,常规做法是开辟一块临时的堆内存来存放清洗后的内容,但这样会频繁引发内存分配。在 LexContext 中,我们通过引入两个整型指针 ctx.i 和 ctx.j,实现了在原始缓冲区上直接修改字符串。i 是扫描指针(Scan Pointer),负责从左向右遍历原始输入。而j 是填充指针(Padding Pointer),负责指示清洗后的有效文本在缓冲区中的实际写入位置。关键的字符收集函数 accumulate_char 实现如下:
1 | static void accumulate_char(LexContext *ctx) |
当我们在 STATE_NORMAL(正常状态)下遇到双引号 " 时,处理逻辑是:
1 | else if (ctx->buf[ctx->i] == '\"') |
通过让 i 步进而 j 保持不变,双引号字符在物理上直接被过滤掉了。随后状态切换到 STATE_IN_DOUBLE_QUOTE,后续的有效字符会继续调用 accumulate_char 写入 buf[j] 位置。这种同步覆盖的机制,让我们零额外空间开销地完成了字符串的“脱壳”。
状态转移与分支处理
1. 正常状态下的符号分流
在 STATE_NORMAL 状态下,词法分析器作为整个解析流程的入口,主要负责对空白字符以及各类运算符(元字符)进行匹配和拦截:
空白字符断句:
当检测到空白字符(如空格、制表符)且当前处于记号收集状态(
is_accumulate为真)时,调用word_end_process进行封口截断。1
2
3
4
5
6
7
8else if (isspace((unsigned char)ctx.buf[ctx.i]))
{
if (ctx.is_accumulate)
{
word_end_process(&ctx); // Seal and generate TOKEN_WORD.
}
ctx.i++;
}word_end_process会在buf[j]处写入\0,并通过strdup把自ctx.token_start开始的、清洗干净的文本拷贝出来,打包成 Token 放入数组。状态触发器:
当遇到
'、"、\时,程序不进行字符收集,而是直接转换ctx.state并递增扫描指针i,实现状态迁移。元字符前瞻匹配(Lookahead):
像
|与||、>与>>之间存在识别冲突。状态机采用单字符前瞻机制来消除二义性。以管道符为例:1
2
3
4
5
6
7
8
9
10
11
12else if (ctx.buf[ctx.i] == '|')
{
if (ctx.is_accumulate) { word_end_process(&ctx); } // Resolve the previous word
if (ctx.buf[ctx.i + 1] == '|')
{
or_process(&ctx); // If identified as a logical OR (TOKEN_OR_IF), the pointer steps by 2.
}
else
{
pipe_process(&ctx); // Identified as a pipe (TOKEN_PIPE), pointer step 1.
}
}
2. 引号与转义状态下的字符保护
一旦状态切离了 STATE_NORMAL,原本具有特殊政治地位的元字符(如 |, &, >)就会失去特殊语义,被当做普通字符处理。
单引号状态 (
STATE_IN_SINGLE_QUOTE):在单引号内,除了闭合单引号
'外,所有的字符都会被无条件保护。1
2
3
4
5
6
7
8
9if (ctx.buf[ctx.i] == '\'')
{
ctx.state = STATE_NORMAL; // Return to a normal state
ctx.i++;
}
else
{
accumulate_char(&ctx); // Collect all text within single quotes.
}双引号状态下的嵌套转义:
双引号允许内部存在转义(如
\"或\\)。因此,如果在STATE_IN_DOUBLE_QUOTE状态下遭遇\,系统需要切换到一个临时的过渡态:STATE_ESCAPE_IN_DOUBLE_QUOTE。1
2
3
4
5else if (ctx.buf[ctx.i] == '\\')
{
ctx.state = STATE_ESCAPE_IN_DOUBLE_QUOTE;
ctx.i++; // Skip the backslash itself.
}在下一轮循环中,程序进入过渡态,此时无论下一个字符是什么,都会通过
accumulate_char被无条件视为普通文本写入,随后状态自动切回STATE_IN_DOUBLE_QUOTE。
内存管理与错误处理机制
编写健壮的系统 C 代码,真正的核心逻辑往往只占一部分,其余绝大部分精力都需要花在应对各类边界异常和内存管理上。
1. 动态数组扩容
Token 数组的初始容量由 HSH_TOKEN_BUF_SIZE 定义。当输入命令较长导致容量不足时,通过 ensure_token_capacity 实现动态扩容:
1 | static void ensure_token_capacity(LexContext *ctx) |
该函数使用临时指针 tmp 接收 realloc 的返回值,避免了因内存分配失败导致的原指针丢失与隐式内存泄漏。
2. 词法异常的内存释放
如果用户输入了类似 echo "hello 的畸形命令(即直到文本结束 \0,单/双引号仍未闭合),状态机会中途拦截并调用 lex_error 函数:
1 | static Token *lex_error(LexContext *ctx, const char *msg) |
一个合格的 Shell 绝不应当因为用户的输入错误而留下隐式内存泄漏。通过遍历清空已分配的堆内存,我们保证了系统在词法分析异常时,能够干净、安全地重置并返回主循环。
词法分析器完成了从“原始纯文本”到“结构化记号数组”的转换。以命令 ls -l | grep "txt" && echo 'done' 为例,经 lexer.c 处理后,会生成如下离散的记号序列:
1 | [TOKEN_WORD("ls"), TOKEN_WORD("-l"), TOKEN_PIPE, TOKEN_WORD("grep"), TOKEN_WORD("txt"), TOKEN_AND_IF, TOKEN_WORD("done"), TOKEN_END] |
然而,由于此时的记号数组是完全扁平的,操作系统尚无法直接理解命令之间的层次结构与执行流向(例如:谁先执行?谁重定向到哪里?)。下一章我们将进入,利用经典的递归下降算法,将这条扁平的数组搭建为具备层级逻辑的树状结构。