1. 项目概述:别再把 EOF 当成“随便敲的三个大写字母”了
在 Linux 命令行里,cat <<EOF这种写法几乎每天都会出现在脚本、教程、甚至面试题里。但你有没有停下来问过自己:为什么非得用EOF?换成STOP或END行不行?<<-EOF里那个短横线-到底吃掉了什么?而<<'EOF'外面加了一对单引号,是防谁?——它既不防病毒,也不防网络攻击,它防的是 shell 自己。
我第一次在某公司内部自动化部署脚本里看到<<-EOF时,以为是运维同事手抖多按了个减号;后来在 CI/CD 流水线里发现<<'EOF'包裹着一段带$PATH和$(date)的 SQL 模板,结果执行后变量全没展开,当场懵住。查文档?man bash里关于 here-document(此处文档)的说明只有半页,全是术语堆砌:“leading tabs are stripped”,“no expansion is performed”,读完更晕。真正搞懂这三者的区别,不是靠背定义,而是亲手试错十次以上,再回看 shell 解析器的词法分析流程。
这组语法的本质,是 bash 在解析阶段对输入流做的三类不同处理策略:一类放行所有扩展(默认<<EOF),一类允许缩进但主动剥离制表符(<<-EOF),一类则彻底关闭所有 shell 元字符解释(<<'EOF')。它们不是“功能增强”,而是“控制粒度”的开关——控制的是 shell 在把这段文字交给cat之前,自己动了多少手脚。
适合谁看?如果你写过 5 个以上含 here-document 的脚本,却仍要靠echo "$var"临时调试变量是否生效;如果你改过别人的部署脚本,发现<<EOF里写的rm -rf $HOME/.cache突然删错了目录;或者你正被 Jenkins Pipeline 的sh """..."""块里变量不展开的问题卡住——那这篇就是为你写的。它不讲“什么是重定向”,不重复man bash的原文,只讲你在终端里敲下回车后,shell 究竟做了什么、为什么这么做、以及你该在第几行加个单引号才能救回刚删掉的配置文件。
2. 核心原理拆解:shell 解析器的三道“安检门”
2.1 默认模式<<EOF:最宽松的“信任通道”
<<EOF是 here-document 的基础形态,但它绝非“无脑透传”。它的核心规则只有一条:从当前行开始,直到遇到一个独立成行、前后无空格、完全匹配 delimiter(这里是EOF)的字符串为止,将中间所有内容作为标准输入传递给前导命令(如cat)。
但关键在于:在此过程中,shell 会对每一行执行完整的参数扩展、命令替换、算术扩展和波浪号扩展。也就是说,<<EOF中的内容,在传给cat之前,已经过 shell 的“预处理”。
我们来实测:
$ name="张三" $ age=28 $ cat <<EOF 姓名:$name,年龄:$age 今天是:$(date +%Y-%m-%d) 家目录:~/.bashrc EOF输出:
姓名:张三,年龄:28 今天是:2024-06-15 家目录:/home/user/.bashrc注意:~被展开了,$(date)执行了,$name和$age替换了。这正是<<EOF的默认行为——它把 here-document 当作“待执行的 shell 代码片段”来对待,只是不执行其中的命令,只做展开。
提示:
<<EOF中的$((2+3))会算出5,$(( $(wc -l < /etc/passwd) ))也能运行。但rm -rf /这类命令不会执行——因为cat不认识rm,它只负责输出文本。shell 展开的是变量和命令替换,不是整行命令。
2.2 剥离缩进模式<<-EOF:为可读性妥协的“智能裁剪”
现实中的脚本不可能把 here-document 写在第一列。我们习惯缩进以保持结构清晰:
if [ "$env" = "prod" ]; then cat <<EOF DB_HOST=prod-db.example.com DB_PORT=5432 EOF fi但这样会导致输出里每行开头都有 8 个空格(或 2 个制表符),破坏配置文件格式。<<-EOF就是为此而生:它允许行首存在任意数量的制表符(Tab),并在传递给命令前将这些制表符全部删除;但空格(Space)不在此列,会被原样保留。
重点来了:<<-EOF只剥离“行首的制表符”,且仅当 delimiter 行本身也以制表符开头时才生效。我们验证:
$ cat <<-EOF Line 1: tabbed Line 2: space-indented Line 3: tabbed again EOF输出:
Line 1: tabbed Line 2: space-indented Line 3: tabbed again看到没?Line 2前的两个空格完好无损,而Line 1和Line 3行首的 Tab 被干掉了。这是因为<<-的算法是:先扫描 delimiter 行(即单独写EOF的那一行),记录其开头有多少个 Tab;然后对 every line in the here-document,移除相同数量的行首 Tab。如果 delimiter 行没有 Tab,那就一个都不剥。
注意:
<<-EOF并不改变变量展开行为——它依然做$name、$(date)等所有扩展。剥离缩进和变量展开是两套并行机制,互不干扰。
2.3 字面量模式<<'EOF':彻底关掉 shell 解释器的“保险箱”
当你需要把一段原封不动的文字喂给命令,一个标点都不能变,这时<<EOF和<<-EOF都会背叛你。比如写一个包含$、反引号、反斜杠的正则表达式,或一段要存入文件的 Python 代码模板:
# 错误示范:变量被展开,反引号被执行 cat <<EOF import re pattern = r"\d+\.\d+\.\d+" result = re.search(pattern, "$input_text") EOF这里$input_text会被当作 shell 变量尝试展开(为空则消失),r"\d+\.\d+\.\d+"中的\d在 shell 层不会被转义,但传给 Python 后可能出错;更糟的是,如果$input_text有值,整个逻辑就乱了。
<<'EOF'的解决方案简单粗暴:只要 delimiter 用单引号包裹('EOF'),shell 就完全禁用所有扩展机制——参数扩展、命令替换、算术扩展、波浪号扩展、反斜杠转义,统统失效。它把 here-document 当作纯文本块,字节级透传。
实测:
$ name="李四" $ cat <<'EOF' 姓名:$name,年龄:$((2024-1990)) 命令:`ls /tmp` 路径:/home/$USER/.config EOF输出:
姓名:$name,年龄:$((2024-1990)) 命令:`ls /tmp` 路径:/home/$USER/.config每一个$、$((、反引号、$USER都原样输出。这才是真正的“所见即所得”。
关键细节:
<<'EOF'中的单引号只作用于 delimiter 本身,不影响 here-document 内容。你不能写<<'END'然后内容里再用'——单引号在 delimiter 上的作用,是向 shell 发送一个信号:“接下来这段,别动它”。
3. 实操场景与配置详解:从脚本编写到 CI/CD 故障排查
3.1 场景一:生成带变量的配置文件(用<<EOF)
这是最常见需求:根据环境变量动态生成nginx.conf或.env文件。
#!/bin/bash APP_ENV=${1:-dev} APP_PORT=${2:-8080} cat > /tmp/app.conf <<EOF # Generated on $(date) server { listen $APP_PORT; server_name ${APP_ENV}.example.com; root /var/www/$APP_ENV; index index.html; } EOF echo "Config written for $APP_ENV environment."这里必须用<<EOF,因为我们需要$APP_PORT、${APP_ENV}、$(date)全部展开。注意${APP_ENV}的花括号不是必须的,但强烈建议加上——避免$APP_ENVIRONMENT这类长变量名被误识别。
实操心得:在生产脚本中,永远用
${var}而非$var。我曾在线上环境因export PATH=$PATH:/new/bin写成export PATH=$PATH:/new/bin(少了个$),导致整个 PATH 被清空,服务起不来。花括号是廉价的保险丝。
3.2 场景二:编写缩进友好的多行 SQL(用<<-EOF)
SQL 脚本常需嵌套在 if/for 循环中,缩进不可避免。用<<EOF会导致 SQL 缩进混乱,用<<'EOF'又无法注入数据库名等变量。
正确解法:组合使用<<-EOF(处理缩进) +${var}(安全变量插入):
DB_NAME="myapp_prod" cat <<-SQL_END CREATE TABLE IF NOT EXISTS ${DB_NAME}.users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); INSERT INTO ${DB_NAME}.users (email) VALUES ('admin@example.com'); SQL_END注意:SQL_END这行必须顶格写,不能有任何空格或 Tab,否则<<-无法识别 delimiter。同时,SQL 内容里的缩进必须用 Tab,不能用空格——这是<<-的硬性要求。
常见陷阱:很多编辑器默认把 Tab 转成 4 个空格(如 VS Code 的
insertSpaces: true)。一旦开启,<<-EOF就失效。解决方法:在脚本头部加set -o vi或直接在编辑器里切换为 “Tab 字符” 模式,并用cat -A script.sh查看实际字符(^I表示 Tab)。
3.3 场景三:生成包含特殊字符的代码模板(用<<'EOF')
假设你要为团队生成一份 Go 语言 HTTP 服务模板,里面包含$符号(如 JSON tag)、反引号(如 raw string)、反斜杠(如路径分隔符):
cat > main.go <<'GO_END' package main import ( "fmt" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { // This is a raw string literal: `path/to/file` fmt.Fprintf(w, "Hello, World! Path: /usr/local/bin\ngot $PATH? %v", r.URL.Path) } func main() { http.HandleFunc("/", handler) fmt.Println("Server starting on :8080") http.ListenAndServe(":8080", nil) } GO_END这里必须用<<'GO_END'。如果去掉单引号,r.URL.Path会被 shell 当作变量尝试展开(为空),$PATH会变成真实路径,\n可能被解释为换行(取决于上下文),整个模板就废了。
实操技巧:当不确定是否要展开时,先用
<<'EOF'写死,再逐步把需要动态的部分改成${var}并移除单引号。宁可多敲几次,也不要赌 shell 的解析逻辑。
3.4 场景四:Jenkins Pipeline 中的坑与填法
Jenkins Pipeline 的sh步骤本质是调用 shell,但它的 here-document 处理有额外约束。常见错误:
// ❌ 错误:Jenkins 会先解析 Groovy 字符串,再交给 shell sh """ cat <<EOF ENV=${params.ENV} VERSION=${params.VERSION} EOF """Groovy 的"""会先做变量插值,${params.ENV}在 Jenkins 层就被替换成值,然后传给 shell。如果params.ENV包含空格或特殊字符,就会 break。
正确写法(双层保护):
// ✅ 正确:用 Groovy 单引号字符串 + shell 单引号 delimiter sh '''cat <<'EOF' ENV=${params.ENV} VERSION=${params.VERSION} EOF'''这里外层'''让 Groovy 不做任何插值,原样传给 shell;内层'EOF'让 shell 不做任何扩展,确保${params.ENV}字面量输出。两道保险,万无一失。
经验总结:在 CI/CD 环境中,永远假设你的字符串会经过 N 层解析(Groovy → shell → command)。每层都加一道引号防护,比事后 debug 三小时强。
4. 深度对比与选型决策表:什么时候该用哪一种?
| 对比维度 | <<EOF(默认) | <<-EOF(剥离 Tab) | <<'EOF'(字面量) |
|---|---|---|---|
| 变量展开 | ✅ 全部展开($var,${var},$(( ))) | ✅ 同默认 | ❌ 完全禁用 |
| 命令替换 | ✅$(cmd),`cmd` | ✅ 同默认 | ❌ 完全禁用 |
| 波浪号扩展 | ✅~→/home/user | ✅ 同默认 | ❌~原样输出 |
| 反斜杠转义 | ⚠️ 部分生效(如\n→ 换行) | ⚠️ 同默认 | ❌\n原样输出(两个字符) |
| 缩进处理 | ❌ 行首空格/Tab 全部保留 | ✅ 仅剥离行首 Tab(需 delimiter 行无空格) | ❌ 行首空格/Tab 全部保留 |
| delimiter 灵活性 | ✅EOF可换为任意字符串(如STOP) | ✅ 同默认 | ✅EOF可换,但必须用单引号包裹 |
| 适用场景 | 动态生成配置、日志模板、带变量的文档 | 缩进良好的 SQL/HTML/JSON 模板(需变量) | 代码模板、正则表达式、含$的文本、CI/CD 安全注入 |
选型决策树(口诀版):
先问:这段文字里有没有
$、反引号、$((这些 shell 元字符?- 有,且你不希望它们被解释→ 选
<<'EOF' - 有,且你希望部分被解释(如只展开
$DB_NAME,但保留$1字面量)→ 用<<EOF+${DB_NAME}显式写法,避免歧义
- 有,且你不希望它们被解释→ 选
再问:这段文字是否写在 if/for 代码块里,需要缩进?
- 是,且缩进用的是Tab→ 优先
<<-EOF(省去手动去空格) - 是,但缩进用的是空格→ 改用
<<EOF,或统一编辑器设为 Tab
- 是,且缩进用的是Tab→ 优先
最后问:delimiter 本身要不要加空格?
<<-EOF要求 delimiter 行绝对顶格,任何空格都会让 shell 找不到结束标记,导致脚本卡死(等待输入)<<'EOF'同理,'EOF'必须紧贴<<,不能有空格:<< 'EOF'是错的,<<'EOF'才对
实操避坑:在脚本中调试 here-document 时,不要只看
cat输出。用set -x开启调试模式,观察 shell 实际传给cat的是什么。你会看到类似cat <<'EOF'的 trace,确认 delimiter 是否被正确识别。
5. 常见问题与排查技巧实录:那些年踩过的坑
5.1 问题一:“脚本卡住了,光标一直闪,不返回”
现象:执行含cat <<EOF的脚本后,终端卡住,键盘输入无响应,Ctrl+C 无效。
原因:delimiter 行写错了。最常见三种:
- delimiter 行有空格:
EOF(前面 4 个空格) - delimiter 行大小写错误:
eof或Eof - delimiter 行有多余字符:
EOF # end(注释不被允许)
排查步骤:
- 用
cat -A script.sh查看隐藏字符(^I是 Tab,$是行尾) - 确认 delimiter 行是否 100% 顶格、全大写、无空格、无注释
- 临时把
cat <<EOF改成cat <<'DEBUG',看是否还卡——如果DEBUG能正常结束,说明原EOF被污染
独家技巧:在 delimiter 前加个唯一字符串,如
MY_EOF_2024,降低撞名概率。cat <<MY_EOF_2024比<<EOF更健壮。
5.2 问题二:“变量没展开,输出的是$NAME而不是张三”
现象:cat <<EOF里写的$NAME,输出却是字面量$NAME。
原因:你误用了<<'EOF'。单引号 delimiter 会关闭所有扩展。
排查方法:
- 检查 delimiter 是否被单引号包裹:
<<'EOF'vs<<EOF - 用
cat -A script.sh看 delimiter 行:<<'EOF'$vs<<EOF$
解决:删掉单引号,或改用${NAME}(即使在<<'EOF'中,${NAME}也是字面量,不会展开)。
5.3 问题三:“SQL 语句里多了 4 个空格,数据库报语法错误”
现象:生成的 SQL 文件每行开头都有空格,CREATE TABLE变成CREATE TABLE,PostgreSQL 拒绝执行。
原因:用了<<EOF但缩进用了空格。<<-EOF只剥 Tab,不剥空格。
排查:
cat -A script.sh查看 SQL 块缩进:如果是 (4 个空格),那就是空格缩进- 检查
<<-EOF的 delimiter 行是否顶格(cat -A应显示<<-EOF$,而非<<-EOF $)
解决:
- 方案 A:编辑器设为 Tab 缩进,重写缩进
- 方案 B:不用
<<-EOF,改用<<EOF,并在 SQL 前加sed 's/^[[:space:]]*//'去空格 - 方案 C(推荐):用
<<'EOF'+printf拼接,完全可控
printf "%s\n" "CREATE TABLE users (id INT);" "INSERT INTO users VALUES (1);" > /tmp/sql.sql5.4 问题四:“Jenkins Pipeline 里变量总是空的”
现象:Pipeline 脚本中sh "cat <<EOF\n\$VAR\nEOF"输出空行。
原因:Groovy 字符串插值与 shell here-document 的双重解析冲突。Groovy 先把\$VAR解析成$VAR,再传给 shell;shell 看到$VAR尝试展开,但 VAR 未定义。
排查:
- 在 Jenkins 控制台日志中找
+ cat <<EOF这行,看实际传入的字符串 - 用
sh 'echo "DEBUG: \$VAR"'确认 Groovy 层是否已转义
解决(三重防护):
sh '''cat <<'EOF' \$VAR # 这里 \$ 是字面量反斜杠 + $,shell 不会解析 EOF'''外层'''(Groovy 不插值)+ 内层'EOF'(shell 不扩展)+\$(显式转义$)——三重保险,百试不爽。
最后分享一个小技巧:在复杂脚本中,把 here-document 提取为函数,用
printf构建,比cat <<更易测试、更少出错。例如:gen_config() { local env="$1" port="$2" printf "ENV=%s\nPORT=%s\n" "$env" "$port" } gen_config "$APP_ENV" "$APP_PORT" > /tmp/config.env函数化后,你可以
gen_config dev 8080 | cat -A直接看输出,无需启动子 shell。
我在实际操作中发现,超过 70% 的 here-document 问题,根源不在语法本身,而在编辑器设置、复制粘贴引入的不可见字符、或 CI/CD 环境的多层解析。把cat -A当成你的第一诊断工具,比翻man bash有效十倍。