Calling Convention

之前并没有很在乎这方面的事情,直到最近需要在某个bcb编译的程序中集成steam sdk。

故事是这样的,steam sdk提供的接口是c++代码,官方号称支持visual studio和gcc两种编译器。但我不想先做一层包装转换成c的接口再使用,所以打算强行在bcb中链接这些lib。

现在问题来了,由于c++的abi很混乱,微软又喜欢玩花活,一旦接口是c++的格式(class, virtual table)就很微妙。这就牵扯到了calling convention的事情,也就是函数调用约定。这件事情本来很简单,在c的时代只要解决两个问题就好:1、参数个数是不是固定的。2、参数是如何被放到堆栈中的。第二个问题无关痛痒,所以c规定参数一律是从右到左压入栈中,这样用的时候就可以从左到右依次弹出。

所以calling convention只和第一个问题有关,这样就诞生了两个关键字:

__cdecl:支持不固定个数的参数,调用方caller负责清理堆栈。好处是printf之类的东西可以实现,坏处是函数内部不能对堆栈玩花活,因为管理权在外面。

__stdcall:参数个数固定,被调用方callee负责清理堆栈。好处就是函数结尾直接用ret xx解决所有问题,坏处是参数个数固定,因为编译期就得决定ret之后的字节数。顺道说,COM中所有调用都是__stdcall。

目前为止一切还是有序的,直到link的时候,出现了name mangling的问题。M$和Borland使用了截然相反的方式来修饰函数名,具体可以参考《Using Visual C++ DLLs with C++Builder》中的Table A。还好双方都提供了解决方法,任选一种就可以生成合适的符号名,可以愉快的link。

即使link没有问题,问题也还远未解决,一旦开始使用c++的接口,run-time的问题就浮现出来。这就要提到M$的发明:

__thiscall:用于non-static member function,会把this放入ecx,再调用函数。好处就是this指针非常容易找到,调试方便。坏处就是……这是M$的发明,而非标准。还好时至今日很多编译器都支持了__thiscall,巨硬不再孤家寡人。

实际上bcb对this的解决方法非常简单,就是把它当作第一个函数的参数,照样传递进去即可。调试时候也不见得有多麻烦,而且如今编译器对代码的优化非常风骚,ecx在入口可能还有用,进入函数体之后几乎就完全无用了。

__fastcall:时代的眼泪,从名字看很拉风,但定义特别复杂。具体就是说把前几个较小的参数从寄存器里传入,而不是压入堆栈中。这样调用双方省去了很多进出栈的操作,故名fast。时至今日,这些开销似乎没什么人在乎了。

其他的调用约定我没怎么看就不说了,手头的情况只和后两种有关。这个bcb的程序诞生于近20年前,作者大概觉得函数调用开销是一件很重要的事情,所以默认全部函数都采用__fastcall。而steam sdk的c++接口全部用默认的__thiscall,运行的时候caller把参数往寄存器一丢了事,而callee还苦苦在ecx和堆栈中寻找,代码就跑飞了。

解决方案非常悲剧,对于每个class的每个non-static member function,都提供一个静态函数作为wrapper。内部帮他们把参数位置转换一下,然后再调用sdk中的实际代码。

用macro封装的效果如下所示(1个参数)

#define STEAM_API_CALL_WRAPPER1(ret, type, interface, index, var1)	\
ret STEAM_API_CALL type##_##interface(type * thisptr, var1) {	   	\
	asm { 						\
		MOV eax, [ebp + 4 * 3];	\
		push eax;				\
		MOV ecx, thisptr;		\
		MOV edx, [ecx];			\
		CALL [edx + 4 * index];	\
	} 							\
}
//

使用之前必须逐个定义,还得注意虚函数在虚表中的位置。不幸中的万幸是bcb和vs的虚表排列都是一样的。万幸中的不幸是,steam sdk中居然还有头文件和编译好的lib中虚表里函数顺序不一致的情况。大概是某个程序员手贱没事就调整函数的位置,又不肯重新编译sdk吧……

STEAM_API_CALL_WRAPPER1(bool, ISteamUserStats, SetAchievement, 7, const char *pchName);

经过这么多折腾之后,终于成功在bcb中链接运行了steam sdk。接下来发现还有callback的问题,steam要调用从bcb程序里传入的函数指针!解决方法和上面类似,callback函数也需要有一层wrapper……

只要对所有使用到的接口做上述处理,最终一切都和谐美好的运行起来,完全没有使用额外的dll,直接在bcb中使用了steam_api.dll。我研究了一下网上已有的实现,都是采用包装一个wrapper dll的方式。我这边的方法相对安全一些,否则wrapper可以被轻松的替换掉。当然咯,steam有提供额外的drm措施,简单换掉wrapper并不能移除steam绑定就是了。所以如果不是像我这样有强迫症的话,写个wrapper dll是最简单直接的方式。

结论是如果以后需要写什么对外发布的sdk,还是以c接口的形式提供最具有兼容性。

为神马不要在代码里写中文(二)

在很多个明天之后,我想起来要更新blog了。上次话说到一半,这次回来把结果补上,说的是字符串”a好人”在string literal中的故事。这个字符串有1个洋文字母和2个中文,测试的环境是win7中文版,非unicode的代码页设置的是中文简体,应该是gbk的编码。为了让结果更加整齐一些,我先把出现的结果列一下。win7 x64是little endian的,我直接打印的字符串各个字节,所以,对应的字符编码就自己调顺序吧:

// (1) "a好人"用UTF-8编码为:

61 | e5 a5 bd | e4 ba ba | 00

//(2) "a好人"用GBK编码为:

61 | ba c3 | c8 cb | 00

//(3) "a好人"用UTF-16 LE编码为:

61 00 | 7d 59 | ba 4e | 00 00 

我用’|’分割了一下结果,实际打印结果里是木有那个’|’的。不过还没完,因为编译器不一定能识别出正确的编码,所以会出现一些诡异的结果。编码(1)所列的utf-8字符如果强制被当做gbk来理解,对应的字符串是“a濂戒汉”,也就是1个英文3个汉字。以此字符串为基准我也列一些结果,待会儿填到表格中。

//(4) "a濂戒汉"用GBK编码和(1)结果相同:

61 | e5 a5 bd | e4 ba ba | 00

//(5) "a濂戒汉"用UTF-16 LE编码结果为:

61 00 | c2 6f | 12 62 | 49 6c  | 00 00

再次说明,“a濂戒汉”是utf-8的“a好人”被错误的以GBK编码方式解释而得到的错误字串,本身并没有写在源码中。在我测试的时候,utf-8的源代码文件中字符串就是以(1)那种编码储存的,gbk的源代码文件中字符串就是以(2)那种编码储存的。

下面贴一下结果,输入的源代码文件以gbk,utf-8,和utf-8 with BOM(byte order mark)三种格式储存,分别在不同的编译器下运行上一篇中提到的代码

源文件编码 \ 编译器 gcc4 vc6 vc2003.net vc2005 vc2008 vc2010
UTF-8(char) (1) (1) (1) (1) (1) (1)
GBK(char) (2)* (2) (2) (2) (2) (2)
UTF-8 BOM(char) N/A N/A (1) (2) (2) (2)
UTF-8(wchar_t) (3) (5) (5) (5) (5) (5)
GBK(wchar_t) (3)* (3) (3) (3) (3) (3)
UTF-8 BOM(wchar_t) N/A N/A (3) (3) (3) (3)

*gcc4使用GBK编码的源文件时,加了编译参数-finput-charset=gbk。

这个表格给出的结果很有趣。首先说说char string literal,几乎所有的编译器都得到了和源文件编码一致的结果……但这里面隐含着一个巧合,那就是对utf-8的处理。对于gcc来说,utf-8是默认的编码格式,但对于微软的编译器来说,当前codepage,在我这里也就是gbk,才是默认的编码格式。所以,UTF-8(char)的结果在所有编译器中都一致不过是巧合而已,只要看看下一行UTF-8 BOM(char)的结果就明白了。

vc的编译器据说是自动识别文件编码的,但对于90%都是英文字符的源代码,utf-8和gbk几乎无法自动区分开来,所以加了BOM在文件头才可以让vc识别出真正的编码。不过加了BOM的源代码gcc是不认识的,而vc自己也直到vc2003才开始支持。加上BOM之后,vc才可以真正理解utf-8格式的源代码,这时vc2003和gcc依旧保持一致,仍把char string literal直接编译成utf-8,但是!!但是!!坑爹的事情从vc2005开始出现,此版本之后vc会强制对char string literal进行转码,它会强制转换成当前的codepage,也就是gbk!

可以这么理解,对于char string literal,自vc2005开始,无论源文件是什么格式,它统统会转码到当前的codepage。而gcc,vc6和vc2003则是和源码文件中的编码保持一致——虽然实际上vc2003和vc6并没有真的理解不带BOM的utf-8文件,它们其实是当gbk的编码来理解的,往后看wchar_t的转码结果就能明白。

上面啰嗦了半天,结论就是,假如你在程序中写了中文,例如这样:

const char* p = "五道杠"; 

vc2005之后编译的结果将和当前codepage挂钩……而且将和gcc或vc2003的编译结果不一致……想搞跨平台?还是洗洗睡吧。

再来看wchar_t string literal,如果理解了char身上发生的事情,对照来看表格中wchar_t的结果倒是相当一致,大家都对源文件中的字串进行了转码(注意我没有提供任何UTF16格式的源文件),最终结果都是UTF16-LE编码。遗憾的是,在没有BOM的情况下,vc的编译器不能正确识别utf-8,所以它把“a好人”当成了“a濂戒汉”,由这4个字转换成UTF16-LE之后就变出了结果(5)

说到这里,可以看出wchar_t string literal也是个坑,更不要提ndk中的gcc是以4字节为1个wchar_t的长度,或许是按UTF32来进行编码的?以后有机会再试验吧。

最后总结一下,如果有潜在的跨平台需求,那是无论如何不能在代码中写ASCII以外的字符的。除非显式的使用0x1234, “\u1234″这种方式。另外,使用平台无关的字符串处理函数库代替wchar_t系的标准库函数也很有必要,例如ICU神马的,星际2里面就用的是它。对于vc这一系的编译器,其string literal的编译结果不仅和编译器版本相关,而且和编译器运行环境的codepage相关,着实是在给广大程序员添堵……

好长~以上= =b

为神马不要在代码里写中文(一)

c++中的字符串是个很麻烦的东西,有了宽字符之后变得更加麻烦,据说c++0x要引入raw string,就是类似这种

const char* str = R"噼里

回个车

啪啦";

的奔放写法,所以未来估计会更加的乱。最近看了一些关于字符串编码的东西,才明白双引号之间的这些玩意儿还是有很多说道的,接下来详细记录一下,要是说错了,那就日后发现再改= =b

双引号搞出来的这种字符串称为string literal,相当于一个const char*或者const wchar_t*。vc的编译器(估计大部分都有)有个优化,叫string pool,是说编译的时候所有相同的字符串会被合并,只保留一份在内存中,因此相同的string literal会返回相同的指针。

但是只要有字符就会牵扯到字符编码,因为程序只存取byte,写在引号之间的字符对应哪些byte呢?另外,代码文件本身就是一个文本文件,也存在编码的问题,这二者之间的关系又是如何呢?请不要走开,我们广告之后再进行讨论……

跨平台移植刻不容缓……程序中惊现神秘字串……究竟是程序员脑残还是编译器脑残……答案即将揭晓,请看走进科学之《不要在代码中写中文》……

嗯嗯,广告完毕,答案是~~~~~~这得看用的什么编译器了╮(╯_╰)╭

我写了段程序,用于打印string literal的各个byte:

#include <iostream>
using namespace std;

void outbyte(const void* buf, int len)
{
	unsigned char* p = (unsigned char*)buf;
	cout<<"Len:"<<len<<endl;
	while(len--)
	{
		cout<<hex<<(int)(*p++)<<" ";
	}
	cout<<endl;
}

int main(int argc, char** argv)
{
	const char* p = "a好人";
	outbyte(p, sizeof("a好人"));

	const wchar_t* pw = L"a好人";
	outbyte(pw, sizeof(L"a好人"));

	return 0;
}

代码分别保存成不同的编码格式,有utf-8, 以BOM(byte order mark)开头的utf-8,还有gbk。然后用gcc4,vc6,vc2003,vc2005以及vc2008编译并运行,得到了很有趣(蛋疼?)的结果。

嗯嗯,结果和分析就明天再贴了= =b

补丁也有补出问题的时候

上一篇提到的member-function pointer的问题,后来发现并不是因为不同的编译环境造成指针大小不一样,而是因为不同的环境下结构体的对齐规则出了问题。

出问题的是vc6.0 sp5,在这个环境下,如果定义unknow type的member-function pointer,例如这样:

class Unkonw; //forward declaration

typedef void(Unkonw::* MFP)(int param);

struct Callback {

char* szName;

MFP fp; }

即使编译选项里面没有设定其他的对其规则,使用了默认的8字节对齐,这个sizeof(Callback)也是32,而不是正常的24。这个问题在原始的vc6.0里面并没有出现,打了sp5补丁才会出现。如果继续升级到sp6就修复这个问题了。所以说,真正邪恶的不是member-function pointer,而是出错的编译器才对-_-b

查问题的时候,有种很简单的输出sizeof结果的方法,可以做到编译期查看对象大小,方法是:

template<int s> class TestTSize; 

然后在需要的地方写TestSize<sizeof(xxx)>()即可在编译出错的信息里面看到求值以后的sizeof。这个技巧貌似在《Modern C++ Design》里面提到过。嗯嗯,很有用的说。

邪恶的member-function pointer

谁都用过指针。

更别说我和你了。

但是当member-function pointer出现的时候,

你还是要跪倒!

嗯……今天耗了些精力d掉一个老bug。症状是这样的,从某人处拷贝来的static lib链接到应用程序之后,lib里面的函数尝试执行应用程序传过去的callback,结果没反应。

传入的callback是一个包含了某member-function pointer(MFP)的结构体,大致定义如下:

typedef void (Object::*FunctionPtr)(void);

struct Callback {
char * szName;
FunctionPtr fp;
}

库函数大致为这个样子:

void foo(Callback* ptr, Object* pObj) {

if(pObj && ptr->fp)
(pObj->*(ptr->fp))(); //怎么这么难看,我写对了伐……

}

开始以为是什么参数错误,后来惊奇的发现在本地重新编译一次static lib,再链接就一切正常僚。既然这样肯定是编译出来的asm有问题,略去痛苦的过程,直接进入主题,一路跟过去发现了不同。在错误的lib中,ptr->fp的地址是ptr+0x8h,而正确的lib里面是ptr+0x10h。于是去看了一下我本地编译出来的sizeof(Callback),猜猜是多少?

答案是32。

为啥这么大啊,于是又求了下sizeof(fp)。猜猜看这次又是多少?

答案是16。

本来以为fp至多再存个虚表,更何况我这个是non-virtual的,不要虚表,结果没想到搞出这么大一坨。这样看来,因为要对齐所以整个Callback结构体被搞成了32字节,这个char*可真是浪费呢。由此推出错误的lib里面是把fp当做8byte大小来储存的,所以ptr->fp就是ptr+0x8h。(好吧,这一段的分析在事后证明是错的,fp总是16byte,出错是因为错误的对齐规则,前者错误的使用了#pragma pack(16)的规则,邪恶的vc6.0 sp5……)

比较巧合的是,由于传入的Callback*来自一个储存在mem pool里的数据,而这个mem pool是由自己的内存分配器来管理的,一开始会把pool里的数据全部写0,所以错误的指针恰好指向了一段为0的内存,阴差阳错的通过了if判断,所以没有报access violation。

最重要的是为啥会有这么大的fp?放狗查到一篇文章,《Member Function Pointers and the Fastest Possible C++ Delegates》,原来这么复杂。其实我以前还用过这个fastdelegate,但是完全没注意到文章中有个描述不同编译器下的member-function pointer大小的表格。真是热闹啊,从4到20无奇不有……回头看看我的环境,应该是由于MSVC的编译选项不同导致了大小不同。具体是被当做那种情况处理了还得再仔细研究下……

裸用member-function pointer真是罪孽啊,还是封一层比较好。比如boost::bind那种东西……

嗯,总之是爽了。

记录一个FPU的猥琐Bug

貌似n多人遇到过,知道问题的原因之后用合适的关键字可以搜出很多帖子。核心问题归结为:float单精度浮点数有效位数不足,而FPU控制寄存器CTRL的bit8和bit9被置为0时,使得FPU被强制工作在单精度(24bit)模式,导致结果出错。

起因是一个朴素的string转浮点数函数,由于输入数据的范围被设定在0~1,000,000且包含两位小数,所以最初的版本使用float储存结果就会导致精度不足。发现问题之后,第一反应就是采用double来储存,理论上不会出错了。结果神奇的事情发生了,改成double之后精度还是不足。

接下来省略掉各种无用处的猜想,再去掉其他的逻辑,这个函数其实就这么一行:

double foo( int input ) { return (double)input / 100; }

简化的症状就是,我调用foo(100,000,000)期待返回1,000,000.01,实际返回的是1,000,000.00

当我尝试在程序最开始调用这个函数,例如放到main的最前面,结果是正确的。如果初始化完其他模块之后,结果就不对了。这样重现n次之后,脑子顿时就不转了,居然存在同一个函数同样的输入但输出不同!我确定这个函数木有inline,就算有啥优化,跑起来的汇编指令也完全一样的说。所以开始考虑是不是thread context有什么不同导致……其实这时候如果对FPU的工作机制比较熟,肯定马上就可以想到。可惜我早就把这种东西还给课本了,只好傻乎乎的肉眼看寄存器有什么问题。

最初想复杂了,脑子一抽想到了以前看过云风一篇帖子,模糊记得是什么东西导致浮点运算出错。搜出来一看,原来人家说的是编译器的问题,比我这个严重的多。不过因为有这么个模糊的印象,又通读了一遍这个帖子,接下来就放在研究FPU那坨诡异的寄存器上面了。

对比发现那条浮点除法指令FDIVR调用过后,结果正确的那次STAT寄存器有所变化,bit8置为了1。google之了解到bit8是如果为1,一切正常,如果为0,代表运算精度不足,FPU对结果做了roundup,大概就是这个意思吧。看来确实是精度问题,但double肯定是完全能存下我要的结果的说……疑问没有消除,继续肉眼看半天,又发现二者的CTRL寄存器也有所不同:正确的那次是0x27F,错误的那次是0x07F。马上google之,果然查到CTRL的bit8和bit9是用来控制运算精度的,(CTRL&0x300)之后,默认状态下应该为0x200,表示双精度(53bit),如果是0x000则表示单精度(24bit)。

好吧,如此看来,第一次调用FPU的时候,CTRL还是用双精度,第二次就单精度了,怪不得出错。问题是这个东东怎么会改变呢?继续google,换了几次关键字终于找到,居然是D3D的CreateDevice干的。DX的文档《Top Issues for Windows Titles》一节中有说,如果没有设置D3DCREATE_FPU_PRESERVE,则FPU被强制设成单精度。更加诡异的是这个东西的影响是per-thread,其他的thread如果没有碰过FPU的CTRL,那么还是默认的精度。为何D3D要做这种处理?彰显它的浮点运算能力?我不是很理解。再次回到程序中验证,的确是图像模块的Init被调用之后,CTRL就奇迹般的从0x27F变成了0x07F了。

解决方案就不说 了,总之问题原因探究到此为止。果然,就如同江湖上传闻的那样,使用浮点数存在各种诡异陷阱。这次悲剧的debug之后,最令人欣慰的一点是让我对float这个该死的单精度浮点数更加敏感……虽说丫是32bit的,其实也就7位有效数字,唉……