rea-reverse

这个 Mac 应用是不是通用二进制?查看 Mach-O 里的 Apple 芯片与 Intel 切片

基于 REA 6.4.0更新于 约 6 分钟读完
本页目录

结论先行:通用二进制(universal binary,也叫 fat binary)文件开头是魔数 0xcafebabe 和一张切片表。每个切片都是一个完整的、针对某一种 CPU 的 Mach-O 映像,通常是给 Apple 芯片的 arm64 和给 Intel 的 x86_64。在 Mac 上,lipo -archs <文件> 就能列出来;在任何电脑上(包括 Windows 和 Linux),本站的 ELF / PE / Mach-O 查看器 都能在浏览器里列出切片并逐个打开,文件不上传。

关键在于:每个切片都是一次独立的构建。同一个文件里的两个切片,最低系统版本可以不同,签名状态可以不同,依赖的库也可以不同。本文自己编了几个通用二进制,逐个切片对比差异,还复现了两个真实问题:依赖库缺切片,以及某个切片没有签名。下面的输出全部来自一台装了 Xcode 的 Apple 芯片 Mac(macOS 27)。

适合谁读:发布自己的 Mac 应用、插件或命令行工具的开发者;以及在其他系统上收到 .app、.dylib 或 framework、需要知道它能在哪种机器上跑的开发者。

你会学到:

  • 通用二进制的结构,以及怎么在 .app 里找到真正的可执行文件
  • 怎么在浏览器里、用 lipo 和 file、用 REA 6.4.0 列出并对比切片
  • 怎么用 Xcode、CMake、Go 或 lipo -create 生成通用二进制
  • 怎么排查 “incompatible architecture” 和切片未签名的问题

只检查你自己构建的应用和库、开源软件,或你有权分析的文件。

通用二进制长什么样

文件开头是一个很短的大端序头:魔数 FAT_MAGIC(0xcafebabe,64 位变体是 0xcafebabf)、切片数量,然后每个切片一条记录,写着 CPU 类型、子类型、文件偏移、大小和对齐。后面就是各个切片,每个切片从自己的偏移开始,是一个普通的瘦 Mach-O 文件。

常见的架构:

  • arm64:Apple 芯片上的应用。
  • x86_64:Intel Mac,以及在 Apple 芯片上通过 Rosetta 2 运行。
  • arm64e:带指针认证(pointer authentication)的 arm64,Apple 自己的系统程序在用。第三方应用一般发 arm64。

进程启动时,内核和 dyld 会为当前 CPU 挑最合适的切片。之后进程加载的每一个库,也都必须有匹配的切片。

第 1 步:找到真正的可执行文件

.app 其实是个文件夹。可执行文件在 Contents/MacOS 里,名字由 Contents/Info.plist 的 CFBundleExecutable 决定:

plutil -extract CFBundleExecutable raw Hello.app/Contents/Info.plist
Hello

主程序不是唯一的 Mach-O 文件。framework、dylib、辅助程序和插件都有各自的切片,只要有一个库只带 Intel 切片,应用在 Apple 芯片上就可能起不来。检查包里所有 Mach-O 文件:

find Hello.app -type f -exec sh -c 'lipo -archs "$1" >/dev/null 2>&1 && echo "$1: $(lipo -archs "$1")"' _ {} \;

我的测试包里故意放了一个只有 Intel 切片的库:

Hello.app/Contents/MacOS/Hello: x86_64 arm64
Hello.app/Contents/Frameworks/libutil.dylib: x86_64

第 2 步:在浏览器里打开各个切片

打开二进制查看器,选中可执行文件(在 Windows 或 Linux 上,先把 .app 从 .zip 或 .dmg 里解出来)。如果是通用二进制,报告里会多一个“通用二进制切片”区块,每个切片一个按钮,标着架构和大小。默认选中第一个切片,点另一个,整份报告就切换过去。

hello-universal 是用 clang -arch arm64 -arch x86_64 -mmacosx-version-min=11.0 编的,查看器列出:

切片 架构 偏移 大小
0 x86_64 4096 12744
1 arm64 32768 50160

“基本信息”区块里同时有整个文件的 SHA-256 和当前切片单独的“切片 SHA-256”。对比两次构建时用切片哈希:两个通用二进制的 arm64 切片可以完全一样,整个文件的哈希却不同。报告里节区和段的偏移都是相对于切片的,和 otool 的打印方式一致。

然后逐个切片对比:

  • 平台、最低系统版本和 SDK:来自 LC_BUILD_VERSION,或较老的 LC_VERSION_MIN_MACOSX。
  • 链接的库:LC_LOAD_DYLIB 等加载命令,包括 @rpath 开头的名字。
  • 代码签名和 Hardened Runtime:在“安全特性”表里。

切片之间可以不一致

我把两个架构分开编译,再合并:

clang -O2 -arch arm64  -mmacosx-version-min=11.0  hello.c -o hello-arm64
clang -O2 -arch x86_64 -mmacosx-version-min=10.13 hello.c -o hello-x86_64
lipo -create hello-arm64 hello-x86_64 -output hello-lipo

查看器里,x86_64 切片的最低系统版本是 10.13,arm64 切片是 11.0。用 otool 看,两个切片连记录版本的加载命令都不一样:

otool -l -arch x86_64 hello-lipo | grep -A4 LC_VERSION_MIN
otool -l -arch arm64  hello-lipo | grep -A4 LC_BUILD_VERSION
      cmd LC_VERSION_MIN_MACOSX
  cmdsize 16
  version 10.13
      sdk 27.0
--
      cmd LC_BUILD_VERSION
  cmdsize 32
 platform 1
    minos 11.0
      sdk 27.0

这是正常的(Apple 芯片的 Mac 从 macOS 11 起步),但它意味着“本应用支持 macOS 10.13”只对 Intel Mac 成立。

第 3 步:在 Mac 上交叉核对

file hello-universal
lipo -archs hello-universal
lipo -detailed_info hello-universal
hello-universal: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64:Mach-O 64-bit executable arm64]
hello-universal (for architecture x86_64):	Mach-O 64-bit executable x86_64
hello-universal (for architecture arm64):	Mach-O 64-bit executable arm64
x86_64 arm64
Fat header in: hello-universal
fat_magic 0xcafebabe
nfat_arch 2
architecture x86_64
    cputype CPU_TYPE_X86_64
    cpusubtype CPU_SUBTYPE_X86_ALL
    capabilities
    offset 4096
    size 12744
    align 2^12 (4096)
architecture arm64
    cputype CPU_TYPE_ARM64
    cpusubtype CPU_SUBTYPE_ARM64_ALL
    capabilities
    offset 32768
    size 50160
    align 2^14 (16384)

偏移和大小与查看器的切片列表一致。想在 Apple 芯片上指定运行某个切片,arch -x86_64 ./hello-universal 会通过 Rosetta 2 启动 Intel 切片。两个切片都正常打印了 hello, world。

用 REA 6.4.0

REA(morluto 的开源项目)有一个 rea list-architectures 命令,内部调用的是 lipo -detailed_info(dist/native/NativeMacOSProvider.js),所以需要 macOS 和 Xcode 命令行工具。参数可以是文件,也可以是 .app:

rea list-architectures /path/to/Hello.app --format json

结果节选:

"subject":{"name":"Hello", ... "architecture":"arm64","local_path":".../Hello.app/Contents/MacOS/Hello"},
"normalized_result":{"architectures":{"items":[
  {"name":"x86_64","cpu_type":"CPU_TYPE_X86_64","cpu_subtype":"CPU_SUBTYPE_X86_ALL","file_offset":4096,"size":12744,"alignment":4096},
  {"name":"arm64","cpu_type":"CPU_TYPE_ARM64","cpu_subtype":"CPU_SUBTYPE_ARM64_ALL","file_offset":32768,"size":50160,"alignment":16384}]

有两个限制要知道。对 .app,它只报告主程序,不管包里的 framework,所以上面那条遍历整个包的 find 还是要跑。另外,这个文件的第一个切片是 x86_64,但证据里的 subject.architecture 在我的 Mac 上写的是 arm64;6.4.0 的结果里没有单独的字段说明读的是哪个切片。REA 的其他 macOS 工具见用 REA 分析 macOS 应用。

第 4 步:自己生成通用二进制

  • Xcode:Architectures 的默认值是 Standard Architectures(ARCHS_STANDARD),Xcode 27 的 macOS 规格文件里它就是 arm64 和 x86_64。Debug 配置通常把 Build Active Architecture Only(ONLY_ACTIVE_ARCH)设为 Yes,只出一个切片;用 Release 归档,再用 lipo -archs 确认。

  • 直接用 clang:像上面那样写两次 -arch。

  • CMake:配置时传 -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"。

  • Go:分别编两个架构再合并,Go 1.27.1 实测可用:

    GOOS=darwin GOARCH=arm64 go build -o app-arm64 .
    GOOS=darwin GOARCH=amd64 go build -o app-amd64 .
    lipo -create app-arm64 app-amd64 -output app
  • 预编译的第三方库:每一个都必须两个切片齐全。lipo -create 只能把不同架构的瘦文件拼在一起,没法把 Intel 库变成 Apple 芯片的库。

常见问题排查

“incompatible architecture (have ‘x86_64’, need ‘arm64’)”

我先把 app 编成通用二进制,链接一个同样是通用二进制的 libutil.dylib,然后把这个库换成只有 Intel 切片的版本,模拟一个没更新的第三方 SDK 混进来的情况。在 Apple 芯片上运行:

dyld[92577]: Library not loaded: @rpath/libutil.dylib
  Referenced from: <196030DD-79A2-3BB0-9B35-3985D8230067> .../dep/app
  Reason: tried: '.../dep/libutil.dylib' (mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64e, arm64e.v1, arm64, arm64')), ...

Referenced from 里的 UUID 就是 arm64 切片的 LC_UUID,查看器在这个切片上显示的是同一个值。如果一开始就链接这个 Intel 版的库,构建阶段就会报错:

ld: warning: ignoring file './libutil.dylib': found architecture 'x86_64', required architecture 'arm64'
Undefined symbols for architecture arm64:

REA 的 rea trace-dylib-resolution 不用运行程序就能发现这个问题。对 app 加 --architecture arm64 运行,@rpath/libutil.dylib 的候选结果是 "outcome":"architecture-missing",findings 里写着:

"kind":"required-load-unresolved", ... "explanation":"No candidate for @rpath/libutil.dylib exists in the analyzed root; dyld would fail to load this required dependency for app (arm64) unless the image is supplied elsewhere."

修复要在构建这一侧做:向库的提供方要通用(或 arm64)版本,或者自己重新编译。

有一个切片没签名

在 Apple 芯片上,链接器会自动给 arm64 产物做 ad hoc 签名,但不会给 x86_64 产物签。所以链接器直接输出的通用二进制,arm64 切片有签名,x86_64 切片没有。查看器里,切片 0 的代码签名显示“否”(no LC_CODE_SIGNATURE),切片 1 显示 adhoc,linker-signed。再看:

codesign -v hello-universal
hello-universal: code object is not signed at all

REA 的 rea inspect-signature 返回了 "signed": true,但在 limitations 里写了 "Unsigned Mach-O slices: x86_64."。要看这个字段,不能只看布尔值。修复方法是用你的证书给最终的通用二进制签名(codesign --sign "Developer ID Application: ..." --options runtime),它会给每个切片都签上。

常听到一种说法:“先签名再 lipo,签名一定会失效。“我的测试结果不是这样:用 lipo -create 合并两个各自 ad hoc 签过名的切片,或者用 lipo -remove、-thin 从已签名的通用二进制里去掉一个切片,得到的文件都能通过 codesign -v,因为每个切片带着自己的签名。我没有用 Developer ID 签名或已公证的应用测试过。稳妥的做法是:最后一次 lipo 之后再签名,然后验证。

常见问题

没有 Mac 能查吗? 能。查看器在任何现代浏览器里都能用,fat 头和每个切片都由它自己解析。lipo、codesign 和 REA 的 macOS 工具则需要 macOS。

arm64e 是什么?我的应用要不要带? 它是带指针认证的 arm64,Apple 的系统程序在用。应用一般发 arm64 就够了。

为什么节区偏移这么小? 报告里的偏移是相对于当前切片的。加上切片列表里的切片偏移,才是在整个文件里的位置。

查看器会上传文件吗? 不会。解析和哈希都在浏览器的 Web Worker 里完成。

下一步