《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;
}