1. 先搞清楚 shell 和 bash 的区别与联系
如果你跟着我的学习路线一路看到这里,应该已经接触过不少命令了。这篇是系列笔记里的第九篇,主题是彻底理解 bash shell。我见过太多人把终端、Shell、bash、命令行这几种说法混在一起用,聊天时讲"在终端里跑一下 bash",写文档时又变成"shell 脚本",大家互相都能猜懂,但真到排错的时候,概念不清会导致连错误信息都看不懂。这篇先把这层窗户纸捅破,再带你过一遍 bash 的语法主干、高频场景和常见坑,属于那种可以反复翻的底子型文章。
不管你最终用 Linux、macOS 还是 Windows 下的某个终端工具,只要你敲命令,背后大概率都是 bash 在处理。它是当前应用面最广的 Shell 实现,也是写自动化脚本绕不开的底子。搞懂它,意味着你不只是在"记住命令",而是真正理解命令行世界是怎么运转的。这篇文章适合三类人:一是刚从图形界面转到命令行的新手,二是写了一两个脚本但老在判断、循环上出错的初级开发者,三是工作中时不时要处理日志、批量改文件、做备份的运维和测试人员。
1.1 终端、Shell、Bash 三者分别是什么
其实这三个词对应的东西完全不同。
- 终端(Terminal)是一个窗口程序,负责显示文字、接收你的键盘输入。macOS 里的 Terminal.app、Windows Terminal、iTerm2、VS Code 的内置终端,都是终端仿真器。
- Shell 是一个用户与操作系统内核之间的翻译层,你输入的每一条命令都经过它解析,再交给内核去执行。
- bash 则是 Shell 的一种具体实现,全称 Bourne Again Shell,是 sh 的增强替代品,Linux 上事实上的默认 Shell。
如果拿吃饭打比方:终端是你面前的餐桌和餐具,Shell 是服务员,bash 就是这位服务员的具体工号和操作规范。你坐在终端前说话,服务员(bash)把话翻译成后厨(内核)能懂的指令,再端出菜来。不同餐厅的服务员可能叫 csh、zsh、fish,但大多数餐厅默认配置都是 bash,所以学它最不亏。
1.2 为什么 bash 能成为事实标准
bash 之所以流行,很大程度是因为它同时做了几件事:兼容 POSIX 规范的历史包袱,保证老脚本基本能跑;又吸收了 csh、ksh 的优点,加入数组、函数这类现代特性;再加上 Linux 发行版默认安装、macOS 内置了旧版本 bash,跨平台场景里几乎没有第二个选项能做到这种覆盖度。
很多朋友会问,那 zsh 不是更好用吗?确实,zsh 的交互体验更好,补全更智能,这也是它成为 macOS 默认 Shell 的原因。但 zsh 的语法和 bash 高度兼容,你写脚本时依然可以按 bash 的规则来。真正重要的是理解 bash 的运行机制:脚本是逐行解释执行的,默认没有强类型,命令之间用管道、重定向和退出码协作。下面这些内容,全部围绕这个核心展开。
2. 不同系统下的 bash 环境怎么选
讨论 bash 绕不开环境搭建。很多人一上来就问"Windows 下到底装 git bash 还是 WSL2",其实先搞清楚自己原生环境是什么,再考虑补哪块会更清晰。
2.1 Linux 和 macOS 上的原生 bash
Linux 和 mac 都自带 bash,区别不大,只在某些内置命令的版本和参数细节上有差异。你可以先用两条命令确认当前环境:
# 查看当前默认 shell echo $SHELL # 查看 bash 版本 bash --version如果是 Linux 桌面版或服务器,通常已经是 bash 5.x,macOS 由于历史原因自带的是 bash 3.2,功能上少了一点,比如关联数组需要升级版本才完整。日常写脚本影响不大,但如果你在 macOS 上发现某些 bash 特性用不了,不用怀疑自己,十有八九是系统自带版本太老,用 Homebrew 装一个新版 bash 再切过去就行。
启动 bash 时会自动读取一系列配置文件,这也是经常有人困惑的问题。交互式登录 shell 会依次加载/etc/profile、~/.bash_profile、~/.bashrc等文件。你装软件时如果看到"Do you wish to update your shell profile to automatically initialize conda?"这种提示,说的就是把初始化命令写进~/.bashrc或~/.bash_profile,这样每次开终端环境变量才生效。
2.2 Windows 下选择 git bash 还是 WSL2
Windows 下跑 bash 主要两条路:git bash 和 WSL2。很多人纠结,我给一个简单的选型建议。
| 对比项 | git bash | WSL2 |
|---|---|---|
| 安装体积 | 小,几十 MB 级别 | 大,需要启用虚拟化,占用数 GB |
| 启动速度 | 快,秒开 | 较慢,首次启动尤其明显 |
| 系统能力 | 只模拟 bash 环境,不能直接跑 Linux 系统服务 | 完整 Linux 内核,能跑 Docker、systemd |
| 文件互通 | 和 Windows 文件天然同盘,切换方便 | 通过/mnt/c访问 Windows 文件 |
| 适合场景 | 学语法、写脚本、跑 git、做文本处理 | 开发、编译、部署、跑 Linux 专属工具 |
我的建议是:如果你只是想学 bash 语法、写点处理文件的小脚本,或者日常用 git,git bash 完全够用,轻量、省心,安装包双击就好。如果你要跑 Linux 服务、需要 Docker、或者部署环境要和服务器保持一致,那直接上 WSL2,不要在 git bash 里硬凑。
git bash 安装本身很简单,去官网下载安装包,一路下一步就行。装完右键菜单会出现"Git Bash Here",点开就是 bash 环境。新手用它学脚本有个好处:文件就在 Windows 目录里,cat、grep、awk命令可以直接处理你熟悉的文件,不用先学文件系统,这比 WSL2 的学习曲线低很多。
2.3 脚本第一行:/bin/bash 还是 /usr/bin/env bash
写 Shell 脚本第一行通常是这样两种写法:
#!/bin/bash和
#!/usr/bin/env bash两者都能跑,但含义不同。#!/bin/bash是写死路径,不管系统里有没有其他地方装了更新版 bash,都执行/bin/bash。#!/usr/bin/env bash则是从 PATH 环境变量里找第一个 bash 可执行文件来用。macOS 用户如果装了新版 bash,第二种写法会优先用到新版;Linux 上两者通常指向同一个文件,差别不大。我习惯在脚本里写#!/usr/bin/env bash,因为可移植性更好,还能顺手规避某些自定义安装场景的路径问题。
2.4 bash -c 的一行执行技巧
除了写脚本,bash 还支持直接传一串命令执行:
bash -c "echo hello; pwd"这个用在 CI 配置、远程命令、容器启动命令里非常常见。比如有些安装脚本会写成bash -c "$(curl -fsSL https://example.com/install.sh)",意思是用 bash 解释执行下载下来的脚本内容。理解这一点,你以后看到任何/bin/bash -c "..."的写法就不会懵了。要注意引号嵌套:如果是bash -c "echo \"hello\""这种场景,内层引号记得转义,或者干脆改用单引号包裹外层的逻辑。
3. bash 脚本的核心语法地图
理解 bash 语法,不用背天书,抓住几条主干就够用。我尽量用"最少必要知识"的角度讲:变量、判断、循环、参数处理、函数,这五样组合起来能解决 80% 的日常脚本需求。
3.1 变量、引号和字符串处理
bash 里变量赋值和读取都很简单:
name="world" echo "hello, ${name}"有两个新手必踩的点。第一,赋值时等号两边不能有空格,name = "world"会被解析成命令name加参数,直接报错。第二,读取变量时建议用${name}包裹花括号,尤其在字符串拼接时更明显。比如echo "$name_suffix"会被识别成变量name_suffix,而echo "${name}_suffix"才是先取 name 再加后缀。
单引号和双引号的区别也很关键。双引号里的变量会被展开,单引号里所有字符都保持字面意思:
name="world" echo "$name" # 输出 world echo '$name' # 输出 $name如果要在字符串中加换行,bash 里推荐用$'...'语法或者 printf:
multi_line=$'第一行\n第二行' printf "%b\n" "第一行\n第二行"注意别用 echo 加转义字符,不同环境行为不一致。处理变量默认值也是高频操作:${var:-默认值}表示 var 未定义时用默认值;${var:?错误信息}表示 var 未定义时直接报错退出。这套写法在健壮性检查里非常好用。
3.2 if 判断与 for 循环
一个常见问题:bash 的 if 必须有 else 子句吗?答案是否定的。if后面接的是"命令",只要命令退出码是 0 就执行 then 分支,否则执行 elif 或 else,而 else 完全可以省略,省略时什么都不做,直接继续往下走。
if grep -q "error" app.log; then echo "日志中有 error" fi这是 if 最经典的用法:if 后面不写[ ],直接写一个命令。很多人刚学的时候以为 if 后面必须接条件表达式,其实不是,任何命令都可以。比如if cd /tmp; then ...,只要 cd 成功就走 then。
如果确实需要条件判断,常见的写法是:
if [ -f "$file" ]; then echo "文件存在" fi if [[ "$name" == "world" ]]; then echo "名字匹配" fi[ ]本质是 test 命令的别名,要求变量加引号;[[ ]]是 bash 内置增强版,支持正则、模式匹配,对空变量更宽容,写脚本时我更推荐用[[ ]]。
for 循环同样有三个常用形态:
# 遍历列表 for f in *.log; do echo "处理 $f" done # 传统计数 for (( i = 0; i < 10; i++ )); do echo "第 $i 次" done # 按行读取文件 while IFS= read -r line; do echo "$line" done < data.txt第一种最适合批量操作文件;第二种适合固定次数循环;第三种处理文本行时比for line in $(cat file)安全得多,因为后者会把空格和换行都当作分隔符。
3.3 位置参数与 shift 命令
任何脚本都可以通过位置参数接收外部传入的值。$0是脚本名,$1、$2依次是第一个、第二个参数,$#是参数总数,$@是全部参数列表。
#!/usr/bin/env bash echo "脚本名: $0" echo "第一个参数: $1" echo "参数个数: $#"shift命令则把参数列表整体左移一位,原来的$1被丢弃,$2变成新的$1。最常见的搭配是用 while 循环逐个处理参数:
#!/usr/bin/env bash while [ "$#" -gt 0 ]; do echo "处理参数: $1" shift doneshift不带数字默认移一位,也可以写shift 2一次移两位。但要小心:如果参数已经处理完,再执行shift会报错,所以循环里一定要先判断$#大于 0。
3.4 函数、退出码与 set 选项
函数是脚本模块化的基础。bash 里的函数定义有两种等价写法:
function hello { echo "hello" } world() { echo "world" }我习惯用第二种,兼容性更好。函数内使用return返回值,退出码范围是 0 到 255;exit则是退出当前脚本。调用函数后立刻用$?看退出码,这是 bash 的错误传递机制:
world if [ "$?" -eq 0 ]; then echo "函数执行成功" fi写稍复杂的脚本时,强烈建议开头加一行set -euo pipefail。set -e让脚本在遇到错误时立刻退出,而不是带着错误往下执行;set -u让未定义变量直接报错;set -o pipefail让管道命令中只要有一个失败,整体就返回失败。这一行能帮你揪出大量"莫名其妙执行完但结果不对"的问题。
4. 高频实战:日期时间、批量重命名、grep 判断
语法掌握之后,紧接着要解决的是日常任务怎么写。我从搜索和咨询里挑出三个出现频率最高的问题,展开讲讲。
4.1 日期时间转数字串
很多人想用当前时间给文件名加时间戳,比如备份文件、日志滚动。bash 命令其实很直接:
stamp=$(date +"%Y%m%d_%H%M%S") echo "$stamp"输出类似20250621_143015,这正是日期时间转换为数字串的常用做法。这套格式在 Linux 和 macOS 上通用,不用担心可移植性。如果想按日期而不是精确到秒,改成date +%Y-%m-%d就行。
实际写日志脚本时,常常要用日期做滚动:
log_file="app_$(date +%Y%m%d).log" echo "写入日志: $log_file"这里有个易错点:$(...)里如果套了引号和括号,注意别和脚本自身的语法混淆。比如date +"%Y%m%d_%H%M%S"放在$( )里完全没问题,因为这里只是命令替换,和外层引号互不干扰。
4.2 Linux 下用 shell 批量重命名文件
批量重命名是 shell 的看家本领。假设目录下有 100 个.JPG文件,想统一改成小写.jpg并在前面加日期前缀:
for f in *.JPG; do base="${f%.JPG}" mv "$f" "${base}_$(date +%Y%m%d).jpg" done${f%.JPG}是删除变量值的后缀,%.JPG是 bash 通配符模式,表示从末尾开始匹配最短的.JPG并删除。如果要去掉前缀,用${f#prefix}。这两个语法解变量字符串非常高效。
还要注意文件名带空格的情况。比如my photo.JPG,如果不加引号写成mv $f ${base}.jpg,bash 会把my和photo.JPG识别成两个文件,命令直接爆炸。正确写法是始终给变量加双引号。这个细节几乎是我看别人脚本时最常见的错误,没有之一。
4.3 grep 在脚本里的正确姿态
grep 在脚本中的高频用法不是命令行的普通搜索,而是配合退出码做条件判断。grep -q特别适合这种场景,因为它不输出任何内容,只要匹配就返回 0,不匹配返回 1:
if grep -q "ERROR" server.log; then echo "发现错误日志,请检查" fi如果是在管道里过滤内容,常见搭配有两种:
# 提取包含关键字的行 grep "pattern" file.txt | awk '{print $2}' # 用扩展正则匹配多个关键字 grep -E "error|warn" app.log还有一个实用技巧是grep -o,它只输出匹配到的部分,而不是整行,适合从日志里提取 ID、IP 这类信息:
grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" access.log | sort -u一句提醒:grep 检查的是"匹配行",不是"匹配次数"。想统计次数时用grep -c或配合wc -l,否则行为会和预期不一致。
5. 我在实际调试中踩过的坑
这部分我想专门分享调试经验,因为 bash 脚本报错往往不是语法复杂,而是几个隐藏细节在捣乱。以下四个坑是我自己踩过或者帮别人排查过很多次的,值得留个心眼。
5.1 CRLF 换行符:Windows 上写脚本的经典事故
在 Windows 下用编辑器写好脚本,拿到 Linux 服务器上一跑,报错信息特别诡异:
$ bash script.sh script.sh: line 3: $'\r': command not found这是因为 Windows 的换行是 CRLF(\r\n),而 Linux 只认 LF(\n)。bash 把\r当成了一个真实字符,卡在每行命令末尾,于是每一条命令都被污染成"命令后面多了个回车",自然找不到。
排查和修复手段都不复杂。查看文件是否带 CRLF:
cat -A script.sh如果看到每行末尾是^M$,就说明是 CRLF。修复方式:
# 方式一:用 sed 删除行尾回车 sed -i 's/\r$//' script.sh # 方式二:用 dos2unix dos2unix script.sh更省事的是在 VS Code 右下角把文件编码格式从 CRLF 切成 LF 再保存。这个坑在 git bash 里尤其容易遇到,因为你在 Windows 上编辑脚本、用 git bash 执行时,行尾风格可能混杂。写脚本前先确认编辑器右下角是 LF,能帮你省掉大量无意义的排错时间。
5.2 if 后面的空格,一个都不能省
另一个高频报错是条件表达式里的空格问题:
if [$name == "world"]; then echo "match" fi这行看着好像没问题,实际跑起来会报[world: command not found。因为[是命令,命令和参数之间必须用空格分隔,正确的写法是:
if [ "$name" == "world" ]; then[的右边、]的左边、比较运算符的两边,都必须有空格。少一个都不行。用[[ ]]会宽容一些,但同样要保持空格规范。这个细节看起来小,却是搜索引擎里"bash if"相关问题的头号来源。
5.3 for 循环遇到带空格的文件名
我在 4.2 提过这个坑,展开说说。默认情况下 bash 的 for 循环按空白字符(空格、tab、换行)分词,所以如果你的文件名是2024 report.txt这种,下面这种写法会把它拆成两个"文件":
for f in $(ls *.txt); do echo "处理 $f" done正确做法是用通配符直接让 bash 自己展开文件名:
for f in *.txt; do echo "处理 $f" done因为 shell 的通配符展开结果是以完整文件名为单位,空格不会破坏它。如果你的需求是处理任意目录下的文件,配合find ... -print0和while IFS= read -r -d ''更稳。总之记住:能不用$(ls ...)就别用,这是处理文件列表时最稳的一条经验。
5.4 shift 的边界问题
shift命令我之前讲过基本用法,这里重点说边界。假设用户只传了一个参数,却写了shift 2,bash 会直接报错并退出脚本。另一个容易忽视的点是:shift会永久改变$@和$#,如果你在循环里移完了所有参数,后面还想再访问第一个参数,那就拿不到了。所以需要保留原始参数时,先复制一份:
original_args=("$@")然后对original_args做操作,而不是直接动$@。我在写多参数解析脚本时,通常会先用 while + case 处理选项参数,再用shift把选项一个个移掉,最后剩下的$@就是纯位置参数文本。这个模式在众多开源安装脚本里非常常见,也是理解shift的最好练习。
6. 一个可直接抄作业的综合备份脚本
最后,我给出一个综合运用上面所有知识点的脚本。它不复杂,但把变量、判断、循环、退出码、日期处理全串起来了,你可以直接拿去改着用。
6.1 需求与设计思路
目标:把指定目录打包成带时间戳的tar.gz,存到备份目录,并输出包大小。支持以下健壮性要求:目录不存在时直接报错;备份目录自动创建;执行中断时不会覆盖旧备份;任何一步失败都返回非零退出码。这些要求用 GNU tar 加set -euo pipefail就能满足。
6.2 完整脚本
#!/usr/bin/env bash set -euo pipefail # 默认备份当前用户的 Documents 目录,也可以传参数指定 BACKUP_SRC="${1:-$HOME/Documents}" BACKUP_DEST="$HOME/backups" STAMP=$(date +"%Y%m%d_%H%M%S") ARCHIVE="$BACKUP_DEST/docs_${STAMP}.tar.gz" # 源目录必须存在 if [ ! -d "$BACKUP_SRC" ]; then echo "错误:源目录不存在:$BACKUP_SRC" >&2 exit 1 fi # 创建备份目录 mkdir -p "$BACKUP_DEST" # 打包并压缩 if tar -czf "$ARCHIVE" -C "$BACKUP_SRC" .; then echo "备份完成:$ARCHIVE" echo "大小:$(du -h "$ARCHIVE" | cut -f1)" else echo "备份失败:tar 返回非零退出码" >&2 exit 1 fi6.3 逐段讲解
BACKUP_SRC="${1:-$HOME/Documents}"用到了参数展开的默认值技巧:如果用户传了第一个参数,就用它;没传就用$HOME/Documents。STAMP=$(date +"%Y%m%d_%H%M%S")是日期时间转数字串的实战应用。set -euo pipefail确保脚本遇到未定义变量或管道失败时停止,这个习惯我从踩过坑之后就一直保留。
源目录判断用了[ ! -d "$BACKUP_SRC" ],-d判断是否是目录,前面的!对条件取反。注意我把错误信息输出到了>&2,也就是标准错误,这样在重定向日志时错误和正常输出能分开。du -h输出文件人类可读的大小,cut -f1只截取第一列,避免后面的文件名重复打印。
这个脚本你也可以扩展一下,比如保留最近 7 天的备份,删除更早的包,加一句:
find "$BACKUP_DEST" -name "docs_*.tar.gz" -mtime +7 -deletefind -mtime +7是查找修改时间超过 7 天的文件,-delete直接删除。放进 crontab 定时执行,就是一个很实用的定期备份方案。
6.4 测试脚本时的小技巧
不要直接拿真实目录测试。我先建一个临时目录,放几个测试文件,然后跑脚本验证行为:
mkdir -p /tmp/baktest/src echo "test" > /tmp/baktest/src/a.txt bash backup.sh /tmp/baktest/src确认正常之后,再测试异常路径:故意传一个不存在的目录,确认脚本会输出错误并返回 1。bash 脚本最容易出现的问题就是"没报错但结果不对",所以测试时优先测失败的场景,别只测成功路径。
最后再分享一个小技巧。如果你在 Windows 上使用 git bash 学习这些内容,建议安装一个终端工具叫 Windows Terminal,它的多标签页体验比 git bash 自带的窗口好很多。bash 的语法本身不会因为终端变化而不同,但一个好的终端会让你更愿意在命令行里折腾,而"愿意折腾"恰恰是理解 bash shell 最好的起点。