ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从Shell到Bash:彻底搞懂命令行工作原理与脚本编写

从Shell到Bash:彻底搞懂命令行工作原理与脚本编写 很多人刚打开 Linux 终端时第一个困惑往往不是某个命令怎么写而是“我到底在跟谁说话”。你说它叫 shell可系统里明明又有个 bashWindows 上装了 Git Bash打开以后又发现跟 Linux 的终端长得差不多。折腾两天命令记住了不少回头问一句“shell 到底是什么”还是答不上来。这篇文章就打算把这件事讲透你敲进去的每一行字经历了什么、bash 在其中扮演什么角色、写脚本时那些总出问题的细节到底卡在哪儿。不管你是准备转运维的新人、写前端的同学想在 Windows 下配环境还是刚接触 EDA 工具、每天要面对 dc shell 这类交互环境的工程师看完整篇再遇到命令行也不会觉得是在“盲敲”。1. 先从“什么是 Shell”说起1.1 内核、命令与“翻译官”的关系每次都有人把 shell 和内核搞混这俩其实差得很远。操作系统真正干活的核心叫内核它管着 CPU、内存、硬盘、网卡这些硬件但你没法直接跟内核对话因为内核只认识系统调用那套接口不理解人类语言。Shell 就是夹在中间的那层翻译官你把ls -l敲进去shell 把这句话拆开、识别、执行对应的程序然后把结果重新翻译成你能看懂的字符流吐回终端。这个过程和你找外国朋友帮忙跟本地人问路一模一样——你说中文朋友翻译成当地语言对方回答后朋友再翻回中文给你。没有 shell你就只能对着二进制文件发愁了。所以广义上的 shell 不只是命令行它本质上是一类“人机接口”程序。你在 Windows 上用的资源管理器其实也是一种 shell叫 GUI shell我们平时说的命令行 shell 是文本式的。常见的命令行 shell 有好几个Linux 上最流行的是 Bash还有 Zsh、Fish、KshWindows 上有 CMD 和 PowerShellmacOS 默认从 Catalina 开始也切到了 Zsh。虽然它们长得不太一样但干的事情都是同一件读取输入、解析命令、执行程序、输出结果。1.2 终端、Shell、Bash 这三者到底什么关系很多翻车事故就是从这个概念不清开始的。终端Terminal是你眼前那个窗口它负责显示字符、接收键盘输入本质上是操作系统里的一个虚拟设备。Shell 跑在这个终端里面负责把输入内容解释给内核听。Bash 只是众多 shell 中的一种全称 Bourne Again SHell是 GNU 项目对经典 Bourne Shellsh的重写和增强。用一句话理清这三者你打开 Windows Terminal 或者 macOS 的 Terminal.app只是一个“窗口”窗口里默认运行的那个解释器是 bash你说的每一句“话”命令由 bash 解释后交给系统执行。Linux 发行版默认把 bash 配给用户当登录 shell所以大家一提 shell 脑子里自然就蹦出 bash这很正常但你要清楚 bash 不是 shell 的全部。后面我会提到 Android 的 adb shell它又是一个完全不同的环境这就是“都是 shell但此 shell 非彼 shell”的典型例子。1.3 为什么 Linux 生态里 Bash 成了默认选择这里有个历史合理性。Bourne Shellsh是 Unix 早期最成功的脚本解释器几乎所有系统管理脚本都基于它写。Bash 写了大量兼容 sh 的特性——变量赋值、函数、流程控制、管道、重定向这些语法跟 sh 保持一致所以老的 sh 脚本拿 bash 也能跑生态不会断裂。同时 Bash 又补了一堆 sh 没有的实用功能数组、[[条件表达式、${var:-default}这类字符串操作、$((算术展开))等等。另一个现实原因Bash 是自由软件几乎所有 Linux 发行版都默认内置你换个发行版、换台服务器脚本大概率还能跑。Zsh 和 Fish 虽然交互体验更好自动补全更强但兼容性不如 Bash。如果你在服务器上写脚本最稳妥的选择仍是 Bash。再说很多工具提供了“某种 shell”风格的交互界面比如 EDA 领域的 dc shell、synopsys 那套工具链、还有数据库的 sqlplus它们的精神内核跟 Bash 是相通的都是一套“读入-解析-执行”的循环。理解了 Bash你再去摸那些专用 shell上手会快很多。2. Bash 到底是怎么工作的2.1 一行命令进去之后发生了哪些事很多人以为 bash 拿到一行字符就是“找到程序塞进去”其实它先要做一连串的解析和展开。比如你敲echo 当前目录$PWD文件数量$(ls | wc -l)bash 不是直接把双引号里的内容原样传给 echo而是先识别出$PWD要替换成当前目录的路径$(ls | wc -l)要先执行这个管道命令、把标准输出替换进去然后才把最终组装好的字符串交给 echo 打印。展开的顺序有讲究先做花括号展开{1..10}和波浪号展开~然后是参数展开$var、命令替换$(...)、算术展开$((...))接着按 IFS 做单词分割再做路径通配符展开*.log最后才执行。很多新手踩的坑比如“变量值里有空格怎么传参”“为什么 $var 会被拆成多个词”根源都在这个展开顺序上。理解展开的顺序比背二十个命令都有用。2.2 命令的四种类型和查找顺序bash 里能执行的“命令”其实分四种优先级从高到低别名alias、函数function、内建命令builtin、外部命令外部程序按$PATH找。举个例子你自定义了一个叫ls的别名那么你敲ls时执行的就不是/bin/ls而是别名定义里头那条命令如果你把别名删了又定义了一个叫ls的函数那么先跑函数函数删了再用 bash 内建的ls? 不bash 没有内建 ls所以去哪找去$PATH里找/usr/bin/ls。年代久一些的服务器上时不时出现这种诡异事明明装了某个软件敲命令却提示command not found。原因通常就是那个软件的安装路径没加到$PATH。$PATH是一堆用冒号分隔的目录列表bash 会从左往右在这些目录里找一个可执行文件找到了就用找不到就报错。排查这种问题有两个好工具type 命令名能告诉你这个命令最终会被解释成什么command -v 命令名在脚本里判断命令是否存在非常好用。我写脚本时习惯先command -v curl || { echo 缺少curl; exit 1; }比which更规范。2.3 退出码脚本逻辑的真正“幕后裁判”命令行里经常出现$?这个变量它保存上一条命令的退出码。Linux 约定0 表示成功非 0 表示失败具体非 0 的值由程序自己定义。这个机制是 bash 里if能做判断的基础——你写if grep -q error /var/log/app.log; then echo 日志里有 error fiif后面跟的其实不是“真假条件”而是一条命令判断标准就是这条命令的退出码是不是 0。grep 找到了匹配就返回 0没找到就返回 1于是 if 分支是否执行就直接跟 grep 的退出码挂钩了。很多教程一上来就讲[[ ... ]]怎么写却不讲这个底层逻辑导致很多人想不通为什么if grep能成立。把退出码这个概念记牢写脚本时会少走很多弯路。3. 变量、引号与展开脚本里的基本功3.1 变量定义最容易犯的错bash 定义变量的语法是变量名值注意等号两边绝对不允许有空格。name Tom在 bash 里不是赋值而是执行一个叫name的命令后面带着两个参数于是又回到 command not found 的坑里。这是新手写脚本报错率最高的一幕没有之一。变量名区分大小写传统约定是普通变量用大写或小写都行但环境变量和常量一般用大写比如PATH、HOME、MY_CONFIG。读取变量时用$var或${var}两者在单独使用的时候没区别但当变量后面连着别的字符时差别就大了$var_suffix会被解析成变量var_suffix而${var}_suffix才是你想要的var的值后面加下划线和 suffix。这也是为什么老手在脚本里几乎永远写${var}而不是$var。3.2 单引号、双引号、反引号一次搞清楚这个知识点几乎人人迷惑过。简单记单引号包裹的内容是“全字面量”里面不管写$、反引号还是*统统原样输出不做任何展开双引号包裹的内容里$变量、$(命令)和反引号内的内容依旧会被展开但通配符*不会被展开反引号的意思是把里面的内容当作命令执行然后把标准输出替换到当前位置。写法作用示例输出假设 varworldhello $var单引号全字面hello $varhello $var双引号中文值hello worldecho $(date)命令替换当前系统时间echo \date老式命令替换当前系统时间现在$(...)是更推荐的做法因为反引号在嵌套时极其痛苦而且容易看错。双引号包裹变量还有一个重要作用防止变量值里的空格被拆成多个参数。比如一个文件名叫my file.txt你直接rm $file会被拆成rm my file.txt两条独立参数而写成rm $file才会当作一个整体名字。3.3 ${} 和 $()一个管变量一个管命令热词里专门有人搜这条说明这俩太容易混了。用一句话区分${}是变量展开取的是一个“值”$()是命令替换取的是一条命令的“输出”。比如version1.2.3 echo ${version}_beta # 输出 1.2.3_beta echo $(echo hello) # 先执行 echo hello得到 hello再输出 hello${}还有一堆高级用法是写脚本时很省力的工具${var:-默认值}变量为空时用默认值很适合给配置项兜底。${var:默认值}变量为空时不仅用默认值还会把默认值重新赋值给变量。${#var}取出变量的字符长度。${var//旧/新}把变量值里所有“旧”替换成“新”常用于字符串清洗。${var:0:5}取子串类似切片。这些语法和$()长得像但用途完全不同接受这一点之后再写脚本时思维就清晰了。3.4 位置参数、$、$* 和 shift脚本执行时可以带参数比如bash deploy.sh prod us-east-1。在脚本内部第一个参数放进$1第二个放进$2以此类推$0是脚本本身的名字$#是参数个数$和$*都表示“所有参数”区别在细节$把每个参数当作独立的词而$*把所有参数合并成一个字符串。实战中遍历参数我从来只用for arg in $不会碰$*因为带空格的参数会被拆开这是老油条的共识。shift命令的作用是“把所有位置参数集体左移一位”shift之后原来的$2变成$1$3变成$2以此类推$#也跟着减一。它特别适合用来写“边取参数边消费”的循环。比如某个脚本接收多个文件路径每个路径后面可以跟一个-o指定输出文件名那结构就这样while [ $# -gt 0 ]; do case $1 in -o) output$2 shift 2 ;; *) files($1) shift ;; esac done4. 写脚本从一行命令到一个文件4.1 Shebang 到底该怎么写脚本文件第一行通常写#!/bin/bash这一行叫 shebang井号加感叹号。它不是一个注释那么简单它的作用是告诉操作系统当你用./script.sh直接执行这个文件时应该调用哪个解释器来运行它。你在这个目录下敲脚本路径内核看到第一行是 shebang就会去启动对应的解释器。有人会写#!/usr/bin/env bash这种方式是先去找env程序再在PATH里定位bash。好处是兼容那些 bash 装在不同路径下的系统比如某些 macOS 上 bash 在/usr/local/bin/bash而不是/bin/bash。但注意env方式在跨平台脚本里也有翻车风险——有些系统env路径不是/usr/bin/env。我个人的偏好如果脚本只在自己公司的 Linux 服务器上跑直接写死#!/bin/bash更稳妥如果要发给别人在不同环境用用/usr/bin/env bash更通用。另外强调一下Debian 系发行版里/bin/sh通常指向 dash不是 bash。如果你脚本第一行写#!/bin/sh那么 bash 专有的语法数组、[[ ]]、${var//}等全会报错。很多人把#!/bin/sh和#!/bin/bash混着用最后在 Ubuntu 上炸了锅其实根源就在这里。4.2 三种执行方式差别比你想象的大执行一个脚本有三种常见方式它们的行为被很多新手忽略bash script.sh显式用 bash 解释执行。此时脚本不一定需要可执行权限shebang 也被忽略因为已经指定了解释器。./script.sh需要文件有可执行权限系统根据第一行的 shebang 找到解释器。source script.sh或. script.sh在当前 bash 进程里直接执行脚本而不是新开一个子进程。第 3 种方式最关键的区别是source不会创建新进程脚本里的变量、函数、目录切换会直接影响你当前这个 shell。这也就是为什么你改完.bashrc必须source ~/.bashrc才能立刻生效因为.bashrc是给当前 shell 会话用的如果你直接bash ~/.bashrc那份改动只会发生在子进程里子进程退出后一切还原。同理如果你想把某个.env文件里的变量加载进来也得用source。4.3 For 循环最常用也最容易出错的写法十个人搜“shell 脚本 for 循环”九个人在找语法。bash 里的 for 循环常见有三种形态第一种遍历一组静态元素for env in dev test prod; do echo deploy to $env done第二种遍历一个命令的输出或者文件的每一行for ip in $(cat servers.txt); do ping -c 1 $ip /dev/null echo $ip ok || echo $ip down done这里要注意$(cat servers.txt)出来的内容会按照 IFS默认是空格、制表符、换行被拆成多个词。如果文件每一行是一个包含空格的路径那这种写法就会炸。遇到这种情况更稳的方式是用while IFS read -r line; do ...; done servers.txt。第三种C 风格的 for 循环适合纯数字区间for ((i1; i10; i)); do echo 第 $i 次 doneC 风格循环里没有$符号变量i直接写i也是 C 语言语法。很多人把$i带进去写for (($i1; $i10; $i))然后报错原因就是没搞明白这里已经是“另一套方言”了。4.4 If、While、Case 和函数的基本盘if 语句的核心是判断退出码前面说过。而判断条件本身常用[ ]或者[[ ]]。[ ]是 test 命令的别名形式里面要有空格[ -f $file ]变量加引号是必须的否则文件名为空时整个表达式会出问题。[[ ]]是 bash 的关键字支持模式匹配和正则还不需要给变量加引号更安全。写 bash 脚本时我几乎只用[[ ]]只有在需要兼容 sh 环境时才会退回[ ]。while 循环最常用的场景之一是按行读取文件while IFS read -r line; do echo 读到$line done /etc/hostsIFS的意思是这一行不拆词-r防止反斜杠被转义两个细节都能避免脏数据破坏逻辑。case 语句做菜单选择非常直观case $1 in start) echo 启动...;; stop) echo 停止...;; restart) echo 重启...;; *) echo 用法: $0 {start|stop|restart}; exit 1;; esac函数的定义有两种等价写法function foo {}和foo() {}。我习惯用后者因为它在 sh 里也可用。函数里可以用return返回退出码注意return只能影响当前函数退出码不能直接退出整个脚本想退出整个脚本得用exit。4.5 Grep、Find、xargs 在脚本里的配合单独用 grep 谁都会但要放在脚本里做判断时记住两条经验一条是 grep 默认会把“匹配到的行内容”打印到标准输出如果你只想知道“有没有匹配”一定加-q静默模式省去大量无意义的输出。另一条是 grep 在管道里配合-E用扩展正则否则你得写一堆转义符非常痛苦。find 处理文件列表时优先用find . -name *.log -exec rm {} \;或者-delete而不是在管道里接 xargs。原因很简单文件名可能带空格管道拆词会有问题。xargs 虽然能通过-0配合 find 的-print0规避这个坑但对新手来说-exec的写法更不容易出错。不过如果要对海量文件执行复杂逻辑xargs 的并发能力还是有优势的。一句话总结简单清理用 find -exec批量处理后再走 xargs。5. 几个高频问题深挖shift、expect、set 与后台任务5.1 Shift 在参数处理中到底怎么用前面提过 shift 是左移位置参数但它真正的价值在于让“参数解析”可以写成循环。比如你写一个安装脚本想支持--prefix/opt/app、--debug、--yes这类参数每次判断完一个参数就把这批参数整体移走循环就能始终只用$1来判当前参数逻辑会非常清爽。while [ $# -gt 0 ]; do case $1 in --prefix*) prefix${1#*} shift ;; --debug) debugtrue shift ;; --) shift break ;; *) echo 未知参数: $1 2 exit 1 ;; esac done注意这里用了${1#*}这个字符串操作意思是从左边删除匹配到的部分剩下的就是后面的值。这种写法比先--prefix */再单独读$2要稳健得多。5.2 Expect处理交互式密码输入的现状热词里有“shell 脚本要在后台执行还要交互式输入密码”这个需求看起来矛盾其实解决思路就是expect。Expect 是一个专门用来“对话”的工具它可以启动一个子程序监听它的输出然后按预设规则回送输入。核心语法就四件事spawn启动目标程序expect等待期望的字符串出现send发送输入interact把控制权交回用户。一个常见的入门示例#!/usr/bin/expect set timeout 30 spawn ssh userhost ls /var/log expect { password: { send mypassword\r } yes/no { send yes\r exp_continue } } expect eof写 expect 脚本有几点心得第一expect后面跟的花括号块里每条匹配规则后面要加exp_continue表示匹配完继续等下一个输出第二密码硬编码在脚本里非常危险建议从环境变量读取比如send $env(SSH_PASS)\r第三expect 像其他 shell 工具一样调试时先用exp_internal 1打开内部日志你会看到它到底卡在哪一步。注意expect 在 macOS 上默认不自带需要brew install expect服务器上一般也要单独装。5.3 Set -euxo pipefail这套参数到底值不值得开很多脚本第一行就写set -euxo pipefail把这几个参数全开了。它们的意思分别是-e任何命令返回非 0 立即退出脚本-u使用未定义变量直接报错-x打印每条实际执行的命令方便调试-o pipefail让管道中任何一条命令失败都让整条管道返回失败。听着全是好事但我在生产环境见过因为set -e引发的事故某条命令返回了非 0但脚本以为它已经华丽结束直接往下跑却什么也没跑成排查起来极度恼火。我的建议是脚本里可以开-u和-o pipefail但-e要谨慎。如果确实想用-e那么一定配合“明确预期失败”的地方比如set -e下想处理 grep 查不到任何行的情况就要写成if grep -q ...; then ...; else ...; fi把失败包进 if 判断里这样-e不会误杀。-x只在开发调试时开正式脚本里留着就是噪音。5.4 后台执行与输出重定向的规范姿势后台执行最原始的方式是命令后面加例如bash deploy.sh deploy.log 21 。这里是把标准输出重定向到文件21是说把标准错误也指向标准输出所在的位置也就是同一个文件。如果你漏掉21脚本报错信息会直接丢掉或者产生一个空的输出文件却不知道发生了什么。后台任务有两个常用命令jobs查看当前终端挂起的后台任务列表fg把一个后台任务拉回前台。如果你希望关闭终端后任务继续跑就得上nohup command nohup 的意思是“忽略挂断信号”配合让进程脱离当前终端的会话控制。现在更推荐的方式是systemd-run --user或者 tmux但 nohup 在所有环境里都可用作为基础技能还是得会。“后台执行还要交互式输入密码”这个需求本质是把交互从“人机交互”转成“程序化交互”前面的 expect 就是干这个的。还有个别场景比如 sudo可以临时用sudo -S从标准输入读密码但安全性和可控性都不如 expect。6. 常见问题与排查技巧实录6.1 Bash: xxx: command not found 到底怎么回事这类报错的原因分三类排查思路也对应三种。第一类命令真的没装比如你要用nslookup基础系统里没有这个包得用系统包管理器装。第二类命令装了但可执行文件所在的目录不在你的$PATH里比如手动编译安装的软件通常放在/usr/local/bin如果这个目录没被加进 PATH 就会找不到。第三类命令在 Windows 和 Linux 环境下的名字不一样比如打开 git bash 敲ll也许可用换成ls -l则通用再比如 Windows 下你明明装了某个工具但它只在 cmd 里可用bash 里则找不到因为 git bash 不会自动继承所有 Windows 的 PATH 项目。排查办法很简单先type 命令名看看 bash 怎么解释它再echo $PATH确认路径最后ls /usr/bin/命令名看看目标是否存在。热词里提到过某个安装脚本执行完以后紧接着敲命令却提示 not found这种通常是安装脚本把可执行文件放到了~/.local/bin或者别的目录但你的 PATH 没加。解决办法就是去安装日志里找实际路径再手动补 PATH。6.2 命令找不到先确认参数放在哪个位置还有一种很迷惑的“未找到命令”比如你写了类似这样的执行语句bash --apiserver-advertise-address192.168.0.109bash 会回报 “未找到命令”。为什么因为 bash 自己并不认识这个长参数它把--apiserver-advertise-address192.168.0.109当成了 bash 的命令行参数而不是脚本的参数。这类问题的本质是哪条命令接收这些参数如果你要执行的是某个安装脚本参数应该放在脚本名后面比如bash install.sh --apiserver-advertise-address192.168.0.109这样参数才会传给 install.sh。先分清“命令自己用的参数”和“命令后面那个脚本用的参数”比一遍遍换引号管用。6.3 Git Bash 下 cp、ls 报 no such file 的经典坑Windows 上装 Git Bash 后很多人发现执行cp 11.txt /tmp/时报cannot stat 11.txt: no such file or directory但明明当前目录下就有这个文件。问题多半出在路径和当前目录上Git Bash 启动时的默认工作目录不一定是你的项目目录比如它可能停在C:/Users/你的用户名而你心里以为自己在桌面上那个项目里。先跑pwd看看实际当前目录再ls看看文件在不在基本就能破案。另一个坑是 Windows 路径风格。在 Git Bash 里C:\Users\test这种盘符路径经常要写成/c/Users/test或者/cygdrive/c/Users/test否则命令无法识别。写脚本时如果需要动态拼接路径别直接写死反斜杠尽量用变量和相对路径。顺便说一句Git Bash 处理跨平台换行符也有坑Windows 上文本文件是 CRLF 换行Linux 工具链期望 LF直接把 Windows 里编辑过的脚本拖到 Linux 跑经常报$\r: command not found这是换行符污染了命令用sed -i s/\r$// script.sh清一下就行。6.4 下载脚本执行时反复 Retrying 该怎么做热词里有curl -fsSL ... | bash反复 retrying 的状况。这种“直接管道给 bash 执行”的安装方式确实方便但问题不少一是网络下行不稳定管道传输一半断掉另一端 bash 可能已经执行了半个脚本二是这种安装方式对用户极不透明你根本不知道下载了什么代码就直接以你的权限跑了。我对这种做法的态度很明确能用官方包管理器装的就用包管理器非要用脚本也要先下载到本地看一眼内容再决定是否执行。命令可以拆成两步curl -fL -o install.sh 地址和bash install.sh。这样如果下载过程中反复 retrying你至少知道是网络的问题重试的是 curl而不是在自己不知情的情况下反复执行一个残缺的脚本。6.5 ADB Shell 不是普通 Bash别硬套热词里好几个人问 adb shell 下某些命令用不了比如uiautomator dump或者dumpsys battery set usb 0。这里要理解一个很关键的概念Android 设备内部的 shell 通常不是 GNU Bash而是 mksh 或者 toybox 提供的一套精简 shell 和命令集。它支持基础的管道、变量、循环但没有grep -P、没有 GNU find 那一堆选项很多在 PC Linux 上能用的参数在设备上不可用。遇到 adb shell 命令“用不了”先分清是哪个环节的问题是 adb 这个客户端没连上设备还是设备上的 shell 不认这个命令还是命令格式不对。比如uiautomator dump需要设备亮屏、解锁状态否则会卡住或报错dumpsys battery set usb 0依赖系统服务是否存在某些定制 ROM 或新版本 Android 直接把写能力禁掉了。排查时先跑一个最简单的adb shell echo ok确认链路通再逐层递进。跨环境调试的核心原则就一句话默认一切都不一样然后逐个验证。6.6 Bash 里 export 不生效的真相有人反馈“bash 里不能用 export”其实 export 是 bash 的内建命令不可能不存在。真正遇到的往往是在脚本里 export 了一个变量脚本结束后再到终端 echo发现变量没生效。这是进程模型的问题——你执行脚本时bash 会 fork 一个子进程来跑脚本里的 export 作用域只在这个子进程里子进程一退出变量就跟着销毁影响不到父进程。解决方法是source script.sh而不是bash script.sh让脚本在当前进程里执行。另一种常见情况是在函数里 export想让它影响当前 shell这的确可以但前提是函数在父进程里被调用而不是在子进程的脚本里被调用。理解“子进程继承环境、但不能反向修改父进程环境”这个规则之后所有关于 export 的困惑都会迎刃而解。最后再分享一个我自己调试 bash 脚本的小习惯凡是脚本逻辑稍微复杂一点我都会先开bash -x script.sh观察每一行命令展开后的真实形态。很多时候你以为脚本在跑 A实际 bash 展开之后跑的是 B肉眼看不出来-x一开立刻原形毕露。再配合 shellcheck一个静态检查工具几乎能把 90% 的引号、空格、变量引用问题拦在运行之前。学 bash 这件事真不用背指令手册把“展开、查找、退出码、进程模型”这四个概念刻在脑子里你就已经从“会敲命令”进阶到“理解 shell”了。
返回列表