跳转到内容

Missing Semester

课程概览与 Shell

Course Overview + Introduction to the ShellMIT Missing Semester 第一讲:官方讲义、视频和课后练习

一句话:终端负责显示和输入,Shell 负责读懂命令并执行。

这两个词经常混着用,但它们不是一回事:终端是窗口,Shell 是窗口里跑的程序。

上世纪六七十年代,主机在机房,你桌上放一台 VT100 或电传打字机(ASR-33),一条串口线连过去。

Terminal 这个词的原意就是「线路的末端」。那台机器没有任何计算能力,只做两件事:

  • 输入:把按键变成字节,从串口线发出去
  • 输出:把主机发回来的字节打到屏幕上

真正的计算全在机房的主机上。

电传打字机 Teletypewriter,缩写 TTY,沿用到了现在:

  • tty 命令:现在在哪个终端上
  • ps 的 TTY 列:进程挂在哪个终端上

硬件终端现在很少见了。我们说的「终端」其实是终端模拟器(terminal emulator),一个普通 GUI 程序,假装自己是 VT100。

它做的事情还是那两件,只不过换成了窗口:

  • 输入:键盘、鼠标、粘贴
  • 输出:把字节渲染成字符、颜色、光标

还多了标签页、分屏、透明度、滚动缓冲这些东西。

叫「模拟器」是因为 VT100 那套控制码成了标准:\e[2J 清屏,\e[31m 变红。今天的终端都在照做。它模拟的是协议,不是硬件。

常见终端:GNOME Terminal、Konsole、xterm、iTerm2、Terminal.app、Windows Terminal、Alacritty、Kitty、WezTerm、Ghostty。

Shell 是一个进程,大部分时间都在跑同一个循环:

提示符 → 读一行 → 解析/展开 → 找程序 → 执行 → 等结束 → 提示符

比如输入:

终端窗口
ls -la

这一行不会原样丢给 ls。Shell 会先处理它:

  1. 切词:ls 和 -la 是两个词
  2. 展开:处理引号、变量、命令替换、glob(这里没有)
  3. 查找:在 $PATH 里找 ls
  4. 执行:fork + exec,父进程 wait
  5. 拿到返回码,打印下一个提示符

切词:空白不是分隔符那么简单

Section titled “切词:空白不是分隔符那么简单”

Shell 先按空白把一行切成词:

终端窗口
echo hello world # 两个参数:hello、world
echo "hello world" # 一个参数
echo hello\ world # 一个参数
echo hello world # 还是两个,多个空格只起分隔作用

echo 会把参数用单个空格拼起来打印,所以前两种输出看起来一样。换个程序就能看出差别:

终端窗口
python3 -c 'import sys; print(sys.argv[1:])' hello world
# ['hello', 'world']
python3 -c 'import sys; print(sys.argv[1:])' "hello world"
# ['hello world']

Shell 传给程序的是 argv 数组。C 的 argc/argv、Java 的 String[] args、Python 的 sys.argv 都是这个东西。

引号:告诉 Shell 哪些空白和字符不要碰

Section titled “引号:告诉 Shell 哪些空白和字符不要碰”
终端窗口
echo '$HOME' # $HOME
echo "$HOME" # /home/you
name=world
echo "hello $name" # hello world
echo 'hello $name' # hello $name
  • 单引号:完全字面,里面的空格、$、* 都不处理
  • 双引号:保留空格,但允许 $变量、$(命令)、反引号和 \ 转义
  • 反斜杠:只转义下一个字符,hello\ world 里的空格不再是分隔符
  • $'...':ANSI-C 引号,支持 \n、\t 这类转义

引号本身不会传给程序,它只是 Shell 解析时的标记。

引号没闭合时,Shell 不会立刻执行,而是进入续行状态,等你补完。按 Ctrl-C 可以取消。

终端窗口
name=world # 等号两边不能有空格
echo "$name"
echo "${name}!" # 花括号划清变量边界
echo "today: $(date +%F)"
  • name=value 定义 Shell 变量
  • $name 取值,${name} 在拼接时更清楚
  • $(命令) 是命令替换:先执行命令,把输出替换到当前位置
  • 旧写法是反引号,不能嵌套,优先用 $()
  • export name 才会把它变成环境变量,传给子进程

变量展开后如果没加引号,Shell 还会做一次「切词」和 glob 展开。

终端窗口
files='a.txt b c.txt'
printf '[%s]\n' "$files"
# [a.txt b c.txt]
printf '[%s]\n' $files
# [a.txt]
# [b]
# [c.txt]

files 里有一个空格。两种写法的区别是 Shell 传给 printf 的参数个数:

写法参数个数每个参数
"$files"1a.txt b c.txt
$files3a.txt、b、c.txt
  • 加引号:空格只是字符串里的普通字符,整个值是一个参数。
  • 不加引号:Shell 按空白切词,空格、Tab、换行都算分隔符,所以切成三个。
  • printf 对每个参数重复一次格式,所以第二种打印三行。

上面是 bash 的行为。zsh 默认不做 word splitting,所以 $files 和 "$files" 会得到一个参数;要显式切用 ${=files}。

echo 会把多个参数用空格拼回去,所以 echo $files 看起来会和 echo "$files" 一样;printf 才能看出参数个数。

$(ls) 的输出用换行分隔;在 bash 里,不加引号也会被切开,但文件名里有空格时照样会被切坏。所以变量一般都要加双引号:"$name"、"$files"。

一句话:Shell 用 *、?、[] 这些通配符去匹配文件名,并把匹配结果替换到命令里。这个展开过程叫 glob,也叫 pathname expansion。

* 不是原样传给程序的参数,Shell 会先按文件名展开,再把结果塞进 argv。

project/
├── intro.md
├── main.md
├── notes.txt
├── a1.txt
├── a2.txt
└── docs/
├── guide.md
└── deep/
└── api.md

假设当前在 project/,输入:

终端窗口
echo *.md

Shell 不会把 *.md 传给 echo,而是先替换:

echo *.md → echo intro.md main.md

echo 收到的是两个参数。用 printf 能看清:

终端窗口
printf '[%s]\n' *.md
# [intro.md]
# [main.md]

加引号就阻止展开:

终端窗口
printf '[%s]\n' "*.md"
# [*.md]

引号和上一节一样:让 * 变成普通字符,原样交给程序。

模式匹配结果说明
*.mdintro.md、main.md* 不跨 /,不会进 docs/
*.txtnotes.txt、a1.txt、a2.txt只匹配当前目录
a?.txta1.txt、a2.txt? 正好一个字符
a[12].txta1.txt、a2.txt集合里任选一个
{main,notes}.*main.md、notes.txt先展开成两个模式,再 glob
docs/**/*.mddocs/guide.md、docs/deep/api.md**/ 递归任意层(zsh 默认,bash 要先 shopt -s globstar)

{a,b} 严格说是 brace expansion,不是 glob;它先展开成多个词,之后每个词还可能继续 glob。

以 . 开头的隐藏文件默认不匹配,要写 .*。

** 是递归 glob:沿着目录往下走,不限制层数。

shell默认说明
bash** 等同 *,只匹配一层要 shopt -s globstar 才递归
zsh**/ 默认递归** 省略 / 的写法看 GLOB_STAR_SHORT
终端窗口
# zsh 默认;bash 要先 shopt -s globstar
printf '%s\n' docs/**/*.md
# → docs/guide.md
# → docs/deep/api.md

bash 没开 globstar 时,docs/**/*.md 等价于 docs/*/*.md,只匹配 docs/deep/api.md,不会匹配 docs/guide.md。

不想依赖 shell 设置就用:

终端窗口
find docs -name '*.md'

bash 默认把模式原样传下去:

终端窗口
printf '[%s]\n' *.nope
# [*.nope]

程序收到的是字面量 *.nope,不是空列表。zsh 默认会直接报 no matches found。

终端窗口
ls *.md # glob,Shell 展开
grep '.*\.md' # 正则,grep 自己解释
  • glob 是 Shell 的路径匹配语言,只有 *、?、[] 这些简单能力
  • 正则由 grep、sed、awk 等程序解释,表达能力强得多
  • 加引号就是把模式原样交给程序,不让 Shell 先展开

一个常见错误:

终端窗口
# 假设当前目录有 a.md、b.md
grep *.md # 展开成 grep a.md b.md;a.md 被当成模式

变量展开后如果没加引号,结果里的 * 也会继续做 glob。所以变量一般都要加引号。

Shell 不是凭空知道 ls 在哪,它查 $PATH。

终端窗口
echo $PATH
which python3
which -a python3
/usr/bin/python3 --version

$PATH 是冒号分隔的目录列表。Shell 按顺序找同名可执行文件,找到第一个就执行。

  • which 显示第一个匹配
  • which -a 显示所有匹配
  • 直接写完整路径可以绕过 $PATH
  • cd、export 这类内建命令和 if、while 这类语法不查 $PATH
  • echo 在 bash 里通常也是内建;ls、grep 才是典型的外部程序
  • $PATH 是环境变量,会被子进程继承

找不到就是 command not found。同名程序在多个目录里时,前面的赢,所以 $PATH 的顺序很重要。

Shell 自己是一个进程。要跑外部程序时,它大致做:

pid = fork()
if pid == 0:
execve("/bin/ls", ["ls", "-l"], env)
else:
wait(pid)
  • fork():复制当前进程,得到一个子进程;子进程有自己的一份内存
  • execve():把子进程的内存镜像替换成新程序;PID 不变
  • 父进程 wait():等子进程结束,拿返回码
  • $? 就是最近一次等待得到的返回码

cd、export 这类内建命令不 fork,直接在 Shell 进程里执行;if、while 本来就是 Shell 语法。外部命令才走 fork + exec。

子进程只能改自己的当前目录,改不了父进程的。

如果 Shell fork 一个 cd 程序,子进程 chdir() 成功后退出,父 Shell 的当前目录根本没变。所以 cd 只能由 Shell 自己实现。

点开终端图标,看起来只是多了一个窗口,背后其实有三个角色:

角色例子负责
终端模拟器GNOME Terminal、iTerm2画字符、收键盘、鼠标、粘贴
Shellbash、zsh读命令、解析、执行
内核Linux / macOS提供 PTY、行规程、发信号

它们不是直接连在一起的。终端模拟器和 Shell 中间隔着一对 PTY。

先补一个 Unix 世界观:一切皆文件

Section titled “先补一个 Unix 世界观:一切皆文件”

stdin / stdout / stderr 在 Shell 和系统调用层面就是文件描述符(file descriptor,fd)0/1/2:

fd名字默认指向
0stdin终端
1stdout终端
2stderr终端

Unix 把很多东西都抽象成文件:普通文件、目录、设备、管道、socket、PTY。进程不用关心 fd 后面是磁盘、管道还是终端,它只读 fd 0、写 fd 1。

fd 是进程级的。fork 后子进程继承一份 fd 表,exec 替换程序但保留 fd。所以 Shell 只要在 exec 之前改掉 fd 的指向,命令就会自动继承。

重定向和管道,做的都是同一件事:

终端窗口
ls > out.txt # Shell 把 fd1 指向 out.txt,再 exec ls
ls 2> err.txt # fd2 指向 err.txt
ls | grep foo # ls 的 fd1 → 管道写端;grep 的 fd0 → 管道读端

看当前 Shell 的 fd:

终端窗口
ls -l /proc/$$/fd
# 0 → /dev/pts/3
# 1 → /dev/pts/3
# 2 → /dev/pts/3

macOS 没有 /proc,可以用:

终端窗口
lsof -p $$ -a -d 0,1,2

Shell 是普通程序,它不知道自己在 GUI 窗口里。它期望 fd 0/1/2 指向一个「终端设备」,这样这些功能才工作:

  • isatty(0) 返回真
  • 行编辑、回显、Ctrl-C
  • tty、stty、作业控制
  • vim、less 这类全屏程序

以前这个终端设备是真硬件;现在没有硬件了,内核就提供一个伪终端(pseudo-terminal,PTY)。

PTY 是一对设备:

  • master:终端模拟器拿着
  • slave:Shell 拿着,看起来就像 /dev/pts/3 这样的真实终端

从「一切皆文件」的角度看,PTY 只是内核提供的一对设备文件:Shell 的 fd 0/1/2 指向 slave,终端模拟器读写 master。

终端模拟器把字节写到 master,Shell 从 slave 读到。Shell 把输出写到 slave,终端模拟器从 master 读到。它像一个双向管道,但 slave 端多了「终端语义」:行缓冲、回显、信号、窗口大小。

  1. 终端模拟器启动,向内核要一对 PTY
  2. 它 fork 一个子进程
  3. 子进程把 slave 接到自己的 stdin / stdout / stderr
  4. 子进程 exec 成 Shell
  5. Shell 往 slave 打印提示符,终端模拟器从 master 读到并画出来

很多终端模拟器会用 forkpty() 把 1 到 3 步打包。exec Shell 还是后面单独做。

假设现在是默认的规范模式,你在提示符下输入 ls,然后按 Enter:

你按 'l'
→ 终端模拟器把 'l' 写到 PTY master
→ 行规程暂存并回显 'l'
→ 终端模拟器读到回显,画到屏幕
你按 Enter
→ 行规程把这一行交给 Shell
→ Shell 从 PTY slave 读到 "ls\n"
→ Shell 解析,fork + exec ls
→ ls 继承 slave 作为 stdout,写出文件名
→ 终端模拟器从 master 读到,渲染到窗口
  • 终端模拟器不知道 ls 是什么,它只转发字节
  • Shell 不负责字体、颜色和窗口渲染,它只读写 slave
  • 默认规范模式下,按键先由行规程整理成一行。bash 的 readline 还会接管补全和历史
端谁拿着看到的东西
master终端模拟器字节流:键盘输入、要显示的输出
slaveShell / 命令一个终端设备,比如 /dev/pts/3
终端窗口
tty # 当前终端设备,通常是 /dev/pts/N
ps -p $$ -o tty=,comm= # 当前 Shell 挂在哪个终端上

所以 tty 打印的是 slave 端的设备名。

行规程:按键在到达 Shell 之前先经过内核

Section titled “行规程:按键在到达 Shell 之前先经过内核”

行规程(line discipline)属于内核 TTY 驱动。它负责把键盘字节整理成 Shell 能读的行。

默认的规范模式(canonical mode)下,行规程负责这些事:

输入行规程的动作
普通字符放进缓冲区并回显
Enter把这一行交给 Shell
Backspace删除缓冲区最后一个字符
Ctrl-C产生 SIGINT,发给前台进程组
Ctrl-\产生 SIGQUIT
Ctrl-Z产生 SIGTSTP,把进程挂起
Ctrl-D不是信号;空行时表示 EOF,read 返回 0

vim、less 这类全屏程序会切到 raw 模式,自己读每个按键、自己重画屏幕,所以按键行为又不一样。bash 的 readline 也会切到自己的模式处理行编辑、补全和历史,但 Ctrl-C 这类信号仍由行规程产生。

终端窗口
stty -a # 看当前行规程设置
stty size # 终端行列数

PTY 的生命周期和终端窗口绑在一起:打开终端时创建,关闭时销毁。

关闭窗口时,master 端关闭,内核会给这个终端的前台进程组发 SIGHUP。Shell 和它的前台任务通常跟着退出。

所以「关掉终端后进程还活着」通常只有两种情况:

  • 进程忽略了 SIGHUP:nohup 或 disown -h
  • 进程的 PTY 不在这个窗口上:tmux / screen 的 server 持有自己的 PTY,关闭窗口只是断开 client,Shell 仍然挂在 server 的 PTY 上

前面「一切皆文件」那节已经看过:stdin / stdout / stderr 就是 fd 0/1/2,默认都指向终端。程序只读写这三个 fd,Shell 在 exec 前改掉它们的指向,就得到重定向和管道。

写法fd 变化作用
ls > files.txtfd1 → files.txt标准输出写入文件,已存在则清空
echo new >> notes.txtfd1 → notes.txt 末尾标准输出追加
wc -l < notes.txtfd0 ← notes.txt标准输入从文件读
ls /nope 2> err.txtfd2 → err.txt标准错误写入文件
ls | grep foo左 fd1 → 右 fd0前一个命令的 stdout 接到后一个命令

前四行是重定向,改的是 fd 指向哪个文件。最后一行是管道,改的是 fd 接到另一个进程。

重定向就是 Shell 在启动命令前,把 fd 0/1/2 从终端改到文件。程序只看到 fd,不关心它后面是终端还是文件。

前面表里的四种写法就是这件事:> 和 >> 改 fd 1,< 改 fd 0,2> 改 fd 2。以 cmd > out 为例,Shell 启动 cmd 前把 fd 1 指向 out,cmd 自己不用打开文件。

2>&1 最容易写错。它让 fd 2 指向 fd 1 当前指向的地方。重定向从左到右执行:

终端窗口
cmd > out 2>&1 # fd1 → out,fd2 复制 fd1,两个都进 out
cmd 2>&1 > out # fd2 先复制终端,fd1 再指向 out

第二条里,stderr 留在终端,只有 stdout 进 out。

> 还会在命令启动前就把文件截断。命令随后失败,原内容也回不来。set -C(noclobber)可以阻止覆盖已有文件,>| 强制覆盖。

exec 改的是 Shell 自己的 fd,常用于脚本里临时重定向:

终端窗口
exec 3>&1 # 把 stdout 复制到 fd 3
exec >log 2>&1 # Shell 的 stdout/stderr 都进 log
...
exec >&3 2>&3 # 恢复
exec 3>&-

0/1/2 只是默认的三个 fd,进程还可以有更多 fd。这里用空闲的 3 暂存 stdout;exec >&3 2>&3 从 3 恢复,exec 3>&- 把 3 关掉。

sudo cmd > /root/out 里,> 是当前用户的 Shell 处理的。Shell 会先打开 /root/out,再运行 sudo;普通用户通常没有权限,cmd 还没开始运行就失败了。

要让 root 处理整条命令,包括重定向:

终端窗口
sudo sh -c 'cmd > /root/out'

sh -c 把后面整段字符串交给 root 的 Shell,> 也就由 root 打开。

管道把一个命令的 stdout 直接交给另一个命令的 stdin。pipe 是内核里的一块缓冲区,有两端:读端和写端。

终端窗口
ls | grep foo

ls 的 stdout 不再打到终端,而是写进一个 pipe;grep 的 stdin 从这个 pipe 读。两边同时跑,Shell 不会先把 ls 的输出存起来。

a | b | c 会接出两个 pipe:

a: fd1 ──▶ pipe1 写端
b: fd0 ◀── pipe1 读端
fd1 ──▶ pipe2 写端
c: fd0 ◀── pipe2 读端

Shell 建好 pipe 后,把上一个命令的 stdout 接到写端,下一个命令的 stdin 接到读端,再启动这些命令。父 Shell 关掉多余的 pipe fd,等它们结束。

  • 三个进程是并发跑的,不是等 a 结束再跑 b。
  • 默认只接 fd 1;stderr 还在终端。
  • pipe 有内核缓冲区(Linux 默认 64 KiB)。读得慢,写端会阻塞,形成反压。
  • 所有写端关闭后,读端才读到 EOF。谁漏关写端,读端就会一直等。
  • 读端提前退出,写端会收到 SIGPIPE,默认终止,退出码 141。yes | head -n 3 就是这种情况。

$? 只看最后一段:

终端窗口
false | true
echo "$?" # 0

这在脚本里很容易误判:前面的命令失败了,返回值仍然是 0。要看每一段,用 bash 的 PIPESTATUS(zsh 是 pipestatus):

终端窗口
false | true
printf '%s\n' "${PIPESTATUS[@]}" # 1 0

PIPESTATUS 会被下一条命令覆盖,所以要先存下来:

终端窗口
false | true
status=("${PIPESTATUS[@]}")
printf '%s\n' "${status[@]}"

如果想让管道在任意一段失败时返回非零,打开 pipefail:

终端窗口
set -o pipefail
false | true
echo "$?" # 1

pipefail 和 set -e 一起用时,yes | head -n 1 会因为 yes 收到 SIGPIPE 而让脚本退出。grep 没匹配也返回 1,这类命令要放在 if 里,或者显式处理。

make 的正常输出走 stdout,错误走 stderr。管道默认只接 stdout,所以 make | grep error 会漏掉错误。

把 stderr 并到 stdout,再进管道:

终端窗口
make 2>&1 | tee build.log | grep -i error

分三步看:

  1. 2>&1 让 make 的 stderr 指向 stdout 当前的目标,也就是后面的 pipe。
  2. tee build.log 把收到的内容写一份到 build.log,同时继续往 stdout 传。
  3. grep -i error 从传下来的内容里筛出包含 error 的行。

2>&1 的位置很重要:

终端窗口
make 2>&1 | tee build.log # make 的 stderr 进管道
make | tee build.log 2>&1 # 2>&1 作用在 tee 上,make 的 stderr 还在终端

tee 默认覆盖 build.log,tee -a 追加。

这里的缓冲是程序自己的输出缓冲,不是前面 pipe 的内核缓冲区。很多程序不会每写一次就立刻落到文件,而是先攒在内存缓冲区里:

  • stdout 连终端时,遇到换行符 \n 就写出去。这叫行缓冲。
  • stdout 连文件或管道时,会先攒一大块,攒满或程序退出才写。这叫全缓冲。
  • stderr 通常不攒,直接写。这叫无缓冲。

所以程序先往 stdout 写 out、再往 stderr 写 err 时,out 可能还在缓冲区里,err 已经写进文件。两个流都重定向到同一个文件,log 里就可能先看到 err,再看到 out:

终端窗口
cmd > log 2>&1

重定向顺序没问题,差别在两边写出的时间不同。

想让 log 的顺序更接近程序实际写出的顺序,可以让 stdout 按行刷新:

  • stdbuf -oL cmd:让使用 stdio 的程序把 stdout 改成行缓冲。
  • grep --line-buffered:只让 grep 自己每行输出,不攒着。
  • unbuffer cmd / script:给程序接一个伪终端,让它以为自己在跟终端说话,从而走行缓冲。

这些办法让 stdout 更早写出来,err 就不会因为缓冲而抢在更早的 out 前面。

但是多个线程或多个进程同时写时,仍然不能保证绝对顺序。

  • 进程替换:diff <(sort a) <(sort b);tee >(gzip > out.gz) 可以一边保留输出一边压缩。
  • 命名管道:mkfifo /tmp/p,一个进程写 /tmp/p,另一个进程读。
  • xargs:把 stdin 的每一行变成 argv;文件名有空格时用 find ... -print0 | xargs -0 ...。
  • pv:查看管道吞吐,cmd | pv | gzip > out.gz。
  • here-doc / here-string:cat <<'EOF' ... EOF、grep foo <<< "$var",数据来自 Shell 而不是文件。
  • |&:bash 里等价于 2>&1 |,make |& grep error 可以直接把 stderr 也接进管道。
  • 多个写者同时写一个 pipe 时,单个 write 不超过 PIPE_BUF(Linux 通常 4 KiB)才不会交错;更大的写入可能被切开。
  • 交互式 Shell 有 job control 时,整条管道在一个前台进程组里,Ctrl-C 的 SIGINT 会发给整个进程组。
问题答案
终端和 Shell 是一回事吗?不是,终端是窗口,Shell 是里面跑的程序
$SHELL 是当前 Shell 吗?不一定,它是登录 Shell;交互式 Shell 看 $0,更稳的是 ps -p $$
$TERM 是 Shell 类型吗?不是,是终端能力,比如 xterm-256color
终端里只能跑 Shell 吗?不是,也可以直接跑 vim、ssh、python
Shell 一定要有终端吗?不用,脚本、CI、ssh host 'cmd' 都可以
单引号和双引号一样吗?不一样,单引号全字面,双引号会展开变量和命令替换
glob 和正则一样吗?不一样,glob 由 Shell 展开,正则由 grep、sed 等程序解释
cd 为什么不能是外部程序?子进程改不了父进程的当前目录
Ctrl-C 是终端杀进程吗?终端只发字节,内核行规程产生 SIGINT,发给前台进程组
管道两边是同时跑的吗?是,Shell 只负责接 fd,不缓存整段输出
2>&1 为什么要看顺序?它复制的是当前 fd1 的指向,重定向从左到右执行
pipefail 和 PIPESTATUS 区别?pipefail 改 $?,PIPESTATUS 保留每一段状态

这些工具不用背参数,先知道它们存在,用的时候查帮助。

终端窗口
man ls # 完整手册,q 退出
ls --help # 常用参数
tldr ls # 常见例子
  • man 里按 / 搜索,n / N 跳下一个 / 上一个
  • --help 通常比 man 短,先看这个更快
  • tldr 只给常见例子,适合快速回忆
命令作用常见写法
cat打印整个文件cat file
less分页查看less file
head/tail文件头 / 尾head -n 20 file / tail -n 20 file
sort按行排序sort -n file / sort -u file
uniq去掉相邻重复行sort file | uniq -c
grep按正则搜索行grep -rn 'TODO' .
sed流编辑,常用来替换sed -i 's/old/new/g' file
find按条件找文件find ~/Downloads -type f -name '*.zip' -mtime +30
awk按列处理文本awk -F, '{print $2}' data.csv

几个容易忘的点:

  • sort 默认字典序,数值排序要 -n
  • uniq 只去相邻重复,先 sort 再 uniq
  • grep 的 pattern 是正则,-r 递归,-l 只列文件名
  • sed -i 直接改文件,g 表示一行内全部替换
  • find 默认递归,-exec ... {} \; 对每个结果执行命令
  • awk 默认按空白分列,-F, 改成逗号分隔
modern-unix常见 Unix 命令的现代替代品合集