第 43 章

实现一个 Shell

实现一个 Shell

每次你在终端输入 ls -la | grep ".go" | wc -l,发生的事情远比表面看起来复杂。你的 shell 必须:解析这行文本,理解 | 的含义,为 lsgrepwc 分别创建进程,把 ls 的标准输出连接到 grep 的标准输入,把 grep 的标准输出连接到 wc 的标准输入,等待它们全部结束,然后把最终结果打印到你的终端。

这整个过程涉及操作系统最底层的几个概念:进程创建(fork/exec)、文件描述符继承、管道(pipe)、信号(signal)。Shell 是一个进程管理器,是人类与内核之间的语言翻译器。

构建一个 shell 是学习操作系统原理最直接的方式——不是读书上的描述,而是直接调用系统调用,亲手感受内核的行为。这一章我们用 Go 构建一个具备管道、重定向、内建命令、历史记录的 bash-lite。

Level 1 · Shell 的本质

命令解释器与进程管理器

Shell 的英文原意是"贝壳",是操作系统内核(核心)外面的那一层壳。它有两个核心职责:

命令解释器:把人类输入的文本翻译成操作系统能执行的操作。ls -la → 找到 /bin/ls 程序 → 用 -l-a 参数执行它。

进程管理器:创建和管理子进程。你运行的每一个程序,都是 shell 创建的一个子进程。Shell 决定子进程的标准输入/输出连接到哪里(终端?文件?管道的另一端?),决定什么时候等待子进程结束,决定如何把信号(Ctrl+C、Ctrl+Z)发送给子进程。

Unix 的核心设计哲学之一是:一切皆文件。管道是文件,终端是文件,网络连接也是文件。Shell 的 I/O 重定向和管道,本质上都是在操作文件描述符(File Descriptor,FD)。

为什么构建 Shell 能让你理解 OS

当你实现 |(管道)时,你必须真正理解 pipe() 系统调用:它创建两个文件描述符,一个只读一个只写,写入一端的数据可以从另一端读出。你必须理解 fork 后文件描述符的继承,理解为什么子进程要关闭不用的那一端(否则读端永远不会收到 EOF),理解为什么要在 exec 后关闭父进程的 fd(防止资源泄漏)。

这些知识,读教科书是一回事,自己写代码调试是另一回事。当你第一次成功让 cat file | wc -l 输出正确结果时,你对管道的理解会从概念变成直觉。

POSIX Shell 标准

我们构建的是一个简化版 shell,不完全符合 POSIX sh 标准。真正的 POSIX shell 要处理:变量、算术展开、命令替换、通配符展开、here-doc、函数定义、条件语句……每一个特性都有大量细节。

如果你想要一个 Go 实现的完整 POSIX shell,可以参考 mvdan/sh——一个完整的 POSIX shell 解析器和解释器,连 Bash 扩展([[, (( )) 等)都部分支持。本章的目标不是完整实现,而是通过实现核心子集来深刻理解每个机制。

Level 2 · 核心机制原理

Shell 语法的层次结构

一个 shell 命令行可以分解为以下层次:

命令行(Command Line)
  └── 管道(Pipeline):cmd1 | cmd2 | cmd3
        └── 简单命令(Simple Command):cmd [args...] [redirections...]
              └── 词元(Token):命令名 / 参数 / 重定向符 / 特殊字符

词法分析(Tokenization)把原始字符串切成词元,语法分析(Parsing)把词元组织成命令树,执行(Execution)按照命令树创建进程。

特殊词元

词元 含义
| 管道:前一个命令的 stdout 接到后一个的 stdin
> 输出重定向:stdout 写到文件(覆盖)
>> 输出重定向追加
< 输入重定向:从文件读 stdin
2> 错误重定向:stderr 写到文件
& 后台执行:不等待命令结束
; 命令分隔符:顺序执行
&& 短路与:前一个成功才执行下一个
|| 短路或:前一个失败才执行下一个
(cmd) 子 shell:在子进程环境中执行

os/exec.Cmd vs syscall.ForkExec

Go 提供两层进程创建 API,它们面向不同的抽象层次:

os/exec.Cmd:高层 API,封装了 fork+exec 的细节。exec.Command("ls", "-la") 创建一个 Cmd 对象,cmd.Start() 启动,cmd.Wait() 等待结束。I/O 通过 cmd.Stdincmd.Stdoutcmd.Stderr 字段设置,可以是 os.Fileio.Reader/io.Writer,也可以是 os.Pipe() 返回的管道。

syscall.ForkExec / syscall.Exec:直接调用系统调用。syscall.ForkExec 执行 fork(2) + exec(2),可以精细控制子进程的属性(进程组、会话、额外的文件描述符、umask 等)。需要更深层的控制(如实现作业控制、设置 setuid)时才用这层。

对于构建 bash-lite,os/exec.Cmd 提供了足够的控制能力,同时避免了直接操作文件描述符的低级细节。当需要实现作业控制(fg/bg、Ctrl+Z)时,才需要用到 syscall.SysProcAttr

进程组与作业控制

作业控制(Job Control)允许用户在前台和后台之间移动进程,挂起和恢复进程。这依赖于操作系统的进程组(Process Group)和会话(Session)概念。

一个进程组是一组相关进程的集合,有一个进程组 ID(PGID)。一个管道 cmd1 | cmd2 | cmd3 的三个进程通常属于同一个进程组。

信号可以发送给整个进程组:kill(-pgid, SIGTERM) 会向进程组中的所有进程发送 SIGTERM。这是 Ctrl+C 能终止整个管道(而不只是第一个进程)的原因。

// 让子进程成为新进程组的 leader
cmd.SysProcAttr = &syscall.SysProcAttr{
    Setpgid: true,  // 子进程的 PGID = 子进程的 PID(成为新进程组的 leader)
}

文件描述符继承

fork() 后,子进程继承父进程的所有文件描述符。这既是管道工作的基础,也是资源泄漏的来源。

对于管道 cmd1 | cmd2,正确的 fd 管理流程:

父进程创建管道: pipe(read_fd, write_fd)

Fork cmd1:
  - cmd1 的 stdout = write_fd(把 cmd1 的输出写到管道)
  - cmd1 关闭 read_fd(cmd1 不读管道,无需保留读端)
  - 父进程关闭 write_fd(父进程不写,无需保留写端)

Fork cmd2:
  - cmd2 的 stdin = read_fd(从管道读)
  - cmd2 关闭 write_fd(cmd2 不写管道,无需保留写端)
  - 父进程关闭 read_fd(父进程不读,无需保留读端)

如果 cmd1 的进程不关闭 read_fd,cmd1 持有管道读端的引用,当 cmd1 进程退出时这个引用才会消失。但如果 cmd2 在等 cmd1 的数据,而 cmd1 还活着(哪怕不写任何数据),管道读端就永远不会收到 EOF,cmd2 就会永远阻塞。这就是"僵死管道"(pipe deadlock)。

Level 3 · 从零构建 bash-lite

项目结构

bashlit/
├── main.go         ← REPL 主循环
├── lexer/
│   └── lexer.go    ← 词法分析:字符串 → Token 列表
├── parser/
│   └── parser.go   ← 语法分析:Token 列表 → 命令树
├── executor/
│   └── executor.go ← 执行:命令树 → 创建进程
└── builtin/
    └── builtin.go  ← 内建命令:cd, exit, export, history

第一步:词法分析器

package lexer

import (
    "strings"
    "unicode"
)

type TokenType int

const (
    WORD       TokenType = iota // 普通词(命令名、参数)
    PIPE                        // |
    REDIR_OUT                   // >
    REDIR_APP                   // >>
    REDIR_IN                    // <
    REDIR_ERR                   // 2>
    BACKGROUND                  // &
    SEMICOLON                   // ;
    AND                         // &&
    OR                          // ||
    LPAREN                      // (
    RPAREN                      // )
    EOF
)

type Token struct {
    Type TokenType
    Val  string
}

type Lexer struct {
    input []rune
    pos   int
}

func New(input string) *Lexer {
    return &Lexer{input: []rune(input)}
}

func (l *Lexer) Tokenize() []Token {
    var tokens []Token
    for l.pos < len(l.input) {
        ch := l.input[l.pos]

        // 跳过空白
        if unicode.IsSpace(ch) {
            l.pos++
            continue
        }

        // 引号字符串
        if ch == '"' || ch == '\'' {
            tokens = append(tokens, Token{WORD, l.readQuoted(ch)})
            continue
        }

        // 特殊字符处理
        switch {
        case ch == '|' && l.peek() == '|':
            tokens = append(tokens, Token{OR, "||"})
            l.pos += 2
        case ch == '&' && l.peek() == '&':
            tokens = append(tokens, Token{AND, "&&"})
            l.pos += 2
        case ch == '>':
            if l.peek() == '>' {
                tokens = append(tokens, Token{REDIR_APP, ">>"})
                l.pos += 2
            } else {
                tokens = append(tokens, Token{REDIR_OUT, ">"})
                l.pos++
            }
        case ch == '<':
            tokens = append(tokens, Token{REDIR_IN, "<"})
            l.pos++
        case ch == '|':
            tokens = append(tokens, Token{PIPE, "|"})
            l.pos++
        case ch == '&':
            tokens = append(tokens, Token{BACKGROUND, "&"})
            l.pos++
        case ch == ';':
            tokens = append(tokens, Token{SEMICOLON, ";"})
            l.pos++
        case ch == '(':
            tokens = append(tokens, Token{LPAREN, "("})
            l.pos++
        case ch == ')':
            tokens = append(tokens, Token{RPAREN, ")"})
            l.pos++
        case ch == '2' && l.peek() == '>':
            tokens = append(tokens, Token{REDIR_ERR, "2>"})
            l.pos += 2
        default:
            tokens = append(tokens, Token{WORD, l.readWord()})
        }
    }
    tokens = append(tokens, Token{EOF, ""})
    return tokens
}

func (l *Lexer) readWord() string {
    start := l.pos
    for l.pos < len(l.input) {
        ch := l.input[l.pos]
        if unicode.IsSpace(ch) || strings.ContainsRune("|&;<>()", ch) {
            break
        }
        if ch == '"' || ch == '\'' {
            break
        }
        l.pos++
    }
    return string(l.input[start:l.pos])
}

func (l *Lexer) readQuoted(quote rune) string {
    l.pos++ // 跳过开头引号
    start := l.pos
    for l.pos < len(l.input) && l.input[l.pos] != quote {
        if l.input[l.pos] == '\\' {
            l.pos++ // 跳过转义字符
        }
        l.pos++
    }
    val := string(l.input[start:l.pos])
    if l.pos < len(l.input) {
        l.pos++ // 跳过结尾引号
    }
    return val
}

func (l *Lexer) peek() rune {
    if l.pos+1 < len(l.input) {
        return l.input[l.pos+1]
    }
    return 0
}

第二步:语法分析器(命令树)

package parser

import "github.com/yourname/bashlit/lexer"

// Command 代表一个可执行的命令节点
type Command struct {
    Args       []string    // 命令名和参数(展开后)
    RedirIn    string      // < filename
    RedirOut   string      // > filename
    RedirApp   string      // >> filename
    RedirErr   string      // 2> filename
    Background bool        // & 后台执行
    Next       *Command    // ; 分隔的下一个命令
    Pipe       *Command    // | 连接的下一个命令
    And        *Command    // && 连接的下一个命令
    Or         *Command    // || 连接的下一个命令
    Subshell   *Command    // (cmd) 子 shell
}

type Parser struct {
    tokens []lexer.Token
    pos    int
}

func New(tokens []lexer.Token) *Parser {
    return &Parser{tokens: tokens}
}

func (p *Parser) Parse() *Command {
    return p.parseList()
}

// parseList 解析分号分隔的命令列表
func (p *Parser) parseList() *Command {
    cmd := p.parsePipeline()
    if p.current().Type == lexer.SEMICOLON {
        p.advance()
        cmd.Next = p.parseList()
    }
    return cmd
}

// parsePipeline 解析管道
func (p *Parser) parsePipeline() *Command {
    cmd := p.parseAndOr()
    if p.current().Type == lexer.PIPE {
        p.advance()
        cmd.Pipe = p.parsePipeline()
    }
    return cmd
}

// parseAndOr 解析 && 和 || 逻辑连接
func (p *Parser) parseAndOr() *Command {
    cmd := p.parseSimple()
    switch p.current().Type {
    case lexer.AND:
        p.advance()
        cmd.And = p.parseAndOr()
    case lexer.OR:
        p.advance()
        cmd.Or = p.parseAndOr()
    }
    return cmd
}

// parseSimple 解析一个简单命令(含重定向)
func (p *Parser) parseSimple() *Command {
    cmd := &Command{}

    // 子 shell:(cmd)
    if p.current().Type == lexer.LPAREN {
        p.advance()
        sub := p.parseList()
        if p.current().Type == lexer.RPAREN {
            p.advance()
        }
        cmd.Subshell = sub
        return cmd
    }

    for {
        tok := p.current()
        switch tok.Type {
        case lexer.WORD:
            cmd.Args = append(cmd.Args, tok.Val)
            p.advance()
        case lexer.REDIR_IN:
            p.advance()
            cmd.RedirIn = p.current().Val
            p.advance()
        case lexer.REDIR_OUT:
            p.advance()
            cmd.RedirOut = p.current().Val
            p.advance()
        case lexer.REDIR_APP:
            p.advance()
            cmd.RedirApp = p.current().Val
            p.advance()
        case lexer.REDIR_ERR:
            p.advance()
            cmd.RedirErr = p.current().Val
            p.advance()
        case lexer.BACKGROUND:
            cmd.Background = true
            p.advance()
            return cmd
        default:
            return cmd
        }
    }
}

func (p *Parser) current() lexer.Token {
    if p.pos < len(p.tokens) {
        return p.tokens[p.pos]
    }
    return lexer.Token{Type: lexer.EOF}
}

func (p *Parser) advance() {
    p.pos++
}

第三步:执行器(管道和重定向)

package executor

import (
    "fmt"
    "io"
    "os"
    "os/exec"
    "path/filepath"
    "strings"

    "github.com/yourname/bashlit/builtin"
    "github.com/yourname/bashlit/parser"
)

type Executor struct {
    env     map[string]string
    history []string
}

func New() *Executor {
    return &Executor{env: make(map[string]string)}
}

// Execute 执行一个命令树
func (e *Executor) Execute(cmd *parser.Command) int {
    if cmd == nil {
        return 0
    }

    // 分号分隔:顺序执行
    if cmd.Next != nil {
        e.Execute(cmd)
        return e.Execute(cmd.Next)
    }

    // 管道
    if cmd.Pipe != nil {
        return e.executePipeline(cmd)
    }

    // && 短路
    if cmd.And != nil {
        code := e.executeSingle(cmd)
        if code == 0 {
            return e.Execute(cmd.And)
        }
        return code
    }

    // || 短路
    if cmd.Or != nil {
        code := e.executeSingle(cmd)
        if code != 0 {
            return e.Execute(cmd.Or)
        }
        return code
    }

    return e.executeSingle(cmd)
}

// executePipeline 执行 cmd1 | cmd2 | ... | cmdN
func (e *Executor) executePipeline(first *parser.Command) int {
    // 收集管道中所有命令
    var cmds []*parser.Command
    cur := first
    for cur != nil {
        cmds = append(cmds, cur)
        cur = cur.Pipe
    }

    n := len(cmds)
    procs := make([]*exec.Cmd, n)
    pipes := make([]*os.File, n-1) // n-1 个管道的读端

    // 创建所有管道
    var writers []*os.File
    for i := 0; i < n-1; i++ {
        r, w, err := os.Pipe()
        if err != nil {
            fmt.Fprintf(os.Stderr, "pipe error: %v\n", err)
            return 1
        }
        pipes[i] = r   // 读端给下一个命令
        writers = append(writers, w) // 写端给当前命令
    }

    // 设置每个命令的 stdin/stdout
    for i, cmd := range cmds {
        args := e.expandArgs(cmd.Args)
        if len(args) == 0 {
            continue
        }
        procs[i] = exec.Command(args[0], args[1:]...)
        procs[i].Env = e.buildEnv()

        // stdin
        if i == 0 {
            // 第一个命令:stdin 可能来自重定向
            if cmd.RedirIn != "" {
                f, err := os.Open(cmd.RedirIn)
                if err != nil {
                    fmt.Fprintf(os.Stderr, "open %s: %v\n", cmd.RedirIn, err)
                    return 1
                }
                procs[i].Stdin = f
            } else {
                procs[i].Stdin = os.Stdin
            }
        } else {
            procs[i].Stdin = pipes[i-1]
        }

        // stdout
        if i == n-1 {
            // 最后一个命令:stdout 可能重定向到文件
            if out, err := e.openOutput(cmd); err != nil {
                fmt.Fprintln(os.Stderr, err)
                return 1
            } else if out != nil {
                procs[i].Stdout = out
            } else {
                procs[i].Stdout = os.Stdout
            }
        } else {
            procs[i].Stdout = writers[i]
        }

        procs[i].Stderr = os.Stderr
    }

    // 启动所有进程
    for i, proc := range procs {
        if proc == nil {
            continue
        }
        if err := proc.Start(); err != nil {
            fmt.Fprintf(os.Stderr, "start %s: %v\n", cmds[i].Args[0], err)
            return 1
        }
    }

    // 关闭父进程持有的管道端(极其重要:否则子进程永远收不到 EOF)
    for _, w := range writers {
        w.Close()
    }
    for _, r := range pipes {
        r.Close()
    }

    // 等待所有进程结束,返回最后一个命令的退出码
    var lastCode int
    for _, proc := range procs {
        if proc == nil {
            continue
        }
        if err := proc.Wait(); err != nil {
            if exitErr, ok := err.(*exec.ExitError); ok {
                lastCode = exitErr.ExitCode()
            } else {
                lastCode = 1
            }
        } else {
            lastCode = 0
        }
    }
    return lastCode
}

// executeSingle 执行单个命令(含 I/O 重定向)
func (e *Executor) executeSingle(cmd *parser.Command) int {
    if cmd.Subshell != nil {
        // 子 shell:在新进程中执行命令树
        return e.Execute(cmd.Subshell)
    }
    if len(cmd.Args) == 0 {
        return 0
    }

    args := e.expandArgs(cmd.Args)

    // 检查内建命令
    if code, handled := builtin.Run(args, e.env, &e.history); handled {
        return code
    }

    // 外部命令
    proc := exec.Command(args[0], args[1:]...)
    proc.Env = e.buildEnv()

    // 设置 stdin
    if cmd.RedirIn != "" {
        f, err := os.Open(cmd.RedirIn)
        if err != nil {
            fmt.Fprintf(os.Stderr, "open %s: %v\n", cmd.RedirIn, err)
            return 1
        }
        defer f.Close()
        proc.Stdin = f
    } else {
        proc.Stdin = os.Stdin
    }

    // 设置 stdout
    if out, err := e.openOutput(cmd); err != nil {
        fmt.Fprintln(os.Stderr, err)
        return 1
    } else if out != nil {
        defer out.Close()
        proc.Stdout = out
    } else {
        proc.Stdout = os.Stdout
    }

    // 设置 stderr
    if cmd.RedirErr != "" {
        f, err := os.Create(cmd.RedirErr)
        if err != nil {
            fmt.Fprintf(os.Stderr, "create %s: %v\n", cmd.RedirErr, err)
            return 1
        }
        defer f.Close()
        proc.Stderr = f
    } else {
        proc.Stderr = os.Stderr
    }

    if cmd.Background {
        if err := proc.Start(); err != nil {
            fmt.Fprintf(os.Stderr, "%s: %v\n", args[0], err)
            return 1
        }
        fmt.Printf("[%d]\n", proc.Process.Pid)
        return 0
    }

    if err := proc.Run(); err != nil {
        if exitErr, ok := err.(*exec.ExitError); ok {
            return exitErr.ExitCode()
        }
        fmt.Fprintf(os.Stderr, "%s: command not found\n", args[0])
        return 127
    }
    return 0
}

func (e *Executor) openOutput(cmd *parser.Command) (*os.File, error) {
    if cmd.RedirOut != "" {
        return os.Create(cmd.RedirOut)
    }
    if cmd.RedirApp != "" {
        return os.OpenFile(cmd.RedirApp, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
    }
    return nil, nil
}

// expandArgs 展开参数(环境变量、Glob)
func (e *Executor) expandArgs(args []string) []string {
    var result []string
    for _, arg := range args {
        // 展开环境变量 $VAR 或 ${VAR}
        expanded := os.Expand(arg, func(key string) string {
            if v, ok := e.env[key]; ok {
                return v
            }
            return os.Getenv(key)
        })
        // Glob 展开
        if strings.ContainsAny(expanded, "*?[") {
            matches, err := filepath.Glob(expanded)
            if err == nil && len(matches) > 0 {
                result = append(result, matches...)
                continue
            }
        }
        result = append(result, expanded)
    }
    return result
}

func (e *Executor) buildEnv() []string {
    var env []string
    for k, v := range e.env {
        env = append(env, k+"="+v)
    }
    // 继承父进程的环境变量
    env = append(env, os.Environ()...)
    return env
}

第四步:内建命令

package builtin

import (
    "fmt"
    "os"
    "strconv"
    "strings"
)

// Run 如果 args[0] 是内建命令则执行并返回 (code, true),否则返回 (0, false)
func Run(args []string, env map[string]string, history *[]string) (int, bool) {
    if len(args) == 0 {
        return 0, false
    }
    switch args[0] {
    case "cd":
        return cd(args), true
    case "exit":
        return exit(args), true
    case "export":
        return export(args, env), true
    case "unset":
        return unset(args, env), true
    case "echo":
        return echo(args, env), true
    case "history":
        return printHistory(*history), true
    case "pwd":
        return pwd(), true
    case "type":
        return typeCmd(args), true
    }
    return 0, false
}

func cd(args []string) int {
    dir := ""
    if len(args) < 2 {
        dir = os.Getenv("HOME")
        if dir == "" {
            fmt.Fprintln(os.Stderr, "cd: HOME not set")
            return 1
        }
    } else {
        dir = args[1]
    }
    if err := os.Chdir(dir); err != nil {
        fmt.Fprintf(os.Stderr, "cd: %v\n", err)
        return 1
    }
    return 0
}

func exit(args []string) int {
    code := 0
    if len(args) >= 2 {
        if n, err := strconv.Atoi(args[1]); err == nil {
            code = n
        }
    }
    os.Exit(code)
    return code
}

func export(args []string, env map[string]string) int {
    // export VAR=value 或 export VAR(从 os.Environ() 获取当前值)
    for _, arg := range args[1:] {
        if idx := strings.Index(arg, "="); idx != -1 {
            env[arg[:idx]] = arg[idx+1:]
        } else {
            if v := os.Getenv(arg); v != "" {
                env[arg] = v
            }
        }
    }
    return 0
}

func unset(args []string, env map[string]string) int {
    for _, key := range args[1:] {
        delete(env, key)
        os.Unsetenv(key)
    }
    return 0
}

func echo(args []string, env map[string]string) int {
    parts := make([]string, 0, len(args)-1)
    newline := true
    for i, arg := range args[1:] {
        if i == 0 && arg == "-n" {
            newline = false
            continue
        }
        // 展开 $VAR
        expanded := os.Expand(arg, func(key string) string {
            if v, ok := env[key]; ok {
                return v
            }
            return os.Getenv(key)
        })
        parts = append(parts, expanded)
    }
    if newline {
        fmt.Println(strings.Join(parts, " "))
    } else {
        fmt.Print(strings.Join(parts, " "))
    }
    return 0
}

func printHistory(history []string) int {
    for i, cmd := range history {
        fmt.Printf("%4d  %s\n", i+1, cmd)
    }
    return 0
}

func pwd() int {
    dir, err := os.Getwd()
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        return 1
    }
    fmt.Println(dir)
    return 0
}

func typeCmd(args []string) int {
    builtins := map[string]bool{
        "cd": true, "exit": true, "export": true, "echo": true,
        "history": true, "pwd": true, "type": true, "unset": true,
    }
    for _, name := range args[1:] {
        if builtins[name] {
            fmt.Printf("%s is a shell builtin\n", name)
        } else {
            fmt.Printf("%s is an external command\n", name)
        }
    }
    return 0
}

第五步:REPL 主循环(带历史和 Tab 补全)

package main

import (
    "fmt"
    "os"
    "os/signal"
    "strings"
    "syscall"

    "github.com/chzyer/readline"
    "github.com/yourname/bashlit/executor"
    "github.com/yourname/bashlit/lexer"
    "github.com/yourname/bashlit/parser"
)

func main() {
    ex := executor.New()

    // 使用 chzyer/readline 支持历史和 Tab 补全
    rl, err := readline.NewEx(&readline.Config{
        Prompt:          "\033[32m$ \033[0m", // 绿色 $ 提示符
        HistoryFile:     os.Getenv("HOME") + "/.bashlit_history",
        AutoComplete:    readline.NewPrefixCompleter(buildCompleter()...),
        InterruptPrompt: "^C",
        EOFPrompt:       "exit",
    })
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    defer rl.Close()

    // 处理 Ctrl+C:不退出 shell,只中断当前输入
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT)
    go func() {
        for range sigCh {
            // readline 内部处理了 Ctrl+C,这里只防止 shell 退出
        }
    }()

    // REPL 主循环
    var history []string
    for {
        // 动态更新提示符(显示当前目录)
        dir, _ := os.Getwd()
        home := os.Getenv("HOME")
        if strings.HasPrefix(dir, home) {
            dir = "~" + dir[len(home):]
        }
        rl.SetPrompt(fmt.Sprintf("\033[36m%s\033[0m \033[32m$\033[0m ", dir))

        line, err := rl.Readline()
        if err != nil { // io.EOF 或 Ctrl+D
            fmt.Println("exit")
            break
        }

        line = strings.TrimSpace(line)
        if line == "" {
            continue
        }

        history = append(history, line)

        // 词法分析
        tokens := lexer.New(line).Tokenize()

        // 语法分析
        p := parser.New(tokens)
        cmdTree := p.Parse()

        // 执行
        ex.Execute(cmdTree)
    }
}

// buildCompleter 返回内建命令的 Tab 补全列表
func buildCompleter() []readline.PrefixCompleterInterface {
    builtins := []string{"cd", "exit", "export", "echo", "history", "pwd", "type", "unset"}
    var items []readline.PrefixCompleterInterface
    for _, b := range builtins {
        items = append(items, readline.PcItem(b))
    }
    return items
}

Level 4 · 进阶与边界

作业控制:fg/bg 和 Ctrl+Z

真正的 shell 支持作业控制:Ctrl+Z 挂起前台进程,bg 把它放到后台继续运行,fg 把后台进程拉回前台。

实现作业控制需要:

  1. SysProcAttr.Setpgid = true 让每个管道命令组成为独立进程组。
  2. tcsetpgrp() 把终端控制权从 shell 进程组切换到前台进程组。
  3. SIGTTOU/SIGTTIN 信号协调终端 I/O。
  4. 维护一个作业表(job table),记录每个后台作业的进程组 ID 和状态。
// 设置子进程的进程组
cmd.SysProcAttr = &syscall.SysProcAttr{
    Setpgid: true,
}
if err := cmd.Start(); err != nil {
    return 1
}
pgid := cmd.Process.Pid

// 把终端控制权给子进程组(前台运行)
// tcsetpgrp 需要 cgo 或 syscall 直接调用
// syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), syscall.TIOCSPGRP, uintptr(unsafe.Pointer(&pgid)))

// 等待子进程(同时处理 SIGCHLD)
var wstatus syscall.WaitStatus
syscall.Wait4(cmd.Process.Pid, &wstatus, 0, nil)

// 恢复终端控制权给 shell 进程组
shellPgid := syscall.Getpgrp()
// 再次 tcsetpgrp(shellPgid)

Heredoc 支持

Heredoc(Here Document)允许在命令行内嵌多行文本:

cat <<EOF
line one
line two
EOF

实现要点:识别 <<DELIMITER 语法,然后持续读取输入行直到遇到独占一行的 DELIMITER,把收集到的行通过 strings.NewReader 传入命令的 stdin。

func readHeredoc(delimiter string) string {
    var lines []string
    scanner := bufio.NewScanner(os.Stdin)
    for scanner.Scan() {
        line := scanner.Text()
        if line == delimiter {
            break
        }
        lines = append(lines, line)
    }
    return strings.Join(lines, "\n")
}

环境变量展开的完整实现

完整的变量展开比 os.Expand 要复杂很多:

echo ${VAR:-default}    # VAR 未设置时用 default
echo ${VAR:+set}        # VAR 已设置时用 set
echo ${#VAR}            # VAR 的长度
echo ${VAR%suffix}      # 去掉最短匹配的后缀
echo ${VAR##prefix}     # 去掉最长匹配的前缀
echo $?                 # 上一个命令的退出码
echo $$                 # 当前 shell 的 PID
echo $!                 # 最近后台进程的 PID

这些特殊变量和展开语法需要自己实现,os.Expand 只处理简单的 $VAR 形式。

mvdan/sh 的对比

mvdan/sh 是 Go 实现的完整 POSIX shell 解析器和解释器,代码质量极高,可以作为学习参考。它的架构清晰地分为:

和我们的 bash-lite 相比,mvdan/sh 有几十倍的代码量,处理了所有 POSIX 规范的细节。但核心思路是一致的:词法 → 语法 → AST → 遍历执行。

一旦你构建了 bash-lite 并理解了每一个机制,阅读 mvdan/sh 的源码会变得非常自然。你会发现自己的简化实现和工业级实现的每一处差异,都对应着真实的 shell 行为细节——而不是神秘的工程黑箱。

这就是"构建它以理解它"的价值:不只是会用,而是真正懂得它为何如此工作。

本章评分
4.6  / 5  (3 评分)

💬 留言讨论