
前一阵子接了个活要把一个原本在 Windows 上编译的 C 库搬到 Linux 构建机上跑 CI产物还得是 Windows 那边能直接LoadLibrary的 DLL。功能层面其实两天就搞定了真正让我多花了一周的是后面这半句话——客户那边的发布清单要求 DLL 的属性-详细信息里必须能看到版本号、版权、产品名称否则打包流程的合规检查直接判不合格。于是问题变成了在 Linux 上用 MinGW 交叉编译出 Windows DLL并且给它塞进完整的 version 和 copyright 资源。这件事听起来像是加个文件重新编译的小事但真正动过手的人都知道里面全是细节工具链装哪一版、windres的代码页怎么选、VERSIONINFO 的四段数字和字符串版本对不上会怎样、资源.o放在链接命令的哪个位置才不会被丢掉、编完了怎么在不打开 Windows 的情况下确认资源真的进了.rsrc节。我前后踩了七八个坑有的坑是编译通过但版本页空白有的是中文版权名变成一串问号还有的是strip之后资源莫名其妙消失。下面把这些东西按我当时的推进顺序重新捋一遍从工具链选型到.rc文件写法再到链接、验证和排错尽量给到可以直接抄的配置和命令。适合已经在 Linux 上交叉编译过至少一次 Windows 目标、现在卡在产物不够像样这一步的朋友看。1. 需求拆解与工具链选型先把编译得出来和看得过去分开1.1 两个层次的需求能加载和被认出来我把这件事拆成两层。第一层是二进制可用生成的 DLL 能被 Windows 加载器正常识别导出表里有该有的符号调用方GetProcAddress拿得到函数地址。这一层只要交叉编译工具链装对了一个-shared就能解决难度不高。第二层是资源可读双击文件看属性或者用 PowerShell 的(Get-Item foo.dll).VersionInfo读出来FileVersion、ProductVersion、LegalCopyright、CompanyName这些字段都有值。这一层走的是 Windows PE 的.rsrc资源节跟代码编译完全是两条线需要额外引入资源编译器。这两层的关系要理清楚它们互不依赖。DLL 没有资源节照样能加载、能调用只是看起来像个野文件反过来资源节写错了也不影响 DLL 的功能只是属性页显示异常。分清这一点很关键因为它决定了排错方向——如果 DLL 加载失败问题在链接和导出如果加载正常但属性页空白问题一定在.rc文件和资源链接环节别去翻编译器参数。1.2 mingw-w64 的三条分叉位数、运行时、线程模型MinGW 这个名字现在基本指 mingw-w64 这个分支老的那个 MinGW.org 早就不维护了。装之前先明确三个选择它们直接决定最终的 DLL 能不能在目标机器上跑。第一是位数。32 位和 64 位对应不同的工具链前缀i686-w64-mingw32-和x86_64-w64-mingw32-。这两套工具链是完全独立的不能用一个编出另一个。我建议一开始就把两套都装上因为很多老项目还在 32 位环境里跑临时补装会很狼狈。第二是 C 运行时。mingw-w64 默认链接 msvcrt也就是 Windows 自带的那套老运行时好处是目标机不需要额外分发任何东西另一条路是 UCRT需要-lucrt并且目标系统上要有对应的通用运行时组件。对于要发给外部用户的 DLL我一般选 msvcrt省掉一堆为什么在我机器上能跑的扯皮。第三是线程模型。Debian 系发行版上的 mingw-w64 包会同时提供 posix 和 win32 两个变体用update-alternatives切换。默认往往是 win32 模型它不支持 C11 的std::thread、std::mutex这些如果你的库里有任何多线程代码必须切到 posix 变体。这个切换是全局的切换完整个工具链都会变所以切换之后最好清一遍构建目录重新编。选择项选项 A选项 B我的默认选择位数i686-w64-mingw32x86_64-w64-mingw32按目标客户环境定两套都装C 运行时msvcrt系统自带ucrt需分发msvcrt线程模型win32posix有 C 多线程就选 posix异常模型Csjljseh仅 64 位64 位优先 seh2. 环境准备与最小可运行 DLL2.1 装工具链别只装 gcc在 Debian 或 Ubuntu 上apt install mingw-w64会把整套东西拉下来包括 gcc、g、binutils 和资源编译器。但如果你只需要 64 位更精确的做法是装具体包名避免拖进来一堆用不上的东西sudo apt update sudo apt install -y gcc-mingw-w64-x86-64 \ g-mingw-w64-x86-64 \ binutils-mingw-w64-x86-64装完先确认三件套都在x86_64-w64-mingw32-gcc --version x86_64-w64-mingw32-windres --version x86_64-w64-mingw32-objdump --versionwindres是资源编译器负责把.rc文本编译成 COFF 目标文件它是整个流程里最容易被忽略的一环。很多人装完 gcc 就以为齐了结果windres: command not found回头补装。另外注意binutils-mingw-w64-x86-64这个包必须显式装上objdump、strip、dlltool都在里面后面验证环节全靠它。顺手确认一下线程模型输出里会标明x86_64-w64-mingw32-gcc -v 21 | grep -i thread看到--enable-threadsposix或者--enable-threadswin32就是当前生效的模型。要切换的话sudo update-alternatives --config x86_64-w64-mingw32-gcc2.2 一个 20 行的 DLL 和它的导出表先做一个不带资源的裸 DLL把基线跑通。这步的意义在于后面加了资源出问题你能快速判断是资源的问题还是本来就有问题。/* mathlib.h */ #ifndef MATHLIB_H #define MATHLIB_H #ifdef _WIN32 # ifdef MATHLIB_EXPORTS # define MATHLIB_API __declspec(dllexport) # else # define MATHLIB_API __declspec(dllimport) # endif #else # define MATHLIB_API #endif #ifdef __cplusplus extern C { #endif MATHLIB_API int math_add(int a, int b); MATHLIB_API const char *math_build_tag(void); #ifdef __cplusplus } #endif #endif/* mathlib.c */ #include mathlib.h int math_add(int a, int b) { return a b; } const char *math_build_tag(void) { return mathlib-1.2.3; }编译x86_64-w64-mingw32-gcc -O2 -DMATHLIB_EXPORTS \ -shared mathlib.c \ -o mathlib.dll \ -Wl,--out-implib,libmathlib.dll.a关键参数只有两个-shared告诉链接器产出 DLL-Wl,--out-implib顺手生成导入库Windows 那边调用方链接的时候要用它。__declspec(dllexport)是标准做法比--export-all-symbols精确得多后者会把所有非静态符号都导出去包括你不想暴露的内部函数导出的名字还会带上一堆修饰很难看。编完立刻验一下导出表x86_64-w64-mingw32-objdump -p mathlib.dll | grep -A 10 Export Address Table看到math_add和math_build_tag两个符号就说明基线没问题。这一步做完剩下的全部精力都可以放在资源上。3. 把 version 和 copyright 写进 .rcVERSIONINFO 逐字段拆解3.1 VERSIONINFO 的骨架与两类 BLOCKWindows 的版本信息不是随便写的键值对它有一套固定的递归结构VS_VERSION_INFO下面挂两个 BLOCK一个是StringFileInfo装字符串另一个是VarFileInfo装二进制变量。字符串块下面还有一层语言代码页子块再下面才是真正的VALUE行。少一层或者层级写错资源编进去但读不出来。一个能用的最小骨架长这样#include winver.h VS_VERSION_INFO VERSIONINFO FILEVERSION 1,2,3,4 PRODUCTVERSION 1,2,0,0 FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_DLL FILESUBTYPE VFT2_UNKNOWN BEGIN BLOCK StringFileInfo BEGIN BLOCK 080404b0 BEGIN VALUE CompanyName, Example Works\0 VALUE FileDescription, Example math helper library\0 VALUE FileVersion, 1.2.3.4\0 VALUE InternalName, mathlib\0 VALUE LegalCopyright, Copyright (C) 2024 Example Works. All rights reserved.\0 VALUE OriginalFilename, mathlib.dll\0 VALUE ProductName, Example Mathlib\0 VALUE ProductVersion, 1.2.0.0\0 VALUE Comments, Built on Linux with mingw-w64\0 END END BLOCK VarFileInfo BEGIN VALUE Translation, 0x804, 1200 END ENDFILEFLAGS那几个标志位要不要用如果这是调试版本把FILEFLAGS写成VS_FF_DEBUG属性页会显示调试字样如果是对外发布的正式版就保持0x0L。这看起来是小事但有些发布流程会自动检查有没有带调试标志带了就拒绝入库。3.2 数字版本与字符串版本必须对齐的四段这里有个我卡了半天的坑FILEVERSION和FileVersion字符串是两个独立字段Windows 不会帮你同步。资源管理器属性页上方显示的那个版本号读的是FILEVERSION而详细信息列表里显示的文件版本读的是字符串表里的FileVersion。两者不一致的时候会出现上面写 1.2.3.4下面写 1.2.0.0的诡异现象客户一眼就能看出来。所以我的规矩是FILEVERSION用四个逗号分隔的数字FileVersion用四个点分隔的字符串两边逐位对应写错一位都得改。PRODUCTVERSION和ProductVersion同理。还有一个硬限制这四个数字每段的范围是 0 到 65535因为它们在二进制结构里是 4 个 WORD。如果你习惯用日期做版本号比如20240501直接超范围编译时windres会报错或者悄悄截断。我遇到过一次截断20501这种值出来之后属性页显示得莫名其妙查了半天才发现是溢出。版本号的自动注入也是常见需求。我的做法是不手改.rc而是在构建时用脚本生成一份头部文件#!/bin/sh # gen_version.sh —— 由构建系统调用 VER_MAJOR1; VER_MINOR2; VER_PATCH3; VER_BUILD${BUILD_NUMBER:-0} cat version_gen.h EOF #define VER_FILEVERSION ${VER_MAJOR},${VER_MINOR},${VER_PATCH},${VER_BUILD} #define VER_FILEVERSION_STR ${VER_MAJOR}.${VER_MINOR}.${VER_PATCH}.${VER_BUILD} #define VER_PRODUCTVERSION ${VER_MAJOR},${VER_MINOR},0,0 #define VER_PRODUCTVERSION_STR ${VER_MAJOR}.${VER_MINOR}.0.0 #define VER_COPYRIGHT_STR Copyright (C) 2024 Example Works. All rights reserved. EOF然后.rc里#include version_gen.h把FILEVERSION写成VER_FILEVERSIONLegalCopyright写成VER_COPYRIGHT_STR。这样 CI 每次构建自动带上正确的构建号不需要人去改文件也不会出现忘记更新版本号就发版的事故。3.3 LegalCopyright 与编码中文不乱码的关键LegalCopyright这个字段是客户最在意的因为法务会检查。它的值是一段普通字符串理论上可以写任何内容。但一旦里面有中文麻烦就来了。windres默认按系统代码页解释.rc文件在 Linux 上通常是 UTF-8 环境但windres未必认这个。两种稳妥的做法一是把.rc存成 UTF-8 并在命令行显式指定代码页 65001二是存成 GBK 并指定 936。我选第一种因为 Git 仓库里统一 UTF-8 更好管理x86_64-w64-mingw32-windres -i version.rc -o version_res.o \ -O coff --codepage65001--codepage65001这个参数就是告诉windres输入文件是 UTF-8。少了它中文版权行在 Windows 属性页里会变成一堆方块或者问号。我最早就是漏了这个参数DLL 在英文环境看着一切正常切到中文系统一看版权名全是乱码被退回一次。另外字符串末尾那个\0别省。VERSIONINFO 的字符串在二进制结构里是长度前缀加内容虽然大多数情况下不加也读得出来但遇上某些版本的资源读取器会多读一个字节导致后面跟一个乱字符。我现在的习惯是每条VALUE都以\0结尾无一例外。关于语言代码BLOCK 080404b0里的0804是简体中文的语言 ID04b0是代码页 1200也就是 Unicode。VarFileInfo里的VALUE Translation, 0x804, 1200必须和它一致。这两处不一致的话Windows 有时能兜底找到有时直接不显示属于典型的偶尔能用的坑——这种坑最难查因为换台机器可能就好了。4. 资源编译与链接让 windres 的产物真正进到 DLL 里4.1 windres 的三种输出格式与常用参数windres的-O参数控制输出格式可选res、coff、rc等。交叉编译场景下必须用coff因为它产出的是 COFF 目标文件能被 mingw 的链接器直接吃进去。用res格式产出的是 Windows 资源文件链接器不认识。完整命令x86_64-w64-mingw32-windres \ -i version.rc \ -o version_res.o \ -O coff \ --codepage65001 \ -I .-I .是包含路径因为.rc里#include了生成的version_gen.h。这个参数经常被忘报错是cant open file看起来像权限问题其实是搜索路径没给。windres还有个隐藏开关--preprocessor可以指定用哪个预处理器。默认行为在某些发行版上会走 gcc 的预处理某些上会走内置的简化版遇到带复杂宏的.rc可能出现行为差异。如果你的.rc里用了条件编译建议显式写成x86_64-w64-mingw32-windres --preprocessorx86_64-w64-mingw32-gcc -E \ -i version.rc -o version_res.o -O coff --codepage650014.2 链接顺序、符号冲突和 .o 的放置位置资源.o跟普通目标文件一样直接加到链接命令里就行x86_64-w64-mingw32-gcc -O2 -DMATHLIB_EXPORTS \ -shared mathlib.c version_res.o \ -o mathlib.dll \ -Wl,--out-implib,libmathlib.dll.a位置其实不太敏感放在源文件后面或者前面都行。但有一个真实存在的情况会让资源丢链接器开了--gc-sections而资源节没有任何外部引用可能被当成垃圾回收掉。MinGW 的 ld 对.rsrc节一般会保留但为了保险我不在负责产出 DLL 的链接命令里开这个选项。如果项目主构建体系里全局开了-ffunction-sections -fdata-sections -Wl,--gc-sections那就单独把这个链接目标摘出来。另一个容易踩的点是同时用.def文件和.rc。有些老项目习惯用.def文件声明导出.def里还能写VERSION 1.2这样的语句。但那个VERSION是模块版本跟.rsrc里的版本资源完全是两码事写了也不会出现在属性页里。两套机制混着用不会冲突但别指望.def里的版本号能替代.rc。还有一个细节如果你链接的静态库或者目标文件里已经带了一份资源节两份.rsrc合并时可能冲突。表现是链接报multiple definition of VS_VERSION_INFO之类的错。解决办法是确保整个工程只有一个.rc参与链接第三方库的资源先剥掉。4.3 Makefile 与 CMake 两种落地方式手敲命令只适合验证真项目里得落到构建系统。Makefile 版本最直观CROSS ? x86_64-w64-mingw32 CC : $(CROSS)-gcc WINDRES : $(CROSS)-windres CFLAGS : -O2 -Wall -DMATHLIB_EXPORTS LDFLAGS : -shared -Wl,--out-implib,libmathlib.dll.a all: mathlib.dll version_gen.h: ./gen_version.sh version_res.o: version.rc version_gen.h $(WINDRES) -i version.rc -o $ -O coff --codepage65001 -I . mathlib.dll: mathlib.c version_res.o $(CC) $(CFLAGS) $^ -o $ $(LDFLAGS) clean: rm -f mathlib.dll libmathlib.dll.a version_res.o version_gen.h这里把version_res.o写成显式依赖而不是靠通配符匹配目的是保证version_gen.h先生成。这个依赖关系漏掉的话第一次干净构建会因为头文件不存在直接失败第二次就好了——典型的在我机器上能编问题。CMake 版本稍麻烦因为要告诉 CMake 用交叉工具链的windres。先写 toolchain 文件# toolchain-mingw64.cmake set(CMAKE_SYSTEM_NAME Windows) set(CMAKE_SYSTEM_PROCESSOR x86_64) set(TOOLCHAIN_PREFIX x86_64-w64-mingw32) set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}-gcc) set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}-g) set(CMAKE_RC_COMPILER ${TOOLCHAIN_PREFIX}-windres) set(CMAKE_FIND_ROOT_PATH /usr/${TOOLCHAIN_PREFIX}) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)然后CMakeLists.txt里启用 RC 语言cmake_minimum_required(VERSION 3.16) project(mathlib C) enable_language(RC) set(CMAKE_RC_FLAGS ${CMAKE_RC_FLAGS} --codepage65001) add_library(mathlib SHARED mathlib.c version.rc) target_compile_definitions(mathlib PRIVATE MATHLIB_EXPORTS) set_target_properties(mathlib PROPERTIES PREFIX OUTPUT_NAME mathlib SUFFIX .dll)PREFIX 这行不加的话CMake 会给产物加个lib前缀出来叫libmathlib.dllWindows 那边按名字加载会找不到。enable_language(RC)必须在project()之后调用写在前面会报找不到编译器。这两点我都栽过。5. 验证与排错从命令行确认资源真的进去了5.1 三条命令验证资源节编完别急着拷到 Windows 上试Linux 本地就能验。第一条看资源节是否存在x86_64-w64-mingw32-objdump -h mathlib.dll | grep -i rsrc输出里能看到.rsrc节和它的大小非零就说明资源编进去了。如果这行都没有问题在链接阶段回头检查version_res.o有没有出现在链接命令里。第二条看版本表的具体内容x86_64-w64-mingw32-objdump -p mathlib.dll | grep -i -A 40 Version Tables这条能看到VS_VERSION_INFO结构被解析出来的样子包括FILEVERSION的十六进制值。这里能直接确认数字版本对不对比打开属性页靠谱。第三条直接掏字符串。VERSIONINFO 里的字符串是 UTF-16LE 存储的所以要用strings的宽字符模式strings -el mathlib.dll | grep -i -E copyright|version|product-el表示按 16 位小端解释少了这个参数grep什么都搜不到会误以为资源没进去。这一条对中文特别有用能直观看到LegalCopyright里到底存的是正常汉字还是乱码。如果想更结构化一点用 Python 的pefile库读import pefile pe pefile.PE(mathlib.dll) for fileinfo in pe.FileInfo: for entry in fileinfo: if entry.Key bStringFileInfo: for table in entry.StringTable: for k, v in table.entries.items(): print(k.decode(), , v.decode(utf-8, replace))这个脚本的输出跟 Windows 属性页读的是同一份数据CI 里可以拿它做断言比如检查LegalCopyright非空、FileVersion符合正则不通过就让流水线挂掉。这比人工抽查可靠得多。5.2 问题速查表下面这些是我和同事在这条路上遇到过的症状按出现频率排序症状最可能原因处理方式属性页详细信息整页空白资源.o没参与链接检查链接命令里有没有version_res.o版本号上面有下面没有FILEVERSION与FileVersion不一致逐位对齐两处都改中文版权显示为方块windres代码页不对加--codepage65001源文件存 UTF-8windres报cant open file缺-I包含路径补-I .或头文件所在目录链接报符号重定义多个.rc或库自带资源只保留一个.rc剥离第三方资源版本号显示成奇怪数字单段超过 65535 被截断改成日期分段或缩短数字strip后资源消失用了过度激进的剥离参数改用--strip-unneeded别用--strip-all配合自定义脚本CMake 产物多出lib前缀没设PREFIX 在set_target_properties里显式置空关于strip那条多说一句。strip一般只删符号表和调试信息.rsrc节本来不会动。但如果你的发布脚本用objcopy去做更复杂的裁剪比如--only-keep-debug加--strip-all的组合就可能把非代码节一起干掉。我的做法是剥离后立刻用objdump -h检查.rsrc还在不在把它写进发布脚本的断言里。5.3 我在实际项目里踩过的几个坑坑一Translation 块和字符串块语言代码打架。一开始我在字符串块里写040904b0英文-美国但Translation写的是0x804, 1200中文-简体。在一台英文 Windows 上属性页正常换到中文系统就成了空白。原因是资源查找会按当前系统语言去匹配找不到就回退回退逻辑在不同 Windows 版本上表现不一致。后来的统一做法是字符串块和 Translation 的语言 ID 严格一致要么都用英文要么都用中文别混。坑二windres版本和 gcc 版本不匹配。有一次在一台老服务器上windres是几年前装的gcc 是新装的编出来的资源.o链接时机器类型对不上报file format not recognized。这个问题非常隐蔽因为两个命令单独跑都没问题。解决办法是确保gcc-mingw-w64-*和binutils-mingw-w64-*来自同一个发行版仓库、同一批次安装。跨发行版混装工具链是自找麻烦。坑三OriginalFilename写了带路径的名字。这个字段按约定只放文件名不含路径。我早期偷懒写了个相对路径进去某些安装器会拿这个字段做校验结果匹配失败。现在统一只写mathlib.dll。坑四以为改了.rc就会重编。构建系统里如果没有把.rc列为依赖改了版权年份重新构建产物还是旧的。这个问题在增量构建里特别容易漏建议把.rc和生成的头文件都显式加进依赖链并且 CI 每次做全量构建。坑五32 位和 64 位共用同一个中间产物目录。两套工具链编出来的.o机器类型不同混在一个目录里第二次构建的时候链接器拿到上一次的.o直接失败。现在我的目录结构一律是build/x86_64/和build/i686/分开中间产物绝不共享。这套流程跑顺之后从改版本号到产出带完整资源的 DLL在 CI 上大概十几秒。我的建议是把验证步骤也写进流水线——编完立刻用pefile读一遍FileVersion和LegalCopyright对不上就红别等到打包环节才发现问题。版本资源这种东西平时没人看一旦被看到就是在出问题的时候。