同一个文件为什么哈希值不一样?
你给一个文件算了哈希,别人给一个他确信是同一个文件的副本算了哈希,两个摘要却对不上。哈希是计算机领域中最具确定性的机制之一,所以值会变,只可能是字节变了——悄无声息、看不见。本文逐一排查这些常见原因:字符编码、BOM、行尾、末尾换行、文本与二进制模式、结构化数据、重新打包的压缩包,并给出让两边重新对上的归一化做法。
你给一个文件算了哈希。同事也对他确信是同一个文件的副本计算了哈希。两个摘要对不上。又或者,你今天算了一次,让它在构建服务器上转了一圈再算一次,值就变了。什么都没改。于是第一个念头最吓人:哈希函数是不是不稳?
不是。哈希是计算机领域中最可靠的机制之一。值变了,是因为字节变了——悄无声息地,以你没看见的方式。本文讲的,就是「同一个文件」不再是同一串字节的种种方式,以及怎样让它们重新对上。
一个核心:哈希是确定性的
密码学哈希是一个只取决于你喂进去的确切字节的纯函数。同样的字节进去,同样的摘要出来——在每台机器上、每种语言里、每一次运行、永远如此。没有随机数,没有时钟,没有机器专属的种子。一段给定字节序列的 SHA-256,是宇宙间的一个常数。
下面的一切都建立在一个前提上:两边跑的是同一个算法,而且你比的是摘要本身——SHA-256 对 SHA-256,不是 SHA-256 对 MD5,也不是 hex 对 base64。在这个前提下,推论毫不留情。两个哈希不同,就说明两串字节不同。到此为止。根本不存在「同一个文件却哈希不同」这回事。你手里的,是两串不同的字节,而你以为它们是同一个文件。说谎的是「同一个」这三个字,不是哈希。
所以问题从来不是「哈希为什么变了」。而是「在我没注意的时候,是什么改了字节」。本文余下的部分,就是逐一排查这些常见原因——它们大多在你用编辑器打开文件时是看不见的。
后半句值得多说一句,因为它最容易坑人。先确认变的是摘要本身,而不只是它印出来的样子。AB… 和 ab… 是同一个值的两种大小写,同一个摘要也可以用 hex 或 base64 两种写法呈现。如果你碰到的是这种情况,那字节其实一模一样、没有任何问题——这个坑和它的同类,都在校验和为什么对不上里。从这里起,我们假定算法相同、摘要是真的不一样。
三十秒,亲眼看它发生
在分类之前,先直接看效果。同样两行文本,屏幕上完全一样,先是 Unix 行尾,再是 Windows 行尾:
printf 'a\nb\n' | sha256sum # 4 字节
# 911169ddaaf146aff539f58c26c489af3b892dff0fe283c1c264c65ae5aa59a2
printf 'a\r\nb\r\n' | sha256sum # 6 字节
# 58055bdcc73787eb88c78d36f0b4939e9c5dc1c3ad17e25cc85a6833cf1a0cab
两个你看不见的多余字节,摘要就面目全非。在 hello 前面加一个字节序标记,同样如此:
printf 'hello' | sha256sum
# 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
printf '\xef\xbb\xbfhello' | sha256sum
# 7489ebbcc2a00056ddaaaac190bce473e5c03696ea1bd8ed83cf59a174283862
自己跑一遍。当你亲眼看见三个看不见的字节改写了整个摘要,本文余下的内容就不再让人意外了。
先查:你算的,真是你以为的那串字节吗?
在怀疑任何微妙的原因之前,先排掉最直白的那种——你算的根本不是你想的那个文件。
- 拿错文件,名字却对。另一个构建、另一个版本、另一个文件夹里的一份副本。文件名会骗人;核对完整路径和大小。
- 文件当时还在写。你在下载或导出到一半时就去算了,随后又写入了更多字节。
- 是符号链接或快捷方式,不是目标。你算的是链接本身,或者在另一台机器上解析到了另一个文件的链接。
- 是目录,不是文件。「一个文件夹的哈希」完全取决于工具:它收进哪些文件、按什么顺序、要不要把元数据也算进去。同一个「目录」,两个工具给你两个答案。
先确认你指向的是完全相同的那个对象。很多时候光看大小就够了:大小不同就是字节不同,你可以不用再找什么复杂的原因了。
文本:「同一段文本」是许多串不同的字节
大多数这类谜团都出在这里。在编辑器里打开两个文件,它们逐字逐句看上去一模一样。但哈希读的是字节,不是字符——而同样的字符,可以被存成很不一样的字节。
字符编码。字母 A 在 UTF-8 里是一个字节,在 UTF-16 里是两个。带重音的和非拉丁文字,差得更多。一个从 UTF-8 另存为 UTF-16、或另存为 Windows-1252、Shift-JIS 这类老式代码页的文件,可见文本一样,字节序列却不同。file 命令通常能告诉你每一边是什么编码。
字节序标记(BOM)。有些编辑器和导出——经典的 Windows 记事本、Excel 的 CSV 导出——会在文件开头写入一个三字节的 UTF-8 BOM(EF BB BF)。它在屏幕上看不见。它改变每一个哈希。一边有、一边没有,摘要就永远对不上。
行尾。跨平台里最常见的原因之一。Unix 用 LF(\n)结束一行;Windows 用 CRLF(\r\n)。在 Windows 上每一行都多一个字节,所以一个 100 行的文件会差出 100 个字节,而肉眼一处也看不出来。Git 让这事成了家常便饭:开着 core.autocrlf 时,它在检出和提交时改写行尾,于是同一份文件在 Windows 克隆和 Linux 克隆之间,工作区里真的就不一样。
末尾换行。文件末尾要不要有一个换行,编辑器和工具各执一词。echo "x" 会加一个,printf x 不加:
printf x | sha256sum # 一个字节
printf 'x\n' | sha256sum # 两个字节——一个完全不同的摘要
末尾多一个 \n 就是多一个字节,而对 SHA-256 这类密码学哈希来说,雪崩效应会把整个摘要变得面目全非。
文本模式与二进制模式。这个更隐蔽,也更容易漏,因为磁盘上的文件自始至终没变。有些哈希工具可以按「文本模式」读文件,并在读取的过程中转换行尾,于是送进哈希的字节,已经不是磁盘上的字节了。GNU 的 md5sum 和 sha256sum 提供 -b(二进制)与 -t(文本):在 Unix 上两者行为一致,但在 Windows 移植版上文本模式是真的会生效,会悄悄把 CRLF 归一化掉。语言 API 里也有同样的坑:用文本模式而不是二进制模式打开文件,再把读到的内容拿去哈希,喂给哈希的就是一份经过转换和重新编码的流。做哈希时,一律用二进制模式打开。
看不见的字符与规范化。行尾的空格。被替换成空格的制表符。从网页粘来的一个零宽字符或不间断空格。还有个微妙的——Unicode 规范化。字符 é 可以是一个码点(U+00E9),也可以是一个 e 后面跟一个组合重音(U+0301)。它们渲染出来一模一样,对人来说是「同一个」,但它们是不同的字节。
这里得划一条边界:普通的文件哈希只计算文件内容,文件名并不参与其中。所以重命名一个文件,不会改变它的内容哈希,文件名的规范化同样不会。只有当名字本身就是输入时,规范化才会影响结果——比如你在哈希一个路径字符串、一份目录清单、一张文件名列表,或者归档里的元数据。这一点值得知道,因为某些 macOS 文件系统和工具会对文件名做 Unicode 规范化,而 Linux 通常保留原始字节;于是即便每个文件的内容都一致,跨系统的路径或文件名清单也可能对不上。
结构化数据:含义相同,字节不同
会「看着稳定、其实不稳」的不只是文本文件。当你哈希一份 JSON,你哈希的是它某一种序列化形式,而不是那份数据本身。任何一次重新序列化——换个库、换个语言、在数据库里走一圈——都可能产出含义完全一致、字节却不同的结果:
printf '{"a":1,"b":2}' | sha256sum # 43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777
printf '{"b":2,"a":1}' | sha256sum # 3fb75453225c732a76b7899ea2096dda1455189c89817239732182f73fe5a09f
printf '{"a": 1, "b": 2}' | sha256sum # d8497d9d82770a70729261095aa98f7ef5154d7af499f8037b6ca250296785a6
三个摘要,同一份数据。第二个只是换了键的顺序,第三个只是多了空格。别的序列化器还会有更多变数:把非 ASCII 转义成 \uXXXX 还是原样输出、末尾要不要换行、浮点数怎么打印、编码器会不会给键排序。
解法不是把哈希算得更用力,而是先规范化再哈希:两边约定同一种序列化形式并各自产出它。给键排序、去掉无意义的空白、把数字和转义规则钉死。JSON 就有专门的标准——JSON Canonicalization Scheme,RFC 8785——多数生态也有现成的库。对规范形式算哈希,那么只要两个系统对数据本身没有分歧,摘要就会一致。
压缩包:内容相同,容器不同
zip 和 tar 包还会引入容器层面的差异。两个压缩包里的文件可以逐字节完全一致,哈希却不同,因为容器记录的不只是文件本身:
- 时间戳。多数格式会存每个条目的修改时间。晚一分钟重新打包,压缩包的字节就变了。
- 文件顺序。条目被加入的顺序也是压缩包的一部分。两次以不同顺序遍历目录,产出的字节就不同。
- 压缩设置。不同的级别,或不同的 zlib/gzip 版本,对同样的输入会压出不同的字节。
- 元数据。权限、属主和属组 ID,以及——对
gzip而言——写进头部的原始文件名和修改时间。
这一条坑到的人比想象中多,因为很多你不觉得是压缩包的格式,本身就是 ZIP 容器:.docx、.xlsx、.pptx、.jar、.epub。打开一个文档,什么都不改,再保存一次,文件的哈希就变了——zip 内部有了新的时间戳,条目顺序也可能变了,哪怕每一个部件都没动过。
这是可重现构建之所以困难的一个重要原因——工具链版本、环境、构建路径同样会造成差异——也是工具后来提供各种选项来剥掉这些噪声的原因:gzip -n 去掉文件名和时间;GNU tar 可以用 tar --sort=name --mtime='UTC 1970-01-01' … 钉死顺序和时间(这些是 GNU 选项,macOS 自带的 BSD tar 并不全支持)。如果你是为了比对构建而给压缩包算哈希,那就改成给解出来的内容算——下面有现成的配方。
一张速查表
| 看着一样、字节却不同,是因为… | 怎么看出来 |
|---|---|
| 编码(UTF-8 对 UTF-16,或老式代码页) | file yourfile 报出编码 |
| 一边有 UTF-8 BOM | 头三个字节是 ef bb bf |
LF 对 CRLF 行尾 | cat -A yourfile 会在行尾显示 ^M |
| 一边末尾多一个换行 | 看最后一个字节是不是 0a |
| 尾随空格或零宽字符 | 十六进制转储一看便知 |
| 文本模式把行尾转换掉了 | 明确以二进制方式读同一个文件再算一次 |
| 重新序列化过的 JSON(键序、空白、转义) | 两边先规范化,再比对 |
| 重新打包过的压缩包(时间、顺序、级别) | 大小就不同;解压后逐个比较文件 |
怎样真正找出差异
别再重算哈希了,开始逐字节比对。
下面的命令假定你在 Linux 或 macOS 的 shell 里,其中几条各平台不同。sha256sum 是 GNU 的,macOS 上用 shasum -a 256。cat -A 只有 GNU 版才有,macOS 的 BSD cat 提供的是 -e 和 -t。diff <(…) 依赖 bash 或 zsh 的进程替换。Windows 上可以用 fc /b 逐字节比较两个文件,用 PowerShell 的 Format-Hex 转储字节。
- 先比大小。
ls -l,或wc -c。要留意大小差能说明什么、不能说明什么:它给出的是长度的净变化,而不是改动字节的数量——把 1000 个字节换成另外 1000 个,大小完全不变。不过这个差值仍是有用的线索:差几个字节,多半是 BOM 或末尾换行;差值随行数增长,多半是CRLF。 - 看字节。
xxd yourfile | head,或者用diff <(xxd a) <(xxd b)对比两份转储。第一个不同的偏移量能把字节级差异从哪里开始缩小出来——那是个起点,不等于根因。编码换了会让其后的一切都变,CRLF会在每一行反复出现,而压缩包的第一处差异往往只是个时间戳。 - 把看不见的显出来。
cat -A显示行尾和尾随空格,瞄一眼头三个字节就能抓到 BOM。file会猜出编码,但对没有 BOM 的 UTF-8 和某些老式代码页,它给的是启发式判断,不是定论。
过程中若需要重算摘要——改完一边核对一下,或者换个算法试试——把文件放进哈希生成器,它一次算一个算法、输出十六进制。但差异是靠大小和十六进制转储找出来的,不是靠再算一遍。
止住它再犯:用归一化让两边对上
找到那个字节只完成了一半。如果两边会反复漂开,就把它们归一化,让摘要从构造上就一致。
命令之前先说一句:sha256sum 是 GNU 的,macOS 上默认没有,对应的命令是 shasum -a 256。下面每一段都适用这个替换。
行尾。哈希之前先把 CRLF 转成 LF。装了 dos2unix 可以直接用(macOS 默认没有),而 tr 属于 POSIX,哪里都能跑:
tr -d '\r' < winfile.txt | sha256sum # Linux
tr -d '\r' < winfile.txt | shasum -a 256 # macOS
对前面那个文件,它返回的正是 911169dd…——也就是 LF 版本的摘要。(tr -d '\r' 会删掉所有回车,而不只是行尾那些;对格式良好的文本文件,这两者是同一批字符。)要从源头止住漂移,在 Git 仓库里提交一个带 * text=auto eol=lf 的 .gitattributes,让所有人的工作区保持一致,而不是依赖每个人各自的 core.autocrlf。
UTF-8 BOM。UTF-8 的 BOM 就是开头固定的三个字节,所以直接跳过这三个字节最简单,也不涉及任何正则转义。别的编码不一样:UTF-16 的 BOM 是两个字节,UTF-32 是四个。下面的命令假定你已经确认存在 UTF-8 BOM——对一个本来没有 BOM 的文件执行它,会白白砍掉三个真实字节:
tail -c +4 utf8-bomfile.txt | sha256sum # Linux
tail -c +4 utf8-bomfile.txt | shasum -a 256 # macOS
它返回 2cf24dba…——正是去掉 BOM 后 hello 的摘要。
编码。先把两边转成同一种编码:
iconv -f UTF-16 -t UTF-8 input.txt | sha256sum
目录与压缩包。别去哈希容器。改成哈希内容,再对排好序的哈希清单算一次哈希——这样一步就把时间戳、打包顺序和压缩设置全都甩掉了。要在目录内部执行,好让记录下来的路径是相对路径:
# 不确定:容器里带着时间戳和条目顺序
tar -cf - mydir | sha256sum
# 确定:内容加相对路径,顺序由 sort 钉死
( cd mydir && find . -type f -exec sha256sum {} + | sort | sha256sum )
要说清楚这个摘要到底涵盖了什么,因为 sha256sum 会在每个哈希旁边打印路径,于是路径也成了输入的一部分。它哈希的是文件内容加它们的相对路径。它不受目录时间戳和打包顺序影响,但文件被改名或移动时它会变,它跳过空目录,也完全不反映符号链接、权限和属主。如果在目录外面执行(find mydir …),文件夹自身的名字也会进到哈希里,于是两棵内容相同但文件夹名不同的树就对不上。要回答「这两棵树最后是不是同样的文件、在同样的位置」,这个配方正合适。如果你还需要权限和符号链接,那就换一个专门做这件事的工具。
排查清单
- 是同一个对象吗?同一路径、同一大小、不是目录、也不是还在写的文件。
- 是摘要真的不同吗?还是只是 hex 大小写、或 hex 对 base64——那根本不算差异,前面链接的那篇校验和排查里讲了。
- 在哈希文本吗?怀疑编码、BOM、
LF对CRLF、末尾换行、看不见的空白,或 Unicode 规范化——同时确认你的工具或代码是以二进制模式读取的。 - 在哈希结构化数据吗?JSON 这类格式有许多种合法的序列化形式。先规范化,再哈希。
- 在哈希压缩包吗?怀疑时间戳、文件顺序、压缩、元数据——包括本质是 zip 的
.docx、.xlsx、.jar。给内容算哈希,别给容器算。 - 还卡着?先比大小,再十六进制转储加 diff。第一个不同的字节能缩小排查范围,但不一定直接说明根因。
贯穿始终的,是开头那个事实:哈希是确定性的,所以哈希不同就证明字节不同。「同一个文件、不同哈希」从来不是哈希的问题。是一个文件悄悄变成了另一串字节——一个换行、一种编码、一个 BOM、一次重新序列化、一次重新打包——而哈希,恰如其分地尽着本职,拒绝假装没这回事。找出那个变了的字节,然后归一化,让它不会再变。