移动平台native代码遭遇的坑

最近客户端终于开始运行在移动平台上了(当然,快开发完才开始在移动平台尝试运行,本身就是一件很错误的顺序,然而这并不是我所能控制的),之前在PC平台上完全没问题的代码,开始出现一些诡异的问题。

为了保证客户端和服务器使用绝对相同的逻辑执行流程,我们采用C++来开发一部分native代码同时供客户端和服务端来使用。在迁移到移动平台时,这些native库在IOS和Android平台上出现了不同程度的水土不服。

首次在移动平台就发生了crash,并且只有Android平台会crash, 而IOS可以正常进入游戏。

最后定位到,当执行类似下面的代码时安卓平台就会发生crash。

int a = 3;
char buf[64];
char *p = buf;
*p = 0;
*(int *)(p + 1) = a;

在编译安卓平台native动态库时,为了尽可能的保证兼容性,我们采用了armeabi-v7a来编译native动态库,据ARMv7开发文档显示,在ARMv7架构下,uint32_t *需要4字节对齐,而uint16_t *则需要2字节对齐,只有uint8_t *才不需要对齐约定。

而苹果自iphone5s发行时,就采用了基于ARMv8-A架构的的Apple-A7。根据ARMv8-A开发文档显示,在ARMv8-A架构下,所有地址访问都不再需要指针对齐要求。换句话说在IOS的64位平台上,上面代码是完全正确的。

当然,木桶原理,为了保证代码在所有平台上都能正常运行,需要做出如下修改:

//此段代码同时可以无视机器大小端,而强制a在内存中的布局为大端还是小端,此种写法为小端
- *(int *)(p + 1) = a;
+ p[1] = a & 0xff; 
+ p[2] = (a >> 8) & 0xff; 
+ p[3] = (a >> 16) & 0xff; 
+ p[4] = (a >> 24) & 0xff;

如果要保持与机器大小端相同可采用如下写法:

- *(int *)(p + 1) = a;
//由于a只有四个字节,此处可以手动展开memcpy以优化函数调用和for循环
+ memcpy(p, &a, sizeof(a));

看到Android这么热闹,IOS也有点不平衡,在调用某个native函数时,报出了`To marshal a managed method, please add an attribute named ‘MonoPInvokeCallback’ to the method definition.`错误。

但是并不是所有native函数都会有这个问题。经过比较发现,这个函数在设计时,为了方便方便Unity可以接管native内部的log, 多增加了一个参数,用来将C#中log函数传入。直接将参数改为NULL时,果然问题解决了。但是很奇怪的是,在Windows下并不会有此问题。

最终在MonoTouch的官方文档中找到了答案。

在微软向ECMA提出的CLI(通用语言基础架构)中,并没有定义标签`MonoPInvokeCallback`。这也正印证了,我们在PC平台上从来没有出现过此问题。

如果在编译成移动平台时’Scripting backend’选项选用了`IL2CPP`,就需要使用AOT编译器来进行编译。

而进行AOT编译时,MONO需要知道哪些静态函数可能会被native代码调用,以便对C#函数进行额外处理(ps. 理论上,一个函数是否需要会被传入native函数中,是可以在编译时推导出来的,不知道MONO为什么没有做这件事)。

GC竞争问题

阅读Lua GC源码中,我们就提到过一个细节,所有带有__gc函数的对象,在第一轮GC循环中只会执行__gc函数,直到第二轮GC才真正清除。

一直没有找到必须这样做的场景,直到最近我发生了一例GC竞争的bug之后,才恍然大悟。回过头想想,其实在我之前翻译Barry Hayes大神的一篇论文里也早都提到过,只不过当时例子是释放OS资源,而场景也太过抽象,才没有引起我的注意。下面来看一个MWE。

我已经尽可能的精简代码,然而还是需要170+LOC。

在这个例子里,实现了一个链表管理,所有的link和node结构均交由Lua GC来自动管理内存。

由于Lua GC不能分析C结构之间的引用关系,因此所有的node(userdata)必须通过一个Table来保持引用,以防GC误回收。这也是为什么在luaopen_link函数中,我们为所有函数绑定了一个相同的UpVal(Table)。

下面来分析为什么会有竞争的发生。

首先这个UpVal是全局的,也就是会与luaVM同生共死。所以`UpVal中node对象的生命周期` >= `link的生命周期`。

因此竞争问题只会出现在UpVal的生命周期与link的生命周期一同结束时。假设UpVal和link的生命周期都在GC 循环C1中结束。没有机制能保证UpVal先于或后于link死亡,因此node对象的__gc和link.free在这一周期是竞争执行的。这就是RACE1和RACE2都需要将buff置为NULL的原因,只要有一方不置为NULL另一方就有可能出现double free。

之所以置NULL不会有memory corruption问题. 是因为在Lua GC实现中,所有带__gc函数的对象,在当前GC循环死亡后并不会立即释放内存,而是会等到下一轮GC循环才会真正释放。换句话说只要在本轮GC循环中,不管什么时间访问操作node指针都是有效的。

反过来讲,由于在释放过程中可能存在竞争或释放过程中循环依赖的情况。GC模块要保证在执行__gc函数过程中,所有需要的数据都是有效的,就必须要延迟一个GC循环来回收内存。

ps.其实上面的竞争问题可以通过其他手段解决,比如为link设置一个__gc函数来释放buff, 移除掉node对象中__gc函数的释放buff行为,但是不管怎么样竞争场景确实存在,比如两个user data相互引用,并被同一个Table持有等。

又一个类型提升引起的Bug

在好几年前我已经中招过一次了, 没想到最近一不留神又中招一次。不过这次的花样和上次又不太一样。

Bug的起因是,我需要一个函数,根据指定速度(可能不是整数)和距离来获取到达目的点的时间,于是就有了下面这样一段代码。

//#define TIME time(NULL)
#define TIME 1526796356
time_t foo(int distance, float speed)
{
        return TIME + distance / speed;
}
int main()
{
        printf("%ld\n", foo(30, 3.0f));
}

咋一看这代码几乎没毛病,严格遵循牛顿大神给出来的公式来算。

然而他的输出却是`1526796416`这样一个值。在思考了将近20分钟之后,我才恍然大悟。

根据《The C Programming Language》第二版中的A6.5节算术转换一节的内容

`
First, if either operand is long double, the other is converted to long double. Otherwise, if either operand is double, the other is converted to double.

Otherwise, if either operand is float, the other is converted to float.

Otherwise, the integral promotions are performed on’ both operands; then, if either operand is unsigned long int, the other is converted to unsigned long int

Otherwise, if one operand is long int and the other is unsigned int, the effect depends on whether a long int can represent all values of an unsigned int; if so, the unsigned int operand is converted to long int; if not, both are converted to unsigned long int

Otherwise, if one operand is long int, the other is converted to long int

Otherwise, if either operand is unsigned int, the other is converted to unsigned int

Otherwise, both operands have type int

There are two changes here. First, arithmetic on float operands may be done in single precision, rather than double; the first edition specified that all floating arithmetic was double precision. Second, shorter unsigned types, when combined with a larger signed type, do not propagate the unsigned property to the result type; in the first edition, the unsigned always dominated. The new rules are slightly more complicated, but reduce somewhat the surprises that may occur when an unsigned quantity meets signed. Unexpected results may still occur when an unsigned expression is compared to a signed expression of the same size.
`

代码`return TIME + distance / speed`在实际执行时会被转换成`return (float)TIME + (float)distance / speed`来执行。

之所以思考了那么久才发现问题,是因为在写代码时就知道编译器会进行类型提升。而从理论上来讲,编译器在进行数学运算时,总是会向最大值更大的类型进行转换,以保证隐式转换的正确性。所以首先就把这种错误是类型提升造成的可能给排除了。

这种理论本身也是正确的,因为float的最大值为`340282346638528859811704183484516925440`,可是我忽略了一个很重要的事实,就是float即然叫单精度,就说明它本身是有精度的。


来看一下float的构成.


Float example.svg

float由1个符号位,8个指数位和23位尾数位构成,而精度完全是由23位尾数控制的,因此虽然float可以表示很大,但那是通过指数位进行跳跃(乘以2的N次方)来得到了,换句话说,float能表示的数其实是不连续的,而我们现在的time(NULL)值`1526796356(0101 1011 0000 0001 0000 0100 0100b)`已经使用了31位二进制了,它的二进制有效数字位为29位,已经超出了float中的尾数位。因此在int转向float时必然会丢失精度。

弄清楚了问题之后,只需要加个类型强制转换就可以解决问题了:

//#define TIME time(NULL)
#define TIME 1526796356
time_t foo(int distance, float speed)
{
        return TIME + (time_t)(distance / speed);
}

BTW. 在查阅文档时,发现了一个有意思的事,《The C Program Language》第二版 在算术类型转换一节中特意标注,在第一版中,所有的float类型算术运算都是先转换成double再进行,而第二版的描述是float类型算术运算‘有可能’直接进行,而不是转换成double之后再进行。

std::vector的错误使用

上周五服务器线上出现了几次crash,拿回dump文件分析后发现代码是崩在了对一个引用的成员变量赋值上。

分析了半天也没看出来代码有什么不妥,就先搁置了。

今天同事又给我看了一段奇怪的代码,某个类成员函数返回了某个成员变量的引用,但是当指针为NULL时去调这个函数依然不会崩溃。

思来想去搞不明白,反汇编之后终于发现原因所在。

c++引用本质上也是指针,只是不能为NULL而已。因此返回引用其实就是返回这个变量的内存地址。也就是说这个函数实际的操作仅仅是拿this指针加上这个成员变量的偏移量,然后将结果返回给引用变量。这个操作从始至终都没有没有去操作内存,当然也不会崩溃。

解决了这个疑问之后,又想起来上周五的崩溃。再次分析了一下代码,想看看是否是因为相同的问题引起的。

花了两个小时之后终于发现,其实是误用vector引起的。

这段代码的作者使用vector实现了一个结构体池,每次申请结构体时从池中获取,释放时归还到池中。

结构体池的定义类似std::vector<struct xxx> pool;

之所以产生bug是因为,每次当vector中的元素被使用完之后,都会掉用resize来将vector的容量加倍。

熟悉vector的人都知道,vector本质上就是一个数组,当大小不够时就重新分配一块更大的内存并释放掉原先的旧内存。这会导致vector中元素的内存地址全部改变。

在调用池的分配函数时,已经把相应元素的内存地址返回给了逻辑代码。vector内存地址的改变势必会导致在操作以前分配出去的元素时会出现访问错误内存。

bug正是这样产生的,函数a从池中申请了一个元素,然后掉用了函数b。函数b又从池中分配了一个元素,恰好池中元素用完了,导致了vector进行resize。当返回到函数a时,在对以前返回的元素进行操作实际上是非法的。因为这块内存已经被释放掉了,而相关数据也已经被挪到新内存了。

bitfield数据类型的坑

bitfield并不具有可移植性,因此实际使用中,我都是尽量使用bitand来代替。

然而代码中之前就已经使用了bitfield的定义方式,作为后续开发我没有理由去改掉这个数据结构(除非它有问题),结果就无意间踩到了这个坑。

bitfield定义和使用大概如下:

union utest {
int val;
struct stest {
int a:3;
int b:5;
};
};

union utest t;
t.value = 0x07;

bitfield冒号后面的数字标识bitfield的位宽,bitfield前面的类型用于标识取出字段后应该变成一个什么样的类型(标准上说仅能支持int, signed int, unsigned int, 然而gcc还支持char, short等类型)。

问题的关键就在于,如果你定义的是有符号类型,那么编译器会将取出的bitfield按照有符号类型进行类型提升

当程序读取变量stest::a时,他会读取utest::val的byte0的低3bit。由于stest::a的类型为int(有符号型类型), 则他将utest::byte0::bit2作为符号位进行整型提升。

也就是说如果utest::byte0::bit2~0的值为110b, 那么你读stest::a时,编译器会将bit2作为符号位来将110b整型提升为0xfffffffe, 即(int)-2;

在实际使用中我使用stest::b作为了一个数组的索引,当stest::b大于0x10时,数组访问直接越界了。

btw, 一般使用bitfield特性时应该很少去依赖于其符号扩展功能(即将其定义为有符号型类型), 因此在将bitfield定义为int而不是unsigned int时一定要再三考虑。

当class遇上union

今天同事又踩到一个以前设计时留下的坑,这次是关于union和class中的。 虽然这种设计我并不认同, 但是至少我觉得设计者对于c++的成员内存布局相当了解。

由于面向对象的存在, 在代码中常常有这样一种用于存储属性的类,类A,类B, 类C,类B继承自类A,类C继承自类B。 而类A, 类B, 类C等这些类的实例都是从socket层传过来的。

作者在设计时为了代码的复用性, 采用了如下设计:

union object {
class A a;
class B b;
class C c;
};

//read_objectX_from_socket函数为伪码, 其实现为逐个读出某个类的成员, 至于这个函数为什么会是这样实现, 这是socket层上的另一个设计问题了, 暂且不谈

void readA(union object &o)
{
read_objectA_from_socket(o.a);
}

void readB(union object &o)
{
readA(o);
read_objectB_from_socket(o.b);
}

void readC(union object &o)
{
readA(o);
readB(o);
read_objectC_from_socket(o.c);
}

从上面看出作者对于C++中的成员变量的内存布局相当有信心, 才会想到使用union的方式来复用代码。

假设这三个类的成员定义如下:class A {int a;}, class B : public A {int b;}, class C : public B {int c;}.

那么此union中的内存布局其实就是A::a, B::b, C::c。
object::a所占的空间就是A::a在union中所占的内存
object::b所占的空间就是A::a和B::b在union中所占的内存
object::c所占的空间就是A::a和B::b和B::c在union中所占的内存

设计者巧妙的利用了union的重叠特性和class的继承特性来完成了代码复用。

大约在上学的时候我也喜欢去hack内存布局(当然没有这种用法这么巧妙), 后来我便渐渐不大喜欢这种做法了.

因为这种代码虽然写起来有种炫技的自豪感, 但事实上一旦出了bug是极难发现的, 人类在汇编语言基础之上又发明了高级语言, 我想也正是因为他们觉得人们需要更多的规则来帮人们减少出错的可能性, 所以我后来便一直主张写出更多可以让编译器检查出错误的代码。


同事踩的坑也正验证了hack内存布局易错不易查的事实。 由于某种偷懒原因, 他实现了class D : public B {int d;}, class F : public C, public D { int f;}。而readF的实现代码如下:

void readF(union object &o)
{
readA(o);
readB(o);
readC(o);
readD(o);

read_objectF_from_socket(o.c);
}

在实现readD时代码看起来依然正常运行, 但是在实现readF时, 明明看到有数据读入,但是类F中继承自C和D的成员总是莫名其妙乱掉(当然这不是我发现的, 这个bug只是我事后知道的罢了)。

此时重新看一下union, 那么C::c和D::d占用的是同一片内存, 那么其实在readF中调用readC和readD时, 覆盖的总是同一块内存。

再看class F的内存布局应该是A::a, b::b, C::c, D::d, f, 也就是说整个readF执行下来, 其实F::D::d这个变量从来就没被操作过, 也就不可能赋值, 由于栈中的随机数, 所以F::D::d这个变量也就变得随机了, 由于object::C::c所占的内存总是会被readC和readD同时操作。因此看上去数据也不那么的有迹可寻。


当第一眼看到这种设计时, 虽然觉得不妥, 但是我并没有找到一种可以不通过hack内存来达到最大代码复用的方式。

在下班回来的路上, 我总觉得应该可以不通过hack内存来达到同样的目的, 终于在快到住的地方时被我想到的了。 其实很简单, 之所以想不到一方面是我不常用C++, 另一方面大概是之前看了这段代码, 一时间先入为主, 思维没有缓过劲来。

其实只需要按照C++最常规的dynamic_cast就可以完成代码的最大复用。

代码接口大概实现如下:


void readA(class A *a)
{
//read a members
}

....

void readD(class F *a)
{
readA(a);
readB(a);
//read D members
}

由于是dynamic_cast, 因此编译器可以帮我们自动去计算每个成员的偏移量, 避免了手动hack可能出现的各种错误。

btw,使用dynamic_cast的方式仅仅能依靠编译器发现这种hack内存时容易出现的bug。 在碰到类似class F这种使用多重继承机制的类时,编译器仅仅会报语法错误,并不能对其父类的readX函数进行复用。

又是权限问题

上周五发布了Beta版之后, 老板觉得这次加的功能挺多就试用了一下, 结果瞬间就崩了, 上去一顿猛批啊。 请他们试用了很久才发现又是因为权限问题。

在%ProgramFiles%下普通用户只有读取和执行的权限, 由于历史原因, 我们Client有一部分DLL是从其他地方copy到client.exe的当前目录下来动态加载的, 而这一版本恰好将管理员权限去掉了(因为Win7及以上版本在管理员权限不能访问网络共享路径), 两个巧合就碰撞到一起导致了这个bug的产生, 当然其实有些代码不是很规范, 不然应该只是加载某个DLL失败而已。

将所有需要动态释放的文件放到了一个与用户相关的目录, 使用DLL的绝对路径去LoadLibrary, 一开始没有任何问题, 直到完全卸载之后安装时就开始发现有些DLL加载不成功, 明明路径存在, LoadLibrary就是会fail。 在MSDN上找到这篇文章之后才找到问题所在, 使用绝对路径去LoadLibrary时, 被使用绝对路径去LoadLibrary的DLL如果静态依赖于另一个DLL, 那么这个被依赖的DLL就会被按照一定的目录顺序搜索, 不幸的是我们释放的DLL所在的路径恰好不在Windows的搜索中径范围之内。 使用MSDN推荐LoadLibrary(path, NULL, LOAD_WITH_ALTERED_SEARCH_PATH)将使用绝对路径加载的DLL所在的目录加入搜索范围问题即可解决。

又一次踩到权限的坑, 以后写代码一定要注意如非有必要尽量不使用root权限, 这样就不会碰到这种中途取消管理员权限之后产生的各种权限及附带引发的其他问题。

权限问题引发的bug

大清早还在睡着, 被电话叫出来说软件出问题了T_T, 说软件不能解析某个文件.

软件挑文件这毛病还从没遇到过, 先把管理员权限, 文件路径是否存在等问题全部确认后没有发现异常.

万般无奈情况下, 荒谬的猜测代码打开文件时要求了写权限(如:fopen(file_path, “rb+”)), 而这个文件恰好被设置了只读属性, 会导致打开失败, 打开代码一看果然如此, 使用CreateFile, 但是却带有GENERIC_WRITE权限的要求.


发一下牢骚 🙂

这其实是一个典型的权限问题, 在此之前曾经碰到过, 在Win7以上系统下看不到网络驱动器, 后来发现在Win7以上Admin权限是不显示网络驱动器的, 这也是Windows自身提供的一种保护机制.

因此在对于与权限有关的代码上要格外小心, 如果打开一个文件只需要读, 那么你就以只读打开就好了, 不必用读写或者以不存在就创建方式来打开. 对于只读数据场合来讲, 在你要了写权限之后就有一定风险去窜改, 这是一件极度危险的事.

说到权限就再扯两句.
目前软件的文件浏览是client通过socket远程查询server那台电脑上的所有文件, 然后以自定义的文件浏览框来显示, vincnet曾指出在以windows下的domain的组网方式中, 有些domain访问是要输入特别的帐号与密码的, 以这种C/S的方式去加载文件必然不能解决domain的帐号密码问题, 事实上, 如一些大的生产厂商正是使用此种方式将烧录文件放在domain服务器上, OP人员禁止私自将烧录文件copy到本机电脑的, 如果需要烧录某文件, 必须使用网络驱动器直接加载. 目前他们还是手动copy到本地再烧录的, 这势必会成为另一个大坑.

其实也并非没有解决方案, 但是对于目前的软件结构来讲, 要变的东西太多, 就只能先使用鸵鸟算法了 ^_^!

一个类型提升的bug

今天碰到一个由于类型提升的bug, 即使找到bug后, 仍花了好大功夫才找到解释, 感觉此坑比较隐蔽, 在此小记一下.

有类似下面一段代码:

unsigned char a;
unsigned char b;

a = -1;
b = 0;
if (a == b - 1)
printf("equal");
else
printf("not equal");

理论上当a为-1, b为0时应该打印出equal. 可事实恰恰相反, 打印出来的是not equal.

C语言中有以下类型提升规则:
2. 一般常数为int型
1. 二元操作符如果两个操作数具有不同的类型, 那么将较低的类型提升为较高的类型, 运算的结果为较高的类型
2. 将char与short类型的操作数转换为int类型

a,b被提升为int.
因为a是无符号型char, 因此a不会被进行符号扩展, 因此会被提升为0x000000ff
因为b是无符号型char, 因此a不会被进行符号扩展, 因此会被提升为0x00000000

if (a == b - 1)
//等价于
if (0x000000ff == 0xffffffff)

因此在有可能出现类型的地方一定要格外小心.

多线程调DLL

最近写代码一不小心又着了多线程的道, 背景如下:
前不久写了这样一个DLL:

const wchar_t *a = L"xxxx";
const wchar_t *b = L"xxxx";

int do_something_a(struct axx *param_a)
{
...
}
int do_something_b(struct bxx *param_b)
{
...
}


在do_something_a与do_something_b中分别用到了字符串a, b.本来这样相安无事, 可是很多地方会用到这个DLL的代码, 但是字符串a, b并不一样, 而字符中a, b可以根据param_a, param_b中的信息来生成, 本着代码正交性的原则, 将DLL重构如下:

wchar_t a[..];
wchar_t b[..];

int do_something_a(struct axx *param_a)
{
gen_a(param_a, a);
...
}
int do_something_b(struct bxx *param_b)
{
gen_b(param_b, b);
...
}

这样咋一看是没什么问题, 代码简洁了, 程序完美了. 可是我忽略了两个问题, 一个进程中不管调LoadLibrary多少次, DLL只会被加载一次. 而我这个DLL是会在多个线程同时加载使用的.
这样一来, 问题就来了, 由于全局数组a与b的存在, 所有的函数都不是线程安全的, 在低并发量的线程中冲突并不严重, 所以问题很难发现, 但是在高并发的线程中两个函数就会大量执行失败.

找到问题了后将代码重构如下:

int do_something_a(struct axx *param_a)
{
wchar_t a[..];
gen_a(param_a, a);
...
}
int do_something_b(struct bxx *param_b)
{
wchar_t b[..];
gen_b(param_b, b);
...
}

这次bug再次给我提了醒, 多线程代码要处处小心, 一不小心就会掉坑里.