首先指出,原版,也就是 HCA 的解码核心并不是我写的。原来的是一个解码命令行程序,将 HCA 解码为波形声音。我的工作是将其封装为一个动态库,期望“Write once, compile everywhere”。本文记录的是测试过程中填上的一些坑。
原作者附上了代码,并有免责声明:
■ 免責事項
このアプリケーションを利用した事によるいかなる損害も作者は一切の責任を負いません。自己の責任の上で使用して下さい。
这里特意指出。而且 kawashima 也使用同样的免责声明:
免责声明
本程序作者不对使用本程序所造成的一切损失负任何责任。请自己担责使用。
Disclaimer
The author of this application does not claim for any damage caused by its usage. Use at your own risk.
名字来源是纪念翻译事故中的大龄偶像川岛瑞树。
一、为什么要使用 P/Invoke
由于我的设计选择语言就是 C#,所以一开始我是希望用纯 C# 实现 HCA 解码的,也就是将 HCA 解码器移植到 C# 上。但是在实际操作中,由于 C 指针的灵活性与调试的难度,移植很麻烦——困难不是很困难,但是容易出差错。
在原程序中,大量使用的解码表。而且保存的是浮点数据,解码用的是作为整数保存的浮点值,直接一个 unsigned int *
转换为 float *
然后从浮点指针去访问就可以了:
void DecodeN() {
static unsigned int uintTable[] = { ... };
float *t = (float *)uintTable;
// ...
float a = t[index];
// ...
}
而在 C# 中,必须要写一个小函数,对每个类似 t[index]
这样的访问,将 uint
转换为 float
。这样,原来一个类似数组访问的语句,就被完全破坏了可读性,变成了一个函数调用:
static uint[] uintTable = new uint[] { ... };
void DecodeN() {
// t 的赋值就没有了,因为本身指向的是固定的数组
float a = Helper.UInt32ToSingleBits(uintTable[index]);
// ...
}
如果对 t
的使用更复杂,那么翻译出的 C# 代码可读性就更差了。翻译之后,原来码表读取就成了一个复杂的长调用。
调试的困难在于,循环量很大,而且两边数据无法同步对比。每个声道有几个解码缓冲区,对翻译后代码的校准当然要比较运行到同样的循环次数时缓冲区内容是否相同了。但是这很难,因为这是两种语言,两种内存模型,运行在两个环境下。至少以我目前的能力,还没法写一个自动校验器,在出现不同时自动命中两边的断点。最后我只好采用土办法,自顶向下测试,最后能定位到循环量级,中间的数据校验只好用“抽查”的方式,而且比较的还是浮点数!这蛋疼的经历有多少就想跳过多少。我现在如果进展顺利的话(这么说是因为即使有数据自己心里还是没底),卡在了 Decode5()
函数上,看上去输入的数据应该没问题,过程就……
然后,有同学应该知道 C++/CLI 吧!如果将 C++ 代码直接用 C++/CLI 做一个 wrapper,岂不是很方便?很不幸,我用 ildasm
观察了一下这样产生的程序集,里面嵌入了大量的 msvcrt
,也就是 VC 运行时的信息。毕竟,目前(我已知的,退一步说,我有的)C++/CLI 编译环境是在 Windows 下,用 cl
,使用微软的头文件和库文件来编译,出现以上的结果是必然。我的目标是“Write once, compile everywhere”,况且目标平台并不是 Windows,所以并不希望出现这么强的依赖。那么用 Cygwin 或者 MinGW 来行不行呢?我还没试过,而且看起来也很麻烦的样子,没精力去试。至于 MonoDevelop,我没有 Linux 环境(而且磁盘也……)去编译、测试,所以就没走 C++/CLI 这条路。
所以摆在眼前的最后的方法,就是 P/Invoke。
二、编译环境
既然是“Write once, compile everywhere”,那么就应该选择一个交集。所以,再见啦,VC 环境。我选择的是 CLion+Cygwin+CMake(3C?),CLion 有 CMake 的集成支持,这个组合很好用,就我的目标而言。
这个环境选择对后面的过程是有影响的,所以列在这里。
三、适配 Linux 的符号导出
导出是使用 StdCall 调用约定的:
// ...
#define DLL_PUBLIC // ...
// ...
#define EXTERN_C extern "C"
#define STDCALL __stdcall
#define KS_API EXTERN_C DLL_PUBLIC STDCALL
较新版本的 GCC 似乎是支持 __declspec(dllexport)
这个语法的,不过保险起见还是留了一个 __attribute__((visibility("default")))
的回退。我经常会忘记三项声明的顺序,虽然也只有6种可能顺序,不过既然这次结果是正确的,就用着吧。
一般来说,作为公共库,导出函数在符号表中应该是 C 样式的(无 C++ 的参数后缀)。不过在名称上我遇到了点麻烦。
指定导出的名称有两种方式,一种就是函数属性(目前采用的),另一种是采用 .def
。前者声明后由链接器决定具体的符号表,后者比较灵活,可以指定顺序、导出为序数(ordinal,没有显式的函数名,但是可以用来链接)。对于 C/C++ 默认的 CDecl,Cygwin 或 MinGW 的工具链是可以自动处理的;但是我指定了 __stdcall
(为了在 Windows 下调试;在 Linux 下测试时再试试是否应该根据平台指定不同的调用约定),根据编译器的不同,符号表中生成的名称会变得五花八门。
根据这篇指导,如果我希望导出 Windows API 风格的函数(Function
,仅保留函数名),使用 .def
是最简单的。不过,我并不知道如何在 CMake 环境下指定这个——或许是在链接器参数中给 ld
用,以后再试试。所以我一直觉得很奇怪,在 GCC 下,我明明是使用 extern "C"
、__stdcall
,导出的却一直是 Function@N
这样的函数,而我以前在 VC 下没有经历过这么“离奇”的事件。
我找到的另一篇博文中说用 --enable-stdcall-fixup
来解决这个问题。我是这么设置的:
# StdCall fixup
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wl,--enable-stdcall-fixup")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wl,--enable-stdcall-fixup")
然而好像并没有什么卯月。倒是另一篇指出的 --kill-at
选项解决了这个问题:
# Using --kill-at: http://www.cnblogs.com/lichmama/p/4126323.html
message("Killing 'at' symbols in exported functions.")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wl,--kill-at")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wl,--kill-at")
现在通过 dumpbin
查看的结果是这样的:
...
ordinal hint RVA name
1 0 000060D2 KsBeginDecode
2 1 00006488 KsCloseHandle
3 2 000062CE KsDecodeData
4 3 00006417 KsEndDecode
5 4 00006577 KsGetHcaInfo
6 5 000061F8 KsGetWaveHeader
7 6 00006724 KsHasMoreData
8 7 0000660E KsIsActiveHandle
9 8 000066AC KsIsHcaCheckPassed
10 9 00005E72 KsOpenBuffer
11 A 00005D50 KsOpenFile
12 B 00005F86 KsSetParamI32
13 C 00006036 KsSetParamI64
14 D 000067E7 KsTest
...
可以看到,在函数名称上是没有问题的了。
四、Cygwin 下的32/64位工具链切换
首先必须要有一个64位的操作系统,才能用64位的 Cygwin,才能使用64位的工具链。
我的操作系统是64位的,我自然就选择了64位的 Cygwin。一切都配置好,输出 Built target kawashima
之后,我就用最简单的 KsTest()
函数做了一个 P/Invoke 实验——我还没试过调用 Cygwin 环境下编译的动态库,先要确保能正常调用,尽管只是一个无参数、无返回值的测试函数。结果第一次运行的时候就抛出了一个 BadImageException
。
看到 dumpbin
有正常输出,说明这是一个正常的 PE 文件。然后我排除了可能的依赖库问题(编译后的64位执行文件导入了 cygwin1.dll
、cygstdc++-6.dll
和 cyggcc_s-seh-1.dll
的符号),异常依旧出现。我突然想到,这难道就是传说中的 PE32 和 PE64 的加载问题?PE64 虽然在指令集和内存布局上都兼容 PE32,但是虚拟内存空间就成了一个问题。杨彦君曾经吐槽说 Linux 下64位和32位根本就不兼容(64位系统无法运行32位应用程序),我反吐槽说微软准备了两条依赖链。各位看到的“64位 Windows 兼容32位 Windows 程序”的现象,实际上是由于同一台机子上既有32位的环境,也有64位的环境——也就是所谓的 WOW(Windows-on-Windows)。WOW 最初的应用是32位的 Windows 95 支持运行16位的应用程序。扯远了,总而言之就是我的主程序和动态库的目标平台不同,导致了这个异常。
所以,在涉及 P/Invoke 的时候,要注意原库的目标平台。那么问题来了:挖掘机技术哪家强如何在64位的 Cygwin 下编译一个32位的动态库呢?
有同学可能会说:这还不简单,给编译器加上 -m32
选项呗。就像这样:
# Force 32-bit
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -m32")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -m32")
但是这在 Cygwin 下不行,链接时是找不到合适的静态库的。我尝试过 link_directories()
,然而还是找不到。由于链接直接失败了,所以我只能凭输出说,指定 -m32
时编译出的中间文件处于未知的状态,从而无论32位还是64位的静态库都无法链接上。
好嘛,既然不能用64位的 Cygwin 编译32位的动态库,那我大不了就装一个32位的 Cygwin,用它的工具链不就好了吗?很可惜,由于我的操作系统是64位的,所以 CLion 直接就不支持32位的 Cygwin,只支持64位的。所以只好继续用64位的工具链。
那么是如何解决的呢?首先,Cygwin 自带一个简易的包管理,安装32位的工具链。但是有了工具链还不行,要让 CMake 使用32位的工具链:
if (${TARGET_ARCH} STREQUAL "x86")
set(CMAKE_C_COMPILER "/usr/bin/i686-pc-cygwin-gcc")
set(CMAKE_CXX_COMPILER "/usr/bin/i686-pc-cygwin-g++")
elseif (${TARGET_ARCH} STREQUAL "x64")
set(CMAKE_C_COMPILER "/usr/bin/x86_64-pc-cygwin-gcc")
set(CMAKE_CXX_COMPILER "/usr/bin/x86_64-pc-cygwin-g++")
else ()
# ...
endif ()
是的,这是一个很不优雅的方案。特别是硬编码了编译器路径,虽然是 Cygwin 能识别并正确映射的,但这个 CMake 配置不能直接拿去给 Linux 用户用。而且工具链上没有 ARM 目标,也是一个败笔。
很神奇地,在采用了32位的编译器和链接器后,它们终于会选择正确平台的静态库了。明明64位+-m32
的配置都无法正确选择。
五、Cygwin 自己的问题
通过了 KsTest()
的测试后,在实际使用这个库的时候,我又发现了一个坑爹的问题:Cygwin 的一些函数对 P/Invoke 很不友好。
我目前碰到了 fopen()
和 malloc()
,没试过它们对应的释放函数,或者其他的标准库函数。调用时现象如下(“随机”选一种出现):
- 程序无响应;
- .NET 抛出
AccessViolationException
。
第一条我是在调用前后加了 MessageBox()
测试过的,确认是这些函数的调用造成的无响应。
由于这些标准函数都是从 cygwin1.dll
直接导出的,所以我原来猜测也许是 Cygwin 的旧版本实现上出问题了。于是我从 2.4.1 切换到 2.5.0-0.7,但是这个问题还是发生了。而原来的 HCA 解码器程序是同样环境编译的,也用到了标准库函数,但是能正常运行,所以我只能推断异常和 P/Invoke 有关。具体怎么个关系,我就真不知道了。
解决方案是条件编译,在 Cygwin 下使用 Windows API 替换标准库函数。malloc()
和 free()
就用 new
和 delete
来代替了,毕竟里面用到的主要是(存储数据用的)结构体,构造函数为默认就行。
六、编译器优化的坑
今天(3月21日)在代码排版的时候,手贱改了一个地方,差点就找不到错误了。
原来的代码是这样的:
void clHCA::clCipher::Init0(void) {
for (int i = 0; i < 0x100; i++)
_table[i] = (unsigned char) i;
}
我一看,这大小不是刚好能用一个 uint8
装下嘛,为何不用呢。于是就改成了这样:
void CHcaCipher::Init0() {
for (uint8 i = 0; i < 0x100; i++) {
_table[i] = (uint8)i;
}
}
于是噩梦出现了,昨天通过的解码测试今天突然就通不过了。经过 C# 端的排查,发现问题出现在对 KsBeginDecode()
的调用上,调用后 CPU 满负荷运转——死循环的特征。为什么一个本来正常的函数出现了死循环呢?它做了什么呢?我挑了几个可疑的点设下断点(没法直接调试,就用 MessageBox()
告诉我),最后定位到 CHCA
的内部变量初始化。再查,就发现是上面这一段出现了错误。
问题出在哪呢?(你懂计算机吗,还最大值256?你看你自己不就是脑残了么。下面的又有什么意义呢?)uint8
的最大值是256(0x100
),而以上的代码会被编译成这样的汇编:
mov al, 0
; ...
cmp al, 100h ; <- 这是不可能出现的
; cmp al, 00h <- 可能是这样
jl ...
inc al
实际上,由于声明的 i
的类型是 uint8
(unsinged char
),所以条件 i < 0x100
实际上恒为真,i < 0x00
恒为假。不管是被编译为上面的代码,还是直接被优化,这个循环是跳不出去的了。
所以,改代码前请三思。难道 malloc()
也是如此吗?