C/C++符号导出
C/C++符号导出
前言
最近在做移植项目,将 Unix 的代码移植到 Windows 下面。GCC 和 MSVC 编译器二者有很多不同之处,很多操作的行为也不尽相同。本文针对二者符号导出的方法介绍项目移植过程中的经验,分享踩过的坑。
1 符号
在C++和C编程中,导出符号通常涉及将函数、变量或类的成员等标识符从动态链接库(DLL)或共享对象(SO)中公开,以便其他程序或库可以链接并使用它们。导出符号的类型和方式在不同平台和编译器中可能有所不同。
(1)函数:在C中,函数符号的导出通常通过在源代码中使用特定的编译器关键字或在DEF文件中列出函数名称来实现。导出后的函数符号可以被其他程序或库通过链接器在编译时或运行时链接和使用。
(2)全局变量:全局变量也可以被导出,导出全局变量的方式与导出函数类似,通常也涉及使用编译器关键字或在DEF文件中列出变量名称。
(3)类成员:在C++中,类的成员函数和静态成员变量也可以被导出。导出类的成员通常涉及将类声明为导出类型,并在类的定义中使用相应的编译器属性或关键字来标记要导出的成员。
(4)模板和泛型编程:在C++中,模板和泛型编程也涉及符号的导出。但是,由于模板实例化是在编译时进行的,并且模板的实例化可能会产生多个不同的符号,因此导出模板符号通常更加复杂。这通常涉及在源代码中显式地实例化模板,并使用编译器属性或关键字来导出这些实例化后的符号。
需要注意的是,由于C++的名称修饰规则,导出C++函数时需要注意确保符号名称在链接时保持一致。
2 GCC 的符号导出与隐藏
在GCC中,如果不使用特定的编译选项,默认情况下,所有未使用static修饰的符号(函数和全局变量)都将被导出,这意味着它们都可以被其他模块或库链接和使用。这种行为在Linux环境下尤为明显,因为GCC通常用于Linux系统的开发。而 Windows 下使用 Mingw 即使不添加这一参数,默认也是隐藏符号的;因此从 Linux 移植代码至 Windows 下,要求原 Linux 项目在符号导出方面的规范性。
为了避免默认导出所有符号,GCC提供了-fvisibility=hidden编译选项。使用这个选项后,库中的所有符号都将被默认设置为对外不可见。这样编译出的二进制文件就不会导出可供外部链接的符号,从而实现了符号的隐藏。
# gcc 符号隐藏参数
-fvisibility=hidden
虽然-fvisibility=hidden选项可以隐藏所有符号,但有时我们需要显式地导出某些符号。这时,可以使用GCC的__attribute__ ((visibility ("default")))属性来指定哪些符号应该被导出。通过在代码中添加这个属性,我们可以明确地告诉编译器哪些符号是库的外部接口。例如:
// 在头文件中.h
__attribute__ ((visibility ("default"))) void my_function();
// 在源文件中.c
__attribute__ ((visibility ("default"))) void my_function() {
// 函数实现
}
在这个例子中,my_exported_function函数被显式地标记为导出符号,即使使用了-fvisibility=hidden编译选项,它仍然可以被其他模块或库链接和使用。
3 Windows下符号导入导出
MSVC下符号默认是隐藏的,需要需要编译器关键字 __declspec 来指定变量的存储类型;该关键字有多种参数,包含如线程局部变量、内存对齐等等操作。
导入导出的参数为 dllexport 以及 dllimport,使用方法一般是通过宏定义的方式:
#ifdef MY_SYMBOL_EXPORT
#define MY_SYMBOL __declspec(dllexport)
#else
#define MY_SYMBOL __declspec(dllimport)
#endif
// 声明一个函数
MY_API void do_something();
在编译动态库时,添加 MY_SYMBOL_EXPORT 宏标记符号为导出状态;在链接库的时候,宏定义为导入状态,告知编译器需要从外部库来链接该符号。
需要注意的是,符号在声明和定义的地方都需要使用该宏,否则编译器将认为两个符号不一致,导致一些错误。
除此之外,常见的如从外部库声明一个变量,与常见的Gcc不同;如 extern:
// 假设该变量在另一个库 A 里里面定义,在 B 库中使用extern int var_from_libA;
上述的代码在连接过程中, var_from_libA 的类型信息将丢失,能够存在的仅是该变量的地址,使用方法也将变为:
*(int *)var_from_libA;
使用 __declspec 关键字标记导入将解决该问题。
#ifdef WIN32
#define IMPORT_SPEC __declspec(dllimport)
#else
#define IMPORT_SPEC
#endif
extern IMPORT_SPEC int var_from_libA;
在进行跨平台开发时,需要注意不同编译器之间的差异,并使用宏定义来封装不同的编译器特性。这些机制有助于我们更好地管理库的接口可见性,从而提高代码的可维护性和安全性:
#if defined(MY_API_EXPORT)
#if defined(__GNUC__) && __GNUC__ >= 4
#define MY_API __attribute__ ((visibility ("default")))
#elif defined(_WIN32) || defined(__CYGWIN__)
#define MY_API __declspec(dllexport)
#else
#define MY_API
#endif
#else
#if defined(_WIN32) || defined(__CYGWIN__)
#define MY_API __declspec(dllimport)
#else
#define MY_API
#endif
#endif
// 使用宏定义导出符号
MY_API void my_cross_platform_function();
4 通用方法 .def 文件
def 文件常用于批量导出符号,我想在移植程序中使用的较多。它的格式比较简单:
LIBRARY LIB1
EXPORTS
Func1
Var1 DATA
将其加入到 CMake 的配置中如:
target_sources(${target} PRIVATE symbols.def)
gcc 将其def文件加入到动态库生成的命令行中即可。
def文件的好处是可以通过枚举的方式控制需要导出的符号,缺点是无法动态的通过宏一些方式动态的控制,因此当代码发生改动时,def文件也需要及时改动。
5 常见问题
5.1 C/C++符号混用
C语言:在C语言中,函数名称在编译后不会进行额外的处理,即函数名在目标文件中保持原样。例如,有一个C语言函数 int AddNum(int x, int y);,编译后其名称仍然是AddNum。
C++语言:而在C++中,由于支持函数重载,编译器会对函数名称进行命名修饰。例如,有两个重载的函数 int AddNum(int x, int y);和 double AddNum(double x, double y);,编译后它们的名称可能会被修饰为类似_Z3AddNumii和_Z3AddNumdd(实际修饰名取决于编译器的实现)。这样,链接器就可以根据修饰名来区分这两个不同的函数。
如GNU GCC编译器使用了一套复杂的命名修饰规则来区分不同的符号。以下是一个类成员函数的命名修饰例子:
namespace wikipedia {
class article {
public:
std::string format(void); // 修饰名为 _ZN9wikipedia7article6formatEv
bool print_to(std::ostream&); // 修饰名为 _ZN9wikipedia7article8print_toERSo
class wikilink {
public:
wikilink(std::string const& name); // 修饰名为 _ZN9wikipedia7article8wikilinkC1ERKSs
};
};
}
在这个例子中,format函数的修饰名包含了命名空间wikipedia、类名article、函数名format以及参数和返回类型的信息。这种修饰方式确保了每个符号在全局范围内都是唯一的。
在C++中,如果希望某些符号能够按照C语言的规则进行导出和链接,可以使用extern "C"语法来指示编译器。例如:
// .cpp
extern "C" {
int a;
}
extern "C" int b;
// .h
extern "C" {
void funcA();
}
exetern "C" funcB();
一般可以用宏来控制 extern 如:
#ifdef __cplusplus
#define EXTERN_START extern "C" {
#define EXTERN_END }
#else
#define EXTERN_START
#define EXTERN_END
#endif
// .h
EXTERN_START
void funcA();
EXTERN_END
5.2 C++类的导出
当类中存在其他类的成员变量,并且想要导出这个类(即,使这个类的所有符号在动态链接库(DLL)或共享对象(SO)中可见),需要确保所有相关的类都被正确地导出,意味着需要为这些类都应用相应的导出宏。
// MyClasses.h
#pragma once
#include "Export.h"
class MYLIBRARY_API BaseClass {
public:
BaseClass();
void BaseFunction();
};
class MYLIBRARY_API DerivedClass {
public:
DerivedClass();
void DerivedFunction();
BaseClass myBaseClassMember; // 包含基础类的成员变量
};