Check if an EXE or DLL Is 32-bit, 64-bit or ARM64 (Incl. .NET)
On this page
Short answer: every Windows EXE and DLL is a PE file, and two header fields decide its architecture. The COFF Machine field says which CPU the code is for (0x014C x86, 0x8664 x64, 0xAA64 ARM64), and the optional header Magic says whether the file is PE32 (32-bit layout) or PE32+ (64-bit layout). Drop the file into this site’s native binary overview and read Architecture and Bitness in the Identity block. Nothing is run or uploaded.
There is one big exception. A .NET assembly built as AnyCPU has a PE32 header with Machine x86, yet it runs as a 64-bit process on 64-bit Windows. For managed code you have to read the CLI flags too, and this guide shows real examples of each case.
Samples used: a minimal Windows program and DLL of my own, built three times on macOS with the Android NDK r29’s clang and lld-link (no C runtime, importing only 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
With Visual Studio you would get the same three outputs by building for the Win32, x64 and ARM64 platforms. For .NET, I used open-source packages from nuget.org: Newtonsoft.Json 13.0.3, xunit.runner.console 2.4.2 (it ships both xunit.console.exe and xunit.console.x86.exe), and System.Text.Json.dll from the .NET 8.0.10 runtime packs for win-x64 and win-arm64.
Why the architecture matters
A Windows process has one architecture, and every DLL it loads directly has to match it. A 64-bit process cannot load a 32-bit DLL, or the other way round. That rule is behind most of the errors people search for:
BadImageFormatExceptionin .NET, when a process loads an assembly or native DLL built for a different architecture.- “%1 is not a valid Win32 application” (Win32 error 193) when Windows is asked to start or load an image it cannot use.
- Plug-ins that “do nothing”. Hosts such as Office, Explorer shell extensions or audio hosts only load add-ins that match the host process.
- Drivers. Kernel drivers must match the architecture of Windows itself; there is no emulation for them.
On Windows 11 on ARM, x86 and x64 applications run under emulation, but that does not relax the rule inside a process: an emulated x64 process still needs x64 DLLs. (ARM64EC, a special ABI designed for mixing ARM64 and x64 code in one process, is outside the scope of this guide.)
Step 1: Read Machine and Magic in the browser
Open the EXE or DLL in the native binary overview. For my six native builds, the Identity block reported:
| File | Format | Architecture | Bitness | File type |
|---|---|---|---|---|
hello-x86.exe |
PE32 | x86 (0x14c) | 32-bit | EXE |
hello-x64.exe |
PE32+ | x64 (0x8664) | 64-bit | EXE |
hello-arm64.exe |
PE32+ | ARM64 (0xaa64) | 64-bit | EXE |
add-x86.dll |
PE32 | x86 (0x14c) | 32-bit | DLL |
add-x64.dll |
PE32+ | x64 (0x8664) | 64-bit | DLL |
add-arm64.dll |
PE32+ | ARM64 (0xaa64) | 64-bit | DLL |
Two smaller hints line up with this. The 64-bit images got the image base 0x140000000 (EXE) or 0x180000000 (DLL), the 32-bit ones 0x400000 and 0x10000000. And the Security features table marks High-entropy ASLR (64-bit) as n/a for PE32 files. Do not rely on those alone, though: an image base is just a linker setting.
Notice that ARM64 and x64 are both PE32+. The Magic only tells you 32-bit vs 64-bit layout, so always read the Machine field to know which 64-bit CPU.
Step 2: Read the two fields yourself
Both fields are at fixed places, which makes them easy to script. The DOS header stores the offset of the PE\0\0 signature at 0x3C. The 2-byte Machine field follows the signature, and the optional header (starting with Magic) begins 24 bytes after the signature:
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+
Look at the two xunit files. Both report x86 and PE32, but only one of them is restricted to 32-bit. The header cannot tell them apart. That is the .NET case.
Step 3: .NET assemblies and AnyCPU
A .NET assembly is a PE file with a CLI header (data directory 14). When it contains only IL, the PE Machine field is just a container default. What the runtime actually looks at is the set of CLI flags in that header:
ILONLY: the image contains only IL, no native code.32BITREQUIRED: the image must run in a 32-bit process.32BITPREFERRED: together with32BITREQUIRED, this means “Prefer 32-bit”: run as 32-bit where possible, otherwise as 64-bit.
The binary overview tells you that the file is a .NET assembly and links to the .NET metadata and IL viewer, which shows the PE header and the CLI flags. Here is what it reported for my samples, with REA 6.7.0’s managed_architecture for the same files:
| File | PE (viewer) | CLI flags (viewer) | 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 pack) | PE32+ x64 | ILLIBRARY STRONGNAMESIGNED | x86_64 |
| System.Text.Json (win-arm64 pack) | PE32+ ARM64 | ILLIBRARY STRONGNAMESIGNED | arm64 |
So the rules are:
- PE32, Machine x86,
ILONLY, no32BITREQUIRED: AnyCPU. It runs as 64-bit in a 64-bit process and as 32-bit in a 32-bit one. - The same plus both
32BITREQUIREDand32BITPREFERRED: AnyCPU, Prefer 32-bit. This option only applies to executables. I did not find a published sample with it, so it is not in the table (see the note below). 32BITREQUIREDwithout32BITPREFERRED: x86 only. Loading it into a 64-bit process fails.- PE32+ with Machine x64 or ARM64: platform-specific. The runtime-pack builds above are ReadyToRun images with precompiled native code for one CPU.
To test the Prefer 32-bit case, I set both bits on a scratch copy of xunit.console.exe (flags 0x9 to 0x2000b, the combination the C# compiler writes for /platform:anycpu32bitpreferred). The viewer listed ILONLY 32BITREQUIRED STRONGNAMESIGNED 32BITPREFERRED, and REA returned "managed_architecture":"anycpu-prefer-32".
One caveat on the ReadyToRun rows. Those images have ILONLY cleared, just like a C++/CLI mixed-mode image. The viewer tells them apart by the ReadyToRun header (the RTR signature in the managed native header), so these files get only the ReadyToRun tag, not Mixed mode. REA reports implementation: "cil-and-ready-to-run" with runtime_family: "modern-dotnet" for both System.Text.Json builds.
With REA on the command line
REA is a third-party open-source tool; rea inspect-managed-artifact takes one argument, the path. I used --filter-output to keep the result short:
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 returned "anycpu" with the same command. On a native PE such as hello-arm64.exe, REA does not guess. It returns status: "not-managed", managed_architecture: "unknown" and the evidence code cli-directory-absent. For native files, use the Machine field from Step 1. More about the managed side is in Check a .NET DLL’s Target Framework, Version and References.
Step 4: The same question for ELF and Mach-O
ELF (Linux, Android) stores the class in byte 4 of the file (1 = 32-bit, 2 = 64-bit) and the CPU in e_machine. The binary overview shows them as Format and Architecture: my 32-bit Android build came out as ELF32, ARM, the 64-bit one as ELF64, AArch64. llvm-readelf agrees:
$ llvm-readelf -h hello-arm-android | grep -E "Class|Machine"
Class: ELF32
Machine: ARM
This matters for Android apps: a 64-bit-only phone cannot load a 32-bit .so. The APK analyzer checks the libraries in each lib/<abi>/ folder for you.
Mach-O can hold several architectures in one universal file. The binary overview lists the slices and lets you pick one; on the command line, lipo does the same:
$ lipo -archs hello-macos-universal
x86_64 arm64
See ELF, PE and Mach-O Viewer Online for a full walk through the identity block of all three formats.
Step 5: Command-line equivalents
On macOS and Linux, file and llvm-readobj read the same headers. I ran both on the samples:
$ 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 recognizes the .NET assemblies but, like the header, it cannot tell the AnyCPU build from the x86-only one.
On Windows, the usual tools are dumpbin /headers from a Visual Studio Developer Command Prompt (read its machine line and the magic # line under OPTIONAL HEADER VALUES) and corflags (also available in the Developer Command Prompt) for the .NET flags. I did not run them for this article, since all the samples here were built and checked on macOS. They read the same fields.
Troubleshooting
BadImageFormatException when loading a DLL. Check the DLL and the host process. If the DLL is x86-only (32BITREQUIRED) or native x86, the host must be 32-bit. If it is x64 or ARM64, the host must match. For an AnyCPU EXE, check whether Prefer 32-bit is set.
“Not a valid Win32 application”. The image’s Machine does not match what the system can run (for example, an ARM64 EXE on x64 Windows), or the file is not a PE at all. The binary overview tells you which.
Everything is AnyCPU, but a native dependency is x64. AnyCPU only covers IL. A P/Invoke target or a native NuGet asset still has a fixed architecture, so open it in the binary overview as well.
The folder says Program Files (x86). The folder is a hint about the installer, not the file. Read the header.
Can a 64-bit app use a 32-bit DLL? Not in the same process. You need a 64-bit build of the DLL, or an out-of-process bridge (a separate 32-bit process you talk to through COM or IPC).
The field values come from Microsoft’s PE Format specification; the CLI flags are defined in ECMA-335.