分片存储的路径
2026年11月12日
许多[1][2][3][4][5][6][7][8][9] Linux 发行版和包管理器都会对刚构建出的二进制文件进行审计,查找不可重定位的绝对路径。如果二进制文件中包含 /path/from/build/machine 这样的路径,它可能无法在最终用户的机器上运行。
那么,对二进制文件做个简单的 grep,或者用稍微高级一点的方式
$ strings bin/app | grep /path/from/build/machine
借助 GNU binutils 的 strings 就能检测出这些路径,对吧?
答案是否定的,至少在使用 GCC 并以 x86-64 为目标时如此:你不应假设字符串常量会原样出现在二进制文件中。
基本示例
在这个 C++ 源码中,我们从字符串常量创建了一个 std::string:
#include <iostream>
#include <string>
#define PREFIX "/home/linuxbrew/.linuxbrew"
int main() {
std::string prefix{PREFIX};
std::cout << prefix << '\n';
}
用 GCC 12 或更高版本编译并运行,会打印出该路径:
$ g++ -O2 main.cpp -o main
$ ./main
/home/linuxbrew/.linuxbrew
但该路径在二进制文件中出现的次数恰好是 0:
$ grep -acF /home/linuxbrew/.linuxbrew main
0
$ strings ./main | grep -cF /home/linuxbrew/.linuxbrew
0
这些字节肯定以某种形式存在于二进制文件的某处吧?我们对它运行 strings,并且只 grep /home 前缀:
$ strings ./main | grep /home
/home/linuxbrew/xbrew/.linuxbrew
等等,GCC 是不是把
/home/linuxbrew/.linuxbrew
变成了一个完全不同的路径
/home/linuxbrew/xbrew/.linuxbrew
中间还凭空多出了一个子目录 xbrew?怎么会这样?
分片存储
答案是 GCC 对我们的 std::string{PREFIX} 做了优化。我们之所以看到类似前缀的东西,纯属巧合。
GCC 把我们的字符串拆成了两块(相互重叠的)16 字节片段,就像这样
/home/linuxbrew/.linuxbrew
|--------------|.......... - 常量 1
..........|--------------| - 常量 2
它们刚好能放进 xmm 寄存器,这样 GCC 就能用向量化的移动指令把字符串复制到 std::string 的缓冲区中。
从 g++ -O2 -S -masm=intel 的输出中过滤出相关部分,我们得到
movdqa xmm0, XMMWORD PTR .LC0[rip]
movups XMMWORD PTR [rax], xmm0
movdqa xmm0, XMMWORD PTR .LC1[rip]
movups XMMWORD PTR 10[rax], xmm0
我们的字符串以常量形式存放在 .LC0 和 .LC1 处,而它们在二进制文件中恰好紧挨在一起:
.LC0:
.quad 7596498784141404207
.quad 3420313983963919726
.LC1:
.quad 7795220194874516088
.quad 8603408430414524009
一般来说,当字符串常量被复制到缓冲区时(比如通过 std::string、strcpy 或 memcpy),就会出现这种情况;而仅作为指针传递的字符串,比如 fopen(PREFIX "/x", "r") 中的那样,则会保持完整。
去重
一些包管理器(如 Conda 和 Spack)在构建期间会把包安装到一个带填充的前缀中,其思路是可以将其“重定位”到用户选择的更短前缀:二进制文件会被原地修补。Conda 在目录名中使用重复的字符串 _placehold 来让前缀足够长。
现在,即使我们按照 Conda 的风格填充路径,选一个 256 字节的路径(256 是所有 x86-64 寄存器宽度的方便的公倍数)
#define PREFIX "/home/conda/feedstock_root/build_artifacts/" \
"foo_1699999999999/_h_env" \
"_placehold_placehold_placehold_placehold_placehold" \
"_placehold_placehold_placehold_placehold_placehold" \
"_placehold_placehold_placehold_placehold_placehold" \
"_placehold_placehold_placehold_placeho/"
并且祈祷 GCC 会把路径的各片段连续地放进二进制文件中,我们却无法在二进制文件中找回我们的前缀。我们得到的反而是一个短得多的、只有 176 字节的字符串(即 11 乘以 16):
$ strings main | grep /home
/home/conda/feedstock_root/build_artifacts/foo_1699999999999/_h_env_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placeho/
这要归功于一项进一步的优化:由于字符串 _placehold 每 10 字节重复一次,16 字节的片段每 lcm(10, 16) = 80 字节重复一次,GCC 只需为填充存储 5 个不同的 16 字节常量:
/home/conda/feed
stock_root/build
_artifacts/foo_1
699999999999/_h_
env_placehold_pl
acehold_placehol # note
d_placehold_plac # that
ehold_placehold_ # this
placehold_placeh # is
old_placehold_pl # cyclic
acehold_placeho/
这个优化有帮助吗?
对于字符串字面量,我不这么认为。Clang 不会把字符串拆分成常量,但它生成的指令本质上相同。例如,取一个更长的路径,并用 -march=x86-64-v3 以 AVX2 为目标:
#define PREFIX "/home/linuxbrew/.linuxbrew/Cellar/foo/1.2.3/share/foo"
Clang 只是把字符串放在专门用于字符串的节 .rodata.str1.1 中:
.L.str:
.asciz "/home/linuxbrew/.linuxbrew/Cellar/foo/1.2.3/share/foo"
并用两次重叠的 32 字节 ymm 加载和存储,在偏移 21 和 0 处复制其 53 个字节:
vmovups ymm0, ymmword ptr [rip + .L.str+21]
vmovups ymmword ptr [rax + 21], ymm0
vmovups ymm0, ymmword ptr [rip + .L.str]
vmovups ymmword ptr [rax], ymm0
这与 GCC 的版本没有特别不同,后者改为从 .rodata.cst32 中的常量加载同样的两个片段:
vmovdqa ymm0, YMMWORD PTR .LC0[rip]
vmovdqu YMMWORD PTR [rax], ymm0
vmovdqa ymm0, YMMWORD PTR .LC1[rip]
vmovdqu YMMWORD PTR 21[rax], ymm0
那么 GCC 获得了什么?在性能方面,可能没有任何可测量的收益:两者执行相同的加载和存储。对于重复的片段,GCC 可能略有优势,因为重复的片段只加载一次并存储多次(前面的例子中为 16 次存储进行了 11 次加载)。它还使用对齐读取,但这只对古老的 CPU 有影响。
在大小方面,GCC 的片段可能比字符串本身占用更多空间:重叠的字节被存储两次,而且片段会对齐到 16、32 或 64 字节。上面的 53 字节路径作为两个片段占用 64 字节,而作为字符串占用 54 字节。只有在实现去重时才有收益,例如当许多字符串共享至少 16 字节(SSE2)、32 字节(AVX2)和 64 字节(AVX-512)的前缀时。据推测,去重在实践中并不会节省多少,尤其是现在一些发行版将 AVX2 作为基线。
我能禁用这个优化吗?
除了用 -Os 编译之外,其实不能。从 GCC 10 开始(也许更早的版本也是?),即使没有指定 -march,拆分成片段的优化也会对最长 256 字节的字符串触发。从 GCC 12 开始,它相对于目标的向量宽度,因此更长的字符串也会被拆分:AVX2 最长 512 字节,AVX-512 最长 1024 字节。
我认为你对此唯一的控制是将最大向量宽度设置为 128 位(强制 xmm 操作),通过传递 -mstore-max=128。但这只是恢复了旧的默认值,并且意味着最长 256 字节的字符串仍会以片段形式存储。
也许事后扫描是不好的做法?
这些针对绝对路径的构建后审计很流行,但你可以说它们从一开始就太脆弱了,那么为什么还指望它们能起作用呢?
确实,Debian 比坏路径扫描走得更远。他们的审计断言,一个包可以在不同环境下逐位重建:在不同的机器上,使用不同的用户、时间和日期、主机名、区域设置等。这是一种更可靠的方法,可以将机器特定的细节排除在二进制文件之外。[10]
碎片化路径
我认为,尽管如此,将字符串常量原样保留在二进制文件中仍然是有用的,即使在 Debian 上也是如此,因为这提升了二进制文件的可解释性。运行 strings ./binary 是一种如此简单方便的方式,可以洞察一个不透明的二进制 blob,以至于它超过了拆分和去重带来的边际节省。这让人想起 Fedora 和 Ubuntu 重新默认开启帧指针,尽管有少量开销,只是为了更可靠地使用性能分析器和跟踪工具。
除此之外,还有二进制修补的理由——尽管被认为有些脆弱——但这是 Conda 和 Spack 中的常见做法:在安装到用户指定位置时,绝对路径会通过某种可视为"升级版 sed 调用"的方式被相应地就地修补。这只有在路径以完整形式存储时才有效。
如果您想讨论这个问题,一个合适的地方是我最近发起的 gcc-patches 讨论串,其中包含一个补丁,添加了一种退出机制。
[1] RPM(Fedora、RHEL、openSUSE):check-buildroot 会在每个已安装文件中搜索构建根目录。
[2] Arch Linux:makepkg 会警告对 $srcdir 和 $pkgdir 的引用。
[3] Yocto / OpenEmbedded:buildpaths QA 检查。
[4] FreeBSD ports:stage QA 会搜索暂存目录。
[5] pkgsrc:CHECK_WRKREF,除非包开发者开启,否则关闭。
[6] Homebrew:brew bottle 会扫描前缀以决定 bottle 是否可重定位。
[7] conda-build:包含构建前缀的文件列在 info/has_prefix 中,在安装时重写。
[8] rattler-build:与 conda-build 相同的方案,使用 memmem。
[9] Julia 的 BinaryBuilder:check_absolute_paths 会警告任何文件中的构建前缀。
[10] 甚至 Debian 也保持一个路径固定:自 2024 年初起,两种构建使用相同的构建目录,因为它记录在 .buildinfo 文件中,并在重新构建时重用。因此,嵌入二进制文件中的构建路径在那里也不会被注意到。