In-Depth Understanding of C/C++ Compilation and Linking Technology 6 – A2: Dynamic Library Design Basics – ABI Interface Design
Introduction
In this blog post, I attempt to summarize and categorize some of the more important technical points in the design of dynamic libraries, such as the design and export of binary interfaces.
So, why bring up the binary interface?
Fundamentally, the ultimate goal of designing a dynamic library (which I believe we must always keep in mind) is to reuse our code for others to use. Therefore, we must consider the details of code collaboration. In a blog post a long time ago, we simplified the abstract concept of a dynamic library to specifying a number of exported symbols, written in header files or dedicated export files, serving as an interface for other users to know how to call the target functionality, alongside the underlying hidden details of machine code.
However, we know that what is written in human-readable files, such as function names under classes and global variable names in header files, is indeed an interface, but we obviously know this does not constitute a binary interface. It seems we have always been accustomed to the idea that as long as we export specified symbols and provide the machine code for the implementation, everything is fine. But, due to the free nature of C++ (note, I didn't say C; in fact, this problem erupts intensely in reusable libraries written in C++), the transformation from human-readable APIs to machine-compatible ABIs handled by compilers from different vendors is inconsistent! This has created a series of issues that are no laughing matter. Below, I enumerate why and under which circumstances our C++ symbol export and ABI matching produce serious inconsistencies, causing trouble in software construction.
More complex naming rules
The mapping from C++ functions to linker symbols is decided by the compiler vendor. Although standards exist to constrain compiler vendors to generate as universal symbols as possible, unfortunately, taking g++ and MSVC as examples, there are still gaps. This means that the same symbol lookup and mapping rules prevent a project using the MSVC compiler from directly using a library built with the g++ compiler without pain (my other meaning is, if we don't adopt some methods, we need to obtain the source code and recompile; the methods we discuss later will finally allow us to avoid this approach).
Readers might ask: How did this happen? Actually, we can easily think of a series of code like this:
// 在C++中,我们很喜欢将一些方法放置到类中,
// OOP就是推介我们这样做的!
class Foo {
public:
void someFunc(int a, const char* b);
};
// 或者,我们喜欢放置一些工具类的函数到单独的命名空间中
namespace charlies_tools {
std::vector<std::string_view> split(const std::string& waited_splits, const char ch);
std::vector<std::string_view> split(const std::string& waited_splits, const std::string_view sp_view);
};As C++ programmers, we naturally use these features to avoid symbol-level conflicts and improve readability in software engineering.
Let's examine the symbol names generated by the g++ compiler:
0000000000000012 T _ZN14charlies_tools5splitERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEc
0000000000000022 T _ZN14charlies_tools5splitERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESt17basic_string_viewIcS3_E
0000000000000000 T _ZN3Foo8someFuncEiPKcNext, let's look at what MSVC produces:
00C 00000000 SECT4 notype () External | ?someFunc@Foo@@QAEXHPBD@Z (public: void __thiscall Foo::someFunc(int,char const *))
00D 00000010 SECT4 notype () External | ?split@charlies_tools@@YAXABV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@D@Z (void __cdecl charlies_tools::split(class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const &,char))
00E 00000020 SECT4 notype () External | ?split@charlies_tools@@YAXABV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@V?$basic_string_view@DU?$char_traits@D@std@@@3@@Z (void __cdecl charlies_tools::split(class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const &,class std::basic_string_view<char,struct std::char_traits<char> >))In reality, we can see that the symbols written into the relocatable file look completely different. This indicates that we cannot use our symbols in a generic way. Furthermore, features like function overloading allow us to use the same function name with different parameter lists within a single object file. Consequently, our toolchain has to go to great lengths to handle these complexities.
This modification is known as name mangling. Great, now we have to deal with these annoying issues.
Static Data Initialization
In C, data is often considered to be trivial (aha, I prefer C too; at least it's predictable). Due to legacy code conventions, we are accustomed to initializing these variables during the linking phase. However, in C++, we know that this data can be objects, which implies the existence of constructor calls. If these objects are initialized under order-independent conditions (meaning the objects do not have dependencies, such that static object A must be initialized before static object B), then it isn't an issue. The real problem arises with order-dependent static objects. Since the CPU executes the program, there are often no fixed constraints on the initialization order of these objects, which can easily lead to random program crashes.
Fortunately, this problem is easy to handle. We know that the initialization of data scattered freely in the data segment is uncertain. However, if we place the object inside a function, it is initialized only when execution reaches that point. Therefore, if static object A indeed must be initialized before static object B, we can do the following:
static void init_a_and_b() {
static A network_instance;
static B authentic_networks;
}
auto dummy = [](){
init_a_and_b();
return 0;
}();So, How to Design a Less Troublesome Binary Interface
Designing C-Style Export Interfaces
Of course, you do not need to strictly follow C naming conventions to avoid conflicts like a C programmer would. The point here is to avoid exporting the distinct ABI symbol rules characteristic of C++. The solution is to decorate the symbols you decide to export with the extern "C" identifier.
#ifdef __cplusplus
extern "C"{
#endif
int functional_a(int a, int b);
#ifdef __cplusplus
}
#endifThis makes the interface presented to the linker much cleaner.
Header Files Providing Complete ABI Declarations
Here, a "header file providing complete ABI declarations" refers to a header file (.h) that contains all necessary declarations, enabling the compiler to fully understand the interface of a library or module. This allows it to:
- Correctly compile code that calls the library.
- Correctly generate machine code that interacts with the functions in the library.
The core of this "complete ABI declaration" is that it includes not only function names but also all details that affect binary-level interaction. Therefore, we use the term "header file providing complete ABI declarations." Let's discuss what such a header file contains:
Function Declarations
This is the most basic part. It tells the compiler the function's name, return type, and parameter types.
// 不完整的声明 - 只知道名字和类型,但可能隐藏问题
int do_something(int a, int b);
// 更完整的声明 - 增加了extern "C"和异常规范
extern "C" int do_something(int a, int b) noexcept;Type Definitions
If we use custom structs or classes in an interface, their memory layout must be well-defined.
// 完整的结构体声明,编译器能确定其大小和内存布局
struct MyData {
int id;
double value;
char name[32];
};
// 函数使用这个结构体
extern "C" void process_data(const MyData* data);If the header file does not contain the full definition of MyData, the compiler does not know the size of sizeof(MyData), and cannot correctly allocate stack space or pass arguments for the process_data function call.
Macro and Constant Definitions
Used to define magic numbers or configurations used in the interface.
#define MAX_BUFFER_SIZE 1024
#define LIB_VERSION 0x00010002
extern "C" int initialize_lib(int buffer_capacity = MAX_BUFFER_SIZE);Including other headers
If a declaration depends on other types (such as size_t from the standard library or custom types), we need to include the corresponding headers.
#include <stddef.h> // 为了使用 size_t
extern "C" void* allocate_buffer(size_t size);Reference
Verifying Names
If you would like to see the symbol differences produced by the MSVC and g++ compilers firsthand, we will explain how the results above were generated.
We used MSVC compiler version 19.44.35217 and g++ version 15.2.1.
We saved the sample code above into a file named test.cpp.
#include <string>
#include <string_view>
class Foo {
public:
void someFunc(int a, const char* b);
};
namespace charlies_tools {
void split(const std::string& waited_splits, const char ch);
void split(const std::string& waited_splits, const std::string_view sp_view);
};
void Foo::someFunc(int a, const char* b) { }
void charlies_tools::split(const std::string& waited_splits, const char ch) { }
void charlies_tools::split(const std::string& waited_splits, const std::string_view sp_view) { }Then, on a Linux machine, we use the -c flag to compile test.cpp into machine code only:
g++ -c test.cpp -o test_nameThen, we use the nm command to inspect the ABI.
[charliechen@Charliechen runaable_dynamic_library]$ nm test_name
0000000000000012 T _ZN14charlies_tools5splitERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEc
0000000000000022 T _ZN14charlies_tools5splitERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESt17basic_string_viewIcS3_E
0000000000000000 T _ZN3Foo8someFuncEiPKcThis yields the results listed in the main text.
For MSVC, we need to open the VS Developer Prompt to initialize the MSVC toolchain environment. Then, assuming we have saved the code to test.cpp, we can use the cl compiler with the compile-only flag and the latest C++ standard flag to obtain the following output:
D:\DownloadFromInternet>cl /c /std:c++latest test.cpp
用于 x86 的 Microsoft (R) C/C++ 优化编译器 19.44.35217 版
版权所有(C) Microsoft Corporation。保留所有权利。
/std:c++latest 作为最新的 C++
working 草稿中的语言功能预览提供。我们希望你提供有关 bug 和改进建议的反馈。
但是,请注意,这些功能按原样提供,没有支持,并且会随着工作草稿的变化
而更改或移除。有关详细信息,请参阅
https://go.microsoft.com/fwlink/?linkid=2045807。
test.cppNext, we use the dumpbin utility to obtain the following:
Expand (36 lines)Collapse
D:\DownloadFromInternet>dumpbin /SYMBOLS test.obj
Microsoft (R) COFF/PE Dumper Version 14.44.35217.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file test.obj
File Type: COFF OBJECT
COFF SYMBOL TABLE
000 01058991 ABS notype Static | @comp.id
001 80010191 ABS notype Static | @feat.00
002 00000003 ABS notype Static | @vol.md
003 00000000 SECT1 notype Static | .drectve
Section length 178, #relocs 0, #linenums 0, checksum 0
005 00000000 SECT2 notype Static | .debug$S
Section length 74, #relocs 0, #linenums 0, checksum 0
007 00000000 SECT3 notype Static | .bss
Section length 4, #relocs 0, #linenums 0, checksum 0, selection 2 (pick any)
009 00000000 SECT3 notype External | __Avx2WmemEnabledWeakValue
00A 00000000 SECT4 notype Static | .text$mn
Section length 25, #relocs 0, #linenums 0, checksum E54AE742
00C 00000000 SECT4 notype () External | ?someFunc@Foo@@QAEXHPBD@Z (public: void __thiscall Foo::someFunc(int,char const *))
00D 00000010 SECT4 notype () External | ?split@charlies_tools@@YAXABV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@D@Z (void __cdecl charlies_tools::split(class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const &,char))
00E 00000020 SECT4 notype () External | ?split@charlies_tools@@YAXABV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@V?$basic_string_view@DU?$char_traits@D@std@@@3@@Z (void __cdecl charlies_tools::split(class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char> > const &,class std::basic_string_view<char,struct std::char_traits<char> >))
00F 00000000 SECT5 notype Static | .chks64
Section length 28, #relocs 0, #linenums 0, checksum 0
String Table Size = 0x123 bytes
Summary
4 .bss
28 .chks64
74 .debug$S
178 .drectve
25 .text$mn