了解如何使用 WebAssembly 和 Fugu API 将与外部设备交互的代码移植到 Web。
在之前的文章中,我展示了如何使用 File System Access API、WebAssembly 和 Asyncify 将使用文件系统 API 的应用程序移植到 Web。现在我想继续探讨将 Fugu API 与 WebAssembly 集成,并将应用程序移植到 Web 而不丢失重要功能的相同主题。
我将展示如何通过将 libusb(一个流行的 C 语言 USB 库)移植到 WebAssembly(通过 Emscripten)、Asyncify 和 WebUSB,将与 USB 设备通信的应用程序移植到 Web。
首先最重要的是:演示
移植库时最重要的事情是选择合适的演示——能够展示移植库的功能,让您能够以各种方式对其进行测试,并且同时具有视觉吸引力的演示。
我选择的想法是 DSLR 远程控制。特别是,开源项目 gPhoto2 在这个领域已经存在很长时间,足以逆向工程并实现对各种数码相机的支持。它支持多种协议,但我最感兴趣的是 USB 支持,它通过 libusb 执行。
我将在两个部分中描述构建此演示的步骤。在这篇博文中,我将描述如何移植 libusb 本身,以及将其他流行库移植到 Fugu API 可能需要的技巧。在第二篇文章中,我将详细介绍移植和集成 gPhoto2 本身。
最后,我得到了一个可用的 Web 应用程序,它可以预览 DSLR 的实时画面,并通过 USB 控制其设置。在阅读技术细节之前,请随意查看在线演示或预先录制的演示
关于相机特定怪癖的说明
您可能已经注意到,在视频中更改设置需要一段时间。与您可能看到的大多数其他问题一样,这并非由 WebAssembly 或 WebUSB 的性能引起,而是由 gPhoto2 与为演示选择的特定相机交互的方式引起。
索尼 a6600 没有公开直接设置 ISO、光圈或快门速度等值的 API,而是仅提供命令以指定的步数增加或减少它们。更复杂的是,它也不返回实际支持的值列表——返回的列表似乎在许多索尼相机型号中都是硬编码的。
当设置这些值之一时,gPhoto2 别无选择,只能
- 朝着所选值的方向迈出一步(或几步)。
- 等待一段时间,让相机更新设置。
- 读回相机实际达到的值。
- 检查最后一步是否没有跳过期望值,也没有环绕列表的末尾或开头。
- 重复。
这可能需要一些时间,但如果相机实际支持该值,它将到达那里,如果不支持,它将停在最接近的支持值上。
其他相机可能会有不同的设置集、底层 API 和怪癖。请记住,gPhoto2 是一个开源项目,对所有相机型号进行自动化或手动测试根本不可行,因此始终欢迎详细的问题报告和 PR(但请确保首先使用官方 gPhoto2 客户端重现问题)。
重要的跨平台兼容性说明
不幸的是,在 Windows 上,任何“众所周知的”设备,包括 DSLR 相机,都会被分配一个系统驱动程序,该驱动程序与 WebUSB 不兼容。如果您想在 Windows 上尝试演示,则必须使用 Zadig 等工具来覆盖连接的 DSLR 的驱动程序,使其成为 WinUSB 或 libusb。这种方法对我和其他许多用户来说效果很好,但您应该自行承担风险使用它。
在 Linux 上,您可能需要设置自定义权限以允许通过 WebUSB 访问您的 DSLR,但这取决于您的发行版。
在 macOS 和 Android 上,演示应该可以直接使用。如果您在 Android 手机上尝试,请确保切换到横向模式,因为我没有在使其具有响应性方面投入太多精力(欢迎 PR!)。

有关 WebUSB 跨平台使用的更深入指南,请参阅“为 WebUSB 构建设备”的“平台特定注意事项”部分。
向 libusb 添加新的后端
现在进入技术细节。虽然可以提供类似于 libusb 的 shim API(以前有人这样做过)并将其他应用程序链接到它,但这种方法容易出错,并使任何进一步的扩展或维护变得更加困难。我想以正确的方式做事,这种方式有可能向上游贡献并在未来合并到 libusb 中。
幸运的是,libusb README 说
“libusb 在内部以某种方式进行了抽象,希望它可以移植到其他操作系统。有关更多信息,请参阅 PORTING 文件。”
libusb 的结构方式是公共 API 与“后端”分离。这些后端负责通过操作系统的低级 API 列出、打开、关闭和实际与设备通信。这就是 libusb 如何抽象化 Linux、macOS、Windows、Android、OpenBSD/NetBSD、Haiku 和 Solaris 之间的差异并在所有这些平台上工作的方式。
我必须做的是为 Emscripten+WebUSB“操作系统”添加另一个后端。这些后端的实现位于 libusb/os
文件夹中
~/w/d/libusb $ ls libusb/os
darwin_usb.c haiku_usb_raw.h threads_posix.lo
darwin_usb.h linux_netlink.c threads_posix.o
events_posix.c linux_udev.c threads_windows.c
events_posix.h linux_usbfs.c threads_windows.h
events_posix.lo linux_usbfs.h windows_common.c
events_posix.o netbsd_usb.c windows_common.h
events_windows.c null_usb.c windows_usbdk.c
events_windows.h openbsd_usb.c windows_usbdk.h
haiku_pollfs.cpp sunos_usb.c windows_winusb.c
haiku_usb_backend.cpp sunos_usb.h windows_winusb.h
haiku_usb.h threads_posix.c
haiku_usb_raw.cpp threads_posix.h
每个后端都包含带有通用类型和帮助程序的 libusbi.h
标头,并且需要公开类型为 usbi_os_backend
的 usbi_backend
变量。例如,这是 Windows 后端的样子
const struct usbi_os_backend usbi_backend = {
"Windows",
USBI_CAP_HAS_HID_ACCESS,
windows_init,
windows_exit,
windows_set_option,
windows_get_device_list,
NULL, /* hotplug_poll */
NULL, /* wrap_sys_device */
windows_open,
windows_close,
windows_get_active_config_descriptor,
windows_get_config_descriptor,
windows_get_config_descriptor_by_value,
windows_get_configuration,
windows_set_configuration,
windows_claim_interface,
windows_release_interface,
windows_set_interface_altsetting,
windows_clear_halt,
windows_reset_device,
NULL, /* alloc_streams */
NULL, /* free_streams */
NULL, /* dev_mem_alloc */
NULL, /* dev_mem_free */
NULL, /* kernel_driver_active */
NULL, /* detach_kernel_driver */
NULL, /* attach_kernel_driver */
windows_destroy_device,
windows_submit_transfer,
windows_cancel_transfer,
NULL, /* clear_transfer_priv */
NULL, /* handle_events */
windows_handle_transfer_completion,
sizeof(struct windows_context_priv),
sizeof(union windows_device_priv),
sizeof(struct windows_device_handle_priv),
sizeof(struct windows_transfer_priv),
};
查看属性,我们可以看到结构体包括后端名称、其功能集、各种低级 USB 操作的处理程序(以函数指针的形式),以及最后,为存储私有设备/上下文/传输级别数据而分配的大小。
私有数据字段至少对于存储所有这些事物的 OS 句柄很有用,因为没有句柄,我们不知道任何给定操作应用于哪个项目。在 Web 实现中,OS 句柄将是底层的 WebUSB JavaScript 对象。在 Emscripten 中表示和存储它们的自然方式是通过 emscripten::val
类,该类作为 Embind(Emscripten 的绑定系统)的一部分提供。
文件夹中的大多数后端都是用 C 语言实现的,但也有一些是用 C++ 实现的。Embind 仅适用于 C++,因此我别无选择,只能添加 libusb/libusb/os/emscripten_webusb.cpp
,其中包含所需的结构和私有数据字段的 sizeof(val)
#include <emscripten.h>
#include <emscripten/val.h>
#include "libusbi.h"
using namespace emscripten;
// …function implementations
const usbi_os_backend usbi_backend = {
.name = "Emscripten + WebUSB backend",
.caps = LIBUSB_CAP_HAS_CAPABILITY,
// …handlers—function pointers to implementations above
.device_priv_size = sizeof(val),
.transfer_priv_size = sizeof(val),
};
将 WebUSB 对象存储为设备句柄
libusb 提供了指向私有数据已分配区域的现成指针。为了将这些指针作为 val
实例使用,我添加了一些小的帮助程序,用于就地构造它们、将它们作为引用检索出来以及移出值
// We store an Embind handle to WebUSB USBDevice in "priv" metadata of
// libusb device, this helper returns a pointer to it.
struct ValPtr {
public:
void init_to(val &&value) { new (ptr) val(std::move(value)); }
val &get() { return *ptr; }
val take() { return std::move(get()); }
protected:
ValPtr(val *ptr) : ptr(ptr) {}
private:
val *ptr;
};
struct WebUsbDevicePtr : ValPtr {
public:
WebUsbDevicePtr(libusb_device *dev)
: ValPtr(static_cast<val *>(usbi_get_device_priv(dev))) {}
};
val &get_web_usb_device(libusb_device *dev) {
return WebUsbDevicePtr(dev).get();
}
struct WebUsbTransferPtr : ValPtr {
public:
WebUsbTransferPtr(usbi_transfer *itransfer)
: ValPtr(static_cast<val *>(usbi_get_transfer_priv(itransfer))) {}
};
同步 C 上下文中的异步 Web API
现在需要一种处理异步 WebUSB API 的方法,而 libusb 期望同步操作。为此,我可以使用 Asyncify,或者更具体地说,通过 val::await()
进行 Embind 集成。
我还想正确处理 WebUSB 错误并将它们转换为 libusb 错误代码,但 Embind 目前没有任何方法可以处理来自 C++ 端的 JavaScript 异常或 Promise
拒绝。这个问题可以通过在 JavaScript 端捕获拒绝并将结果转换为 { error, value }
对象来解决,现在可以从 C++ 端安全地解析该对象。我通过 EM_JS
宏和 Emval.to{Handle, Value}
API 的组合来完成此操作
EM_JS(EM_VAL, em_promise_catch_impl, (EM_VAL handle), {
let promise = Emval.toValue(handle);
promise = promise.then(
value => ({error : 0, value}),
error => {
const ERROR_CODES = {
// LIBUSB_ERROR_IO
NetworkError : -1,
// LIBUSB_ERROR_INVALID_PARAM
DataError : -2,
TypeMismatchError : -2,
IndexSizeError : -2,
// LIBUSB_ERROR_ACCESS
SecurityError : -3,
…
};
console.error(error);
let errorCode = -99; // LIBUSB_ERROR_OTHER
if (error instanceof DOMException)
{
errorCode = ERROR_CODES[error.name] ?? errorCode;
}
else if (error instanceof RangeError || error instanceof TypeError)
{
errorCode = -2; // LIBUSB_ERROR_INVALID_PARAM
}
return {error: errorCode, value: undefined};
}
);
return Emval.toHandle(promise);
});
val em_promise_catch(val &&promise) {
EM_VAL handle = promise.as_handle();
handle = em_promise_catch_impl(handle);
return val::take_ownership(handle);
}
// C++ struct representation for {value, error} object from above
// (performs conversion in the constructor).
struct promise_result {
libusb_error error;
val value;
promise_result(val &&result)
: error(static_cast<libusb_error>(result["error"].as<int>())),
value(result["value"]) {}
// C++ counterpart of the promise helper above that takes a promise, catches
// its error, converts to a libusb status and returns the whole thing as
// `promise_result` struct for easier handling.
static promise_result await(val &&promise) {
promise = em_promise_catch(std::move(promise));
return {promise.await()};
}
};
现在我可以在从 WebUSB 操作返回的任何 Promise
上使用 promise_result::await()
,并分别检查其 error
和 value
字段。
例如,从 libusb_device_handle
检索表示 USBDevice
的 val
,调用其 open()
方法,等待其结果,并将错误代码作为 libusb 状态代码返回,如下所示
int em_open(libusb_device_handle *handle) {
auto web_usb_device = get_web_usb_device(handle->dev);
return promise_result::await(web_usb_device.call<val>("open")).error;
}
设备枚举
当然,在我可以打开任何设备之前,libusb 需要检索可用设备列表。后端必须通过 get_device_list
处理程序实现此操作。
困难在于,与其他平台不同,出于安全原因,无法在 Web 上枚举所有已连接的 USB 设备。相反,流程分为两个部分。首先,Web 应用程序通过 navigator.usb.requestDevice()
请求具有特定属性的设备,并且用户手动选择他们想要公开的设备或拒绝权限提示。之后,应用程序通过 navigator.usb.getDevices()
列出已批准和连接的设备。
起初,我尝试在 get_device_list
处理程序的实现中直接使用 requestDevice()
。但是,显示带有已连接设备列表的权限提示被认为是敏感操作,并且必须由用户交互(例如页面上的按钮单击)触发,否则它总是返回被拒绝的 promise。libusb 应用程序可能经常希望在应用程序启动时列出已连接的设备,因此使用 requestDevice()
不是一个选项。
相反,我不得不将 navigator.usb.requestDevice()
的调用留给最终开发人员,并且仅从 navigator.usb.getDevices()
公开已批准的设备
// Store the global `navigator.usb` once upon initialisation.
thread_local const val web_usb = val::global("navigator")["usb"];
int em_get_device_list(libusb_context *ctx, discovered_devs **devs) {
// C++ equivalent of `await navigator.usb.getDevices()`.
// Note: at this point we must already have some devices exposed -
// caller must have called `await navigator.usb.requestDevice(...)`
// in response to user interaction before going to LibUSB.
// Otherwise this list will be empty.
auto result = promise_result::await(web_usb.call<val>("getDevices"));
if (result.error) {
return result.error;
}
auto &web_usb_devices = result.value;
// Iterate over the exposed devices.
uint8_t devices_num = web_usb_devices["length"].as<uint8_t>();
for (uint8_t i = 0; i < devices_num; i++) {
auto web_usb_device = web_usb_devices[i];
// …
*devs = discovered_devs_append(*devs, dev);
}
return LIBUSB_SUCCESS;
}
大多数后端代码以与上面已显示的方式类似的方式使用 val
和 promise_result
。数据传输处理代码中还有一些更有趣的技巧,但对于本文的目的而言,这些实现细节不太重要。如果您有兴趣,请务必查看 Github 上的代码和注释。
将事件循环移植到 Web
我想讨论的 libusb 移植的另一个部分是事件处理。如上一篇文章所述,像 C 这样的系统语言中的大多数 API 都是同步的,事件处理也不例外。它通常通过一个无限循环实现,该循环“轮询”(尝试读取数据或阻塞执行,直到某些数据可用)来自一组外部 I/O 源,并且当至少其中一个响应时,将其作为事件传递给相应的处理程序。一旦处理程序完成,控制权将返回到循环,并且它会暂停以进行另一次轮询。
这种方法在 Web 上存在一些问题。
首先,WebUSB 不会并且不能公开底层设备的原始句柄,因此直接轮询这些设备不是一个选项。其次,libusb 使用 eventfd
和 pipe
API 来处理其他事件以及在没有原始设备句柄的操作系统上处理传输,但是 eventfd
目前在 Emscripten 中不受支持,而 pipe
虽然受支持,但 目前不符合规范,并且无法等待事件。
最后,最大的问题是 Web 有自己的事件循环。此全局事件循环用于任何外部 I/O 操作(包括 fetch()
、计时器,或者在本例中为 WebUSB),并且每当相应的操作完成时,它都会调用事件或 Promise
处理程序。执行另一个嵌套的无限事件循环将阻止浏览器的事件循环继续进行,这意味着不仅 UI 会变得无响应,而且代码永远不会收到它正在等待的同一 I/O 事件的通知。这通常会导致死锁,而这正是我在演示中使用 libusb 时发生的情况。页面冻结了。
与其他阻塞 I/O 一样,为了将此类事件循环移植到 Web,开发人员需要找到一种在不阻塞主线程的情况下运行这些循环的方法。一种方法是重构应用程序以在单独的线程中处理 I/O 事件,并将结果传递回主线程。另一种方法是使用 Asyncify 暂停循环并以非阻塞方式等待事件。
我不想对 libusb 或 gPhoto2 进行重大更改,并且我已经将 Asyncify 用于 Promise
集成,所以这就是我选择的路径。为了模拟 poll()
的阻塞变体,对于最初的概念验证,我使用了如下所示的循环
#ifdef __EMSCRIPTEN__
// TODO: optimize this. Right now it will keep unwinding-rewinding the stack
// on each short sleep until an event comes or the timeout expires.
// We should probably create an actual separate thread that does signaling
// or come up with a custom event mechanism to report events from
// `usbi_signal_event` and process them here.
double until_time = emscripten_get_now() + timeout_ms;
do {
// Emscripten `poll` ignores timeout param, but pass 0 explicitly just
// in case.
num_ready = poll(fds, nfds, 0);
if (num_ready != 0) break;
// Yield to the browser event loop to handle events.
emscripten_sleep(0);
} while (emscripten_get_now() < until_time);
#else
num_ready = poll(fds, nfds, timeout_ms);
#endif
它的作用是
- 调用
poll()
以检查后端是否已报告任何事件。如果有一些,循环停止。否则,Emscripten 的poll()
实现将立即返回0
。 - 调用
emscripten_sleep(0)
。此函数在底层使用 Asyncify 和setTimeout()
,此处用于将控制权返回给主浏览器事件循环。这允许浏览器处理任何用户交互和 I/O 事件,包括 WebUSB。 - 检查指定的超时是否已过期,如果未过期,则继续循环。
正如注释中提到的,这种方法不是最佳的,因为它即使在没有 USB 事件要处理时(大多数情况下都是这样)也一直在使用 Asyncify 保存-恢复整个调用堆栈,并且因为 setTimeout()
本身在现代浏览器中的最小持续时间为 4 毫秒。尽管如此,它仍然运行良好,可以在概念验证中产生来自 DSLR 的 13-14 FPS 直播。
后来,我决定通过利用浏览器事件系统来改进它。这种实现可以通过多种方式进一步改进,但目前我选择直接在全球对象上发出自定义事件,而不将它们与特定的 libusb 数据结构关联。我是通过以下基于 EM_ASYNC_JS
宏的等待和通知机制来实现的
EM_JS(void, em_libusb_notify, (void), {
dispatchEvent(new Event("em-libusb"));
});
EM_ASYNC_JS(int, em_libusb_wait, (int timeout), {
let onEvent, timeoutId;
try {
return await new Promise(resolve => {
onEvent = () => resolve(0);
addEventListener('em-libusb', onEvent);
timeoutId = setTimeout(resolve, timeout, -1);
});
} finally {
removeEventListener('em-libusb', onEvent);
clearTimeout(timeoutId);
}
});
每当 libusb 尝试报告事件(例如数据传输完成)时,都会使用 em_libusb_notify()
函数
void usbi_signal_event(usbi_event_t *event)
{
uint64_t dummy = 1;
ssize_t r;
r = write(EVENT_WRITE_FD(event), &dummy, sizeof(dummy));
if (r != sizeof(dummy))
usbi_warn(NULL, "event write failed");
#ifdef __EMSCRIPTEN__
em_libusb_notify();
#endif
}
同时,当收到 em-libusb
事件或超时到期时,em_libusb_wait()
部分用于从 Asyncify 睡眠中“唤醒”
double until_time = emscripten_get_now() + timeout_ms;
for (;;) {
// Emscripten `poll` ignores timeout param, but pass 0 explicitly just
// in case.
num_ready = poll(fds, nfds, 0);
if (num_ready != 0) break;
int timeout = until_time - emscripten_get_now();
if (timeout <= 0) break;
int result = em_libusb_wait(timeout);
if (result != 0) break;
}
由于睡眠和唤醒次数的显着减少,这种机制修复了早期基于 emscripten_sleep()
的实现的效率问题,并将 DSLR 演示吞吐量从 13-14 FPS 提高到稳定的 30+ FPS,这对于流畅的实时画面来说已经足够了。
构建系统和第一次测试
后端完成后,我必须将其添加到 Makefile.am
和 configure.ac
中。这里唯一有趣的部分是 Emscripten 特定的标志修改
emscripten)
AC_SUBST(EXEEXT, [.html])
# Note: LT_LDFLAGS is not enough here because we need link flags for executable.
AM_LDFLAGS="${AM_LDFLAGS} --bind -s ASYNCIFY -s ASSERTIONS -s ALLOW_MEMORY_GROWTH -s INVOKE_RUN=0 -s EXPORTED_RUNTIME_METHODS=['callMain']"
;;
首先,Unix 平台上的可执行文件通常没有文件扩展名。但是,Emscripten 会根据您请求的扩展名生成不同的输出。我使用 AC_SUBST(EXEEXT, …)
将可执行文件扩展名更改为 .html
,以便包中的任何可执行文件(测试和示例)都变成 HTML,其中包含 Emscripten 的默认 shell,该 shell 负责加载和实例化 JavaScript 和 WebAssembly。
其次,因为我正在使用 Embind 和 Asyncify,所以我需要启用这些功能 (--bind -s ASYNCIFY
) 以及允许通过链接器参数进行动态内存增长 (-s ALLOW_MEMORY_GROWTH
)。不幸的是,库无法向链接器报告这些标志,因此每个使用此 libusb 端口的应用程序也必须将相同的链接器标志添加到其构建配置中。
最后,如前所述,WebUSB 要求通过用户手势完成设备枚举。libusb 示例和测试假设他们可以在启动时枚举设备,并且在没有更改的情况下会因错误而失败。相反,我必须禁用自动执行 (-s INVOKE_RUN=0
) 并公开手动 callMain()
方法 (-s EXPORTED_RUNTIME_METHODS=...
)。
完成所有这些操作后,我可以使用静态 Web 服务器提供生成的文件,初始化 WebUSB,并在 DevTools 的帮助下手动运行这些 HTML 可执行文件。
它看起来并不多,但是,当将库移植到新平台时,第一次达到生成有效输出的阶段是非常令人兴奋的!
使用该端口
如上面所述,该端口依赖于一些 Emscripten 功能,这些功能目前需要在应用程序的链接阶段启用。如果您想在自己的应用程序中使用此 libusb 端口,则需要执行以下操作
- 将最新的 libusb 下载为构建的一部分的存档,或将其作为 git 子模块添加到您的项目中。
- 在
libusb
文件夹中运行autoreconf -fiv
。 - 运行
emconfigure ./configure –host=wasm32 –prefix=/some/installation/path
以初始化项目以进行交叉编译,并设置要放置构建工件的路径。 - 运行
emmake make install
。 - 将您的应用程序或更高级别的库指向在较早选择的路径下搜索 libusb。
- 将以下标志添加到应用程序的链接参数中:
--bind -s ASYNCIFY -s ALLOW_MEMORY_GROWTH
。
该库目前有一些限制
- 没有传输取消支持。这是 WebUSB 的限制,而 WebUSB 的限制又源于 libusb 本身缺乏跨平台传输取消。
- 没有同步传输支持。按照现有传输模式的实现作为示例添加它应该不难,但它也是一种相对罕见的模式,我没有任何设备可以对其进行测试,因此目前我将其保留为不受支持的状态。如果您确实有此类设备,并且想为该库做出贡献,欢迎提交 PR!
- 前面提到的跨平台限制。这些限制是由操作系统强加的,因此我们在这里无能为力,除了要求用户覆盖驱动程序或权限。但是,如果您要移植 HID 或串行设备,则可以遵循 libusb 示例并将其他库移植到另一个 Fugu API。例如,您可以将 C 库 hidapi 移植到 WebHID,并完全绕过与低级 USB 访问相关的这些问题。
结论
在这篇文章中,我展示了如何在 Emscripten、Asyncify 和 Fugu API 的帮助下,即使是像 libusb 这样的低级库也可以通过一些集成技巧移植到 Web。
移植如此重要且广泛使用的低级库尤其有意义,因为反过来,它也允许将更高级别的库甚至整个应用程序也带到 Web 上。这为以前仅限于一两个平台的用户打开了体验,使其适用于各种设备和操作系统,只需单击链接即可获得这些体验。
在下一篇文章中,我将逐步介绍构建 Web gPhoto2 演示所涉及的步骤,该演示不仅检索设备信息,而且还广泛使用了 libusb 的传输功能。同时,我希望您发现 libusb 示例具有启发性,并将尝试演示、使用库本身,或者甚至继续并将另一个广泛使用的库移植到 Fugu API 之一。