ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

安卓USB串口与STM32通讯全解析:从接线到数据帧协议

安卓USB串口与STM32通讯全解析:从接线到数据帧协议 做嵌入式项目的朋友大概率都遇到过这个场景手机是现成的显示终端STM32是手头最熟悉的主控两边想打通数据却发现“能想到的方案”里蓝牙要配模块、WiFi要折腾协议栈最后真正成本最低、链路最直观的反而是安卓底部的USB接口通过一颗几块钱的USB转串口芯片直接跟STM32的串口对接。这篇文章就来完整拆解“安卓设备通过USB串口与STM32单片机通讯”这个基础链路的落地过程。核心内容会覆盖方案选型、安卓侧USB Host与串口库的使用、STM32侧的串口配置与数据帧设计以及联调阶段最常踩的坑。适合正在做智能硬件调试工具、设备控制APP或者想给手头单片机项目配一个手机上位机的开发者参考。1. 项目整体设计与方案选型1.1 主流“安卓连接单片机”方案对比安卓设备与STM32之间传输数据市面上常见的有三条路线蓝牙串口模块、WiFi模块、USB串口线。我实际做过几个类似项目简单横向对比一下方案连接稳定性开发难度实时性典型成本适用场景蓝牙HC-05/HM-10中等易受干扰低串口透传中等有抖动10~30元短距离遥控、穿戴设备WiFiESP8266/ESP32高但依赖网络环境中高需处理TCP/MQTT中等15~40元数据上云、远程控制USB转串口很高有线物理连接低安卓侧有成熟库高近乎实时5~15元调试工具、本地高速数据采集如果你只是做“手机屏幕上实时显示传感器数据”或者“点按钮控制电机启停”USB串口方案是最省心的。蓝牙虽然省了一根线但协议层偶尔会出现延迟抖动排查起来比有线麻烦WiFi方案适合远程访问但前期要配置网络、做心跳维持工作量明显上一个台阶。USB方案的额外好处是调试时一根线同时解决了供电和通讯不用单独给模块配电源。1.2 USB转串口芯片选择与硬件接线USB转串口方案里芯片选型是第一个分层点。市面上最常见的是CH340、CP2102、FT232、PL2303这几种我挨个说下实际体验CH340国产芯片价格最便宜几块钱就能买到模块。驱动兼容性好Windows、Linux、安卓原生内核基本都认识它。缺点是部分安卓设备的USB Host枚举过程对CH340不太友好偶尔出现“识别到了但打不开”的情况后面排坑部分会细讲。CP2102Silicon Labs的经典芯片模块价格10元左右稳定性比CH340好一截尤其是在连续大数据量传输时丢包率更低。很多工控设备原厂就用的这颗属于“不会出错”的稳妥选择。FT232老牌FTDI芯片价格偏高模块30元以上但驱动体系最完善兼容性天花板级别。适合预算充足、需要长期稳定运行的工业级项目。PL2303老的Prolific方案早期版本在Linux/安卓下问题较多而且现在市面上仿冒芯片很多容易买到假货不建议新项目使用。我的建议很简单首测用CH340正式项目换CP2102。原因是CH340模块容易买到、坏了不心疼适合前期验证代码逻辑一旦逻辑跑通换CP2102模块时安卓代码完全不用改串口库对这两颗芯片是统一抽象的。硬件接线部分是新手最容易忽视的地方。CH340模块有TXD、RXD两个引脚必须交叉连接到STM32的串口模块TXD接STM32的RX引脚比如PA10模块RXD接STM32的TX引脚比如PA9同时GND必须共地。经常有人把TXD接TXD、RXD接RXD结果数据永远出不来这不是代码问题是物理连接就错了。1.3 整体链路架构与“之一”系列的内容边界整体数据链路是这样一个结构安卓AppUSB Host → OTG线 → USB转串口模块 → STM32USART外设安卓设备在这个链路里作为USB Host主设备通过OTG线扩展出USB A口插入USB转串口模块后模块在安卓侧会被识别成一个串口设备模块另一侧的TXD/RXD则通过杜邦线连接到STM32的USART引脚。数据从手机APP发出经过USB总线到达模块模块内部完成USB协议到UART协议的转换最终从TXD引脚逐字节发给STM32。既然标题是“之一”这里也明确下系列边界。本篇聚焦在基础链路的打通安卓侧能够枚举并打开USB转串口设备能够收发字节流STM32侧具备串口回环能力能够正确接收和响应。至于更细的协议状态机设计、大数据量分包传输、错误重传机制这些属于“之二”“之三”的内容本篇只做铺垫不做深挖。2. 安卓端USB串口通讯实战2.1 底层原理USB Host模式与串口抽象安卓从3.1API 12开始支持USB Host模式简单说就是允许安卓设备充当USB总线的Host直接与外设通信。我们用的USB转串口模块本质是一个CDCCommunications Device Class设备安卓系统通过UsbDeviceConnection与它建立连接再通过UsbRequest或者bulkTransfer方式在USB端点上收发数据。这里有个关键认知安卓系统本身并不理解“串口”这个概念它只看到接口Interface和端点Endpoint。一个USB转串口设备通常暴露一个接口接口下分两个批量端点一个用于发送OUT一个用于接收IN。所谓“串口通讯”实际上是在这两个USB端点上读写字节流。如果从零开发你需要自己去解析设备描述符、找到正确的接口和端点、按CDC协议维护波特率等串口参数工作量不小。好在开源社区已经把这一层封装好了知名的usb-serial-for-android库GitHub上的mik3y项目直接支持CH340、CP2102、FT232、PL2303等常见芯片调用方只需要几行代码就能打开一个“串口”。2.2 工程配置权限声明与设备过滤器使用USB Host功能第一步是修改AndroidManifest.xml声明USB Host特性并注册设备过滤规则。这一步很多人不做导致APP安装后根本看不到USB设备或者系统没有弹出权限申请框。manifest packagecom.example.usbserialdemo uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / application activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter intent-filter action android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED / /intent-filter meta-data android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED android:resourcexml/device_filter / /activity /application /manifestres/xml/device_filter.xml用来声明本应用关心的USB设备VID和PID。VID是厂商IDPID是产品ID需要转成十进制。以最常见的CH340为例?xml version1.0 encodingutf-8? resources !-- CH340: VID0x1A866790, PID0x752329987 -- usb-device vendor-id6790 product-id29987 / !-- CP2102: VID0x10C44292, PID0xEA6060000 -- usb-device vendor-id4292 product-id60000 / /resources这个过滤器的作用有两个一是设备插入时系统能自动唤醒你的APP二是声明“这个APP被允许申请这个设备的访问权限”。如果不写或者写错VID/PID设备插入后安卓系统不知道把权限弹窗推给哪个应用连接自然就建立不起来。2.3 打开USB串口的完整流程与代码核心流程分四步枚举设备、申请权限、打开连接、配置串口参数。我用Kotlin给出一段可直接套用的核心代码注释写详细些。首先在MainActivity里初始化UsbManager并枚举USB设备class MainActivity : AppCompatActivity() { private lateinit var usbManager: UsbManager private var serialPort: UsbSerialPort? null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) usbManager getSystemService(Context.USB_SERVICE) as UsbManager findAndOpenSerialDevice() } private fun findAndOpenSerialDevice() { // 1. 用串口库内置的探测器找到可用串口设备 val serialDevices UsbSerialProber.getDefaultProber().findAllDrivers(usbManager) if (serialDevices.isEmpty()) { Log.e(USB_SERIAL, 没有找到USB串口设备) return } val driver serialDevices[0] val device driver.device // 2. 检查是否有权限没有则申请 if (!usbManager.hasPermission(device)) { usbManager.requestPermission(device, PendingIntent.getBroadcast( this, 0, Intent(USB_PERMISSION_ACTION), PendingIntent.FLAG_IMMUTABLE )) return } // 3. 打开设备连接 val connection usbManager.openDevice(device) if (connection null) { Log.e(USB_SERIAL, 打开USB设备失败) return } // 4. 打开串口端口并配置参数 serialPort driver.ports[0] serialPort?.open(connection) serialPort?.setParameters( 115200, // 波特率 8, // 数据位 UsbSerialPort.STOPBITS_1, // 停止位 UsbSerialPort.PARITY_NONE // 校验位 ) // 5. 启动读线程 startReadingThread() } }权限申请是异步的需要注册一个静态广播接收器处理用户的授权结果private val usbPermissionReceiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action USB_PERMISSION_ACTION) { if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { // 用户同意授权重新连接 findAndOpenSerialDevice() } else { Log.e(USB_SERIAL, 用户拒绝了USB权限) } } } }这里有个务实的细节UsbSerialProber.getDefaultProber()只能识别库内置支持的芯片。如果你用的是一款小众芯片库里没有预置驱动可以改成val prober UsbSerialProber(context) // 拿到全部可能的设备包括未知厂商 val allDevices prober.findAllDrivers(usbManager)但这种情况需要自己确认设备是否为串口类。绝大多数项目用内置探针就够了CH340、CP2102、FT232都覆盖到了。2.4 读写线程模型与资源释放串口读写和文件IO一样是阻塞式的绝不能在主线程直接调用read()否则界面会卡死。标准做法是单独开一个读线程循环读取数据存入缓冲队列然后通过Handler或协程回调到主线程更新UI。// 读线程核心逻辑 private fun startReadingThread() { Thread { val buffer ByteArray(1024) while (!Thread.currentThread().isInterrupted) { val len serialPort?.read(buffer, 1000) ?: 0 if (len 0) { val data buffer.copyOf(len) runOnUiThread { Log.d(USB_RX, 收到 ${data.size} 字节: ${bytesToHex(data)}) } } } }.start() }写入倒是简单注意加锁防止多个线程同时写导致数据交错即可Synchronized private fun sendData(data: ByteArray): Boolean { return try { serialPort?.write(data, 1000) ?: 0 true } catch (e: IOException) { Log.e(USB_SERIAL, 写入失败: ${e.message}) false } }资源释放是很多人忽略的重灾区。串口打开后如果不主动关闭USB设备会一直处于被占用状态下次插拔后会触发“设备无法打开”的异常。正确的释放时机是页面销毁或者串口不再使用时override fun onDestroy() { super.onDestroy() runCatching { serialPort?.close() serialPort null } }如果是USB线在运行过程中被拔出还需要注册一个ACTION_USB_DEVICE_DETACHED广播在里面做串口关闭和界面状态更新否则APP会持有已经失效的连接再次插入时出现奇怪问题。3. STM32端配置与协议设计3.1 CubeMX初始化USART1STM32侧的开发我默认大家用的是CubeMX HAL库这套主流工具链这也是目前效率最高的方式。打开CubeMX新建工程选择MCU型号比如STM32F103C8T6引脚配置界面里找到USART1把PA9设为TX、PA10设为RX。然后在“Connectivity → USART1”选项卡里配置参数ModeAsynchronous异步模式Baud Rate115200 Bits/s与安卓端保持一致Word Length8 BitsParityNoneStop Bits1记住一个原则双方串口参数必须完全一致。波特率不一致是联调时最常见的乱码原因其次容易出错的是校验位安卓库默认是NoneSTM32端也必须是None。生成代码后CubeMX会自动初始化MX_USART1_UART_Init()和引脚复用配置不需要手写GPIO复用代码。这是用CubeMX的最大优势时钟树、引脚映射、外设句柄全部自动处理。3.2 一个可用的回环测试程序验证链路连通性时最简单可靠的方法是“回环”STM32收到什么数据就原样返回什么。这样一来安卓端发一串任意数据只要收到相同的数据说明整条链路是通的。在main.c的主循环里写一个查询式回环uint8_t rx_buffer[1]; uint8_t tx_buffer[1]; while (1) { // 尝试接收1字节超时100ms if (HAL_UART_Receive(huart1, rx_buffer, 1, 100) HAL_OK) { // 原样发回 if (HAL_UART_Transmit(huart1, tx_buffer, 1, 1000) ! HAL_OK) { Error_Handler(); } } }这个程序逻辑很简单但一个字节一个字节地查询接收效率偏低而且容易丢数据。实际项目中建议改用串口空闲中断方式接收不定长数据。我在后面协议设计部分会给出更完整的框架这里先用最简单的回环验证链路。3.3 数据帧协议设计解决粘包与半包打通链路后紧接着要面对的是串口数据的一个经典问题收不到完整的数据帧。原因在于串口是字节流协议没有天然的消息边界。STM32可能一次收到半个帧也可能一次收到两个帧拼在一起的数据这就是所谓的“半包”和“粘包”。解决思路是自定义一个帧协议常见格式如下帧头(0xAA 0x55) 长度(1字节) 命令(1字节) 数据(N字节) 校验(1字节)帧头固定为0xAA 0x55用来识别帧的起始位置。长度字段表示“命令数据”部分的总字节数。数据区承载具体的控制指令或数据内容。校验字段用累加和或CRC8用来检测传输错误。接收端用状态机解析帧先找帧头找到后读长度再攒够一整帧后做校验校验通过再交给上层逻辑处理。这种方案能有效避免粘包、半包问题代码结构也清晰。STM32端的中断空闲接收框架比较实用的写法是这样的#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint8_t rx_len 0; // 在主循环外开启串口空闲中断 HAL_UART_Receive_IT(huart1, rx_buf, 1); // 先接收1字节触发后继续 // 串口中断回调 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart huart1) { rx_len; // 这里做帧解析状态机逐步累积到完整帧后再处理 HAL_UART_Receive_IT(huart1, rx_buf rx_len, 1); } }更高效的做法是开启DMA 空闲中断一次接收一批数据适合大数据量传输场景。作为系列第一篇先掌握查询式和单字节中断就够用了DMA部分等后续文章展开。3.4 帧协议接收示例下面给一个最小但完整的STM32帧解析示例协议采用“帧头长度命令数据校验”这里以收到完整帧后返回一条ACK为例uint8_t rx_buffer[64]; // 接收缓冲区 uint8_t rx_index 0; // 当前接收索引 uint8_t expect_len 0; // 期望接收的帧长 #define FRAME_HEAD0 0xAA #define FRAME_HEAD1 0x55 // 假设已通过中断连续填充rx_buffer void Parse_Rx_Data(uint8_t byte) { static uint8_t state 0; switch (state) { case 0: if (byte FRAME_HEAD0) state 1; break; case 1: if (byte FRAME_HEAD1) state 2; else state 0; break; case 2: expect_len byte; // 长度域 rx_index 0; state 3; break; case 3: rx_buffer[rx_index] byte; if (rx_index expect_len) // 数据区收满了 { // 到这里帧头长度数据均已经约定长度 // 再做一次校验和判定通过就执行命令处理 state 4; } break; case 4: { // 校验字节 uint8_t calc_sum 0; for (uint8_t i 0; i rx_index; i) { calc_sum rx_buffer[i]; } if (calc_sum byte) { // 校验通过执行命令解析 // ExecuteCmd(rx_buffer[0], rx_buffer[1], rx_index - 1); } state 0; break; } default: state 0; break; } }代码里我刻意把命令执行函数留空为注释因为命令业务各项目差异太大。这个框架本身可以直接用理解了状态机切换的原理之后换成DMA接收也只是一个数据填充来源的差异解析逻辑可以复用。4. 联调步骤与常见问题排查4.1 三段式联调法硬件通讯项目最忌讳“写完代码直接上”一旦出错很难定位问题在安卓侧、接线侧还是STM32侧。我习惯把联调拆成三段逐段验证第一步电脑端验证STM32。用USB转TTL模块连接STM32的USART1打开电脑上的串口助手比如XCOM、sscom发送数据看STM32是否回传。如果电脑通过串口助手能正常收发说明STM32侧的串口初始化、回环程序没有问题。第二步电脑端验证安卓模块。把同一颗USB转串口模块从STM32上拆下来插到手机的OTG线上用自己写的安卓APP发一段数据同一时刻用电脑的串口助手也接着这个模块把模块的TXD/RXD接到电脑串口助手对应的USB转TTL上确认APP发出的数据能在电脑上收到同时电脑发出的数据能在手机APP上打印出来。这步主要验证安卓端代码和USB模块本身。第三步完整链路联调。把模块接到STM32上手机APP直接发一个帧看STM32能不能解析、能不能回ACK。这一步如果出问题再回头检查两端的线序、共地、参数一致性。三段式的好处是每个环节都能独立验证排查问题时不需要同时怀疑三个环节。4.2 高频问题排查速查表现象可能原因排查方法手机不弹USB权限框device_filter.xml里的VID/PID不匹配OTG线不支持Host用系统设置里的“USB设备”列表确认设备是否被识别识别到设备但打不开串口权限未授权USB设备被其他应用占用确认权限弹窗已同意重启APP后再试打开串口后发送无反应TX/RX接反STM32端没进接收中断检查杜邦线是否交叉连接用示波器/逻辑分析仪看STM32 RX引脚波形收到的数据全是乱码波特率不一致校验位设置不一致GND没共地对照检查两端串口参数确认GND已连接插拔USB线后APP不可用串口资源没释放拔插广播未处理onDestroy里关闭串口注册ACTION_USB_DEVICE_DETACHED广播CH340在部分手机上枚举失败手机USB Host驱动对CH340兼容性差供电不足换CP2102模块用带外部供电的USB HUB中转STM32偶发丢帧串口中断处理不够快缓冲区溢出改用DMA空闲中断增大接收缓冲区4.3 几个容易被忽视的坑这里重点说三个实操中踩过多次的坑。第一个是供电问题。很多USB转串口模块直接从USB总线取电设计时供电电流就偏小。接STM32开发板还好如果STM32板上还带着显示屏、传感器等外设总电流可能超过USB Host端口能提供的上限表现为模块偶尔枚举失败、通讯过程中突然断开。解决方案是在模块的5V和GND引脚外接一个稳定的电源或者用带外部供电的USB HUB中转一下不要指望手机USB口能满足所有外设的电流需求。第二个是CH340模块在个别手机上的兼容性问题。我遇到过小米、三星等部分机型插入CH340后系统能识别设备但每次调用openDevice都会返回null。换成CP2102模块后直接就好了。这不是代码问题是USB Host驱动的芯片兼容性差异。所以遇到“API调用都正常但设备就是打不开”的情况可以先换个USB转串口芯片试试。第三个是数据线≠OTG线。安卓手机底部那个口是Type-C或Micro-USB普通数据线只能传数据到电脑并不能让手机充当USB Host。要想接USB设备必须使用支持OTG功能的转接头或转接线。很多人在这一步卡了半天其实只是少了一根OTG线。判断标准很简单手机接上OTG线后再插U盘能弹出U盘浏览界面说明OTG链路是通的。写在最后的一点体会这套链路我在多个项目里反复搭过最大的感受是安卓与STM32之间的USB串口通讯难点从来不在某一个单点技术上而在两端参数匹配和细节处理上。安卓侧要注意USB权限的生命周期管理STM32侧要提前想好数据帧的边界定义联调时按段验证、不跳步基本就能把问题控制在一个很小的范围内。下一步可以做的事情很多比如给STM32端加上DMA接收、给安卓端加上Modbus协议支持、或者把数据在APP里实时绘制成曲线图。这些都是建立在这篇基础链路之上的扩展。这个系列的后续文章里我会逐个展开聊。
返回列表