7、USB串口通信开发:CDC ACM协议原理、Android端USB转串口库使用、串口参数配置、实战连接Arduino/STM32
好,咱们进入第七章。这一章聊的是USB串口通信,说白了就是让Android手机跟单片机(比如Arduino、STM32)通过USB线“聊天”。很多做嵌入式的小伙伴,一开始都觉得这玩意儿很神秘——手机怎么就能跟单片机传数据了呢?其实背后的原理并不复杂,就是CDC ACM协议在起作用。
7.1 CDC ACM协议原理:手机怎么“假装”成串口
CDC是USB设备类规范里的一个子类,全称是Communication Device Class。ACM是Abstract Control Model,抽象控制模型。合起来就是:通过USB模拟出一个串口。
我当年第一次接触这个,是在做一个工业数据采集项目。客户要求用手机直接读取PLC的数据,PLC那边走的是RS232串口。我当时想,总不能给手机焊个DB9接头吧?后来查资料才发现,USB转串口芯片(比如CH340、CP2102、FT232)内部都实现了CDC ACM协议。手机端不需要额外驱动,系统自带就能识别。
CDC ACM的核心逻辑其实就两点:
- 枚举阶段:设备告诉主机“我是一个CDC设备”,主机加载对应的usbserial驱动。
- 数据传输:通过USB的Bulk端点(批量传输端点)收发数据,数据格式就是标准的串口帧。
你想想看,USB是主从架构,手机是Host,单片机是Device。单片机里的USB转串口芯片,把UART信号转成USB信号。手机这边,Android系统通过USB Host API拿到设备句柄,然后通过CDC ACM驱动把USB包解析成字节流。嗯,就是这么回事。
关键点:CDC ACM协议本身不关心波特率、数据位这些参数。这些参数是在串口层面配置的,USB只负责传输原始字节。所以你在Android端配置串口参数,实际上是配置USB转串口芯片内部的UART寄存器。
7.2 Android端USB转串口库:usb-serial-for-android
在Android上做USB串口开发,最常用的开源库就是 usb-serial-for-android。这个库由 mik3y 维护,封装了CDC ACM、FTDI、CP2102、CH340等常见芯片的驱动。我个人习惯直接用它,省去了自己写USB枚举和Bulk传输的麻烦。
我记得第一次用这个库,是在一个智能家居项目里。手机要跟STM32通信,控制家里的灯光和窗帘。当时我踩了一个坑——没有在AndroidManifest里声明USB权限,结果插上设备毫无反应。后来加上权限声明和动态请求,才跑通。
7.2.1 集成方式
在 build.gradle 里添加依赖:
dependencies {
implementation 'com.github.mik3y:usb-serial-for-android:3.7.0'
}
然后在AndroidManifest里声明USB权限:
<uses-feature android:name="android.hardware.usb.host" />
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
设备过滤器 res/xml/device_filter.xml 里指定VID/PID:
<resources>
<usb-device vendor-id="1027" product-id="24577" /> <!-- 举例:CH340 -->
</resources>
小提示:如果你不确定设备的VID/PID,可以在Linux上用 lsusb 命令查看,或者在Windows设备管理器里看硬件ID。我曾经因为写错了PID,折腾了整整一下午。
7.2.2 核心API使用
库的使用流程很清晰:枚举设备 → 请求权限 → 打开串口 → 配置参数 → 读写数据。我贴一段核心代码:
// 1. 获取USB管理器
UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE);
// 2. 枚举设备
List<UsbSerialDriver> availableDrivers = UsbSerialProber.getDefaultProber()
.findAllDrivers(usbManager);
if (availableDrivers.isEmpty()) {
// 没有找到设备
return;
}
UsbSerialDriver driver = availableDrivers.get(0);
// 3. 请求权限(需要动态请求)
PendingIntent permissionIntent = PendingIntent.getBroadcast(this, 0,
new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE);
usbManager.requestPermission(driver.getDevice(), permissionIntent);
// 4. 打开串口
UsbDeviceConnection connection = usbManager.openDevice(driver.getDevice());
if (connection == null) {
// 权限被拒绝
return;
}
UsbSerialPort port = driver.getPorts().get(0);
port.open(connection);
// 5. 配置串口参数
port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
// 6. 读写数据
byte[] sendData = "Hello STM32!".getBytes();
port.write(sendData, 1000); // 超时1秒
byte[] buffer = new byte[1024];
int len = port.read(buffer, 1000);
String received = new String(buffer, 0, len);
7.3 串口参数配置:波特率、数据位、停止位、校验位
串口通信的参数配置,说白了就是让两端“说同一种语言”。如果两边参数不匹配,收到的就是乱码。我在项目中遇到过好几次这种问题——明明线接对了,数据就是不对,最后发现是波特率设错了。
| 参数 | 常见值 | 说明 |
|---|---|---|
| 波特率 | 9600, 115200, 921600 | 每秒传输的比特数。115200是常用值,短距离通信没问题。 |
| 数据位 | 7, 8 | 一帧数据中的有效数据位数。绝大多数场景用8位。 |
| 停止位 | 1, 1.5, 2 | 帧结束标志位。一般用1位停止位。 |
| 校验位 | None, Even, Odd | 奇偶校验。简单通信用None,要求高可用Even。 |
你想想看,为什么波特率要匹配?因为发送端和接收端的时钟频率不同,如果不一致,采样点就会偏移,导致误码。我曾经在调试一个STM32项目时,把波特率设成了9600,但Arduino那边默认是115200,结果收了一堆乱码。排查了半小时才发现是这个问题。
注意:Android端的波特率设置,最终会通过USB控制请求写入到USB转串口芯片的UART寄存器中。不同芯片支持的波特率范围不同,CH340最高支持到2Mbps,FT232可以到3Mbps。但实际通信中,建议不要超过921600,否则容易丢包。
7.4 实战:连接Arduino/STM32进行数据收发
好,理论讲完了,咱们来点实际的。我以Arduino Uno为例,演示如何跟Android手机通信。STM32的原理完全一样,只是单片机端的代码不同。
7.4.1 Arduino端代码
Arduino上写一个简单的回显程序:收到什么就发回什么。
void setup() {
Serial.begin(115200); // 波特率必须与Android端一致
}
void loop() {
if (Serial.available() > 0) {
char c = Serial.read();
Serial.write(c); // 回显
}
}
7.4.2 Android端完整收发逻辑
在Android端,我建议把串口操作封装成一个独立的线程,避免阻塞UI。下面是一个简单的收发示例:
// 在子线程中执行
new Thread(() -> {
try {
// 打开串口(代码同上)
port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
// 发送数据
String cmd = "AT+STATUS\r\n";
port.write(cmd.getBytes(), 1000);
Log.d("Serial", "发送: " + cmd);
// 接收数据(循环读取)
byte[] buf = new byte[1024];
int len = port.read(buf, 2000); // 等待2秒
if (len > 0) {
String response = new String(buf, 0, len);
Log.d("Serial", "收到: " + response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
port.close();
} catch (IOException ignored) {}
}
}).start();
实战经验:我建议你在接收数据时,不要一次性读太多。因为USB传输有延迟,单片机可能还没发完。更好的做法是用一个循环,每次读一小段,直到超时或读到结束符。比如这样:
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[64];
int len;
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < 2000) {
len = port.read(buf, 100);
if (len > 0) {
baos.write(buf, 0, len);
start = System.currentTimeMillis(); // 有数据就重置超时
}
}
String fullResponse = baos.toString();
7.4.3 常见问题与避坑
- 设备无法识别:检查USB线是否支持数据传输(有些充电线只有电源线)。我曾经被一根劣质线坑过,换了线就好了。
- 权限请求失败:确保在AndroidManifest里声明了USB Host权限,并且动态请求权限时用户点击了“允许”。
- 乱码:先检查波特率是否匹配,再检查数据位和停止位。如果还不行,可能是编码问题——Android端用UTF-8,单片机用ASCII,一般没问题,但如果有中文就要注意了。
- 数据丢包:USB批量传输有最大包长限制(通常是64字节)。如果一次发送大量数据,需要分包。库内部已经处理了分包,但接收端要注意组包。
嗯,这一章的内容就这些。CDC ACM协议其实不复杂,核心就是USB转串口芯片在中间做翻译。Android端用usb-serial-for-android库,几行代码就能实现收发。关键是参数要配对,逻辑要严谨。你在实际项目中遇到问题,可以多看看库的源码,里面有很多细节处理值得学习。