1. 为什么先啃这四条命令
我带过的很多新人学Shell,都有同一个毛病:一上来就抱着一本厚厚的命令行手册啃,今天背find明天背awk,一个礼拜下来还在原地打转,真正写脚本时连个判断分支都写不利索。学Shell这事,真没必要这么痛苦。找对切口,一天就能打通最关键的地基。
我给的切口就是四条命令:echo、read、printf、test。它们分别对应Shell脚本里最核心的三件事——往外输出、往里输入、做判断。你只要仔细琢磨任意一段脚本就会发现,逻辑再复杂,落到代码上也逃不出这几个动作:要么在打印信息,要么在等待输入,要么在执行条件分支。把这条主线吃透,等于拿到了Shell脚本的“任督二脉”。
这篇内容不是把man手册翻出来照着念,我按自己多年写脚本和排查踩坑的实际经历来写,重点放在那些文档里不写、但实战中一定会遇到的细节上。刚接触Shell的读者可以顺着往下捋,写过不少脚本但总被诡异报错卡住的人,建议直接跳到各节的“坑”部分看。
1.1 输出、输入、判断:脚本的三块基石
先想一个最简单的场景:你想让脚本问用户“要不要删除旧日志”,用户输入y之后才执行删除。这个场景里,提示信息得用输出命令打出来,用户的回答得用读取命令收进来,后续走删除还是跳过,得靠判断命令决定。一个不能再简单的交互,恰好把四条命令全用上了。
这也是我把它们放在一起讲的原因。单独背语法很快会忘,但当你意识到这三个动作是构建所有脚本的公用积木时,学习就有了着力点。echo和printf承担输出,read承担输入,test承担判断,四者组合能覆盖日常八成以上的脚本需求。剩下两成,不过是在这条主线外面接上循环、函数、文件操作而已。
1.2 为什么不是别的命令
可能有人会问:ls、grep、cd这些命令使用频率不是更高吗?确实更高,但它们属于“工具型命令”,大多用来完成某个具体任务,而echo、read、printf、test是“结构型命令”,它们决定脚本怎么组织、数据怎么流动、逻辑怎么分支。
工具型命令可以边用边查,结构型命令却最好一开始就过关。这就像学炒菜,油盐酱醋的牌子可以随便换,但先放盐还是先放糖、什么时候下锅,这个框架得先立起来。所以我建议新手不要急着背几十个命令的参数,先把这四条弄熟,后面的路会顺很多。
2. echo命令:Shell里最常用的“发声器官”
echo是绝大多数人写Shell接触到的第一个命令,它的作用很简单:把后面跟着的字符串打印到标准输出。简单归简单,真用起来其实有不少讲究,尤其是在引号、转义和跨shell兼容性上。
2.1 常用选项与转义:-n、-e、-E
最基本的用法是echo hello world,屏幕上就会输出一行hello world。它默认会在结尾补一个换行符,所以终端里的每一句输出都独占一行。如果不想换行,可以用-n,比如echo -n "正在下载",提示文字打出来之后光标停在行尾。
-e选项用来启用转义序列解释。比如echo -e "第一行\n第二行"会输出两行,\t是制表符,\r是回车符,\\是反斜杠本身。与之对应的是-E,它强制关闭转义解释,是多数Shell的默认行为。
这里有个容易翻车的点:echo -e在bash里好使,但脚本开头如果写的解释器是/bin/sh,而系统把sh指向了dash,-e就会被当成普通字符串原样打出来,屏幕上出现一行让人摸不着头脑的-e 第一行\n第二行。遇到这种诡异现象,第一反应就应该检查脚本用的是不是/bin/sh,或者干脆改用printf,后面会细说。
2.2 引号、变量展开与空格那些坑
echo后面接的内容,Shell会先做变量替换和通配符展开,再执行打印。这个机制看起来平常,却是无数新手踩坑的源头。
举个最常见的例子:
name=Alice echo $name # 输出 Alice echo "$name" # 输出 Alice好像没区别,但把name改成"Alice Smith"再看:
name="Alice Smith" echo $name # 输出 Alice Smith,但中间的空格是“分裂”出来的 echo "$name" # 输出 Alice Smith,整个作为一份文本表面上输出一样,内部处理却完全不同。不带引号时,Shell会把Alice和Smith当成两个独立的词传给echo,如果这个变量后续还要传给别的命令,问题立刻放大。比如:
file="/tmp/my file.txt" cat $file # 报错,被拆成两个文件路径 cat "$file" # 正确这个坑被称为“词分裂”,几乎每天都会咬人一口。我的建议简单粗暴:凡是使用变量,一律加双引号,尤其是文件路径和可能包含空格的字符串。
单引号和双引号也有区别。双引号里的变量会被展开,单引号里的内容原封不动:
echo "$name" # 输出 Alice Smith echo '$name' # 输出 $name想打印带美元符号的原文,用单引号;想打印变量的值,用双引号。这个习惯养成之后,脚本的稳定性会肉眼可见地提升。
2.3 实际项目里的echo用法
日常脚本里,echo主要干三件事。一是打印进度提示,比如echo "文件下载完成";二是打印分隔线,让终端输出不至于挤成一团;三是往日志文件里追加记录。
追加日志时,我通常还会在前面拼一个时间戳:
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 任务执行完毕" >> run.log这里顺带提醒一句:重定向>>表示追加,如果写成>就会把日志文件覆盖掉,线上脚本一旦搞错,历史日志直接没影。这个低级错误我见过不止一次。
还有一个很多人不知道的用法是输出彩色文本。通过给输出包上ANSI转义码,可以让成功、警告、错误信息呈现不同颜色,方便排查问题:
echo -e "\033[31m错误:文件不存在\033[0m"但这又回到刚才说的兼容性问题上。所以我在生产脚本里基本不用echo -e做彩色输出,而是统一用printf,可移植性更好,这也是下面第四部分要展开的内容。
3. read命令:脚本的耳朵,让程序学会倾听
read命令的作用是从标准输入读取一行内容,把它赋值给一个或多个变量。没有它,脚本就只能闷头执行,无法根据用户的选择做出不同响应,交互体验无从谈起。
3.1 从终端读取输入的常用参数
最基础的用法是read name,脚本会停在那里等用户输入,用户敲完回车后,输入的内容存入name变量。加一个-p参数可以让提示语和读取合到一起,省去先echo再read的繁琐:
read -p "请输入你的名字:" name echo "你好,$name"-t用来设置超时时间,单位是秒。比如只等5秒,5秒内没输入就继续往后走:
read -t 5 -p "5秒内输入任意字符继续:" input-s是静默模式,输入内容不回显在终端上,适合读取密码。但要注意,这个模式不会自动换行,用户输完密码按回车后,光标还停在密码后面,你需要手动补一个echo让输出换行:
read -s -p "请输入密码:" pass echo-n用来限制读取的字符个数,比如只读一个字符用于确认操作:
read -n1 -p "确认删除?[y/N] " answer echo-a可以把输入按空格拆分成数组,适合一次性读取多个值。-r则禁止反斜杠转义,让输入里出现\时原样保留,读取文件时尤其重要。
3.2 从文件逐行读取:while read的正确姿势
read不仅能读终端,也可以配合重定向逐行读取文件,这是Shell脚本里处理配置文件的经典姿势:
while IFS= read -r line; do echo "读取到:$line" done < config.txt这里面的IFS=和-r都是有讲究的。IFS=把字段分隔符清空,这样行首行尾的空格不会被剥掉;-r让反斜杠不被特殊处理,遇到Windows换行符\r时也不会出事。二者齐上,才能保证“逐行原样读取”。如果漏了-r,一旦配置里有路径C:\new这类内容,就会被拆得乱七八糟。
需要格外小心的是管道和子Shell的坑:
count=0 cat config.txt | while read line; do count=$((count + 1)) done echo "总行数:$count"这段代码输出的总行数永远是0。原因是管道右边的while跑在子Shell里,循环里的一切变量修改都发生在子进程,父进程这边的count根本没动过。解决办法是别再通过管道传给while,直接用输入重定向:
count=0 while read line; do count=$((count + 1)) done < config.txt echo "总行数:$count"这是Shell新手最容易怀疑人生的问题之一,看到变量“明明改了却没生效”,第一个念头就应该是“是不是遇到了子Shell”。
3.3 read的三大经典坑
第一个坑是输入残留。read -n1读完一个字符就立刻继续,但用户按的回车还留在输入缓冲区里,下一个read可能会把这个回车当成内容。解决办法是在使用-n之后紧跟一个空read,把残留的换行吃掉,或者干脆用read -r配合额外处理。
第二个坑是最后一行读不到。read按行读取,如果文件最后一行没有换行符,某些写法会在循环里漏掉它。稳妥的写法是借助||兜底:
while IFS= read -r line || [ -n "$line" ]; do echo "读取到:$line" done < config.txt第三个坑是行内容包含大量空格时被无声拆分。如果没设IFS=,read line会把连续的空白压缩成单个空格。想要保留原始格式,必须严格写while IFS= read -r line。这三个坑都属于“不遇到不会信、遇到了才恍然大悟”的类型,提前知道能省下大把排查时间。
4. printf命令:被低估的格式化输出神器
如果说echo是便携喇叭,那printf就是一套标准扩音系统。它从C语言的printf继承了格式化输出的思想,能力远不止打印字符串这么简单。
4.1 为什么我在正式脚本里更推荐printf
第一是行为可预期。echo在不同Shell里对转义和选项的处理不一致,有的支持-e,有的不支持,有的还会把选项原样打印出来。printf的行为在POSIX标准里是统一的,写出来的脚本换到另一台机器上更不容易出幺蛾子。
第二是格式化能力强。输出表格、对齐字段、截断字符串、控制小数位数,这些事printf做起来得心应手。第三是变量安全性更好。用printf "%s" "$var"时,变量的内容会被当成普通字符串,不会被误解析成格式串。反观printf $var这种错误写法,变量里的%d会被当成格式占位符,输出立刻错乱。
4.2 格式说明符:%s、%d、%f、%-Ns逐个拆解
printf的基本语法是printf 格式串 参数...。格式串里用%开头指定占位符,后面的参数按顺序填进去。最常用的几个:
printf "姓名:%s,年龄:%d\n" "Alice" 30 printf "圆周率:%.2f\n" 3.14159 printf "十六进制:%x,八进制:%o\n" 255 8%s表示字符串,%d表示整数,%f表示浮点数,%.2f保留两位小数,%x和%o输出十六进制和八进制。数字和加减号可以控制宽度和对齐方向:
printf "%-10s|%10s\n" "left" "right"%-10s是左对齐并占满10个字符宽度,%10s是右对齐。这在输出列对齐的报表时非常实用。printf还有一个和echo很不一样的特点:如果给的参数比格式串里的占位符多,它会自动重复使用格式串。比如printf "%s\n" a b c会输出三行,这个行为用来对列表逐项输出很方便。反过来说,如果参数不够,缺失的部分会被当作空值处理。
4.3 printf 中文乱码到底是谁的锅
很多同学一遇到printf输出中文变成乱码,就以为是printf的编码有问题,其实它更像是一个背锅侠。乱码通常由两个原因造成。
第一个原因是脚本文件编码和终端环境的locale不一致。比如脚本文件保存成了GBK,终端却按UTF-8渲染,中文自然全是“口口口”或乱码符号。解决办法是统一编码环境:脚本文件存成UTF-8,终端字符集设置为UTF-8,并在脚本里明确环境:
export LANG=zh_CN.UTF-8检查当前语言环境用locale,查看文件编码用file 脚本名,诊断思路先从这里开始。
第二个原因和%c有关。在Shell里,%c只取参数字符串的第一个字节,而一个中文字符在UTF-8下占3个字节。比如:
printf "%c\n" "中文"它只会从“中”字里抠出一个字节,输出自然就是乱码。这个坑特别隐蔽,因为处理英文时%c看起来完全正常。想输出完整的中文字符串,用%s而不是%c。
还有一个虽不算乱码但也很恼人的问题:printf的宽度按字节算,不按字符算。中文每个字符占3个字节,一个包含中文的表格字段,即使用了%-20s,也会因为实际显示宽度不一致而歪歪扭扭。我的经验是:纯中文或中英混排的表格,要么手工估算宽度多留几个空格,要么直接用column命令或awk来处理对齐,别死磕printf的宽度参数。
4.4 printf与echo怎么选
我的选择标准很明确:临时调试、打一两个固定字符串,用echo;任何需要拼接变量、控制格式、或者可能移植到不同环境里的输出,一律用printf。宁可多敲几个字,也要换来行为稳定。
特别是要输出带转义的文本时,printf的%b说明符非常有用,它可以安全地解释字符串里的\n、\t等转义。对比一下:
printf "%b" "第一行\n第二行\n"这比echo -e更规范,换到任何POSIX兼容Shell都不会掉链子。
5. test命令:脚本的分叉路口
没有test,脚本只能是一条直线走到黑。有了它,脚本才真正具备“看情况办事”的能力:判断文件是否存在、目录是否可写、字符串是否为空、数字是否达到阈值。
5.1 test的三种写法:test、方括号、双方括号
Shell里判断条件的写法有三种形态,其实是同一个家族:
test -f /etc/passwd [ -f /etc/passwd ] [[ -f /etc/passwd ]]test是命令原生形态,[其实是test命令的别名,所以[后面必须跟空格,结尾还得再加一个]。忘记空格或结尾缺],是Shell新手高频报错之一。
[[ ]]是bash提供的增强版,它比前两者更好用,甚至在很多场景下更安全。它支持&&、||这类直观的逻辑组合,还支持模式匹配。但要注意,如果脚本解释器是/bin/sh,[[ ]]可能不可用,写POSIX兼容脚本时得回到[ ]或test。
5.2 文件、字符串、数值:三类判断场景速查
文件相关判断是最常用的,速查如下:
-e 路径:路径是否存在-f 路径:是否为普通文件-d 路径:是否为目录-r / -w / -x:是否可读、可写、可执行-s 路径:文件是否非空-L 路径:是否为符号链接
字符串判断常用的有:
-z "$var":字符串为空-n "$var":字符串非空"$a" = "$b":两个字符串相等"$a" != "$b":两个字符串不相等
数值判断是另一组,和字符串比较长得完全不一样,容易混淆:
-eq:等于-ne:不等于-lt:小于-le:小于等于-gt:大于-ge:大于等于
注意,数值比较的写法是[ "$num" -gt 10 ],这里的-gt不能换成数学上的>,后面马上会解释为什么。
5.3 新手最容易翻车的三个test坑
第一个坑:变量不加引号。比如用户没有输入内容,var为空,此时[ $var = "yes" ]展开后变成[ = "yes" ],直接报错说缺了操作数。正确写法是[ "$var" = "yes" ]。这再次印证了上一章的原则:变量一律加双引号,特殊情况另说。
第二个坑:把>、<用在[ ]里。[ "$a" > "$b" ]根本不是在比较大小,>在这里是输出重定向符号,会把"$b"当成一个文件并把空内容写进去,同时产生一个同名的垃圾文件。要比较字符串大小,要么用[ "$a" \> "$b" ]给尖括号加转义,要么用[[ "$a" < "$b" ]],后者更清晰。
第三个坑:数字和字符串的比较方式搞混。判断变量num=10是否大于5,写成[ "$num" > 5 ]同样是重定向问题;写成[ "$num" -gt 5 ]才是正解。字符串的“相等”用=,数值的“等于”用-eq,两者不能互通,这是入职笔试和实际排障里反复出现的考点。
5.4 test的扩展用法:组合条件与模式匹配
复杂判断可以用&&和||组合条件,也支持布尔非。推荐写成两个[ ]之间加逻辑符号的形式,可读性更好:
if [ -f "$file" ] && [ -r "$file" ]; then echo "文件存在且可读" fi在[[ ]]里可以直接写&&、||、甚至正则匹配:
if [[ "$name" == *.zip ]]; then echo "这是一个zip包" fi这里==右边的*.zip是模式,不是普通字符串,所以不需要加引号。很多老手写脚本时偏爱[[ ]],就是因为它的语义更接近自然表达,不容易踩上面那些坑。
6. 四命令联合作战:写一个可交互的检测脚本
学命令好比收集零件,把它们组合在一起才算真的会组装机器。这里我把四条命令全部放进去,写一个简单的系统检测脚本,用来演示它们是如何协作的。
6.1 脚本目标与功能设计
这个脚本要实现三件事:显示当前主机基本信息;给用户列出可执行的操作菜单;根据用户选择做不同处理。核心逻辑无非是:用printf展示菜单,用read读用户选择,用test判断用户输入是否合法,用echo或printf输出结果。
脚本开头我用了#!/usr/bin/env bash,明确指定bash解释器,这样可以放心使用[[ ]]、read -p等增强特性,不会因为sh环境不同而行为怪异。
6.2 完整脚本与逐段解读
#!/usr/bin/env bash printf "\033[36m========== 系统信息 ==========\033[0m\n" printf "%-12s: %s\n" "主机名" "$(hostname)" printf "%-12s: %s\n" "系统版本" "$(uname -srm)" printf "%-12s: %s\n" "当前用户" "$(whoami)" printf "\n请选择要执行的检查:\n" printf "1) 磁盘使用率\n" printf "2) 内存使用率\n" printf "3) 退出\n" read -n1 -p "输入选项 [1-3]:" choice echo case "$choice" in 1) df -h ;; 2) free -h ;; 3) echo "再见" exit 0 ;; *) printf "\033[31m无效选择:%s\033[0m\n" "$choice" exit 2 ;; esac if [ "$?" -eq 0 ]; then printf "\033[32m执行完成\033[0m\n" fi一点点拆开看。printf里带\033[36m和\033[0m,这是ANSI颜色码,前者让文字变成青色,后者关闭颜色效果,这是我在生产环境里更偏爱的彩色输出方式,因为它不依赖echo -e。%-12s则让所有字段左对齐,输出整齐。
read -n1只读取一个字符,用户不用敲回车就能触发选择,交互体验更顺。这里必须紧跟一个echo,否则后续所有输出都会挤在同一个行尾。case接收这个字符做分支,如果用户乱按了其他键,就进入*)分支,这里用printf把非法输入原样回显出来。
最后一行的判断if [ "$?" -eq 0 ],检查的是上一个命令的退出状态。$?保存着前一条命令的返回码,-eq 0表示成功。这是test在脚本里最常见的应用之一:根据退出码判断后续流程。整体看下来,输出、输入、判断、分支四件事全齐了,而且每一处都刻意用了相对稳妥的写法。
7. 常见报错与排查速查表
脚本写多了,你会发现大部分报错根本不是随机事件,而是固定的几个原因反复出现。下面这张表是我整理的高频问题速查,基本都能对号入座。
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
echo -e不生效,输出里带-e | 脚本用/bin/sh运行,当前Shell不支持该选项 | 改用printf,或者脚本显式写#!/usr/bin/env bash |
printf输出中文乱码 | 文件编码、终端字符集、locale不一致 | 脚本存UTF-8,检查locale,用export LANG=zh_CN.UTF-8 |
%c输出中文出现残缺 | %c只取第一个字节,中文占多字节 | 改用%s |
read读文件漏掉最后一行 | 文件最后一行没有换行符 | 使用while IFS= read -r line || [ -n "$line" ] |
管道里while read修改的变量外部为0 | while在子Shell中执行,变量改动带不出来 | 把cat file | while改成while ... done < file |
报错unary operator expected | 变量为空且没加双引号 | 所有变量统一加双引号 |
报错[: too many arguments | 变量内容被拆分成多个词 | 变量加双引号,检查输入中是否带空格 |
[ "$num" > 5 ]生成一个名为5的文件 | 在[ ]里>被当成重定向 | 数值比较使用-gt,字符串比较用\>或[[ ]] |
if里明明相等却走不到对应分支 | 数值用了=,字符串用了-eq | 记住:字符串比相等用=,数值比相等用-eq |
read -n1之后第二个read自动跳过 | 残留的回车符留在缓冲区 | 在-n之后补一个空read清理缓冲 |
排查问题有个通用思路:先看脚本用的是哪个解释器,再看变量有没有加引号,最后考虑编码和子Shell。这三板斧砍下去,能解决掉至少七成Shell脚本里莫名其妙的报错。
最后说一点个人习惯,也算不上什么大道理:我写脚本时,凡是面向用户的正式输出,一律交给printf;凡是临时调试用的输出,随手用echo;凡是涉及条件判断,先把判断条件在脑子里翻译成test表达式,再动手写if。这套流程谈不上精妙,但用来保证脚本稳定和后续好维护,效果一直不错。你按这个思路把你自己的脚本翻一遍,很多之前绕来绕去的问题,应该都能变得清楚起来。