一份面向前端 / 后端 / 客户端同学的分享文档:为什么同一个 C/C++/Rust 源程序,在 Windows、Linux、macOS 上编译出来的可执行文件互不通用?本文从文件格式、依赖加载、运行机制、权限模型四个层面拆解差异,并给出跨平台发布的实践建议。


一、为什么可执行文件不能跨平台

一个高级语言源文件(.c / .rs / .go 等)要变成能被操作系统执行的程序,必须经历编译 → 汇编 → 链接三个阶段,最终产出的不是"机器码裸流",而是一个符合目标操作系统加载器(Loader)约定的容器文件

操作系统内核的可执行文件加载器是"专格式解析器":

  • Windows 内核只认 PE(Portable Executable)
  • Linux 内核只认 ELF(Executable and Linkable Format)
  • macOS / iOS 内核只认 Mach-O(Mach Object)

因此,即使指令集(CPU 架构)相同(例如都是 x86-64),把 Linux 上编译出的 ELF 直接拷到 Windows 上也无法运行——不是 CPU 看不懂,而是加载器无法解析对方的容器格式。这就是跨平台需要分别编译(或借助兼容层)的根本原因。

关键区分:可执行文件是否通用,取决于两个维度——① 操作系统 ABI(文件格式 + 系统调用约定);② CPU 指令集(x86-64 / ARM64 等)。二者必须同时匹配,缺一不可。


二、三大主流可执行文件格式

2.1 Windows:PE / PE32+

PE(Portable Executable)源自微软的 COFF 格式,是 Windows NT 系列的标准可执行格式,不仅用于 .exe,也用于 .dll.sys(驱动)、.ocx 等。

结构特征:

  • DOS MZ 头:文件开头是 4D 5A(“MZ”)魔术字,这是一段兼容老 DOS 的桩程序,现代系统仅用它识别格式。
  • PE 签名 + COFF 头:偏移处有 PE\0\0 签名,随后是机器类型、节数量等信息。
  • 可选头(Optional Header):区分 PE32(32 位)与 PE32+(64 位,Magic 为 0x20B),记录入口地址、镜像基址、数据目录。
  • 节表(Section Table):代码与数据按节组织,典型节包括 .text(代码)、.data(已初始化数据)、.rdata(只读数据)、.rsrc(资源)、.reloc(重定位)。

PE 的鲜明特点: 资源(图标、菜单、版本信息、字符串)被结构化地塞进 .rsrc 节,这是 Windows 图形界面程序的典型需求。

2.2 Linux / Unix:ELF

ELF 是 Unix System V 衍生出的开放标准,是 Linux、BSD 等系统的通用格式,一份文件可同时充当可执行文件、共享库(.so)、目标文件(.o,靠头部 e_type 字段区分。

结构特征:

  • ELF Header:开头是 7F 45 4C 46(“\x7FELF”)魔术字,标识文件类别、目标机器、入口地址等。
  • 程序头表(Program Header Table):面向运行时加载,描述每个可加载段(Segment)如何映射进内存虚拟地址空间,是内核 execve 加载的核心依据。
  • 节头表(Section Header Table):面向链接与调试,描述 .text / .data / .bss / .symtab 等节,供链接器和调试器使用。
  • 严格区分 链接视图(Section)执行视图(Segment):链接期看节,运行期看段。

ELF 的鲜明特点: 段与节的分离设计、动态链接信息(.dynamic.interp 指定动态链接器路径如 /lib64/ld-linux-x86-64.so.2)非常完备,是 Unix 生态"小而组合"哲学的体现。

2.3 macOS / iOS:Mach-O

Mach-O 继承自 NeXTSTEP 的 Mach 内核体系,是 Apple 全系平台的标准格式,同样兼顾可执行文件、动态库(.dylib)、Bundle、目标文件(.o)。

结构特征:

  • Mach-O Header:以 FE ED FA CE(32 位大端)等魔术字开头,记录 CPU 类型、子类型、加载命令数量。
  • Load Commands(加载命令):等价于 ELF 的程序头,逐条描述每个段/段内如何映射、动态库依赖、符号表位置等,是 dyld 加载程序的指令清单。
  • Sections:按 Segment(如 __TEXT__DATA__LINKEDIT)分组,段内再细分节。
  • Fat Binary / Universal Binary:Apple 特有的"胖文件",一个文件内打包多个架构(如 x86-64 + arm64),实现一份安装包同时兼容 Intel 与 Apple Silicon。

Mach-O 的鲜明特点: Universal Binary 的"多架构合一"、.dylib 动态库 + @rpath 路径解析机制,以及强制代码签名(详见第五节)是最具辨识度的差异点。


三、核心差异对比表

对比维度Windows (PE)Linux (ELF)macOS / iOS (Mach-O)
文件魔术字4D 5A(MZ)7F 45 4C 46(\x7FELF)FE ED FA CE / CA FE BA BE
可执行扩展名.exe无固定后缀(靠权限位 + 魔术字)无固定后缀 / .app Bundle 内二进制
动态库扩展名.dll.so.dylib / .framework
静态库扩展名.lib.a.a
目标文件扩展名.obj.o.o
内存映射依据可选头 + 数据目录程序头表(Segment)Load Commands
入口 / 加载器由 Loader 直接加载PT_INTERP 指定动态链接器LC_LOAD_DYLINKER 指定 dyld
动态库路径解析搜索 PATH、应用目录、KnownDLLsDT_RPATH/DT_RUNPATHLD_LIBRARY_PATH@rpath/@loader_path/@executable_path
多架构打包一般单架构,靠安装包分发单架构(fat 需额外工具)Universal / Fat Binary 原生支持
代码签名Authenticode 数字签名可选(内核模块常签名)强制(macOS Gatekeeper / iOS)
典型工具链MSVC (cl/link)、MinGW、LLDGNU Binutils (ld)、LLDApple clang + ld(ld-prime / ld64)

四、依赖与加载机制的差异

4.1 动态链接库的查找路径

三种系统"怎么找到依赖库"的策略差异极大,是跨平台部署踩坑的重灾区:

  • Windows:按固定顺序搜索——应用程序所在目录 → 系统目录(System32)→ PATH 环境变量目录。LoadLibrary 是显式加载入口。问题:同名 DLL 容易被"劫持",且系统库与应用库可能版本冲突(俗称 DLL Hell)。
  • Linux:可执行文件内嵌 DT_RUNPATH / DT_RPATH,再回退到 /etc/ld.so.cache(由 ldconfig 生成),最后可用 LD_LIBRARY_PATH 临时覆盖。可通过 ldd 命令查看依赖清单。
  • macOS:使用 @rpath(运行时替换)、@loader_path(相对加载者)、@executable_path(相对主程序)三种占位符做可重定位路径解析,配合 install_name_tool 修改、otool -L 查看依赖。

4.2 加载器与启动流程

  • Windows:内核建立进程后,由用户态 ntdll.dll 中的加载器负责映射模块、解析导入表(IAT),并调用 DllMain
  • Linux:内核读取 ELF 程序头映射段,再跳转到 PT_INTERP 指定的动态链接器(如 ld-linux-x86-64.so.2),由其完成符号解析后跳转到程序入口。
  • macOS:内核把控制权交给动态链接器 dyld,dyld 解析 Load Commands、递归加载依赖、做绑定(binding)与重定位,再启动 main

三者都支持延迟绑定(lazy binding)符号插桩,但在导入表 / 符号表的具体组织上各不相同。


五、安全与权限模型的差异

机制WindowsLinuxmacOS
能否运行扩展名 + 数字签名 + SmartScreen文件执行权限位chmod +x)+ 魔术字执行权限 + 强制代码签名 / 公证(Notarization)
防篡改Authenticode 签名验证内核模块签名、dm-verity签名 + Gatekeeper 公证,未签名会被拦截
细粒度限制UAC、AppLockerSELinux / AppArmor、CapabilitiesSIP(系统完整性保护)、沙箱
常见坑杀软 / SmartScreen 误拦截、DLL 劫持忘加执行权限、glibc 版本不兼容未签名 / 未公证被系统阻止运行、架构不匹配

实践含义:在 macOS 上分发未签名、未公证的二进制,用户会直接遇到"无法打开 / 已损坏"提示;在 Linux 上忘记 chmod +x 会报 Permission denied;在 Windows 上则可能触发 SmartScreen 警告或杀软误报。跨平台发布时,签名、公证、执行权限是必须分别处理的三件事。


六、给开发者的跨平台实践建议

  1. 不要试图直接拷贝可执行文件跨系统运行:指令集与 ABI 都必须匹配,要么在目标平台分别编译,要么走兼容层(Linux 上的 Wine 转译 Windows PE,macOS 上 Rosetta 2 转译 x86-64 到 arm64)。
  2. 优先用统一工具链降低心智负担:LLVM / Clang + LLD 已能生成 PE、ELF、Mach-O 三种格式(通过 --target 指定目标三元组),Rust、Go 也内置交叉编译能力(GOOS / GOARCH、Rust --target)。
  3. 发布时按平台分别打包并处理签名:Windows 走 Authenticode 签名;macOS 走 Developer ID 签名 + 公证;Linux 通常按发行版(deb / rpm / AppImage)分发。
  4. 善用架构维度:macOS 优先产出 Universal Binary;其余平台在 CI 中针对 x86-64 与 arm64 分别交叉编译,再统一打包。
  5. 用对应工具排查依赖:Windows 用 dumpbin /dependents,Linux 用 ldd / readelf,macOS 用 otool -L / vtool

七、一句话总结

可执行文件的差异,本质是三套操作系统为"如何把字节变成进程"各自定义了一套容器格式与加载契约:Windows 用 PE、Linux 用 ELF、macOS 用 Mach-O。它们在文件魔术字、内存映射依据、动态库查找、多架构打包与代码签名上各有设计取舍。理解这些差异,才能真正搞懂"为什么这份代码在我电脑能跑、在服务器上跑不了",也才能设计出健壮的跨平台构建与发布流程。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐