写 shell 脚本写了十来年翻车最多的地方往往不是那些绕来绕去的流程控制而是看起来最没技术含量的注释。新手觉得注释就是把#往行首一放的事老手却知道多行注释这件事在 shell 里根本没有官方语法全是靠各种副作用凑出来的野路子每一种都有自己的适用边界和坑。这篇文章就把单行注释和多行注释这两件事彻底扒开讲透先说清单行注释里那些反直觉的边界规则再横向对比多行注释的四条主流路线然后动手演示怎么在真实脚本里安全地屏蔽一大段代码、怎么用 sed 和 vim 批量加注释、怎么在编辑器里折叠长注释块最后整理一份我自己踩过的报错排查表。不管你是刚接触 shell 脚本入门的新人还是天天在服务器上写运维脚本的老手都能从里面捞到点能直接抄走的东西。1. 为什么 shell 注释值得单独拎出来讲1.1 注释在脚本里的真实定位很多人把注释当成给人看的废话写完就扔。但在 shell 这个语境里注释承担的角色比在其他语言里重得多。shell 脚本天生语法宽松、缺少类型约束一个变量到底是字符串还是数字一个参数到底是空的还是没定义很多时候只能靠注释交代清楚。我接手过的运维脚本里凡是半年后还能被人放心修改的头部一定有一段写明白用途、参数、依赖命令和退出码的注释块。更关键的是shell 脚本的临时屏蔽场景特别多。改一个线上部署脚本想先关掉其中一段清理逻辑观察效果直接删掉又怕回头要找回来这时候多行注释就是最省事的手段。这也意味着多行注释的可靠性直接关系到你会不会误删、误执行——它不只是可读性问题还是安全问题。1.2 一个被低估的风险点注释和解析器的边界真正让多行注释变复杂的是 shell 的解析顺序。shell 执行一行命令之前要经过分词、展开参数展开、命令替换、算术展开、路径展开、再去执行。多行注释的那些野路子写法本质都是让某一段文本落进一个不会真正执行的位置但展开这一步可能依然会发生。这就是为什么偶尔会出现我明明把那段代码注释掉了它怎么还在跑的诡异现象。所以判断一种多行注释写法靠不靠谱我通常问三个问题第一里面的内容会不会被变量展开和命令替换第二里面的内容语法不合法会不会导致整个脚本失败第三内容里出现特殊字符单引号、反引号、结束标记同名字符串会不会提前中断。把这三条想清楚选型就不会错。2. 单行注释# 的完整规则与边界条件2.1 # 从哪个字符开始生效教科书只告诉你#是注释符号但没说它生效的前提。shell 里#只有当它位于一个词的开头时才开启注释判断依据是它前面是行首、空白或者;、|、、(这类元字符。看下面这三行结果完全不同echo hello#world # 输出 hello#world# 只是普通字符 echo hello #world # 输出 hello#world 被注释掉 echo hello;#world # 输出 hello#world 被注释掉这个规则的实际影响比想象中大。比如拼路径的时候写成echo /var/log#backup你以为加了注释其实是把整个字符串原样打印。反过来处理带#的 URL、带#的配置文件片段时如果你不想让#被当成注释就必须用引号包起来或者用\#转义。我在处理 nginx 配置生成、带锚点的链接拼接时被这个问题坑过两次现在写脚本养成的习惯是只要字符串里有#一律加单引号。还有一条容易被忽略的在交互式 shell 里#注释的开关由interactive_comments控制bash 默认是开的但某些精简过的 shell 环境可能没开。写脚本时不用操心这件事因为在脚本文件里#注释是无条件生效的。2.2 shebang 与 # 的特殊关系脚本第一行的#!是个特例它虽然是#开头但不当注释用而是告诉内核用哪个解释器来跑这个文件。这里有两个硬性条件经常被违反第一#!必须是文件的头两个字节前面不能有任何东西第二解释器路径必须正确。第一个条件衍生出一个特别隐蔽的坑——BOM 头。如果你在 Windows 上用某些编辑器保存脚本文件开头可能被自动塞进三个字节的 UTF-8 BOM肉眼完全看不出来但内核读到的前两字节就不是#!了于是 shebang 失效脚本被丢给默认 shell 解析可能出现莫名其妙的语法错误。排查方法很简单head -c 3 deploy.sh | xxd正常应该输出2321开头如果看到efbbbf就中招了。处理办法是在 vim 里执行:set nobomb然后:w重新保存。第二个条件的常见争议是#!/bin/bash和#!/usr/bin/env bash怎么选。前者路径固定、启动快但在 FreeBSD 这类系统上 bash 装在/usr/local/bin就会失效后者靠env去 PATH 里找可移植性好但如果脚本是在 cron 或者某些最小化容器环境里跑PATH 被裁得很干净env也可能找不到 bash。我的取舍是目标是固定版本的服务器就用绝对路径需要跨多种发行版分发就用env并且在使用env的脚本里显式加一行 PATH 兜底。2.3 单行注释里的三类高危写法单行注释的内容不会被解析这一点让很多人放松了警惕。它确实不会执行命令替换但有三类写法依然会给你添麻烦。第一类是注释里出现未闭合的引号。这个在单行注释里是安全的echo hi # its fine完全没问题因为解析器遇到#之后直接跳到行尾根本不管引号配对。但如果你在写多行注释的时候把这条经验带过去就会炸——下一节会细说。第二类是在命令替换里写注释这个是真会出错的echo $(hostname # 打印主机名)多数 bash 版本上这段会报unexpected EOF while looking for matching )原因是#之后的注释一直吃到物理行尾把配对的右括号也一起吞了。写法上要么把右括号提到注释前面要么老老实实换行写。多行命令里带注释时这个坑出现的频率非常高尤其是写长了之后随手在行尾补一句说明。第三类是注释里藏着续行符。行尾的\在普通代码里是续行在注释里行为的可预期性就差得多不同 shell 实现可能有差异。结论就一句话别在注释行末尾留\需要多行说明就老老实实写多行#。提示脚本里用grep -c ^[[:space:]]*# script.sh可以统计注释行数量做代码审查时比肉眼扫快得多。3. 多行注释的四条主流路线怎么选3.1 路线一: ... 单引号包裹法这是最简洁的写法利用:冒号内建命令什么都不做接收参数的特性把一整段文本作为它的参数传进去参数是单引号包裹的字符串所以内部不会被展开: echo 这段代码不会执行 rm -rf /tmp/old_data VERSION$(cat version.txt) 它的优点是短、直观、跟其他语言的块注释长得最像。痛点也很明显单引号字符串内部不能出现单引号。一旦你在注释内容里写了个英文缩写dont或者粘贴了一段本身带单引号的 shell 代码整个字符串提前闭合后面的内容就变成了真的会被执行的命令——这是最危险的情况注释块里的代码可能直接被跑到。绕过办法是拆开拼接比如: ... ... 但写出来非常丑可读性直接归零。另外set -x打开的时候:的参数会被 xtrace 打到标准错误里日志里会凭空多出一大段注释内容排障时容易被带偏。所以我的判断是短小的、确定不含单引号的说明性段落可以用屏蔽大段代码别用这一招。3.2 路线二: EOF heredoc 法heredoc 是我认为最靠谱的多行注释方案也是我在生产脚本里唯一会用的方案。写法是: COMMENT echo 不会执行 VERSION$(cat version.txt) 这段里写什么都可以包括单引号 dont 和反引号 date COMMENT原理上:是空操作的命令heredoc 把两块标记之间的所有行作为标准输入喂给它什么都不做。关键是结束标记必须加引号COMMENT、COMMENT、\COMMENT这三种写法都表示不做任何展开是安全的不加引号的COMMENT会执行参数展开、命令替换和反引号等于把注释块变成了代码执行区。为什么强烈推荐这一条内容里可以随便出现单引号、双引号、$、反引号、括号全都不用转义语法结构简单缩进随意结束标记可以自定义成COMMENT、NOTES、DISABLED这类有语义的名字一眼就知道这块是什么。唯一需要记住的规则是结束标记必须顶格写前面不能有任何空格或制表符。有个变体写法: -COMMENT-的作用是剥离每行行首的制表符注意只剥 tab不剥空格。如果你习惯用空格缩进这个选项完全帮不上忙结束标记该顶格还是得顶格。我见过太多人在这里翻车后面单独讲。3.3 路线三if false; then ... fi 法把要屏蔽的代码放进一个永远不成立的条件分支里if false; then echo 不会执行 VERSION$(cat version.txt) fi优点是不引入任何新语法任何懂 if 的人都能看懂编辑器也能正常折叠。缺点是里面的内容语法必须合法因为 shell 解析整个文件的时候会完整解析 if 块只是不执行而已。里面如果有个未闭合的引号或者写错的括号脚本照样报错退出。另外要注意虽然不执行但如果你在块里写了函数定义、变量赋值这类东西它们的不存在是运行时的事静态检查工具仍然会看到它们并可能给出提示。所以这一招适合临时调试时快速关掉一段逻辑不适合长期作为文档性注释保留在脚本里——留久了容易被人误以为是在做条件开关。3.4 路线四定义未调用函数与其它偏门写法社区里还流传几种写法了解一下就行实际项目中我不建议用。第一种是把内容塞进一个永远不调用的函数_legacy_code() { echo 旧逻辑暂时保留 }它能跑但会占用一个函数名字可能和后面的命名冲突而且部分静态检查工具会提示函数定义了但从未使用噪音比价值大。第二种是用: EOF之外的空命令变体比如true EOF ... EOF效果等价于:只是把空操作命令换成了true没什么本质区别。第三种是在[[ ]]里做文章这类写法依赖具体实现的行为可移植性差不建议在需要跨 shell 环境的脚本里使用。3.5 四条路线的横向对照把关键差异拉成一张表选型的时候对着看就行方案内容含单引号内容含$/反引号内容语法必须合法推荐场景: ... 会中断报错或误执行安全不展开不要求短说明段落: EOF安全安全不展开不要求屏蔽大段代码首选if false; then ... fi需正确配对不展开要求临时调试开关未调用函数需正确配对不展开要求不推荐这张表最关键的一列是内容语法必须合法。前两种方案之所以安全是因为那段文本压根没被当成代码解析后两种方案是把代码正常解析完了再选择不执行风险等级完全不同。4. 实操把注释用进一个真实的部署脚本4.1 脚本头部注释块的标准写法先看一个我平时用的脚本头部模板可以直接改成自己的#!/usr/bin/env bash # # 脚本名称: deploy.sh # 功能说明: 拉取指定分支代码并滚动重启服务 # 参数说明: # $1 分支名默认 main # $2 环境标识可选 test / prod默认 test # 依赖命令: git, rsync, systemctl # 退出码: 0 成功1 参数错误2 部署失败 # 维护人: 我 # 更新记录: 2024-05-10 初版 # set -euo pipefail这个头部每次都要写吗我一开始也嫌烦后来发现它节省的时间远远超过写它的时间。尤其退出码和依赖命令这两项出了问题第一个被问到就是这两个信息。头部注释里不要写太长控制在十行以内超过二十行的头部反而没人看。这里有个细节#和后面的内容之间习惯留一个空格纯属可读性约定不影响执行但整个团队统一之后 diff 会干净很多。4.2 临时屏蔽一大段代码的完整演示假设有个采集脚本里面的清理逻辑我想临时关掉观察一天。原代码是#!/usr/bin/env bash set -euo pipefail collect_data() { echo 开始采集 } clean_old_files() { find /data/raw -type f -mtime 7 -delete echo 清理完成 } collect_data clean_old_files echo 任务结束现在要把clean_old_files整个函数体和调用都停掉。直接删掉的话回头找不回来用#一行行加又太慢。用 heredoc: DISABLED clean_old_files() { find /data/raw -type f -mtime 7 -delete echo 清理完成 } DISABLED collect_data echo 任务结束DISABLED这个标记名是我个人的习惯语义清楚搜索这个关键词就能定位所有被临时关掉的地方。上线前用grep -rn DISABLED scripts/扫一遍确保没有遗留。验证改动是否生效千万别直接在生产上跑。我一般先做两步检查bash -n deploy.sh # 语法检查不执行 bash -x deploy.sh 21 | head -30 # 追踪前 30 行执行过程bash -n是纯语法解析能抓到引号没配对、fi少写这类问题几毫秒就返回。bash -x会把实际执行的每一条命令打到标准错误用来确认被注释的块确实没被执行。这两步是我改任何线上脚本之前的固定动作成本低到可以忽略救过的命不止一次。注意heredoc 的结束标记DISABLED必须顶格写。如果你的代码块整体缩进过很容易顺手也把它缩进了结果 shell 一直找不到结束标记报unexpected EOF while looking for matching然后吃掉后面所有内容。4.3 用 sed 和 vim 批量加注释与取消注释给一段连续行加注释sed 最快sed -i 10,25s/^/#/ deploy.sh # 给 10 到 25 行加注释 sed -i 10,25s/^#// deploy.sh # 取消注释 sed -i 10,25s/^#\{1,\}// deploy.sh # 取消多层注释更稳妥 sed -i /^$/!s/^/# / deploy.sh # 非空行才加注释用sed -i有两个必须知道的坑。第一macOS 自带的 BSD sed 语法不同-i后面必须跟一个备份后缀参数写sed -i 10,25s/^/#/ file才行直接照抄 Linux 命令会报错。第二取消注释时如果某行本来是内容里就带#的比如 shebang 或者已经有注释的行简单地把行首#删掉会误伤所以批量操作前先cp一份备份或者先跑不带-i的版本看一眼输出。用 vim 的话命令模式和 sed 类似:10,25s/^/#/g :10,25s/^#//gvim 里更好用的是可视块模式光标移到起始行按Ctrlv进入块选择向下选中若干行按大写I进入插入模式输入#然后按Escvim 会把这一列插入应用到所有选中行。取消的时候同样Ctrlv选中第一列按d或x删掉即可。这个操作熟练之后比敲命令快得多。顺便说一个几乎人人都遇到过的问题你在 vim 里编辑脚本临时想回到 shell 看一眼按了CtrlZ把 vim 挂到后台然后在 shell 里习惯性地敲了:wq结果看到/bin/sh: wq: command not found甚至还有[no write since last change]的提示。这不是编辑器坏了纯粹是把 vim 的命令敲进了 shell。正确做法是先执行fg把 vim 唤回前台再敲:wq。如果已经慌到不记得挂起了几个任务jobs看一下列表就行。4.4 VSCode 里折叠长注释块脚本大了之后头部注释块和临时屏蔽块加起来能占上百行滚动起来很烦。VSCode 本身支持按缩进折叠另外较新版本对 shell 脚本也能识别#region/#endregion这对标记#region 部署参数说明 # 这里可以放一大段参数文档 #endregion这对标记不会影响脚本执行因为都是合法的单行注释。如果发现折叠没生效多半是当前语言模式没走这条逻辑可以点右下角的语言标识确认文件被识别成了 Shell Script实在不行把注释块整体缩进一层靠缩进折叠也能达到差不多的效果。我不建议为了折叠把整段注释强行缩进因为 heredoc 结束标记顶格的规则在那儿摆着缩进很容易把人带沟里。5. 常见报错与排查速查5.1 unexpected EOF while looking for matching这是多行注释最高频的报错。触发原因按出现频率排heredoc 结束标记没有顶格结束标记拼写和开头不一致比如开头写COMMENT结尾写成COMMENTS用了: ... 而内容里混进了单引号导致字符串没闭合注释内容里又嵌了一层 heredoc结束标记撞车。排查顺序建议从后往前先grep -n EOF\|COMMENT script.sh把所有出现过的标记列出来肉眼比对数量和拼写再检查这些行的行首有没有空格最后用bash -n定位到具体行号。有个小技巧是结束标记换成完全不可能出现在正文里的字符串比如COMMENT_BLOCK_END_2024能一次性排除撞车问题。5.2 注释块里的变量和命令替换被真的执行了症状是脚本里忽然出现莫名其妙的输出、临时文件被创建、甚至目录被删。九成是写了不加引号的 heredoc: COMMENT 这个 $VERSION 会被展开 这个 date 会被执行 COMMENT记住结论只要结束标记不加引号heredoc 里所有展开照常发生。改成: COMMENT立刻就好。这个坑在从别处复制代码时特别容易中招因为复制过来的片段往往只保留了内容引号在复制过程中丢了肉眼也很难发现。5.3 注释掉的内容依然报语法错误如果你用的是if false; then ... fi或者未调用函数这两种方式里面的内容会被完整解析语法错误照样让脚本挂掉。判断办法是看报错行号是不是落在注释块内部——如果是说明这段内容确实在被解析。解决办法就是换成 heredoc 方式因为那段文本根本没进入语法解析流程。这里有个容易被忽略的延伸即使语法合法if false分支里如果定义了函数或者做了变量赋值虽然运行时不存在但静态检查工具、IDE 跳转、代码覆盖率统计都会把它们算进去长期留在脚本里会污染工具链的输出。5.4 中文注释显示乱码脚本在服务器上跑得好好的cat出来注释全是乱码通常不是脚本坏了而是编码或 locale 不匹配。排查三板斧file -i deploy.sh # 看文件本身的编码 locale # 看当前终端的 LANG 设置 iconv -f gbk -t utf-8 deploy.sh deploy_utf8.sh # 转码最常见的成因是在 Windows 环境下编辑脚本保存成了 GBK 编码。稳妥做法是在 vim 里执行:set fileencodingutf-8再:w强制以 UTF-8 落盘。另一个成因是终端LANG是C或POSIX中文注释显示成问号这种情况下文件的编码其实是好的改终端环境变量即可。还有一种情况更隐蔽脚本从别处传输过来时经过了一次编码转换导致某几个汉字变成了非法字节序列脚本直接报语法错误——这种只能用iconv反向验证或者用十六进制编辑器找异常字节。5.5 问题速查表现象最可能原因快速处理unexpected EOF while looking for matching结束标记未顶格 / 拼写不一致检查标记行行首空格注释块里的命令被执行heredoc 标记未加引号改为COMMENT注释块内语法错误导致脚本失败用了if false/ 函数方案换成 heredoc 方案注释内容混入 xtrace 日志: ... 参数被追踪改用 heredoc中文注释乱码文件是 GBK / 终端 LANG 不对iconv转码或改 localewq: command not found在 shell 里敲了 vim 命令先fg回到 vimshebang 不生效文件开头有 BOM:set nobomb重存6. 进阶细节与团队协作里的注释约定6.1 set -e / set -u / set -x 下注释块的表现set -e让脚本遇到非零返回就退出。这一点对注释块基本没影响因为:永远返回 0heredoc 方案稳得很if false; then ... fi里条件判断为假也不算错误因为 if 结构本身的返回码来自不执行的分支属于正常路径。真正会受影响的是那些利用副作用的偏门注释写法它们可能在某次 shell 版本升级后返回非零把整个脚本带崩。set -u禁止引用未定义变量。在没加引号的 heredoc 里如果写了$UNDEFINED_VAR就会直接触发 unbound variable 报错退出。这算是一种意外的安全网但别指望它老老实实加引号才是正解。set -x的表现前面提过:带参数的写法会把注释内容打到标准错误。在 CI 流水线里日志会被全量收集一大段注释内容混进去之后真正有用的执行记录会被淹没。如果你的脚本要在流水线里跑多行注释统一走 heredoc。6.2 shellcheck 指令型注释的用法有一类注释不是给人看的是给静态检查工具看的叫指令型注释。最常见的是对 shellcheck 的抑制指令# shellcheck disableSC2086 rm $FILES # shellcheck source./common.sh source ./common.shSC2086是未加引号的变量会导致分词这条警告写某些确实需要分词的场景时可以用它精准抑制。文件级别的抑制要写在 shebang 之后、其它代码之前并且注释格式略有不同。用这类注释的原则是精准到行、写明原因我通常会在下面再补一行普通注释说明为什么要忽略否则三个月后自己都不知道当初在想什么。6.3 几道面试常问的注释题被问得最多的三道简单过一下。第一道#注释里能不能写命令替换答案是不能执行# echo $(date)只会把整行忽略掉不会有任何输出。追问通常是那多行注释里呢这时候要立刻补上取决于写法不加引号的 heredoc 会执行这个追问就是区分度所在。第二道注释里能不能嵌套注释严格说 shell 的多行注释都是模拟出来的#本身不支持嵌套# # 注释只是两个独立的注释行。heredoc 方式同理内容里再出现一个同名结束标记会导致提前结束所以嵌套基本靠换标记名来实现。第三道怎么在脚本里临时禁用一段代码又不删标准答案就是 heredoc并且要提到结束标记加引号、必须顶格这两条。6.4 我自己的注释约定最后分享一下我在团队里推行的几条约定都不复杂但坚持下来效果明显。单行注释一律#后加一个空格注释内容写为什么而不是做什么。i$((i1)) # 计数器加一这种注释没有价值i$((i1)) # 跳过表头行才有价值。临时屏蔽代码统一用: DISABLED并且在上线前用grep -rn DISABLED扫一遍确认没有遗留。这个习惯看着土但比任何流程规范都管用。脚本头部固定七项名称、功能、参数、依赖命令、退出码、维护人、更新记录。团队里新人接手老脚本的时候这七项省下的沟通时间非常可观。注释里不写任何可能过期的具体数字比如这个目录下有 300 个文件。数字要写就写成从配置里读的形式否则半年后没人知道那个 300 还是不是准的。我自己在实际操作中最深的一点体会是shell 的注释机制看起来简陋但恰恰因为它简陋每一种多行注释写法背后的原理都是透明的、可验证的。别背写法把这段文本有没有进入展开流程有没有进入语法解析流程这两句话记住遇到没见过的写法也能自己判断它安不安全。真要给一条最实用的建议那就是——屏蔽代码只用: COMMENT别的一概不碰省下来的时间足够你多写好几个脚本。