rea-reverse

不运行就能判断 EXE / DLL 是 32 位、64 位还是 ARM64(含 .NET AnyCPU)

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

结论先行:Windows 的 EXE 和 DLL 都是 PE 文件,架构由两个文件头字段决定。COFF 头里的 Machine 字段说明代码面向哪种 CPU(0x014C 是 x86,0x8664 是 x64,0xAA64 是 ARM64);可选头里的 Magic 说明文件是 PE32(32 位布局)还是 PE32+(64 位布局)。把文件拖进本站的 原生二进制概览,看基本信息里的 架构 和 位数 就行,文件不会运行,也不会上传。

有一个重要的例外:编译成 AnyCPU 的 .NET 程序集,PE 头是 PE32、Machine 是 x86,但在 64 位 Windows 上它其实以 64 位进程运行。托管代码还得看 CLI 标志,下面每种情况都给出真实样本的结果。

适合谁读:正在排查 BadImageFormatException 或“不是有效的 Win32 应用程序”的开发者;要打包必须和宿主架构一致的插件(Office 加载项、Node 或 Python 原生模块、驱动)的人;以及准备为 Windows on ARM 出包的团队。

读完你能回答:

  • 这个 EXE 或 DLL 是 x86、x64 还是 ARM64?
  • 一个 .NET 程序集是 AnyCPU、仅限 x86,还是绑定了某个平台?
  • ELF 和 Mach-O 文件怎么判断同样的问题?
  • 哪些命令行工具可以复核?

示例文件:我自己写的一个最小 Windows 程序和一个 DLL,在 macOS 上用 Android NDK r29 的 clang 和 lld-link 各编译三次(不带 C 运行时,只导入 kernel32.dll):

for t in x86:i686 x64:x86_64 arm64:aarch64; do
  n=${t%%:*}; a=${t##*:}
  clang -target $a-pc-windows-msvc -O2 -c -o $n.obj win.c
  lld-link /nologo /nodefaultlib /entry:mainCRTStartup /subsystem:console \
    /dynamicbase /nxcompat /out:hello-$n.exe $n.obj kernel32-$n.lib
done

在 Visual Studio 里分别选 Win32、x64、ARM64 平台构建,得到的是同样的三种产物。.NET 部分用的是 nuget.org 上的开源包:Newtonsoft.Json 13.0.3、xunit.runner.console 2.4.2(里面同时有 xunit.console.exe 和 xunit.console.x86.exe),以及 .NET 8.0.10 运行时包 win-x64 和 win-arm64 里的 System.Text.Json.dll。

为什么要关心架构

一个 Windows 进程只有一种架构,它直接加载的每个 DLL 都必须和它一致。64 位进程加载不了 32 位 DLL,反过来也一样。大部分让人头疼的报错都源于这条规则:

  • BadImageFormatException:.NET 进程加载了为另一种架构编译的程序集或原生 DLL。
  • “%1 不是有效的 Win32 应用程序”(Win32 错误 193):系统被要求启动或加载一个它用不了的映像。
  • 插件“没反应”:Office、资源管理器外壳扩展、音频宿主之类的程序,只会加载和宿主进程架构一致的插件。
  • 驱动:内核驱动必须和 Windows 本身的架构一致,没有任何模拟层。

在 Windows 11 on ARM 上,x86 和 x64 应用程序 可以通过模拟运行,但进程内部的规则不变:一个被模拟的 x64 进程照样需要 x64 的 DLL。(ARM64EC 是专门用来在同一进程里混合 ARM64 和 x64 代码的特殊 ABI,不在本文讨论范围内。)

第 1 步:在浏览器里读 Machine 和 Magic

在 原生二进制概览 里打开 EXE 或 DLL。我的六个原生构建,基本信息显示如下:

文件 格式 架构 位数 文件类型
hello-x86.exe PE32 x86 (0x14c) 32 位 EXE
hello-x64.exe PE32+ x64 (0x8664) 64 位 EXE
hello-arm64.exe PE32+ ARM64 (0xaa64) 64 位 EXE
add-x86.dll PE32 x86 (0x14c) 32 位 DLL
add-x64.dll PE32+ x64 (0x8664) 64 位 DLL
add-arm64.dll PE32+ ARM64 (0xaa64) 64 位 DLL

还有两个小线索和它吻合:64 位映像的映像基址是 0x140000000(EXE)或 0x180000000(DLL),32 位的是 0x400000 和 0x10000000;安全特性表里,PE32 文件的 高熵 ASLR(64 位) 显示为“不适用”。不过别只靠这些,映像基址只是一个链接器选项。

注意 ARM64 和 x64 都是 PE32+。Magic 只区分 32 位和 64 位布局,要知道是 哪一种 64 位 CPU,必须看 Machine。

第 2 步:自己读这两个字段

这两个字段位置固定,写脚本很容易。DOS 头在 0x3C 处存着 PE\0\0 签名的偏移;签名之后紧跟 2 字节的 Machine;可选头(第一个字段就是 Magic)从签名之后第 24 字节开始:

import struct, sys

MACHINES = {0x14C: "x86", 0x8664: "x64", 0xAA64: "ARM64", 0x1C4: "ARM (Thumb-2)"}
MAGIC = {0x10B: "PE32", 0x20B: "PE32+"}

for path in sys.argv[1:]:
    with open(path, "rb") as f:
        data = f.read(4096)
    if data[:2] != b"MZ":
        print(f"{path}: not a PE file"); continue
    pe = struct.unpack_from("<I", data, 0x3C)[0]          # e_lfanew
    if data[pe:pe + 4] != b"PE\0\0":
        print(f"{path}: no PE signature"); continue
    machine = struct.unpack_from("<H", data, pe + 4)[0]    # COFF Machine
    magic = struct.unpack_from("<H", data, pe + 24)[0]     # optional header Magic
    print(f"{path}: {MACHINES.get(machine, hex(machine))} ({machine:#06x}), {MAGIC.get(magic, hex(magic))}")
$ python3 pearch.py *.exe *.dll
hello-arm64.exe: ARM64 (0xaa64), PE32+
hello-x64.exe: x64 (0x8664), PE32+
hello-x86.exe: x86 (0x014c), PE32
xunit.console.exe: x86 (0x014c), PE32
xunit.console.x86.exe: x86 (0x014c), PE32
add-arm64.dll: ARM64 (0xaa64), PE32+

看那两个 xunit 文件:都是 x86、PE32,可只有一个真的限定 32 位。光看 PE 头分不出来,这就是 .NET 的特殊情况。

第 3 步:.NET 程序集与 AnyCPU

.NET 程序集是带 CLI 头(数据目录第 14 项)的 PE 文件。如果里面只有 IL,PE 的 Machine 字段只是一个容器默认值,运行时真正看的是 CLI 头里的 CLI 标志:

  • ILONLY:映像里只有 IL,没有原生代码。
  • 32BITREQUIRED:必须在 32 位进程里运行。
  • 32BITPREFERRED:和 32BITREQUIRED 同时出现时表示“首选 32 位”(Prefer 32-bit):能用 32 位就用 32 位,否则用 64 位。

原生二进制概览会告诉你这个文件 是 .NET 程序集,并链接到 .NET 程序集元数据与 IL 查看器,后者会显示 PE 头和 CLI 标志。下表是查看器对我的样本给出的结果,以及 REA 6.4.0 对同一文件给出的 managed_architecture:

文件 PE(查看器) CLI 标志(查看器) REA managed_architecture
Newtonsoft.Json net45 PE32 x86 ILONLY STRONGNAMESIGNED anycpu
xunit.console.exe PE32 x86 ILONLY STRONGNAMESIGNED anycpu
xunit.console.x86.exe PE32 x86 ILONLY 32BITREQUIRED STRONGNAMESIGNED x86
System.Text.Json(win-x64 包) PE32+ x64 ILLIBRARY STRONGNAMESIGNED x86_64
System.Text.Json(win-arm64 包) PE32+ ARM64 ILLIBRARY STRONGNAMESIGNED arm64

判断规则如下:

  1. PE32、Machine x86、有 ILONLY、没有 32BITREQUIRED:AnyCPU。在 64 位进程里以 64 位运行,在 32 位进程里以 32 位运行。
  2. 在此基础上同时带 32BITREQUIRED 和 32BITPREFERRED:AnyCPU 首选 32 位。这个选项只对可执行文件有意义。我没找到公开发布的这类样本,所以表里没有(见下面的说明)。
  3. 有 32BITREQUIRED、没有 32BITPREFERRED:仅限 x86,加载进 64 位进程会失败。
  4. PE32+,Machine 是 x64 或 ARM64:绑定平台。上面两个运行时包里的文件是 ReadyToRun 映像,带有针对某一种 CPU 预编译的原生代码。

REA 6.4.0 在 managedArchitectureFor(dist/dotnet/ManagedArtifactInspector.js)里推导 managed_architecture:PE machine 不是 x86,或者 ILONLY 没有置位时,直接返回 PE machine;否则先查 32BITREQUIRED(返回 x86),再查 32BITPREFERRED(返回 anycpu-prefer-32),都没有就是 anycpu。因为先查的是 32BITREQUIRED,两个标志都带的“首选 32 位”EXE 在 6.4.0 里会被报成 x86,而不是 anycpu-prefer-32。为了确认,我把 xunit.console.exe 的一份临时副本的标志从 0x9 改成 0x2000b(也就是 C# 编译器 /platform:anycpu32bitpreferred 写入的组合),查看器列出 ILONLY 32BITREQUIRED STRONGNAMESIGNED 32BITPREFERRED,REA 返回 "managed_architecture":"x86"。所以遇到首选 32 位,以查看器列出的原始标志为准。

ReadyToRun 那两行还有一点要说明。这类映像的 ILONLY 是清零的,这一点和 C++/CLI 混合模式映像一样。查看器靠 ReadyToRun 头(托管原生头里的 RTR 签名)把两者区分开,所以这些文件只打 ReadyToRun 标签,不打 混合模式。REA 6.4.0 先看 ILONLY,对它们报告 implementation: "cpp-cli-mixed"。架构结论没有问题,但对这些框架文件来说,准确的描述是 ReadyToRun,而不是 C++/CLI。

用 REA 命令行查看

rea inspect-managed-artifact 只接受一个参数:文件路径。我用 --filter-output 把输出精简了一下:

rea inspect-managed-artifact ./xunit.console.x86.exe --format json \
  --filter-output normalized_result.classification.status,normalized_result.classification.implementation,normalized_result.classification.managed_architecture
{"normalized_result":{"classification":{"status":"managed","implementation":"cil","managed_architecture":"x86"}}}

同样的命令对 xunit.console.exe 返回 "anycpu"。对 hello-arm64.exe 这种原生 PE,REA 不会瞎猜,而是返回 status: "not-managed"、managed_architecture: "unknown" 和证据代码 cli-directory-absent。原生文件请用第 1 步的 Machine 字段。托管代码的更多信息见 查看 .NET DLL 的目标框架、版本号和依赖。

第 4 步:ELF 和 Mach-O 怎么看

ELF(Linux、Android)在文件第 4 字节存位数(1 = 32 位,2 = 64 位),在 e_machine 存 CPU。原生二进制概览把它们显示为格式和架构:我的 32 位 Android 构建是 ELF32、ARM,64 位的是 ELF64、AArch64。llvm-readelf 的结果一致:

$ llvm-readelf -h hello-arm-android | grep -E "Class|Machine"
  Class:                             ELF32
  Machine:                           ARM

这对 Android 应用很关键:只支持 64 位的手机加载不了 32 位 .so。APK 分析工具 会帮你检查每个 lib/<abi>/ 目录里的库。

Mach-O 可以在一个通用文件里放多种架构。原生二进制概览会列出所有切片,让你选一个分析;命令行上用 lipo:

$ lipo -archs hello-macos-universal
x86_64 arm64

三种格式基本信息的完整讲解,见 ELF / PE / Mach-O 在线查看。

第 5 步:命令行对照

在 macOS 和 Linux 上,file 和 llvm-readobj 读的是同样的文件头。我在样本上跑了一遍:

$ file hello-arm64.exe xunit.console.exe xunit.console.x86.exe
hello-arm64.exe:       PE32+ executable (console) Aarch64, for MS Windows
xunit.console.exe:     PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows
xunit.console.x86.exe: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows

$ llvm-readobj --file-headers hello-arm64.exe | grep -E "Machine|Magic"
  Machine: IMAGE_FILE_MACHINE_ARM64 (0xAA64)
  Magic: 0x20B

file 能认出 .NET 程序集,但和 PE 头一样,分不清 AnyCPU 版和仅限 x86 的版本。

在 Windows 上,常用的是 Visual Studio 开发者命令提示符里的 dumpbin /headers(看 machine 那一行,以及 OPTIONAL HEADER VALUES 下面的 magic # 那一行),以及同样在开发者命令提示符里可用的 corflags(看 .NET 标志)。本文的样本都是在 macOS 上构建和检查的,所以我没有跑这两个工具,它们读的是同样的字段。

排错

加载 DLL 时出现 BadImageFormatException:同时检查 DLL 和宿主进程。DLL 如果仅限 x86(32BITREQUIRED)或者是原生 x86,宿主就必须是 32 位;如果是 x64 或 ARM64,宿主必须一致。如果宿主是 AnyCPU 的 EXE,看看是否设置了“首选 32 位”。

“不是有效的 Win32 应用程序”:映像的 Machine 和系统能运行的架构不匹配(比如在 x64 Windows 上运行 ARM64 EXE),或者文件根本不是 PE。原生二进制概览会告诉你是哪一种。

全都是 AnyCPU,但有个原生依赖是 x64:AnyCPU 只管 IL。P/Invoke 调用的原生库、NuGet 包里的原生资产,架构都是固定的,也要放进原生二进制概览看一看。

文件在 Program Files (x86) 目录里:目录只说明安装程序怎么装的,不说明文件本身。以文件头为准。

常见问题

不装任何软件能查吗? 能。原生二进制概览和 .NET 查看器都在浏览器里一个禁止联网的 Web Worker 中运行,从不执行文件。

PE32+ 一定是 x64 吗? 不是。ARM64 映像也是 PE32+。PE32+ 只表示“64 位布局”,CPU 要看 Machine。

我的 AnyCPU DLL 为什么显示 Machine 是 x86? 这是纯 IL 程序集的默认容器格式,真正决定运行方式的是 CLI 标志。

64 位程序能用 32 位 DLL 吗? 同一个进程里不行。要么换成 64 位版本的 DLL,要么做进程外桥接(另起一个 32 位进程,通过 COM 或 IPC 通信)。

请只检查你自己构建的、或有权检查的文件。如果接下来要运行或调试别人的软件,请先阅读 REA 的 DISCLAIMER.md。

下一步