C++ 跨平台开发遇到的问题

2,487 阅读6分钟
原文链接: zhuanlan.zhihu.com

我们维护一个 C++ 编写的滤镜和特效库,可跨平台运行在 Windows、iOS、Android 上。Windows 上使用 Visual Studio 2013 或 2017 编译,iOS 是 Xcode 带的 clang,Android 使用 gcc。

抛开 iOS 和 Android 的平台差异,只从 C++ 语言来看,clang 和 gcc 的行为是很相似的,一个移动端编译运行没有问题,另一个就基本没有问题。但 VS 编译器的表现却很不同。此文对比 VS 编译器和 clang 编译器的一些差异,顺便记录我们踩过的坑。

1. 字符编码

在我的开发环境,clang 编码默认是 utf8, VS 是 GB2312(代码页是 936),它们都兼容 ASCII。

假如代码文件中只出现英文,两端都可编译。假如代码中出现中文,文件编码为 utf8, iOS 编译没有问题,VS 会出现编译错误 error C2001。假如设置编码为 utf16, VS 编译没有问题,而 iOS 会出现编译错误 encoding is not supported。因此假如代码有中文,需要将源文件编码修改为 Unicode(UTF8 带签名)- 代码页 65001。

参见 vs编译 error C2001: 常量中有换行符 中文无法通过编译

另外假如包含中文字符串,直接读取使用,程序运行起来很容易出现乱码。类似这样的代码:

const char* str = "你好啊,世界";

想将 str 的文字在不同平台都显示正确,是不可控的。跨平台代码不应该使用中文,绝对不能用中文定义字符串再读取,更严格的甚至不能用中文写注释。假如要显示中文字符串,应该将其从程序中分离出来,写在一个 utf8 编码的配置文件中,再动态读取。

我们就碰坑了,我们本意是在导出一个 lua Api 的时候,自动生成对应的文档。有类似这样的代码:

ADD_METHOD_WITH_DOC(Context,
    nv12ToRGBPass, "获取颜色空间 nv12 到 rgb 的着色器程序",
    "3.2",
    "[Program](#program)", "program",
    0)

后来发觉在 Windows 上编译通过,iOS 编译不过。修改编码后, iOS 编译过了,Windows 上又编译不过。当两端都编译过了,但又乱码了。最后只好都写成英文,自动生成英文文档。

2. int8_t 和 char

在 VS 上,int8_t 实际上是 char 的 typedef,也就是说 int8_t 和 char 是同类型的。但是在 iOS 上,int8_t 和 char 是不同类型的。下列代码

printf("%d\n", (int)std::is_same<int8_t, char>::value);

在 VS 上输出 1,在 iOS 上输出 0。这刷新我认知,我一直以为 char 和 int8_t 是相同的。因为这区别,又踩坑了。

为了方便写 lua 导出,我们用了 LuaCpp 的库,里面有这代码

typedef LOKI_TYPELIST_15(
bool, char, unsigned char, short, unsigned short, 
int, unsigned int, long, unsigned long, float, double,
std::string, luaObject, luatable, int64_t) SupportType;

这里定义了一个 Loki 的 typelist, 包含支持自动转换的类型。typelist 参见书籍C++设计新思维

LuaCpp 基本都是模板代码,假如类型 T 属于 SupportType,就可执行自动转换的代码,不然就需要手写转换,假如没有手写转换,对于此类型 T, 就会直接崩掉。

这里的代码很老了,一直都运行正常。直到某个接口出现了 int8_t,于是 VS 上运行正常,iOS 上崩溃了。

同理,int8_t, uint8_t, int16_t, uint16_t 也需要注意。

类似的模板代码,最好还是乖乖地使用标准库中的 std::is_integral 吧。不要那么聪明自己手写 typelist 了。

3. __VA_ARGS__

__VA_ARGS__ 可用于不定参数的宏。但是它的行为在 VS 和 clang 上是有区别的。如下面代码

#include <iostream>

#define MY_PRINT(format, ...) printf(format, __VA_ARGS__)

int main(int argc, const char* argv[])
{
    MY_PRINT("Hello, World");
    return 0;
}

在 VS 上可以编译通过。但在 clang 上确实编译失败,clang 编译器的 __VA_ARGS__ 不能展开 0 个变长参数的。写成

MY_PRINT("Hello, World, %d", 1);

才可以正确展开。为了展开 0 个参数,需要写成 ##__VA_ARGS__, 定义为

#define MY_PRINT(format, ...) printf(format, ##__VA_ARGS__)

参考 Variadic macros with zero arguments

4. 跨 dll 模块的静态变量

一个工程经常有多个动态模块。在 VS 上,动态模块为 dll 文件; iOS 上为 framework 或者 dylib。VS 在跨模块时,默认符号是不导出的。clang 默认符号都是导出的。

在 VS 上,当想在 A 模块中定义某个类或者某个函数,让 B 模块使用,就需要使用 __declspec(dllexport)__declspec(dllimport) 标明。通常会定义一些宏,比如。

#if defined(OF_WIN32) || defined(_WIN32) || defined(WIN32)
#   ifdef MODULE_A_API_LIB
#       define MODULE_A_API __declspec(dllexport)
#   else
#       define MODULE_A_API __declspec(dllimport)
#   endif
#else
#   define MODULE_A_API
#endif

之后需要跨模块使用的函数或者类写成

class MODULE_A_API TestClass {
};

MODULE_A_API void myfunction(int a, int b);

通常都没有问题,假如忘记写导出,就会链接错误。但一旦涉及到模板和静态变量,这种平台的差别,就会是个坑。

模板代码通常会直接写在头文件中,比如下代码。

// myheader.h
template <typename T>
class TemplateClass {
public:
	static std::string str;
};

template <typename T>
std::string TemplateClass<T>::str;

在模块中,包含了头文件 myheader.h,就可以使用 TemplateClass 了。假如这时模块 A 使用语句设置 str 的值

TemplateClass<int>::str = "Hello, World";

之后模块 B 读取 str 的值。

std::string str = TemplateClass<int>::str;

在 VS 中,模块 A 和模块 B 虽然都使用 TemplateClass<int>,但因为没有导出,实际是分离的两个类,他们的静态变量并不会共享。于是就是模块 A 设置了 TemplateClass<int>::str,模块 B 读取的还是默认的空值。

而在 clang 编译器中,默认是导出的。于是模块 A 和模块 B 看到的是相同的 TemplateClass<int>,静态变量是共享的。于是模块 A 设置了 TemplateClass<int>::str,模块 B 读取的是设置后的 "Hello, World"

这种 Bug 比较隐蔽,可以正常编译,也可以运行,但实际结果就是不对。我们就踩过类似的坑。

前文说过,我们使用了 LuaCpp 这个库来导出 lua。这个库是个模板库,它包含一些静态变量,用来实现自动注册。我们在模块 A 中注册了一批 lua 类。之后在模块 B 中往 lua 虚拟机压注册过的类对象,在 iOS 上运行正常,但在 Windows 上就异常。因为在模块 B 中看来,LuaCpp 的记录中,这些类根本就没有被注册过。

结论就是,跨模块不要直接导出静态变量。也尽量不要在模板代码中包含静态变量,因为你不知道这份模板代码啥时候就被两个不同模块包含,到时这些静态变量就容易出问题。