把安卓手机当成STM32的调试上位机,这事儿听起来挺折腾,但实际做下来会发现,它比想象中简单,而且非常实用。尤其当你做便携式设备、野外调试,或者不想抱着笔记本跑来跑去的时候,手机通过USB串口直接跟单片机通讯,能省掉一大半麻烦。这个项目解决的核心问题就一个:让安卓设备识别并操作USB转串口芯片,打通数据链路,实现双向通讯。
这篇文章我会把硬件连接、安卓端USB Host模式原理、串口库选型、STM32固件配置、联调排错整个流程拆开讲清楚。内容偏向实际落地,适合正在做安卓工控APP、嵌入式调试工具、或者搞物联网网关的朋友参考,新手也能按步骤跟着做,不会一上来就劝退。
1. 项目整体架构与硬件准备
1.1 通讯链路的基本构成
整条链路从数据流向上看其实很清晰:STM32单片机通过USART串口引脚输出TTL电平信号,接到USB转串口模块(比如CH340、CP2102、FT232),模块再把TTL电平转换成USB协议信号,通过OTG线进入安卓设备的USB口。安卓端在USB Host模式下枚举到这个设备,由应用层库完成数据的读取和写入。
听起来环节不少,但每一步都是成熟方案。真正需要花心思的地方有两个:一是安卓端如何拿到USB设备的读写权限,二是STM32端如何稳定地收发数据不丢字节。这两个问题后面会重点展开。
1.2 硬件清单与选型心得
我实际用的是这几样东西,你可以直接照抄配置:
| 器件 | 型号/规格 | 说明 |
|---|---|---|
| 安卓设备 | 任意支持OTG的手机或平板 | 系统建议Android 5.0以上,USB Host API更稳定 |
| OTG线 | 标准Micro USB/Type-C转USB-A母口 | 注意手机是Micro还是Type-C,别买错 |
| USB转TTL模块 | CH340G或CP2102模块 | CH340便宜,CP2102兼容性略好 |
| STM32开发板 | STM32F103C8T6最小系统板 | 经典中的经典,资料多,便宜耐造 |
| 杜邦线 | 母对母若干 | 连接模块与开发板 |
有个选型细节值得说:USB转TTL模块的供电跳线。很多模块上有3.3V和5V的跳线帽选择,如果STM32开发板是3.3V逻辑,模块跳线就选3.3V,避免IO口电平不匹配。虽然STM32F103大部分IO是容忍5V的,但稳妥起见,电平一致能少很多玄学问题。
1.3 为什么选择USB串口而不是蓝牙或WiFi
你可能会有疑问:现在蓝牙模块那么便宜,WiFi也普及,为什么还要用USB串口?我的回答是:稳定性和实时性。USB串口是纯硬件链路,没有无线协议栈的延迟和干扰问题。如果你要调试的是工业设备、传感器数据采集,或者要跑Modbus这类时序敏感协议,USB串口比蓝牙靠谱得多。
另外,USB串口还有一个隐藏优势:同时给安卓设备供电,不用额外给手机准备充电宝。OTG线加上USB HUB甚至能实现边充电边通讯,对长时间运行的测试场景非常友好。
2. 安卓端:从零跑通USB串口通讯
2.1 USB Host模式原理解析
安卓设备作为USB主机,是在Android 3.1(API 12)以后才正式支持的。Android通过UsbManager类管理USB设备,系统会负责枚举连接到OTG口的USB设备,并把设备信息以UsbDevice对象的形式暴露给应用层。
但这里有个关键问题:安卓系统本身并不认识CH340、CP2102这类USB转串口芯片。它只知道插入了一个USB设备,却不知道这个设备是干什么的、该怎么读写。真正的串口数据转换,需要应用层通过USB的bulk端点(批量传输端点)来读写。幸运的是,开源社区已经把这些脏活累活封装好了,不需要你直接操作端点。
2.2 串口库选型:为什么不自己写
我最早尝试过直接用UsbDeviceConnection的bulkTransfer方法做数据收发,写了一半就放弃了。因为你要自己处理USB控制传输、端点地址解析、串口线路参数设置,还要应对不同芯片的差异,工作量非常大而且极易出错。
后来换成了GitHub上的mik3y/usb-serial-for-android库(简称UsbSerial),项目瞬间清爽了。这个库封装了CH340、CP2102、FT232、PL2303等常见USB转串口芯片的驱动,统一的UsbSerialPort接口,操作起来和普通Java的InputStream/OutputStream一样简单。
依赖添加也很简单,在build.gradle里加入:
implementation 'com.github.mik3y:usb-serial-for-android:3.7.0'2.3 权限申请与设备插拔监听
安卓的USB设备访问是受权限保护的,即使你的App有所有运行时权限,也不能直接打开USB设备。必须在用户点击“允许”之后,才能建立连接。这段流程每次插拔设备后都要走一遍。
// 获取UsbManager实例 UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); // 查找目标设备(根据vendorId和productId过滤) HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); UsbDevice device = null; for (UsbDevice usbDevice : deviceList.values()) { if (usbDevice.getVendorId() == 0x1A86) { // CH340的VendorID device = usbDevice; break; } } // 申请权限 if (device != null) { if (usbManager.hasPermission(device)) { openSerialPort(device); } else { PendingIntent permissionIntent = PendingIntent.getBroadcast( this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_MUTABLE); usbManager.requestPermission(device, permissionIntent); } }权限申请的结果通过广播回调。这里有一个非常容易踩的坑:PendingIntent的FLAG_MUTABLE标志。Android 12(API 31)以后强制要求显式指定可变性,如果代码里不写,应用会直接崩溃。我在项目里就是在这里吃过亏,系统日志报的是SecurityException,排查半天才发现是PendingIntent的标志位问题。
2.4 串口参数配置与数据收发
拿到权限后,要先把UsbDeviceConnection和UsbSerialPort建立起来,再配置波特率等参数:
UsbSerialPort port = null; try { UsbDeviceConnection connection = usbManager.openDevice(device); port = new CH340SerialDriver(device).getPorts().get(0); port.open(connection); port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE); } catch (IOException e) { e.printStackTrace(); }这里有几个参数需要你重点理解,不只是设个值就完了:
- 波特率:必须和STM32端的USART配置一致,否则收到的就是乱码。我习惯用115200,因为这是大部分调试工具(比如串口助手)的默认值,联调时省心。
- 数据位:串口协议里数据位通常是8,对应一个字节,也是STM32 HAL库的默认配置。
- 停止位:1个停止位是最常见的选择。
- 校验位:常用无校验,如果做工业通讯需要校验,可以选偶校验,这也需要在两端保持一致。
读写数据用port.read()和port.write(),但这两个方法在UsbSerial库里是同步阻塞的。如果你在UI线程里调用,会把界面卡死。建议单独开一个工作线程做数据读写,或者用ExecutorService管理。
// 写入数据 byte[] data = {0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xC5, 0xCD}; port.write(data, 1000); // 1秒超时 // 读取数据(在子线程中) byte[] buffer = new byte[1024]; int len = port.read(buffer, 1000); // 阻塞最多1秒 if (len > 0) { // 处理收到的数据 }需要注意的是,read()返回的len可能小于buffer长度,这是正常现象。串口是流式数据,没有固定的包边界,你需要自己的协议来判定一帧数据的开始和结束。
3. STM32端:串口配置与固件编写
3.1 硬件连接与电平匹配
STM32端接线很简单,但也很容易接反。USB转TTL模块的TXD要接STM32的RX,模块的RXD要接STM32的TX。注意是交叉连接,不是直连。我见过好几个朋友第一次接的时候TXD接TXD,结果数据怎么都跑不通。
以STM32F103C8T6最小系统板为例,我使用USART2,引脚是:
- PA2(USART2_TX)→ 模块RXD
- PA3(USART2_RX)→ 模块TXD
- GND → 模块GND
GND必须共地,这是很多人忽略的细节。如果不共地,信号电平没有参考基准,通讯大概率失败,表现是数据全乱,或者完全收不到。另外,如果模块是从USB取电给STM32供电,那共地是自动的;如果STM32是独立供电,一定要单独接一根GND线。
3.2 使用STM32CubeMX配置串口
STM32CubeMX是ST官方的图形化配置工具,用它能省去大量手写初始化代码的时间。我习惯先CubeMX生成工程,再在生成的代码基础上加业务逻辑。
关键配置项如下:
- 选择芯片型号:STM32F103C8T6
- 时钟树:将系统主频配置为72MHz(外部8MHz晶振9倍频)
- USART2模式选择:
Asynchronous(异步通讯) - 参数配置:波特率115200,数据位8,无校验,停止位1
- 使能USART2全局中断:在NVIC设置中勾选USART2 global interrupt
生成代码后,CubeMX会自动创建huart2实例。接下来要做的就是在main函数里启动串口接收中断。
3.3 中断接收与数据回传
串口接收最基础的做法是中断方式,也就是每收到一个字节就触发一次中断,在中断回调函数里处理数据。这样做的好处是CPU不用轮询等待,节省资源。
在main.c的main函数中加入:
HAL_UART_Receive_IT(&huart2, &rx_data, 1);这行代码的意思是:开启USART2的接收中断,每次收到1个字节就触发回调。rx_data是一个全局变量,用来暂存收到的字节。
然后在stm32f1xx_it.c或main.c中实现回调函数:
uint8_t rx_data = 0; uint8_t rx_buffer[256]; uint16_t rx_index = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { rx_buffer[rx_index++] = rx_data; // 简单回显,测试用 HAL_UART_Transmit(&huart2, &rx_data, 1, 100); // 重新开启接收中断 HAL_UART_Receive_IT(&huart2, &rx_data, 1); } }这里的逻辑是:收到一个字节,存进缓冲区,同时原样回显给安卓端,然后重新开启中断等待下一个字节。rx_data必须定义成全局变量,因为中断回调是在中断上下文执行的,不能使用局部变量。
3.4 防止数据丢失的缓冲区设计
上面这种一字节一回调的做法,在低波特率下没问题,但如果数据量大、速率快,频繁中断会拖累系统。更优的做法是使用环形缓冲区(Ring Buffer),在中断回调中只做“放入缓冲区”这件事,主循环里再做数据解析和处理。
环形缓冲区的核心思想:一个固定大小的数组,通过头尾指针实现循环读写,头指针是写入位置,尾指针是读取位置。当缓冲区满时,可以选择覆盖旧数据或丢弃新数据,根据业务需求来定。
#define RX_BUFFER_SIZE 512 typedef struct { uint8_t buffer[RX_BUFFER_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuffer; RingBuffer rx_ring; void ring_buffer_write(RingBuffer *rb, uint8_t data) { uint16_t next_head = (rb->head + 1) % RX_BUFFER_SIZE; if (next_head != rb->tail) { // 缓冲未满 rb->buffer[rb->head] = data; rb->head = next_head; } // 如果满了,丢弃该字节 } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { ring_buffer_write(&rx_ring, rx_data); HAL_UART_Receive_IT(&huart2, &rx_data, 1); } }主循环里通过检查head != tail来判断是否有新数据到达,然后按自己的协议解析。这种设计把“接收”和“处理”解耦,即使主循环在处理其他任务(比如驱动LED),串口数据也不会丢,除非缓冲区真的满了。
3.5 一个完整的Modbus命令响应示例
做工业通讯的话,Modbus RTU协议用的非常多。下面给出一个简单的协议解析例子:安卓端发送01 03 00 00 00 0A,STM32返回对应地址的数据。
void parse_rx_data(uint8_t *data, uint16_t len) { if (len < 8) return; if (data[0] == 0x01 && data[1] == 0x03) { // 读保持寄存器命令,返回20字节数据(10个寄存器) uint8_t response[23]; response[0] = 0x01; // 从机地址 response[1] = 0x03; // 功能码 response[2] = 20; // 数据字节数 for (int i = 0; i < 10; i++) { response[3 + i*2] = (uint8_t)(regs[i] >> 8); response[4 + i*2] = (uint8_t)(regs[i] & 0xFF); } // 计算CRC16(Modbus),这里省略具体实现 uint16_t crc = modbus_crc16(response, 23); response[21] = crc & 0xFF; response[22] = crc >> 8; HAL_UART_Transmit(&huart2, response, 23, 100); } }Modbus CRC16的计算网上有很多现成实现,但要注意高低字节的顺序:发送时先低字节后高字节。这也是一个容易犯错的点,如果不注意顺序,从机端会一直报CRC错误。
4. 联调过程与常见问题排查
4.1 联调步骤实录
准备工作做完,就到了最激动人心的联调环节。我的建议是:先分开测试,再连起来测。不要一上来就把安卓和STM32直接连起来,不然出问题你都不知道是哪一端的锅。
第一步,先用USB转TTL模块接电脑,用串口助手(我用的XCOM)测试STM32的串口回显功能。如果电脑上能正常看到STM32发回来的数据,说明STM32端没问题。
第二步,把USB转TTL模块从电脑拔下来,插到安卓手机上。注意看手机上会不会弹出“允许访问USB设备”的提示。如果弹出来了,点允许;如果没弹,说明你的App没有正确注册USB权限广播,需要回头检查代码。
第三步,在安卓App里发一条测试指令(比如一个简单的握手协议),看STM32那边是否收到。如果收到正确数据,并且STM32返回的数据也能在App里读到,恭喜,链路打通了。
4.2 常见问题速查表
说实话,这个项目90%的问题都出在几个固定的地方,我把它们整理成一张表,你照着排查就行:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 手机不弹USB权限框 | USB枚举失败或App未注册 | 检查OTG线是否支持数据;检查App的USB权限广播注册代码 |
| 权限允许后打不开设备 | 设备被其他App占用 | 关闭其他串口App,或先拔插USB线释放设备 |
| 收到全是乱码 | 波特率不一致 | 确认两端波特率都是115200 |
| 能发不能收 | TX/RX接反 | 交叉连接,模块TXD接STM32的RX,模块RXD接STM32的TX |
| 能收不能发 | STM32未开启发送中断 | 检查HAL_UART_Transmit的使用是否正确;检查发送缓冲区是否被占满 |
| 数据丢字节 | 缓冲区太小或处理太慢 | 加大环形缓冲区;把数据处理移到主循环 |
| 打开串口报Device not found | 驱动不匹配 | 确认UsbSerial库里包含对应芯片的Driver类 |
| App崩溃闪退 | Android 12 PendingIntent标志位 | 使用PendingIntent.FLAG_MUTABLE或FLAG_IMMUTABLE |
这里特别说一下“能发不能收”的情况,我遇到过很多次。STM32端以为自己在发送数据,但安卓端就是收不到。排查思路是这样的:先用手机上的串口调试App(比如“串口调试助手”)测试,如果调试App能收到,说明硬件和驱动没问题,问题在你的App代码里;如果调试App也收不到,检查模块的TXD引脚是否真的有信号——用万用表量一下空闲电平应该接近3.3V。
4.3 几个必须避开的坑
第一个坑:USB转串口模块的供电不足。有些劣质OTG线或者旧手机的USB口输出电流只有100mA左右,带不动CH340模块和STM32同时工作。表现是模块可以枚举成功,但是一通讯就卡死或丢数据。解决方案是:用带外部供电的USB HUB,或者单独给STM32供电。
第二个坑:CH340和CP2102的驱动差异。CH340的VendorID是0x1A86,ProductID是0x7523;CP2102的VendorID是0x10C4,ProductID是0xEA60。UsbSerial库虽然都支持,但Driver类注册的顺序有讲究。如果两个设备同时插入,有些版本可能识别到错误的设备。
第三个坑,也是容易被忽略的:USB线缆的质量。很多安卓设备的充电线内部只有电源线,没有数据线,插上去根本不会有反应。这就是为什么联调前要先拿数据线连电脑测试,确认线的数据功能正常再上手机。
第四个坑:串口接收缓冲区的溢出。如果你在STM32中断里做太多事情(比如打印日志、浮点运算),会导致接收中断响应不及时,数据被USART硬件FIFO覆盖。我的建议是中断回调里只做入队操作,其余逻辑一律放主循环。
4.4 性能优化与后续扩展方向
如果通讯频率高、数据量大,有几个优化方向值得尝试:
使用DMA接收代替中断接收。STM32的USART支持DMA传输,可以把串口数据直接搬到内存缓冲区,几乎不占CPU。配置方法是在CubeMX里把USART2的模式改成Asynchronous + DMA,然后使能RX的DMA通道。DMA接收需要配合空闲中断(IDLE Line)来判定一帧数据的结束,具体实现我在另一个项目里有整理过,这里先提一嘴,有需要的朋友可以深挖。
安卓端也可以考虑使用UsbSerialProber来自动探测设备,这样不用硬编码设备ID:
List<UsbSerialDriver> drivers = UsbSerialProber.getDefaultProber() .findAllDrivers(usbManager); if (!drivers.isEmpty()) { UsbSerialDriver driver = drivers.get(0); UsbSerialPort port = driver.getPorts().get(0); }这种方式对用户更友好,尤其是你打算把App给别人用的时候,不同硬件厂商的产品都能自动识别。
最后再分享一点个人体会
做这个项目的过程中,我最大的感触是:嵌入式调试工具永远是“能用”和“好用”之间差着十万八千里。安卓USB串口通讯这个方案,论速度比不过仿真器,论方便比不过无线,但在“现场调试、快速部署、便携操作”这些场景下,它是无可替代的。我后来做的几个设备维护工具类App,基本都沿用这套架构:一个手机、一根OTG线、一个USB转TTL模块,走遍现场都不怕。
如果你按照上面的步骤做通了,你手头就相当于有了一套非常灵活的软硬件调试平台。后续可以在此基础上继续扩展——比如加入数据图表显示、协议解析框架、甚至远程日志上传。关键是先把第一步打通,链路通了,后面的想象力就大了。