
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会先做变量替换和通配符展开再执行打印。这个机制看起来平常却是无数新手踩坑的源头。举个最常见的例子nameAlice echo $name # 输出 Alice echo $name # 输出 Alice好像没区别但把name改成Alice Smith再看nameAlice 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的坑count0 cat config.txt | while read line; do count$((count 1)) done echo 总行数$count这段代码输出的总行数永远是0。原因是管道右边的while跑在子Shell里循环里的一切变量修改都发生在子进程父进程这边的count根本没动过。解决办法是别再通过管道传给while直接用输入重定向count0 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第三个坑是行内容包含大量空格时被无声拆分。如果没设IFSread 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 LANGzh_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 ]]后者更清晰。第三个坑数字和字符串的比较方式搞混。判断变量num10是否大于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 bashprintf输出中文乱码文件编码、终端字符集、locale不一致脚本存UTF-8检查locale用export LANGzh_CN.UTF-8%c输出中文出现残缺%c只取第一个字节中文占多字节改用%sread读文件漏掉最后一行文件最后一行没有换行符使用while IFS read -r line || [ -n $line ]管道里while read修改的变量外部为0while在子Shell中执行变量改动带不出来把cat file | while改成while ... done file报错unary operator expected变量为空且没加双引号所有变量统一加双引号报错[: too many arguments变量内容被拆分成多个词变量加双引号检查输入中是否带空格[ $num 5 ]生成一个名为5的文件在[ ]里被当成重定向数值比较使用-gt字符串比较用\或[[ ]]if里明明相等却走不到对应分支数值用了字符串用了-eq记住字符串比相等用数值比相等用-eqread -n1之后第二个read自动跳过残留的回车符留在缓冲区在-n之后补一个空read清理缓冲排查问题有个通用思路先看脚本用的是哪个解释器再看变量有没有加引号最后考虑编码和子Shell。这三板斧砍下去能解决掉至少七成Shell脚本里莫名其妙的报错。最后说一点个人习惯也算不上什么大道理我写脚本时凡是面向用户的正式输出一律交给printf凡是临时调试用的输出随手用echo凡是涉及条件判断先把判断条件在脑子里翻译成test表达式再动手写if。这套流程谈不上精妙但用来保证脚本稳定和后续好维护效果一直不错。你按这个思路把你自己的脚本翻一遍很多之前绕来绕去的问题应该都能变得清楚起来。