1. 为什么要把 gettext 塞进 CMake 构建流程
先说我自己的处境。接手了一个需要支持三种语言的 C 项目,界面文案要跟着主程序一起发布。最开始的思路很简单:翻译交给翻译平台,PO 文件拿回来,手动跑一遍msgfmt把.mo文件放到指定目录,然后继续编程序。听起来没问题,但实际做起来完全不是那么回事——PO 文件一多,手动操作就会出乱子;没有统一的构建入口,谁也不知道当前产出的.mo文件到底对应哪个版本的 PO;更不用说新同事加入之后,第一步不是写代码,而是先学会一套"人肉构建流程"。
后来我把整个多语言流程纳入了 CMake,从扫描源码提取翻译条目,到生成.mo文件,再到安装到正确路径,一条命令全部跑通。这篇文章就是把整个过程拆开给你看:每一步为什么这么做、CMake 里对应的机制是什么、以及我在实际项目中踩过的坑。
如果你手头项目也是用 CMake 管理,并且需要支持多语言,这篇内容可以直接照着改。了解 gettext 的基本概念会有帮助,但即使不熟悉,我也会把 po、pot、mo 三者的关系讲清楚。
2. gettext 工作链里的核心角色:pot、po、mo
在写 CMake 之前,得先把 gettext 这套工具链的"原料流向"弄清楚。其实就三个文件格式:
| 文件类型 | 用途 | 生成方式 |
|---|---|---|
.pot | 从源码提取出的原始翻译模板,只含英文(或默认语言)文本 | xgettext扫描源文件生成 |
.po | 面向人类翻译者的文件,每条英文对应一种语言的译文 | 由.pot复制或经翻译平台转换而来 |
.mo | 编译后的二进制文件,程序运行时读取 | msgfmt编译.po生成 |
这里的核心逻辑是"三层流转":源码里的字符串 →.pot模板 → 各语言.po文件 →.mo二进制运行文件。CMake 要做的,就是把这条链路固化下来,让它在每次构建时按需执行。
这里有一个初学者容易忽略的点:.mo文件并不是"源文件",而是"构建产物"。它跟.o文件、可执行文件一样,应该由构建系统生成,而不是直接提交到版本库里。把.mo当成普通文件放仓库里,会导致两个问题:一是 PO 文件更新后很容易忘记重新编译;二是不同平台生成.mo的方式有细微差异,手动处理的出错概率高。
明白了这几类文件的关系,就自然会问一个问题:构建系统里怎么把"生成.mo"变成一条自动规则?这正是 CMake 擅长的地方。
3. 工具链的检测与方案选型:不靠运气靠探测
3.1 三种可选集成方式的对比
在 CMake 里集成 gettext,我总共试过三种思路,各有取舍,先把结论放出来,再逐一分析。
第一种是依赖外部脚本。CMakeLists 里写add_custom_command去调用msgfmt,前提是系统里已经装好了 gettext 工具链。这种方式最直接,也是我最终采用的方案。它的好处是完全可控,缺什么命令都能在配置阶段提前暴露,不会等到构建中途才报"找不到 msgfmt"。
第二种是ExternalProject。把整个 gettext 的构建过程作为一个外部项目拉下来编译。这套方式适合目标机器上完全没有 gettext 工具链的场景,比如交叉编译环境。但对大多数桌面项目来说,这属于过度设计——为了几个文本文件,把整条工具链下载编译一遍,成本和收益不成正比。
第三种是基于 CMake 自身模块。CMake 从版本 3.12 起对国际化支持逐渐增强,有Gettext模块可供使用。这个模块提供了一些便捷的宏,看起来很方便,但有个问题:它对项目的目录结构有预设要求,如果项目里 PO 文件的组织方式跟默认期待不一致,反而需要更多额外配置来"纠正"它的行为。
我最终选的是第一种:用 CMake 的命令探测机制找到msgfmt、xgettext、msginit等工具,然后通过add_custom_command把整个翻译构建流程串起来。理由很简单——项目的目录结构和翻译流程完全由自己掌控,CMake 只负责按依赖关系编排。
3.2 探测工具时的细节
CMake 里找外部可执行程序,标准做法是find_program。但这里有个坑:gettext 工具在不同操作系统上的命名和路径差得很多。
Linux 上通常在/usr/bin下,名字就是msgfmt;macOS 上如果通过 Homebrew 安装,路径可能变成/opt/homebrew/bin/msgfmt,好在find_program会搜索 PATH 环境变量里的路径;Windows 上则需要额外指定搜索路径——如果你用 MSYS2 或 Cygwin 环境,路径一般类似于C:\msys64\usr\bin。
所以我的探测配置一般是这样的:
find_program(GETTEXT_MSGFMT_EXECUTABLE NAMES msgfmt HINTS ${GETTEXT_BIN_DIR} REQUIRED ) find_program(GETTEXT_XGETTEXT_EXECUTABLE NAMES xgettext HINTS ${GETTEXT_BIN_DIR} REQUIRED )注意我用了REQUIRED关键字。这意味着如果找不到工具,CMake 配置阶段就直接报错退出,而不是等到构建时才出问题。这是一个值得坚持的原则:msgfmt这类工具是构建的硬依赖,不存在"程序里没用到就不用装"的说法,因为翻译文件是最终安装包的一部分。
4. 在 CMake 中落地一条完整翻译流水线的核心配置
4.1 从源码扫描到生成.pot文件
这一步的目标是让xgettext扫描项目源代码,把其中标记为"需要翻译"的英文字符串全部提取出来,生成.pot模板。
先明确要扫描哪些文件。C 项目里通常是.c和.cpp文件,但如果你有头部文件里也包含待翻译字符串,也需要加上。我的做法是单独收集一份源文件列表:
set(PROJECT_SOURCES src/main.c src/ui.c src/options.c )然后写xgettext的调用。这里有一个关键的参数设定:--keyword。gettext 的标准做法是把字符串包进_()函数(其实是gettext()的宏),xgettext默认就会找这个关键字。如果你的项目用了别的函数名,比如tr()或自定义的N_(),那必须在命令行里显式声明。
我的做法是同时申明多个关键字:
set(XGETTEXT_OPTIONS --keyword=_ --keyword=N_ --keyword=tr --from-code=UTF-8 --add-comments=TRANSLATORS: )--from-code=UTF-8是防止 Windows 上因为源码编码不同而抽取乱码的关键选项。--add-comments=TRANSLATORS:的作用是把源代码中标记了TRANSLATORS:前缀的注释也提取到.pot文件里,给翻译人员提供上下文信息。
然后生成.pot文件。为了让xgettext正常工作,需要给它正确的源代码目录作为工作目录:
add_custom_command( OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/po/messages.pot COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/po COMMAND ${GETTEXT_XGETTEXT_EXECUTABLE} ${XGETTEXT_OPTIONS} -o ${CMAKE_CURRENT_BINARY_DIR}/po/messages.pot ${PROJECT_SOURCES} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} DEPENDS ${PROJECT_SOURCES} COMMENT "生成 pot 翻译模板" )这里稍微解释一下add_custom_command的OUTPUT机制:CMake 会把那个messages.pot当作一个构建目标来看待,任何其他目标如果依赖这个文件,构建系统会自动先执行这条命令。DEPENDS ${PROJECT_SOURCES}确保源码变化时会触发重新扫描。
4.2 从.pot更新.po文件
.po文件有两种来源:一种是从无到有新建,另一种是在已有基础上更新。CMake 里这两者用不同的命令处理。
新建.po文件,需要针对每一个语言环境执行msginit。注意这里的技巧:msginit工具会读取系统当前的 locale 设置,如果不显式指定语言,同一台机器上生成的文件可能各不相同。所以语言参数必须写死:
set(LANGUAGES zh_CN de_DE)对应的.po路径可以这样组织:
po/zh_CN.po po/de_DE.po生成逻辑的 CMake 部分:
foreach(LANG ${LANGUAGES}) set(PO_FILE ${CMAKE_CURRENT_BINARY_DIR}/po/${LANG}.po) add_custom_command( OUTPUT ${PO_FILE} COMMAND ${GETTEXT_MSGFMT_EXECUTABLE} --check -o /dev/null ${PO_FILE} COMMAND ${CMAKE_COMMAND} -E echo "PO file exists: ${PO_FILE}" DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/po/messages.pot COMMENT "检查 ${LANG} 的 po 文件" ) endforeach()等等,上面的写法有问题,这只是做一个存在性检查。更合理的方式是先判断.po文件是否存在,如果不存在就调用msginit创建,存在则调用msgmerge合并更新。CMake 里的话,可以在配置阶段进行判断:
foreach(LANG ${LANGUAGES}) set(PO_FILE ${CMAKE_CURRENT_SOURCE_DIR}/po/${LANG}.po) if(NOT EXISTS ${PO_FILE}) add_custom_command( OUTPUT ${PO_FILE} COMMAND ${GETTEXT_MSGFMT_EXECUTABLE} --version COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/po COMMAND ${GETTEXT_MSGFMT_EXECUTABLE} --check -o ${PO_FILE} ${CMAKE_CURRENT_BINARY_DIR}/po/messages.pot DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/po/messages.pot WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/po COMMENT "为 ${LANG} 生成 po 文件" ) else() add_custom_command( OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/po/${LANG}.po COMMAND ${GETTEXT_MSGFMT_EXECUTABLE} --check -o /dev/null ${PO_FILE} DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/po/messages.pot ${PO_FILE} COMMENT "检查 ${LANG} 的 po 文件" ) endif() endforeach()这个判断有一个明显的分层问题:.po文件是独立于构建目录的"资源文件",它由翻译人员维护。把它当作纯构建产物来强制生成,不太符合实际工作流——翻译人员通常是在本地改po/zh_CN.po,改完后直接提交版本库。
所以更贴近实际的做法是:.pot作为构建产物自动生成,供翻译参考;.po作为版本库中的源文件存在,构建系统只负责把它编译成.mo,不负责创建。
这样可以避免"每次构建都发现.po文件更新了"的烦恼。我的做法是:
set(PO_DIR ${CMAKE_CURRENT_SOURCE_DIR}/po) set(POT_FILE ${CMAKE_CURRENT_BINARY_DIR}/po/messages.pot) add_custom_target(update-po COMMAND ${GETTEXT_MSGFMT_EXECUTABLE} --version COMMAND ${CMAKE_COMMAND} -E echo "POT 文件: ${POT_FILE}" DEPENDS ${POT_FILE} COMMENT "pot 已更新,可执行 msgmerge 合并到各 po 文件" )翻译人员拿到新的.pot后,一个标准命令是:
msgmerge --update po/zh_CN.po po/messages.pot如果想在 CMake 里提供一键合并的能力,可以加一个update-po的自定义 target,对每种语言执行上述命令。这是"按需执行"的典型场景,没必要放进常规构建流程。
4.3 把.po编译成.mo:最核心的一步
到了真正该自动化的部分:每次构建开始时,把每个语言的.po编译成.mo文件。这里才是add_custom_command+add_custom_target协作发挥价值的地方。
先说命令本身:
set(MO_DIR ${CMAKE_CURRENT_BINARY_DIR}/mo) foreach(LANG ${LANGUAGES}) set(PO_SRC ${CMAKE_CURRENT_SOURCE_DIR}/po/${LANG}.po) set(MO_OUTPUT ${MO_DIR}/${LANG}.mo) add_custom_command( OUTPUT ${MO_OUTPUT} COMMAND ${CMAKE_COMMAND} -E make_directory ${MO_DIR} COMMAND ${GETTEXT_MSGFMT_EXECUTABLE} --check --statistics -o ${MO_OUTPUT} ${PO_SRC} DEPENDS ${PO_SRC} COMMENT "编译 ${LANG} 的 mo 文件" ) list(APPEND MO_FILES ${MO_OUTPUT}) endforeach()这里的--check参数会在 PO 文件有格式错误时直接报错,避免把有问题翻译装到用户系统上。--statistics会输出翻译进度,比如"已翻译 120 条,模糊 5 条,未翻译 3 条",对于评估翻译质量很直观,建议保留。
然后把MO_FILES全部关联到一个自定义 target 上,保证构建时自动执行:
add_custom_target(translations ALL DEPENDS ${MO_FILES} COMMENT "构建多语言翻译文件" )注意ALL关键字——没有它,translations目标只有在显式指定时才会执行,这样达不到"一键构建"的效果。加上ALL之后,默认make就会触发整个翻译流程。
4.4 安装阶段:让.mo文件进入正确位置
程序运行时,gettext 会按照一个固定模式去查找.mo文件。用textdomain()设置了域之后,查找路径通常是:
<prefix>/share/locale/<语言名>/LC_MESSAGES/<domain>.mo比如我的项目叫demoapp,语言环境zh_CN,安装前缀是/usr/local,那最终路径就是/usr/local/share/locale/zh_CN/LC_MESSAGES/demoapp.mo。
这意味着构建目录里生成的zh_CN.mo不能直接放到那个位置,安装时还要重组目录结构。CMake 里做这一步要用install(FILES ...):
foreach(LANG ${LANGUAGES}) install(FILES ${MO_DIR}/${LANG}.mo DESTINATION share/locale/${LANG}/LC_MESSAGES/ RENAME ${PROJECT_NAME}.mo) endforeach()RENAME参数的意义是:把生成的文件名zh_CN.mo安装为demoapp.mo。这样可以让源码中的语言代码和实际的.mo文件命名解耦,避免必须强关联。gettext 的查找逻辑也不会受影响,因为它会基于setlocale的结果去找对应LC_MESSAGES目录。
这里有一个常见的路径陷阱:share/locale是 GNU 标准布局里的一个约定,但有些发行版(特别是基于 Debian 的系统)会用/usr/share/locale-langpack或/usr/lib/locale之类的额外路径。如果你用GNUInstallDirs模块,可以更规范地指定:
include(GNUInstallDirs) # ... install(FILES ${MO_DIR}/${LANG}.mo DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/locale/${LANG}/LC_MESSAGES/ RENAME ${PROJECT_NAME}.mo)两种写法都行。核心问题是:.mo文件要落在运行时 gettext 找得到的位置,这个位置由安装规则决定,而不是由"生成时目录"决定。
5. 实测过程中最值得记录的五个坑
光写配置看起来好像很顺,真正跑起来时,我连续排了五个下午的雷。这些坑如果不记下来,换个环境可能还会踩。
5.1msgfmt的库依赖和版本问题
msgfmt在工作时会链接一些 gettext 的运行时库。如果你在一个跨平台环境中构建,别用 "直接调用系统程序" 的方式去依赖它,而是要在 CMake 里对find_program的路径做一次显式的运行测试,确保它能执行。更隐蔽的一个问题是:系统里的msgfmt可能来自不同项目实现——比如某些发行版默认用的是msgfmt脚本,某些用的是gmsgfmt。
一个稳妥的检查方式是在find_program之后这样做:
if(NOT GETTEXT_MSGFMT_EXECUTABLE) message(FATAL_ERROR "未找到 msgfmt,请先安装 gettext 工具链") endif()这句话说出来很简单,但实际项目里真的有人直接把add_custom_command跑起来,然后在构建日志里才第一次看到 "command not found"。更早地暴露依赖问题,是对团队友好。
5.2--check把所有错误都变成构建错误
msgfmt --check会同时检查语法、格式占位符、以及对 C 语言格式字符串的匹配。如果你的项目里有printf("%s")这样的字符串,翻译人员不小心把%s写成%S,构建就会中断。
这看起来像是"给自己找麻烦",但其实是"把问题提前拦截"。可以做一个权衡:如果项目的翻译还不稳定,先把--check从常规构建里移出,只放到单独的检查 target 里。熟练之后再把检查塞回主流程,避免别人拿半成品翻译阻塞了日常开发。
5.3 构建目录里.mo文件过期的假象
CMake 的add_custom_command判定是否重新执行的依据是OUTPUT文件的时间戳是否比DEPENDS更旧。有时会出现这种诡异现象:修改了.po文件,重新构建,msgfmt没有重新执行。
原因出在文件系统的时间戳精度上。某些文件系统(特别是网络盘、FAT 格式的移动存储)对时间戳的精度只能到 2 秒甚至 1 秒。CMake 在比较时间戳时,如果看到Output和Dependency的修改时间相同,会认为"已经是最新的",于是跳过命令。
解决方法有两个方向:一是在add_custom_command里加一个不存在的中间 stamp 文件来强制刷新机制;二是干脆把.po文件的修改时间显式 touch 一下。我自己的经验是,把COMMAND ${CMAKE_COMMAND} -E touch放到命令最后一行的输出,可以强制让OUTPUT的修改时间戳晚于依赖:
add_custom_command( OUTPUT ${MO_OUTPUT} COMMAND ${GETTEXT_MSGFMT_EXECUTABLE} -o ${MO_OUTPUT} ${PO_SRC} COMMAND ${CMAKE_COMMAND} -E touch ${MO_OUTPUT} DEPENDS ${PO_SRC} )5.4 生成.pot时的自动化破坏注释
xgettext默认会自动记录源码里待翻译字符串所在的文件和行号。这看起来很好,但会带来一个问题:每次源码改动一点点,.pot文件就哗啦啦变一大片。翻译人员用版本控制器查看改动时,会被无意义的行号变化淹没。
解决方案是用--omit-header与--no-location。前者去掉 POT 文件的头部创建时间(减少无意义 diff),后者不记录每一处字符串的源码位置。虽然丢掉了定位信息的便利,但从长期维护的角度看,重复内容少了很多。
我的经验是,常规提交时不记录位置,但在需要定位"这句话在哪"时,可以临时手动跑一次带位置的xgettext查一下。
5.5 Windows 下的路径分隔符陷阱
当构建环境切换到 Windows(特别是 MSVC 或 MinGW),CMake 生成的自定义命令在传给msgfmt时,Windows 路径分隔符\可能造成解析问题。尤其是路径中包含空格时,更要小心的引号转义。
一个经过验证的做法:统一把路径变量转成 POSIX 风格,再拼进命令。CMake 里有现成的函数:
file(TO_CMAKE_PATH "${PO_SRC}" PO_SRC_CMAKE) file(TO_NATIVE_PATH "${PO_SRC_CMAKE}" PO_SRC_NATIVE)把这句加进add_custom_command的参数里,可以避免多数令人莫名其妙的路径问题。
6. 更深一层:一键构建的隐形收益与团队协作方式变化
跑通自动化流程之后,我注意到一个有意思的变化:以前"翻译"这件事在构建流程里是完全断裂的一环。代码人只管把字符串标记出来,翻译人拿到模板后改完交付,交付物经常延迟或者找不到了。现在构建系统承担了持续的msgmerge提醒和.mo生成职责,打破了这个循环。
实际操作中,翻译平台、版本库、构建的流程最好是闭合的:
- 开发者修改源码里的英文字符串。
- 构建时,
xgettext自动重新生成.pot文件。 - 翻译人员基于
.pot更新.po,提交版本库。 - 下一次构建时,
msgfmt自动把更新后的.po编译进发布目录。
有一次我在 CI 日志里看到msgfmt --statistics输出"未翻译 3 条",马上回查 PO 文件,才发现有新增功能没被翻译。如果手动跑构建,这个反馈要等实际安装运行时才能发现。这个 "构建期即校验期" 的收益,在我看来是整条流水线价值最大的部分。
6.1 如何评估.po的完成质量
顺带分享一个经验性的习惯——每次构建后看一眼.po文件的统计信息,这比任何数量指标都直观。msgfmt --check --statistics输出的信息结构类似这样:
zh_CN.po: 213 条已翻译,8 条模糊翻译(将占位),5 条未翻译。"模糊翻译"这个概念对很多新手来说比较陌生。它是指翻译人员自己标记为"不确定"的条目,在最终产出的.mo文件里会使用其译文,但质量不受保障。所以 CI 阶段可以另外写一个脚本检查"模糊+未翻译"的总数是否超过某个阈值,如果超过就发一条提醒。这个跟 CMake 本身关系不大,但值得放到团队协作流程里。
6.2 再提一种需求:同一个项目,不同语言文件放在不同模块
如果你的项目是大型多模块结构,每个模块有独立的翻译域,前面的配置需要稍微演进。核心改动是将每个模块的xgettext扫描路径和textdomain()指定为独立的 POT 文件。我的做法是维护一个列表,每个模块一项:
set(I18N_MODULES core ui plugins )然后循环生成多组POT和.mo,每个模块的.po文件分别放在po/<module>/<lang>.po目录。运行时,每个模块调用bindtextdomain()时指向不同的 text domain 即可。这个方案的好处是避免多个模块共用同一个.pot导致相互干扰。
6.3 处理运行时语言切换的坑
.mo装好了,CMake 的工作就基本完成了,但程序跑起来还有一个容易懵的问题:为什么总是显示英文,设置无效?
这通常与setlocale有关。gettext 的运行机制是:程序启动时调用setlocale(LC_ALL, ""),让系统根据环境变量LANG、LANGUAGE、LC_ALL决定当前语言环境,然后 gettext 库在这个基础上寻找对应语言的.mo文件。如果你的程序在初始化时调用了setlocale(LC_ALL, "C"),或者根本没有调用setlocale,那 gettext 只会把LC_MESSAGES视为默认的C环境,自然找不到zh_CN.mo。
代码层面常见的初始化是:
#include <libintl.h> #include <locale.h> setlocale(LC_ALL, ""); bindtextdomain("demoapp", "/usr/local/share/locale"); textdomain("demoapp");bindtextdomain的第二个参数决定了.mo的查找根目录,这个路径必须跟 CMake 里的install(FILES ... DESTINATION ...)保持一致。如果 CMake 里改过安装路径,代码里也要同步更新,否则发布后会发现语言文件明明存在,程序依然显示英文。
7. 更顺手的补充:透明化 CI 与本地构建体验
这里我想额外提一点使用心得,关于如何让 CI 中的多语言构建更安静、更可控。
如果你在 CI 环境里配置了前述 CMake 流程,建议把--statistics输出单独提取出来,搜索undefined、fuzzy、translated关键词,发到团队即时通信里。其实不用额外脚本,很多 CI 平台已经支持在日志中做关键字高亮。不过初始配置快速跑通一个最小化的流程更重要——我每次到一个新环境,第一件事永远是先跑一句cmake -S . -B build && cmake --build build,确认原有代码和翻译流程都能同时完成,之后才逐步添加新语言的.po文件。
在这个配置稳定之后,我还做了一个额外的参数,允许构建时通过-DLINGUAS=zh_CN,de_DE来指定需要构建的语言列表,而不是每次都把所有语言的.mo都生成一遍。开发迭代时只编一种语言速度更快,发布时再全量编译:
set(LINGUAS "zh_CN;de_DE" CACHE STRING "需要构建的语言代码列表") string(REPLACE "," ";" LINGUAS "${LINGUAS}")改起来很简单,但对日常开发的体验提升很明显。
8. 写在最后的实操体会
整套流程跑通之后,我对"构建系统承载多语言"这件事多了一层理解:这其实不是技术问题,而是工程协作问题。你需要的不是"有一个脚本能生成 .mo",而是"所有人都不需要关心 .mo 是怎么生成的"。CMake 在这里做的是把生成 .pot、更新 .po、编译 .mo、安装到正确位置这一整套动作,变成了随构建而发生的确定性过程。
最后分享一个我踩过几次坑之后的习惯:每次准备发版前,我会临时把CMakeLists.txt里--no-location那一行注释掉,重新生成一次POT,然后快速定位到最近改过的字符串确认是否都已进模板。确认完毕再恢复写法。这个习惯帮我避免过好几次"构建成功但新文案没进产物"的问题。
如果你想照着这套实践改造自己的项目,建议不要一次引入全部功能,而是按这个顺序落地:
- 先只做
.po编译.mo的基础add_custom_command,验证手动搭建的产物可运行。 - 再接入
xgettext自动生成.pot,让源码改动自动进入翻译模板。 - 最后配置安装路径和多模块方案,然后把 CI 里的检查项加上。
这样每走一步都能确认成果,出了错也能快速定位问题出在哪一环。这个项目现在维护了三年多,中间换过编译服务器、升级过 CMake 版本、加过新的语言,翻译流程一直没有再需要人肉干预过。