移动平台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为什么没有做这件事)。

C程序中让两个不同版本的库共存

今天有同学提出,如何在一个C程序中让两个不同版本的库共存。

首先想到的方案是,把其中一个版本的库函数全部重命名,比如把每一个函数名都加一个_v2的后缀。

人工替换到没什么,但是如果函数个数超过10个,就有点不拿人当人使了。

而使有工具去替换就会遇到一些棘手的问题,如何识别哪些是函数,哪些是系统函数(系统函数不需要添加后缀)等。

随后想到的另一个解决方案是C++的方案,为其中一个版本库中的所有文件添加命名空间。然后使用g++将这部分代码编译成.o文件,之后再使用gcc将这些.o文件与整个程序中的其他代码进行链接。

不过需要注意的是,g++编译后所有导出接口名都会变化得不那么直观。


第三种方案完全解决了以上两种方案的痛点。

考虑一个C语言的编译链接过程。

首先会将每个c文件编译成.o文件。

在编译过程中,导出函数并不会被实际分配地址,而是将函数名以F符号的方式存在.o文件的符号表中。

在本c文件调用的函数如果不存在于本文件,也会生成一个UND的符号存在.o文件的符号表中。

在链接过程中,链接器接收输入的.o文件,为每个.o文件中的符号分存地址,并生成可执行文件。

有了这几点事实,问题就变得的简单多了。

首先将其中一个版本的库中所有代码编译为.o文件。然后收集所有.o文件中的F符号。

由于整个库代码有内部依赖关系,收集到的F符号必然是所有.o文件中UND符号的超集。

换句话说,所有的F符号名就是我们要重命名的所有函数名。

这里我们需要借助objdump和objcopy工具。objdump -t 用于列表.o文件的符号表,objcopy用于重命名符号。

我随手写了一段用于过虑F符号的lua脚本

--rename.lua
local list = {}
local reg = "([^%s]+)%s+([^%s]+)%s+([^%s]+)"..
        "%s+([^%s]+)%s+([^%s]+)%s+([^%s]+)"
for l in io.stdin:lines() do
	local a,b,c,d,e,f = string.match(l, reg)
	if a and c == "F" then
		list[#list + 1] = " --redefine-sym "
		list[#list + 1] = string.format("%s=%s_v2", f, f)
	end
end
print("#/bin/sh")
print("objcopy " .. table.concat(list) .. " $1")

我们可以使用如下命令来收集所有.o文件的F符号, 并产生修改符号所用的脚本

find . -name '*.o' | xargs objdump -t | ./lua rename > rename.sh

现在我们只需要再执行一条命令就可以把所有函数名增加一个_v2的后缀.

find . -name '*.o' | xargs -n 1 sh ./rename.sh

至此,我们这个版本的库代码的所有函数名已经全部增加了_v2后缀。

这些被处理过的.o文件与我们将所有.c代码中函数名重命名之后编译出的.o文件完全一等价。


8月2号补充:

在实际使用中发现, 局部函数(static 函数)符号有可能会被gcc做修饰,将被修饰的符号重命名会给我们带来一些麻烦,而我们原本也不需要去处理局部函数。

因此对rename.lua做如下修改,过虑掉非全局符号:

--rename.lua
local list = {}
local reg = "([^%s]+)%s+([^%s]+)%s+([^%s]+)"..
        "%s+([^%s]+)%s+([^%s]+)%s+([^%s]+)"
for l in io.stdin:lines() do
	local a,b,c,d,e,f = string.match(l, reg)
	if a and c == "F" and b == "g" then
		list[#list + 1] = " --redefine-sym "
		list[#list + 1] = string.format("%s=%s_v2", f, f)
	end
end
print("#/bin/sh")
print("objcopy " .. table.concat(list) .. " $1")

为什么要有头文件

我在写C文件时,一般会首先确定这个模块需要哪些功能,然后在头文件中定义相应的接口函数。之后才是在C文件中实现,在实现过程中除非有遗漏的接口,不然是不会再切回头文件的,一般辅助函数我都是直接以static的方式定义在C文件中。

在写C++代码时,这些代码辅助类的函数,都必需要以private的方式在头文件中声明。这会导致在写代码时,需要频繁在h/cpp之间切换,极度令人不舒服。

因此每次在写C++代码时,都免不了在心里抱怨几句为什么不把private函数直接定义在cpp文件中,或者干脆像java一样不要头文件算了。

前两天把这事跟朋友抱怨了一下,结果竟然得到了一个反问为什么需要头文件,头文件的作用到底是什么?

有人说头文件是为了展现接口,以头文件和.a或.so发布时,别人只要看头文件就可以知道提供了什么接口,然而对于java这类没有头文件的语言来讲只要一个工具同样可以提取出来class文件中的public接口信息。因此我觉得还是需要从编译器角度来分析一下头文件的用途。

那么C语言的头文件到底起到什么作用呢?且看下面一段代码(ps.为了使这段代码在任何平台上效果都一样,使用了stdint.h中的可移植类型):

////////////compile: gcc -o a a.c b.c
////////////b.h
#ifndef _B_H
#define _B_H
#include <stdint.h>
struct test {
        uint8_t a1;
        uint8_t a2;
        uint8_t b1;
        uint8_t b2;
};
#endif
////////////b1.h
#ifndef	_B1_H
#define	_B1_H
#include <stdint.h>
struct test {
        uint16_t a;
        uint16_t b;
};
extern struct test T;
#endif
////////////b.c
#include "b.h"
struct test T = {.a1 = 1, .a2 = 2, .b1 = 3, .b2 = 4};
////////////a.c
#include &lt;stdio.h&gt;
#include "b1.h"
int main()
{
        printf("a:0x%x b:0x%x\n", T.a, T.b);
        return 0;
}

这段代码的运行结果很有意思,是’a:0x201 b:0x403’。

这段代码编译器不会报任何错误,甚至连警告也不会报。为什么会这样呢?这要从几个.c文件变成elf(linux)/exe(win)文件过程说起。

从c文件到可执行文件至少要经过两个阶段,即‘编译’和‘链接’。
‘编译’会将相应的C代码转换成相应的汇编,但保留符号名(如上述代码中的T)然后生成.o文件.
而‘链接’会收集所有的.o文件然后为每个符号分配地址,并将.o文件中的相应的符号换成相关地址并生成相应格式的可执行文件(ps.上面的流程并不严谨).

以a.c中的代码为例,在编译时T.a语句其实就已经转换为了与*((uint16_t*)((uint8_t *)&T+0))等效的汇编代码,相应的T.b的语句等效*((uint16_t*)((uint8_t *)&T+sizeof(uint16_t))).这一点其实可以通过gcc -S来反汇编证明。

那么头文件的功能就呼之欲出了,在‘编译期间’为了保证能生成正确的汇编代码,必须要头文件指明struct的字段分布,及此c文件中引用的符号在外部有提供(这就是声明的意义)。

当然头文件所带来的问题也正像上述代码中描述的一样,当给出一个错误的头文件时编译器并不会察觉,在没有源码的情况下,这种错误极难发现。

那么java是如何实现的呢?我尝试着写了两个类编译了一下。猜测,他应该是在编译时自动提取出本类的声明信息,然后放在.class文件中,当javac编译时用到某个类时就去找当前目录下打开‘类.class’文件,从其中提出取类的声明信息,从而达到与有头文件有相同的效果。

ps. 我怀疑.class的声明信息与其反射机制有密切关系,但是jvm的代码量有20多万行,找到其反射部分的实现还是比较麻烦的,天气这么冷还是先放一下:)

pps. 如果C语言也这么搞的话,似乎是行不通的,java中的类的概念,可以约定使用哪个类就从‘类名.class’文件中寻找,如果是C呢,找一个struct test的布局去test.o中找?那找一个函数helloworld应该去哪个文件中找呢?


在与Qwerty交流后发现,查找helloworld函数时可以根据此c文件import的模块来实现,那么似乎为C引用import机制也并不是不太可能。

简单思考了一下,在不改变现有编译流程的情况下,似乎可以为c文件引入一个轻量级import机制来代替头文件。

我们可以在编译器(如:gcc)之上包个壳,假设叫xcc,然后为.o为文件也加上一个壳叫.m。

.m文件其实包含了其源文件中的public的函数接口定义和一个完整的gcc编译出来的.o文件内容。

xcc的执行流程大概如下:

1. 使用xcc编译A.c文件时,xcc首先分析出A.c文件中的声明信息’T’及import的模块’M’。
2. 从M.m文件中提取出M.c文件中的声明信息T,并生成M.h文件,之后将a.c中的import ‘M’替换为#include “M.h”,并另存为到/tmp/A.c.tmp。
3. 调用相应的编译器如’gcc’编译/tmp/A.c.tmp生成A.o
4. 将A.c的声明信息T追加到A.o的最后(之所以追加到最后,是因为A.m可以被当作A.o直接传给ld, 这样我们就不用为ld再包一个壳了)

有一个特例,一个struct是否要导出,必须要等分析完所有的导出接口之后才能决定,如果在导出接口参数中有用到,那么我们就可以将其导出到头文件中。

如此,我们就有了一个兼容import的C语言。

C++默认构造函数

在C++中,如果不为某个struct/class实现一个构造函数,那么编译器就会自动为这个类添加一个默认构造函数,而这个默认构造函数什么也不干。

但是我却从来不知道,默认构造函数在不同的情况下,会出现不一样的效果(当然这是C++03之后的标准).

先看一段代码:

struct test {
int a;
int b;
};


void *operator new(size_t sz)
{
void *p = malloc(sz);
for (size_t i = 0; i < sz; i++)
((char *)p)[i] = 0x01;
return p;
}
int main()
{
struct test *t1 = new test;
struct test *t2 = new test();
printf("t1:%x-%x\n", t1->a, t1->b);
printf("t2:%x-%x\n", t2->a, t2->b);
return 0;
}

重载new操作符是为了把分配出来的内存弄脏。然后观察new test和new test()的区别。

结果出人意料,t1的值就是内存中被污染的值,然后t2的值却全部被清0,而造成这种现象的惟一的区别就是new之后类型是否带有括号。

这就是C++03版本的新增内容,当没有实现构造函数时,编译器为你自动生成的构造函数是有两种用途的。当你在使用new T()构造对象时,默认的构造函数会对各变量执行清0操作,其实应该就是memset为0. 而在使用new T来构造对象时,其默认构造函数是任何事也不做的。

总觉得这么做违背了语言的一致性的设计, 但是不管怎么说有了这个,在写struct定义时在一定的情况下就可以省掉默认构造函数了。

迭代器模式

在写C++代码时,首先接触的就是迭代器。甚至于设计模式都有一种模式叫迭代器模式。虽说网上到处都说迭代器用于隐藏数据结构的细节,但我却一直没有真正搞明白为什么需要迭代器去隐藏数据结构细节。

在写C++代码时,一般我每用一个数据结构都会去查一下,他大致是如何实现的(不然用起来不太放心:D)。

因此一般情况下我在c++下都是使用类似类似for(size_t i = 0; i < vector.size(); i++)的方式去遍历vector的每个元素。

直到最近的一次重构我才大概明白什么时候去使用迭代器模式。


首先大致说一下zproto的作用。

zproto是一个序列化/反序列化的工具,类似google protobuf,但是实现更简单,更轻量级。

在实现之初,我是希望zproto.c可以实现syntax和serialize/unserialize的核心功能,然后再为每种不同的语言写一组操作本语言数据结构的函数,即可通过zproto.c来实现bind功能。但我又不想做成callback接口。

由于zproto所有字段都是可选的,因此某一字段可能是不存在的,在encode和decode时,zproto.c必须知道上一次有效的字段是哪一个。

因此接口就变成了这个样子:

void zproto_encode_tag(struct zproto_buffer *zb, struct zproto_field *last,
struct zproto_field *field, int32_t count);
void zproto_encode(struct zproto_buffer *zb, struct zproto_field *last,
struct zproto_field *field, const char *data, int32_t sz);
struct zproto_field *zproto_decode_tag(struct zproto_buffer *zb, struct zproto_field *last,
struct zproto_record *proto, int32_t *sz);

从接口就可以看出,在写bind代码时,每次都需要维护上一个有效有字段,这其实从一定程度上暴漏了zproto.c作为core的实现细节而且加重了写bind代码的负担,这一度让我觉得很恶心。

几次想把last放入zproto_buffer字段中,但这样就需要在zproto_buffer中维护一个栈的结构,因为record(结构体)中有field(字段),field又可能是record类型。 这样一来就增加了实现复杂度,与我的初衷不符。因此也就迟迟没有动手。

直到最近在一次写C++的代码时,再一次用到迭代器(遍历map中的所值)时,突然觉得这个问题可以用迭代器来解决。

使用迭代器实现后的接口如下:

void zproto_encode_array(struct zproto_buffer *zb, struct zproto_field_iter *iter,
int32_t count);
void zproto_encode(struct zproto_buffer *zb, struct zproto_field_iter *iter,
const char *data, int32_t sz);
int zproto_decode_field(struct zproto_buffer *zb, struct zproto_record *proto,
struct zproto_field_iter *iter, int32_t *sz);
int zproto_decode(struct zproto_buffer *zb, struct zproto_field_iter *iter,
uint8_t **data, int32_t *sz);

明显可以看出,接口和内聚性都提高了很多。在写bind代码时再也不需要维护上一次有效的字段了,因为在encode时已经被记入iter了。

当然代价肯定了也是有的,就是多实现了一组迭代器的函数,不过我认为这代代价是值得的。

由于zproto已经完全屏蔽了细节,因此在写bind代码时可以只关心本语言的数据结构的存取,而不会由于不懂zproto的实现细节导致传入错误的last_field造成zproto.c工作异常,大大降低了心理的包袱。


可以结总出迭代器一般用于提供给其他模块遍历时才使用(其实从标准库就可以看出,只是我自己没领会到^_^!),迭代器数据结构也不一定只存储用于遍历的数据,只要方便达到目的也可以存一些冗余数据,比如zproto.c中的迭代器会存储上一次有效的字段。

现在再反思为什么以前写代码都不会用到迭代器模式其实都很清楚了。

在写C语言时,模块一般都是提供某种属性或某种行为的而不会提供遍历的特性,这种情况下也就没有必要去为此模块提供一个迭代器。

而在模块内部的数据结构, 其实也没有必要去实现一个迭代器,有时候直接操作成员去访问可以更直观更高效。

同样在C++开发时也并非说,实现了一个容器类数据结构就需要去为他实现一个迭代法器,如果此数据结构仅仅为优化特定模块而生。那么直接去访问有时后反而代码更少更简洁,也并不会造成什么坏的影响。

还是那句话,没有银弹,什么时候合适什么时候不合适还是要靠自己判断。

模板的高级用法

一直以来都是通过C用基于对象的设计方法来写代码。即使工作中使用C++, 也是尽可能少的使用超出C的一些特性。当然这并不是C++不好,而是C++实在太复杂了。以我的脑力来讲, 如果使用C++过多的特性, 很容易让我过于陷入语法特性之中, 而忽略了设计。因此, 对于C++的一些高级特性, 如模板等并没有深入研究过。

模板对我来讲, 仅限于知道可以实现泛型。至于怎么巧妙的利用泛型来实现其他特性, 从来没有深入研究过。最近工作中,碰到了一些看起来比较高端的模板用法,令人有一种耳目一新的感觉,因此就记录一下。


如果实现一个单纯的组播模块, 该模块提供的组播接口可能类似:

void multicast(func, arg1, arg2, arg3, ...);

在不用模板的情况下,假设每种参数有可能有m种类型,如果参数的个数为n个。 那么针对参数为n个的函数就需要手写m^n个重载板本。

使用模板推导功能,对于参数个数为n个的函数,就仅仅只需要实现一个实现即可。

示意代码大概如下:

template
int call(void (*func)(T1 p1, T2 p2), T1 p1, T2 p2)
{
func(p1, p2);
}
void func1(int a, float b)
{
printf("%d, %f\n", a, b);
}
void func2(double a, const char *b)
{
printf("%lf, %s\n", a, b);
}
int main()
{
call(func1, 3, 3.5f);
call(func2, 5.3, "hello");
return 0;
}

这样使用模板忽略掉了参数类型,仅仅针对参数个数进行重载, 会大大提高代码的编写效率。


对于C++来讲,其实RAII应该算是惯用手段了。

在不使用模板的情况下,如果我们使用RAII来管理指针,我们就需要为每一个类实现一个指针类。而如果使用模板,仅仅实现一个指针类就可以了。

比如:

template
class PTR {
public:
PTR(T a) {
ptr = a;
};
~PTR() {
if (ptr == NULL)
return;
delete ptr;
};

T get() {
return ptr;
};
private:
T ptr;
};

class test {
public:
test() {printf("test\n");}
~test() {printf("~test\n");}
void hello() {printf("%s\n", h);}
void set(const char *n) {h = n;}
private:
const char *h;
};

int main()
{
PTR p(new test);
test *t = p.get();
t->set("hello");
t->hello();

return 0;
}

其实上面的代码大致就是C++11提供的std::unique_ptr实现的功能了.


在网络通信过程中,定义数据结构总是一件很烦的事,使用纯C的数据结构便于传输,但是会将结构定义的很死, 而且数据种类有限。

如果使用vector/unordered_map并且相互嵌套时传输就会很麻烦。一般这时候就不得不采用protobuf的方式来进行传输。

但是有时候你要传输的数据结构仅仅就是vector/unordered_map等动态数据结构的一些嵌套, 这时候去使用protobuf就稍嫌重量了。

这时其实可以结合C++的模板推导及参数重载来实现一个简易的数据序列化库。

大概试了一下vector,用起来还是很方便的。

template void
serial(const T &a, std::string &res)
{
res.append((char *)&a, sizeof(a));
}

template size_t
unserial(T &a, const char *p)
{
a = *(T *)p;
return sizeof(T);
}

template void
serial(const std::vector &a, std::string &res)
{
size_t n = a.size();
res.append((char *)&n, sizeof(n));
for (size_t i = 0; i < n; i++) serial(a[i], res); } template size_t
unserial(std::vector &a, const char *p)
{
size_t pos;
size_t n = *(size_t *)p;
a.reserve(n);
pos = sizeof(size_t);
for (size_t i = 0; i < n; i++) { T tmp; pos += unserial(tmp, &p[pos]); a.push_back(tmp); } return pos; } int main() { std::vector src1 = {1, 2, 3, 4, 99, 100};
std::vector src2;
std::string dst;

serial(src1, dst);
unserial(src2, &dst[0]);

for (size_t i = 0; i < src2.size(); i++) printf("%d ", src2[i]); return 0; }


从上面三个模板的例子上看,基本上模板就是强类型语言用来在写代码时弱化类型的一个折中。如果有过动态语言编写经验就会明显感觉到,模板明显是为了有限的支持动态语言在编写时的一些优点。如果能够把握这一点,也许才能够更合理,也更巧妙的去运用模板。

c语言部分的开销测试

最近在写c代码时底气越来越弱,原因在于某些调用的开销,我心里并不是十分明确。写起代码来由于纠结也就变的畏畏缩缩。

今天终于抽时间测了一下,仅测试了最近常遇到的一些调用的开销。

测试环境如下:

CentOS release 6.7(Final)

CPU:Intel(R)Xeon(R)CPU E3-1230 V2 @ 3.30GHz

采用gcc(glibc)编译,未开任何优化。
在测试时,大部分操作cache均会命中,因此如果cache经常命中失效,还需要另外考虑。

测试结果如下:

可以看出for循环是最廉价的。

malloc是开销最大的,这还是在单线程的情况下,如果再多线程由于锁的开销malloc的代价将会更甚。

在栈上分配1M次内存的代价几乎等同于for了1M次,在栈上分配内存开销最低。

而copy了64M内存也才花费了5ms而已。

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时一定要再三考虑。

volatile关键字

今天快下班前听到他们正在讨论说某个bug可能是编译器优化导至的bug, 我当时正在忙其他事也就没有参与讨论,下班回来的路上想到了编译器优自然而然就想到了volatile关键字。

随手上百度搜了一下,得到的结论令我大吃一惊,说什么的都有,有说阻止编译器优化,还有说用于多线程并发,还有说影响cpu cache,总之说什么的都有。其他的我不敢说,但是至少编译器级别的优化还达不到cpu cache的层次,毕竟我搞过一段时间的无锁算法的,对cpu的cache原理有一定的了解。

至于真相到底是什么,也只能自力更生,验证一下了,虽然我平时尽量不用代码或尽量使用伪码说明问题,奈何这是一篇验证性的文章,相关的C代码和汇编肯定是少不了的。

我写了段代码分别测试了一下局部变量,局部指针变量,全局变量,静态全局变量分别是volatile和非volatile的情况,使用gcc -O2 -S a.c来编译以下C代码:

#include "stdlib.h"
#include "stdio.h"

int gg = 1;
static int xx = 1;

int test()
{
        gg = 0;
        xx = 0;
        return 0;
}

int main()
{
        //test the local variable
        int a = 0;
        a += 3;
        a += 4;
        printf("%d\n", a);

        //test the volatile local variable
        volatile int b = 0;
        b += 3;
        b += 4;
        printf("%d\n", b);

        //test the local memory pointer
        int *c = (int *)malloc(4);
        *c = 0;
        *c += 3;
        *c += 5;
        printf("%p, %d\n", c, *c);
        //test the variable local memory pointer
        volatile int *d = (int *)malloc(4);
        *d = 0;
        *d += 3;
        *d += 5;
        

        //test the global variable
        while (gg) {
                printf("helloworld\n");
        }

        //test the static global variable
        while (xx) {
                printf("helloworld\n");
        }

        return a;
}

产生的汇编代码如下:

.section __TEXT,__text,regular,pure_instructions
.globl _test
.align 4, 0x90
_test: ## @test
.cfi_startproc
## BB#0:
pushq %rbp
Ltmp2:
.cfi_def_cfa_offset 16
Ltmp3:
.cfi_offset %rbp, -16
movq %rsp, %rbp
Ltmp4:
.cfi_def_cfa_register %rbp
movl $0, _gg(%rip)
movb $1, _xx(%rip)
xorl %eax, %eax
popq %rbp
retq
.cfi_endproc

.globl _main
.align 4, 0x90
_main: ## @main
.cfi_startproc
## BB#0:
pushq %rbp
Ltmp8:
.cfi_def_cfa_offset 16
Ltmp9:
.cfi_offset %rbp, -16
movq %rsp, %rbp
Ltmp10:
.cfi_def_cfa_register %rbp
pushq %rbx
pushq %rax
Ltmp11:
.cfi_offset %rbx, -24
leaq L_.str(%rip), %rbx
movl $7, %esi
xorl %eax, %eax
movq %rbx, %rdi
callq _printf
movl $0, -12(%rbp)
addl $3, -12(%rbp)
addl $4, -12(%rbp)
movl -12(%rbp), %esi
xorl %eax, %eax
movq %rbx, %rdi
callq _printf
movl $4, %edi
callq _malloc
movq %rax, %rcx
movl $8, (%rcx)
leaq L_.str1(%rip), %rdi
movl $8, %edx
xorl %eax, %eax
movq %rcx, %rsi
callq _printf
movl $4, %edi
callq _malloc
movl $0, (%rax)
addl $3, (%rax)
addl $5, (%rax)
cmpl $0, _gg(%rip)
je LBB1_3
## BB#1:
leaq L_str3(%rip), %rbx
.align 4, 0x90
LBB1_2: ## %.lr.ph4
## =>This Inner Loop Header: Depth=1
movq %rbx, %rdi
callq _puts
cmpl $0, _gg(%rip)
jne LBB1_2
LBB1_3: ## %.preheader
movb _xx(%rip), %al
testb %al, %al
jne LBB1_6
## BB#4:
leaq L_str3(%rip), %rbx
.align 4, 0x90
LBB1_5: ## %.lr.ph
## =>This Inner Loop Header: Depth=1
movq %rbx, %rdi
callq _puts
movzbl _xx(%rip), %eax
cmpl $1, %eax
jne LBB1_5
LBB1_6: ## %._crit_edge
movl $7, %eax
addq $8, %rsp
popq %rbx
popq %rbp
retq
.cfi_endproc

.section __DATA,__data
.globl _gg ## @gg
.align 2
_gg:
.long 1 ## 0x1

.zerofill __DATA,__bss,_xx,1,0 ## @xx
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "%d\n"

L_.str1: ## @.str1
.asciz "%p, %d\n"

L_str3: ## @str3
.asciz "helloworld"

.subsections_via_symbols

对比局部变量a和volatile局部变量b可以看出:
a被优化成了一个寄存器%esi, 而关于a的操作被优化成一个常量赋值。
而b的所有操作均没有被优化,b的每次存取操作均是直接操作栈上内存。

对比局部指针变量c和volatile局部指针变量d:
向*c和*d的赋值的最后一步均为直接写入内存,但是对于c指针所指向内存的加法则被优化成了常量赋值,而对于d指针所指向内存的所有操作均没有被优化。

再看对于全局变量和静态全局变量的访问和修改,均是直接读写或修改内存,并不使用cpu的寄存器做优化。

那么使可以得出以下结论:
volatile关键字使得所有操作此变量的操作均不会被优化(如优化为寄存器或操作合并)
即使对于对内存和全局变量进行一些优化操作(如将连续的加合并为一个常量赋值),在最后一个操作时一定会将所得的值存入内存(即其效果一定看起来等效于原来的语句)。
也就是说除了不想编译器合并对某个变量的操作和不想编译器将某一局部变量优化成寄存器,我们几乎没有使用volatile的地方(这一准则同样适用于嵌入式领域)

现在开始反驳一些结论:
1. 多线程通过变量交互,需要使用volatile变量。
如果使用变量进行多线程通信, 那么这个变量必然是全局的,如上所述全局变量是会即时写入内存的,并不存在所谓的寄存器残留问题, 也就不需要使用volatile.

2. 在中断程序中需要修改变量时,此变量一定是volatile变量(嵌入式系统)
同结论1相似,如果中断程序可以访问此变量,而应用程序也可以访问些变量,则此变量至少一定是静态全局变量,同样不需要volatile。

3. 影响cpu cache。
除了使用cpu自带的无锁汇编指令或lock汇编指令,其他的汇编是不能够影响cpu cache的。关于这个问题是可以参考Intel参考手册的。

ps.虽然我一向不喜欢做大自然的搬运工,但还是做了一次。