MashPlant的笔记

“你将孤单度过一生”

0%

在Rust的wasm后端中链接C++

一般的Rust程序中可以在build.rs中借助cc这个库链接C++代码,不过我在使用wasm后端并试图链接C++代码时遇到了一些问题,这里简单记录一下解决的过程。

最终代码位于github.com/MashPlant/ncmdump-rs/tree/wasm,演示网页位于mashplant.online/ncmdump-rs/

背景

网易云音乐设计了一个ncm格式,用来保存会员专属的音乐,这样可以做到手机客户端上会员有效期结束后不允许播放下载的会员音乐,别的音乐软件也不能识别它。ncm格式并不复杂,ncmdump这个库实现了将它解密为mp3或者flac文件的算法,出于好奇我也照着它实现了一个,并且希望能够用wasm来运行这个算法。

解密过程大致分为两步,第一步是将纯音乐的部分解密出来,涉及一些aes,base64的库,用Rust写起来非常方便;第二步是将ncm文件中包含的封面,作者等媒体信息添加到解密出来的音频文件上,这一步是借助C++库taglib实现的,如果要重新造一遍轮子应该会相当复杂。

taglib提供了C API,也有一个相应的Rust binding的库,但是C API非常不完整,比如我需要给一段内存中的音频加上信息,C API就做不到,必须用C++ API来完成,C API只提供了给文件系统中的音频文件加上信息的接口。所以大致思路就是把taglib编译成一个wasm的库,然后写一个C++函数接受Rust传来的数据并使用taglib来生成加上信息的音频,用emscripten工具链编译这段C++代码,再把这个库和这段C++代码链接到最终的wasm文件中。

编译taglib

1
2
3
4
$ git clone https://github.com/taglib/taglib
$ cd taglib && mkdir build && cd build
$ CC=emcc CXX=em++ AR=emar cmake ..
$ make -j16

cmake可能会报一个错:

1
2
3
4
5
6
-- Check size of wchar_t
-- Check size of wchar_t - done
CMake Error at ConfigureChecks.cmake:23 (if):
if given arguments:
"LESS" "2"
Unknown arguments specified

意思应该是找不到wchar_t的大小,不过可以直接忽略掉这个检查,注释掉ConfigureChecks.cmake中的相关检查即可:

1
2
3
4
# check_type_size("wchar_t" SIZEOF_WCHAR_T)
# if(${SIZEOF_WCHAR_T} LESS 2)
# message(FATAL_ERROR "TagLib requires that wchar_t is sufficient to store a UTF-16 char.")
# endif()

make生成了build/taglib/libtag.a,这就是我们需要的wasm库文件了,但是之后链接时会报错说它缺少index,让我们用ranlib加一个,所以这里就提前加好:

1
$ emranlib ./taglib/libtag.a # 假设仍在build文件夹中

编译Rust程序

原理上,Rust这边从ncm文件中提取出音频文件和各种信息,把这些传递给C++,C++调用taglib得到加上信息的音频,再传回Rust。至于这些信息怎么安全地跨过FFI边界,不是这篇文章的重点。其实我实现时用了一些FFI不安全的结构体,但是我心里清楚它们的内存布局,理论上虽然没有保证,实际上应该不太可能出错。

如果是编译在本机运行的程序,需要在本机安装libtag1-dev(以Ubuntu为例),这样就往系统include路径添加了必要的头文件,往系统library路径添加了必要的库,build.rs就很简单:

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
println!("cargo:rerun-if-changed=src/add_tag.cpp");
println!("cargo:rustc-flags=-l tag -l stdc++");
cc::Build::new().file("src/add_tag.cpp").compile("add_tag");
}
// add_tag.cpp大致内容:
// #include <taglib/attachedpictureframe.h>
// #include <taglib/fileref.h>
// ...
// ByteVectorStream stream(ByteVector(data.data.ptr, data.data.len));
// MPEG::File *f1 = new MPEG::File(&stream, ID3v2::FrameFactory::instance());
// ...

但是要编译到wasm后端,它的include路径和library路径中就没有这些东西了。

先解决include的问题。taglib仓库中的头文件分布比较零散,要添加include搜索路径比较麻烦,可以利用libtag1-dev添加到系统include路径中的头文件目录/usr/include/taglib。直接用emcc编译时搜索不到它们,这是因为emcc的include搜索路径并不包括系统include路径,而是emscripten自己定义的一系列头文件的路径,需要手动指定系统include路径:

1
2
3
4
fn main() {
...
cc::Build::new().file("src/add_tag.cpp").include("/usr/include").compile("add_tag");
}

但是这会让类似stdio.h这样的文件也都在/usr/include中搜索,而不是使用emscripten自己定义的版本,emcc就没法编译了。解决方法是先把add_tag.cpp#include <taglib/fileref.h>这样的语句中的taglib/去掉,然后build.rs中改成.include("/usr/include/taglib")

再解决library的问题。这里不再需要-l stdc++了,-l tag改为-L<taglib>/build/taglib -l tag,其中<taglib>是taglib仓库的路径。但是这样没有把emscripten的标准库链接进去,为了知道要链接哪些库,可以随便编译一个程序,看看链接器是怎么执行的:

1
2
3
4
5
# <emsdk>表示emsdk仓库的路径,<emscripten>表示<emsdk>/upstream/emscripten
$ em++ test.cpp -o test.html -v
...
"<emsdk>/upstream/bin/wasm-ld" ... -L<emscripten>/system/local/lib -L<emscripten>/system/lib -L<emscripten>/cache/wasm <emscripten>/cache/wasm/libc.a ... <emscripten>/cache/wasm/libsockets.a ...
...

中间还省略了一些.a文件,虽然这些库应该不是都有必要,但是写上也没什么坏处,优化编译时会自动忽略掉没有用的库。最终的build.rs如下:

1
2
3
4
5
fn main() {
println!("cargo:rerun-if-changed=src/add_tag.cpp");
println!("cargo:rustc-flags=-L<taglib>/build/taglib -l tag -L<emscripten>/system/local/lib -L<emscripten>/system/lib -L<emscripten>/cache/wasm -l c -l compiler_rt -l c++-noexcept -l c++abi-noexcept -l dlmalloc -l pthread_stub -l c_rt_wasm -l sockets");
cc::Build::new().file("src/add_tag.cpp").include("/usr/include/taglib").compile("add_tag");
}

现在就可以编译了:

1
$ CC=emcc CXX=em++ AR=emar wasm-pack build --debug

按照预期在pkg文件夹中生成了wasm文件和一些js胶水文件。

修复链接错误

按照教程(例如Tutorial: Conway’s Game of Life)写一个简单的网页来使用这个wasm库,会出现这样的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ npm run start
...
ERROR in ../pkg/ncmdump_rs_bg.wasm
Module not found: Error: Can't resolve 'env' in '/home/mashplant/Code/CLionProjects/ncmdump-rs-wasm/pkg'
@ ../pkg/ncmdump_rs_bg.wasm
@ ../pkg/ncmdump_rs.js
@ ./index.js
@ ./bootstrap.js

ERROR in ../pkg/ncmdump_rs_bg.wasm
Module not found: Error: Can't resolve 'wasi_snapshot_preview1' in '/home/mashplant/Code/CLionProjects/ncmdump-rs-wasm/pkg'
@ ../pkg/ncmdump_rs_bg.wasm
@ ../pkg/ncmdump_rs.js
@ ./index.js
@ ./bootstrap.js
...

普及一下wasm的基础知识:一个编译好的wasm文件不一定可以直接运行,它可以从外界import函数,在初始化wasm模块时必须给它提供这些函数(这个过程就是“链接”)。可以用wabt中的wasm2wat工具来查看具体import了哪些函数:

1
2
3
4
5
6
7
8
9
10
11
12
$ wasm2wat ncmdump_rs_bg.wasm
...
(import "env" "emscripten_memcpy_big" (func $emscripten_memcpy_big (type 9)))
(import "env" "abort" (func $abort (type 0)))
(import "env" "emscripten_resize_heap" (func $emscripten_resize_heap (type 4)))
(import "env" "__cxa_atexit" (func $__cxa_atexit (type 9)))
(import "wasi_snapshot_preview1" "environ_sizes_get" (func $__wasi_environ_sizes_get (type 7)))
(import "wasi_snapshot_preview1" "environ_get" (func $__wasi_environ_get (type 7)))
(import "env" "__cxa_allocate_exception" (func $__cxa_allocate_exception (type 4)))
(import "env" "__cxa_throw" (func $__cxa_throw (type 8)))
(import "./ncmdump_rs_bg.js" "__wbindgen_throw" (func $wasm_bindgen::__wbindgen_throw::ha3eb5828fbe1e793 (type 6)))
...

env中import了emscripten_memcpy_big__cxa_throw等,从wasi_snapshot_preview1中import了environ_sizes_getenviron_get,从./ncmdump_rs_bg.js中import了__wbindgen_throw。最后这个函数在pkg中初始化时会提供,而前两组函数,如果使用emscripten工具链,它生成的js胶水文件中初始化时会提供这些函数,但Rust的wasm工具链生成的胶水文件不会。

考虑这些函数的语义,应该只有emscripten_memcpy_bigemscripten_resize_heap是需要实现的。其他的函数应该都用不到(__cxa_atexit是注册函数在程序结束时执行;environ_sizes_getenviron_get与环境变量有关;abort, __cxa_allocate_exception, __cxa_throw__wbindgen_throw都与异常或程序中止有关),输出一句记录一下。

执行em++ test.cpp -s ALLOW_MEMORY_GROWTH=1(开启ALLOW_MEMORY_GROWTH时才有emscripten_resize_heap的实现),看看生成的胶水文件中的emscripten_memcpy_bigemscripten_resize_heap的功能:

  • emscripten_memcpy_big(dest, src, num):相当于memcpy(dest, src, num)destsrc都是整数,把wasm的内存看成u8数组,它们就是数组下标
  • emscripten_resize_heap(request):调整内存的大小,要求至少包含request个字节,返回布尔值表示是否成功

wasm模块export了一个memory变量,类型是WebAssembly.Memory,依据文档和胶水文件中的实现很容易写出上面两个函数的实现。其实直接复制胶水文件中的实现也是可以的,不过我觉得它的emscripten_resize_heap太长了,自己写了一个更简单的。

pkg中用的是import的方式来初始化wasm模块,我不熟悉js,不知道应该怎么把它改成WebAssembly.instantiate那一套,同时还能把wasm中的函数export出去。所以我选择把这个wasm文件拷贝到npm项目下,然后直接调用WebAssembly.instantiate,不依赖pkg中的胶水文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
let wasm;
let cacheHeap8 = null;

// 从pkg中的胶水文件复制来的,把内存当做u8数组使用
function HEAP8() {
if (cacheHeap8 === null || cacheHeap8.buffer !== wasm.memory.buffer) {
cacheHeap8 = new Uint8Array(wasm.memory.buffer);
}
return cacheHeap8;
}

WebAssembly.instantiateStreaming(fetch('./ncmdump_rs_bg.wasm'), {
env: {
// 不能写成emscripten_memcpy_big: (dest, src, num) => HEAP8().copyWithin(dest, src, src + num),
// 因为Uint8Array.copyWithin()会返回这个数组本身,如果是一般的js程序也没有关系,因为只是返回一个引用
// 但是经实验如果在wasm模块import的函数中返回一个数组,数组会被拷贝一份放进wasm的内存,效率会非常低下。
emscripten_memcpy_big: (dest, src, num) => { HEAP8().copyWithin(dest, src, src + num); }
emscripten_resize_heap: (request) => {
let old = HEAP8().length;
if (request > old) {
// WebAssembly.Memory.grow(delta)中的delta不是字节数,而是内存页数,根据规范wasm中页大小为64KB
// 增长到能容纳2 * request个字节,并且至少增长1页
wasm.memory.grow(((2 * request - old) >>> 16) + 1);
}
return true;
},
__cxa_atexit: () => console.log('__cxa_atexit'),
abort: () => console.log('abort'),
__cxa_allocate_exception: () => console.log('__cxa_allocate_exception'),
__cxa_throw: () => console.log('__cxa_throw'),
},
'./ncmdump_rs_bg.js': {
__wbindgen_throw: () => console.log('__wbindgen_throw'),
},
wasi_snapshot_preview1: {
environ_sizes_get: () => console.log('environ_sizes_get'),
environ_get: () => console.log('environ_get'),
}
}).then(obj => {
wasm = obj.instance.exports;
// 调用wasm中的函数...
});

修复运行错误

到此wasm模块就可以正确地初始化了,但是调用其中的函数时仍然可能会遇到运行错误。

一个错误是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
Uncaught (in promise) RuntimeError: function signature mismatch
at _ZNK6TagLib5ID3v212FrameFactory11createFrameERKNS_10ByteVectorEPKNS0_6HeaderE (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[31]:0x26c8d)
at _ZNK6TagLib5ID3v212FrameFactory11createFrameERKNS_10ByteVectorEPNS0_6HeaderE (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[2119]:0x886f7)
at _ZN6TagLib5ID3v23Tag5parseERKNS_10ByteVectorE (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[217]:0x52182)
at _ZN6TagLib5ID3v23Tag4readEv (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[278]:0x57f43)
at _ZN6TagLib5ID3v23TagC2EPNS_4FileElPKNS0_12FrameFactoryE (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[592]:0x6b9dc)
at _ZN6TagLib4MPEG4File4readEb (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[235]:0x53eba)
at _ZN6TagLib4MPEG4FileC2EPNS_8IOStreamEPNS_5ID3v212FrameFactoryEbNS_15AudioProperties9ReadStyleE (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[1053]:0x7aaf0)
# demangle后即TagLib::MPEG::File::File(TagLib::IOStream*, TagLib::ID3v2::FrameFactory*, bool, TagLib::AudioProperties::ReadStyle)
at add_tag (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[62]:0x356bf)
at ncmdump_rs::transform::hcdc5151f7ab9b5f0 (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[11]:0x10f30)
at ncmdump_rs::work::h156846be72f8a579 (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[429]:0x6306e)

这个错误真是折磨了我很久,一点头绪也没有。错误栈中最底层的taglib中的函数基本就是add_tag.cpp中的第二句话,所以完全想不出来问题出在哪里。后来想到做点对比实验,用一样的输入数据调用这个函数,但是用emscripten工具链来编译,结果就能正常运行。用wasm2wat查看它的wasm,偶然看到这样一个函数:

1
2
3
4
5
6
7
8
9
(export "__wasm_call_ctors" (func $__wasm_call_ctors))
...
(func $__wasm_call_ctors (type 0)
call $__emscripten_environ_constructor
call $_GLOBAL__sub_I_tstring.cpp
call $_GLOBAL__sub_I_tbytevector.cpp
call $_GLOBAL__sub_I_asffile.cpp
call $_GLOBAL__sub_I_id3v2frame.cpp
call $_GLOBAL__sub_I_id3v2framefactory.cpp)

应该是在初始化全局变量,翻了翻taglib的源码,确实定义了一些需要调用构造函数的全局变量,但是我的代码中没有任何地方执行了这个初始化的过程。

wasm2wat查看cargo编译出来的wasm,发现根本没有__wasm_call_ctors。这应该是因为没有导出它,也没有任何其他函数调用它,链接时就被优化掉了,所以需要给rustc提供参数让它导出__wasm_call_ctors。参考How can I specify linker flags/arguments in a build script? ,创建.cargo/config

1
2
3
4
5
6
# 注意`--export`和`__wasm_call_ctors`不能写在一行,因为它们必须作为两个参数分别传给rustc
[build]
rustflags = [
"-C", "link-arg=--export",
"-C", "link-arg=__wasm_call_ctors",
]

在得到wasm模块后手动调用这个函数即可:

1
2
3
4
5
6
...
}).then(obj => {
wasm = obj.instance.exports;
wasm.__wasm_call_ctors();
// 调用wasm中的函数...
});

另一个错误是这样的:

1
2
3
4
5
6
7
8
9
10
11
Uncaught (in promise) RuntimeError: memory access out of bounds
at dlmalloc::dlmalloc::Dlmalloc::realloc::h430fa370263f868f (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[127]:0x467d9)
at __rdl_realloc (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[984]:0x7a484)
at __rust_realloc (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[1959]:0x8924f)
at alloc::alloc::realloc::hb47e55c361cc3873 (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[959]:0x799e2)
at <alloc::alloc::Global as core::alloc::AllocRef>::grow::h197f5bad8094e7c6 (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[142]:0x490be)
at alloc::raw_vec::finish_grow::hf66aa09834bcc688 (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[92]:0x3f3a4)
at alloc::raw_vec::RawVec<T,A>::grow_amortized::h11902a88edb24012 (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[106]:0x427c4)
at alloc::raw_vec::RawVec<T,A>::try_reserve::ha5b7f89b8444ce9a (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[883]:0x778e6)
at alloc::raw_vec::RawVec<T,A>::reserve::hbc956c8a4b8cd696 (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[624]:0x6e4c3)
at alloc::vec::Vec<T>::reserve::hbf6dbd4f147dde00 (http://localhost:8080/ncmdump_rs_bg.wasm:wasm-function[1299]:0x8115c)

看起来应该是Rust中申请内存时出错了,我猜测是因为同时存在两个内存分配器,Rust和C++中各有一个,它们都认为自己是独占堆空间的,所以可能会覆盖对方的元信息,导致出错。解决方法就是去掉其中一个,用另一个来代替它。比如去掉Rust中的,直接用malloc这一套申请内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::alloc::{GlobalAlloc, Layout};

struct Malloc;

extern "C" {
fn malloc(size: usize) -> *mut u8;
fn free(ptr: *mut u8);
fn realloc(ptr: *mut u8, size: usize) -> *mut u8;
}

// 这里直接忽略了align的要求,不过应该没有什么大问题,我的应用应该只要求4字节对齐,malloc可以保证
unsafe impl GlobalAlloc for Malloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 { malloc(layout.size()) }
unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) { free(ptr); }
unsafe fn realloc(&self, ptr: *mut u8, _layout: Layout, new_size: usize) -> *mut u8 { realloc(ptr, new_size) }
}

#[global_allocator]
static A: Malloc = Malloc;

去掉C++中的也可以,但是麻烦一些。首先build.rs中不再链接libdlmalloc.a,即去掉-l dlmalloc,这样wasm文件会从env中多import三个函数:mallocreallocfree。wasm模块中还export了__wbindgen_malloc__wbindgen_free两个函数,这是Rust那边实现的,可以用它们来实现mallocreallocfree,但会有一点困难,因为__wbindgen_free需要传指针和长度作参数,而free只有一个指针参数,可能需要手动在内存中维护这种信息,当然也可以直接把free实现成空操作

到此所有问题都解决了。