课程概览 + Shell 入门

我们是谁?

本课程由 AnishJonJose 联合讲授。我们都是 MIT 校友,在学生时代就创办了这门 MIT IAP 课程。如有任何问题,欢迎通过以下方式联系我们:
missing-semester@mit.edu

我们不以此课程获得报酬,也不以任何方式将其商业化。我们将所有的 课程资料讲座录像 免费公开。如果你想支持我们的工作,最好的方式就是向他人推荐这门课程。如果你是公司、大学或其他组织,在更大范围内使用了本课程的内容,欢迎邮件告诉我们你的使用情况或提供反馈,我们很希望听到这些信息 :)

课程目的

作为计算机科学家,我们都深知计算机擅长辅助完成重复性任务。然而,我们往往不经意间遗漏了一点:这个优势不仅适用于程序执行的计算过程,对我们使用计算机本身也同样适用。我们掌握着大量功能强大的工具,这些工具能显著提升我们的工作效率、帮助解决更复杂的问题。可惜的是,许多人仅仅利用了这些工具的冰山一角;我们往往只是死记硬背几句「魔法咒语」来应付日常工作,一旦陷入困境就盲目地从网上复制粘贴命令。

本课程致力于 解决这个问题

我们想教你如何充分利用已知的工具,为你介绍新的工具来扩充你的工具箱,并激发你对探索(乃至自己开发)更多工具的热情。这正是我们所认为的大多数计算机科学课程中所缺失的内容。

课程结构

本课程是一门免学分(费用)课程,包含九场 1 小时的讲座,每场讲座围绕一个 特定主题 展开。这些讲座在很大程度上彼此独立,但随着学期进行,我们会假设你已经熟悉前面讲座的内容。我们提供在线讲义,但课堂上可能会涵盖讲义中没有的内容(如演示等)。与往年一样,我们会录制讲座并将录音录像 在线发布

考虑到仅用几场 1 小时讲座要涵盖大量内容,这些讲座的信息量相当大。为了让你有时间按自己的节奏熟悉内容,每场讲座都附带一组习题,指导你学习讲座的核心知识点。我们不设专门的答疑时间,但欢迎你在 OSSU Discord#missing-semester-forum 频道或通过邮件 missing-semester@mit.edu 向我们提问。

由于时间有限,我们无法以全日制课程的详细程度涵盖所有工具。在可能的情况下,我们会为你指引资源来进一步探讨某个工具或话题;如果有什么特别引起你兴趣的,欢迎随时联系我们咨询!

最后,如果你对课程有任何反馈,欢迎邮件告诉我们:
missing-semester@mit.edu

主题一:Shell

Shell 是什么?

如今的计算机拥有多种多样的界面来接收命令:华丽的图形用户界面、语音输入接口、AR/VR,以及近来出现的大语言模型。这些交互接口在 80% 的使用场景中都表现出色,但它们往往在根本上受到限制——你无法点击一个不存在的按钮,也无法下达一条未被编程的语音命令。要充分利用计算机提供的所有工具,我们必须「复古」一下,使用一个古老而强大的文本界面:Shell 。

几乎所有你能接触到的平台都以某种形式提供了 Shell,其中许多还提供了多个 Shell 供你选择。尽管各 Shell 在细节上各不相同,但在本质上它们都大同小异:它们都允许你运行程序、向程序提供输入,并以半结构化的方式检查程序的输出。

要打开 Shell 的提示符(即你可以输入命令的地方),首先需要一个终端——它是与 Shell 交互的可视化界面。你的设备很可能已预装了终端,如果没有预装,你也可以安装一个:

在 Linux 和 macOS 上,这通常会打开 Bourne Again Shell,简称「bash」。它是应用最广泛的 Shell 之一,其语法与你在其他许多 Shell 中看到的类似。在 Windows 上,你可能会看到「批处理(batch)」或「powershell」Shell,具体取决于你运行的命令。这些都是 Windows 特有的,我们在本课程中不会重点关注,尽管它们对我们要教授的大多数内容都有对应实现。你可以考虑使用 适用于 Linux 的 Windows 子系统(WSL) 或 Linux 虚拟机。

还有其他一些 Shell ,它们在使用体验上相较于 bash 做了许多改进(例如 fish 和 zsh 是最常见的)。虽然这些 Shell 非常流行(所有授课教师都在使用其中之一),但它们的普及程度远不及 bash,而且它们依赖的许多概念也与 bash 相同,因此本讲不会重点介绍这些 Shell 。

为什么你要关心 Shell ?

Shell 的优点不只是比「点来点去」快得多,还具备一种在任何单一图形化程序中都难以获得的表达能力。正如我们将要看到的,Shell 让你能够以富有创造性的方式把不同程序组合起来,从而自动化几乎任何任务。

熟悉 Shell 还非常有助于你在开源软件世界中畅行无阻(许多安装说明都需要用到 Shell)、为你的软件项目搭建持续集成(如 代码质量 一讲所述),以及在其他程序出错时进行排障。

在 Shell 中导航

当你打开终端时,会看到一个通常长这样子的提示符

missing:~$

这是 Shell 的主要文本交互界面。它告诉你:你当前在名为 missing 的机器上,你的「当前工作目录」(也就是你现在所在的位置)是 ~ ,它是「home 目录」的简写,在 Linux 上通常对应 /home/用户名(例如 /home/jon )。
$ 表示你当前不是 root 用户(后面会详细讲)。在这个提示符后,你可以输入一条命令,Shell 会对其进行解释并执行。最基本的命令就是运行一个程序:

missing:~$ date
Fri 10 Jan 2020 11:49:31 AM EST
missing:~$

这里我们执行了 date 程序,它会(不出所料地)打印当前日期和时间。随后 Shell 会等待我们输入下一条命令。
我们也可以带上参数(argument来执行命令:

missing:~$ echo hello
hello

在这个例子中,我们让 Shell 执行 echo 程序,并传入参数 helloecho 程序的作用很简单:它会把收到的参数原样打印出来。Shell 在解析这条命令时,会先按照空白字符(whitespace,如空格、Tab 等)把整条命令拆分成若干部分,然后把第一个单词当作要执行的程序,其后的每一个单词都会作为参数传递给这个程序,程序可以在运行时读取这些参数。
如果你想传递的参数本身包含空格或其他特殊字符(例如一个名为「My Photos」的目录),可以用两种方式处理:

对于初学者最重要的一条命令也许是 man,即 「manual(手册)」的缩写。 man 命令有很多用途,其中之一是帮你查询系统中任意命令的详细说明。比如运行 man date,它会告诉你 date 是什么,以及你可以传入哪些参数来改变它的行为。对大多数命令来说,你通常也可以通过加上 --help 参数来查看更简短的帮助信息。

除了 man 之外,我们也推荐安装 tldr :它会直接在终端里给出常见的命令使用示例,非常方便。此外,大语言模型通常也很擅长解释命令的工作原理,以及应该如何调用命令来实现你想完成的任务。

学会 man 之后,下一个最重要的命令是 cd(change directory,切换目录)。这个命令实际上是 Shell 的内建命令,而不是独立程序(也就是说,输入 which cd 会显示 no cd found)。你给它传入一个路径,该路径就会成为你当前的工作目录。你也会在 Shell 提示符中看到当前工作目录随之变化。

missing:~$ cd /bin
missing:/bin$ cd /
missing:/$ cd ~
missing:~$

需要注意的是,Shell 通常自带自动补全功能,所以按下 Tab 往往能更快地补全路径。

许多命令在没有指定路径时,默认会作用于当前工作目录。如果你不确定自己现在位于哪个目录,可以运行 pwd(print working directory 的意思,即「打印当前工作目录」),或者查看 $PWD 环境变量(例如运行 echo $PWD)。这两种方式都会输出当前工作目录的路径。

当前工作目录的另一个重要作用,是让我们能够使用相对路径。到目前为止我们看到的路径都是绝对路径:它们以 / 开头,给出了从文件系统根目录( / )到目标位置所需经过的完整目录路径。 在实际使用中,你会更常接触到相对路径。之所以称为「相对」,是因为它们是相对于当前工作目录来解释的。对于相对路径(也就是任何不以 / 开头的路径),Shell 会先在当前工作目录中查找路径的第一个部分,然后再像平常一样逐级向下查找。例如:

missing:~$ cd /
missing:/$ cd bin
missing:/bin$

每个目录里还都有两个「特殊路径」:... 。 其中,. 表示「当前目录」,.. 表示「父目录」。例如:

missing:~$ cd /
missing:/$ cd bin/../bin/../bin/././../bin/..
missing:/$

对于大多数命令参数来说,绝对路径和相对路径通常可以互换使用;只是在使用相对路径时,一定要时刻清楚你当前所在的工作目录!

我们建议安装并使用 zoxide 来加速 cd 操作。它提供的 z 命令会记住你经常访问的路径,让你用更少的输入实现快速跳转。

Shell 中有哪些可用的程序?

但 Shell 是怎么知道去哪里找 dateecho 这样的程序呢?当 Shell 需要执行一条命令时,它会查询一个名为 $PATH 的环境变量。这个变量列出了一系列目录,Shell 会在这些目录中搜索与命令名称匹配的程序:

missing:~$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
missing:~$ which echo
/bin/echo
missing:~$ /bin/echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

当我们运行 echo 命令时,Shell 会识别出需要执行名为 echo 的程序,然后在 $PATH 中以冒号(:)分隔的目录列表里逐个搜索同名文件。一旦找到,就会运行它(前提是该文件是可执行的,关于这点稍后会详细说明)。
我们可以用 which 程序来查看某个命令实际对应哪个文件。我们也可以完全绕过 $PATH直接给出要执行文件的完整路径

这也揭示了一个办法:我们可以通过列出 $PATH 中所有目录的内容,来确定 Shell 中有哪些程序可供执行。我们可以把目录路径传给 ls 程序来实现(程序名称取自「list」,用于列出文件):

missing:~$ ls /bin

我们建议安装并使用 eza,它是一个更加现代友好的工具,用于替代 ls

在大多数计算机上,这会打印出非常多的程序,但我们这里只关注其中最重要的几个。先从一些简单的开始:

我们建议安装并使用 bat 来替代 cat,它支持语法高亮和分页滚动。

还有一个命令是 grep pattern hello.txt,它会在指定的文本文件(即 hello.txt )中查找所有匹配 pattern 的行。这个命令非常实用,值得多花点时间了解,它的功能比你想象的要丰富得多。

这里的 pattern 实际上是正则表达式(regular expression),可以描述非常复杂的匹配模式——我们会在「代码质量」一讲中 详细讲解

除了指定单个文件,你也可以指定一个目录作为搜索范围(或者直接不写,默认就是当前目录 . ),并加上 -r 参数让 grep 递归搜索目录里的所有文本文件,输出匹配的行。

如果想要更快、更好用的体验,可以考虑安装 ripgrep 来替代 grep 。它默认就会递归搜索当前工作目录里的文本文件,使用起来更直观,但可移植性稍弱一些。

还有一些非常实用的工具,它们的使用方式可能稍微复杂一些。我们先来看看 sed ——一个可编程的文件编辑器。它有自己的「小语言」,可以用来自动化修改文件。最常见的用法是:

missing:~$ sed -i 's/pattern/replacement/g' hello.txt

这条命令会把 hello.txt 中所有的 pattern 替换为 replacement 。具体来说:

译者注:
sed 是 stream editor(流编辑器) 的缩写,最早设计用来对输入流中的文本进行自动化处理,而不仅仅是单个文件。
s/ 为什么表示替换:在 sed 的命令语法里,s 就是 substitute(替换) 的首字母,表示「把匹配到的内容替换成其他内容」。
/g 为什么表示替换所有匹配项:结尾的 g 是 global(全局) 的意思,表示在每一行中替换所有匹配项,如果没有 gsed 只会替换每行的第一个匹配项。

grep 一样,这里的 pattern 也是正则表达式,可以描述非常复杂的匹配模式。此外,正则表达式替换还允许 replacement 引用匹配模式中的部分内容,我们稍后会通过例子演示这一点。

接下来是 find ,它可以(递归地)查找满足特定条件的文件。比如:

missing:~$ find ~/Downloads -type f -name "*.zip" -mtime +30

这会在下载(Downloads)目录中查找所有超过 30 天的 ZIP 文件。

missing:~$ find ~ -type f -size +100M -exec ls -lh {} \;

这会在你的「home 目录」中查找所有大于 100M 的文件并列出它们。需要注意的是,-exec 参数接受一条命令,命令以单独的 ; 结尾(因此我们需要像转义空格那样对它进行转义)。find 会把每个匹配到的文件路径替换到 {} 的位置。

missing:~$ find . -name "*.py" -exec grep -l "TODO" {} \;

这会在当前工作目录下查找所有包含 TODO (这个大写单词)的 .py 文件。

find 的语法可能有点让人望而生畏,但希望通过这些例子,你能感受到它有多么实用!

我们建议安装并使用 fd 来替代 find,它更加人性化(但可移植性稍弱)。

接下来我们介绍 awk,它和 sed 一样,也有自己的小语言。如果说 sed 是专门用来编辑文件的,那么 awk 则是专门用来解析文件的。 awk 最常见的用途是处理具有规则语法的数据文件(比如 CSV 文件),从每条记录(即每一行)中提取你想要的部分:

missing:~$ awk '{print $2}' hello.csv

这条命令会打印 hello.csv 中每一行的第二列(默认以空白字符分隔,空格或制表符都算)。如果你的文件是逗号分隔的(CSV 文件常见格式),可以加上 -F, 参数:

missing:~$ awk -F, '{print $2}' hello.csv

这样就会把每一行按逗号分成列,然后打印第二列。

除了提取列,awk 还能做很多操作——比如过滤行、计算统计、求和等等。具体可以通过习题自己动手试试。

将这些工具组合起来,我们可以完成一些很酷的操作,比如:

missing:~$ ssh myserver 'journalctl -u sshd -b-1 | grep "Disconnected from"' \
  | sed -E 's/.*Disconnected from .* user (.*) [^ ]+ port.*/\1/' \
  | sort | uniq -c \
  | sort -nk1,1 | tail -n10 \
  | awk '{print $2}' | paste -sd,
postgres,mysql,oracle,dell,ubuntu,inspur,test,admin,user,root

这条命令从远程服务器上抓取 SSH 日志(关于 ssh 我们会在下一讲详细介绍),搜索断开连接的消息,从每条消息中提取用户名,最后打印出现次数最多的前 10 个用户名(用逗号分隔)。这一切都在一条命令里完成!我们把逐步拆解这条命令的任务留作习题。

Shell 语言(bash)

前面的例子引入了一个新概念:管道( | )。它可以把一个程序的输出连接到另一个程序的输入。之所以可行,是因为多数命令行程序在没有给出 file 参数时,都会从「标准输入」(通常是你键盘输入的位置)读取数据。| 会把它前面程序的「标准输出」(通常是打印在终端上的内容)作为后面程序的标准输入。借助这种机制,我们就能把多个 Shell 程序组合起来(compose),这也是 Shell 如此高效好用的重要原因之一。

事实上,大多数 Shell(例如 bash)本身都实现了一套完整的编程语言,就像 Python 或 Ruby 一样。它有变量、条件判断、循环和函数。当你在 Shell 中执行命令时,本质上就是在编写一小段由 Shell 解释执行的代码。我们今天不会系统讲完 bash,但有几部分你会特别常用:

先说重定向:> file 可以把程序的标准输出写入 file ,而不是显示在终端中,这样之后再分析就更方便。>> file 会追加到 file,而不是覆盖原内容。还有 < file ,它会让程序把 file 当作标准输入来源,而不是从键盘读取。

这里正好提一下 tee 程序。tee 会把标准输入原样输出到标准输出(和 cat 一样),但同时也会把内容写入文件。所以像 verbose cmd | tee verbose.log | grep CRITICAL 这样的命令,既能把完整的详细日志保存到文件里,又能让终端里只保留筛选后的关键信息,保持终端整洁。

接着是条件语句:if command1; then command2; command3; fi 会先执行 command1,如果执行成功,就继续执行 command2command3 。你也可以加上 else 分支。最常作为 command1 的是 test 命令,通常简写成 [ ,可用于判断诸如「文件是否存在」( test -f file / [ -f file ] )或「字符串是否相等」( [ "$var" = "string" ] )等条件。在 bash 中还有 [[ ]],它是 test 的一种更「安全」的内置形式,在引号处理等方面的怪异行为更少。

bash 还有两种循环形式:whilefor
while command1; do command2; command3; done 的逻辑和前面的 if 命令类似,不同之处在于:只要 command1 不报错,就会不断重复执行整个循环体。
for varname in a b c d; do command; done 会执行 command 四次,每次把 $varname 依次设为 abcd
实际使用中,你往往不需要手动列出这些值,而是用「命令替换(command substitution)」,例如:

for i in $(seq 1 10); do

这会执行命令 seq 1 10(它会输出从 1 到 10 的所有整数,包含 10),然后用该命令的输出替换整个 $(),从而得到一个循环 10 次的 for 循环。 在较早之前编写的代码中,你有时会看到直接使用反引号(例如 for i in `seq 1 10`; do)来做同样的事;但现在应当优先使用 $() 这种写法,因为它支持嵌套。

虽然你可以直接在提示符里写很长的 Shell 脚本,但通常更推荐把它们写进 .sh 文件。例如,下面这个脚本会在循环中不断运行某个程序,直到它失败为止;它只会打印失败那一次运行的输出,同时在后台对 CPU 施加压力(例如,这在复现那些「偶尔才会失败的测试(flaky test)」时非常有用)。

#!/bin/bash
set -euo pipefail

# Start CPU stress in background
stress --cpu 8 &
STRESS_PID=$!

# Setup log file
LOGFILE="test_runs_$(date +%s).log"
echo "Logging to $LOGFILE"

# Run tests until one fails
RUN=1
while cargo test my_test > "$LOGFILE" 2>&1; do
    echo "Run $RUN passed"
    ((RUN++))
done

# Cleanup and report
kill $STRESS_PID
echo "Test failed on run $RUN"
echo "Last 20 lines of output:"
tail -n 20 "$LOGFILE"
echo "Full log: $LOGFILE"

这段代码里包含了不少新内容,建议花些时间深入理解,因为它们对编写实用的 Shell 命令非常有帮助。比如:用后台任务( & )并发运行程序、更复杂的 Shell 重定向 、以及 算术扩展

值得先特别看一下这个程序的前两行。
第一行是「解释器指示行(shebang)」,你在很多不仅仅是 Shell 脚本的文件开头也会看到它。 当一个以 #!/path 这段「魔法咒语」开头的文件被执行时,Shell 会启动 /path 指向的程序,并把该文件内容作为输入传递给它。 对 Shell 脚本来说,这意味着把脚本内容交给 bash ;但你同样可以写 Python 脚本,并使用 /usr/bin/python 作为 shebang 。 第二行则是一种让 bash 运行得更「严格」的方式,可以避免许多编写 Shell 脚本时常见的陷阱。set 可以接收很多参数,简单说:

Shell 编程和其他编程语言一样,是个很深的主题;
但我们要提醒你:bash 的「坑」尤其多,多到已经有 不止一个网站 专门整理 这些问题
我们强烈建议你在写脚本时大量使用 shellcheck
LLM 在编写和调试 Shell 脚本方面也很有帮助;当脚本对 bash 来说变得过于臃肿(100 行以上)时,它们也很适合把脚本迁移到更「正式」的编程语言(例如 Python)。

下一步

到这里,你已经对 Shell 足够熟悉,可以完成一些基础任务。你应该能够在系统中导航、找到你关心的文件,并使用大多数程序的基本功能。下一讲里,我们会讨论如何借助 Shell 以及众多好用的命令行工具来完成并自动化更复杂的任务。

练习

本课程每一讲都配有一组练习。有些练习给出明确任务,有些则是开放题,比如「试试使用 X 和 Y 工具」。我们非常鼓励你亲自上手。

我们还没有提供这些练习的标准答案。如果你被某个问题卡住了,欢迎在 Discord#missing-semester-forum 发帖,或发送邮件告诉我们你已经尝试了什么,我们会尽力帮你。 这些练习也很适合作为与 LLM 交流时的起始提示,让你以交互方式深入探索。这些练习真正的价值在于「探索答案的过程」,而不只是答案本身。我们鼓励你在做题时顺着分支问题继续深挖,多问「为什么」,而不是只追求最短解法路径。

  1. 本课程要求你使用类 Unix 的 Shell,如 Bash 或 ZSH 。若你在 Linux 或 macOS 上,无需额外设置。若你在 Windows 上,请确认你用的不是 cmd.exePowerShell;你可以使用 Windows Subsystem for Linux 或 Linux 虚拟机来获得 Unix 风格的命令行工具。要确认当前 Shell 是否合适,可运行 echo $SHELL;若输出类似 /bin/bash/usr/bin/zsh ,就说明没问题。

  2. ls-l 选项(flag)作用是什么?运行 ls -l / 并观察输出。每一行最前面的 10 个字符分别代表什么?(提示:man ls

  3. 在命令 find ~/Downloads -type f -name "*.zip" -mtime +30 中,*.zip 是一个 「glob」。什么是 glob ?新建一个测试目录并创建一些文件,试试 ls *.txtls file?.txtls {a,b,c}.txt 等模式。参见 Bash 手册中的 Pattern Matching

  4. '单引号'"双引号"$'ANSI 引号' 有什么区别?写一条命令,输出一个同时包含字面量 $! 和换行符的字符串。参见 Quoting

  5. Shell 有三条标准流:stdin(0)、stdout(1)、stderr(2)。运行 ls /nonexistent /tmp ,把 stdout 和 stderr 分别重定向到两个文件。你将如何把两者都重定向到同一个文件?参见 Redirections

  6. $? 保存上一条命令的退出状态(0 表示成功)。&& 仅在前一条成功时执行后一条;|| 仅在前一条失败时执行后一条。写一个一行命令:仅当 /tmp/mydir 不存在时才创建它。参见 Exit Status

  7. 为什么 cd 必须是 Shell 内建命令,而不能是独立程序?(提示:想想子进程能影响和不能影响父进程的哪些状态。)

  8. 写一个脚本,接收文件名参数($1),用 test -f[ -f ... ] 检查该文件是否存在,并根据结果输出不同提示。参见 Bash Conditional Expressions

  9. 把上一题完成的脚本保存为文件(如 check.sh)。先运行 ./check.sh somefile ,会发生什么?然后执行 chmod +x check.sh 再试一次。为什么这一步是必须的?(提示:比较 chmod 前后的 ls -l check.sh 输出)

  10. 在脚本的 set 选项(flag)里加入 -x 会发生什么?写个简单脚本试试并观察输出。参见 The Set Builtin

  11. 写一条命令,把文件复制为带当天日期的备份文件名(例如 notes.txtnotes_2026-01-12.txt)。(提示:$(date +%Y-%m-%d))参见 Command Substitution

  12. 修改讲义中的「复现偶尔才会失败的测试」脚本(flaky test),使它能够从命令行参数接收测试命令,而不是在脚本中写死 cargo test my_test。(提示:$1$@)参见 Special Parameters

  13. 使用管道找出你「home 目录」中最常见的 5 种文件扩展名。(提示:组合 findgrep / sed / awksortuniq -c 以及 head

  14. xargs 会把 stdin 的每一行转换为命令参数。结合 findxargs(不要用 find -exec),找出目录中所有 .sh 文件,并用 wc -l 统计每个文件行数。加分项:正确处理文件名中的空格。(提示:-print0-0)参见 man xargs

  15. 使用 curl 获取 课程网站 的 HTML,并通过 grep 统计列出了多少讲。(提示:找出每讲课程名称在那份 HTML 中的共性;用 curl -s 关闭进度输出。)

  16. jq 是处理 JSON 的强大工具。用 curl 获取示例数据 https://microsoftedge.github.io/Demos/json-dummy-data/64KB.json,再用 jq 提取 version 大于 6 的人员姓名。(提示:先 jq . 看结构;再试 jq '.[] | select(...) | .name'

  17. awk 可以按列值过滤行并改写输出。例如,awk '$3 ~ /pattern/ {$4=""; print}' 会只输出第三列匹配 pattern 的行,并省略第四列。请写一个 awk 命令:只输出第二列大于 100 的行,并交换第一列和第三列。可用这条命令测试:printf 'a 50 x\nb 150 y\nc 200 z\n'

  18. 拆解讲义中的 SSH 日志处理管道:每一步分别做了什么?然后仿照它构建一个管道,从 ~/.bash_history(或 ~/.zsh_history)中找出你最常使用的 Shell 命令。


Edit this page.

Licensed under CC BY-NC-SA.