背景
前段时间例行升级我为游戏框架写的构建系统 cmake-toolset 时,又遇到了 protobuf 的一个新 ABI 兼容性问题。
这已经不是第一次被 protobuf 的 ABI 兼容性问题“教育”了。这篇把问题拆开讲清楚,并给出一套可落地的规避方案,供遇到类似问题的同学参考。
- 编译环境:Windows + Visual Studio 2026
- Abseil-Cpp:20250512.1
- Protobuf:v31.1(看起来目前最新的 v33.1 也有这个问题)
现象与错误定位
首先在链接阶段出现错误:
[build] test_pb.pb.obj : error LNK2001: 无法解析的外部符号 "class google::protobuf::internal::GlobalEmptyStringConstexpr const google::protobuf::internal::fixed_address_empty_string" (?fixed_address_empty_string@internal@protobuf@google@@3VGlobalEmptyStringConstexpr@123@B) [D:\workspace\git\github\atframework\cmake-toolset\test\build_jobs_dir\cmake-toolset-test.vcxproj]
[build] D:\workspace\git\github\atframework\cmake-toolset\test\build_jobs_dir\bin\Debug\cmake-toolset-test.exe : fatal error LNK1120: 1 个无法解析的外部命令 [D:\workspace\git\github\atframework\cmake-toolset\test\build_jobs_dir\cmake-toolset-test.vcxproj]
我也在 opentelemetry-cpp 社区提了一个同样现象的 issue(见 open-telemetry/opentelemetry-cpp#3799)。
为了说明问题,我们把“生成代码”和“protobuf 库本体”的相关片段并排看一下。
生成的 .pb.cc
生成的 .pb.cc 里会出现类似这样的代码(把 std::string 的默认值初始化为一个固定地址的空串):
inline constexpr EntityRef::Impl_::Impl_(
::_pbi::ConstantInitialized) noexcept
: id_keys_{},
description_keys_{},
schema_url_(
&::google::protobuf::internal::fixed_address_empty_string,
::_pbi::ConstantInitialized()),
type_(
&::google::protobuf::internal::fixed_address_empty_string,
::_pbi::ConstantInitialized()),
_cached_size_{0} {}
Protobuf 的定义(port.h / port.cc)
protobuf 的 port.h 里有如下定义(简化摘录):
class alignas(8) GlobalEmptyStringConstexpr {
public:
const std::string& get() const { return value_; }
// Nothing to init, or destroy.
std::string* Init() const { return nullptr; }
template <typename T = std::string, bool = (T(), true)>
static constexpr std::true_type HasConstexprDefaultConstructor(int) {
return {};
}
static constexpr std::false_type HasConstexprDefaultConstructor(char) {
return {};
}
private:
std::string value_;
};
using GlobalEmptyString = std::conditional_t<
GlobalEmptyStringConstexpr::HasConstexprDefaultConstructor(0),
const GlobalEmptyStringConstexpr, GlobalEmptyStringDynamicInit>;
PROTOBUF_EXPORT extern GlobalEmptyString fixed_address_empty_string;
对应的 port.cc 里会定义这个全局对象:
PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT
PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 GlobalEmptyString
fixed_address_empty_string{};
根因:C++20 触发了类型/符号差异
查阅 C++ 文档(例如 https://en.cppreference.com/w/cpp/string/basic_string/basic_string.html)可以看到:std::string 的构造函数是在 C++20 之后才逐步具备 constexpr 能力。
这意味着:
- 当 protobuf 以 C++20(或更高) 编译时,
GlobalEmptyStringConstexpr::HasConstexprDefaultConstructor(0)可能成立,GlobalEmptyString会走到GlobalEmptyStringConstexpr路径。 - 当 protobuf 以 C++17(或更低) 编译时,上述条件不成立,
GlobalEmptyString会走到另一条类型分支。
而如果你的工程里出现了“protobuf 库”和“.pb.cc”采用不同 C++ 标准编译,且跨越了 C++20 这条分界线,那么:
- 生成代码里引用的符号签名(mangled name)会以
GlobalEmptyStringConstexpr为前提; - 但库里实际导出的符号却来自另一条类型分支;
最终就会出现前面那种链接错误(LNK2001)。
解决方法
一句话:不要让 “protobuf 库” 和 “生成的 .pb.cc” 在 C++20 前后混用。
最理想的做法当然是全工程统一 C++ 标准(例如都用 C++20),并用同一套工具链把 protobuf/abseil 也一起重编。
但现实里经常会遇到“依赖是预编译包”的情况,这时就需要在 CMake 层面尽量推断 protobuf 的“实际编译标准”,并把 .pb.cc 的编译标准对齐到不会产生 ABI 差异的范围。
从 CMake 目标信息推断(第一版)
如果 protobuf 是通过 CMake package 导入的,导出的目标里一般会带 INTERFACE_COMPILE_FEATURES,例如:
set_target_properties(protobuf::libprotobuf PROPERTIES
INTERFACE_COMPILE_FEATURES "cxx_std_17"
INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include"
INTERFACE_LINK_LIBRARIES "\$<LINK_ONLY:ZLIB::ZLIB>;absl::absl_check;absl::absl_log;absl::algorithm;absl::base;absl::bind_front;absl::bits;absl::btree;absl::cleanup;absl::cord;absl::core_headers;absl::debugging;absl::die_if_null;absl::dynamic_annotations;absl::flags;absl::flat_hash_map;absl::flat_hash_set;absl::function_ref;absl::hash;absl::if_constexpr;absl::layout;absl::log_initialize;absl::log_globals;absl::log_severity;absl::memory;absl::node_hash_map;absl::node_hash_set;absl::optional;absl::random_distributions;absl::random_random;absl::span;absl::status;absl::statusor;absl::strings;absl::synchronization;absl::time;absl::type_traits;absl::utility;absl::variant;\$<LINK_ONLY:utf8_range::utf8_validity>"
)
我们可以读取 INTERFACE_COMPILE_FEATURES,尝试获得 cxx_std_NN 并用于约束生成的 proto targets。
我在 cmake-toolset 里原本就有一个 “patch protobuf 生成代码 target” 的接口(主要用来屏蔽 protobuf 生成代码触发的严格告警)。这次我把它扩展为:如果能推断出 protobuf 的 C++ 标准,就显式把生成的 .pb.cc target 对齐过去。
function(project_build_tools_patch_protobuf_targets)
# 如果之前找到了 cxx_std_NN 的 INTERFACE_COMPILE_FEATURES, 就把 ATFRAMEWORK_CMAKE_TOOLSET_THIRD_PARTY_PROTOBUF_CXX_STANDARD 设置成这个标准数字部分
if(ATFRAMEWORK_CMAKE_TOOLSET_THIRD_PARTY_PROTOBUF_CXX_STANDARD)
foreach(PROTO_TARGET ${ARGN})
set_target_properties(${PROTO_TARGET} PROPERTIES CXX_STANDARD
${ATFRAMEWORK_CMAKE_TOOLSET_THIRD_PARTY_PROTOBUF_CXX_STANDARD})
endforeach()
if(MSVC)
set(__additional_cxx_standard "/std:c++${ATFRAMEWORK_CMAKE_TOOLSET_THIRD_PARTY_PROTOBUF_CXX_STANDARD}")
else()
set(__additional_cxx_standard "-std=c++${ATFRAMEWORK_CMAKE_TOOLSET_THIRD_PARTY_PROTOBUF_CXX_STANDARD}")
endif()
endif()
foreach(PROTO_TARGET ${ARGN})
set(PROTO_TARGET_OPTIONS_CHANGED FALSE)
get_target_property(PROTO_TARGET_OPTIONS ${PROTO_TARGET} COMPILE_OPTIONS)
if(PROTO_TARGET_OPTIONS)
set(__need_cxx_standard TRUE)
if(NOT ATFRAMEWORK_CMAKE_TOOLSET_THIRD_PARTY_PROTOBUF_CXX_STANDARD
OR PROTO_TARGET_OPTIONS MATCHES
"(/std:|-std=)(c|gnu)\\+\\+${ATFRAMEWORK_CMAKE_TOOLSET_THIRD_PARTY_PROTOBUF_CXX_STANDARD}")
set(__need_cxx_standard FALSE)
else()
if(MSVC)
string(REGEX REPLACE "/std:c\\+\\+[0-9a-zA-Z_]+" "" PROTO_TARGET_OPTIONS "${PROTO_TARGET_OPTIONS}")
endif()
string(REGEX REPLACE "-std=(c|gnu)\\+\\+[0-9a-zA-Z_]+" "" PROTO_TARGET_OPTIONS "${PROTO_TARGET_OPTIONS}")
set(__need_cxx_standard TRUE)
endif()
# 我们原来有一些根据编译环境生产的Patch编译选项,放在 PROJECT_BUILD_TOOLS_PATCH_PROTOBUF_SOURCES_OPTIONS ,这里是做融合
foreach(TEST_OPTION ${PROJECT_BUILD_TOOLS_PATCH_PROTOBUF_SOURCES_OPTIONS})
if(NOT "${TEST_OPTION}" IN_LIST PROTO_TARGET_OPTIONS)
list(APPEND PROTO_TARGET_OPTIONS "${TEST_OPTION}")
set(PROTO_TARGET_OPTIONS_CHANGED TRUE)
endif()
endforeach()
if(__need_cxx_standard)
list(APPEND PROTO_TARGET_OPTIONS "${__additional_cxx_standard}")
set(PROTO_TARGET_OPTIONS_CHANGED TRUE)
endif()
else()
set(PROTO_TARGET_OPTIONS ${PROJECT_BUILD_TOOLS_PATCH_PROTOBUF_SOURCES_OPTIONS})
if(__additional_cxx_standard)
list(APPEND PROTO_TARGET_OPTIONS "${__additional_cxx_standard}")
endif()
set(PROTO_TARGET_OPTIONS_CHANGED TRUE)
endif()
if(PROTO_TARGET_OPTIONS_CHANGED)
set_target_properties(${PROTO_TARGET} PROPERTIES COMPILE_OPTIONS "${PROTO_TARGET_OPTIONS}")
endif()
endforeach()
endfunction()
不过把这个策略落下去之后,又遇到了两个“二次踩坑”。这俩坑的共同点是:仅凭 cxx_std_NN 并不足以推断“实际在用的编译标准/ABI约束”。
新问题一:cxx_std_NN 可能不准确
上游工程实际使用了 C++17:
C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\include\string_view(12): warning STL4038: The contents of <string_view> are available only with C++17 or later. [D:\a\opentelemetry-cpp\opentelemetry-cpp\build\opentelemetry_proto.vcxproj]
(compiling source file '../../../../generated/third_party/opentelemetry-proto/opentelemetry/proto/common/v1/common.pb.cc')
C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\include\vector(29,1):
see declaration of 'std'
D:\a\opentelemetry-cpp\opentelemetry-cpp\tools\vcpkg\installed\x64-windows\include\absl\strings\string_view.h(53,26): error C2061: syntax error: identifier 'string_view' [D:\a\opentelemetry-cpp\opentelemetry-cpp\build\opentelemetry_proto.vcxproj]
(compiling source file '../../../../generated/third_party/opentelemetry-proto/opentelemetry/proto/common/v1/common.pb.cc')
但 protobuf 导出的 package 却声称自己是 cxx_std_14 (某些老一些的版本):
get_target_property(protobuf_lib_compile_features protobuf::libprotobuf INTERFACE_COMPILE_FEATURES)
if(protobuf_lib_compile_features MATCHES "(^|\\s)cxx_std_([0-9]+)(|$\\s)")
message(
STATUS
"protobuf::libprotobuf detected compile features cxx_std_${CMAKE_MATCH_2}."
)
endif()
输出是 -- protobuf::libprotobuf detected compile features cxx_std_14.。
所以这里的处理思路是:不要完全依赖 cxx_std_NN,而是把 C++20 作为 ABI 分界线。
如果你的工程整体用的是 C++20(或更高),而导入的 protobuf “看起来”低于 C++20,那么就需要避免让 .pb.cc 跨过 C++20;反过来也同理。
if(DEFINED CMAKE_CXX_STANDARD AND protobuf_lib_compile_features MATCHES
"(^|\\s)cxx_std_([0-9]+)(|$\\s)")
# Whether the protobuf library using C++20 or higher may have different
# ABI. We need to make sure our proto targets are using the same C++
# standard.
if(${CMAKE_MATCH_2} LESS 20 AND CMAKE_CXX_STANDARD GREATER_EQUAL 20)
# Some versions of protobuf will set cxx_std_14, but actually require
# c++17
set(protobuf_lib_compile_features_cxx_std 17)
elseif(${CMAKE_MATCH_2} GREATER_EQUAL 20 AND CMAKE_CXX_STANDARD LESS 20)
set(protobuf_lib_compile_features_cxx_std ${CMAKE_MATCH_2})
endif()
message(
STATUS
"protobuf::libprotobuf detected compile features cxx_std_${CMAKE_MATCH_2}."
)
endif()
新问题二:abseil-cpp 如果以 C++20 配置/编译,会出现“配置宏不匹配”
在 Linux + GCC 上,如果你把 .pb.cc 强行降到 C++17,可能会遇到如下编译错误:
[ 2%] Building CXX object CMakeFiles/opentelemetry_proto.dir/generated/third_party/opentelemetry-proto/opentelemetry/proto/resource/v1/resource.pb.cc.o
In file included from /usr/local/include/absl/numeric/int128.h:41,
from /usr/local/include/absl/strings/internal/str_format/arg.h:35,
from /usr/local/include/absl/strings/str_format.h:83,
from /usr/local/include/absl/crc/crc32c.h:32,
from /usr/local/include/absl/crc/internal/crc_cord_state.h:23,
from /usr/local/include/absl/strings/cord.h:80,
from /usr/local/include/google/protobuf/io/coded_stream.h:111,
from /home/runner/build/generated/third_party/opentelemetry-proto/opentelemetry/proto/resource/v1/resource.pb.h:20,
from /home/runner/build/generated/third_party/opentelemetry-proto/opentelemetry/proto/resource/v1/resource.pb.cc:6:
/usr/local/include/absl/types/compare.h:60:12: error: ‘partial_ordering’ has not been declared in ‘std’
60 | using std::partial_ordering;
| ^~~~~~~~~~~~~~~~
/usr/local/include/absl/types/compare.h:61:12: error: ‘strong_ordering’ has not been declared in ‘std’
61 | using std::strong_ordering;
| ^~~~~~~~~~~~~~~
/usr/local/include/absl/types/compare.h:62:12: error: ‘weak_ordering’ has not been declared in ‘std’
62 | using std::weak_ordering;
| ^~~~~~~~~~~~~
/usr/local/include/absl/types/compare.h:455:56: error: ‘weak_ordering’ in namespace ‘absl’ does not name a type
455 | constexpr bool compare_result_as_less_than(const absl::weak_ordering r) {
| ^~~~~~~~~~~~~
/usr/local/include/absl/types/compare.h:470:17: error: ‘weak_ordering’ in namespace ‘absl’ does not name a type
470 | constexpr absl::weak_ordering compare_result_as_ordering(const Int c) {
这里涉及 Three-way comparison(C++20 引入的 <compare> 与 std::weak_ordering 等类型)。当 abseil 在安装/打包阶段被“按 C++20 可用”来配置过,而你的 .pb.cc 又用 C++17 编译时,就会出现这种 “头文件里 using std::weak_ordering; 但标准库并未提供” 的报错。
这也说明了:即便你只想“把 .pb.cc 降回 C++17 来贴合 protobuf 的 ABI”,也必须确保 abseil 的配置/编译假设不会被打破。
此外还有一个容易误判的点:一些 protobuf 版本会写 target_compile_features("${target}" PUBLIC cxx_std_17),但如果你在构建 protobuf 时设置了 -DCMAKE_CXX_STANDARD=20,那么它实际可能会用 C++20 编译,可导出的 package 信息仍然停留在 cxx_std_17。这会让“靠 package 元信息推断 ABI”变得更不可靠。
因此,更稳妥(但成本更高)的思路是:在工具链层面做一个小探针,分别用 -std=c++17 和 -std=c++20(或 MSVC 对应的 /std:c++XX)尝试编译/链接一个最小样例,判断当前安装的 protobuf/abseil 组合到底要求哪个标准。
#include <iostream>
#include <google/protobuf/message.h>
int main () {
std::cout<< ::google::protobuf::internal::fixed_address_empty_string.get()<< std::endl;
return 0;
}
当然实际情况还和 protobuf/abseil 具体版本、打包方式、编译器/标准库实现有关,落地时要保留余地。
不过实际上,在Linux+GCC 13里,protobuf用C++17,而 .pb.cc 使用C++20是没问题的。因为这样虽然符号表里有非 constexpr 的分支,但是编译 .pb.cc 的时候,整个 fixed_address_empty_string 走编译期计算了。
因为 constexpr 并不是强制性的编译期评估( consteval 才是 ),所以如果依赖这个行为的话会和编译环境、编译器版本相关。而恰好MSVC没有全部走编译期计算,所以触发了这个问题。
官方的临时解决方案
截至写作时间,最新 protobuf 主干分支已经把相关逻辑改成了下面这样:在 MSVC 下强制禁用 constexpr std::string 的那条优化路径,从而规避跨标准导致的符号不一致问题。
这类改动在发布版落地之前,可以视作一个“临时止血方案”(前提是你能升级到包含该修复的版本/提交,或在自维护分支里 backport)。
// Take advantage of C++20 constexpr support in std::string.
class alignas(8) GlobalEmptyStringConstexpr {
public:
const std::string& get() const { return value_; }
// Nothing to init, or destroy.
std::string* Init() const { return nullptr; }
// Disable the optimization for MSVC and Xtensa.
// There are some builds where the default constructed string can't be used as
// `constinit` even though the constructor is `constexpr` and can be used
// during constant evaluation.
#if !defined(_MSC_VER) && !defined(__XTENSA__)
// Compilation fails on Xtensa: b/467129751
template <typename T = std::string, bool = (T(), true)>
static constexpr std::true_type HasConstexprDefaultConstructor(int) {
return {};
}
#endif
static constexpr std::false_type HasConstexprDefaultConstructor(char) {
return {};
}
private:
std::string value_;
};
最后
Google 生态下 protobuf 的坑确实不少。如果你对我之前踩过的其他 protobuf 相关坑感兴趣,可以直接在 Blog 里搜关键词 protobuf。
欢迎小伙伴们交流拍砖。