Linux 脚本报 bad interpreter:用 Python 定位并修复 CRLF 与 BOM
把脚本从 Windows 复制到 Linux 后,文件明明存在,运行却报 bad interpreter、No such file or directory,或者 /usr/bin/env: 'python3\r': No such file or directory。问题可能藏在第一行的不可见字节里,反复执行 chmod +x 并不能改掉它们。
本文面向初学者到中级读者,用 Python 标准库复现 CRLF 换行和 UTF-8 BOM 引起的启动失败,再检查字节、生成新的 LF 副本并验证执行。适用于自己维护的 shell、Python 自动化入口,以及 Hugo 构建脚本;整个实验只操作新建的演示目录。
1. 方法:先区分启动失败和程序失败
Linux 直接执行脚本时,需要文件从 #! 开始。根据 Linux 内核的脚本加载代码,第一行以 LF 分隔,末尾只剔除空格和制表符。由此可知:#!/bin/sh\r\n 中的 \r 会进入解释器路径,而放在 #! 前面的 BOM 会让脚本格式识别失败。
| 第一行字节 | 可能表现 | 下一步 |
|---|---|---|
#!/bin/sh\r\n |
文件存在,却提示找不到文件或解释器 | 检查 CRLF,另存 LF 副本 |
ef bb bf 后才出现 #! |
Exec format error,或 shell 回退后的异常 |
检查并去除开头的 UTF-8 BOM |
正常 #!,解释器路径确实不存在 |
同样可能提示找不到文件 | 修正解释器路径或项目环境 |
正常字节,但 Permission denied |
权限或挂载限制 | 核对执行权限、父目录和挂载选项 |
这些报错不是一一对应关系;终端 shell 可能回退到自行解释文件,所以显示不同。下面通过 Python 直接启动文件,避免 shell 回退掩盖原始执行错误。若脚本在终端正常、定时任务中失败,继续检查 Python 定时任务的路径和环境。
2. 准备环境和测试文件
需要 Linux、Python 3.9 或更新版本、/bin/sh 和 chmod。没有第三方依赖,也不需要安装 dos2unix。示例已在 Linux、Python 3.11.4 上测试。
- 在 bash 或 zsh 中新建目录,重复实验时换一个目录名。后续步骤都留在这个目录中:
python3 --version
mkdir script-bytes-demo
cd script-bytes-demo
python3 - <<'PY_DATA'
import codecs
from pathlib import Path
script = b"#!/bin/sh\nprintf 'job ok\\n'\n"
Path("good.sh").write_bytes(script)
Path("bad-crlf.sh").write_bytes(script.replace(b"\n", b"\r\n"))
Path("bad-bom.sh").write_bytes(codecs.BOM_UTF8 + script)
Path("bare-cr.sh").write_bytes(b"#!/bin/sh\rprintf 'stop'\r")
for name in ("good.sh", "bad-crlf.sh", "bad-bom.sh"):
Path(name).chmod(0o700)
PY_DATA
good.sh使用 LF,bad-crlf.sh使用 CRLF,bad-bom.sh在文件开头添加 UTF-8 BOM;bare-cr.sh只有孤立 CR,用来验证拒绝自动转换的分支。生成的脚本只输出一行文字。
3. 用原始字节定位问题
运行下面的检查。repr() 会把隐藏的 \r 和 BOM 显示出来:
python3 - <<'PY_DIAG'
from pathlib import Path
import subprocess
for name in ("good.sh", "bad-crlf.sh", "bad-bom.sh"):
raw = Path(name).read_bytes()
print(name, "first_line=", repr(raw.split(b"\n", 1)[0]), flush=True)
try:
result = subprocess.run(["./" + name], capture_output=True, check=False)
except OSError as exc:
print("exec failed:", exc.errno, exc.strerror)
else:
print("exit=", result.returncode, "stdout=", repr(result.stdout))
PY_DIAG
本机输出如下;系统语言不同会改变错误文字,错误号仍有助于判断:
good.sh first_line= b'#!/bin/sh'
exit= 0 stdout= b'job ok\n'
bad-crlf.sh first_line= b'#!/bin/sh\r'
exec failed: 2 No such file or directory
bad-bom.sh first_line= b'\xef\xbb\xbf#!/bin/sh'
exec failed: 8 Exec format error
这里的 2 和 8 是 OSError.errno,不是子进程退出码:启动失败时根本没有子进程结果。不要用 read_text().splitlines() 做这一步;文本读取可能转换换行,隐藏需要排查的原始 CRLF。Python 的二进制读取接口保留文件字节,文本模式的换行转换规则见 open() 文档。
4. 完整实现:检查或另存 LF 副本
将以下代码保存为当前目录的 normalize_script.py。保存这个工具自身时也选择 UTF-8 编码、LF 换行:
"""Check UTF-8 script line endings, or write a new LF copy. Python 3.9+."""
import argparse
import codecs
from pathlib import Path
import sys
MAX_BYTES = 1024 * 1024
def inspect_script(path):
if not path.is_file():
raise ValueError("input must be a regular file")
with path.open("rb") as source:
raw = source.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
raise ValueError("input exceeds 1 MiB")
bom = raw.startswith(codecs.BOM_UTF8)
body = raw[len(codecs.BOM_UTF8):] if bom else raw
body.decode("utf-8", errors="strict")
if b"\x00" in body:
raise ValueError("NUL byte: expected a UTF-8 text script")
if not body.startswith(b"#!"):
raise ValueError("expected #! at the start after an optional UTF-8 BOM")
crlf = body.count(b"\r\n")
bare_cr = body.count(b"\r") - crlf
return body, bom, crlf, bare_cr
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("source", type=Path)
parser.add_argument("--output", type=Path,
help="create a new LF copy; never overwrite a path")
args = parser.parse_args()
try:
body, bom, crlf, bare_cr = inspect_script(args.source)
print(f"bom={int(bom)} crlf={crlf} bare_cr={bare_cr}")
if args.output is None:
return 1 if bom or crlf or bare_cr else 0
if bare_cr:
raise ValueError("bare CR found; inspect the source manually")
cleaned = body.replace(b"\r\n", b"\n")
with args.output.open("xb") as target:
target.write(cleaned)
print(f"WROTE: {args.output}")
return 0
except (OSError, ValueError, UnicodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
工具最多读取 1 MiB,要求输入是 UTF-8 文本,且去掉可选 BOM 后立即出现 #!。它只识别这类小型脚本,不检测其他编码,也不检查解释器是否存在。输入符号链接会跟随到目标;输出使用二进制独占创建模式 xb,因此已有文件和链接都不会被覆盖。
检查模式不写文件,发现 BOM、CRLF 或孤立 CR 时返回 1。传入 --output 后,工具去掉开头的 UTF-8 BOM,把 CRLF 改成 LF;孤立 CR 会被拒绝,避免直接猜测其含义。Python 编码文档说明了 UTF-8 BOM 的位置与含义;本例直接处理其字节,不做任意编码转换。
5. 使用:先检查,再验证副本
- 显示帮助并修复 CRLF 演示文件:
python3 normalize_script.py --help
if python3 normalize_script.py bad-crlf.sh; then
printf 'Already clean\n'
else
printf 'Check reported a problem; inspect before repairing\n'
fi
python3 normalize_script.py bad-crlf.sh --output repaired.sh
python3 normalize_script.py repaired.sh
/bin/sh -n repaired.sh
chmod u+x repaired.sh
./repaired.sh
检查原文件会输出 bom=0 crlf=2 bare_cr=0,返回 1;成功生成副本时返回 0。再次检查副本应输出 bom=0 crlf=0 bare_cr=0,最后执行输出 job ok。参数错误返回 2,输入、解码或写入失败返回 1;检查结果可能已写到标准输出,必须同时判断退出码和标准错误。
- 对 BOM 文件采用相同步骤:
python3 normalize_script.py bad-bom.sh --output repaired-bom.sh
python3 normalize_script.py repaired-bom.sh
/bin/sh -n repaired-bom.sh
/bin/sh repaired-bom.sh
新文件不复制原文件的执行权限,确认内容和语法后再显式 chmod u+x。/bin/sh -n 只检查语法,不能证明脚本运行正确;上面的执行步骤仅针对已知的演示脚本。修复生产脚本后应先审阅差异,再按项目流程验证,不要为了诊断直接执行有副作用的任务。
CRLF 转 LF 也会改变多行字符串或 here-document 中的真实 CR 字节。只有确认它们代表行尾时才使用此工具;若这些字节是有意保存的数据,应人工处理。写入中断或磁盘错误可能留下不完整的新副本,保留原件并换新输出名称重试。
6. 验证失败分支和原文件保留
在同一个演示目录运行下面的自检。它另外创建 fixed-crlf.sh 和 fixed-bom.sh;重复运行前使用一个全新的演示目录:
python3 - <<'PY_CHECK'
import codecs
from pathlib import Path
import subprocess
import sys
def run(*arguments):
return subprocess.run(
[sys.executable, "normalize_script.py", *arguments],
capture_output=True, text=True, check=False,
)
assert run("--help").returncode == 0
assert run("good.sh").returncode == 0
for name in ("bad-crlf.sh", "bad-bom.sh", "bare-cr.sh"):
result = run(name)
assert result.returncode == 1, result
for source, output in (("bad-crlf.sh", "fixed-crlf.sh"),
("bad-bom.sh", "fixed-bom.sh")):
before = Path(source).read_bytes()
result = run(source, "--output", output)
assert result.returncode == 0, result
assert Path(source).read_bytes() == before
cleaned = Path(output).read_bytes()
assert cleaned == Path("good.sh").read_bytes()
assert not cleaned.startswith(codecs.BOM_UTF8) and b"\r" not in cleaned
result = run(source, "--output", output)
assert result.returncode == 1, result
assert Path(output).read_bytes() == cleaned
result = subprocess.run(["/bin/sh", output], capture_output=True, check=False)
assert result.returncode == 0 and result.stdout == b"job ok\n", result
assert run("bare-cr.sh", "--output", "refused.sh").returncode == 1
assert not Path("refused.sh").exists()
assert run("missing.sh").returncode == 1
assert run(".").returncode == 1
assert run("good.sh", "--unknown").returncode == 2
print("OK: detection, repair, unchanged sources, no overwrite, errors")
PY_CHECK
预期最后输出:
OK: detection, repair, unchanged sources, no overwrite, errors
测试同时验证检测结果、修复后的精确字节、原文件未变、重复输出被拒绝、缺失输入和目录输入报错,以及参数错误返回 2。不要给检查命令统一加 || true,否则真实失败会被隐藏。若后续要通过 Python 调用业务脚本,参见 外部命令超时与错误处理。
7. 排错与预防再次出现
| 现象 | 具体处理 |
|---|---|
expected #! |
检查是否有空行、空格或错误文件类型;将正确 shebang 放在文件最开头。 |
| UTF-8 解码失败或发现 NUL | 确认来源编码;从编辑器另存 UTF-8 文本,不要用忽略错误的解码方式。 |
bare CR found |
检查孤立 CR 是否属于旧式行尾或嵌入数据,人工确认后再转换。 |
File exists |
选择新的输出名称,保留已有文件供比较。 |
| 转换后仍提示解释器不存在 | 查看第一行路径;对于 #!/usr/bin/env python3,在实际运行环境中执行 command -v python3。 |
转换后仍是 Permission denied |
核对文件执行权限、目录访问权限和 noexec 挂载限制,不要递归改成 777。 |
- 编辑器保存脚本时选择 UTF-8(无 BOM)和 LF。
- 对使用 Git 的项目,审阅并合并以下规则到项目已有的
.gitattributes;不要覆盖现有规则:
*.sh text eol=lf
*.py text eol=lf
Git 官方文档解释了 text 的换行规范化和 eol=lf 的检出行为。这些规则不会删除 BOM,也不会立刻改写当前工作目录中的旧文件。先修复和审阅受影响脚本,再确认某个实际路径采用了哪些属性:
git check-attr text eol -- scripts/job.sh
路径替换成自己仓库中的脚本即可;本例不要求暂存、提交或修改全局 Git 配置。Hugo 构建入口修好后,还应验证构建产物的链接和图片,见 Python 检查 Hugo 站内链接。
8. 小结
脚本文件存在却无法启动时,先查看 shebang 的原始字节,再区分 CRLF、BOM、解释器路径和权限问题。用新副本完成转换并验证精确内容,保留原件便于回查。最后固定编辑器的 UTF-8 无 BOM、LF 设置,减少同类启动故障。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/linux-script-crlf-bom-python.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。