一次惨绝人寰的编译(下)

接着上一篇,来说说这次在 Linux 上编译时踩的各种坑。

因为之前已经在 Windows 和 Mac 上搞过一轮,加上有 AI 老师傅指点,我以为这次只是跑两个脚本那么简单。没想到一开始就翻了车。

首先是确定使用哪个 Linux 版本。按照 GPT 老师的建议,最好用 Electron 1.8.0 发布时的官方环境 —— Ubuntu 16.04。可惜我手头那台 Linux 机器早几年就升到了 20.04,不太想为了这个重装系统。而且 Electron 编译起来非常庞大,放 VM 里跑也不现实。

这时 GPT 老师突然灵光一闪:为什么不用 Docker?
我以前没怎么碰过 Docker,以为它只是 Linux 的进程隔离工具。结果发现连 Mac 上也能跑,于是就装了一下。很顺利地创建了一个 Ubuntu 16.04 的环境——这算是 VM 吗?还是进程隔离?搞不清楚,但总之新买的 M2 机器终于有了用武之地。

接下来运行 scripts/bootstrap.py,结果立刻报错:文件下载失败。查了一圈才知道,Electron 1.8.0 已经是上古版本,当年开发者把一些关键文件放在 Amazon S3 上。可惜这些 S3 容器现在都挂了。

换句话说,当年 Electron 相较于 nw.js 的一个“先进设计” —— 不需要自己编译 libchromiumcontent,而是用官方共享的预编译版本来加快流程 —— 现在彻底失效了。

没办法,我只能亲自编译这个庞然大物的 libchromiumcontent。先是在 Docker 里尝试拉取指定版本的代码,然后添加 Electron 的 patch,准备编译。可惜这只是我的美好想象,第一步拉取代码就失败了。

原因是 Google 的代码实在太大,他们还特地设计了一个工具来同时sync几十个仓库。Docker 给的内存又太小,每次clone到 80% 左右就崩了。

找到了问题,就把 Docker 的内存和硬盘配额都拉大。总算把所有代码clone下来了。好在 Google 还算厚道,老版本的代码都还在,接下来的编译过程还算顺利。

编译完成后,把生成的 lib 文件重命名为指定的 commit id,丢回 Electron 的目录,bootstrap 就能跳过失败的下载步骤了。

当然,问题还没完。很快又出现了依赖包下载失败的问题,这次是某个 sysroots 压缩包。问了 GPT,才知道 Linux 编译为了避免头文件版本不一致,引入了类似 Windows SDK 的概念,把一整套头文件、库文件打包起来统一管理。

好在这些文件还能在 GitHub 上搜到,我重新创建了一份,填上去就行了。

终于,bootstrap 跑通了,可以开始编译 Electron 本体了。

意外的是,编译和链接过程都异常顺利,顺利得让我有点害怕。果不其然,最后生成的 binary 文件不太对劲,有整整 2G —— 显然还没 strip。Electron 提供了一个 create-dist.py 脚本来处理发布包,但它默认还要执行一些文档生成的流程。只好小改一下脚本,去掉这些额外步骤,然后就得到了大小正常的发布包。

下一步是实机测试,我拿到 Ubuntu 20.04 环境中一测,果然又报错,提示缺少各种动态链接库。

于是我开始一一排查,把缺失的库都摘出来,统一放到发布目录的 lib 文件夹中,然后套一层启动脚本,把 LD_LIBRARY_PATH 指向自己的 lib 目录。终于,程序能正常运行了。

然后是重头戏:部署到 Steam Deck 上看看吧。果然还是报动态库缺失的错误。继续手动补,直到 Electron 成功打开窗口,游戏主界面也能正常显示。

成功了吗?我正想叉会儿腰,结果一进游戏场景直接报错……

这才发现,Steam Deck 的桌面模式和 Steam 启动环境之间还有些差异。折腾半天,终于意识到可能是编码的问题。错误日志提示是某个汉字命名的文件找不到。

我打印了两种环境下的变量,果然,在 Steam 的启动环境中,LC_ALLLANG 被设为了中文 GBK,而我整个程序用的都是 UTF-8。

知道原因就好办了:在启动脚本中强制把 LANGLC_ALL 设成 en_US.UTF-8

最后检查了一遍工作路径、存档目录这些,没再出啥问题。

Linux 版本,终于搞定!


总结:

  1. Docker 非常好用,但拉取大仓库还是得调高内存和硬盘。
  2. Linux 的编译环境必须固定头文件版本,打包发布时还得附带所需动态链接库。可以用 Steam Deck 进行真实环境测试,Steam 启动器自带部分常用库,只需补少数缺失项。
  3. 一定要完整测试整套环境。Linux 环境变化太多,Steam 与桌面环境也可能差异极大。

一次惨绝人寰的编译(上)

为了让一个老游戏兼容 Mac 和 Steam Deck,我前阵子动手做了个新版本的构建。游戏是用 JavaScript 写的,跑在 Electron 上。既然用了“跨平台”的技术栈,那它至少应该在这种时候派上点用场吧?

任务目标很明确:编译 Electron 1.8.0 的 macOS 和 Linux 版本。

一开始我以为构建 macOS 版本需要用 Apple Silicon 的设备,于是入手了一台最低配的 Mac Mini M2,8GB 内存、256GB SSD,能省就省。但上手之后才发现,macOS 提供了一个叫 Rosetta 的 Intel 兼容层,大多数老程序在 M2 上运行完全没问题。换句话说,我这台新电脑根本没派上用场。

于是我翻出了多年前的老设备——Mac Mini 2014,发现当年我确实在这台机器上编译过 Electron 1.8.0。稍微调整下配置,理论上是可以继续用的。信心满满地开工。

回顾旧文档、准备构建环境、跑脚本,一切按部就班。但第一轮构建很快就失败了,脚本一运行就报错。问题出在 Python:Electron v1.8.0当年依赖的是 Python 2.7,而我在 2023 年升级了 macOS,系统默认已经切换到了 Python 3。很多老脚本因此不再兼容。

于是退回 Python 2.7,再次运行 build.py,这次终于顺利进入构建流程。前期编译过程很快,毕竟历史中间文件还在。可没想到,等到了 link 阶段,机器突然开始变卡,几分钟后系统直接重启了。

我试了几次,都是在 link 阶段死机。最初还怀疑是老机器硬件出问题了。但联想到死机总发生在同一阶段,我猜可能是资源耗尽。打开任务管理器观察,果然在崩溃前有异常磁盘写入,某个系统进程(kernel什么的)在短时间内写了三十多个 G数据。但编译目录并没有生成相应的大文件,看起来不是 link 阶段输出的问题。

我接着观察内存使用情况,发现一个叫 dsymutil 的进程在这个阶段疯狂占用内存,远超物理内存限制,很可能触发了大量 swap 操作,最终导致系统不堪重负直接重启。macOS 在内存耗尽时没有进行优雅处理,反而选择硬重启,这倒是出乎意料。

dsymutil 是什么我并不清楚,于是请教了 ChatGPT。它告诉我,这个工具是 LLVM 工具链的一部分,用于生成调试符号(debug symbols)。考虑到 Electron 本身体积庞大,符号信息数量可能非常可观。显然,dsymutil 没有能力处理这种规模的符号,最终把系统拖垮。

既然问题出在生成调试信息,而 link 阶段其实并不依赖它,我想到一个简单粗暴但有效的办法:制作一个同名空脚本,替代系统中的 dsymutil,让它直接跳过这一步。

具体做法很简单:写一个只包含 exit 0 的 shell 脚本,命名为 dsymutil,赋予可执行权限,放进 $PATH 的前列。这一改动之后,构建流程终于顺利完成,link 阶段不再崩溃。

剩下的收尾工作就相对平淡:为了发布到 Steam,还需要处理 macOS 应用包中的 symbolic links,把它们转换成实际文件,避免打包时出现兼容性问题。另外也踩了 macOS 的路径处理一个坑:JavaScript 获取到的是真实路径而非 symbolic link,对路径逻辑略作调整后问题解决。

Mac版本处理完之后,我打算着手构建 Linux 版本,以支持 Steam Deck。听起来似乎没什么难度?嗯嗯,我得先写到这儿了,牵扯到electron这坨***肯定简单不了,Linux的事情且听下回分解吧。

Unity的美好与绝望

先说好的吧。可能目前最令我满意的就是IMGUI这部分API的定义,写Editor Plugin和In-game Debug Menu爽到飞起,很快就能出效果。

UI一向是很麻烦的东西,要保存状态,还要异步,还要同步View和Model的数据。很多框架动不动就是MVC,各种模式,用起来都痛苦的一比。这个IMGUI非常简单粗暴,反正也不能用来开发更复杂的玩意儿,设计和使用契合度非常高。

目前用到的其他特色的设计,比如Component Based,和别的商业引擎比起来大同小异。当然从价格和可用性的角度,Unity更胜一筹,以后有机会再多扯点。

主要还是吐槽,就说点看似美好其实比较坑的东西吧。

官方文档

全还是比较全的,这个我主要吐槽两点。

第一是组织方式,有些很重要的东西(比如对象的生命周期),或者说Unity和其他引擎比很不一样的东西(例如特殊目录,Editor/Resources/),隐藏在犄角旮旯等着你来发现。基本上都是在别处找到更直接的教程,然后拿到关键字,再到官方来看细节。

第二是官方真喜欢视频教程啊,不能搜还看得慢,动不动20分钟说点屁事。不能写点图文教程吗?比如IMGUI的各种用法,第三方写的比官方好用多了。

资源加载

三种方式,内在逻辑截然不同。

以Editor内操作为主,基于Reference的这套可能比较适合小游戏,或者非程序员开发的游戏。只要一切资源都是经过Editor操作和绑定的,那么后续过程也都是自动的。官方强力推荐这种,美术和策划友好的东西。

所有不能在Editor上绑定的数据,都需要Load-on-demand。一但牵扯到Load-on-demand,需要动态加载数据,事情就来了。

其实加载资源(Loading)是非常简单粗暴的操作。储存方面就是打个包,把数据和标识存起来。读取方面,只要给定一个标识(比如路径或者id),然后决定是同步加载还是异步即可。同步立刻返回结果,异步就给个handle或者允许callback。这样的底层设计几乎可以满足所有基本需求,其他花哨的玩意儿再上面继续包装即可。

但Unity的坑无处不在。首先是打包,Unity有两套截然不同的资源管理方式,分别对应Resources目录下的文件和Asset Bundle,姑且称为R和A。

R的打包是自动的,所有此目录下的文件都会在运行时打包,方便的很。这么好的东西,官方给出的Best Practice居然是“别用”。写文档的朋友,你以为自己很幽默吗?不能用的东西做出来吃屎呢……

A的打包是手动的,只不过没有现成UI可用,需要在Editor里调用API。但官方文档写的稀烂,打包和加载的过程同DLC的资料搅合在一起,还要牵扯到Cache和Server。乍一看仿佛要启动什么web service做host才能使用,打开还得从WWW下载。其实只要StreamingAssets目录一放,自己写脚本打包就好。

别忙,坑还没完。

R打包出来的资源是不带扩展名的,因为自动打包的时候就是根据扩展名来的,打好就全给删了。加载的时候遇到重名,请提供type。再出现重复我就不知道要怎么玩了……R的路径是相对于Resources的,这个倒还能理解。

A打包出来的资源,标识是写入的时候给定的数据,如果写的时候带了扩展,那就是有扩展名,目测不能重复。

所有“测试的时候想用R,发布的时候想用A”的同学,请自己写wrapper吧……真的好痛苦好痛苦。实际上更好的做法是,测试的时候直接读取Editor里面的AssetDatabase,并不需要使用Resources。官方说的“别用”就是字面意思,但您能提示下还有别的选择吗?直接介绍大家用AssetsBundle真的很不友好啊。

序列化和热更新 Hot-swapping

Unity一大特色就是各种自动,写个public直接就能在Editor上改了。而且改改c#,一回头自动把新的dll热更新上去,数据都还在。

听着很美好,实际情况是,改完代码出现大量Null Reference的异常……

https://gist.github.com/cobbpg/a74c8a5359554eb3daa5

这个小哥大概记录了一下,我踩了半天坑再补充一点:

1、首先是ISerializationCallbackReceiver,调用时机很诡异,Editor下如果你选中了带有这个接口的对象,Inspector会疯狂触发OnBeforeSerialize()。存起来很慢的东西就悲剧了。而OnAfterDeserialize()在Swap的时候会触发多次,目测Editor有创建多个实例,有些数据是没准备好的,还得自助检查。

2、在Editor里创建的各种引用,例如Component都会存下来。但如果动态AddComponent出来的玩意儿就悲剧了,在OnBeforeSerialize()之前就会被干掉,请自己重新动态创建。后来证明只要是Unity的Component,引用都会正确的保存下来,核心的混乱点还是Editor里有多个实例,初始化状态各不相同。

3、虽说需要动态创建,但特么的所有AddComponent这类方法,甚至GetTime都不允许在callback种调用。大概恢复数据的时候是多线程环境?所以只能自己搞个boolean做dirty check,然后在Update()里面实际恢复数据。实际上参考官方的例子,可以在OnEnable里面做恢复处理,比Update要省。

4、虽然官方号称大部分数据类型都可以自动保存,但“大部分”这种说法就意味着你需要被干很多次才能修复“少部分”的问题。比如标记为Serializable的数据,运行时可以为null,但Swap的时候会变成non-null状态。例如字符串变成了””,结构体变成了初始状态,最坑的是Array,里面的null元素会被填充。如果有逻辑依赖于null-check就悲剧了。

以后遇到新的再补充。

补充个新坑:打包的时候如果用BuildAssetBundleOptions.None这个option,看似人畜无害的默认选项居然是用LZMA压缩整个包,第一次开启会做解压缩操作,慢的一笔。最好的方式是使用ChunkBasedCompression,这个选项开包很快。港真我不知道None这个选项除了给网络下载几件衣服的DLC用以外还有什么应用场景,官方文档用这个名字(None是压缩的,咱还提供UncompressedAssetBundle选项哦~),并且用在代码范例真的好么?

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位有效数字,唉……