Skip to content

Latest commit

 

History

History
388 lines (302 loc) · 13.7 KB

File metadata and controls

388 lines (302 loc) · 13.7 KB

05. Shell 与命令行

Shell 不只是"打命令的地方",它是一门编程语言,也是你和内核之间的翻译官。

这一层解决什么问题

内核只认系统调用。人不可能直接敲 syscall。Shell 的职责:接收人类输入 → 解析成命令 → fork/exec 出程序 → 用管道把程序串起来。

理解 Shell 的两个身份,很多困惑会消失:

  1. 交互式解释器 —— 你敲一行,它执行一行。
  2. 脚本语言 —— 有变量、条件、循环、函数,能写程序。

一、选哪个 Shell

Shell 特点 适合
bash 事实标准,几乎所有系统都有,脚本兼容性最好 写脚本、通用
zsh 功能强(补全、globbing、主题),macOS 默认 日常交互
fish 开箱友好,语法不兼容 POSIX 新手、交互
dash 极简极快,POSIX 合规 /bin/sh 实现(Debian 上)
ash / busybox sh 极小 嵌入式、Alpine、initramfs
$ echo $SHELL                  # 登录 shell(注意:不是你当前正在用的)
$ ps -p $$ -o comm=            # 当前实际运行的 shell ← 更准
$ cat /etc/shells              # 系统允许的合法 shell 列表
$ chsh -s /bin/zsh             # 换默认 shell

写脚本用 /bin/sh 还是 #!/bin/bash?

  • 要可移植(Docker/Alpine/嵌入式)→ #!/bin/sh,只用 POSIX 语法。
  • 要用数组、[[ ]]、${var//a/b} 等 → #!/bin/bash,并明确声明。
  • 同一个脚本里 [[ ]] 配 #!/bin/sh,在 Debian 上(dash)会直接报错。这是常见坑。

二、命令行基本功

2.1 通配与展开:谁在处理 *?

这是最重要的认知:* 是 Shell 展开的,不是命令处理的。

$ echo *.md                    # shell 把 *.md 展开成文件名列表再传给 echo
$ ls ~/Doc*                    # 同理

由此推出:命令收到的是已经展开的真实路径。如果需要传给命令让它自己处理,必须引号:

写法 结果
"$var" 变量展开,不分词,不 glob ← 默认就该这么写
'$var' 单引号,字面量,什么都不展开
`cmd` 命令替换(老式,不推荐)
$(cmd) 命令替换(推荐,可嵌套)
${var} 界定变量名边界,${var}abc
$ f="my file.txt"
$ rm $f        # ❌ 被拆成两个参数,删错东西
$ rm "$f"      # ✅ 永远加引号

2.2 括号家族(最容易混)

语法 名字 作用
{a,b,c} 花括号展开 生成列表:file{1..3}.txt
(cmd1; cmd2) 子 shell 在新 shell 中执行(变量改动不保留)
{ cmd1; cmd2; } 命令组 在当前 shell 执行(注意分号和空格)
$((1+2)) 算术展开 整数运算
$[...] [ ] test 条件判断(POSIX)
[[ ]] 条件判断 bash/zsh 扩展,更安全(不分词)
(( )) 算术判断 (( i++ ))、if (( a > b ))

2.3 重定向与管道

Linux 的哲学:小工具 + 管道 = 无限组合。

$ cmd > out            # stdout 覆盖到文件
$ cmd >> out           # stdout 追加
$ cmd 2> err           # stderr 到文件
$ cmd > all 2>&1       # stdout 和 stderr 都进 all(顺序不能反)
$ cmd &> all           # bash 简写,同上
$ cmd > /dev/null 2>&1 # 全部丢弃
$ cmd1 | cmd2          # stdout 接到下一命令的 stdin
$ cmd | tee log        # 同时输出到屏幕和文件

三个标准流:

编号 名字 默认
0 stdin 键盘
1 stdout 终端
2 stderr 终端

其他有用的重定向:

$ exec 3< file           # 打开文件描述符 3 用于读
$ while read -r line <&3; do echo "$line"; done
$ cmd <<< "string"       # here-string
$ cmd <<EOF              # here-document
多行内容
EOF
$ cmd |& grep err        # bash 4+:stdout+stderr 同时管道

管道的一个重要性质:管道中的每个命令都在独立子进程里跑。所以:

$ echo "hi" | read x; echo "$x"   # ❌ 空,因为 read 在子 shell
$ read x <<< "hi"; echo "$x"      # ✅

2.4 退出码与逻辑连接

$ cmd; echo $?          # 上一条命令的退出码:0 成功,非 0 失败
$ cmd1 && cmd2          # cmd1 成功才跑 cmd2
$ cmd1 || cmd2          # cmd1 失败才跑 cmd2
$ cmd1 || { echo "failed"; exit 1; }
$ true; false; echo $?  # 1

set -euo pipefail 是脚本的第一行(在 #!/bin/bash 之后):

  • -e 任一条命令失败立即退出(避免"错了还继续跑")
  • -u 使用未定义变量报错(避免 rm -rf $DIR/ 里 $DIR 为空的灾难)
  • -o pipefail 管道中任一环节失败整条管道算失败(默认只看最后一条)

2.5 历史与快捷键

$ history                      # 历史
$ Ctrl+R                       # 反向搜索 ← 最高效的技能,务必练熟
$ !$                           # 上一条命令的最后一个参数
$ sudo !!                      # 重跑上一条命令并加 sudo
$ Ctrl+A / Ctrl+E              # 行首 / 行尾
$ Ctrl+W                       # 删掉前一个词
$ Ctrl+U / Ctrl+K              # 删到行首 / 行尾
$ Ctrl+L                       # 清屏

2.6 常用文本处理三剑客

$ grep -rn "pattern" --include="*.py" .      # 递归搜索
$ grep -v "noise"                            # 反选
$ grep -E "a|b"                              # 扩展正则
$ grep -C 2 "error" file                     # 带上下文

$ sed -n '10,20p' file                       # 打印 10-20 行
$ sed -i 's/old/new/g' file                  # 原地替换(⚠️ 先备份!)
$ sed -i.bak 's/old/new/g' file              # 自动留 .bak

$ awk -F: '{print $1, $3}' /etc/passwd       # 按字段处理
$ awk '$3 > 1000 {print $1}' /etc/passwd     # 带条件
$ awk '{sum+=$1} END {print sum}' nums.txt   # 累加

$ cut -d: -f1 /etc/passwd
$ sort file | uniq -c | sort -rn | head      # "统计 TopN"万能组合
$ tr 'a-z' 'A-Z'
$ wc -l file
$ xargs -r -n1 cmd                           # 把 stdin 变成参数(-r 避免空输入)
$ find . -name "*.log" -mtime +30 -exec rm {} \;
$ find . -type f -size +100M -ls

find 的 -exec ... + 比 \; 高效(批量传参而非逐条 fork)。且 find -delete 比管道到 rm 更安全(能处理文件名里的空格和换行)。

三、Shell 脚本

3.1 骨架模板

#!/usr/bin/env bash
# 用途:一句话说明这个脚本干什么
# 用法:./script.sh <参数>
set -euo pipefail
IFS=$'\n\t'                     # 防止文件名含空格时分词出错

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="/tmp/$(basename "$0").log"

log() { printf '[%s] %s\n' "$(date +'%F %T')" "$*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*" >&2; exit 1; }

usage() { echo "用法: $0 [-h] [-f 文件]"; exit 0; }

main() {
    [[ $# -eq 0 ]] && usage
    log "开始执行"
    # 主逻辑
}

main "$@"

3.2 变量

$ name="value"                # 赋值不能有空格!(name = value 会报错)
$ readonly PI=3.14
$ export PATH="$PATH:/opt/bin"   # 导出给子进程
$ unset name
$ local_var=1; local lv=2     # local 只能在函数内

# 参数展开(值得专门背)
${var:-默认值}     # var 为空则用默认值
${var:=默认值}     # 为空则赋值并返回
${var:?错误消息}   # 为空则报错退出
${var#前缀}        # 去最短前缀
${var##前缀}       # 去最长前缀
${var%suffix}      # 去后缀
${var//a/b}        # 全部替换(bash 扩展)
${#var}            # 长度
${var^^}           # 转大写(bash 4+)
# 数组
$ arr=(a b c)
$ echo "${arr[0]}" "${arr[@]}" "${#arr[@]}"
$ for x in "${arr[@]}"; do echo "$x"; done     # 注意引号,否则元素会被拆开

变量作用域的关键区分:

$ export VAR=1        # 环境变量:子进程可见
$ VAR=1               # shell 变量:只有当前 shell 可见
$ env | grep VAR      # 验证是否真的导出了

3.3 条件与循环

# 条件:注意 [ ] 里必须有空格
if [[ -f "$file" ]]; then
    echo "是文件"
elif [[ -d "$file" ]]; then
    echo "是目录"
else
    echo "不存在"
fi

case "$1" in
    start|up)   do_start ;;
    stop)       do_stop ;;
    *)          echo "用法: $0 {start|stop}"; exit 1 ;;
esac

for i in {1..10}; do echo "$i"; done
for f in *.txt; do echo "$f"; done
while read -r line; do echo "$line"; done < file
until [[ -f /tmp/ready ]]; do sleep 1; done

文件测试运算符(高频):

测试 含义
-f f 是普通文件
-d f 是目录
-e f 存在
-r/-w/-x f 可读/写/执行
-s f 存在且非空
f1 -nt f2 f1 比 f2 新
-z "$s" / -n "$s" 字符串为空 / 非空
-L f 是符号链接

3.4 函数与陷阱处理

cleanup() { rm -f "$TMPFILE"; }
trap cleanup EXIT INT TERM          # 无论怎么退出都清理 ← 好习惯

retry() {
    local n=0 max=3
    until "$@"; do
        (( n++ )) || true            # ← 注意:(( n++ )) 在 n=0 时返回假,set -e 会杀掉脚本
        (( n < max )) || return 1
        sleep 2
    done
}

(( n++ )) 与 set -e 的冲突是经典坑:(( 0 )) 求值为假,返回码 1,set -e 直接终止。安全写法:n=$((n+1)) 或 ((n++)) || true。

3.5 调试

$ bash -n script.sh          # 只做语法检查,不执行 ← 提交前的必做
$ bash -x script.sh          # 打印每条执行的命令
$ set -x; ...; set +x        # 局部开启
$ shellcheck script.sh       # 静态分析,强烈推荐(apt install shellcheck)

shellcheck 应该成为你写脚本的标准动作。它能抓出未加引号的变量、[ ] 用法错误、ls 解析等大量隐蔽问题。

四、环境与配置加载

"我改了 ~/.bashrc 为什么没生效" —— 这是最高频的问题。根因是加载流程分四种情况。

bash 的启动分类:

类型 触发 读哪些文件
交互式登录 shell 本地登录、ssh 登录、su - /etc/profile → ~/.bash_profile → ~/.bashrc
交互式非登录 shell 打开的终端窗口、bash ~/.bashrc
非交互式 shell 跑脚本 $BASH_ENV(通常不读任何 rc)
登录 + POSIX bash --login --posix /etc/profile、$ENV
$ echo $BASH_ENV               # 非交互 shell 唯一会读的东西
$ shopt login_shell            # 当前是不是登录 shell

zsh 的差异:

文件 用途
/etc/zsh/zshenv 总是读(所有 zsh)
~/.zshenv 总是读
/etc/zsh/zprofile / ~/.zprofile 登录 shell
/etc/zsh/zshrc / ~/.zshrc 交互 shell ← 日常改这个
~/.zlogin 登录 shell 最后
~/.zlogout 退出时

fish:只有 ~/.config/fish/config.fish,用 fish_add_path 管理 PATH,不需要 export。

$ env | sort                   # 看所有环境变量
$ echo $PATH | tr ':' '\n'     # 分行看 PATH(PATH 顺序 = 查找优先级)
$ type -a cmd                  # 命令来自哪(别名/函数/内置/文件)
$ command -v cmd               # 别名和函数绕过的查找
$ hash -r                      # 清命令缓存(刚装的命令找不到时用)
$ source ~/.bashrc             # 重新加载(不影响新开终端)

⚠️ 在 ~/.bashrc 里放 alias 给非交互场景用是无效的。cron、systemd、docker exec 都不会读你的 rc 文件 —— 这就是"手动能跑、自动化不行"的根本原因。写服务脚本时把所有环境显式写出来。

PATH 冲突排查

$ which -a python3             # 按 PATH 顺序列出所有匹配
$ echo $PATH | tr ':' '\n' | nl

PATH 里靠前的目录优先。conda、~/.local/bin、/usr/local/bin 常造成"我装的是新版,跑起来却是旧版"。

五、排障入口

症状 排查
改了 rc 不生效 确认 shell 类型 + 登录/非登录;source 或重开终端
脚本手动行、cron 不行 PATH 与变量缺失;用绝对路径;env 对比
Syntax error 但看着没错 检查是否 bash 语法跑在 sh 下;bash -n
命令找不到 which -a、hash -r、PATH 顺序、shell 哈希缓存
变量里全是空格问题 全部加引号;IFS 设置
中文/特殊字符乱码 LANG/LC_ALL、终端编码(见 10)
管道结果不对 注意子 shell 问题;set -o pipefail
文件没了 检查是否被 -exec / xargs 的引号问题误伤

常见误区

  • * 由 shell 展开,不是命令。加引号能阻止展开。
  • 管道里的命令在子进程,变量赋值会丢失。
  • set -e 不是万能的,if cmd 里的失败不会退出。
  • [ ] 是命令(test 的别名),所以空格是语法的必需部分。
  • #!/bin/sh 不等于 #!/bin/bash,Debian 上是 dash。
  • 不要用 ls 的输出做循环输入,文件名含空格会出错,用 glob 或 find -print0。

待补充

  • zsh 深度配置(oh-my-zsh / starship、补全系统、glob 限定符)
  • fish 的 POSIX 差异对照表
  • 正则表达式速查(BRE/ERE/PCRE 区别)
  • 完整的日志分析实战(awk/sed/sort/uniq 组合拳)
  • jq / yq 处理结构化数据
  • xargs 与 parallel 并行化
  • 写一个生产级脚本的完整范例(含锁、日志、重试、dry-run)
  • shopt 各选项与 bash 版本差异

← 上一章:用户、权限与安全 · 返回目录 · 下一章:发行版与包管理 →