1. 为什么要花一整篇去讲这四个命令
做Shell编程这几年,我见过太多脚本跑着跑着就崩的案例,十有八九都出在echo、read、printf、test这四个命令上。你说它们简单?确实,语法一眼就能看完。但恰恰是这种“看着简单”的错觉,让很多人在写脚本时栽了跟头——要么变量带空格被拆得七零八落,要么read读文件读到一半丢了数据,要么printf输出中文变成一堆乱码,要么test在数字比较上直接给了个让人摸不着头脑的报错。
这四个命令其实是Shell脚本的底层骨架:echo负责把信息抛出来,read负责把外部输入接进来,printf负责把内容按格式钉死,test负责把条件判断立起来。你写任何一个有实际用途的脚本,几乎都绕不开它们。尤其是自动化部署、日志采集、系统巡检这一类场景,这四个命令是否用得顺手,直接决定脚本的健壮性和可维护性。
这篇不是命令手册的搬运,我把这些年实际踩过的坑、排查过的诡异问题、以及最终沉淀下来的用法都整理出来,按四个命令逐个拆解,最后再聊一些组合实战和故障排查的实录。无论你是刚入门Shell的新手,还是写了两三年脚本但偶尔还会被引号折腾的老手,这篇都值得花十几分钟仔细看一遍。
2. echo:最基本的输出,也是最容易被坑的输出
2.1 先搞清楚你用的是内建echo还是外部echo
绝大多数情况下,你在终端敲echo,用的是Bash的内建命令,而不是/bin/echo这个外部程序。这两个东西行为有差异,最典型的就是转义参数-e的处理。Bash内建echo默认不解释反斜杠转义序列,想输出带换行的内容得显式加-e:
echo "第一行\n第二行" # 输出"第一行\n第二行",\n是字面量 echo -e "第一行\n第二行" # 分两行输出而/bin/echo在部分系统上默认就解释转义。这种不一致正是脚本从一台机器搬到另一台机器就行为异常的常见源头。建议在脚本里明确写清楚:要么统一用echo -e,要么干脆用printf替代,把格式控制权拿到自己手里。
顺带提一个我经常用的小写法。调试脚本的时候,我喜欢在关键节点打这样的标记:
echo "===== $(date +%F_%T) 开始执行 check_disk ====="等号加时间戳,日志里一搜就能定位到每个阶段的边界。别嫌土,排查线上问题的时候这种朴素的标记比花里胡哨的日志框架好使多了。
2.2 echo开启转义后的颜色和样式控制
早期脚本输出日志时,为了区分错误和普通信息,我习惯用ANSI转义序列给文字上色。在echo -e后面直接拼颜色码,比如:
echo -e "\033[31m错误:磁盘空间不足\033[0m" echo -e "\033[32m成功:备份已完成\033[0m"\033[31m是红色起始,\033[0m是恢复默认。这套东西在终端里显示没问题,但如果脚本输出被重定向到文件,颜色码会一起写进文件,日志系统里就多出一堆可见的乱码符号。后来我学到的做法是:判断一下输出目标是不是终端,是终端才加颜色,重定向就干干净净输出纯文本。
if [ -t 1 ]; then echo -e "\033[31m错误信息\033[0m" else echo "错误信息" fi-t 1判断文件描述符1是否是终端,这个技巧在日志脚本里特别实用。很多人问为什么生产环境的脚本日志里没有颜色,就是这个原因。
2.3 引号处理:echo最容易翻车的点
我见过最多的问题,是变量值里带空格或带通配符时,echo输出出来的结果和预期完全不一样。看这个例子:
file_list="a.txt b.txt" echo $file_list # 输出 a.txt b.txt,好像没问题? echo "$file_list" # 同样是 a.txt b.txt,但语义不同表面看两个都输出一样,但如果变量里有换行符或者多个连续空格,不加引号会直接被Shell拆词重排,输出就变形了。更危险的情况是变量值里有星号,不加引号时星号会被展开成当前目录下的所有文件名,echo瞬间变成目录列表工具,轻则输出杂乱,重则引发后续命令操作错误文件。
我的规矩很简单:echo "$var"永远带双引号。除非你明确知道我想让它分词,否则不带引号的echo我写代码时直接当bug处理。
2.4 关于echo输出换行的一个冷知识
有个老梗说“Windows换行是\r\n,Linux是\n”,但很多人没意识到,echo的输出默认就是在结尾补一个\n。如果你的脚本在Windows上用编辑器编辑,保存成CRLF换行格式再传到Linux上跑,每行结尾都会带着一个\r,echo输出时这个\r会让终端光标回到行首,看起来就像输出被覆盖了一样。这个坑在写脚本时年年都有人踩,建议所有脚本文件统一用LF换行,最好在编辑器里设置默认。
3. read:把输入稳稳接进来,没那么简单
3.1 read的常用参数一张表讲清楚
read的作用是读取一行输入,按空格或IFS拆分,依次赋给后面的变量。参数不少,但真正高频使用的是下面这些:
| 参数 | 作用 | 使用场景 |
|---|---|---|
| -p | 先输出提示语再读取 | 交互式输入 |
| -n | 读取指定字符数就返回 | 按键选择菜单 |
| -t | 设置超时时间,单位秒 | 防止脚本卡死 |
| -s | 静默模式,不显示输入内容 | 密码输入 |
| -r | 禁用反斜杠转义 | 读取含反斜杠的路径 |
| -a | 将输入按空格拆分赋值给数组 | 批量解析 |
最常用的是-p,一句话就把提示和读取干完了:
read -p "请输入目标端口号:" port要限制用户在5秒内完成输入,加个-t。比如巡检脚本里问管理员是否继续,等5秒没回应就自动跳过,不至于人离开终端脚本就挂在那:
if ! read -t 5 -p "5秒内未操作则跳过,按y继续:" choice; then echo "操作超时,自动跳过" fi这里必须注意,read超时返回非0状态,所以要用if包一层来判断,否则变量没赋到值脚本还会继续往下走。
3.2 逐行读取文件时,小心IFS和转义
read最常见的非交互用法是从文件读内容。想按行处理文件,大多数人第一反应是:
cat file.txt | while read line; do echo "$line" done这样能用,但不严谨。问题有两个。一是管道会让while在子Shell里执行,循环里给变量赋的值在循环结束后丢失;二是read默认会拆分行内空格,行里若有多个空格就只能拿到第一段。
严谨写法之一是去掉cat,直接重定向,让循环留在当前Shell进程里:
while IFS= read -r line; do echo "$line" done < file.txt我对每一处的解释:IFS=把行内分隔符临时置空,整行内容原样进变量;-r避免行尾反斜杠被吞;< file.txt把文件用重定向喂给循环,不用cat也没子Shell问题。这三件套我几乎在每一个读文件的脚本里都会写。
3.3 读密码和读选项的特殊处理
让用户输密码,千万记得-s,但-s不回显输入,提示语要写清楚。而且读完后要手动补一个换行,否则终端上光标会停在密码输入那行后面:
read -s -p "请输入sudo密码:" pwd echo这里的echo就是用来补换行的,配合read使用非常经典。
读单字符选项用-n 1,只取一个按键,不用等回车:
read -n 1 -p "是否继续?[y/n]" confirm echo case $confirm in y|Y) echo "继续执行" ;; *) echo "退出脚本" ;; esaccase里的|是或逻辑,yY大小写都接住。这个小交互模板我几乎每个脚本都在用。
3.4 多个变量一次性读取的隐藏逻辑
read的一大优势是一次读多段赋值给多个变量。默认按IFS拆分,空格或Tab都能拆:
read ip port user <<< "192.168.1.10 8080 admin"这里<<<是Here String,把字符串作为标准输入喂给read。这个用法很巧妙,用一行代码就完成了“把一段文本拆成多个字段并分别赋值”的操作,日志解析、配置解析场景中非常常见。
要注意的是,如果实际输入段的个数少于变量个数,多余的变量就为空;如果输入段个数多于变量个数,最后那个变量会吃掉剩余所有内容。这个特性有时候可以故意利用,比如:
read first rest <<< "hello world shell scripting" echo "$first" # hello echo "$rest" # world shell scripting3.5 读取关联数据时的超时与校验
生产环境中,read读取的输入必须先校验再使用,否则后面跟着命令可能被特殊字符“干掉”。一个不错的习惯是:读取端口号后做数字校验:
read -p "端口号: " port if [[ ! "$port" =~ ^[0-9]+$ ]]; then echo "端口号必须为数字" exit 1 fi正则匹配判断是否纯数字,这招在参数校验时通用性极强。凡是read读进来的参数,我都默认先做合法性检查再进业务逻辑。
4. printf:格式化输出的正统方案
4.1 为什么我推荐优先用printf而不是echo
printf严格来说不是输出工具,而是一个按格式模板生成文本的工具。它的第一个参数是格式串,后面的参数按格式串里的占位符依次填入。这个设计让printf的输出完全可控,不会有echo那种“反斜杠解释不解释”的困惑。在脚本里做任何需要严格格式的输出,我都优先选择printf。
对比一下同样输出一行带变量的内容:
name="web" printf "服务名: %s 状态: %d\n" "$name" "$status" echo "服务名: $name 状态: $status"printf里%s和%d定义了参数的类型,传错了会被纠正或报错;echo纯粹是字符串替换,状态值如果非数字,格式上根本看不出来。对日志系统而言,printf这种严格格式意味着后续用awk/sed解析时不会被意外打乱。
4.2 常用格式说明符与转义
printf最常用的占位符是%s(字符串)、%d(十进制整数)、%f(浮点数),以及%-Ns这种带宽度的左对齐写法。我做一个表格整理一下核心用法:
| 格式 | 含义 | 示例输出 |
|---|---|---|
| %s | 字符串 | printf "%s" "abc" |
| %d | 十进制整数 | printf "%d" 42 |
| %05d | 补零宽度5位 | printf "%05d" 42 输出00042 |
| %.2f | 保留2位小数 | printf "%.2f" 3.14159 输出3.14 |
| %-10s | 左对齐宽度10 | printf "%-10s" "name" |
| \n | 换行 | printf "a\nb" |
| \t | 制表符 | printf "a\tb" |
宽度补零和保留小数这两个在生成报表时特别有用。比如统计脚本要输出磁盘使用百分比,想对齐表格,用%-10s加%5d就能整齐地排成列。
4.3 格式化输出到变量,而不是屏幕
printf一个容易被人忽视的能力是把格式化结果保存到变量,用命令替换即可:
formatted=$(printf "%-10s | %9.2f" "$user" "$score") echo "$formatted"这个用法在执行环境里配置参数拼接时极其好用,比如拼带参数的SQL语句、拼IP加端口字符串,既能保证格式统一,又避免了一堆字符串拼接的可读性问题。
4.4 中文乱码问题,核心在locale不在printf
热搜词里“printf中文乱码”出现频率很高。很多人以为是printf不支持中文,实际不是。中文乱码大多是因为终端或环境变量locale没设对。printf严格按照字节流输出,中文按UTF-8编码本来就没问题,但如果你输出后终端用GBK解码,或者脚本开头没有export LANG,就可能出现乱码。
建议脚本开头统一写上:
export LANG=zh_CN.UTF-8 export LC_ALL=zh_CN.UTF-8再用printf输出中文,配合运行环境的终端UTF-8编码,基本不会乱码。另外要注意,printf的宽度计算是按字节数来的,中文字符占3个字节(UTF-8),所以%s的宽度填充在中英文混排时会显得不够整齐,这一般无解,行业常见做法是改用awk或python来做对齐。
4.5 printf拼接JSON时的转义处理
现在很多脚本需要生成JSON上报到监控系统,printf在这里表现不错,但要注意双引号要转义:
printf '{"host":"%s","port":%d}\n' "$host" "$port"用单引号包住格式串,里面的双引号就不用转义了。而%s对应的变量值如果本身包含双引号或者反斜杠,得先在变量层做替换处理,否则拼出来的JSON不合法。这个细节我在写监控上报脚本时反复踩过。
5. test:条件判断的基础设施
5.1 test的三种写法,别混着用
test命令在Shell里有三种等价形态:test expr、[ expr ]、[[ expr ]]。前两者是外部命令或内建命令的调用,[[ expr ]]是Bash的关键字。我的建议是:在Bash脚本里尽量用[[ ]],因为它的运算符更丰富、支持正则匹配、且不会像[ ]那样在某些极端参数上翻车。
看一个典型对比:
# 传统test写法 test -f /etc/hosts && echo "文件存在" [ -f /etc/hosts ] && echo "文件存在" # 推荐写法 [[ -f /etc/hosts ]] && echo "文件存在"三者功能类似,但对空变量、特殊字符变量的处理上有差别。[ ]里变量没加引号,变量为空时会被拆成语法错误;[[ ]]内部对变量做了额外处理,即使不加引号问题也不大。虽然我依然建议加引号,但至少[[ ]]给了多一道保险。
5.2 文件测试和字符串测试速查
test主要分三类:文件测试、字符串测试、整数测试。先看文件测试,这也是我写得最多的:
| 表达式 | 含义 |
|---|---|
| -f file | 是否为普通文件 |
| -d file | 是否为目录 |
| -e file | 是否存在 |
| -r file | 是否可读 |
| -w file | 是否可写 |
| -x file | 是否可执行 |
| -s file | 是否非空文件 |
字符串测试中最常用的就是判断变量是否为空:
[[ -z "$var" ]] && echo "var为空" [[ -n "$var" ]] && echo "var不为空"-z表示字符串长度为0,-n表示长度不为0。注意-z和-n后面最好带引号,虽然[[ ]]对引号不那么敏感,但保持这个习惯能避免不少边缘问题。
5.3 数字比较和字符串比较的陷阱
这是test最大的坑。数字比较用-eq、-ne、-gt、-lt、-ge、-le,字符串比较用=、==、!=、<、>。如果把字符串比较符号用在数字上,比如:
[ 10 > 2 ] && echo "成立"这里>被当成输出重定向,10 > 2等于把2写入文件10,执行结果完全错误。正确写法是:
[ 10 -gt 2 ] && echo "成立"而如果用==写数字比较,比较的是字符串内容而不是数值。最经典的例子:
[ "10" == "9" ] && echo "字符串比较,不成立" [ 10 -eq 9 ] && echo "整数比较,不成立" [ 9 -eq 09 ] # 这个会报错,因为09被当成八进制时非法最后一行是我踩过最深的一个坑。数字带前导零时,在部分Shell里会按八进制解析。处理端口、月日这类可能带零的数值时,一定要先去零或统一转成十进制再比较:
value=09 value=$(echo "$value" | sed 's/^0*//') [ -n "$value" ] && [ "$value" -gt 5 ] && echo "大于5"5.4 组合条件:&&、||在test里的优先级
test条件可以自由组合:
[[ -f /etc/passwd && -r /etc/passwd ]] && echo "可读的passwd文件" [[ -n "$a" || -n "$b" ]] && echo "a或b至少一个非空"&&和||在[[ ]]里的优先级比[ ]要更自然。在[ ]里写多个条件会被整体当成一次test调用,容易出错;而[[ ]]允许直接用&&、||组合,代码好读得多。如果遇到“变量为空导致test语法错误”这种疑难杂症,第一时间检查是不是用了旧式[ ]且漏了引号。
5.5 逻辑短路与结合if的使用节奏
test经常配合if使用,但我更常用的是短路语法:
[[ -d "$backup_dir" ]] || mkdir -p "$backup_dir"这行代码等于“目录不存在就创建”,比if-else写起来简洁太多。同样的逻辑还可以做参数默认值:
[[ -n "$env" ]] || env="prod"注意,这两个例子都在用||的“不满足就执行”特性。命令行的可读性虽然不如if明显,但脚本看多了就会喜欢上这种紧凑写法。不过要节制,超过三层组合时还是老老实实写if,否则维护成本太高。
6. 四大命令组合实战,以及我排查过的几个典型故障
6.1 一个典型的交互式部署脚本框架
把四个命令串起来,最常见的场景是交互式部署脚本。我来搭一个简化版本的框架,展示它们在真实脚本里怎么配合:
#!/bin/bash export LANG=zh_CN.UTF-8 # 读目标环境参数 read -p "目标环境 (dev/test/prod): " env read -p "服务端口: " port # test校验 [[ "$env" =~ ^(dev|test|prod)$ ]] || { echo "环境不合法"; exit 1; } [[ "$port" =~ ^[0-9]+$ ]] || { echo "端口必须为数字"; exit 1; } # printf输出格式化的部署信息 printf "部署环境: %-6s\n服务端口: %d\n" "$env" "$port" # 确认部署 read -n 1 -p "确认部署? [y/n] " confirm echo [[ "$confirm" =~ ^[Yy]$ ]] || { echo "取消部署"; exit 0; } # 模拟执行 echo "===== $(date +%T) 开始部署到 $env ====="这个不到二十行的脚本,四个命令各司其职:read负责获取输入,[[ ]](test)负责校验,printf负责格式输出,echo负责时间戳标记。这种结构我已经用了很長時間,遇到生产事故时定位效率比一堆if嵌套高得多。
6.2 排查故障:读取文件时行内容消失
有一回写日志解析脚本,用while read line读文件,发现中间有些行读出来是空的,但源文件明明有内容。排查下来原因有两个:一是文件是Windows传上来的,行尾是\r\n,\r被当成内容读到变量里,打印时光标跳回行首,看似没有内容;二是在循环里又用了echo -e解析变量,把底层的特殊符号吃掉了。
解决办法也很经典:先dos2unix统一换行符,再用IFS= read -r line三件套读取。上面3.2节提到的那套写法,就是为了这种场景准备的。
6.3 排查故障:printf中文变问号
另一个真实案例是脚本输出的中文在终端全变成问号。排查过程是:先echo $LANG看环境变量,结果显示空;再查系统locale是否装了中文包,发现压根没生成。最终在脚本里加了export LANG=zh_CN.UTF-8,并把终端编码调成UTF-8,问题解决。
如果你的环境里连中文本地化语言包都没有,怎么export都不管用,那就只能退而求其次,用英文提示,或者用uni2ascii转换。但绝大多数云主机和桌面发行版,装完系统locale就是齐的,export一句就能搞定。
6.4 排查故障:test数字比较报错
“integer expression expected”大概是test相关报错里出现频率最高的。原因无一例外,变量不是纯数字。比如[ "$count" -gt 10 ],但count是从日志里grep出来的,带了个冒号或者前后有空白,就直接报错。
排查时先在命令行把变量值打印出来看看,用printf '"%s"\n' "$count",就能看到变量值前后有没有隐含字符。我习惯在比较前统一做一次“清洗”:去掉非数字字符、去空白、去前导零,然后再比较。脚本里宁可多两行清洗代码,也别让一个脏数据把整条流程干掉。
6.5 关于read超时和管道子Shell的年轻队友常踩的坑
团队里的小朋友写了一个批量处理脚本,处理完成后需要汇总计数,结果循环里count++的值最后输出总是0。原因就是他把while read循环放在管道后面,子Shell里改的变量带不回主进程。改造方式就是3.2节说的,用重定向替代管道:
# 错误示范 cat list.txt | while read line; do ((count++)) done echo "$count" # 仍然是0 # 正确示范 count=0 while IFS= read -r line; do ((count++)) done < list.txt echo "$count" # 正确次数这个问题我几乎每隔一阵就会在论坛看人问一次,写成脚本时请直接养成重定向的习惯。
7. 留在最后的一句体己话
这四个命令单个看都没什么好说的,但组合在一起就是整个Shell世界的语法骨架。我这些年写脚本最大的感悟是:Shell不怕你用得多,就怕用得糙。echo多想想引号,read多想想IFS,printf多想想格式串,test多想想到底是比字符串还是比数字,四个点都想清楚了,脚本的健壮性直接上一个台阶。
最开始学Shell的时候,我也曾觉得这些命令太简单没必要深究,直到被线上脚本的诡异表现教育了几次,才老老实实把每个细节抠明白。希望这篇文章能让你少走我走过的弯路。下次写脚本的时候,不妨刻意把printf和test用得更规范一些,时间会给你回报。