
很多人学 Shell 脚本入门时最先接触的就是echo能把ls、cd、grep用得滚瓜烂熟但真到自己动手写脚本反而卡在几个最基础的环节怎么让脚本暂停等用户输入、怎么输出一张对得齐的表格、怎么写判断条件。问题往往不在复杂语法上而在于对echo、read、printf、test这四条“语法级”命令的理解太浅。这四个命令看似简单实则是构成一切 Shell 脚本交互、判断、输出能力的基石。这篇文章就把它们逐个拆开讲透——原理是什么、坑在哪里、实际项目里怎么组合着用希望能帮刚入门的同学少走几个弯路也能让写过一段时间脚本的人重新审视一下自己的用法是否稳。1. 四个命令的分工逻辑为什么Shell脚本入门必须先吃透它们1.1 脚本的本质输入、输出、判断任何一个脚本无论业务多复杂拆到最底层都逃不开三类动作接收外界数据输入、把结果呈现给用户输出、根据条件决定走哪条分支判断。Shell 编程里对应这三类动作的最基础命令正好就是read、echo/printf、test。拿一个真实场景来说假设你要写一个部署脚本脚本启动时需要问用户“目标目录不存在是否创建”这叫输入答完之后脚本根据回答决定创建还是不创建这叫判断最后把每一步的执行结果打印成带缩进、带颜色的日志这叫输出。整个流程没有碰任何高级命令但已经是一个完整可用的工具脚本了。所以说这四个命令不是“四个单独的零碎知识点”而是一套互相协作的最小闭环。1.2 它们和普通命令的区别语法级 vs 工具级很多 Shell 教程会把ls、cd、curl、grep和这四个命令混在一起讲这是我觉得比较误导的地方。ls是“工具级”命令作用是完成一个具体业务动作而echo、read、printf、test是“语法级”命令它们更接近编程语言里的输出语句、输入语句、逻辑判断表达式。差异体现在哪里举个例子printf的行为几乎在所有符合 POSIX 标准的 Shell 里都一致你可以在 bash、dash、sh、zsh 里跑同一个printf脚本而不用担心行为漂移但ls的彩色输出、排序方式在不同系统上差异明显。这两个层级最大的区别是工具级命令可以随时替换成别的工具而语法级命令是你自己写的脚本逻辑的一部分换了一个环境脚本能不能跑取决于你对它们的理解到不到位。我自己带过不少新人观察到一个规律凡是在[ ]里不加引号、用比较数字、在管道里用read后变量丢失这类问题上反复踩坑的基本都是没想清楚这四件事的分工。所以这篇博文不按“命令大全”的路子罗列参数而是按“输入-输出-判断-组装”的思路来逐个突破。2. echo看似简单输出细节里全是门道2.1 三种引号状态的差异不加引号、单引号、双引号echo最容易翻车的地方不是命令本身而是它后面的字符串怎么写。初学者经常看见三种写法都输出同一个结果就以为随便写都行直到遇到变量和特殊符号才懵。nameshell echo hello world # 裸奔式 echo hello world # 单引号 echo hello world # 双引号这三种写法在输出“hello world”时没有区别但一旦字符串里出现变量、转义符、通配符行为就完全不同了不加引号Shell 会先做变量展开、命令替换、路径通配符展开然后按空格把所有结果拆成多个词传给echo最后用空格拼接输出。这个“拆词”过程是很多人没意识到的隐藏步骤。单引号里面所有字符都按字面意思输出$、反引号、*全部失效。双引号变量和命令替换照常执行但路径通配符不展开分词也不发生。举个最容易踩坑的例子namehello world echo $name # 输出 hello world但实际拆成两个词只是输出时用空格连起来 echo $name # 输出 hello world作为一个整体echo $name和echo $name在屏幕上看起来一样但前者会把name的值按空白拆开再重组如果值里有连续空格连续空格会被折叠成一个后者则会原封不动保留。所以凡是涉及变量输出我强烈建议养成加双引号的习惯。这个习惯在test判断里同样关键后面会再讲。2.2 转义与换行echo 不是默认不换行而是默认帮你换行echo每条输出末尾默认带一个换行符这是它与printf最明显的差异点之一。很多人以为echo hello输出的是hello其实完整输出是hello\n。在绝大多数场景这就是你想要的效果但当你需要连续输出不带换行的提示时就得用-n参数echo -n 请输入用户名 read username这样提示和输入会出现在同一行交互体验更自然。如果想要在字符串中间插入换行或制表符bash 内建的echo默认不解释转义序列需要加-eecho 第一行\n第二行 # 原样输出 \n 两个字面字符 echo -e 第一行\n第二行 # 真正换行 echo -e 列1\t列2\t列3 # 制表符分隔需要特别留意的是-e不是 POSIX 标准里echo的通用选项。在 Ubuntu 等系统默认的dash里sh script.sh执行时echo -e abc会把-e当成普通文本原样输出结果变成-e abcLinux 发行版里/bin/sh指向 dash 是很常见的事情这也是为什么很多教程会强调如果你要精确控制转义和格式请用printf而不是赌echo -e是否可用。2.3 echo 最容易被忽视的边界行为第一类边界是输出以-开头的字符串。比如你想输出-n这个字面文本echo -n # 某些 Shell 里输出为空因为 -n 被当成参数 echo -- -n # 部分实现支持 -- 结束选项解析 printf %s\n -n # 更稳妥用printf处理这类场景几乎不会出问题这也是它存在的意义之一。第二类边界是变量值以特殊选项开头或者含有控制字符。比如echo $var当$var的值刚好是-e时echo 会当成开启转义选项处理行为就变了。加不加引号都避免不了这个风险因为-e是在参数解析阶段被识别的。真正一劳永逸的做法就是输出普通文本用 echo输出任何不可控变量或需要精确格式的内容一律用 printf。这是我在脚本里一贯的取舍标准。第三类边界是echo与命令替换组合时的换行丢失。比如result$(ls -1) echo $result # 换行保留 echo $result # 换行全部变成空格不加引号的echo $result会把命令结果里的换行当成空白分隔符重新“拍平”这往往是脚本日志格式搅成一团的原因。命令行里肉眼看着没问题写到脚本里就开始出现怪现象多半就是引号问题。3. read脚本获取输入的真正入口3.1 交互式读取-p -t -s 三个参数解决大部分场景很多脚本不是给人填参数的而是运行时需要按步骤确认。read就是把标准输入读进变量的命令基础用法是read 变量名回车结束。如果变量名不止一个Shell 会按空白把输入切分成多段剩余部分全给最后一个变量。最常用的三个参数是配合交互场景的read -p 请输入目标IP: ip # 直接在同一行打印提示 read -t 10 -p 10秒内确认是否继续(y/n): confirm # 超时自动跳过 read -s -p 输入密码: passwd # 静默模式输入不回显-p负责把提示文本和输入框放在同一行脚本里就省掉一句echo -n-t给了超时保护避免脚本卡死在等人输入-s常用于密码读取回车后需要自己补一个换行打印否则后续输出会直接接在密码后面。这三个参数在实际脚本里几乎是标配。比如一个自动化巡检脚本会先read -t 5给操作员一个中断窗口超时就按默认值继续跑既保留人工干预能力又不会阻塞无人值守的任务。3.2 按行读取文件while read 的正确姿势read不只是读键盘更常见的高阶用法是逐行读取文件内容。经典写法是while IFS read -r line; do echo $line done config.txt这里有三个细节值得解释因为很多人直接抄但不知道为什么要这么写IFS把内部字段分隔符临时置空防止read把行首行尾的空白字符吃掉。默认的IFS包含空格和制表符会让读进来的行“变形”。-r禁止把行尾的反斜杠当作续行符处理。如果不加文件里一行末尾是\时read 会认为行没结束把下一行拼接进来导致解析错乱。done file把文件通过输入重定向喂给整个循环而不是在循环体里一个个打开文件。为什么不直接用for line in $(cat file)因为 $(cat file) 会先做命令替换文件里的换行全部变成空格整个文件变成一长串单词随后还要再经历一次分词和通配符展开几乎每一行都会变形。用一个包含空行、带星号、带空格的配置文件试一次就能直观体会到差距。3.3 管道里的 read 陷阱变量为什么会“凭空消失”按行读文件时有一种很自然的写法是cat config.txt | while read line; do count$((count1)) done echo 总行数: $count运行后你会发现$count是空的。原因是管道右侧的命令是放在子Shell里执行的while read循环体里对变量的所有修改都发生在子Shell环境内循环结束、子Shell退出改动就丢了。外面的父Shell看到的还是初始值。这不是read本身的问题而是管道机制的特性。解决思路有两种一是改用输入重定向的方式也就是while ... done config.txt这样循环在当前Shell里执行二是如果非要用管道就把需要带出来的结果写到文件里再读或者用组结构{ ...; } | command配合进程替换 (command)让整个结构在同一进程里跑。我个人的习惯是能不用cat | while就不用改成重定向或进程替换避免后续排查时多花一小时才想起子Shell这回事。3.4 多变量读取一次请求拆成多个输入有时候交互需要用户一次输入多个值比如“端口号 协议 超时时间”可以写read -r port proto timeout用户输入8080 https 30三个值会按顺序分别进入三个变量最后一个变量会接收剩余的所有内容。如果只想读两个字段而用户多给了一个值多出来的部分会整体粘在第二个变量上不会报错。这个特性在解析形如名称 值的配置行时很有用while read -r key value; do echo key$key, value$value done settings.txt配合IFS:还能自定义分隔符比如解析/etc/passwd风格的数据IFS: read -r user pass uid gid desc home shell (echo root:x:0:0:root:/root:/bin/bash) echo 用户: $user, UID: $uid, 家目录: $home4. printf格式化输出的正规军echo 的跨平台替代者4.1 为什么必须学 printfecho 的跨shell差异和不可控格式我前文反复提到echo -e在 sh 和 bash 下行为不一致而printf最大的价值就是“稳定”。它源自 C 语言的printf在 POSIX 规范里行为严格定义不管用 bash、dash、zsh 还是 ksh 跑同一个脚本的输出基本一致。printf hello\n # 输出 hello 并换行 printf %s\n hello # 按字符串格式输出printf的第一个参数是格式字符串后面的参数根据格式符逐一对位输出。注意它不会像echo那样对参数里的变量做额外的二次整理所以printf %s\n $var是处理不可控变量的最安全方式——不管变量里有多少个空格、以什么特殊字符串开头都会被当作一个普通参数塞进%s里。4.2 格式符实操字符串、整数、浮点数的对齐与精度printf的格式符最常用的就四个%s处理字符串、%d处理整数、%f处理浮点数、%x处理十六进制。配合宽度和精度修饰符可以做表格对齐、补零、小数截断等效果。printf %-20s %10s\n 项目名 状态 printf %-20s %10s\n nginx OK printf %-20s %10s\n mysql FAIL%-20s表示左对齐占 20 个字符宽度%20s表示右对齐占 20 个宽度。做日志输出时这比手工补空格靠谱得多因为你不用自己去数字符串长度。数字格式化printf 本次失败次数: %d\n 3 printf 占用率: %.1f%%\n 67.89 # 保留一位小数%% 输出百分号 printf 编号: %05d\n 42 # 相当于是 00042 printf 十六进制: %#x\n 255 # 输出 0xff这里面有无数新人踩过%%的坑格式字符串里%是特殊字符要输出字面百分号必须写两个%%。否则printf 占用率: %\n会直接报错或者输出怪异内容。4.3 printf中文乱码的真相与处理热搜里经常有人搜“printf中文乱码”我自己也遇到过。先分清楚printf本质是字节流输出如果终端和系统 locale 都是 UTF-8它输出中文和在文件里写入中文没有任何区别不会有乱码问题。真正常见的两类“中文乱码”其实是第一类是终端编码问题。脚本里输出中文乱码先查环境变量echo $LANG如果输出的是POSIX或C说明当前 locale 是英文模式很多终端下中文会显示成问号。简单处理是export LANGzh_CN.UTF-8放在脚本开头或者写入用户的环境配置文件里。但要注意很多服务器没有安装中文本地化数据强制设置zh_CN.UTF-8可能报错。兼容做法是export LANGC.UTF-8大部分现代 Linux 都支持中文能正常显示英文提示也不受影响。第二类是对齐宽度错乱。真正和printf直接相关的“乱”不是乱码而是对不齐。printf的宽度计算按“字符个数”来算一个中文字符算一个宽度单位但在终端里实际显示占两个英文字符的宽度。所以下面的输出右边会不齐printf %-15s\n 名称 printf %-15s\n 数据库服务解决思路有两个一是中英文混合对齐时不要依赖printf的宽度符改用column -t之类的工具对表格做重排二是在知道固定列宽的前提下把中文字符串按显示宽度换算成字节长度手动补齐但这样代码会很丑。项目里如果明确要求中文表格对齐我的建议是直接用制表符或者column工具不要跟printf的宽度较劲。4.4 printf 与变量展开的隐藏交互网上有个经典段子printf $var写法在某些情况下会挂。原因是如果$var的值里恰好有%这个%会被当作格式符引入比如变量内容是100%损坏printf $var\n会把%损坏当成未知格式符轻则报错重则输出错乱。正确做法永远是printf %s\n $var让变量以参数形式进入而不是拼进格式字符串。同理格式字符串里要输出变量值但想控制宽度时可以写成printf %-20s 内容内容是参数宽度在格式里写死。这种习惯能帮你避开一大批隐性问题。5. test决定脚本走向的隐形判断器5.1 三种写法的历史沿革与选择test这个命令有很多“长相”最古老的直接写test 条件随后演变成更常用的[ 条件 ]再后来 bash 等高级 Shell 里有了[[ 条件 ]]扩展写法。三种写法本质都在做同样的事根据条件真假返回退出码 0 或 1。对新人来说最令人困惑的是[ ]里的空格。[实际上是一个命令不是语法符号所以它和里面的表达式之间必须有空格]和前面表达式之间也必须有空格。写成[$var]或者[ $var]都会报错因为命令名变了。我的实践选择是[[ ]]是 bash/zsh 的扩展支持~正则匹配、、||直接写在里面且不会做词拆分逻辑也更直观推荐在 bash 脚本里使用。[ ]是 POSIX 标准写法可移植性最强但表达能力弱、容易踩引号坑。如果脚本要同时兼容多种 Shell比如/bin/sh环境就老老实实用[ ]加引号。5.2 文件判断检查存在性、类型、权限写脚本时最常判断的无非是文件或目录的状态。下面这组参数要刻进脑子[ -e $path ] # 路径存在文件、目录、软链接均算 [ -f $path ] # 是普通文件 [ -d $path ] # 是目录 [ -s $path ] # 文件存在且大小非0 [ -r $path ] # 当前用户可读 [ -w $path ] # 当前用户可写 [ -x $path ] # 当前用户可执行 [ $f1 -nt $f2 ] # f1比f2新典型用法是部署脚本里的路径预检if [ -d /opt/myapp ]; then echo 目录已存在 else echo 目录缺失准备创建 fi if [ ! -w /opt/myapp ]; then echo 目录不可写请检查权限 exit 1 fi这里-w判断的是“当前用户视角”的可写性依赖运行时用户身份。如果脚本通过sudo执行判断结果和普通用户执行可能完全不同所以要留意脚本本身的运行身份。5.3 数值比较与字符串比较千万不能混用这是test命令里事故率最高的区域。我见过太多人写这样的判断count10 if [ $count 5 ]; then echo 数量大于5 fi这段代码本质上并不是在比较“10是否大于5”而是把当成了输出重定向操作符实际执行的是给[ $count这个命令创建了一个名为5的文件并写入内容然后判断命令是否成功。初始执行可能碰巧退出码为 0于是输出“数量大于5”但副作用是当前目录多了一个叫5的文件。这是非常经典的坑。数值比较必须用专用的单字母运算符[ $count -eq 5 ] # 等于 [ $count -ne 5 ] # 不等于 [ $count -gt 5 ] # 大于 [ $count -ge 5 ] # 大于等于 [ $count -lt 5 ] # 小于 [ $count -le 5 ] # 小于等于字符串比较则是另一套[ $name shell ] # 相等POSIX标准 [[ $name shell ]] # 相等bash扩展 [ $name ! shell ] # 不等 [ -z $name ] # 长度为0 [ -n $name ] # 长度非0特别注意和在字符串比较里也可以用但比较的是字典顺序而不是数值大小。要是把[[ $count 5 ]]放在 bash 里当count8时输出“大于”当count10时反而输出“不大于”因为字符串10在字典序上小于5。这种随机性极强的问题排查起来非常隐蔽。5.4 组合条件-a/-o 与 /|| 的区别和引号问题[ ]里组合多个条件用-aand和-oorif [ $user root -a $home /root ]; then echo root用户 fi而[[ ]]和普通 shell 语句级可以直接用、||if [[ $user root $home /root ]]; then echo root用户 fi这两种混用很常见但要注意范围和优先级。推荐在[ ]里统一用-a/-o在[[ ]]里统一用/||不要混着写否则到优先级问题时很难阅读。引号问题在test里尤其致命。看这段name if [ -n $name ]; then echo 非空 fi当$name为空时经过 Shell 展开后[ -n $name ]实际变成[ -n ]。此时-n被当成一个长度为 2 的字符串传入单参数测试条件为真。这个反直觉的结果让很多人怀疑人生。正确写法是if [ -n $name ]; then变量加双引号空变量展开后依然是一个空参数条件才符合预期。凡是test里用到变量一律加双引号这条规则能过滤掉 90% 的误判问题。5.5 [[ ]] 的高级特性正则匹配是杀手锏bash 的[[ ]]里可以用~做正则匹配这个能力在判断输入格式、校验 IP、校验版本号时非常实用version1.9.3 if [[ $version ~ ^[0-9]\.[0-9]\.[0-9]$ ]]; then echo 版本号格式合法 fi answerY if [[ $answer ~ ^[Yy](es)?$ ]]; then echo 用户确认 fi这比[ $answer y -o $answer Y ]这种写法清爽太多。正则功能是 bash 3.0 之后引入的日常几乎不用担心兼容性但对类似/bin/sh的环境不可用所以脚本第一行写了#!/usr/bin/env bash才建议在主逻辑里依赖[[ ]]。6. 四个命令协同一个完整的上线前自检脚本6.1 脚本需求与设计思路光讲单个命令枯燥组合起来才能体现价值。假设我们现在要写一个上线前自检脚本preflight.sh需求如下接收目标目录作为参数未传则使用默认路径检查目录是否存在不存在则询问用户是否创建检查目录是否可写不可写则报错退出展示一个格式化的检查项表格最终确认后输出部署开始提示。这个脚本刚好覆盖echo、read、printf、test四个命令的全部核心用法。设计上没有用任何高级功能但足够作为模板套用到真实项目里。6.2 完整实现与逐段讲解#!/usr/bin/env bash # preflight.sh - 上线前基础环境自检 target_dir${1:-/opt/myapp} check_passed0 printf %-25s %s\n 检查项 结果 printf %-25s %s\n ------------------------- ------ # 检查1目标目录是否存在 if [ -d $target_dir ]; then printf %-25s %s\n 目标目录存在 通过 else printf %-25s %s\n 目标目录存在 缺失 read -r -p 目录 $target_dir 不存在是否创建[y/N] answer if [[ $answer ~ ^[Yy](es)?$ ]]; then mkdir -p $target_dir || { printf 创建目录失败退出\n 2 exit 1 } printf %-25s %s\n 目录创建 通过 else printf 用户取消部署退出\n 2 exit 1 fi fi # 检查2目标目录是否可写 if [ -w $target_dir ]; then printf %-25s %s\n 目标目录可写 通过 else printf %-25s %s\n 目标目录可写 不可写 printf 请检查目录权限或使用具备写权限的身份执行\n 2 exit 1 fi # 检查3磁盘可用空间简单模拟判断大于100M视为通过 avail$(df -Pm $target_dir | awk NR2 {print $4}) if [ $avail -gt 100 ]; then printf %-25s %s\n 磁盘可用空间 通过 else printf %-25s %s\n 磁盘可用空间 不足 exit 1 fi # 最终确认 printf 预检完成结果列表见上方 \n read -r -p 确认继续部署[y/N] confirm if [[ ! $confirm ~ ^[Yy](es)?$ ]]; then echo 已取消部署 exit 0 fi echo 开始部署... # 这里放真实业务动作 sleep 1 printf 部署完成\n逐步拆解一下这段脚本里每个决策的来由${1:-/opt/myapp}是参数展开$1未传时用默认值脚本就不会因为少一个参数而报错。表格部分全部用printf %-25s %s\n左对齐宽度固定肉眼就能看出哪行没对齐。用户确认部分用read -r -p-r防止用户输入反斜杠时出现意外续行。判断用户是否一路确定时把[[ ~ ^[Yy](es)?$ ]]这种写法和大小写都兼容。磁盘空间判断里把awk提取的数字存成字符串然后用-gt比较数字值。有人会问“字符串怎么直接比较数字”——在 test 的-gt语义里两边的字符串会被当作整数解析并比较所以150和100的比较结果是正常的数字比较而不是字典序比较。第二个read的确认判断用了[[ ! $confirm ~ ... ]]非匹配时退出配合exit 0让取消不算错误符合业务直觉。6.3 复盘高频坑位这条排查链路值得记下来我把实际维护脚本过程中最高频的坑位按“现象-原因-解法”的格式列一下每条都是真实踩过的现象根因正确做法[ $count 5 ]生成了名为5的文件被当成重定向数字比较用-gt字符串比较用时放在[[ ]]里[ -n $name ]对空变量判真变量展开后-n变成单参数统一$name加双引号管道里 read 后变量丢失管道子Shell隔离变量用done file或进程替换echo -e a\nb在 sh 下输出字面\ndash 等 shell 不识别-e精确格式用printfprintf $var报未定义格式符变量里的%进入格式串用printf %s $vardf -Pm输出里的数字带空格字段提取不准awk NR2 {print $4}后按空格分列[[ 10 5 ]]返回假字典序比较而非数值比较数值比较必须用-gt如果脚本行为诡异我的排查顺序一般是先看变量是否为空、再看引号是否齐全、然后确认管道子Shell是否有数据丢失、最后用bash -x script.sh打开执行追踪一步步看每条命令的展开结果。配合set -u变量未定义即报错和set -o pipefail管道中任一命令失败即算失败能把一大部分静默问题直接变成显式报错。6.4 再扩展一步应用到批量配置读取场景这个自检脚本稍微改一下就能变成处理配置文件的通用模板。比如目标目录列表存在targets.txt里每行一个路径逐个执行预检while IFS read -r line; do case $line in |\#*) continue ;; *) do_check $line ;; esac done targets.txtcontinue跳过空行和以#注释的行这个模式在实际运维脚本里随处可见。用read -r保证转义安全用 IFS 置空保证字段不被贪吃再用case做首字符判断——三个特性合在一起就能稳妥地解析大多数简单配置文件。从我个人经验来说这四个命令里最容易被低估的是read它的“按行读文件 子Shell坑 多变量拆解”三个能力几乎能覆盖所有轻量配置解析需求而printf是最值得从入门阶段就养成的习惯一旦适应了它的格式思维再看echo的多平台差异会觉得处处是暗礁。写脚本没有太多高深莫测的技巧把输入、输出、判断这三件事做扎实就已经超过一大半的“半桶水”代码了。