1. EOF 到底是什么:从一个小例子说起
我最早接触 EOF,是看同事写初始化脚本时满屏幕的cat << EOF,当时第一反应是“这玩意儿是要读文件直到文件尾吗?”后来才搞清楚,这里的 EOF 根本不是“文件末尾”的意思,它只是一个自定义的分隔符,叫 End of File 只是习惯而已,本质上你可以把它换成任何一串字符。
先看一段最常见的用法:
cat << EOF hello world this is a test EOF执行结果是输出两行字符串。看到这里你可能会觉得:“这不就是 echo 吗?”,别急,这只是它的开胃菜。EOF 真正厉害的地方在于它能把多行内容一次性传给任意命令,比如生成配置文件、拼接 SQL、给 ssh 传一段远端脚本、往文件里批量写入内容,这些都是它最常出现的场景。
适合谁来用?我觉得只要你平时需要在 Linux 终端里处理配置文件、写部署脚本、批量生成文本,那 EOF 就是绕不开的基础技能。它属于那种“看着简单,但用不好全是坑”的语法,尤其是变量展开、引号处理、层级嵌套这几个点,网上能讲透的教程不多,这篇文章就把我这几年的实战经验一次说清楚。
2. 基础语法与设计思路:为什么选 EOF 而不是 echo/printf
2.1 heredoc 的完整格式拆解
在 Linux 中,这种写法有一个正式的名字叫 heredoc,中文社区常翻译成“内嵌文档”或“此处文档”。它的完整格式是这样:
命令 << 分隔符 内容区 分隔符四个部分缺一不可:
- 命令:接收标准输入的命令,常见的有
cat、tee、ssh、python3、mysql等; <<:重定向运算符,表示“把后面这段文本喂给命令的标准输入”;- 分隔符:建议使用大写字母组合,比如
EOF、END、EOF_MARKER,它起一个界标作用; - 内容区:你要传入的实际文本,可以是配置、脚本、数据甚至是代码。
这里有个关键点:<<后面的分隔符在内容区结束后必须原样再出现一次,而且必须顶格写在行首,前面不能有空格,后面除了换行符不能有多余字符。不然 shell 就会一直等输入,直到报错unexpected EOF。
先说一个小类比,方便理解。想象你要寄一个纸箱,箱子里放各种杂物,箱子上贴一张标签写着“完毕”。箱子就是命令cat,杂物就是内容区,标签就是结束分隔符。你把想放的东西放好,贴上标签,这箱货才算完整。<< EOF就是箱子的开口,最后一个EOF就是那张“完毕”标签。
2.2 为什么实战中都爱用 EOF 而不是 echo 拼接
很多人写脚本时喜欢用一连串echo来输出多行内容,比如:
echo "server_name example.com;" > /etc/nginx/conf.d/app.conf echo "listen 80;" >> /etc/nginx/conf.d/app.conf echo "root /var/www/html;" >> /etc/nginx/conf.d/app.conf这种写法有三大痛点:一是引号容易打架,比如内容里既有单引号又有双引号时,echo 命令的转义非常折磨人;二是每行都要写一次命令和重定向,脚本读起来冗长;三是变量混合在引号里时,展开规则复杂,你以为的字符串到最后可能会被解释得面目全非。
而 heredoc 的写法直接把整个文本块当作一个整体处理,不用关心每行的转义,内容和脚本逻辑分离,维护起来一目了然:
cat > /etc/nginx/conf.d/app.conf << EOF server_name example.com; listen 80; root /var/www/html; EOF注意这里的>是覆盖写,如果你写的是cat >>那就是追加写。这个细节经常有人踩坑,覆盖写会把原文件清空,所以重要配置文件建议先备份再操作。
3. 三种分隔符写法:带引号和不带引号的区别是最大的坑
3.1EOF、'EOF'、\EOF分别代表什么
很多教程会告诉你 heredoc 里的变量会展开,但实际情况要分三种写法,这是整个 heredoc 最容易踩坑也是最重要的知识点。
第一种,不写引号:
name="node1" cat << EOF hostname: $name date: $(date +%F) EOF输出结果:
hostname: node1 date: 2025-01-15这里$name和$(date +%F)都被 shell 展开了。如果你希望变量能自动替换成值,用这种写法就行。
第二种,分隔符加单引号:
name="node1" cat << 'EOF' hostname: $name date: $(date +%F) EOF输出结果:
hostname: $name date: $(date +%F)所有内容全部原样输出,$、反引号、反斜杠都不做任何解释。这一种在写脚本模板、SQL 语句、配置文件片段时非常实用,尤其是内容里包含大量$符号时,能帮你避开一堆转义噩梦。
第三种,分隔符前加反斜杠:
cat << \EOF content: $PATH EOF效果和'EOF'完全一样,都是关闭变量展开。区别只是书写习惯,老派运维更喜欢用反斜杠,因为少按一次 Shift,但单引号更直观,可读性更好。我自己一般固定用单引号,团队统一风格,减少沟通成本。
3.2 双引号包裹分隔符是什么效果
除了上面三种,还有一种写法是"EOF",比如:
cat << "EOF" hello $USER EOF双引号包裹分隔符时,效果等于不写引号,也就是说变量仍然会展开。因为 shell 解析<<"EOF"时会把引号去掉,剩下的逻辑和不加引号一致。
很多新手看到这里容易乱,我建议你直接记住一条规律:带单引号或反斜杠的,全部原样输出;不带引号或带双引号的,里面的变量、命令替换、转义都会被 shell 处理。这条规律我写在便利贴上贴在显示器边框上,用了好几年没出过错。
3.3 表格对比三种写法的行为差异
为了方便查阅,我把三种写法的行为整理成一个对照表,建议收藏。
| 写法 | 变量$VAR | 命令替换$(cmd) | 反斜杠\ | 适用场景 |
|---|---|---|---|---|
<< EOF | 展开 | 执行 | 转义 | 需要动态生成内容时 |
<< 'EOF' | 不展开 | 不执行 | 原样保留 | 写配置模板、脚本片段 |
<< \EOF | 不展开 | 不执行 | 原样保留 | 等效于单引号版,看个人习惯 |
<< "EOF" | 展开 | 执行 | 转义 | 等效于不带引号版 |
这里特别提醒一句,如果你写 shell 脚本给别人用,而内容里恰好有$符号(比如 systemd 服务文件里的$MAINPID、awk 命令里的$1),不包裹单引号的话,shell 会先把这些变量展开成空字符串,运行结果就会和你预期差得十万八千里。我之前给一个 systemd 写启动脚本就吃过这个亏,service 文件里的$MAINPID被展开成了空串,服务怎么都启不来。
3.4<<-的写法:专门处理 Tab 缩进
还有一种变体写法<<-EOF,它只在一种场景下有用:内容区里的行首 Tab 会被忽略。注意是 Tab,不是空格。
什么时候会用到?当你把 heredoc 写在函数里时,为了提高可读性,你可能会把内容区缩进几个 Tab:
function write_conf() { cat > /tmp/app.conf <<-EOF server_name example.com; listen 80; EOF }没有<<-的情况下,行首的 Tab 会被原样写入文件,导致配置文件解析失败。加上<<-之后,这些 Tab 会被自动剥离。但有一点要注意:如果缩进用的是空格,<<-是不生效的,这也是很多人用了<<-依然报错的原因。我建议函数内嵌 heredoc 时直接统一用 Tab,所见即所得,不容易出错。
4. 几个高频实战场景:配置文件、ssh 远程脚本、函数嵌套与命令管道
4.1 用 heredoc 批量生成配置文件
最经典的场景是生成 nginx、redis、mysql 的配置文件。生成静态配置时推荐带单引号的写法,避免变量误展开:
cat > /tmp/nginx_demo.conf << 'EOF' server { listen 80; server_name example.com; root /var/www/html; index index.html; location /healthz { return 200 'ok'; } } EOF如果配置文件里需要动态填入当前环境的 IP 或目录路径,那就去掉单引号:
APP_PORT=8080 APP_ROOT=/data/www cat > /tmp/app.conf << EOF server { listen ${APP_PORT}; root ${APP_ROOT}; } EOF注意我用了${APP_PORT}而不是$APP_PORT,这是一种好习惯,它能明确变量的边界,尤其是后面紧跟字母或下划线时,能避免 shell 把变量名读错。比如你想要$PORT_8080这个变量,但实际 shell 会把$PORT_8080整体当作变量名,若变量不存在就替换成空串。
4.2 通过 ssh 执行远端 heredoc 脚本
运维场景里经常需要把一段脚本传到远端机器执行。heredoc 配合 ssh 的典型写法是这样的:
ssh user@192.168.1.10 << 'EOF' echo "remote hostname: $(hostname)" echo "remote user: $(whoami)" uname -a EOF这里结尾的EOF在本地 shell 里作为标准输入传给 ssh,ssh 会把它原样送到远端 shell 里执行。由于分隔符带了单引号,本地的$(hostname)不会被本地 shell 抢先执行,而是传到远端后由远端 shell 执行,输出的自然是远端的机器名。
如果这里你忘了写单引号,本地 shell 就会先执行$(hostname),然后传过去的就是本机的主机名,等远端再执行时已经晚了。这种“本地执行还是远端执行”的混淆,我在排查线上问题时见过不止一次。
再往深一层,如果你要在远端执行一段脚本,而脚本内部本身又有 heredoc,这个时候就要把内层分隔符改名,比如:
ssh user@192.168.1.10 << 'EOFOUT' cat > /tmp/inner.sh << 'EOF' echo "inner: $HOME" EOF bash /tmp/inner.sh EOFOUT外层用EOFOUT,里层用EOF,两个分隔符必须不同,否则 shell 遇到第一个EOF就会以为是外层文本结束了,后面的内容全部错乱。这个嵌套技巧在写自动化部署脚本时几乎必用。
4.3 函数内通过 tee 和 heredoc 写文件
tee在 heredoc 中的价值在于可以同时输出到文件和标准输出,一边写配置一边看回显,用于调试非常方便:
setup_app() { local version="$1" tee /tmp/app_settings.conf << EOF version=${version} path=/opt/app user=$(id -un) EOF }调用setup_app "v2.1"后,终端会重新打印一遍内容,而且文件也写好了。tee默认覆盖写,如果想追加就加-a参数:
tee -a /tmp/app_settings.conf << EOF extra_flag=true EOF这段内容会追加到文件末尾,不会覆盖原有内容。如果你在函数里用 heredoc,建议把结束分隔符顶格,不要缩进,否则 shell 会一直找不到结束标记。如果想缩进美观,就记住前面说的用<<-,这是唯一能解决这种矛盾的原生方案。
4.4 通过管道把 heredoc 内容交给命令行工具
heredoc 不一定要接cat或tee,它本质上就是一段标准输入,所以只要命令能读标准输入就能接过来。比如把一段多行数值传给bc做计算:
bc << EOF 1 + 2 3 * 4 scale=2; 10 / 3 EOF输出结果是 3、12、3.33。又比如传到 Python 里执行多行代码:
python3 << EOF for i in range(3): print(f"line {i}") EOF这种方式在做快速实验时特别顺手,不用专门建文件,直接在终端里就能把多行代码跑完。还有一种技巧是配合mysql命令批量执行 SQL,比如:
mysql -u root -p << EOF USE app_db; SELECT * FROM users WHERE status=1; UPDATE users SET status=2 WHERE id=1; EOF好处是免去每次敲一行 SQL 都要等响应的问题,整个事务在同一个会话里顺序执行,调试效率高很多。
5. 常见错误与排查技巧:我踩过的几个最具迷惑性的坑
5.1unexpected EOF的原因和定位方法
这是遇到最多的报错。很多人一看到unexpected EOF就以为是系统层面的连接断开了,其实在 heredoc 的语境里,它往往只是说“shell 没有等到结束分隔符”。常见的触发原因有四个:
- 结束分隔符没写;
- 结束分隔符行首有空格或 Tab;
- 结束分隔符行尾有隐藏字符,比如 Windows 换行符
\r; - 内容区里的某一行恰好和分隔符同名,shell 把它当成结束标记提前收场了。
排查方法很简单,先看你是不是在交互式 shell 里敲的,如果敲完内容后回车,终端没返回命令提示符,而是继续显示>提示符,说明 shell 还在等结束分隔符。这时候立刻输入分隔符再回车即可。如果是脚本文件,建议用cat -A 脚本名检查行尾是否有^M$这样的符号,^M就是 CRLF 换行符,处理掉即可。
5.2 变量展开“翻车”现场:单引号到底该不该加
我之前帮同事排查过一个很有意思的案例。他写了一段生成环境变量的脚本:
cat > /tmp/set_env.sh << EOF export PATH=$PATH:/opt/bin EOF执行后他一看文件,发现路径被当前 shell 的 PATH 展开成了超长的一串,直接把原来的 PATH 全部复制进去了,根本不是他想要的模板效果。原因就是不写单引号时$PATH被剪贴板式地替换了。他的本意是文件里保留$PATH这个变量名,编译到目标机器上再展开,于是正确的写法应该是:
cat > /tmp/set_env.sh << 'EOF' export PATH=$PATH:/opt/bin EOF这类问题的本质在于“现在展开”和“以后展开”的取舍。我的习惯是:凡是内容里包含$、反引号、\这些特殊字符,一律先上单引号包裹分隔符,只有确认需要立刻动态生成文本时才去掉单引号。
5.3 行尾反斜杠和空格的隐形陷阱
heredoc 内容区里如果出现行尾反斜杠,比如:
cat << EOF hello \ world EOF输出会变成一行hello world,因为反斜杠转义了换行符。有时候你从网页上复制多行文本贴进去,每一行末尾残留空格,这些空格也会原样写入文件。对 shell 脚本来说,行尾空格基本无感,但如果是配置文件的 key-value,这些空格就会成为万恶之源。
建议写完 heredoc 后先执行一遍,用cat或sed -n l检查输出是否符合预期,尤其是不可见字符。对于关键脚本,我会加一段调试开关:
if [[ "$DEBUG" == "1" ]]; then cat -A /tmp/generated_file fi这样能在不打断脚本的情况下看到隐藏字符,排查效率极高。
5.4 结束分隔符和后续命令抢行的问题
再讲一个经常出现的低级错误。有人在脚本里写:
cat > /tmp/test.txt << EOF line1 EOF echo "done"这样写没问题。但如果你写成:
cat > /tmp/test.txt << EOF line1 EOF echo "done"那echo "done"就会被当成内容区的一部分写进文件,因为结束分隔符必须独占一行,其后不能跟任何内容。有的编辑器在自动补全时会把下一个语句拼到同一行,就特别容易触发这个问题。遇到文件内容莫名多出一行命令的情况,十有八九就是外面编辑器的锅。
还有人在 heredoc 结束后马上想做条件判断:
if true; then cat > /tmp/x << EOF content EOF fi注意最后的fi必须另起一行,不能紧跟在EOF后面。这和上一条同理。
5.5 关于docker: unexpected eof的延伸说明
很多人看到热搜里有个词“docker: unexpected eof”,顺手把锅甩给 heredoc,其实这是完全两回事。Docker 客户端报unexpected EOF时,大多是和守护进程通信时连接被掐断,比如镜像拉取到一半断网、磁盘满了、Docker 服务重启了,或者代理层中断了连接。这和 shell 里的 heredoc 报错只是同一个英文短语,内核完全不同。
这里也提醒一句,排查报错时不要光看关键词,先确认报错来源是哪个程序。heredoc 的unexpected EOF出现时,shell 通常会停在某个>提示符;Docker 的报错则会带上docker进程名和 daemon 通信相关的上下文。定位错了方向,花一晚上也查不出问题。
5.6 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
敲完 heredoc 内容后终端卡在> | 结束分隔符没输入或没顶格 | 输入同名的顶格分隔符 |
文件里的$VAR变成空串 | 分隔符没加单引号 | 改用<< 'EOF' |
文件里的$VAR被本机值替换 | 分隔符没加单引号 | 确认要“以后展开”时改用单引号 |
| 内层 heredoc 和外部冲突 | 分隔符同名导致提前闭合 | 换一个不同的分隔符,如EOFOUT |
| 函数里 heredoc 内容带 Tab | <<模式会保留 Tab | 改用<<-配合 Tab 缩进 |
| 结束分隔符后带空格 | 文件内容多出多余内容 | 保证结束符独占一行且无尾随字符 |
| 内容区本来是原始文本却被转义 | 行尾反斜杠触发了续行 | 用<< 'EOF'或删除行尾反斜杠 |
| 脚本在 Windows 上编辑过导致报错 | 换行符为 CRLF | 用sed -i 's/\r$//'转换 |
6. 关于命名规范和脚本安全的个人建议
最后再说几个我养成的习惯,虽然不算语法硬性要求,但在多人协作或上线部署时能省去很多麻烦。
第一,分隔符不要只用EOF一种。一个脚本里如果只有一个 heredoc,用EOF没问题;一旦有嵌套,建议按层级取名,比如EOF_HEAD、EOF_BODY、EOF_TAIL,或者EOFOUT、EOFIN,这样看 log 时能快速定位是哪个段落出问题。我见过所有 heredoc 都用EOF的脚本,改一次配置要找半天。
第二,写脚本前先确认当前 shell 是 bash 还是 sh。虽然 heredoc 是 POSIX 标准支持的,但<<<here string 以及某些数组特性在 sh 下可能不工作。如果你写的脚本用#!/bin/bash开头,那就全程按 bash 的规则来;如果环境里只有sh,就尽量避开那些高级特性,减少兼容性风险。
第三,把 heredoc 当作“模板系统”来用。需要重复生成的配置或代码文件,完全可以用一个带单引号的 heredoc 当模板,再用sed或envsubst做变量替换,产出最终的动态文件。这种“模板 + 替换”的玩法在大规模批量部署时非常好用,比起逐个文件手工改,既快又稳。
第四,生产环境脚本执行前先做语法检查。heredoc 本身不会直接导致语法错误,但它生成的内容如果是要继续执行的脚本,可能会引入脏字符。习惯性在写完脚本后跑一遍bash -n和shellcheck,能在上线前拦截掉很多低级错误。
我自己的流程是:先在终端里手动执行一遍 heredoc,cat看一眼输出,再进到生成文件里确认无误,最后才把这套脚本同步到部署任务里。多花一分钟验证,能避免一次莫名其妙的线上事故。这算是我在这上面付出过不少学费之后,总结出来最实用的一点经验。