外设访问(USB / 串口)
本文是 README.md 外设章节的展开,记录 ZCode 在 HarmonyOS 上如何把 USB 能力交给应用内的
程序,以及维护这层时不能踩的坑。改这一层之前先读 log.md 里对应的实测结论。
一、为什么需要这一层
普通应用在本域里:
- 枚举不了
/sys/bus/usb/devices; - 只能按已知路径打开
/dev/bus/usb节点,无法扫描; - 没有
/dev/ttyUSB*、/dev/ttyACM*之类的串口节点。
而 @ohos.usbManager(不需要受限权限,用户授权一次即可)能拿到设备通道。于是本项目把
它包装成三条对上层透明的通道,原生工具、命令行、agent 终端都不需要改源码,也不需要
手动启任何服务。
程序 open("/dev/bus/usb/B/A") ─┐
程序 read(fd) 取描述符链 ──────┤ libusbcompat.so(entry.mjs 自动 LD_PRELOAD)
程序 ioctl(USBDEVFS_*) ────────┤
程序 open("/dev/ttyUSB0") ─────┘ → 虚拟串口(pty + CH34x 直驱 + 推送流)
↓
usbManager(应用内,自动运行,不监听 TCP)
二、三条通道
1. 伪造 sysfs
路径重写 + 把 statfs("/sys") 伪装成 sysfs 魔数,让依赖 sysfs 枚举的程序(libusb、adb 的
usb_linux 后端、fastboot)照常看到设备。树由 usbManager 实时生成,换设备零改动。
2. fd 分发
usbManager.getFileDescriptor() 的 fd 经 Unix socket(SCM_RIGHTS)发给子进程,之后
claim/bulk 都是它自己的系统调用,吞吐取决于内核而不是协议往返。
3. 桥接兜底
拿不到 fd 时退化为同进程内的 Unix socket 命令通道(cache/usb-bridge.sock,不监听任何
TCP 端口)。命令集:list / open / openid / claim / control / bulk / bulk64 / desc / setcfg / setalt / reset / close / release / pump / pumpstop / slist / sopen / sread / swrite / sattr / sdtr / sclose。
三、让原生程序开箱即用
native/usb-libusb-shim/ 提供 libusb-1.0.so.0,把 libusb API 直接转给上面这套通道;
entry.mjs / wrapper.mjs 自动注入 LD_PRELOAD(libusbcompat.so)与
LD_LIBRARY_PATH,因此 动态链接 libusb 或使用 usbfs 的程序源码不用改。
静态链接 libusb 的二进制无法拦截(LD_PRELOAD 无效),这类工具必须换动态链接构建。
实测可用
| 工具 | 结果 |
|---|---|
| adb | devices / shell / push / pull(一加 31f025c3;华为需在手机侧允许 USB 调试) |
| fastboot | usb_linux 与 libusb 两版都能枚举并与 bootloader 通信 |
| pk2cmd | PICkit 3 烧写:-I 读 ID、-C blank check 通过 |
| avrdude | Arduino Uno(CH340)读写校验全通 |
纯 JS 通道:给 MCP / node_repl 进程用
兼容层是 LD_PRELOAD 注入的,而 Electron-as-Node 子进程(MCP server 的运行时)带上它就会
在加载阶段空转——实测连 OHB_PASSTHROUGH=1(全部 hook 关闭)也照样挂死,说明冲突不在某条
拦截逻辑,而在符号介入本身。所以"MCP 进程也要 USB"不能靠预加载解决。
改走协议:ArkTS 侧本来就有行协议服务(UsbBridgeServer,应用沙箱内 unix socket),
任何同进程树的 node 进程都能连。scripts/usb.mjs 是它的 JS 客户端,无原生依赖:
const usb = await import('file:///data/storage/el2/base/cache/usb.mjs');
const devs = await usb.list(); // [{bus,addr,vid,pid,class,name,serial,ifaces}]
const { handle } = await usb.open(devs[0].bus, devs[0].addr);
await usb.claim(handle, 0, true);
const { data } = await usb.bulkIn(handle, 0x81, 64, 1000);
await usb.bulkOut(handle, 0x02, Buffer.from('hello'));
await usb.close(handle);
也可当命令用:node usb.mjs list | desc <bus> <addr> | serial-list。模块由构建期从
scripts/usb.mjs 复制进包,启动时落到 el2 缓存,因此路径稳定。命令集与 UsbBridgeServer
一一对应,新命令可以直接 raw("<line>") 先试。
四、串口(Arduino 等 USB-串口设备)
系统串口服务(API 19 的 @ohos.usbManager.serial 与 API 26 的 @ohos.busManager.serial)
实测都不枚举 CH34x(getPortList() 均返回 0),所以兼容层自带一层虚拟串口:
- 拦截
open("/dev/ttyUSB0")→ 返回 pty 从端; - 主端与设备的 bulk 端点对接,termios 的波特率/DTR 翻译成 CH34x 厂商请求
(
0xA1/0x9A/0xA4,分频算法对齐 Linuxch341.c); - 数据面由 ArkTS 侧常驻读循环写进 FIFO,C 侧顺序读——避免逐包 request/response 的往返延迟。
效果:avrdude 一行不改即可在平板上烧写 Uno:
LD_LIBRARY_PATH=<shim 目录> LD_PRELOAD=/data/storage/el1/bundle/libs/arm64/libusbcompat.so \
./avrdude -c arduino -p m328p -P /dev/ttyUSB0 -U flash:w:sketch.hex:i
实测读 32KB 6.2s、写 32KB 7.6s 并回读校验通过。.ino 也可以在平板内编译(Alpine 的
gcc-avr + ArduinoCore-avr),见 skill arduino-build-flash;免 root 交叉编译 avrdude 见
scripts/build-avrdude-cross.sh。
五、维护这层的硬约束
都是实测踩出来的,改动前请确认不会破坏:
- claim 顺序:先
requestRight,再usbManager.claimInterface,失败才落回 raw ioctl。 只做 raw 会"假成功"(rc=0 只是 usb_service 记账),随后 bulk 被内核判EACCES。 - 不要用
SET_CONFIGURATION(0)解绑驱动:会触发设备重新枚举(地址 1-6→1-7→…), 程序缓存的 fd 当场失效。恢复只能按 VID/PID 重开设备。 - 阻塞式
USBDEVFS_REAPURB只能等 URB 完成或 fd 被关闭,任何时间上限都会被上层 判成读失败(adb 会直接拆 transport)。 - 伪造的
manufacturer/product/serial不能为空串:有些程序直接塞进printf("%s"),空指针即段错误(adb 的 device poll 线程崩过)。 usbManager.bulkTransfer的"无数据"返回取决于请求长度:请求恰好等于端点包大小 (32)时返回一堆假零,请求 ≥64 时返回 -1。串口这类连续数据必须用大缓冲(512)。- 包外 .so 必须自签名:包内(
el1/bundle)免签;可写目录未签则 dlopenPermission denied、LD_PRELOAD完全静默失效。见 skillohos-elf-sign。 usbManager的 fd 不是完整 usbfs fd:CLAIMINTERFACE可用,但CONTROL返回ENOTTY、BULK返回EACCES—— 串口数据无法在 C 侧直发,必须经 ArkTS。
调试可以用随包部署的 node <resfile>/usbctl.mjs list|open|claim|bulk 直接观察通道状态。
六、DDK 不是这里的通路
ACCESS_DDK_USB 的 UsbDdk 只能在 DriverExtensionAbility 内调用,且其 metadata 的
vid/pid 白名单不支持通配符;运行时按 deviceId 绑定所需的 ACCESS_DDK_DRIVERS 在调试
签名下装不上。需要"设备固定 + 白名单写死"的专项驱动时再用。