大的问题
先记录一下跨平台时需要注意的大方向.
1.OS和CPU
同一个操作系统, CPU也可能是不一样的, 比如windows也有基于arm CPU的版本,而android目前有x86,arm,mips几种.
即便是同一种CPU架构系列, 细节特性也不一样.
所以目前个人准备了3个宏开关来判断目标平台. OS, CPU, CPUbits
OS对应不同的操作系统, CPU对应不同的CPU架构(比如x86和arm), CPU-bits目前是32和64, 比如CPU是x86时, CPUbits是64,则对应x86_64(x64)平台.同样arm系列也有32位和64位的区别.
32/64位的处理之前已经转载过一篇文章专门说明,这里不多说了.
通过这三个宏可以确定最终目标平台.但是为了减少维护代价, 这几个宏一般情况下只在底层封装使用, 在上层还是少用为好, 能不用尽量不用, 尽量使用标准C/C++.
SIMD的话, Intel Atom支持SSE, 而ARM有NEON指令集, 具体可以参考ndk的sample.
2.程序数据.
一般来说, 需要根据每一种目标平台, 各生成一份对应的数据.
这样做的好处是每个平台的数据可以根据目标特性做调整.比如移动平台的场景规模/质量和数据量一般都相对要小.
由于每种CPU的数据处理特性(cache line, alignment, SIMD指令集)可能都不一样,所以需要做特别的处理.
对于数据的格式,有两种做法, 第一是每个平台的数据包格式不一样, 第二是种是使用统一的格式, 也就是说尽管每个目标平台都有不同的一个数据包(因目标规模而定), 但理论上某个平台可以正常加载其他所有平台的数据包.
而第一种则不同, 各个平台的数据格式不一样, 比如一个struct的成员对齐不同, 那么序列化后的数据会有大小和偏移量的差别.
第一种方式不用特别考虑数据对齐等等问题, 但是要生成和维护不同目标平台下的数据包和工具, 虽然维护成本相对较低,但是如果工具很多的时候也很繁琐.
第二种方式需要在代码中考虑对齐等等因素, 保证一个数据包可以被所有的平台加载, 这么做可能也很繁琐,需要特别小心,并经过完整的数据测试.
这里有一个android下x86和arm的结构对齐的处理 (4.1 Forced Memory Alignment):
比如上面第一个链接中使用的"-malign-double" GCC参数, 强制8字节的数据对齐的8字节边界.保证同一份数据可以被不同的CPU正常加载. 第二个链接使用的是__attribute__((aligned(n))), 来强制数据成员的对齐.
我所在的公司使用的是第一种方式. 第一种方式的问题在于同一个OS也有不同的target CPU(platform), 这样会出现对于android平台, x86有一份数据, 而arm有另外一份数据. 所以个人倾向于第二种方式, 除了上文链接中的方式以外, 也可以强制不读写struct, 只读写原子数据, 把一个struct的成员逐个单独读写.
--附:
考虑到endian的问题, 一般数据写入会使用固定的endian, 在读取的时候根据目标特性做byte swap. 而封装出原子数据的读写以后这一点应该也不难做到.
3.Platform API和库
不同的OS的Platform API不尽相同, 不过幸运的是, 大部分OS都支持posix API接口规范, 所以一般来说,如果标准C/C++不支持的功能, 能使用posix接口的尽量使用. 个人遇到的最多的是IO, 线程和计时器(timer)这些.
虽然windows也支持posix子集, 但是一部分函数没有, 这个时候要用OS的宏来做条件分支, 使用Windows API.
比如windows下的findfirst遍历文件, 在android/posix下面要改用opendir.
但是为了便于移植和维护,要注意一个原则,尽量不要将platform header暴露出来: public header中不要引用系统头文件, 这是一种强制检错机制. 因为一旦暴露出来的话,上层代码很容易误用.比如游戏代码里面使用了HANDLE,在win32下编译没问题,所以没有发现错误,换到其他平台会出错.如果一定要用的话,用宏分开平台相关的数据.
对于一些跨平台功能, 如果不想自己写的话, 也有很多现成的跨平台三方库可以用, 它们已经将常用平台封装好了,跳过繁琐的Native API条件编译, 比如UI系统可以使用Wx/Qt库等.
线程的话, 大部分OS都支持pthread或者其子集, 由于笔者使用的是Intel TBB, 而最新的TBB4.x已经支持android平台, 前不久把TBB3.0更新到最新版本, 并且编译可用. 这里不多说了.
4.编译器
编译器首选GCC, 交叉编译的神器啊. 但由于历史原因(笔者一开始使用的是MSVC,工程从VC8一直到VC11,目前保留了VC10和VC11), 这里也介绍下MSVC, 并简说一下两者.
对于C++11, GCC支持的更好. 对于C, MSVC基本停止在C89. 如果要考虑尽可能多的编译器支持的话, 尽量使用C++03和C89.
对于Clang, 没有使用过, 暂不讨论.
而不同的编译器有不同的扩展, 而且很多扩展是等价的. 如果扩展格式类似, 那么可以写在基础头文件里面.比如Ogre的alignment,我也拿过来用了:
1
2
3
4
5
6
7
8
9
10
11
|
#if defined(_MSC_VER)
# define BLADE_COMPILER BLADE_COMPILER_MSVC
# define BLADE_ALIGNED(n) __declspec(align(n))
# define BLADE_FUNCTION __FUNCTION__
#elif defined(__GNUC__)
# define BLADE_ALIGNED(n) __attribute__((aligned(n)))
# define BLADE_COMPILER BLADE_COMPILER_GNUC
# define BLADE_FUNCTION __PRETTY_FUNCTION__
#else
# error "Compiler not supported yet."
|
这样对于一个需要对齐的结构, 可以使用 struct BLADE_ALIGNED(8) YOURSTRUCT {}; 来指定对齐, calling convension 也类似, 不过我这里只有定义,没有实际用到.
对于格式不一样的扩展(某些compiler intrinsics), 可以在最后的具体使用时根据编译器开关来条件编译, 也可以将基本功能封装成基础类, 在实现里同样使用条件编译, 这样上层的使用代码比较统一.
顺便说下我所在公司的tool chain, 他有自己的make系统, 比如XMAKE, 有自己的编译器前端collection, 比如XCC, 使用非标准的C语言.
通过一个XC Compiler, 把自定义语法和扩展的的C语言转换成本地编译器(VC/GCC)支持的源代码, 然后再使用本地编译器编译.
这样做的优点是跨平台/编译器的东西可以全部放在自己的compiler extension里面, 上层代码很干净, 只要按语法来写, 那么写出来的代码就是跨平台(除了Platform API 需要单独封装)的, 基本不用考虑编译器和CPU相关的的东西, 而且这是强制性的, 很少出现差错.
缺点就是学习成本, 一个标准C/C++的程序员需要一定时间适应他的语法.
当然他基本不支持C++(除了类/动态绑定以外, 模板和STL都不支持), 所以有点恶心, 但是可以直接调用本地编译器,编译标准C++代码, 并且与其大部分ABI兼容, 可以链接. 所以可以编译如MFC这些代码并且链接, 但是有一些限制.
这里面也有历史的原因, 因为这套工具很早就有了, 那个时候可能第一个C标准还没有出来.所以才会费很大力搞这套东西, 不过到现在已经很稳定了. 而且他很早支持的某些扩展, 目前的C++11才刚刚有. 总的来说是一个不错的东西, 但是如果是换到现在的话,有成熟的编译工具链和语言标准, 估计没有人会再去这么搞了.
细节
然后记录下移植过程中遇到的细节问题, 主要是MSVC到GCC过程中遇到的问题.
问题主要在于MSVC对标准支持不好, 允许很多标准不允许的方言.
记录下这些细节, 以后养成好的编程习惯.
还有一部分android OS API 和bionic libc的问题.
1.模板
嵌套模板时, 标准不允许<<>>这种方式, 因为它与operator>>冲突.VC却没有这个问题..所以正确的方法是要用空格 隔开, 这个很早就做了, 只有部分没有注意的这次改过来了.
1
2
|
typedef
std::vector<std::vector<
int
>> intdvector;
//error
typedef
std::vector< std::vector<
int
> > intdvector;
|
同样在cast的时候,最好也用空格将<>隔开, 避免编译错误,
1
2
|
::RGBQUAD& quad =
reinterpret_cast
<::RGBQUAD&>( q );
//error
::RGBQUAD& quad =
reinterpret_cast
< ::RGBQUAD& >( q );
|
2.组合关键字的类型转换
如果是单个关键字则都是合法的.但MSVC允许
1
|
unsigned
short
s = unsigned
short
(1.0f);
|
这样的转换, 但是GCC比如要把组合关键字用括号扩起来 (至于哪个是标准, 没有去查, 但是猜想应该是GCC的)
1
|
unsigned
short
s = (unsigned
short
)(1.0f);
|
3.mutable reference
1
2
3
4
5
|
struct
A {...};
struct
B
{
mutable
A& mARef;
//error
};
|
GCC 不允许使用mutable reference, 因为reference可以在const成员函数里面修改.
这个也没有去查标准, 但是应该心照不宣了.
4.纯虚析构
MSVC允许将纯虚析构(纯虚函数的实现)放在类内,但是标准不允许.这个很久前查过标准, 而且修改过代码,不过后来有少量代码由于习惯的原因又这么写了.
1
2
3
|
virtual
~Class() = 0 {}
// OK on MSVC, error on GCC
virtual
~Class() = 0;
//OK
virtual
~Class() {}
//OK
|
5.Function scope functor
1
2
3
4
5
6
7
8
|
iterator findSomeThing()
{
struct
FnFinder
{
bool
operator(
const
T& val) { ... }
};
return
std::find_if( v.begin(), v.end(), FnFinder() );
}
|
这个在GCC下编译错误, 需要把struct FnFinder定义在函数体外面.
6.动态库的模板导出
前面的都是小改动, 但这是个最头疼的问题.
MSVC下的DLL导出模板很简单:
DLL_EXPORT_API 是根据当前编译的代码, 是本地DLL代码,还是引用该DLL的客户代码所做的__declspec( dllexport )/__declspec( dllimport )
本地代码就是编译DLL时的代码, 客户代码指使用DLL(对应的头文件)并链接该DLL的模块代码.
1
2
3
4
5
|
# ifdef DLL_LOCAL_COMPILE
# define DLL_EXPORT_API __declspec( dllexport )
# else
# define DLL_EXPORT_API __declspec( dllimport )
# endif
|
然后在公共头文件里面声明该导出模板:
1
|
template
class
DLL_EXPORT_API Handle<Class>;
|
只要本地DLL内部任何一个编译单元包含了上面语句(可以重复包含), 就可以在该dll里面生成一份唯一的类代码.
但是GCC下面的机制有所不同: 由于没有特别的导出关键字, 只是通过visibility来控制可见性:(要设置为默认时所有符号都不可见)
1
2
|
#DLL_EXPORT_API __attribute__ ((visibility("default")))
template
class
DLL_EXPORT_API Handle<Class>;
|
这句显式实例化语句, 会在客户代码的每一个编译单元里生成一份实例化代码.而不像MSVC那样根据__declspec( dllimport )生成对外部链接的引用.
如果是纯代码的类, 最多就是生成冗余的代码, 目标文件过大. 这或许勉强可以忽略.
但问题在于class 内部的static变量或者成员函数内部的static变量也同样存在多个, 导致singleton等等某些类的单例指针存在多份实例, 从而导致libA.so 的singleton实例与libB.so的singleton实例不是同一个实例.
GCC下的模板支持声明外部模板.
1
|
extern
template
class
Handle<Class>;
|
这样在客户代码使用该声明的时候, 就可以阻止模板实例化, 使他具有对外部链接(.so导出符号)的引用.
而真正的显示实例化要放在本地.so文件的一个编译单元里, 而且不能重复声明(重复以后导致多次实例化, 产生重复符号, 链接错误).
这样最终的版本(GCC & MSVC都可行)如下:
1
2
3
4
5
6
7
8
9
10
|
# if BLADE_COMPILER == BLADE_COMPILER_MSVC
# ifdef XXX_DLL_LOCAL_COMPILE
# define DLL_EXPORT_API __declspec( dllexport )
# else
# define DLL_EXPORT_API __declspec( dllimport )
# endif
# elif BLADE_COMPILER == BLADE_COMPILER_GNUC
# define DLL_EXPORT_API __attribute__ ((visibility("default")))
# endif
extern
template
class
DLL_EXPORT_API Handle<Class>;
|
虽然MSVC警告说__declspec( dllexport )和extern不兼容(因为dllexport是要求本地生成实例化代码作为导出符号, 与extern的意义冲突)但是可以关闭这个警告.
同时要在本地库的某编译单元里面加上唯一的一句:
1
|
template
class
Handle<Class>;
|
来完成GCC的本地模板实例化, 而对于MSVC来说, 有没有这一句都一样, 因为上面的 extern template class DLL_EXPORT_API Handle<Class>; 已经完成了导出模板的实例化(extern被忽略), 只要它被本地的任何一个或者多个编译单元包含就可以.
最终的移植工作量就是, 给所有的导出模板声明加上 extern 关键字, 并在本地.so工程的某编译单元添加一个对应的显示实例化声明.目前感觉这样做的改动是最小的.
--附
另外需要注意的是, 如果一个模板类A, 集成自另一个模板类B, 那么extern template class 只对本类有效, 基类没有extern属性.幸好我这里这种情况不多, 类只有一个, 但是需要在每个extern显式实例化时加上基类, 由于有点繁琐, 根据目前情况,直接在子类overwrite掉父类的函数, 在子类的导出函数里调用父类函数, 避免直接调用父类函数导致父类在客户代码被实例化, 这样也可以解决.
7.Bionic libc下的libstdc++不支持宽字符
据说之前的版本sizoeof(wchar_t)还是1, 后来支持了,是4, 但是当前版本(ndk r9)宽字符的库函数仍然没有.
比如vswscanf等等, 这个也需要自己黑了.
8.NDK下没有messagebox
由于把messagebox做成了基础框架函数,所以必须要使用这个功能.
但是ndk下几乎没有任何窗口API, 这个需要用JNI, 在C/C++层调用Java来解决了.
本来使用了ndk内置的NativeActivity想跳过Java coding, 编写full native code, 目前看来不可行了.
9.dlopen不支持RTLD_NODELETE
由于使用了很多动态库, 而为了调试内存, 把上层库内部的local buffer指针比如__FILIE__, __PRETTY_FUNCTION__传到了基础库里面,在memory leak dump的时候需要把这些上层库关闭(所有对象释放)以后, 才能够dump.
但是这些上层库卸载之后,这些localbuffer已经随着.so的释放被释放掉. 因为这些常量buffer通常是在编译期直接生成到.so镜像里面的常量section. 这导致leak dump时引用了无效的野指针.
windows下也有类似的问题, 不过windows下的GetModuleHandleEx的GET_MODULE_HANDLE_EX_FLAG_PIN可以支持永久不卸载库,与RTLD_NODELETE等价,在debug模式下需要开启才能正常leak dump, 否则有泄露的时候可能会崩溃掉.
这个问题已经提交到官方issue里了: http://code.google.com/p/android/issues/detail?id=64069
10.GLES 2.0
这部分没有实现, 留着以后补充. 除了GLES以外,其他所有模块都已经移植完毕并且真机测试可以启动运行.
先简单总结下渲染部分, 主要有渲染设备(drawcall/ render state), Texture/RenderTarget, Index/VertexBuffer, Shader, 这些封装一下, 都是体力活, 还有很多坑. 但移植的基调已经定了, 这些等以后有空了在做, 最好等到GLES3.0普及了再做. 后面主要任务是平台无关的图形效果, 等效果做的差不多了, 再移植就可以用了.
11.其他
还有很多APK打包上的诸多限制, 比如google play上要求apk最大50M, 超过的数据需要用扩展包, apk打包时候的文件名/目录结构限制, 动态库.so加载的限制等等,已经在前一篇笔记中记录了.