在 Linux 下面写多行文本这个需求几乎每天都逃不掉向服务器丢一段 nginx 配置、给 systemd 补一个 unit 文件、在部署脚本里临时生成一个 .env甚至把一个待执行的脚本批量搬进目标机器。前几年我还在用一串 echo 硬拼后来偶然试了一次 cat EOF 的写法才意识到自己之前绕了多大的弯——它把写多行文本从一场体力活变成了一条干净的文本管道。今天聊的正是这个cat 配合 here-document 重定向也就是常说的 cat EOF 技巧。它既能让我在终端直接键入多行内容也能让我在 shell 脚本里把一整块文本干净利落地落盘到文件还能自由决定块内的变量要不要当场展开。对小到一行配置、大到整套自动化流水线它都是值得优先掌握的基础能力。这篇文章不打算对着 man 手册念经。我会直接从 cat、 和 EOF 三者的关系讲起然后区分带引号和不带引号的行为差异再用几个真实场景演示在 sudo、函数、多文件生成时怎么避开那些坑。你如果已经熟练用过 heredoc可以跳过前两节直接看后面如果经常被 bash 提示warning: here-document ... delimited by end-of-file逼疯第四节会给你一套完整的排查路线。1. 拆开“cat EOF”这一步的操作重定向符号、文本块和收尾标记1.1 一次最简单的多行写入到底发生了什么先看最常见的写法cat test.txt EOF 第一行内容 第二行内容 第三行内容 EOF执行完之后test.txt 里面就是那三行内容。如果你在终端里手动敲会看到 bash 在 EOF之后不断显示次级提示符直到你输入单独的一行 EOF 并回车输入立刻结束。我把这一步拆成三块看cat是人肉搬运工。它默认只干一件事把标准输入原样拷贝到标准输出。here-document 给 cat 提供标准输入而 test.txt把标准输出重定向到文件于是 cat 成了一个两端对接的管道。你当然可以换成tee test.txt效果差不多tee 还会把内容同步打到屏幕上但 cat 因为足够单纯反而是这里最顺手的搭档。这是输入重定向运算符更准确的名字叫 here-document。 EOF告诉 shell接下来从下一行开始一直到某个单独成行的 EOF 为止的整块文本都作为前面命令的标准输入。注意接收者不一定是 catwc -l EOF、sort EOF甚至ssh host EOF都是合法的cat 只是最常见的用法而已。EOF这个地方最容易产生误解。它不是一个内置关键字也不是一条命令它只是一个标记delimiter用来告诉 shell“内容从这里开始、到这里结束”。你可以换成任何没有 shell 特殊含义的词比如END、CONF_BLOCK、_EOF_。我写 EOF 只是因为大家都习惯用 end-of-file 的语义顺手而已。所以严格说cat EOF的含义不是“让 cat 读取文件”而是“让 shell 把这段多行文本喂给 cat”。想通这一点后面各种变形都不会跑偏。为了让你感受“标记”这个词的独立性可以试试cat note.txt END_MARKER 同一个标记开头 就要用同一个标记结尾。 END_MARKER甚至换成THE_END、FINISH也一样。只要结束标记是单独一行、行首没有空格、内容跟开头标记完全一致即可。1.2 为什么堆一串 echo 不是好做法新手最常见的替代方案是这样echo server { listen 80; nginx.conf echo server_name example.org; nginx.conf echo } nginx.conf能工作但问题一堆。第一转义地狱。内容一出现双引号、反斜杠、美元符号你就得要么套单引号要么不停处理\写出来的命令又长又容易错。第二难以维护。每次改动都得盯着一大堆和引号稍不留神引号没闭合整条命令就悬在 bash 提示符底下。等你想把它写进部署脚本时排板更是难看得要命。第三它无法自然地表达多行文本。虽然$line1\nline2这类语法也能拼出多行但可读性极差别人一眼看不懂你在生成什么。相比之下here-document 写出来就是扁平的文本块人类怎么读文本它就怎么组织。更重要的是它在进入 cat 之前已经由 shell 解析过了变量展开、命令替换、引号规则全部复用你已有的 shell 知识不用为了“写个多行文本”去学另一套模板语法。用一句话概括当你要处理的是一段文本而不是一行命令的参数时here-document 天生就是对的工具。这也是从琐碎 echo 堆砌里解放出来的第一步。2. 带引号和不带引号的 EOF是两套完全不同的“渲染”语义2.1 不带引号的 EOF内容会在当前 shell 里先被“烤”一遍nameworld cat greeting.txt EOF Hello, $name Today is $(date %F) EOF生成的内容里$name会被展开成 world$(date %F)会被执行并嵌入结果。因为 bash 在读取 here-document 内容时会先对它做参数展开、命令替换和算术展开然后把处理完的结果交给 cat。这个特性在动态配置文件场景里非常有用。假设要根据当前环境生成 application.propertieshostdb-01 secret$(cat /run/secrets/db_pass) cat /etc/app/prod.yml EOF database: host: $host password: $secret url: jdbc:postgresql://$host:5432/app EOF这样得到的文件是渲染后的最终形态后续不需要再二次加工。如果同一个块里希望某些$保留就得用反斜杠转义。一个典型例子是生成一个脚本同时又要保留部分动态值cat /tmp/check.sh EOF #!/bin/bash echo 当前主机是 \$HOSTNAME echo 登录名是 $(whoami) EOF这里的\$HOSTNAME会原样留在脚本里作为运行时变量而$(whoami)会在生成脚本的这一刻就被展开成当前用户。如果你不想让后者提前展开就该用下一节说的带引号形式。2.2 带引号的 EOF不渲染纯搬运把定界符用单引号包起来行为就完全反转cat deploy.sh EOF #!/bin/bash echo Hello, $USER echo Home is $HOME EOF执行后 deploy.sh 的内容是字面上的$USER和$HOME不会在生成时被当前 shell 展开。这里的规则是当 here-document 的定界符被引号包裹时Bash 规定这块内容中不做任何展开原样输出。什么场景必须带引号生成脚本或模板文件因为里面的$、反引号、反斜杠是留给后续运行环境用的生成 SQL、正则、awk 代码、Makefile 这类含大量特殊字符的文本任何你不想在生成时刻被误解释的场景。想象一下如果你用不带引号的 EOF 去生成一个部署脚本而脚本里恰好写了$(pwd)那生成结果就已经是“当前目录”这个静态字符串而不是将来每跑一次都会动态获取目录的命令。这种错误很隐蔽因为它不报错只会让你拿到一堆看似正常、行为却诡异的文件。2.3 判断规则这些特殊字符是给谁用的我把取舍标准总结成一句看特殊字符是给“当前 shell”用的还是给“最终文件的使用者”用的。前者用不带引号的 EOF后者用带引号的 EOF。需求写法示例根据环境变量生成运行时配置不带引号把 host、port 写进 app.yml生成一个独立的 bash 脚本带引号 EOF部署脚本、CI 脚本生成 SQL、正则、awk 代码带引号 EOF避免$被提前解释需要部分展开、部分保留不带引号 \$转义把当前时间写进 header其余保持原样这一节基本覆盖了 here-document 80% 的实际问题。我见过不少同事把脚本写坏几乎都属于“该带引号没带”或“该转义没转”。记住这条判断规则后面看到 sudo、tee 时就不会绕进去。3. 实战场景拆解从普通用户文件到 sudo 权限下的系统文件3.1 用同一个脚本生成多份配置文件在 CI 或初始化脚本里经常要一次性吐出好几份配置。比如 nginx site、systemd unit、docker-compose.yml一次部署全都要。cat /tmp/nginx.conf EOF server { listen 80; server_name ${DOMAIN}; root /var/www/html; } EOF cat /tmp/myapp.service EOF [Unit] DescriptionMy App Afternetwork.target [Service] ExecStart/opt/myapp/run.sh Restartalways EOF这里我用带引号的EOF原因很直接nginx 配置里的${DOMAIN}是要留给环境变量系统替换的不是让当前 shell 展开的。如果我不小心漏掉引号生成出来的文件里就是一个空变量展开后的“残骸”到时候排查起来会不知从何下手。如果配置值来自当前执行的上下文就改回不带引号domainexample.org port8080 cat /tmp/nginx.conf EOF server { listen $port; server_name $domain; root /var/www/html; } EOF这种“一个脚本生成整个服务目录”的模式在预置环境、docker 镜像构建里极其常见。它的价值不是省几行 echo而是整个系统服务的配置内容都集中在脚本里评审、diff、权限控制都能看到全貌。3.2 需要 sudo 时怎么处理重定向别把文件打开这一步交给错误的进程这是 here-document 里最容易被新手误解的一块。比如要改/etc/sysctl.conf很多人会写成sudo cat /etc/sysctl.conf EOF net.ipv4.tcp_syncookies 1 EOF看起来没毛病实际上这条命令会失败。原因是shell 在构造命令的时候先处理 /etc/sysctl.conf这个重定向而这个文件是在当前 shell 进程里打开的当前进程不是 root当然没有权限。等sudo cat真正运行文件早就因为权限被拒而打不开了。正确的做法是把“写文件”这件事交给你提权后的进程来干。最常用的解决方案是用 teesudo tee /etc/sysctl.conf /dev/null EOF net.ipv4.tcp_syncookies 1 net.ipv4.tcp_tw_reuse 1 EOF这里sudo提权的是teetee 以 root 身份打开并写入目标文件而 here-document 只是向它提供标准输入数据管道畅通无阻。追加多行配置也是一样sudo tee -a /etc/ssh/sshd_config /dev/null EOF Match User deploy PasswordAuthentication no EOF如果你实在不想引入 tee还可以让 sudo 直接启动一个 bash 子进程来承担重定向sudo bash -c cat /etc/sysctl.conf EOF net.ipv4.tcp_syncookies 1 EOF两种都能用我更推荐sudo tee。它更简洁也更容易让别人一眼看懂你的意图我要在 root 权限下覆盖/追加这个文件。3.3 函数内部的 heredoc结束标记不能缩进但内容可以在 shell 脚本里函数中写 here-document 是很自然的场景install_app() { echo 开始安装... cat /tmp/app.cfg EOF serverlocalhost port8080 EOF echo 安装完成 }上面这个写法本身没错但有个细节很容易踩结束标记EOF必须顶格写在行首不能有空格缩进。也就是说函数体里的其他行可以缩进但EOF这一行不行否则 shell 不认。这种“内容行可以缩进结束行必须顶格”的割裂感在长函数里尤其让人难受。如果你实在想整段一起缩进可以用-运算符install_app() { cat /tmp/app.cfg -EOF serverlocalhost port8080 EOF }-的作用是忽略每行开头的所有制表符Tab注意是 Tab 不是空格。如果你的代码风格是全空格缩进那-帮不了你结束标记依然无法匹配。所以这里有个现实教训要么用-同时把内容行和结束行都用 Tab 缩进要么就一直让 EOF 顶格。我自己的习惯是后者正文随便缩进结束标记永远放在第 0 列。看着有点怪但省心也经得起各种编辑器循环。3.4 一处生成多个文件把配置块当“文本积木”排布很多自动化任务不是只产一个文件。把多个 heredoc 连接起来就像搭积木cat /tmp/init.sql EOF CREATE DATABASE mydb; USE mydb; EOF cat /tmp/seed.sql EOF INSERT INTO users(name) VALUES (alice); INSERT INTO users(name) VALUES (bob); EOF cat /tmp/cleanup.sql EOF DROP TABLE IF EXISTS temp; EOF这比每个文件都写一条独立命令直观得多。最舒服的地方在于你处理的是“一块块扁平的文本”而不是一行行拼起来的命令”修改、评审、版本化都容易。我经常在这种结构里穿插变量渲染生成出来的全套配置依然保持可读性因为它们本质上就是“带占位符的模板”和“渲染后的成品”之间的切换。4. 最容易踩的坑与排查方法CRLF、隐藏空格、结束标记冲突4.1 从 Windows 带过来的 CRLF怎么把 EOF 变成 EOF\r如果你是 Windows 用户或者在 Windows 编辑器里改过脚本再把文件搬到 Linux 跑here-document 很可能会神秘失败。原因是 Windows 文本文件默认用\r\nCRLF行尾而 Linux 只认\n。于是原本应该是EOF实际文件里是EOF\r那个\r不可见。shell 匹配结束标记时拿到的是EOF\r而不是EOF于是它认为“结束标记还没出现”一直读到文件末尾最终报出bash: warning: here-document at line 5 delimited by end-of-file (wanted EOF)修复方式很简单先把文件转成 LFsed -i s/\r$// myscript.sh # 或者直接 dos2unix myscript.sh粘贴进来的代码也可能带\r尤其是从网页或聊天软件复制时。如果你的 heredoc 卡住了先用cat -A看一眼末尾几行有没有^M$cat -A myscript.sh | tail -5有^M$就说明存在 CRLF先转格式再查逻辑。这是我从踩坑中总结出的第一步永远优先排查。4.2 行尾空格和行首空格两个“隐形凶手”比 CRLF 更隐蔽的是行尾空格。在终端里EOF和EOF后面多一个空格肉眼看毫无区别但 shell 会把它们当成完全不同的标记。你明明打了 EOF文件却因为编辑器或复制操作多出一个不可见空格整个 here-document 就陷入“无限等待输入”的僵局。排查经验先用cat -A看最后几行行尾空格会显示在$前面。在编辑器里打开开启“显示空白字符”把结束标记那行末尾清干净。如果整份文件是从别处复制来的最保险的办法是sed -i s/[[:space:]]\$// script.sh把行尾所有空白一次性去除。另一种就是行首多余的空格。结束标记必须在行首没有任何字符除非用-并配合 Tab。如果你的脚本整体有缩进而 EOF 被顺手缩进了就会撞上同一个 warning。用-可以把 Tab 缩进的结束标记解放出来但空格缩进不行。这两种问题视觉上几乎等于零却能让一个脚本直接卡死。我见过 CI 任务因为一个粘贴出来的空格停了半小时所以现在凡是带 heredoc 的脚本我都会cat -A检查几遍再往下跑。4.3 那个经典 warning 到底在说什么把警告拆开看bash: warning: here-document at line 8 delimited by end-of-file (wanted EOF)意思是第 8 行开始的 here-document预期以 EOF 结束但代码在文件结束前都没找到这个标记。常见原因就是第 4.1、4.2 节说的那几类结束标记被缩进行首有空格结束标记后面有不可见空格或者\r你压根忘了写结束标记开始标记和结束标记用词不一致比如开头写EOF收尾写成了end。如果在交互式终端症状是敲完内容后按回车bash 还继续显示次级提示符怎么都结束不了。这时如果是因为结束标记匹配不上你敲下去的 EOF 也不会被接受只会一直等。最稳的操作是先按 Ctrl-C 跳出再用cat -A搜一下那几行到底藏了什么。这里有个让我印象很深的教训有一次脚本里包含从别处复制来的 CRLF我在终端里里敲的 EOF 明明是干净的但脚本文件里存的却是带\r的标记。重启了好几轮才意识到——问题从来不在你肉眼看到的那行而在文件字节里存的那行。4.4 内容里恰好出现 EOF 怎么办换标记名与嵌套 heredoc如果文本本身包含“EOF”字样继续用 EOF 作标记就会提前断开。解决起来很简单换一个不冲突的标记名。cat readme.txt DOC_END 这是一段说明。 如果你看到 EOF 字样它是内容而不是结束标记。 DOC_END更复杂一点的情况是“在 heredoc 里再生成一个 heredoc”。比如生成一个部署脚本而脚本自己又要用 here-document 去写 .env 文件cat job.sh OUTER_EOF #!/bin/bash cat .env INNER_EOF USER${DEPLOY_USER} PASS${DEPLOY_PASS} INNER_EOF OUTER_EOF这里外层用OUTER_EOF内层用INNER_EOF两者互不干扰。注意外层必须带引号否则内层内容里的${DEPLOY_USER}和${DEPLOY_PASS}会被当前 shell 展开掉那不是我们想要的结果。这个嵌套 heredoc 技巧在 CI 里生成部署脚本时非常常用我至今还在大量依赖。5. 更自由的变形动态内容注入、变量承载与我的使用习惯5.1 利用 bash 先渲染的特性生成动态配置不带引号的 EOF 天然适合注入实时值。比如发布脚本里我想把当次构建的信息直接写进服务的配置文件version$(git rev-parse --short HEAD) branch$(git branch --show-current) build_time$(date -u %Y-%m-%dT%H:%M:%SZ) cat /etc/app/build_info.txt EOF version$version branch$branch build_time$build_time EOF这样的文件生成出来每一行都是“最终状态”后续不用再加工。它和“拿模板再替换”的方案相比少了一个中间步骤直接从 shell 现有的变量系统里取数。当你需要在同一个块里混合“要展开的变量”和“留给模板引擎的变量”时可以用\$转义来精确控制cat /tmp/app.tpl EOF server_name $SITE_NAME; location / { proxy_pass http://\${UPSTREAM}:8080; } EOF$SITE_NAME在生成时展开\${UPSTREAM}原样保留。生成完之后再交给 envsubst 或 nginx 变量系统继续处理灵活度非常高。5.2 不只是写文件heredoc 还能把多行文本塞进变量here-document 的另一个用途是把内容存进变量当作一个“多行字符串容器”block$(cat EOF Hello, This is a multi-line string. EOF ) echo $block这个技巧的价值在于你可以优雅地定义一个包含大量空格和特殊字符的多行字符串而不需要把它包进一串难看的引号里。比如构造一段要传给远程主机的命令remote_script$(cat SCRIPT_END #!/bin/bash echo Current user: $USER df -h SCRIPT_END ) ssh userhost bash -s $remote_script带引号的原因很关键我们希望$USER和df -h这些内容原样带到远端在远端展开而不是在本地就被替换掉。用这种方式组织跨主机脚本比一遍遍手动传参要干净许多。5.3 组织多个 heredoc 时的风格规则写久了之后我给自己定了一套简单的风格规则默认带引号。只要不是明确需要变量展开我一律写EOF。它是最安全的选择避免内容里几百个$被灾难性展开。结束标记要有辨识度。生成 nginx 段用NGX_EOF生成 systemd 段用SVC_EOF生成内嵌脚本用SCRIPT_END。文件很长时眼睛也能快速定位是哪个区块在收尾。EOF 永远顶格。结束标记放在行首不用空格缩进除非- Tab 配合。一个块只干一件事。每个 here-document 块负责一个文件的完整生成不要混入太多边角操作否则排错时光是找结束标记就要花半天。长脚本里最容易犯的错是复制粘贴时把两个结束标记弄乱了位置。比如复制了一个 heredoc 块改开头标记时却漏改了收尾那一行。这会让 bash 在文件结束前都找不到匹配标记报出的 warning 和 4.3 节完全一样。所以我写完脚本会先跑一遍语法检查再执行bash -n myscript.sh这个习惯能救很多时间强烈养成。5.4 最后一些来自实战的收尾建议把这么多条经验浓缩成一句大约就是熟悉“结束标记必须在行首”和“带引号 vs 不带引号”这两个核心点cat EOF 就没有秘密了。说两个身边的真实教训。一个同事想把$(hostname)写进 /etc/motd但用了不带引号的 EOF。结果生成出来的文件是“这台机器名”的静态文本而不是“每次登录时取机器名”的动态命令。刚开始看好像没问题多台机器共用同一份构建时才发现所有机器的 motd 都显示成构建那一刻的机器名哭笑不得。这种错误特别隐晦因为它不报错。另一个更常见的是 CI pipeline 里用 heredoc 生成 Kubernetes yaml。yaml 里如果包含${VAR}一不小心就会被当前 shell 展开掉。我的建议是分开写需要渲染的变量用不带引号的块需要字面保留的部分用带引号的块清晰隔离最终文件的语义完全可控。我更推荐两个极简的验证手段cat generated.txt md5sum generated.txt前者看内容是否符合预期后者确认它在 CI 流水线中是否可复现。如果要在多个环境复用同一组配置给生成文件加个 hash 名称往往是最干净的编排方式。真正的高手未必会写复杂的 bash但他们一定会在该用 heredoc 的地方把 cat EOF 用对。希望你看完这篇也能在批量生成配置、写部署脚本时从“echo 一行的堆砌”里跳出来用一块干净的 heredoc 就把事情办妥。