《HarmonyOS技术精讲-Basic Services Kit》USB服务:设备发现与通信

《HarmonyOS技术精讲-Basic Services Kit(基础服务)》USB服务:设备发现与通信

USB服务是HarmonyOS Basic Services Kit中经常被误解的一个模块。很多人以为接上U盘就能直接用fileIO读文件,但应用沙箱机制并不允许直接访问外部存储。真正能用的方案是通过USB服务自行实现大容量存储协议------也就是手动发送SCSI命令,把U盘当成一个"黑盒设备"来读写扇区。

这个例子本身能跑,但放到实际项目里还需要处理很多细节。本文将从设备枚举、权限请求、控制传输到批量传输,一步步实现检测U盘并读取根目录文件列表的功能。

它解决了什么问题

USB大容量存储设备(U盘、移动硬盘)在HarmonyOS设备上可以通过USB Host模式直接通信。系统不提供"挂载成文件系统再读文件"的途径,所以必须自己实现SCSI命令和文件系统解析。

适用场景:

  • OTG U盘数据采集(如读取传感器记录文件)
  • 嵌入式设备固件升级
  • 定制化文件管理工具

不适用场景(有更好方案):

  • 简单的文件复制:建议用文件选择器(Picker)让用户主动选择文件
  • 打印机或键盘:有专门的服务Kit

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机(支持USB OTG)

核心实现:从枚举到读取文件列表

第一步:添加权限与导入模块

entry/src/main/module.json5中添加USB权限:

json 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.USB",
        "reason": "需要访问USB设备"
      }
    ]
  }
}

在代码中导入usbManager

typescript 复制代码
import { usbManager } from '@kit.BasicServicesKit';

第二步:枚举USB设备并筛选存储设备

usbManager.getDevices()返回所有已连接的USB设备。我们需要根据bInterfaceClass == 0x08(Mass Storage)过滤。

typescript 复制代码
function getMassStorageDevices(): Array<usbManager.USBDevice> {
  const devices = usbManager.getDevices();
  const storageDevices: Array<usbManager.USBDevice> = [];
  for (const device of devices) {
    for (const config of device.configs) {
      for (const iface of config.interfaces) {
        if (iface.bInterfaceClass === 0x08) {
          storageDevices.push(device);
          break;
        }
      }
    }
  }
  return storageDevices;
}

这段代码遍历每个设备的配置描述符,找到接口类为Mass Storage的U盘设备。注意一个设备可能包含多个配置,通常取第一个即可。

第三步:请求设备权限

只有获取权限后才能与设备通信。requestDevicePermission会弹出系统弹窗:

typescript 复制代码
async function requestUsbPermission(device: usbManager.USBDevice): Promise<void> {
  try {
    await usbManager.requestDevicePermission(device.deviceId);
    console.info('权限获取成功');
  } catch (error) {
    console.error('权限获取失败:' + JSON.stringify(error));
    throw error;
  }
}

权限是一次性的,应用被杀死后需要重新请求。

第四步:声明接口(claimInterface)

声明接口后才能进行通信。通常在claimInterface后系统会锁定该接口,其他应用无法同时使用。

typescript 复制代码
function claimMassStorageInterface(device: usbManager.USBDevice): usbManager.USBInterface | undefined {
  for (const config of device.configs) {
    for (const iface of config.interfaces) {
      if (iface.bInterfaceClass === 0x08) {
        usbManager.claimInterface(device.deviceId, iface.id, true);
        return iface;
      }
    }
  }
  return undefined;
}

第二个参数force设为true表示强制声明,如果接口已被其他应用使用会失败。实际项目中建议先检查再声明。

第五步:获取端点地址

存储设备通常有一个批量输入端点(设备到主机)和一个批量输出端点(主机到设备)。需要从接口描述符中提取:

typescript 复制代码
interface EndpointInfo {
  bulkInEndpoint: number;
  bulkOutEndpoint: number;
}

function getBulkEndpoints(iface: usbManager.USBInterface): EndpointInfo {
  let bulkIn = -1;
  let bulkOut = -1;
  for (const endpoint of iface.endpoints) {
    const direction = (endpoint.bEndpointAddress & 0x80) >> 7;
    const transferType = endpoint.bmAttributes & 0x03;
    if (transferType === 0x02) { // Bulk
      if (direction === 1) {
        bulkIn = endpoint.bEndpointAddress;
      } else {
        bulkOut = endpoint.bEndpointAddress;
      }
    }
  }
  if (bulkIn === -1 || bulkOut === -1) {
    throw new Error('未找到批量端点');
  }
  return { bulkInEndpoint: bulkIn, bulkOutEndpoint: bulkOut };
}

bEndpointAddress的低7位是端点号,最高位表示方向(1=输入)。

第六步:控制传输获取设备描述符(可选)

虽然不通过控制传输也能通信,但可以用来验证设备连接状态。这里演示获取设备描述符中的厂商ID和产品ID:

typescript 复制代码
async function getDeviceDescriptor(device: usbManager.USBDevice): Promise<Uint8Array> {
  const buffer = new Uint8Array(18); // 标准设备描述符长度18字节
  const result = usbManager.controlTransfer(
    device.deviceId,
    0x80,        // bmRequestType: 设备到主机
    0x06,        // bRequest: GET_DESCRIPTOR
    0x0100,      // wValue: 设备描述符类型(1) << 8 | 索引0
    0x0000,      // wIndex: 语言ID (0)
    buffer
  );
  if (result < 0) {
    throw new Error('控制传输失败');
  }
  return buffer;
}

controlTransfer的返回值是实际传输的字节数。注意wValue高字节为描述符类型,低字节为索引。

第七步:批量传输------发送SCSI INQUIRY命令

SCSI命令通过批量输出端点发送CBW(Command Block Wrapper),然后从批量输入端点接收CSW(Command Status Wrapper)和数据。

我们封装一个通用函数发送SCSI命令并接收响应:

typescript 复制代码
interface SCSIResponse {
  status: number; // 0成功
  data: Uint8Array;
}

async function sendSCSICommand(
  device: usbManager.USBDevice,
  endpointInfo: EndpointInfo,
  cdb: Uint8Array,     // 命令描述块,如INQUIRY的CDB为{0x12, 0, 0, 0, 36, 0}
  dataLength: number,
  direction: 'in' | 'out'
): Promise<SCSIResponse> {
  // 构建CBW
  const cbwLength = 31;
  const cbw = new Uint8Array(cbwLength);
  // CBW签名:0x43425355
  cbw[0] = 0x55; cbw[1] = 0x53; cbw[2] = 0x42; cbw[3] = 0x43;
  // dCBWTag:随便一个值
  cbw[4] = 0x01; cbw[5] = 0x00; cbw[6] = 0x00; cbw[7] = 0x00;
  // dCBWDataTransferLength(小端)
  const view = new DataView(cbw.buffer);
  view.setUint32(8, dataLength, true);
  // bmCBWFlags:0x00表示主机到设备,0x80表示设备到主机
  cbw[12] = direction === 'in' ? 0x80 : 0x00;
  // bCBWLUN:通常0
  cbw[13] = 0;
  // bCBWCBLength:CDB长度
  cbw[14] = cdb.length;
  // 填充CDB
  for (let i = 0; i < cdb.length; i++) {
    cbw[15 + i] = cdb[i];
  }

  // 发送CBW到批量输出端点
  usbManager.bulkTransfer(device.deviceId, endpointInfo.bulkOutEndpoint, cbw, 5000);

  let data: Uint8Array | undefined;
  if (dataLength > 0) {
    if (direction === 'in') {
      const buf = new Uint8Array(dataLength);
      const result = usbManager.bulkTransfer(device.deviceId, endpointInfo.bulkInEndpoint, buf, 5000);
      data = buf.slice(0, result);
    } else {
      // 写入数据到设备
    }
  }

  // 读取CSW(13字节)
  const csw = new Uint8Array(13);
  const cswResult = usbManager.bulkTransfer(device.deviceId, endpointInfo.bulkInEndpoint, csw, 5000);
  if (cswResult < 13) {
    throw new Error('CSW读取失败');
  }
  const status = csw[12]; // bCSWStatus: 0成功, 1失败, 2阶段错误
  return { status, data: data ?? new Uint8Array(0) };
}

注意bulkTransfer的返回值是实际传输字节数,非负表示成功。如果超时或出错会抛异常。

第八步:读取设备信息(INQUIRY与READ CAPACITY)

使用INQUIRY命令获取设备型号字符串:

typescript 复制代码
async function scsiInquiry(device: usbManager.USBDevice, endpoints: EndpointInfo): Promise<string> {
  const cdb = new Uint8Array([0x12, 0x00, 0x00, 0x00, 0x24, 0x00]);
  const response = await sendSCSICommand(device, endpoints, cdb, 36, 'in');
  if (response.status !== 0) {
    throw new Error('INQUIRY命令失败');
  }
  // 提取厂商信息(8字节)和产品信息(16字节)
  const vendor = String.fromCharCode(...response.data.slice(8, 16)).trim();
  const product = String.fromCharCode(...response.data.slice(16, 32)).trim();
  return `${vendor} ${product}`;
}

async function scsiReadCapacity(device: usbManager.USBDevice, endpoints: EndpointInfo): Promise<number> {
  const cdb = new Uint8Array([0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
  const response = await sendSCSICommand(device, endpoints, cdb, 8, 'in');
  if (response.status !== 0) {
    throw new Error('READ CAPACITY失败');
  }
  // 返回总扇区数(大端)
  const view = new DataView(response.data.buffer);
  const blocks = view.getUint32(0, false);
  const blockSize = view.getUint32(4, false);
  return blocks * blockSize;
}

第九步:读取根目录扇区并解析文件名

这是一个"足够简单"的解析:假定U盘是FAT32格式。我们读取第一个扇区(LBA 0)判断是否为MBR,然后找到第一个分区,再读取分区的引导扇区获取根目录信息。实际代码会很长,这里给出关键函数:

typescript 复制代码
async function readSector(device: usbManager.USBDevice, endpoints: EndpointInfo, lba: number, sectorSize: number): Promise<Uint8Array> {
  const cdb = new Uint8Array(10);
  cdb[0] = 0x28; // READ10
  cdb[2] = (lba >> 24) & 0xFF;
  cdb[3] = (lba >> 16) & 0xFF;
  cdb[4] = (lba >> 8) & 0xFF;
  cdb[5] = lba & 0xFF;
  cdb[7] = (sectorSize >> 8) & 0xFF;
  cdb[8] = sectorSize & 0xFF;
  const response = await sendSCSICommand(device, endpoints, cdb, sectorSize * 512, 'in');
  if (response.status !== 0) {
    throw new Error('READ10失败');
  }
  return response.data;
}

function parseFAT32RootDirectory(sectorData: Uint8Array): string[] {
  const files: string[] = [];
  // 每个目录项32字节
  for (let i = 0; i < sectorData.length; i += 32) {
    if (sectorData[i] === 0x00) break; // 空条目结束
    if (sectorData[i] === 0xE5) continue; // 已删除
    const attr = sectorData[i + 11];
    if (attr & 0x08) continue; // 卷标
    // 提取文件名(8.3格式)
    let name = '';
    for (let j = 0; j < 8; j++) {
      const c = sectorData[i + j];
      if (c === 0x20) break;
      name += String.fromCharCode(c);
    }
    const ext = [];
    for (let j = 8; j < 11; j++) {
      const c = sectorData[i + j];
      if (c === 0x20) break;
      ext.push(String.fromCharCode(c));
    }
    if (ext.length > 0) name += '.' + ext.join('');
    files.push(name);
  }
  return files;
}
相关推荐
程序员黑豆9 小时前
从零开始:编写第一个鸿蒙(HarmonyOS)程序
前端·harmonyos
ldsweet9 小时前
《HarmonyOS技术精讲-Basic Services Kit》升级服务:应用内更新检测与下载安装
华为·harmonyos
解局易否结局10 小时前
ArkUI MVI 架构实战:单向数据流设计模式在 HarmonyOS NEXT 中的深度实践
华为·设计模式·架构·harmonyos
信仰87410 小时前
HCIA-华为数通基础理论与实践10
网络·华为·智能路由器
TechEdu20260610 小时前
HarmonyOS鸿蒙操作系统:架构、分布式能力与生态概览
操作系统·harmonyos·计算机科学
信仰87410 小时前
HCIA-华为数通基础理论与实践06
网络·华为
2301_7681034910 小时前
HarmonyOS趣味相机实战第17篇:BackupExtensionAbility备份恢复与Preferences版本迁移
harmonyos·arkts·数据迁移·preferences·corefilekit
千逐6811 小时前
HarmonyOS 实战 | 数据存本地——Preferences 轻量存储入门
华为·harmonyos·鸿蒙
youtootech12 小时前
HarmonyOS《柚兔学伴》项目实战08-关系型数据库入门——待办事项持久化
jvm·数据库·华为·harmonyos