跳转至

第 2 章 Shell 与命令行

排障、脚本、构建、服务临时调试都要落到 Shell。日常写 30 行小脚本与"能跑就行"的差别,集中体现在三处:引号与 word splitting 处理不对、脚本没设 set -euo pipefail、systemd / cron 环境里依赖交互 shell 的副作用。本章把 Bash 语法基线、脚本工程习惯、文本三件套(grep / sed / awk)、find / xargs、以及非交互 shell 的坑逐一拆开,末尾给出"命令效率工具"的最小集。ROS 2 工作区脚本、udev 调试脚本、.service 里的 ExecStart 命令串,都会踩在这几条基线上。

1. Bash 语法基线

1.1 变量、引号与 word splitting

Bash 的变量展开后,默认会经历一次 word splitting(按 $IFS 切分)与 glob 展开(*?[] 匹配文件名)。这是绝大多数"变量传进去怎么不对"的根源。

msg="hello   world"
echo $msg        # 未加引号:word splitting 生效,输出 hello world(三个空格合成一个)
echo "$msg"      # 加双引号:原样保留,输出 hello   world
echo '$msg'      # 单引号:不展开,字面 $msg

规则:

  • 变量默认加双引号。"$var" 保住空格与换行;${var:-default} 给未设变量兜底。
  • 单引号关展开,只在写正则或字面量时用。
  • 数组展开要显式:"${arr[@]}" 保留每个元素为独立词,${arr[*]} 会拼成一个。
  • glob 要防"没有匹配":shopt -s nullglob 让不匹配的 glob 展开为空数组,避免脚本被字面 *.log 绊倒。

echo $file 传一个含空格的路径给 rm,会误删两个文件——这是最贵的一个空格。写脚本永远 "$file"。

1.2 退出码、管道与短路

Bash 用退出码(exit status,0 成功 / 非 0 失败)传递结果,不靠 stdout。每个命令跑完,$? 是它的退出码:

grep -q "error" /var/log/syslog
if [ $? -eq 0 ]; then echo "有 error"; fi
## 更简洁的等价:
grep -q "error" /var/log/syslog && echo "有 error"
  • A && B:A 成功才跑 B(短路)。
  • A || B:A 失败才跑 B。
  • 管道 A | B:只把 stdout 传给 B 的 stdin;默认 $? 是最后一个命令的退出码,中间命令失败会被吞掉——这是脚本沉默失败的头号成因。要拿到全链结果需 set -o pipefail。

1.3 重定向与文件描述符

三个默认文件描述符:0 = stdin、1 = stdout、2 = stderr。

cmd > out.txt 2>&1      # stdout 覆盖到文件,stderr 并入 stdout(POSIX 顺序敏感)
cmd &> out.txt          # Bash 4+ 的简写,等价上一行
cmd >> out.txt          # 追加而非覆盖
cmd 2> err.txt          # 只重定向 stderr
cmd > /dev/null 2>&1    # 丢弃全部输出(cron 里常见)

顺序陷阱先讲:cmd 2>&1 > out.txt 错——2>&1 先把 stderr 指向 stdout 当时的目标(终端),随后 > out.txt 只把 stdout 移到文件,stderr 依旧回终端。正确写法永远把 2>&1 放最后。

变量展开到 argv 的加工顺序,以及引号在哪一步跳过加工:

flowchart LR
    var["$var 展开"] --> split["word splitting<br/>按 IFS 切分"]
    split --> glob["glob 展开<br/>* ? [] 匹配文件名"]
    glob --> argv["argv 传给命令"]
    quote["双引号包住变量"] -.->|"跳过切分与 glob"| argv

坑:不加引号的变量先被切分再传参

$var 展开后会先做 word splitting 与 glob,再进 argv;"$var" 才是一个完整参数。变量里带空格的路径,是脚本事故最常见的起点。

2. 脚本工程习惯

2.1 set -euo pipefail

三行开头是分享型脚本的最低门槛:

##!/usr/bin/env bash
set -euo pipefail
## -e:命令失败即退出(受 && || 短路和 if 条件豁免)
## -u:引用未设变量当错误退出,防 ${FOO} 静默变空串
## -o pipefail:管道任一命令失败即整链失败

三行加起来的收益:"脚本静默跑到最后一步炸了、前面 20 步的错全被吞掉" 这类问题几乎绝迹。调试期临时想放宽用 set +e 局部关闭、跑完 set -e 恢复。双基线:Ubuntu 22.04 默认 bash 5.1、24.04 升到 5.2——set -u 与 pipefail 行为保持一致,24.04 下 ${var@Q} 与部分数组展开行为更严;写脚本以 5.1 行为基线,遇新特性单独注明。

2.2 [[ ]] 与 [ ]

Bash 提供两种条件表达式:POSIX [ ](test 内建)与 Bash 关键字 [[ ]]。新脚本一律用 [[ ]]:

特性 [ ] [[ ]]
词分割与 glob 会(要引号) 不会
字符串比较 == 部分实现、= 通用 == 支持 glob 与正则
逻辑组合 -a -o(易踩优先级坑) && \|\|
正则匹配 无 =~
空变量 需要额外加引号防语法错 直接可
[[ "$name" =~ ^[a-z0-9_]+$ ]] && echo "合法标识符"
[[ -f "$path" && -r "$path" ]] && cat "$path"

只在必须给 dash / sh 复用的 shebang 才回退 [ ]。Ubuntu /bin/sh 是 dash,[[ ]] 会报语法错。

2.3 函数与局部变量

## 用 local 声明函数内变量,防止污染全局
resolve_device() {
    local vendor="$1" product="$2"
    local line
    line=$(lsusb | grep -i "$vendor $product" || true)   # || true 让 set -e 不炸
    [ -n "$line" ] || return 1
    awk '{print $4}' <<< "$line"                          # 提取 /dev/ttyVIDL 字段
}

三点习惯:

  • 函数入参用 $1 $2 局部化,不直接读外层 $@。
  • 返回值走退出码(return N),不走 stdout;stdout 留给"取数据"的调用者 $(func)。
  • local 声明紧贴使用,跨多行时开头集中 local a b c;无 local 的赋值会泄漏到调用者。

3. 文本三件套

3.1 grep:找存在与抽行

grep -n "error" log.txt          # 显示行号
grep -r "TODO" src/              # 递归目录
grep -E "warn|error" log.txt     # 扩展正则(等价 egrep)
grep -F "192.168.1.10:1234" ...  # 固定串匹配,跳过正则解析,快且不歧义
grep -v "^#" conf                # 反向:忽略以 # 开头的行
grep -A2 -B1 "panic" dmesg.txt   # 上下文:After 2 行、Before 1 行

GNU 与 BSD 正则方言不通用:\d 是 PCRE,GNU ERE 要写 [0-9] 或加 -P 用 PCRE。写脚本尽量落在 ERE 或字面 -F,跨发行版可迁移。

3.2 sed:行级替换与流式打印

sed 面向"每行做一次替换、别的信息别丢"。三个高频用法:

sed -n 's/foo/BAR/p' file        # 只打印替换成功的行(-n 抑制默认输出)
sed -i.bak 's/\r$//' file        # 原地改并留 .bak;去掉 Windows CRLF
sed -i 's/[[:space:]]*$//' conf  # 去行尾空白;[:space:] 比 \s 更可移植

-i 在不同实现下语法不同(GNU -i、BSD 必须 -i '')——脚本要在 CI 跨平台跑,就 sed 's/.../.../' file > tmp && mv tmp file。

3.3 awk:按字段处理与聚合

awk 是"每行按空白切字段 + 一个嵌入式小语言":

## 输出 ps aux 里内存占比 > 5% 的进程名与占比
ps aux | awk '$4 > 5 {print $11, $4}'

## 统计 ros2 topic list 里每类话题的前缀计数
ros2 topic list | awk -F/ '{c[$2]++} END {for (k in c) print k, c[k]}'

字段号 $1 起始、$0 是整行;NF 字段数、NR 行号;-F 指定分隔符。写复杂逻辑用 Python 更清晰,awk 承担"三行以内、按字段处理"的活。

4. find、xargs 与进程替换

4.1 find:按名字、时间、类型挑文件

## 当前目录及子目录下所有 .py,最近 7 天改过
find . -type f -name "*.py" -mtime -7

## 权限 644 之外的文件
find /etc -type f ! -perm 644

## 找到即执行(-exec ... + 批量传参,接近 xargs)
find /var/log -name "*.gz" -mtime +30 -exec rm -f {} +

-name glob 匹配、-iname 忽略大小写、-regex 走 EMACS 正则;-print0 与 xargs -0 配套处理含空格文件名。

4.2 xargs:把 stdin 变成参数

## 安全形式:处理含空格、换行的文件名
find . -name "*.log" -print0 | xargs -0 rm -f

## 并行
nproc=$(getconf _NPROCESSORS_ONLN)
find . -name "*.jpg" -print0 | xargs -0 -P "$nproc" -n1 convert -resize 50%

不用 xargs 的坑:find ... | xargs rm 遇到含空格的路径会拆错。永远 -print0 | xargs -0。

4.3 进程替换与 here-string

diff <(sort a.txt) <(sort b.txt)      # 把两个命令的输出当文件传给 diff
while IFS= read -r line; do
    echo "处理:$line"
done < <(ros2 topic list)             # 关键:< <(...) 保证循环在 shell 进程内,变量可读
grep "root" /etc/passwd <<< "$input"  # 把字符串当 stdin 喂给命令

5. 结构化格式:YAML、JSON、XML 与 jq / yq

grep / sed / awk 处理的是"行与字段",而机器人开发里的配置与交换数据是层级结构——承载它们的是 YAML / JSON / XML 三种数据序列化格式(data serialization format):它们是格式不是编程语言,没有控制流,职责是同一件事——把层级数据表示成文本,再由解析器读回成结构。开发者需要的是两样:读懂它的文法,以及用对解析工具。

三者的分工与差异(机器人开发语境):

层级表达 注释 典型场景
YAML 缩进即层级 # 参数文件、netplan(第 6 章)、CI 配置——人手工编辑的配置首选
JSON {} [] 显式嵌套 不支持 工具输出、API 响应、compile_commands.json——机器间传递首选
XML 标签 + 属性 <!-- --> URDF / MJCF / package.xml——结构复杂的描述(连杆树适合属性+嵌套)

语法与解析行为的四类高频坑(前四条属 YAML 1.1 规范范畴的 PyYAML 行为,不同解析器不完全一致):

  • YAML 缩进禁 tab——a: 下用 tab 缩进的 b: 1 解析即报 ScannerError;三件套与编辑器层面的 expandtab(第 3 章)都是在防这一条。
  • 键名与特殊字符必须加引号:含 : 的值会被当作键值分隔。
  • 类型推断会"吞"字面值:1:30 被解析为整数 90(YAML 1.1 的六十进制遗风)、on / yes / NO 被解析为布尔、2026-11-05 被解析为日期——配置里要保留字面量时加引号。
  • JSON 严格子集直觉不可靠:key 未加引号、尾逗号、// 注释三种写法均被标准解析器拒绝(标准库 json 模块即拒绝,各 Python 版本一致)。
  • 工具链侧:jq 查询与改写 JSON(.field | select(...) 管道语法),XML 侧 xmllint --xpath 抽取。yq 存在两支语法不兼容的实现——mikefarah/yq(Go,自成一套 eval 管道语法)与 kislyuk/yq(Python,包名 yq,把 YAML 转成 JSON 后原样交给 jq 过滤器),引用命令前须写明所用实现。Ubuntu 22.04 仓库:jq 1.6、xmllint(apt 包 libxml2-utils,2.9.13)在仓库且在默认安装里,yq 不在 apt 仓库——kislyuk 支走 pip install yq(依赖 jq 二进制),mikefarah 支走 snap 渠道(snap info yq 的 latest/stable 为 v4.49.2)。24.04 侧按 packages.ubuntu.com 页(取用 2026-11):jq 升到 1.7.1;kislyuk 支进了 universe 仓库,包名就叫 yq(3.1.0-3,依赖 jq);mikefarah 支的 apt 包名为 yq-go,在 22.04 / 24.04 两个 LTS 均不可用,只进非 LTS 系列(该页 tenacious 4.53.3)——LTS 上取 mikefarah 支仍走 snap。

一条工程原则:结构化格式的查询与转换走"解析 → 结构 → 再序列化",不用 sed / awk 对 YAML / XML 做行级正则改写——「sed:行级替换与流式打印」的替换不理解层级,缩进与转义一改就坏。命令行最小样:

## ROS 2 参数文件(velodyne_driver 的 VLS128-…-params.yaml)抽一个字段:
jq 支——先解析成 JSON 再过滤(系统自带 python3-yaml + apt jq,输出 600):
python3 -c "import yaml,json,sys; json.dump(yaml.safe_load(open('VLS128-velodyne_driver_node-params.yaml')), sys.stdout)" \
  | jq '.velodyne_driver_node.ros__parameters.rpm'
kislyuk/yq 支——同一 jq 过滤器直接吃 YAML(pip 安装,输出 600):
yq '.velodyne_driver_node.ros__parameters.rpm' VLS128-velodyne_driver_node-params.yaml
xmllint——对 xacro 取 robot 根元素的 name 属性(realsense2_description,输出 sensor_d405):
xmllint --xpath 'string(/robot/@name)' _d405.urdf.xacro

Python 侧把这些结构读进程序的写法(json / yaml / xml.etree)在第 3 章开发环境一节交代;ROS 2 参数文件的 /**: ros__parameters 层级归 ROS2-note 第 3 章。

6. 非交互 shell 与环境变量的坑

cron 与 systemd 的 ExecStart 都跑在非交互、非登录的 shell 环境里,与桌面终端行为差着一层——这是"我在终端能跑、写成服务就起不来"的第一号成因。

6.1 谁 source 了 .bashrc

启动方式 login interactive 读哪些
桌面终端(GNOME Terminal) 否 是 ~/.bashrc
ssh host / su - 是 是 ~/.profile → ~/.bashrc
bash script.sh 否 否 什么都不读,只继承环境
cron 否 否 什么都不读;PATH 极简
systemd ExecStart 否 否 什么都不读;PATH 来自 /etc/environment + 单元内 Environment=

规则:把只在交互 shell 需要的东西放 ~/.bashrc(补全、别名、PS1),把服务与环境无关需要看到的 PATH 与环境变量放:

  • 全局:/etc/environment(NAME=VALUE,不解析变量、不执行脚本)
  • 单服务:EnvironmentFile=/etc/<svc>.env 与 Environment=PATH=/opt/ros/humble/bin:/usr/bin:/bin

6.2 cron 与 systemd 单元的 PATH / Environment

cron 一行内写定 PATH 最稳(不加时,cron 用调用者环境的 PATH,跨用户与系统差异大):

## crontab -e
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * /home/user/backup.sh >> /var/log/backup.log 2>&1

systemd 单元同理,环境变量与工作目录一律写进 [Service],不指望 shell 继承:

## /etc/systemd/system/my-node.service
[Service]
Environment="ROS_DOMAIN_ID=42"
EnvironmentFile=-/etc/default/my-node        # 前缀 - 表示文件不存在也不报错
ExecStart=/usr/bin/bash -c 'source /opt/ros/humble/setup.bash && exec ros2 run demo talker'
WorkingDirectory=/home/user/robot

ExecStart 里的 exec 把 shell 直接替换为目标进程,只留一个 PID,systemd 信号能直达(呼应第 4 章 TimeoutStopSec);bash -lc 会多读一份 /etc/profile 拉长启动,日常服务不建议开。单元完整语义与依赖链在第 4 章「systemd 单元模型」展开。

坑:cron 与 systemd 里没有交互 shell

两者都不读 .bashrc,PATH 与 Environment= 必须显式给出;在单元里 source ~/.bashrc 也无效——Ubuntu 的 .bashrc 开头就在非交互 shell 里 return。

7. 命令效率工具与会话保持

装不装随个人偏好,但机器人上位机的日常运维确实靠这几样提速:

  • tmux:在 SSH 断线时保住会话是刚需——远程起一个 tmux new -s main,所有长跑任务放里面;网络抖了重连 tmux attach -t main 即恢复。systemd 环境下 tmux 需要显式设 TERM=screen-256color。
  • ripgrep(rg):默认递归 + 忽略 .gitignore + 多线程,比 grep -r 快一个数量级;输出对高亮更友好。
  • fd:find 的现代化替代,语法短、默认忽略隐藏与 .gitignore。
  • fzf:模糊选择器;与 Ctrl-R(历史搜索)、Ctrl-T(文件选择)、Alt-C(目录跳转)绑定后几乎改变键盘习惯。
  • bat:带语法高亮与 git gutter 的 cat;小文件翻页更顺手。
  • eza:ls 的现代替代,支持 git 状态列与树状输出。
  • zsh:作为交互式 shell 补全与主题更强,脚本兼容性上 Bash 更保守——脚本一律 #!/usr/bin/env bash、不依赖 zsh 特性。zsh 仅作为配置项对照。

包名与命令名在 Ubuntu 上并不一一对应:fd 在 22.04 主仓库叫 fd-find、装的命令是 fdfind;bat 装的命令是 batcat(与 bacula-console-qt 冲突);eza 自 24.04 起进主仓库,22.04 用前身 exa(已归档)。脚本里写成完整包名与命令名,跨版本迁移时才不会找不到。

8. 要点

  • 加引号是第一原则:"$var" 与 "${arr[@]}";未加引号的展开会走 word splitting 与 glob,脚本事故的高频起点。
  • 脚本起手三行 set -euo pipefail;$? 只看最后一个命令,管道加 pipefail 才能全链检查。
  • [[ ]] 优先于 [ ];只在必须给 dash 复用时回退 POSIX 语法;跨平台避免 sed -i 与 GNU 正则方言。
  • find / xargs 永远走 -print0 | xargs -0,处理含空格文件名;-exec ... + 是同类替代。
  • YAML / JSON / XML 是格式不是语言:选型按"人编辑用 YAML、机器传递用 JSON、复杂描述用 XML";YAML 的 tab 缩进、类型推断(1:30、on/off)与 JSON 的严格语法是高频坑;结构化文本的查询走 jq / yq / xmllint 的解析链,不用 sed 改层级。
  • cron 与 systemd 都是非交互 shell:不 source .bashrc;PATH 与环境变量走 /etc/environment、EnvironmentFile= 或单元 Environment=。
  • 长跑任务放 tmux,SSH 断线不丢会话;命令效率工具(rg / fd / fzf / bat / eza)按需引入,脚本主线仍是 Bash + POSIX 工具。

参考文献与延伸阅读

  • GNU. Bash Reference Manual. 文档
  • The Open Group. POSIX.1-2024 规范. 规范
  • tmux. Wiki 与文档. 文档