前言

年初的时候我们项目组的构建系统( cmake-toolset )里把 protobuf 升级到了 v20/v3.20 版本, gRPC 也升级到了 v1.54 版本。然而这两个版本在Linux的ELF ABI和MacOS的Macho ABI下都出现了一些符号未定义的问题(当然也包含Android和iOS)。 这些问题也不仅限于 protobuf v20/v3.20 和 gRPC v1.54,后续的版本有些修复了,有些没有。在官方完全修复之前,我们自己打了一些patch去修复这些问题。

protobuf 的链接和符号问题

InternalMetadata::~InternalMetadata() 未定义

protobuf的问题主要分两组,第一组报的是 InternalMetadata::~InternalMetadata() 未定义。这个问题存在于 v20/v3.20 和 v21/v3.21 。v22/v4.22 版本已修复(v22/v4.22在构建和依赖上有个很大的变化,后面再写分享说明)。这个问题的issue也可以见于 https://github.com/protocolbuffers/protobuf/issues/9947

其本质原因可以先看 v20/v3.20 版本的 metadata_lite.h#L73

#if defined(NDEBUG) || defined(_MSC_VER)
  ~InternalMetadata() {
    if (HasMessageOwnedArenaTag()) {
      delete reinterpret_cast<Arena*>(ptr_ - kMessageOwnedArenaTagMask);
    }
  }
#else
  ~InternalMetadata();
#endif

还有源文件 message_lite.cc#L528

// Non-inline implementations of InternalMetadata routines
#if defined(NDEBUG) || defined(_MSC_VER)
// for opt and MSVC builds, the destructor is defined in the header.
#else
// This is moved out of the header because the GOOGLE_DCHECK produces a lot of code.
InternalMetadata::~InternalMetadata() {
  if (HasMessageOwnedArenaTag()) {
    GOOGLE_DCHECK(!HasUnknownFieldsTag());
    delete reinterpret_cast<Arena*>(ptr_ - kMessageOwnedArenaTagMask);
  }
}
#endif

这里的本意是在非Debug模式下这个析构写头文件里,这样某些编译器和编译选项可以被自动内连,可以优化掉。而调试模式下有额外的检查走本地的符号。 但是这里的问题是,我们经常会在编译依赖库采用Release模式,而使用者可能处于Debug模式。这就意味着编译 protobuf 的时候是可能被优化掉而没有这个符号的。 但是使用者认为有这个符号,最终链接失败。

这个问题在 protobuf v21.4/v3.21.4 版本里进行了部分修复,但是某些编译环境还是有问题。

我们先来看 protobuf v21/v3.21 版本里 metadata_lite.h#L81 的代码。

  ~InternalMetadata() {
#if defined(NDEBUG) || defined(_MSC_VER)
    if (HasMessageOwnedArenaTag()) {
      delete reinterpret_cast<Arena*>(ptr_ - kMessageOwnedArenaTagMask);
    }
#else
    CheckedDestruct();
#endif
  }

咋一看好像是没问题了。无论什么情况都有 ~InternalMetadata() 了,但是C++编译器在自动内联一说,😂。某些编译器在编译 .pb.cc 时如果走下面的分支自动内联了,那么就不会生成 ~InternalMetadata() 这个符号。这个类的析构在 MessageLite 这个类中被调用,在生成的 .pb.cc 里是配有被直接调用的。但是某些编译器会生成对它的析构符号的引用(可能也属于编译器的BUG)。 这时候又会导致符号未定义。

我们发现问题的环境是编译iOS版本时,具体编译器版本号忘记了,好像是AppleClang 12或者AppleClang 13。

解决这个问题也很简单,把声明改成 PROTOBUF_NOINLINE ~InternalMetadata() 就行了。 以上问题也可以在我们的构建系统项目中找到Patch文件(还包含少量其他问题的适配):

XXX_default_instance_ 未定义

第二个问题是默认的instance符号未定义的问题。这个问题存在于 v21/v3.21 到目前最新版本 (v23/4.23)。我没有追查更早版本,大概率也有这个问题。 报的错误大致是 "struct XXX YYYY_default_instance_" 符号未定义。触发条件比较多:

  • 需要编译成动态库
  • 默认符号隐藏(Windows默认隐藏,Linux默认可见)
  • 使用 dllexport_decl= 来设置导出符号

在Windows中个,每一个dll和exec都有自己的符号表和堆管理。所以当使用dll时,需要把要导出的符号设置为 __declspec(dllexport)/__attribute__((__dllexport__)), 导入的时候设置为 __declspec((dllimport))/__attribute__((__dllimport__)) 。 而在Linux里,默认是共享且全局可见的。而很多构建系统中会把Windows版本依赖使用静态库,所以很多同学不会碰到这些问题。

有一些更严谨更安全防止符号冲突的方式是吧Linux下符号也通过 __attribute__((visibility("hidden"))) 设为默认隐藏,然后对于要导出的符号设置为 __attribute__((visibility("default"))) 来共享(比如Unreal Engine的构建系统)。这时候通常的做法是提供一个宏,在编译的时候设置PRIVATE定义为 __declspec(__dllimport__)/__attribute__((__dllimport__))/__attribute__((visibility("default"))) 对外传递的PUBLIC定义为 __declspec(dllexport)/__attribute__((__dllexport__))/__attribute__((visibility("default")))

protobuf 生成的代码中,由于 .pb.cc 中存在全局变量,我们也不能允许同一个全局变量在多个动态库中,否则会重复注册和执行构造析构函数。那么为了实现上面的流程,protobuf 提供了一个生成选项 dllexport_decl,用来对要导出的符号指定这个宏。

那么我们再来看一个这个问题对应的生成代码(选项是 --cpp_out=dllexport_decl=TGF_BATTLE_PROTOCOL_API:<OUT DIR>)。 首先对于 .pb.h :

class DBattleAffix;
struct DBattleAffixDefaultTypeInternal;
TGF_BATTLE_PROTOCOL_API extern DBattleAffixDefaultTypeInternal _DBattleAffix_default_instance_;

// ...
class TGF_BATTLE_PROTOCOL_API DBattleAffix final : // ...
{
  // ...
  static inline const DBattleAffix* internal_default_instance() {
    return reinterpret_cast<const DBattleAffix*>(
               &_DBattleAffix_default_instance_);
  }
}

可以看到,有接口引用了 _DBattleAffix_default_instance_ 这个全局变量的地址。那么在 .pb.cc 里:

struct DBattleAffixDefaultTypeInternal {
  PROTOBUF_CONSTEXPR DBattleAffixDefaultTypeInternal()
      : _instance(::_pbi::ConstantInitialized{}) {}
  ~DBattleAffixDefaultTypeInternal() {}
  union {
    DBattleAffix _instance;Bu
  };
};
PROTOBUF_ATTRIBUTE_NO_DESTROY PROTOBUF_CONSTINIT PROTOBUF_ATTRIBUTE_INIT_PRIORITY1 DBattleAffixDefaultTypeInternal _DBattleAffix_default_instance_;

可以看到,.pb.cc 里面并没有设置 TGF_BATTLE_PROTOCOL_API 来导出符号,最终就会导致类似下面这样的链接错误:

7>TGFBattleUtility.lib(BattleAffixAlgorithm.obj): Error LNK2001: unresolved external symbol "struct tgf::DBattleAffixDefaultTypeInternal tgf::_DBattleAffix_default_instance_" (?_DBattleAffix_default_instance_@tgf@@3UDBattleAffixDefaultTypeInternal@1@A)

那么解决方法也很简单,就是在 protoc 的代码里补上这些,(v21和v23版本变化比较大,具体可以参考下面的patch files)

这个issue已经提交到 https://github.com/protocolbuffers/protobuf/issues/13084 并且BUG修复的PR在 https://github.com/protocolbuffers/protobuf/pull/13085 。有兴趣的小伙伴也可以跟进。

gRPC 的链接和编译问题

gRPC 的 v1.54.0 的链接符号问题

我们在使用高版本编译器时,会尽可能使用高版本的STD标准。在C++17以后 constexpr 关键字隐式会被附加 inline (详情参考: https://en.cppreference.com/w/cpp/language/constexpr)。 这意味着这些变量可能不会生成实际的符号。那么问题就来了。我们来看v1.54.0 版本的代码:

首先 src/core/ext/gcp/metadata_query.h :

class MetadataQuery : public InternallyRefCounted<MetadataQuery> {
 public:
  static const char kZoneAttribute[];
  static const char kClusterNameAttribute[];
  static const char kRegionAttribute[];
  static const char kInstanceIdAttribute[];
  static const char kIPv6Attribute[];
// ...

然后 src/core/ext/gcp/metadata_query.cc :

constexpr const char MetadataQuery::kZoneAttribute[] =
    "/computeMetadata/v1/instance/zone";
constexpr const char MetadataQuery::kClusterNameAttribute[] =
    "/computeMetadata/v1/instance/attributes/cluster-name";
constexpr const char MetadataQuery::kRegionAttribute[] =
    "/computeMetadata/v1/instance/region";
constexpr const char MetadataQuery::kInstanceIdAttribute[] =
    "/computeMetadata/v1/instance/id";
constexpr const char MetadataQuery::kIPv6Attribute[] =
    "/computeMetadata/v1/instance/network-interfaces/0/ipv6s";

这时候,某些编译环境下这些变量被inline了以后,就会出现未定义符号的问题。 相关修复放在了 https://github.com/atframework/cmake-toolset/blob/main/ports/grpc/grpc-v1.54.0.patch 。 同时这个BUG在 v1.54.2 里已经被修复。

gRPC 的在部分编译器上的兼容性问题

按照 https://en.cppreference.com/w/cpp/memory/unique_ptr 的要求, std::unqieur_ptr<T, std::default_delete<T>> 中的 T 应该是允许前置声明的。 但是某些编译器或者STL实现的问题,导致开启最高支持标准时 src/core/lib/surface/server.h 里前置申明类型用在 std::unqieur_ptr 时会报 incomplete type 的问题。 临时的处理方式是把它们改成 shared_ptr ,虽然暂时有轻微的性能损失,但是也可以后续等官方更完整的修复。Patch文件在 https://github.com/atframework/cmake-toolset/blob/main/ports/grpc/grpc-v1.54.2.patch

最后

近期碰到的 protobufgRPC 的构建坑也就这么多。后续针对 protobuf v22+ 和 gRPC v1.55+ 还有一些比较重大的变化。导致我们的Patch过程有一个大改造。特别是设计upb的部分。 这部分后续在写分享吧,也欢迎有兴趣的小伙伴交流。