外设访问(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,分频算法对齐 Linux ch341.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。


五、维护这层的硬约束

都是实测踩出来的,改动前请确认不会破坏:

  1. claim 顺序:先 requestRight,再 usbManager.claimInterface,失败才落回 raw ioctl。 只做 raw 会"假成功"(rc=0 只是 usb_service 记账),随后 bulk 被内核判 EACCES。
  2. 不要用 SET_CONFIGURATION(0) 解绑驱动:会触发设备重新枚举(地址 1-6→1-7→…), 程序缓存的 fd 当场失效。恢复只能按 VID/PID 重开设备。
  3. 阻塞式 USBDEVFS_REAPURB 只能等 URB 完成或 fd 被关闭,任何时间上限都会被上层 判成读失败(adb 会直接拆 transport)。
  4. 伪造的 manufacturer / product / serial 不能为空串:有些程序直接塞进 printf("%s"),空指针即段错误(adb 的 device poll 线程崩过)。
  5. usbManager.bulkTransfer 的"无数据"返回取决于请求长度:请求恰好等于端点包大小 (32)时返回一堆假零,请求 ≥64 时返回 -1。串口这类连续数据必须用大缓冲(512)。
  6. 包外 .so 必须自签名:包内(el1/bundle)免签;可写目录未签则 dlopen Permission denied、LD_PRELOAD 完全静默失效。见 skill ohos-elf-sign。
  7. 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 在调试 签名下装不上。需要"设备固定 + 白名单写死"的专项驱动时再用。