Skip to content

计算机教育中缺失的一课

INFO

参照 MIT 在线课程 The Missing Semester of Your CS Education 翻译和改编。 由 Gemini 2.5 Pro 生成,请对内容进行甄别。

The Shell

本教程旨在深入讲解 Shell 的核心概念和基本用法,让你不仅知道如何使用,更能理解其工作原理。

Shell 是什么?—— 与计算机对话的根本

我们日常使用的图形用户界面(GUI)虽然直观,但其功能被设计者预先设定好了。你无法点击一个不存在的按钮,也无法执行一个未被编程的语音命令。

Shell 提供了一种更根本、更强大的交互方式:文本接口。它是一个命令行解释器,是你与操作系统内核沟通的桥梁。你输入文本命令,Shell 解析它们,并让操作系统执行相应的程序。

本课程将重点使用 Bash (Bourne Again SHell),它是目前最流行、应用最广泛的 Shell 之一。

Shell 基础:命令与参数

打开终端后,你会看到一个提示符 (Prompt),通常长这样:

console
missing:~$
  • missing: 当前登录的主机名。
  • ~: 当前所在的目录。~ 是主目录(Home Directory)的简写。
  • $: 提示符的结束,表示当前是普通用户。如果是 _,则代表超级用户(root)。

执行程序

在提示符后输入命令,就是让 Shell 执行一个程序。

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

这里,我们执行了名为 date 的程序,它打印出当前的日期和时间。

传递参数

大多数程序都可以接受参数 (Arguments),以改变其行为。Shell 使用空格来分割命令和参数。

console
missing:~$ echo hello world
hello world

在这个例子中:

  1. echo 是要执行的程序。
  2. hello是传递给 echo 的第一个参数。
  3. world 是第二个参数。 echo 程序的功能就是把它接收到的所有参数打印出来。

处理带空格的参数:如果你的参数本身就包含空格(例如文件名 "My Photos"),你需要用引号或转义符来告诉 Shell 这是一个单独的参数。

console
## 使用双引号
missing:~$ echo "hello world"
hello world

## 使用单引号
missing:~$ echo 'hello world'
hello world

## 使用反斜杠 `\` 进行转义
missing:~$ echo hello\ world
hello world

Shell 如何找到程序?$PATH 环境变量

当你输入 date 时,Shell 是如何知道去哪里找到并执行这个程序的呢?

答案是环境变量 $PATH。它是一个包含多个目录路径的列表,用冒号 : 分隔。当你输入一个命令时,Shell 会依次在 $PATH 列表的这些目录中查找同名的可执行文件。

console
## 查看 $PATH 的内容
missing:~$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

## 使用 which 命令查看某个程序的确切路径
missing:~$ which echo
/bin/echo

which echo 的输出 /bin/echo 告诉我们,echo 程序位于 /bin 目录下,而 /bin 正是 $PATH 列表中的一员。

我们也可以通过提供程序的绝对路径来绕过 $PATH 的搜索,直接执行它:

console
missing:~$ /bin/echo "Directly executed"
Directly executed

在文件系统中穿梭

文件系统是一个树状的目录结构。掌握如何在其中自由移动是 Shell 操作的基础。

  • /: 根目录,所有文件和目录的起点。
  • 绝对路径: 从根目录 / 开始的路径,例如 /home/user/documents
  • 相对路径: 从当前目录开始的路径,例如 ../images

常用导航命令

命令功能示例
pwdprint working directory,显示当前工作目录的绝对路径。pwd
cdchange directory,切换目录。cd /var/log (切换到 /var/log)
cd ..切换到上级目录cd ..
cd ~cd切换到主目录 (Home Directory)。cd
.代表当前目录ls .
..代表上级目录ls ..

示例:

console
## 查看当前位置
missing:~$ pwd
/home/missing

## 切换到根目录
missing:~$ cd /
missing:/$ pwd
/

## 切换回主目录下的 missing 文件夹(使用相对路径)
missing:/$ cd home/missing
missing:~$ pwd
/home/missing

文件与目录操作

ls:列出文件

ls 命令用于列出目录内容。它支持非常多的选项(flags)来改变输出格式。

  • ls: 列出当前目录的内容。
  • ls /home: 列出 /home 目录的内容。
  • ls -l: 使用长列表格式(long listing format),显示更详细的信息。
  • ls -a: 显示所有文件,包括以 . 开头的隐藏文件。
  • ls -t: 按修改时间排序。
  • ls -lh: 结合 -l,并以人类可读的格式显示文件大小 (e.g., K, M, G)。

解读 ls -l 的输出:

console
missing:~$ ls -l /home
drwxr-xr-x 1 missing  users  4096 Jun 15  2019 missing

让我们分解这行信息的含义:

部分示例解释
文件类型dd: 目录, -: 普通文件, l: 符号链接
权限rwxr-xr-x分为三组:用户 (owner), 用户组 (group), 其他人 (others)r: 读, w: 写, x: 执行。
硬链接数1指向此文件的硬链接数量。
所有者missing拥有此文件的用户。
所属组users拥有此文件的用户组。
大小4096文件大小(以字节为单位)。
修改日期Jun 15 2019最后修改的日期和时间。
文件名missing文件或目录的名称。

其他核心命令

命令功能示例
mkdirmake directory,创建新目录。mkdir new_project
touch创建一个空文件,或更新已存在文件的时间戳。touch README.md
mvmove,移动或重命名文件/目录。mv old.txt new.txt (重命名)
mv report.pdf ./docs/ (移动)
cpcopy,复制文件/目录。cp source.txt dest.txt (复制文件)
cp -r source_dir/ dest_dir/ (递归复制目录)
rmremove,删除文件。注意:此操作不可恢复!rm temp.txt
rm -r old_project/ (递归删除目录)
manmanual,查看命令的帮助手册。按 q 退出。man ls

连接命令:输入/输出流与管道

Shell 的真正威力在于将简单的工具组合起来完成复杂的任务。这是通过流 (streams)管道 (pipes) 实现的。

每个程序主要有两个流:

  • 输入流 (stdin): 程序读取数据的地方,默认为键盘。
  • 输出流 (stdout): 程序写入数据的地方,默认为屏幕。

重定向 (Redirection)

我们可以改变流的默认目标,这就是重定向

  • >: 重定向输出。将命令的输出写入文件,会覆盖文件原有内容

    console
    ## 将 "hello" 写入 file.txt。如果 file.txt 已存在,其内容将被覆盖。
    missing:~$ echo "hello" > file.txt
  • >>: 追加重定向输出。将命令的输出追加到文件末尾。

    console
    ## 在 file.txt 的末尾追加 "world"。
    missing:~$ echo "world" >> file.txt
  • <: 重定向输入。让命令从文件而不是键盘读取输入。

    console
    missing:~$ cat < file.txt
    hello
    world

管道 (Pipes)

管道 | 是 Shell 的精髓。它将一个命令的输出流 (stdout) 直接连接到另一个命令的输入流 (stdin)

示例: 假设你想找到 /etc 目录下所有包含 "network" 字符串的文件名。

console
## 1. `ls -l /etc` 会输出 /etc 下的所有文件信息。
## 2. `|` 将这些信息作为 `grep` 命令的输入。
## 3. `grep network` 会在输入中筛选出包含 "network" 的行。
missing:~$ ls -l /etc | grep network
-rw-r--r-- 1 root root      841 Apr 15  2020 networks

超级用户 sudo

在类 Unix 系统中,root 用户拥有至高无上的权限,可以执行任何操作。为了避免误操作损坏系统,我们通常以普通用户身份工作,只在需要时临时获取 root 权限。

sudo ( super user do ) 命令就是为此而生。它允许你以 root 身份执行单条命令。

console
## 尝试在系统目录 /etc 下创建文件,将会失败
missing:~$ touch /etc/test_file
touch: cannot touch '/etc/test_file': Permission denied

## 使用 sudo 执行,输入你的密码后,命令成功
missing:~$ sudo touch /etc/test_file

sudo 与重定向的陷阱

一个常见的误区是 sudo 和输出重定向 > 的结合使用。

错误示例: 假设你想向一个只有 root 才能写入的文件 /sys/class/backlight/intel_backlight/brightness 写入数值 500 来调节屏幕亮度。

console
## 这个命令会失败!
missing:~$ sudo echo 500 > /sys/class/backlight/intel_backlight/brightness
bash: /sys/class/backlight/intel_backlight/brightness: Permission denied

为什么会失败? 重定向符号 > 是由你当前的 Shell (以普通用户身份运行) 来解析的,而不是由 sudo 之后的 echo 命令。因此,Shell 在执行命令前,会先尝试以普通用户权限去打开并清空 brightness 文件,这个操作自然会被拒绝。

正确做法:使用 tee 命令tee 命令会从其输入流中读取内容,并将其同时写入到输出流和指定的文件中。我们可以让 teesudo 权限下运行。

console
## `echo 500` 的输出通过管道传给 `sudo tee`
## `tee` 命令在 root 权限下运行,因此有权限写入 brightness 文件
missing:~$ echo 500 | sudo tee /sys/class/backlight/intel_backlight/brightness

课后练习

现在,动手实践是巩固知识的最佳方式。请尝试完成以下任务:

  1. 环境检查: 打开你的终端,执行 echo $SHELL,确保你使用的是 Bash 或 Zsh。

  2. 创建目录: 在 /tmp 目录下,创建一个名为 missing 的新目录。

  3. 学习 touch: 使用 man touch 命令阅读 touch 的手册。

  4. 创建文件: 在 /tmp/missing 目录中,创建一个名为 semester 的空文件。

  5. 写入脚本: 使用 echo 和重定向,将以下两行内容写入 semester 文件。

    • #!/bin/sh
    • curl --head --silent https://missing.csail.mit.edu

    提示: 由于 ! 在 Bash 中有特殊含义,直接使用 echo "..." 可能会遇到问题。尝试使用单引号 '...' 来写入第一行。

  6. 尝试执行: 在终端中输入 ./semester 并回车,尝试执行它。你会看到一个 "Permission denied" 的错误。使用 ls -l semester 查看它的权限,理解为什么它不能被执行。

  7. 学习 chmod: 阅读 chmod 的手册 (man chmod)。

  8. 添加权限: 使用 chmod +x semester 命令为文件添加执行权限。再次执行 ./semester,观察它是否成功运行。

  9. 组合命令: 使用管道 | 和重定向 >,将 semester 脚本输出内容中包含 last-modified 的那一行,保存到你的主目录(~)下的 last-modified.txt 文件中。

  10. 系统探索 (Linux 用户): 编写一条命令,从 /sys 目录中找到并显示你笔记本的电池电量信息(通常在 /sys/class/power_supply/ 下的某个文件里)。

Shell 工具和脚本

Shell 脚本入门

我们已经学会了如何单独执行命令和使用管道 | 连接它们。但要实现更复杂的自动化任务,我们需要编写脚本 (Script)

Shell 脚本的优势在于,它为命令行操作(如创建命令管道、读写文件、获取输入)提供了原生的、简洁的支持,这比通用的脚本语言(如 Python)更直接。

变量

在 Bash 中,变量赋值的语法非常严格,等号两边不能有空格

bash
## 正确的赋值
foo=bar

## 错误的赋值 (会被解析为:执行 `foo` 命令,参数为 `=` 和 `bar`)
foo = bar

使用 $ 符号来访问变量的值。

单引号 vs. 双引号

  • 单引号 ('): 定义原义字符串 (Literal String)。其中的任何字符(包括 $)都会被视为普通字符,变量不会被展开。
  • 双引号 ("): 定义解释字符串 (Interpreted String)。其中的变量会被替换为其对应的值。
bash
foo="bar"

## 双引号会进行变量替换
echo "The value of foo is $foo"
## 输出: The value of foo is bar

## 单引号不进行变量替换
echo 'The value of foo is $foo'
## 输出: The value of foo is $foo

Shell 中的特殊变量

Bash 提供了一系列特殊的内置变量,用于访问脚本参数、执行状态等信息。这些变量是编写健壮脚本的关键。

变量解释示例
$0脚本本身的名称
$1, $2...$9位置参数$1 是第一个参数,$2 是第二个,以此类推。
$@所有参数。将每个参数视为一个独立的字符串。这是最常用的参数变量。for arg in "$@"
$#参数的个数if [ $## -lt 2 ]
$?上一个命令的退出状态码 (Exit Code)0 表示成功,非 0 表示失败。grep "foo" file.txt; echo $?
$$当前脚本的进程 ID (PID)echo "My PID is $$"
!!上一条完整的命令,包括其参数。apt install vim (失败) -> sudo !! (以 sudo 再次执行)
$_上一条命令的最后一个参数mkdir my_dir; cd $_

函数

函数用于将一系列命令组合成一个可复用的代码块,让你的脚本更加模块化。

定义函数的两种语法:

bash
## 语法一 (推荐)
my_func() {
    ## 函数体
    echo "Hello from function!"
}

## 语法二
function another_func {
    ## 函数体
    echo "Hello again!"
}

函数参数: 函数内部使用 $1, $2, $@ 等特殊变量来接收传递给它的参数,用法与脚本参数完全相同。

示例:一个创建并进入目录的函数 mcd

bash
## 创建一个目录,并立即 cd 进去
mcd() {
    mkdir -p "$1"
    cd "$1"
}
  • mkdir -p: -p 选项确保即使父目录不存在也能成功创建。
  • "$1": 始终用双引号包裹参数变量。这是一个至关重要的好习惯,可以防止在参数包含空格或特殊字符时出现问题。

控制流:if 语句和逻辑操作符

退出状态码与逻辑判断

在 Shell 中,命令的成功或失败是通过退出状态码 ($?) 来判断的。

  • 0: 成功
  • 0: 失败

&& (与) 和 || (或) 这两个操作符会根据前一个命令的退出状态码来决定是否执行下一个命令。

  • command1 && command2: 只有当 command1 成功时,才会执行 command2
  • command1 || command2: 只有当 command1 失败时,才会执行 command2
bash
## 如果 `mkdir` 成功,则执行 `echo`
mkdir my_dir && echo "Directory created successfully"

## 如果 `grep` 失败 (没找到),则执行 `echo`
grep "non_existent_pattern" file.txt || echo "Pattern not found"
if 语句

使用 if 语句可以构建更复杂的条件逻辑。

基本语法:

bash
if [[ condition ]]; then
    ## ... commands to execute if condition is true ...
elif [[ another_condition ]]; then
    ## ... commands to execute if another_condition is true ...
else
    ## ... commands to execute if no conditions are true ...
fi

关键点:

  • 使用双方括号 [[ ... ]] 进行条件测试。它比单方括号 [ ... ] 更健壮、更安全,错误更少。
  • [[]] 的两边必须有空格。
  • then 必须单独占一行,或者用分号 ; 与条件隔开:if [[ ... ]]; then

常用的条件测试操作符:

操作符含义
-f file如果 file 是一个文件,则为真。
-d dir如果 dir 是一个目录,则为真。
-e path如果 path 存在,则为真。
-z string如果 string 为空,则为真。
-n string如果 string 不为空,则为真。
str1 == str2如果两个字符串相等,则为真。
str1 != str2如果两个字符串不相等,则为真。
num1 -eq num2如果两个数字相等 (equal),则为真。
num1 -ne num2不相等 (not equal)
num1 -lt num2小于 (less than)
num1 -gt num2大于 (greater than)
num1 -le num2小于等于 (less or equal)
num1 -ge num2大于等于 (greater or equal)

命令替换

命令替换 $( ... ) 允许你将一个命令的输出结果捕获并赋值给一个变量,或者作为另一个命令的一部分。

bash
## 获取当前日期并赋值给变量
current_date=$(date +%Y-%m-%d)
echo "Today is $current_date"

## 在 for 循环中使用命令替换
echo "Listing all .txt files:"
for file in $(ls *.txt); do
    echo "  - $file"
done

Shell 通配 (Globbing)

Globbing 是 Shell 的一种模式匹配功能,用于匹配文件名。

  • *: 匹配任意数量的任意字符。
    • *.log: 匹配所有以 .log 结尾的文件。
  • ?: 匹配单个任意字符。
    • image_?.png: 匹配 image_1.png, image_A.png 但不匹配 image_10.png
  • {}: 花括号扩展。用于生成一系列字符串。
    • mv image.{jpg,jpeg} 会被 Shell 扩展为 mv image.jpg image.jpeg
    • touch report-{2022,2023,2024}.md 会创建三个文件。
    • touch {foo,bar}/{a..d}.txt 会创建 foo/a.txt, foo/b.txt... bar/d.txt

强大的 Shell 工具

查找文件

find: 精确而强大的传统工具

find 是一个功能极其丰富的搜索工具,它会递归地搜索指定目录下的文件,并可以根据名称、类型、大小、修改时间等多种条件进行过滤。

find [path] [expression]

常用表达式:

表达式功能
-name "pattern"按文件名模式查找(区分大小写)。
-iname "pattern"按文件名模式查找(不区分大小写)。
-type f查找普通文件。
-type d查找目录。
-mtime -N查找 N 天内修改过的文件。
-mtime +N查找 N 天前修改过的文件。
-size +10M查找大于 10MB 的文件。
-size -1G查找小于 1GB 的文件。

find-exec 动作:-exec 允许你对 find 找到的每一个结果执行一个命令。

  • {}: 代表 find 找到的当前文件。
  • ;: 是 -exec 命令的结束符,需要用 \ 转义。
bash
## 查找所有 .tmp 文件并删除它们
find . -name "*.tmp" -type f -exec rm {} \;

## 将所有 .jpeg 文件重命名为 .jpg
find . -name "*.jpeg" -type f -exec mv {} {}.jpg \;  ## 这会变成 a.jpeg.jpg,需要更复杂的处理
fd: 简单、快速、友好的替代品

find 的语法有时会比较繁琐。fd 是一个现代化的替代品,它更快、更直观,并提供了合理的默认设置(如自动忽略 .gitignore 中的文件,默认使用正则表达式)。

find 命令fd 命令
find . -name "pattern"fd pattern
find . -iname "pattern"fd -i pattern
find . -type f -name "*.py"fd -e py

查找文件内容 (grep 及其替代品)

grep: 内容搜索的瑞士军刀

grep 用于在文件或标准输入中搜索匹配指定模式的行。

常用选项:

选项功能
-iignore-case,忽略大小写。
-r-Rrecursive,递归搜索子目录。
-vinvert-match,反向选择,即打印不匹配的行。
-C NContext,打印匹配行及其前后各 N 行的上下文。
-lfiles-with-matches,只打印包含匹配项的文件名。
bash
## 在所有 .py 文件中递归搜索 "import requests",不区分大小写
grep -ir "import requests" . --include="*.py"
rg (ripgrep): grep 的超高速替代品

fd 类似,rg (ripgrep) 是 grep 的一个现代替代品。它非常快,并且默认行为很智能(例如,自动忽略二进制文件和 .gitignore 中的文件)。

grep 命令rg 命令
grep -ir "pattern" .rg -i "pattern"
grep -irl "pattern" .rg -il "pattern"
grep -v "pattern" file.txtrg -v "pattern" file.txt
rg -t py "pattern" (只在 python 文件中搜索)

历史命令的查找与补全

  • history: 显示你执行过的所有命令的列表。
    • history | grep docker: 搜索历史记录中所有包含 "docker" 的命令。
  • Ctrl+R: 反向搜索。按下 Ctrl+R 后,开始输入你记得的命令片段,Shell 会实时显示最近的匹配项。持续按 Ctrl+R 会在所有匹配项之间循环。
  • fzf (Fuzzy Finder): 这是一个模糊查找工具,可以极大地增强 Ctrl+R 的体验,提供一个交互式的、可滚动的历史列表。
  • zsh-autosuggestions (Zsh 插件): 它会根据你的历史记录,在你输入时以灰色显示一个可能的命令补全建议。按 → (右方向键) 即可采纳。

xargs: 连接命令的桥梁

管道 | 将前一个命令的标准输出连接到后一个命令的标准输入。但有些命令(如 rm, cp, mkdir)并不从标准输入读取数据,而是期望从命令行参数中获取文件名。

xargs 的作用就是一座桥梁:它从标准输入读取数据(通常是文件名列表),然后将这些数据作为参数传递给它后面的命令。

场景:查找所有 .log 文件并压缩它们

bash
## 1. 找到所有 .log 文件,并将列表通过管道传给 xargs
## 2. xargs 读取文件名列表,然后将它们作为参数传递给 `tar` 命令
find . -name "*.log" | xargs tar -czvf logs.tar.gz

处理带空格的文件名

如果文件名包含空格,简单的 | xargs 会出错。安全的做法是让 find 输出以 NULL 字符 (\0) 分隔的文件列表,并告诉 xargs 以同样的方式解析。

bash
find . -name "*.log" -print0 | xargs -0 tar -czvf logs.tar.gz

课后练习

  1. ls 的高级用法:
    • 阅读 man ls
    • 编写一条 ls 命令,使其能够:
      1. 列出所有文件,包括隐藏文件 (.file)。
      2. 以人类可读的格式显示文件大小 (e.g., 1K, 234M, 2G)。
      3. 按最近修改时间排序。
      4. 输出结果带有颜色。
  2. marcopolo:
    • 编写两个 Bash 函数,marcopolo
    • 执行 marco 时,它应该保存当前的目录位置。
    • 执行 polo 时,它应该立即 cd 回到你执行 marco 时的那个目录。
  3. 循环调试:
    • 有一个偶尔会出错的脚本(见课程原文)。
    • 编写一个 Bash 脚本,循环执行这个 "不稳定" 的脚本,直到它出错为止。
    • 当出错时,将它的标准输出和标准错误都保存到一个日志文件中。
    • (加分项)在脚本结束时,报告它在失败前成功运行了多少次。
  4. findxargs:
    • 编写一条命令,递归地查找当前目录及子目录下的所有 .html 文件,并将它们打包成一个名为 archive.zip 的压缩文件。
    • 你的命令必须能正确处理带空格的文件名。
  5. (进阶) 查找最新文件:
    • 编写一个命令或脚本,可以递归地在目录中找到那个最后被修改的文件。
    • (更进一步)你能否让它按修改时间倒序列出所有文件吗?

编辑器 (Vim)

为什么程序员需要一个“特别”的编辑器?

编程和普通写作有本质区别。编程时,我们花费大量时间在阅读、跳转、修改代码片段,而不是像写文章那样进行大段的连续输入。因此,为编程设计的代码编辑器和为写作设计的文本编辑器(如 Word)是两种截然不同的工具。

掌握一个强大的代码编辑器,是一项高回报的投资。学习曲线可能如下:

  • 最初2小时:学习打开、编辑、保存、退出等基本操作,你会觉得效率比以前低。
  • 累计20小时:你的编辑速度将追平甚至超过使用旧编辑器的你。
  • 此后:随着肌肉记忆的形成和高级技巧的掌握,效率将大大提升。Vim 的学习永无止境,学的越多,效率越高。

Vim 的哲学:为何如此与众不同?

Vim (Vi IMproved) 是一个历史悠久、久经考验的编辑器。它的核心设计哲学基于一个关键洞察:程序员花费在移动、阅读和修改上的时间远多于纯粹的输入

为此,Vim 创造了几个核心理念:

  1. 多模态编辑 (Modal Editing):Vim 有不同的“模式”来处理不同的任务。在“插入模式”下打字,在“正常模式”下高效移动和修改。这避免了复杂的快捷键组合(比如 Ctrl+Shift+Alt+P)。
  2. 告别鼠标 (Mouse-Free Navigation):鼠标会打断你的心流,因为它需要你将手从键盘移开。Vim 的所有操作都可以通过键盘完成,甚至它鼓励你放弃方向键,因为 hjkl 键就在你的指尖。
  3. 像语言一样操作 (Interface as a Language):Vim 的操作是可组合的。一个动词(操作,如 d 代表删除)可以跟一个名词(移动范围,如 w 代表一个单词)组合成一个强大的命令(dw 删除一个单词)。

核心概念:Vim 的模式

Vim 的精髓在于其多种模式,不同的模式下,相同的按键有不同的含义。

  • 正常模式 (Normal Mode)

    • 这是 Vim 的默认和核心模式。你大部分时间都应该待在这里。
    • 此模式下,所有按键都是功能键,用于导航、删除、复制、粘贴等。
    • 如何进入:启动 Vim 时默认进入。任何其他模式下按 <ESC> 键即可返回正常模式。
  • 插入模式 (Insert Mode)

    • 此模式下,Vim 就像一个普通的文本编辑器,你输入的字符会直接显示在屏幕上。
    • 如何进入:在正常模式下按 i (insert) 键。左下角会显示 -- INSERT --
    • 如何退出:按 <ESC> 返回正常模式。
  • 命令模式 (Command-Line Mode)

    • 用于执行保存、退出、搜索、替换、显示行号等“编辑器级别”的命令。
    • 如何进入:在正常模式下按 : 键,光标会跳到底部命令行。
    • 如何退出:命令执行完后会自动返回正常模式,也可以按 <ESC>
  • 可视模式 (Visual Mode)

    • 用于选择文本块,类似于用鼠标拖拽高亮。
    • 如何进入:在正常模式下按 v (字符选择)、V (行选择) 或 Ctrl+v (块选择)。
    • 如何退出:按 <ESC>

高手提示<ESC> 键是使用最频繁的键。许多 Vim 用户会将键盘上的 Caps Lock (大小写锁定) 键映射为 <ESC>,这样左手小指就能轻松按到,极大提升效率。

Vim 基础操作:生存指南

打开终端,输入 vim filename.txt 即可创建或打开一个文件。现在,让我们学习如何生存下来。

保存与退出 (命令模式)

这些命令都在正常模式下,先输入 : 进入命令模式后执行。

命令功能
:wwrite,保存文件。
:qquit,退出 Vim。如果文件有未保存的修改,会提示错误。
:wq保存并退出。
:q!强制退出,不保存任何修改。
:x:wq效果相同,都是保存并退出。
:w new_filename将当前内容另存为 new_filename

Vim 的语言:操作 = 动词 + 名词

这是 Vim 最强大、最核心的概念。一个操作命令(动词)可以和任意一个移动命令(名词)组合使用。

移动 (名词)

正常模式下,忘记方向键,用下面的按键在文件中穿梭。

基本移动

  • h j k l:左、下、上、右。

词汇移动

  • w:移动到下一个word (单词) 的开头。
  • bbackward,移动到上一个单词的开头。
  • e:移动到当前单词的 end (结尾)。

行内移动

  • 0:移动到行首 (第0列)。
  • ^:移动到行内第一个非空白字符。
  • $:移动到行尾。

文件级移动

  • gg:移动到文件第一行。
  • G:移动到文件最后一行。
  • {行数}G:{行数}:跳转到指定行,例如 15G 跳转到第15行。

屏幕移动

  • H: High,移动到屏幕顶部。
  • M: Middle,移动到屏幕中间。
  • L: Low,移动到屏幕底部。
  • Ctrl+u / Ctrl+d: 向上/向下翻半页。

编辑 (动词)

正常模式下执行。

命令功能示例组合
ddelete,删除。dw (删除一个单词), d$ (删除到行尾), dG (删除到文件尾)
cchange,改变。删除后立即进入插入模式。cw (改变一个单词), c$ (改变到行尾)
yyank,复制。yw (复制一个单词), yy (复制整行)
ppaste,粘贴。在光标后粘贴。p (小写p)
PPaste,粘贴。在光标前粘贴。P (大写P)
uundo,撤销。u
<C-r>redo,重做。Ctrl+r

命令重复:许多命令可以通过连续按两次来作用于整行

  • dd:删除当前行。
  • cc:改变 (删除并重写) 当前行。
  • yy:复制当前行。

计数 (量词)

你可以在任何“动词+名词”的组合前加上一个数字,来重复执行它。

  • 3w:向后移动 3 个单词。
  • 5j:向下移动 5 行。
  • d2w:删除 2 个单词。
  • 3yy:复制 3 行。

修饰语

修饰语 i (inside) 和 a (around) 让操作更精确。

  • ci(change inside (),改变括号内的内容。
  • di"delete inside "",删除双引号内的内容。
  • ca'change around '',改变整个单引号字符串 (包括引号本身)。
  • dat: delete around tag,删除整个 HTML/XML 标签。

自定义与扩展 Vim

原生的 Vim 已经很强大,但通过配置和插件,你可以把它打造成属于你的终极编辑器。

配置文件 ~/.vimrc

Vim 的所有配置都存储在用户主目录下的 .vimrc 文件中。你可以用 Vim 打开它来编辑:vim ~/.vimrc

以下是一些推荐的基础配置:

vim
" 显示行号
set number

" 开启语法高亮
syntax on

" 设置缩进为4个空格
set tabstop=4
set shiftwidth=4
set expandtab

" 搜索时高亮匹配项
set hlsearch

扩展插件

从 Vim 8.0 开始,内置了强大的插件管理功能。你不再需要第三方的插件管理器。

安装插件的步骤:

  1. 创建插件目录:mkdir -p ~/.vim/pack/vendor/start
  2. 进入目录:cd ~/.vim/pack/vendor/start
  3. 克隆插件仓库:例如,安装模糊文件搜索插件 ctrlp.vimgit clone https://github.com/ctrlpvim/ctrlp.vim

一些广受欢迎的插件:

实践出真知:课后练习

  1. 完成官方教程: 在你的终端里输入 vimtutor 命令。这是一个交互式的官方入门教程,大约需要30分钟,是学习 Vim 最好的第一步。
  2. 配置你的 .vimrc: 创建并编辑你自己的 ~/.vimrc 文件,至少加上显示行号和语法高亮。
  3. 安装一个插件: 按照上面的步骤,尝试安装 ctrlp.vim 插件,并学习使用 Ctrl+P 在你的项目中快速查找文件。
  4. 坚持使用: 最重要的一步!强迫自己接下来一周内,所有的文本编辑任务都只用 Vim 完成。当感觉“一定有更好的方法”时,去网上搜索,你总能发现惊喜。

数据整理

什么是数据整理?

数据整理(Data Wrangling)是将数据从一种原始格式转换为另一种更有用、更结构化的格式的过程。在 Shell 环境中,这通常意味着将一系列简单的、专用的命令行工具通过管道 (|) 连接起来,一步步地过滤、转换和重塑文本数据,直至得到你想要的结果。

我们的核心场景是日志分析。日志文件记录了系统和应用程序的详细活动,但信息量巨大,手动阅读几乎不可能。通过数据整理,我们可以从中提取出有价值的信息。

核心工具链:日志分析实战

我们的目标是:分析服务器的 SSH 登录日志,找出尝试登录次数最多的用户名。

步骤 1: 获取和初步过滤 (ssh, grep)

首先,我们从远程服务器获取日志。ssh 命令允许我们在远程机器上执行命令,并将其输出流传送到本地。

bash
## 1. 登录到 myserver 并执行 journalctl 命令
## 2. 将远程命令的输出通过管道传送到本地的 grep 命令
## 3. grep 会筛选出所有包含 "sshd" 的行
ssh myserver journalctl | grep sshd

日志内容仍然太多。我们只关心用户断开连接的记录,因为这通常包含了用户名。我们可以串联另一个 grep 来进一步过滤。

bash
ssh myserver journalctl | grep sshd | grep "Disconnected from"

技巧:为了节省网络流量,更好的做法是在远程服务器上完成所有过滤,只将最终结果传回本地。

bash
## 在远程服务器上执行整个管道命令,然后将结果重定向到本地文件 ssh.log
ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' > ssh.log

## 使用 less 命令分页查看,更适合浏览大文件
less ssh.log

步骤 2: 精确提取 (sed 与正则表达式)

我们得到的日志行格式如下: Jan 17 03:13:00 hostname sshd[2631]: Disconnected from invalid user C-ute 46.97.239.16 port 55920 [preauth]

我们只想要用户名 (C-ute)。这时,流编辑器 sed 和强大的正则表达式就派上用场了。

sed 最常用的命令是 s (substitute/替换),其基本语法是:s/REGEX/SUBSTITUTION/

bash
## ... (前面的命令) ...
## | sed 's/.*Disconnected from //'

这条命令会查找匹配 .*Disconnected from 的部分,并将其替换为空字符串(即删除它)。

正则表达式入门

正则表达式是一种描述文本模式的语言。

元字符含义示例
.匹配除换行符外的任意单个字符a.c 匹配 "abc", "axc", "a1c"
*匹配零次或多次前一个字符a* 匹配 "", "a", "aa", "aaa"
+匹配一次或多次前一个字符a+ 匹配 "a", "aa", "aaa"
?匹配零次或一次前一个字符colou?r 匹配 "color" 和 "colour"
[abc]匹配 a, b, c 中的任意一个字符gr[ae]y 匹配 "gray" 和 "grey"
[^abc]匹配 a, b, c 之外的任意字符[^0-9] 匹配任何非数字字符
(..|..)逻辑"或",匹配其中一个模式`cat
^匹配行首^start 匹配以 "start" 开头的行
$匹配行尾end$ 匹配以 "end" 结尾的行

贪婪匹配问题: *+ 默认是“贪婪的”,它们会尽可能多地匹配文本。例如,对于 Disconnected from invalid user Disconnected from 46.97...,正则表达式 .*Disconnected from 会一直匹配到第二个 "Disconnected from",这不是我们想要的。

使用捕获组提取用户名

为了精确地提取用户名,我们需要一个更复杂的正则表达式,并使用捕获组 ((...))。被圆括号包裹的模式所匹配到的内容,可以被后续引用,如 \1, \2

bash
## ... | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

让我们分解这个复杂的命令:

  • sed -E: 使用扩展正则表达式语法,让 ?, +, () 等元字符无需转义。
  • s/.../\2/: 整个是一个替换命令。
  • .*Disconnected from: 匹配并跳过日志前缀。
  • (invalid |authenticating )?: 匹配 "invalid user" 或 "authenticating user",? 表示这部分可能不存在。
  • user : 匹配 "user "。
  • (.*): 这是关键! 这是第二个捕获组,它会匹配并“捕获”任意字符序列(即用户名)。
  • [^ ]+ port [0-9]+: 匹配 IP 地址、"port" 和端口号。
  • ( \[preauth\])?$: 匹配行尾可能出现的 [preauth]
  • \2: 在替换部分,我们使用 \2 来引用第二个捕获组的内容,也就是我们想要的用户名。

执行后,我们就得到了一个干净的用户名列表。

步骤 3: 计数与排序 (sort, uniq)

现在我们有了一个用户名列表,如何统计每个用户出现的次数呢?

  1. sort: uniq 只能检测连续的重复行,所以我们必须先用 sort 将相同的用户名排在一起。
  2. uniq -c: -c 选项会统计每个重复行出现的次数,并将其作为前缀输出。
bash
## ... (前面的命令) ...
## | sort | uniq -c

输出会是这样:

   5 admin
  12 c-ute
   3 root

步骤 4: 按频率排序并提取结果 (sort, tail, awk, paste)

我们想按出现次数从高到低排序,并提取最终结果。

bash
## ... (前面的命令) ...
## | sort -nk1,1 | tail -n10
  • sort -n: 按数字顺序排序,而不是默认的字典序。
  • sort -k1,1: 只根据第一个字段(由空格分隔)进行排序。
  • tail -n10: 显示排序后结果的最后 10 行(即出现次数最多的 10 个)。

现在,如果我们只想得到一个由逗号分隔的用户名列表:

bash
## ... (前面的命令) ...
## | awk '{print $2}' | paste -sd,
  • awk '{print $2}': awk 是一个强大的文本处理语言。这条命令的意思是:对于每一行输入,打印第二个字段($2),默认用空格分隔。这会提取出用户名。
  • paste -sd,: paste 命令用于合并行。-s 表示串行合并,-d, 表示使用逗号作为分隔符。

最终,我们得到了一行由逗号分隔的、尝试登录次数最多的 10 个用户名。

更强大的工具:awk

awk 不仅仅是字段提取工具,它本身就是一种编程语言。其基本结构是 pattern { action }

示例:统计所有以 c 开头、以 e 结尾,且只尝试过一次登录的用户名数量。

bash
## ... (经过 sort | uniq -c 之后) ...
## | awk '$1 == 1 && $2 ~ /^c.*e$/ { count++ } END { print count }'
  • $1 == 1: 这是一个模式,要求第一个字段(计数值)等于 1。
  • &&: 逻辑与。
  • $2 ~ /^c.*e$/: 这是另一个模式,要求第二个字段(用户名)匹配正则表达式。
  • { count++ }: 如果模式匹配,则执行花括号内的动作,让变量 count 自增。
  • END { print count }: END 是一个特殊模式,在处理完所有行后执行。这里我们打印最终的计数值。

可以看到,awk 几乎可以独立完成 grep, sed, uniq 的所有工作。

处理现代 Web 数据 (curl, pup, jq)

数据整理不仅限于日志文件。我们经常需要从网站或 API 获取数据。

  • curl: 一个强大的网络请求工具,用于从 URL 获取数据。
  • pup: 一个用于 HTML 的命令行解析器,可以像 jq 处理 JSON 一样使用 CSS 选择器来提取 HTML 元素。
  • jq: 一个用于 JSON 的命令行处理器,可以轻松地切片、过滤和重构 JSON 数据。

示例:从网页提取标题

bash
## 1. curl -s 下载网页的 HTML 内容
## 2. pup 使用 CSS 选择器 'title' 找到 <title> 标签
## 3. text{} 表示只提取标签内的文本内容
curl -s https://missing.csail.mit.edu/ | pup 'title text{}'

示例:从 API 获取数据并用 jq 解析

假设一个 API https://api.github.com/users/octocat 返回如下 JSON 数据:

json
{
  "login": "octocat",
  "id": 583231,
  "name": "The Octocat",
  "location": "San Francisco",
  ...
}

我们可以用 jq 轻松提取特定字段:

bash
## 获取 'name' 字段的值
curl -s https://api.github.com/users/octocat | jq '.name'
## "The Octocat"

## 获取 'location' 字段的值
curl -s https://api.github.com/users/octocat | jq '.location'
## "San Francisco"

这些工具的组合为我们从网络上抓取和整理数据提供了无限可能。

课后练习

  1. 正则表达式: 完成这个简短的交互式正则表达式教程来巩固你的知识。
  2. 原地替换的风险: 为什么 sed s/REGEX/SUB/ input.txt > input.txt 这样的命令是危险的,并且通常会导致 input.txt 文件被清空?(提示:查阅 man sed 关于 -i 选项的说明,并思考 Shell 重定向的工作时机)。
  3. 统计分析: 找出 /usr/share/dict/words 文件中,包含至少三个 a 且不以 's 结尾的单词个数。
  4. 系统日志分析 (Linux): 使用 journalctl 找出你最近三次开机的启动耗时,并计算平均值。
  5. 网页数据提取: 找一个你感兴趣的网站(例如新闻、维基百科页面),使用 curlpup 提取出所有的标题和对应的链接。
  6. API 数据提取: 使用 curljqGitHub API 获取 Linus Torvalds 的信息,并提取出他的 name, companypublic_repos 数量。

命令行环境

精通单个命令是基础,但要真正提升效率,你需要学会如何管理和定制你的整个命令行环境。本章将教你如何驾驭多任务、个性化你的 Shell、并通过 SSH 高效地与远程服务器协作。

任务控制:管理正在运行的进程

当你执行一个长时间运行的命令时(例如在庞大的文件系统中搜索),你不需要一直等到它结束。你可以通过信号 (Signals) 来与进程进行通信。

信号是一种软件中断,用于通知进程发生了某个事件。

终止进程:不仅仅是 Ctrl-C

快捷键/命令信号含义和作用
Ctrl-CSIGINT (Interrupt)中断信号。这是最常用的方式,请求程序终止。程序可以捕获 (catch) 这个信号并选择忽略它或执行清理操作后退出。
Ctrl-\SIGQUIT (Quit)退出信号。比 SIGINT 更强硬,通常会导致程序立即终止并生成一个核心转储 (core dump),用于调试。
kill -TERM <PID>SIGTERM (Terminate)终止信号。这是通过 kill 命令发送的默认信号,是请求程序终止的“标准”和“优雅”的方式。程序同样可以捕获它并进行清理。
kill -KILL <PID>SIGKILL (Kill)强制杀死信号。这是一个特殊的、终极的信号。它不能被程序捕获或忽略,会由操作系统内核立即终止进程。这可能导致数据丢失或留下孤儿进程,应作为最后手段使用。

示例:一个无法被 Ctrl-C 终止的程序 下面这个 Python 脚本捕获了 SIGINT 信号,因此你无法用 Ctrl-C 来终止它。

python
#!/usr/bin/env python
import signal, time

def handler(signum, frame):
    print("\nI got a SIGINT, but I am not stopping")

signal.signal(signal.SIGINT, handler)
i = 0
while True:
    time.sleep(.1)
    print(f"\r{i}", end="")
    i += 1

运行它,然后按下 Ctrl-C,你会看到它打印了消息但并未退出。此时,你需要使用 Ctrl-\ (SIGQUIT) 来强制退出它。

暂停与后台执行

有时候你不想终止一个任务,只是想暂时把它“放一边”。

快捷键/命令信号作用
Ctrl-ZSIGTSTP (Terminal Stop)暂停当前在前台运行的进程,并将其放入后台。
bg-将后台中一个已暂停 (suspended) 的任务,转为在后台继续运行 (running)
fg-将后台中的一个任务(无论是暂停的还是运行的)拉回到前台来。
jobs-列出当前 Shell 会话中所有在后台的任务及其状态。
command &-在命令末尾加上 &,可以直接让它在后台运行

工作流示例:

console
## 1. 启动一个长时间任务
$ sleep 1000

## 2. 发现它会阻塞终端,按 Ctrl-Z 暂停它
^Z
[1]+  Stopped                 sleep 1000

## 3. 查看后台任务列表
$ jobs
[1]+  Stopped                 sleep 1000

## 4. 让它在后台继续运行
$ bg %1
[1]+ sleep 1000 &

## 5. 现在你可以继续使用终端了。如果想把它调回前台:
$ fg %1
sleep 1000
  • %1jobs 命令输出的任务编号。
  • $! 是一个特殊的 shell 变量,代表最近一个放入后台的进程的 PID。

让进程在关闭终端后依然运行: 默认情况下,关闭终端会发送 SIGHUP (Hangup) 信号,这会导致该终端启动的所有子进程(包括后台任务)终止。为了避免这种情况:

  • nohup command &: 使用 nohup (no hangup) 启动命令,它会忽略 SIGHUP 信号,并将输出重定向到 nohup.out 文件。
  • disown: 对于一个已经在后台运行的任务 (例如通过 Ctrl-Zbg),使用 disown -h %1 可以让它忽略 SIGHUP
  • 终端多路复用器 (Tmux/Screen): 这是管理远程会话和后台任务的最佳方式,详见下一节。

终端多路复用器 (Tmux)

终端多路复用器 (Terminal Multiplexer) 允许你在一个终端窗口内创建和管理多个独立的 Shell 会话。tmux 是目前最流行和功能最强大的选择。

为什么 tmux 是必备工具?

  1. 多任务处理: 在一个屏幕内创建多个窗格 (pane) 和窗口 (window),同时运行编辑器、编译器、服务器日志等。
  2. 会话分离与重连: 与远程服务器的 SSH 连接断开后,tmux 会话和其中的所有进程会继续在服务器上运行。你可以随时重新连接 SSH,并恢复到之前的工作状态。这是 nohupdisown 的完美替代方案。

tmux 核心概念

  • 会话 (Session): 一个独立的工作区,可以包含多个窗口。
  • 窗口 (Window): 类似于浏览器的标签页,占据整个屏幕。
  • 窗格 (Pane): 一个窗口可以被分割成多个窗格,每个窗格都是一个独立的终端。

基本操作 (所有快捷键的前缀都是 Ctrl-b)

操作命令/快捷键描述
会话管理(在普通 Shell 中执行)
新建会话tmuxtmux new -s <name>启动一个新的 tmux 会话,可指定名称。
列出所有会话tmux ls
分离会话Ctrl-b dtmux 会话中返回到普通 Shell,会话在后台继续运行。
重新连接tmux atmux a -t <name>重新连接到上一个或指定名称的会话。
窗口管理(在 tmux 会话中执行)
创建新窗口Ctrl-b c
切换到下/上一个窗口Ctrl-b n / Ctrl-b p
切换到指定编号的窗口Ctrl-b <0-9>
窗格管理(在 tmux 会话中执行)
垂直分割Ctrl-b %
水平分割Ctrl-b "
在窗格间移动Ctrl-b <方向键>
切换窗格布局Ctrl-b Space
最大化/还原当前窗格Ctrl-b z

定制你的环境:别名与配置文件

别名 (Alias)

别名是为长命令创建的快捷方式。它们可以极大地减少你的键盘输入,并使常用命令更易于记忆。

bash
## 在 bash 或 zsh 中定义别名的语法
alias short_name="a_very_long_and_complex_command --with --many --options"

实用的别名示例:

bash
## 1. 常用命令的缩写
alias ll="ls -lh"
alias la="ls -lha"

## 2. 防止误操作
alias rm="rm -i"    ## 删除前进行提示
alias mv="mv -i"    ## 覆盖前进行提示
alias cp="cp -i"

## 3. 统一不同系统的命令 (例如在 macOS 和 Linux 间)
## 在 macOS 上,ls 不支持 --color=auto, ggrep 是通过 brew 安装的 GNU grep
if [[ "$(uname)" == "Darwin" ]]; then
  alias ls="ls -G"
  alias grep="ggrep --color=auto"
else
  alias ls="ls --color=auto"
  alias grep="grep --color=auto"
fi

## 4. Git 快捷方式
alias g="git"
alias gs="git status"
alias ga="git add"
alias gc="git commit -m"
alias gp="git push"

配置文件 (Dotfiles)

几乎所有命令行工具都通过点文件 (dotfiles)(文件名以 . 开头,如 .bashrc)进行配置。将这些配置文件管理好,你就可以在任何一台新机器上快速复现你熟悉的工作环境。

为什么要管理 Dotfiles?

  • 可移植性: 在任何新电脑或服务器上,一键恢复你所有的配置。
  • 同步性: 在一处修改,所有设备同步更新。
  • 版本控制: 使用 Git 跟踪你对配置的所有修改,随时可以回滚。

管理 Dotfiles 的最佳实践:

  1. 创建 Git 仓库: 在主目录下创建一个专门的文件夹(例如 ~/dotfiles),并将其初始化为 Git 仓库。

  2. 移动配置文件: 将你的配置文件(如 .bashrc, .zshrc, .vimrc, .tmux.conf, .gitconfig)移动到这个仓库中。

  3. 创建符号链接 (Symlink): 从仓库中的文件创建符号链接到它们原本应该在的位置。这会让程序以为文件还在原处,但实际上你编辑的是 Git 仓库中的版本。

    bash
    ## 将 .bashrc 移入仓库
    mv ~/.bashrc ~/dotfiles/bashrc
    
    ## 创建从仓库到主目录的符号链接
    ln -s ~/dotfiles/bashrc ~/.bashrc
  4. 编写安装脚本: 创建一个简单的安装脚本 (install.sh),自动完成创建符号链接的过程,这样在新机器上部署就变得非常简单。

  5. 发布到 GitHub: 将你的 dotfiles 仓库推送到 GitHub,方便随时随地访问。

高效的 SSH:连接远程服务器

SSH (Secure Shell) 是与远程服务器交互的标准工具。除了基本的登录,它还有许多强大的功能。

SSH 密钥:告别密码登录

使用密钥对进行认证,比密码更安全、更方便。

  1. 生成密钥对:

    bash
    ## ed25519 是目前推荐的算法,更安全、更快速
    ssh-keygen -t ed25519 -C "your_email@example.com"

    这会在 ~/.ssh/ 目录下生成 id_ed25519 (私钥,绝不能泄露!) 和 id_ed25519.pub (公钥)。

  2. 将公钥复制到服务器: 最简单的方式是使用 ssh-copy-id

    bash
    ssh-copy-id user@remote_host

    它会自动将你的公钥追加到远程服务器的 ~/.ssh/authorized_keys 文件中。

SSH 配置文件:~/.ssh/config

不要每次都输入冗长的 ssh 命令。通过配置 ~/.ssh/config 文件,可以为你的远程连接创建快捷方式。

## ~/.ssh/config

## 一个简单的例子
Host dev-server
    HostName 192.168.1.100
    User myuser
    Port 2222
    IdentityFile ~/.ssh/id_ed25519

## 使用通配符
Host *.compute.amazonaws.com
    User ec2-user
    IdentityFile ~/.ssh/aws-key.pem

## 包含端口转发的例子
Host jupyter
    HostName 10.0.0.5
    User data-scientist
    ## 将本地的 8888 端口转发到远程服务器的 8888 端口
    LocalForward 8888 localhost:8888

配置好之后,你就可以:

  • ssh dev-server 代替 ssh -p 2222 -i ~/.ssh/id_ed25519 myuser@192.168.1.100
  • scp file.txt dev-server:~/ 来复制文件。
  • ssh jupyter 自动建立端口转发,然后在本地浏览器访问 http://localhost:8888

远程文件操作

  • scp: 简单地复制文件/文件夹。scp local_file user@host:remote_path
  • rsync: 更强大的复制工具,支持增量同步(只传输变化的部分),速度更快,功能更丰富。rsync -avz --progress local_dir/ user@host:remote_dir/
  • sshfs: 将远程服务器的目录挂载到本地文件系统,像操作本地文件一样操作远程文件,非常适合图形界面的编辑器。

扩展你的 Shell:Zsh, Fish 和框架

Bash 虽然强大且普遍,但还有更现代、更用户友好的替代品:

  • Zsh: Bash 的超集,提供了更好的自动补全、主题、拼写纠错等。结合 Oh My Zsh 框架可以轻松获得大量插件和漂亮的主题。
  • Fish (Friendly Interactive Shell): 开箱即用,提供语法高亮、强大的历史搜索和智能的自动补全,非常适合新手。

课后练习

  1. 任务控制:
    • 运行 sleep 10000。用 Ctrl-Z 将其暂停。
    • bg 让它在后台继续运行。
    • 使用 pgrep sleep 找到它的 PID。
    • 使用 pkill sleep (或 kill <PID>) 来终止它。
  2. Tmux:
    • 安装 tmux
    • 启动一个新的 tmux 会话。
    • 创建一个垂直分割和一个水平分割的窗格布局。
    • 创建一个新窗口,然后在两个窗口之间切换。
    • Ctrl-b d 分离会话,然后用 tmux a 重新连接。
  3. 别名与配置文件:
    • 在你 shell 的配置文件 (.bashrc.zshrc) 中,为你最常用的 5 个命令创建别名。
    • 创建一个 ~/dotfiles 目录,并用 Git 进行版本控制。将你的 shell 配置文件移入其中,并创建符号链接。
  4. SSH:
    • 确保你有一个 SSH 密钥对。
    • 配置 ~/.ssh/config,为你经常访问的服务器创建一个 Host 别名。
    • 尝试禁用服务器的密码登录(编辑 /etc/ssh/sshd_config 并设置 PasswordAuthentication no),只允许密钥登录,以提高安全性。

版本控制 (Git)

为什么需要版本控制?

想象一下你正在写一个大型项目,你可能会这样做:

  • project_v1.zip
  • project_v2_final.zip
  • project_v3_really_final.zip
  • project_v3_bugfix_for_boss.zip

这种方式混乱、低效且容易出错。版本控制系统 (Version Control System, VCS) 正是为了解决这个问题而生的。它是一种能追踪文件和目录变更的工具。

即使是单人开发,VCS 也极其有用:

  • 创建项目快照:你可以随时将项目“存档”,并在未来恢复到任何一个存档点。
  • 记录变更目的:每一次存档,你都可以附上说明,解释你“为什么”做出这些修改。
  • 并行开发:可以同时在多个“版本”(分支)上工作,而互不干扰。例如,在一个分支上修复紧急 bug,同时在另一个分支上开发新功能。

在团队协作中,VCS 更是不可或缺:

  • 协同工作:清晰地看到团队成员的修改,避免互相覆盖。
  • 解决冲突:当多人修改了同一个文件的同一部分时,VCS 提供了工具来帮助你合并这些冲突。
  • 责任追溯:可以轻松回答“这行代码是谁写的?”、“这个 bug 是哪个版本引入的?”等问题。

在众多 VCS 中,Git 已经成为事实上的标准。虽然它的命令行初看可能有些复杂,但其底层的设计模型却非常优雅和强大。理解了这个模型,你就能真正掌握 Git。

因此,我们将采用一种“自底向上”的方式来学习:先理解数据模型,再学习命令行

Git 的核心:数据模型

Git 并不记录文件的差异或变化。相反,它将项目的历史记录视为一系列快照 (Snapshots)

基础构建块:对象 (Objects)

Git 仓库的核心是其数据库,里面存储了三种类型的“对象”:

  1. Blob (Binary Large Object):

    • 是什么:一个文件的内容
    • 特点:它只包含文件的数据,不包含文件名、时间戳等任何元信息。你可以把它想象成一坨二进制数据。
  2. Tree (树):

    • 是什么:一个目录的快照。
    • 特点:它像一个清单,记录了某个目录下包含的文件和子目录。对于每个条目,它存储了:
      • 文件/目录名
      • 指向对应的 Blob 或 Tree 对象的指针 (哈希值)
      • 文件类型(是普通文件、可执行文件还是子目录)

    一个 Tree 对象看起来像这样:

    <root> (tree)
    |
    +- foo (tree) -> 指向代表 foo 目录的 Tree 对象的哈希
    |  |
    |  + bar.txt (blob) -> 指向代表 bar.txt 内容的 Blob 对象的哈希
    |
    +- baz.txt (blob) -> 指向代表 baz.txt 内容的 Blob 对象的哈希
  3. Commit (提交):

    • 是什么:一个项目在特定时间点的快照。这是构成 Git 历史的基本单位。
    • 特点:一个 Commit 对象包含:
      • 一个指向顶层 Tree 对象的指针,代表了该次提交时整个项目的完整快照。
      • 指向一个或多个父 Commit (Parent Commits) 的指针。这正是将所有提交串联成历史记录的关键。
      • 元数据:作者、提交者、时间戳,以及最重要的——提交信息 (Commit Message)

唯一标识:SHA-1 哈希

Git 数据库中的所有对象(Blob、Tree、Commit)都是通过其内容的 SHA-1 哈希值来索引的。这是一个 40 位的十六进制字符串。

  • 唯一性:任何内容的微小改变都会导致一个全新的哈希值。
  • 完整性:Git 通过哈希值来确保数据的完整性。如果一个对象损坏了,它的哈希值将不再匹配。
  • 不可变性:由于对象的 ID 由其内容决定,所以 Git 中的对象都是不可变的。你不能“修改”一个 Commit,你只能创建一个新的 Commit 来替代它。

历史记录:提交构成的有向无环图 (DAG)

每个 Commit 都有指向其父辈的指针,这就在所有 Commit 之间形成了一个有向无环图 (Directed Acyclic Graph, DAG)

  • 线性历史:最简单的情况,每个提交只有一个父提交。

    Commit A <-- Commit B <-- Commit C (master)

    (箭头指向父提交)

  • 分支与合并:当历史出现分叉(例如,创建了一个新分支来开发特性),并在之后合并时,会产生一个拥有两个父提交的“合并提交”。

    o <-- o <-- o <----  o (合并提交)
                ^      /
                 \    v
                  --- o <-- o

人类可读的指针:引用 (References)

记住一长串 SHA-1 哈希值是不现实的。因此,Git 提供了引用 (References),它们是指向特定 Commit 哈希值的、人类可读的指针

  • 分支 (Branches):例如 master, develop, feature-x。它们是可变的,当你创建一个新的 Commit 时,当前分支的指针会自动向前移动到这个新的 Commit。
  • 标签 (Tags):例如 v1.0, v2.1.3。它们通常是不可变的,用于标记项目历史中的重要节点(如版本发布)。
  • HEAD:这是一个特殊的引用,它指向你当前所在的位置。通常情况下,HEAD 指向一个分支(例如 master),意味着你正工作在这个分支上。

总结:一个 Git 仓库就是一个由 Blob、Tree、Commit 对象构成的数据库,外加一组指向特定 Commit 的引用(如分支)。

工作区、暂存区与仓库

理解了数据模型后,我们来看实际操作中涉及的三个区域:

  1. 工作区 (Working Directory):你在电脑上实际看到和编辑的文件目录。
  2. 暂存区 (Staging Area / Index):一个位于 .git 目录中的文件。它像一个购物车的清单,记录了你下一次准备提交的内容的快照。它让你能够精确控制哪些改动要被包含在下一次提交中。
  3. 仓库 (Repository):即 .git 目录,存储了项目所有的对象(Commits, Trees, Blobs)和引用,也就是项目的完整历史。

基本的 Git 工作流程如下:

  1. 工作区修改文件。
  2. 使用 git add 将你想要包含在下一次提交中的改动,放入暂存区
  3. 使用 git commit暂存区中的内容永久性地记录成一个快照(一个新的 Commit),存入仓库

Git Three Areas

常用 Git 命令解析

现在,我们可以将具体的命令与它们对数据模型的操作对应起来。

基础操作

  • git init: 在当前目录创建一个新的 Git 仓库(即 .git 文件夹)。
  • git status: 显示工作区、暂存区和当前 HEAD 指向的 Commit 之间的差异。这是你最常用的命令之一。
  • git add <filename>:
    1. 读取 <filename> 的内容,创建一个新的 Blob 对象。
    2. 将这个 Blob 对象的信息更新到暂存区
  • git commit -m "Your message":
    1. 根据暂存区的内容,创建一系列 Tree 对象。
    2. 创建一个新的 Commit 对象,让它指向顶层 Tree,并设置其父提交为当前 HEAD 指向的 Commit。
    3. 当前分支的引用(例如 master)移动到这个新创建的 Commit 上。
  • git log: 从 HEAD 开始,沿着父提交指针回溯,显示提交历史。
    • git log --all --graph --decorate --oneline: 一个非常有用的别名,可以清晰地可视化分支和合并历史。
  • git diff:
    • git diff: 显示工作区暂存区之间的差异。
    • git diff --staged: 显示暂存区和上一次提交之间的差异。

分支与合并

  • git branch <name>: 创建一个新的分支(引用),让它指向当前 HEAD 所在的 Commit这只是创建了一个指针,并不会切换过去。
  • git checkout <name> (或 git switch <name>):
    1. HEAD 引用移动到 <name> 分支上。
    2. 工作区的文件内容更新为该分支所指向的 Commit 的快照。
  • git checkout -b <name>: 创建新分支并立即切换过去(branch + checkout 的组合)。
  • git merge <revision>: 将 <revision> 分支的历史合并到当前分支。Git 会找到两个分支的共同祖先,并创建一个新的“合并提交”,这个提交会有两个父提交

远程协作

  • git clone <url>: 从远程服务器下载一个完整的仓库副本,包括所有历史记录。
  • git remote add <name> <url>: 添加一个远程仓库的“书签”,通常默认名为 origin
  • git fetch <remote>: 从远程仓库下载你本地没有的对象和引用(如 origin/master),但不会修改你本地的分支或工作区
  • git pull <remote>: 拉取远程更新并合并。它基本上是 git fetch + git merge 的组合。
  • git push <remote> <branch>: 将你本地的提交上传到远程仓库,并更新远程仓库的分支引用。

撤销操作

  • git commit --amend: 用暂存区的内容创建一个新的 Commit,来替换掉当前分支的最新一次 Commit。用于修改最后一次提交的信息或内容。
  • git reset HEAD <file>: 将文件从暂存区中移除,但保留在工作区的修改。
  • git checkout -- <file> (或 git restore <file>): 丢弃工作区中对文件的修改,用暂- 存区中的版本覆盖它。

课后练习

  1. 基础学习:如果你是 Git 新手,强烈推荐完成 Learn Git Branching 教程,它能以可视化的方式帮助你理解分支操作。
  2. 仓库探索
    • 克隆本课程网站的仓库: git clone https://github.com/missing-semester-cn/missing-semester-cn.github.io.git
    • 进入该目录,使用 git log --all --graph --decorate --oneline 可视化历史记录。
    • 找出最后一次修改 README.md 文件的人是谁?(提示: git log -- README.md)
    • 找出 _config.yml 文件中 collections: 这一行,最后一次是被哪次提交修改的?提交信息是什么?(提示: git blame _config.yml 会告诉你每一行的最后修改信息,然后用 git show <commit_hash> 查看该次提交的详情)。
  3. 历史修改
    • 在本地仓库中,创建一个新文件,并提交它。
    • 现在,假装这个文件包含敏感信息,你需要从整个项目的历史中彻底删除它。参考这篇文章来完成这个挑战。
  4. 配置别名
    • 编辑你的全局 Git 配置文件 ~/.gitconfig
    • [alias] 部分添加一个别名 graph,使其成为 log --all --graph --decorate --oneline 的简写。之后,你就可以直接运行 git graph 了。
  5. 全局 .gitignore
    • 创建一个 ~/.gitignore_global 文件。
    • 在其中添加常见的系统或编辑器临时文件,例如 .DS_Store, *.swp, *~ 等。
    • 运行 git config --global core.excludesfile ~/.gitignore_global 来让这个全局忽略文件生效。

元编程 (Metaprogramming)

本教程将深入探讨软件开发中的“元”流程:构建、测试和依赖管理。这些技能在处理任何规模的项目时都至关重要。

什么是“元编程”?

首先,我们需要厘清“元编程”在本课程中的含义。

  • 经典定义: 元编程指的是编写“能操作程序的程序”。这意味着代码可以读取、生成、分析甚至在运行时修改自己或其他程序。这通常通过反射(Reflection)或宏(Macros)等技术实现。
  • 本课程的定义: 这里,我们借用这个词来涵盖更广泛的“关于编程的编程”流程。它不侧重于代码修改代码,而是关注如何自动化和管理整个软件开发周期,包括:
    • 构建系统 (Build Systems): 如何将源代码和其他资源自动编译、打包成最终产品。
    • 依赖管理 (Dependency Management): 如何处理和版本化你的项目所依赖的外部库和工具。
    • 持续集成 (Continuous Integration): 如何在代码变更时自动运行构建和测试流程。
    • 软件测试 (Software Testing): 保证代码质量和功能正确性的基本原则。

构建系统:使用 make 自动化流程

几乎所有项目,从编译代码到生成论文,都有一系列需要执行的命令。手动重复运行这些命令既繁琐又容易出错。构建系统就是为了解决这个问题而生的。

make 是一个经典且功能强大的构建工具,通过一个名为 Makefile 的文件来定义构建规则。

Makefile 的核心概念

Makefile 由一系列规则 (rules) 组成,基本语法如下:

makefile
target: dependency1 dependency2 ...
    <tab> command1
    <tab> command2
  • target (目标): 你想要生成的文件名,例如 paper.pdfprogram
  • dependencies (依赖): 构建目标所需要的文件或其他的目标。
  • command (命令): 从依赖构建目标的具体 Shell 命令。极其重要:命令行的开头必须是一个 Tab 字符,而不是空格!

make 的工作逻辑是:

  1. 检查目标文件是否存在。
  2. 如果目标文件不存在,或者任何一个依赖文件比目标文件更新,那么就执行相应的命令来重新生成目标。
  3. 如果目标文件及其所有依赖都存在,并且目标文件是更新的,那么 make 就什么也不做,因为没有必要。

实例解析

让我们分解课程讲义中的 Makefile 示例:

makefile
## 规则 1: 默认目标
paper.pdf: paper.tex plot-data.png
	pdflatex paper.tex

## 规则 2: 模式规则
plot-%.png: %.dat plot.py
	./plot.py -i $*.dat -o $@

分析:

  1. 默认目标: 当你只输入 make 命令时,make 会构建 Makefile 中的第一个目标,这里是 paper.pdf
  2. 规则 1: 要构建 paper.pdfmake 需要 paper.texplot-data.png 这两个文件。如果这两个文件中的任何一个比 paper.pdf 更新,make 就会运行 pdflatex paper.tex 命令。
  3. 规则 2 (模式规则): 这是一个更通用的规则。% 是一个通配符。
    • 它表示:任何以 .png 结尾的目标(如 plot-data.png),都依赖于一个同名但以 .dat 结尾的文件(data.dat)以及 plot.py
    • 特殊变量:
      • $@: 代表规则中的目标文件名 (e.g., plot-data.png)。
      • $*: 代表模式 匹配到的部分 (e.g., data)。

make 的执行流程

当你第一次在目录下运行 make

  1. make 想构建 paper.pdf
  2. 它发现 paper.pdf 依赖于 paper.texplot-data.png
  3. 它发现 paper.tex 存在,但 plot-data.png 不存在。
  4. 它在 Makefile 中寻找一个可以生成 plot-data.png 的规则,并找到了模式规则 plot-%.png
  5. 这个模式规则需要 data.datplot.py。假设这两个文件都存在。
  6. make 执行 ./plot.py -i data.dat -o plot-data.png 来生成 plot-data.png
  7. 现在 paper.pdf 的所有依赖都准备好了,make 执行 pdflatex paper.tex 来生成最终的 PDF。

Phony Targets (伪目标)

有些目标,如 cleaninstall,并不代表一个要生成的文件。它们只是一个需要执行的动作标签。为了避免与同名文件冲突,并确保命令总是执行,我们将它们声明为伪目标。

makefile
## ... 其他规则 ...

## clean 目标用于删除所有生成的文件
clean:
	rm -f *.pdf *.png

## 将 clean 声明为伪目标
.PHONY: clean

现在,运行 make clean 将总是会执行 rm 命令,即使目录下恰好有一个名为 clean 的文件。

依赖管理

现代软件开发很少从零开始,我们总是站在巨人的肩膀上,使用他人编写的库和工具。这些外部的代码就是依赖

包管理器和软件仓库

为了方便地获取和管理依赖,我们使用包管理器 (Package Managers)。它们会从集中的软件仓库 (Repositories) 中下载、安装和管理依赖。

领域包管理器软件仓库
操作系统 (Debian/Ubuntu)aptUbuntu Packages
操作系统 (macOS)brew (Homebrew)Homebrew Core
Pythonpip, poetryPyPI (Python Package Index)
JavaScript/Node.jsnpm, yarnnpm Registry
JavaMaven, GradleMaven Central
RustcargoCrates.io

版本控制与语义版本号 (Semantic Versioning)

当你依赖一个库时,你不能总是使用它的最新版本,因为新版本可能会引入不兼容的改动,导致你的代码崩溃。因此,我们需要精确地管理依赖的版本。

语义版本号 (SemVer) 是一个被广泛采纳的版本号标准,格式为 主版本号.次版本号.补丁号 (MAJOR.MINOR.PATCH),例如 1.3.7

  • PATCH (补丁号): 递增此版本号,表示你修复了一些 bug,但没有改变任何功能接口 (API)。这些改动是完全向后兼容的。
  • MINOR (次版本号): 递增此版本号,表示你增加了一些新功能,但仍然保持向后兼容。
  • MAJOR (主版本号): 递增此版本号,表示你做了不向后兼容的 API 修改。

这个约定非常有用。如果你的项目依赖 1.3.7 版本,那么你可以安全地升级到 1.3.81.6.1,但升级到 2.0.0 就需要小心了,因为它可能需要你修改自己的代码来适配。

锁文件 (Lock Files)

即使你指定了版本范围(例如 ^1.3.7,表示可以使用 1.x.x 系列的任何新版本),为了保证团队中每个开发者和最终部署时使用的依赖版本完全一致,包管理器会生成一个锁文件(如 package-lock.json, poetry.lock)。

这个文件会记录下当前安装的每个依赖的确切版本号。这确保了构建是可复现的 (reproducible)

持续集成 (Continuous Integration, CI)

持续集成是一种开发实践,它会自动地在你每次提交代码时执行一系列任务,例如:

  • 运行代码风格检查 (Linting)
  • 构建项目
  • 运行所有测试
  • 部署到服务器

工作流程:

  1. 你在本地修改代码,并推送到代码托管平台(如 GitHub)。
  2. 该平台检测到代码变更,并通知 CI 服务(如 GitHub Actions, Travis CI)。
  3. CI 服务启动一个干净的虚拟机,拉取你的最新代码。
  4. CI 服务根据你项目中的配置文件(例如 .github/workflows/main.yml),执行你定义好的所有命令。
  5. CI 服务报告结果(成功或失败)。如果失败,团队会收到通知,以便立即修复问题。

这确保了代码库中的代码永远处于一个可工作、已测试的状态。

示例:一个简单的 GitHub Actions 配置文件 在你的项目根目录下创建 .github/workflows/main.yml:

yaml
name: CI

## 当有代码 push 到 main 分支时触发
on:
  push:
    branches: [ main ]

jobs:
  build-and-test:
    ## 在最新的 Ubuntu 虚拟机上运行
    runs-on: ubuntu-latest

    steps:
    ## 1. 拉取你的代码到虚拟机
    - uses: actions/checkout@v2

    ## 2. 设置 Python 3.8 环境
    - name: Set up Python 3.8
      uses: actions/setup-python@v2
      with:
        python-version: 3.8

    ## 3. 安装项目依赖
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt

    ## 4. 运行测试
    - name: Run tests
      run: |
        python -m pytest

测试简介

软件测试是保证代码质量的基石。一个好的测试套件能让你在修改或添加功能时充满信心。

关键测试术语

  • 测试套件 (Test Suite): 项目中所有测试的总称。
  • 单元测试 (Unit Test): 粒度最小的测试,专注于测试一个独立的函数或模块的功能是否正确。就像测试一块乐高积木是否合格。
  • 集成测试 (Integration Test): 测试多个组件协同工作时是否正常。就像测试几块乐高积木拼在一起后是否稳固。
  • 回归测试 (Regression Test): 当一个 bug 被发现并修复后,专门为这个 bug 编写一个测试。这个测试确保该 bug 将来不会再次出现。
  • 模拟 (Mocking): 在测试中,用一个“假的”实现来替换一个真实的依赖(如数据库连接、网络请求)。这可以让你的测试更专注于被测单元本身,而不会受到外部因素的干扰。

课后练习

  1. 为 Makefile 添加 clean 目标:
    • 为教程中的 Makefile 添加一个 clean 伪目标 (.PHONY: clean)。
    • clean 目标应该能删除所有构建生成的文件(如 .pdf.png 文件),让目录恢复到干净状态。
  2. 理解版本要求:
    • 研究 Rust Cargo 的依赖指定语法
    • 对于尖号 (^1.2.3)、波浪号 (~1.2.3)、通配符 (*) 等不同的版本约束,分别设想一个使用场景。
  3. 使用 Git 钩子:
    • 在任意一个 Git 仓库的 .git/hooks 目录下,找到 pre-commit.sample 文件。
    • 将其重命名为 pre-commit 并编写一个脚本:在每次 git commit 之前,自动运行 make。如果 make 失败,则中断本次提交。
  4. 设置 GitHub Actions:
    • 创建一个新的 GitHub 仓库,并基于 GitHub Pages 创建一个简单的个人页面。
    • 为该仓库添加一个 GitHub Action,对仓库中所有的 Shell 脚本 (.sh 文件) 执行 shellcheck 检查。
  5. 构建自己的 GitHub Action:
    • 尝试构建一个自己的 GitHub Action
    • 这个 Action 的功能是:对仓库中所有的 Markdown 文件 (.md) 执行 proselintwrite-good 这样的写作风格检查工具。
    • 在你的仓库中启用这个 Action,并故意提交一个有语法错误的文件来验证它是否能正常工作。

安全与密码学

本教程将深入探讨一些在现代计算机工具(如 Git 和 SSH)背后默默工作的核心安全与密码学概念。理解这些基本原理,能帮助你更好地认识我们所使用的工具,并建立起更完善的安全观。

注意:本教程旨在普及概念,而非培养密码学专家。请切勿自行设计或修改加密算法,这是一项需要深厚专业知识的工作。

熵:衡量密码的真正强度

我们常常被告知要使用“复杂”的密码,比如 Tr0ub4dor&3。但一个更长、看似更简单的密码,如 correcthorsebatterystaple,实际上可能安全得多。衡量这种“强度”的科学标准,就是熵 (Entropy)

什么是熵?

在信息安全领域,熵是不确定性的度量。一个密码的熵越高,意味着它包含的不确定性越大,攻击者猜中它的难度也就越高。

熵的单位是比特 (bit)。一个系统的熵可以通过以下公式计算: 熵 (比特) = log₂(可能组合的总数)

  • 抛硬币: 只有正反两种可能,所以熵是 log₂(2) = 1 比特。
  • 掷六面骰子: 有六种可能,熵是 log₂(6) ≈ 2.58 比特。

计算密码的熵

假设攻击者了解密码的构成规则(比如“8位随机字母和数字”),我们可以计算出其熵。

  • 例1: rg8Ql34g

    • 字符池 (R): 26个小写字母 + 26个大写字母 + 10个数字 = 62种可能。
    • 长度 (L): 8位。
    • 可能组合总数: 62⁸
    • : log₂(62⁸) = 8 * log₂(62) ≈ 8 * 5.95 ≈ 47.6 比特。
  • 例2: correcthorsebatterystaple

    • 字符池 (R): 从一个包含10万个单词的词典中随机选择。
    • 长度 (L): 4个单词。
    • 可能组合总数: (100,000)⁴
    • : log₂((10⁵)⁴) = 4 * log₂(10⁵) ≈ 4 * 16.6 ≈ 66.4 比特。

结论correcthorsebatterystaple 的熵远高于 rg8Ql34g,因此它是一个更强的密码。 长度对密码强度的贡献通常比字符复杂性更大。

哈希函数:数据的数字指纹

密码学哈希函数(或称散列函数)是一种特殊的数学函数,它可以将任意大小的输入数据,转换成一个固定大小的输出,这个输出被称为哈希值 (Hash)摘要 (Digest)

你可以把它想象成一个数据的“指纹”生成器。

console
## 对 "hello" 进行 SHA-1 哈希
$ printf 'hello' | sha1sum
aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d

## 即使输入只改变一个字母的大小写,输出也面目全非
$ printf 'Hello' | sha1sum
f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0

核心特性

一个理想的加密哈希函数必须具备以下特性:

  1. 确定性: 相同的输入永远会产生相同的输出。
  2. 高效性: 计算一个输入的哈希值必须非常快。
  3. 不可逆性 (单向性): 从哈希值反推出原始输入,在计算上是不可行的。这就像你无法从指纹复原出整个人的样貌。
  4. 雪崩效应: 输入的任何微小变化(哪怕只改一个比特)都会导致输出的哈希值发生巨大且不可预测的变化。
  5. 抗碰撞性: 找到两个不同的输入,使得它们的哈希值相同,在计算上是不可行的。

应用场景

  • 文件完整性校验: 你从镜像站下载了一个 Linux ISO 文件,网站会提供官方的 SHA256 哈希值。你在本地计算下载文件的哈希值,如果两者一致,就证明文件在下载过程中没有被篡改或损坏。
  • Git 的内容存储: Git 为你的每一次提交、每一个文件都生成一个哈希值作为其唯一ID。正是基于哈希的不可逆性和抗碰撞性,Git 才能确保历史记录的完整和不可篡改。
  • 密码存储: 网站绝不能明文存储你的密码。它们存储的是 KDF(密码 + 盐) 的结果。KDF (密钥生成函数) 是一种特殊的、更慢的哈希函数,而盐 (salt) 是为每个用户生成的随机字符串,用来抵御“彩虹表攻击”。

对称加密:一把钥匙锁天下

对称加密是最直观的加密方式。它使用同一个密钥来进行加密和解密。

  • 工作流程:
    1. keygen() -> 密钥
    2. encrypt(明文, 密钥) -> 密文
    3. decrypt(密文, 密钥) -> 明文

类比: 就像你家的门锁,你用一把钥匙锁门,也用同一把钥匙开门。

  • 优点: 加密解密速度非常快,适合对大量数据进行加密。
  • 缺点: 密钥分发难题。如何将这把唯一的密钥安全地送到接收方手中?如果在网络上传输,密钥本身就可能被窃取。
  • 常见算法: AES (Advanced Encryption Standard) 是目前最广泛使用的标准。

应用场景: 加密存储在云盘上的个人文件。你可以用一个密码通过密钥生成函数(KDF)派生出一个密钥,然后用这个密钥加密文件。

非对称加密:公开的锁,私有的钥匙

为了解决对称加密的密钥分发难题,非对称加密应运而生。它使用一对密钥:公钥 (Public Key)私钥 (Private Key)

  • 公钥: 可以随意分发,公之于众,就像一把打开的挂锁。
  • 私钥: 必须由本人严格保密,绝不外泄,是能打开这把挂锁的唯一钥匙。

这对密钥有两个核心用途:

用途一:加密与解密(实现机密性)

  • 加密: 任何人都可以用你的公钥来加密信息。
  • 解密: 只有拥有对应私钥的你,才能解密这些信息。

工作流程:

  1. A 想给 B 发送秘密信息。
  2. A 获取 B 的公钥
  3. A 用 B 的公钥加密信息,然后发送出去。
  4. B 收到密文后,用自己的私钥解密,读取信息。

即使中间有人截获了密文,因为没有 B 的私钥,也无法解密。

用途二:签名与验证(实现认证与完整性)

  • 签名: 你可以用自己的私钥对一段数据的哈希值进行“签名”。
  • 验证: 任何人都可以用你的公钥来验证这个签名是否真实有效。

工作流程:

  1. A 要发布一个软件,为了防止别人冒充,A 先计算软件的哈希值。
  2. A 用自己的私钥对这个哈希值进行签名。
  3. A 将软件、签名、以及自己的公钥一同发布。
  4. 用户下载后,用 A 的公钥验证签名。如果验证通过,就证明:
    • 认证: 这个软件确实是 A 发布的(因为只有 A 的私钥才能生成这个签名)。
    • 完整性: 软件从发布到下载,内容没有被篡改过(因为哈希值对得上)。

案例分析:SSH 的工作原理

ssh 是我们每天都在使用的工具,它完美地结合了以上多种密码学技术,实现安全远程登录。

连接过程分解:

  1. 密钥生成 (ssh-keygen):

    • 你在本地运行 ssh-keygen,它会为你生成一对非对称密钥:id_rsa (私钥) 和 id_rsa.pub (公钥)。
    • 系统会提示你为私钥设置一个密码 (passphrase)。这个密码通过 KDF 生成一个对称密钥,用来加密你的私钥文件,确保即使私钥文件被盗也不会立刻泄露。
  2. 建立连接与身份验证:

    • 你将你的公钥 (id_rsa.pub) 放到远程服务器的 ~/.ssh/authorized_keys 文件中。
    • 当你执行 ssh user@host 时,连接开始。
    • 服务器向你的客户端发送一个随机生成的字符串(挑战)。
    • 你的客户端使用你的私钥对这个字符串进行签名,并将签名发回给服务器(应答)。
    • 服务器在你 authorized_keys 文件里找到你的公钥,用它来验证客户端发回的签名。
    • 如果验证成功,服务器就确认了你的身份——因为只有持有对应私钥的人才能正确完成挑战。这就是挑战-应答认证机制。
  3. 会话加密:

    • 一旦身份验证通过,非对称加密的使命就完成了。因为它计算速度较慢,不适合加密大量的实时数据。
    • 此时,客户端和服务器会通过一个安全的密钥交换算法(如 Diffie-Hellman)协商出一个临时的对称会话密钥
    • 之后的所有通信数据,都由这个对称密钥进行加密,保证了高效和安全。连接断开后,这个密钥即被销毁。

总结一下,SSH 在:

  • 认证时使用非对称加密(签名/验证)来确认你的身份。
  • 通信时使用对称加密来保护数据流。

课后练习

  1. 熵计算:

    • 一个密码由4个随机的、从一个包含2048个单词的词典中选出的单词组成。它的熵是多少比特?
    • 一个密码是10位的随机数字(0-9)。它的熵是多少比特?
    • 哪个密码更强?破解它们分别需要多长时间(假设攻击者每秒尝试10亿次)?
  2. 哈希函数实践:

    • 找一个较大文件的下载链接(如 Ubuntu Desktop ISO)。
    • 在下载页面找到官方提供的 SHA256SUMS 文件。
    • 下载文件后,在你的终端里使用 sha256sum [文件名] 命令计算其哈希值。
    • 对比你计算出的哈希值和官方提供的是否完全一致。
  3. 对称加密体验:

    • 创建一个名为 secret.txt 的文件,并写入一些文字。
    • 使用 OpenSSL 对它进行 AES 加密:openssl aes-256-cbc -salt -in secret.txt -out secret.enc (会提示你输入密码)。
    • 尝试用 cat 查看加密后的 secret.enc 文件,看看是什么内容。
    • 使用命令解密文件:openssl aes-256-cbc -d -in secret.enc -out decrypted.txt
    • 使用 diff secret.txt decrypted.txt 命令,确认解密后的文件和原文件完全相同。
  4. 非对称加密应用:

    • 如果你还没有 SSH 密钥对,使用 ssh-keygen -t ed25519 命令生成一个。务必为你的私钥设置一个健壮的密码。
    • 将你的公钥配置到 GitHub 或 GitLab 账户中。
    • 尝试使用 git commit -S 命令对你的一个 Git 提交进行 GPG/SSH 签名,并用 git log --show-signature 查看签名验证信息。

程序员的“大杂烩”工具箱

除了核心的编程语言和算法知识,一名高效的开发者还需要掌握大量能够提升效率、简化工作流程的工具和概念。本章将介绍一系列这样的“大杂烩”主题,它们虽然零散,但每一个都可能在你的职业生涯中扮演重要角色。


修改键位映射:打造你的专属键盘

键盘是你的主要生产力工具。花时间定制它,使其更符合你的习惯,是一项回报率极高的投资。通过软件,我们可以拦截键盘按下的信号,并将其替换为我们想要的操作。

常见的键位修改建议

  • Caps Lock -> Ctrl / Escape: Caps Lock 键位于键盘的黄金位置,但其功能使用频率极低。将其映射为更常用的 Ctrl(尤其对于需要频繁使用组合键的 Emacs 或终端用户)或 Escape(对于 Vim 用户)是首选改造。
  • Media Keys: 将不常用的键(如 PrtSc)映射为播放/暂停、上一首/下一首等多媒体控制键。
  • 修饰键互换: 交换 CtrlMeta (Win/Cmd) 键,以统一不同操作系统下的使用习惯。

高级玩法:自动化与快捷指令

  • 启动应用: 设置快捷键一键打开终端、浏览器或代码编辑器。
  • 输入常用文本: 映射一个组合键来快速输入你的邮箱地址、常用代码片段等。
  • 系统控制: 设置快捷键让电脑或显示器进入睡眠模式。
  • 序列与长按:
    • 序列: 设置“连按五次 Shift”来切换一个特殊模式。
    • 单击 vs 长按: 实现“单击 Caps Lock 为 Escape,长按则为 Ctrl”的智能操作。

推荐工具

  • macOS:
    • Karabiner-Elements: 功能极其强大的键位修改工具。
    • skhd: 轻量级的快捷键守护进程,常与窗口管理器配合使用。
    • BetterTouchTool: 除了键盘,还能自定义触控板、鼠标等多种输入设备。
  • Linux:
    • xmodmap: 传统的 X11 键位映射工具。
    • Autokey: 功能更丰富的自动化脚本工具。
  • Windows:
    • AutoHotkey: Windows 平台上的自动化神器,通过脚本实现无限可能。
    • SharpKeys: 通过修改注册表来实现键位映射,简单直接。
  • 硬件级 (跨平台):
    • QMK Firmware: 如果你拥有客制化机械键盘,可以通过刷写 QMK 固件,将键位设置直接保存在键盘硬件中,从而在任何电脑上都能享受一致的体验。

守护进程 (Daemons):让服务在后台默默运行

守护进程 (Daemon) 是一种在后台运行、无需用户直接交互的特殊进程。它们是操作系统服务的基石,负责处理网络请求、管理硬件、定时执行任务等。通常,守护进程的程序名以 d 结尾,例如 sshd (SSH aemon)。

使用 systemd 管理服务 (Linux)

在现代 Linux 系统中,systemd 是管理守护进程和系统服务的标准工具。通过 systemctl 命令,你可以轻松地控制服务的生命周期。

  • systemctl status sshd: 查看 sshd 服务的当前状态。
  • systemctl start myapp: 启动名为 myapp 的服务。
  • systemctl stop myapp: 停止服务。
  • systemctl restart myapp: 重启服务。
  • systemctl enable myapp: 设置服务开机自启。
  • systemctl disable myapp: 取消开机自启。

示例:创建一个自定义服务

假设我们有一个 Python Web 应用 (app.py),我们希望它能作为服务在后台持续运行。我们可以创建一个 systemd 服务文件:

路径: /etc/systemd/system/myapp.service

ini
[Unit]
## 服务的描述
Description=My Custom Python Web App
## 指定此服务在网络服务启动后才启动
After=network.target

[Service]
## 指定运行此服务的用户和用户组
User=myuser
Group=myuser
## 设置工作目录
WorkingDirectory=/home/myuser/projects/myapp
## 核心:定义如何启动服务的命令
ExecStart=/usr/bin/python3 /home/myuser/projects/myapp/app.py
## 定义失败后的行为:自动重启
Restart=on-failure

[Install]
## 定义此服务在哪个“运行级别”下被启用,multi-user.target 表示在多用户模式(无GUI)下即可启动
WantedBy=multi-user.target

cron:简单的定时任务

如果你的需求只是“定期运行某个脚本”(例如每天凌晨备份数据库),那么使用 cron 是一个更轻量级的选择。cron 是一个专门用于执行定时任务的守护进程。


FUSE:在用户空间中创造文件系统

传统上,文件系统是操作系统内核的一部分,开发和调试都非常复杂。FUSE (Filesystem in Userspace) 打破了这一限制,它允许开发者在普通的用户程序中实现一套完整的文件系统逻辑。

FUSE 的应用场景

通过 FUSE,我们可以将各种数据源“伪装”成本地文件系统,让所有应用程序都能像操作本地文件一样与它们交互。

  • sshfs: 通过 SSH 连接,将远程服务器上的目录挂载到本地,像操作本地文件夹一样读写远程文件。
  • rclone mount: 将 Google Drive, Dropbox, S3 等云存储服务挂载为本地磁盘。
  • gocryptfs: 一个加密文件系统。文件在磁盘上是加密存储的,但挂载后你可以透明地读写未加密的内容。
  • borgbackup mount: 浏览你的 BorgBackup 备份仓库,像普通文件一样恢复单个文件。

备份:数据安全的最后一道防线

没有备份的数据,就是随时准备消失的数据。 一个可靠的备份策略远比简单地复制文件要复杂。

常见的备份误区

  • 同盘复制不是备份: 硬盘是单点故障,一旦损坏,源数据和“备份”将一同丢失。
  • 同步不是备份: Dropbox/Google Drive 等同步工具会“忠实地”同步你的错误。如果你在本地误删或文件被病毒损坏,这些“更改”也会被同步到云端。
  • RAID 不是备份: RAID 解决了硬盘物理故障的可用性问题,但无法防止文件误删、病毒感染或人为破坏。

有效备份策略的核心要素

  1. 版本控制 (Versioning): 能够恢复到任意历史时间点的文件状态。
  2. 异地存储 (Off-site): 至少有一份备份存储在与源数据物理隔离的地方(例如云端或另一个城市的保险箱),以防范火灾、盗窃等灾害。
  3. 自动化 (Automation): 备份应该自动、定期执行,避免因人为疏忽而中断。
  4. 加密 (Encryption): 确保备份数据的安全,即使存储介质丢失,数据也不会泄露。
  5. 定期验证 (Verification): 定期尝试从备份中恢复数据,确保备份的完整性和可用性。

API:与世界进行程序化交互

API (Application Programming Interface) 是线上服务提供的数据接口,允许你通过代码来访问和控制它们的功能,而无需通过浏览器。

API 交互基础

  • URL 结构: API 通常有结构化的 URL,如 api.service.com/v1/users
  • HTTP 请求: 使用 curl 或编程语言的 HTTP 库向 API 端点发送请求。
  • 数据格式: API 的返回结果最常用的是 JSON 格式,你可以使用 jq 等命令行工具来解析和提取所需信息。
  • 认证 (Authentication): 多数 API 需要认证。常见的方式是 OAuth,它会给你一个私密的令牌 (Token),你需要在每个请求中附上这个令牌,以证明你的身份。请务必像对待密码一样保管好你的 API 令牌!

示例:获取天气信息

bash
## 请求获取指定经纬度的天气信息
curl https://api.weather.gov/points/42.3604,-71.094

## 使用 jq 解析返回的 JSON,提取小时预报的 URL
curl -s https://api.weather.gov/points/42.3604,-71.094 | jq -r '.properties.forecastHourly'

自动化工作流

IFTTT (If This Then That) 这类服务可以让你轻松地连接不同的 API,创建自动化工作流。例如:“如果我在 Instagram 上发布了新照片,就自动将它备份到我的 Dropbox”。


Markdown:轻量级文档标记语言

Markdown 是一种简单易学的标记语言,让你可以在纯文本文档中添加格式。它的设计目标是“易读易写”,并且可以轻松转换为 HTML 等其他格式。

核心语法

  • 标题: ## 一级标题, ### 二级标题
  • 强调: *斜体*, **粗体**, ~~删除线~~
  • 列表:
    • 无序列表: - 项目一, - 项目二
    • 有序列表: 1. 项目一, 2. 项目二
  • 代码:
    • 行内代码: `code`
    • 代码块:
      python
      def hello():
          print("Hello, World!")
  • 链接: [显示文本](https://example.com)
  • 图片: ![替代文本](image.jpg)
  • 引用: > 这是一段引用

本教程本身就是使用 Markdown 编写的,它的应用范围极其广泛,从 GitHub 的 README.md 文件到各种笔记软件和论坛。


虚拟机与容器:创建隔离的计算环境

虚拟机 (VMs)容器 (Containers) 是两种强大的技术,用于创建与主机系统隔离的、可复现的计算环境。

  • 虚拟机 (Virtual Machines): 模拟一整套硬件(CPU、内存、硬盘),并在其上运行一个完整的、独立的操作系统。它隔离性强,但资源开销较大。
  • 容器 (Containers): 在主机操作系统内核之上,通过进程隔离技术创建一个轻量级的运行环境。它启动快、资源占用少,是现代云应用部署的主流方式。

常用工具

  • Vagrant: 一个用于自动化创建和配置虚拟开发环境的工具。你只需一个配置文件,就能一键启动一个配置好的虚拟机。
  • Docker: 目前最流行的容器化平台。开发者可以将应用及其所有依赖打包到一个 Docker 镜像中,确保在任何地方都能以相同的方式运行。

云计算 (Cloud Computing)

AWS, Google Cloud, Azure 等云服务商允许你按需租用虚拟机和容器资源。这为个人和企业提供了巨大的灵活性,可以根据需要快速获取强大的计算能力,而无需购买和维护物理硬件。


GitHub:参与开源世界

GitHub 是全球最大的代码托管和开源协作平台。为你每天都在使用的开源工具做贡献,比你想象的要简单。

两种主要的贡献方式

  1. 提议题 (Issues):

    • 作用: 报告你遇到的 Bug 或提出新功能建议。
    • 价值: 这是非常有价值的非代码贡献。一个清晰、可复现的 Bug 报告能为开发者节省大量时间。你也可以在现有的议题下参与讨论,提供更多信息。
  2. 提交拉取请求 (Pull Requests, PRs):

    • 作用: 提交你自己的代码更改,以修复问题或实现新功能。
    • 工作流程:
      1. 复刻 (Fork): 在你的 GitHub 账号下创建一份项目的完整副本。
      2. 克隆 (Clone): 将你复刻的仓库下载到本地。
      3. 创建分支 (Branch): 为你的修改创建一个新的分支。
      4. 修改与提交 (Commit): 编写代码并提交你的更改。
      5. 推送 (Push): 将你的分支推送到你复刻的 GitHub 仓库。
      6. 创建 PR: 在 GitHub 页面上,从你的分支向上游(原始)项目的主分支创建一个拉取请求。

之后,项目维护者会审查你的代码,提出反馈,最终可能会将你的贡献合并到项目中。

Reunited - Toby Fox
00:0000:00