☰
STM32循迹小车Android经典蓝牙遥控开发实战:从权限到协议
2026/10/14 1:14:44 网站建设 项目流程

1. 从"能动"到"可控":为什么最后一公里偏偏卡在蓝牙上

做过STM32循迹小车的人大概都有这么一段经历:车能跑了,循迹也稳了,PID调得七七八八,看着小车沿着黑线一圈一圈地转,心里挺美。但接下来想让它"听人话"——按一下手机就前进,松手就停,拐弯的时候还能自己调速——这时候问题就来了。很多人卡在这一步,不是卡在STM32端,而是卡在Android端。

我见过太多项目,硬件部分做得漂漂亮亮,电机驱动、红外对管、电源管理全都到位,结果手机App那一端要么是随便找个现成的串口助手凑合,要么是拿别人开源的APK改个名字,真正自己动手写一个稳定可靠的经典蓝牙控制端的人反而不多。原因也很简单:Android的蓝牙API从早期版本到现在经历了多次调整,权限模型变了,扫描机制变了,后台限制也变了,网上搜到的代码片段很多是五六年前的老写法,直接抄过来要么编译不过,要么运行起来各种崩溃。

这篇是循迹小车系列的完结篇,前面几篇把STM32端的电机控制、传感器采集、循迹算法都讲完了,这一篇专门解决"最后一公里"的问题——用Android经典蓝牙(Bluetooth Classic,也就是常说的SPP协议)做一个真正能用的遥控端。关键词就三个:STM32循迹小车、Android蓝牙控制、经典蓝牙开发。适合已经有一辆能跑的小车、想给它加上手机遥控功能的开发者,也适合正在学Android蓝牙通信、想找一个完整落地案例的朋友。

需要先说明一点:经典蓝牙和低功耗蓝牙(BLE)是两条完全不同的技术路线。循迹小车这种场景,数据量小、实时性要求中等、需要持续双向通信,经典蓝牙的SPP(Serial Port Profile)模式是最省事的选择,因为它对STM32端来说就是一个透明的串口,你之前调通的USART代码几乎不用改,换个外设接上去就行。BLE虽然功耗低,但协议栈复杂得多,服务、特征值、通知机制一套下来,对于只是想"发几个字节控制指令"的场景来说属于杀鸡用牛刀。所以这个项目里,我们坚定地走经典蓝牙路线。

下面我会从硬件对接、Android端权限与配对、连接建立、数据收发、指令协议设计、稳定性处理这几个维度,把整个开发过程拆开讲清楚。中间会穿插我自己踩过的坑,以及一些文档里不会写、但实际开发中一定会遇到的细节。

2. 硬件侧的准备:HC-05/HC-06与STM32的串口对接

2.1 蓝牙模块选型:HC-05和HC-06到底差在哪

市面上最常见的经典蓝牙串口模块就是HC-05和HC-06这两兄弟。很多人买的时候随手拿一个,用起来发现有些功能用不了,回头才搞明白区别。

HC-05是主从一体的,既可以作为从机被手机连接,也可以作为主机去主动连接别的蓝牙设备。HC-06通常只能作为从机。对于循迹小车这个场景,手机是主机,小车上的模块是从机,所以两者都能用。但HC-05多出来的主机模式和AT指令配置能力,在调试阶段非常有用——比如你可以让它主动去连另一个模块做双车通信,或者用AT指令改波特率、改设备名。价格上HC-05贵不了几块钱,我一般建议直接上HC-05,留出扩展余地。

接线方面,模块和STM32之间就是标准的UART交叉连接:模块的TXD接STM32的RX,模块的RXD接STM32的TX,VCC和GND对应接好。这里有个细节要注意:HC-05的工作电压通常是3.6V到6V,但它的IO电平是3.3V的,而很多STM32开发板的串口引脚也是3.3V,所以直接连没问题。如果你用的是5V的单片机,那RXD线上最好加个分压或者电平转换,否则长期用下来模块容易出问题。

2.2 串口配置与AT指令调试

HC-05默认的串口参数是9600波特率、8位数据位、1位停止位、无校验。你在STM32端初始化USART的时候,先按这个参数来,等通信跑通了再考虑改。改波特率需要通过AT指令进入配置模式,具体操作是:模块上电前按住按键(或者把EN/KEY引脚拉高),再上电,这时候模块进入AT模式,指示灯慢闪。然后用串口助手发AT指令,比如AT+UART=115200,0,0把波特率改成115200。

这里有个坑我踩过:改完波特率之后,STM32端的USART配置也要同步改,否则收到的全是乱码。而且改完之后模块要重新上电才生效,不是发完指令立刻切换。另外,AT指令模式下发的每条指令都要以回车换行结尾,很多串口助手默认不加,导致指令没反应,新手容易在这里卡半天。

提示:调试阶段建议先用USB转TTL模块把HC-05接到电脑上,用串口助手把参数配好、确认能正常收发,再接到STM32上。这样能把"模块问题"和"单片机问题"分开排查,效率高很多。

2.3 STM32端的串口接收逻辑

STM32这边,我建议用中断接收而不是轮询。轮询方式在循迹任务繁重的时候容易丢数据,中断方式则可以在收到一个字节时立刻存进缓冲区,主循环再慢慢处理。具体做法是:开启USART的接收中断,在中断服务函数里把数据存进一个环形缓冲区,主循环里检查缓冲区有没有完整的一帧数据。

判断"一帧"的方式取决于你的指令协议。如果指令是固定长度的,比如每次都是3个字节,那就数够3个就处理。如果指令长度可变,那就需要一个帧头帧尾或者超时机制。我在这个项目里用的是最简单的方案:每条指令固定3字节,第一个字节是命令类型,第二个字节是参数,第三个字节是校验。这样接收端逻辑非常干净,不容易出错。

关于环形缓冲区的实现,网上有很多现成的代码,核心就是两个指针(读指针和写指针)加一个数组,写指针在中断里推进,读指针在主循环里推进,两者相等表示空,写指针加一等于读指针表示满。这个结构在串口通信里几乎是标配,建议自己手写一遍理解透,比直接抄库函数收获大。

3. Android端的权限迷宫:从搜索到配对要过几道关

3.1 蓝牙权限的版本差异

Android的蓝牙权限是新手最容易翻车的地方,因为不同Android版本要求的权限完全不一样。在Android 12(API 31)之前,你只需要在Manifest里声明BLUETOOTH和BLUETOOTH_ADMIN这两个权限,而且它们属于普通权限,安装即授予,不需要运行时申请。但从Android 12开始,Google引入了BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE这三个新权限,而且它们属于危险权限,必须在运行时动态申请。

这意味着如果你的目标版本设成了API 31以上,却只声明了老权限,那扫描和连接都会失败,而且报错信息往往很含糊,让人摸不着头脑。我的做法是在Manifest里把新旧权限都声明上,然后用版本判断在运行时决定申请哪些:

<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />

注意最后那个定位权限。在Android 6到Android 11之间,扫描蓝牙设备是需要定位权限的,因为蓝牙扫描理论上可以用来推断位置。很多人只声明了蓝牙权限,忘了定位权限,结果startDiscovery()调用后一个设备都搜不到,还以为是模块坏了。这个坑非常隐蔽,因为代码不报错,就是静默地搜不到东西。

3.2 运行时权限申请的写法

运行时申请这块,我用的是ActivityResultLauncher,比老的onRequestPermissionsResult回调写法清晰得多。核心逻辑是:先检查需要哪些权限还没授予,然后一次性申请,在回调里判断是否全部授予,如果有拒绝的就提示用户去设置里手动开。

private final ActivityResultLauncher<String[]> permissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), result -> { boolean allGranted = true; for (Map.Entry<String, Boolean> entry : result.entrySet()) { if (!entry.getValue()) { allGranted = false; break; } } if (allGranted) { initBluetooth(); } else { Toast.makeText(this, "需要蓝牙权限才能搜索设备", Toast.LENGTH_LONG).show(); } });

这里有个经验:不要假设用户会乖乖点"允许"。实际使用中,用户可能点"拒绝",甚至勾选"不再询问"。所以你的代码要能优雅处理权限被拒的情况,给出明确的引导,而不是直接崩溃或者卡在空白界面。

3.3 已配对设备列表与扫描新设备

经典蓝牙的连接流程和BLE不一样。BLE是先扫描拿到设备地址,再发起连接。经典蓝牙则通常先看"已配对设备列表",因为配对是一次性的,配对过之后设备信息会被系统记住,下次直接连就行,不需要重新扫描。

获取已配对设备用BluetoothAdapter.getBondedDevices(),返回一个Set<BluetoothDevice>。这个操作需要BLUETOOTH_CONNECT权限(Android 12以上)。拿到列表后,你可以按设备名过滤,比如只显示名字里带"HC-05"的设备,或者干脆全部列出来让用户选。

如果目标设备还没配对,那就需要先扫描。调用startDiscovery()之后,系统会广播ACTION_FOUND,你在BroadcastReceiver里接收,每个广播携带一个BluetoothDevice对象。扫描一般持续12秒左右,期间会不断发现新设备。扫描完成后系统广播ACTION_DISCOVERY_FINISHED。

注意:扫描和连接不能同时进行。如果你正在扫描,要先cancelDiscovery()再发起连接,否则连接大概率失败。这个细节在文档里写得很清楚,但实际写代码时很容易忘,尤其是把扫描和连接放在同一个按钮里触发的时候。

配对操作调用device.createBond(),这是一个异步过程,结果通过ACTION_BOND_STATE_CHANGED广播返回。配对成功后,设备就进入已配对列表,后续可以直接连接。配对过程中系统可能会弹出配对码确认框,HC-05默认配对码通常是1234或0000,这个在模块的AT指令里可以改。

4. 建立连接:UUID、Socket与那条容易断的链路

4.1 SPP服务的UUID

经典蓝牙的串口通信走的是SPP协议,它对应一个固定的UUID:00001101-0000-1000-8000-00805F9B34FB。这个UUID是蓝牙SIG定义的标准值,所有支持SPP的设备都用它。你在代码里创建Socket的时候必须用这个UUID,用错了就连不上。

private static final UUID SPP_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); BluetoothSocket socket = device.createRfcommSocketToServiceRecord(SPP_UUID); socket.connect();

connect()是一个阻塞调用,会卡住当前线程,所以绝对不能放在主线程里,否则界面直接卡死甚至ANR。标准做法是开一个子线程来连接,连接成功后再通过Handler或者LiveData通知主线程更新UI。

4.2 连接失败的常见原因

连接失败是经典蓝牙开发里最让人头疼的问题,因为失败原因往往不明确。我总结了几种最常见的情况:

第一种是设备没配对。没配对的设备直接调connect()会抛异常,必须先createBond()配对。第二种是扫描没停。前面说过,扫描期间连接会失败,所以连接前一定要cancelDiscovery()。第三种是模块被别的设备占用了。经典蓝牙的SPP连接是独占的,如果HC-05已经连了另一台手机,你再连就连不上,必须等对方断开。第四种是UUID不对或者模块固件不支持SPP。

排查的时候,我习惯在每一步都打日志,把异常信息完整打出来。IOException的message有时候是"read failed, socket might closed",有时候是"Service discovery failed",不同的message指向不同的问题。别嫌日志多,蓝牙这块没有日志基本没法调。

4.3 连接后的数据通道

连接成功后,你拿到的是两个流:socket.getInputStream()和socket.getOutputStream()。发送数据就是往输出流里写字节,接收数据就是从输入流里读字节。这里要注意,输入流的read()也是阻塞的,同样要放在子线程里循环读取。

我一般会开两个线程:一个专门负责读,一个专门负责写。读线程用一个while(true)循环不断读,读到数据就通过Handler发给主线程处理。写线程可以用一个阻塞队列来接收待发送的数据,避免多线程同时写输出流导致数据错乱。

// 读线程 private class ReadThread extends Thread { private final InputStream inputStream; public void run() { byte[] buffer = new byte[1024]; while (isConnected) { try { int len = inputStream.read(buffer); if (len > 0) { byte[] data = Arrays.copyOf(buffer, len); Message msg = handler.obtainMessage(MSG_RECEIVED, data); handler.sendMessage(msg); } } catch (IOException e) { // 连接断开,通知主线程 handler.sendEmptyMessage(MSG_DISCONNECTED); break; } } } }

这段代码看起来简单,但有几个细节值得说。read()返回-1表示流结束,也就是对方断开了,这时候要跳出循环并通知UI。read()抛异常也意味着连接出问题了,同样要处理。另外,buffer的大小要根据你的数据量来定,太小了会频繁触发读操作,太大了浪费内存,1024字节对于控制指令来说绰绰有余。

5. 指令协议设计:让STM32和Android说同一种语言

5.1 为什么需要自定义协议

有人可能会想,不就是发个前进后退吗,直接发一个字符'A'表示前进,'B'表示后退不就行了?简单场景下确实可以,但一旦指令多起来,或者需要传参数(比如速度值、转弯角度),单字符就不够用了。而且单字符没有校验,一旦传输过程中出现误码,小车可能执行错误的动作,这在高速行驶时是危险的。

所以我们需要一个简单的自定义协议。我的设计是固定3字节一帧:

字节位置含义取值范围
第1字节帧头固定0xAA
第2字节命令码0x01-0xFF
第3字节参数0x00-0xFF
第4字节校验前3字节异或

等等,这里我改一下,用4字节更稳妥。帧头用来做同步,命令码区分动作,参数传递速度或角度,校验用来验证数据完整性。校验用简单的异或就够了,不需要CRC,因为数据量小、环境干扰有限,异或的计算开销也低。

命令码的定义可以这样规划:0x01前进、0x02后退、0x03左转、0x04右转、0x05停止、0x06调速、0x07查询状态。参数字节在调速命令里表示速度值(0-255),其他命令里可以忽略或者作为保留。

5.2 STM32端的解析逻辑

STM32收到数据后,先找帧头0xAA,找到后连续读4个字节,验证校验,校验通过再根据命令码执行动作。如果校验失败,丢弃这一帧,重新找帧头。这个逻辑用状态机实现最清晰:

typedef enum { STATE_IDLE, STATE_HEADER, STATE_CMD, STATE_PARAM, STATE_CHECK } ParseState; void parseByte(uint8_t byte) { static ParseState state = STATE_IDLE; static uint8_t frame[4]; static uint8_t index = 0; switch (state) { case STATE_IDLE: if (byte == 0xAA) { frame[0] = byte; index = 1; state = STATE_CMD; } break; case STATE_CMD: frame[index++] = byte; state = STATE_PARAM; break; case STATE_PARAM: frame[index++] = byte; state = STATE_CHECK; break; case STATE_CHECK: frame[index] = byte; if (verifyChecksum(frame)) { executeCommand(frame[1], frame[2]); } state = STATE_IDLE; index = 0; break; } }

这个状态机的好处是,即使中间丢了几个字节,它也能自动重新同步——只要收到0xAA就重新开始组帧。实际测试中,这种设计对偶发的数据丢失有很好的容错能力。

5.3 Android端的发送封装

Android端发送的时候,把命令码和参数按协议打包好,一次性写入输出流。注意write()之后要flush(),否则数据可能留在缓冲区里没发出去。虽然很多情况下不flush也能工作,但显式flush是更稳妥的做法。

public void sendCommand(byte cmd, byte param) { byte[] frame = new byte[4]; frame[0] = (byte) 0xAA; frame[1] = cmd; frame[2] = param; frame[3] = (byte) (frame[0] ^ frame[1] ^ frame[2]); try { outputStream.write(frame); outputStream.flush(); } catch (IOException e) { // 处理发送失败 } }

发送频率也要注意。如果用户按住前进按钮不放,你不能每毫秒都发一帧,那样会把串口带宽占满,STM32也处理不过来。合理的做法是设置一个发送间隔,比如每100毫秒发一次,或者只在状态变化时发送。我一般用"状态变化时发送+定时心跳"的方式:按钮按下时发一次,松开时发一次停止,中间如果持续按住,每隔200毫秒补发一次,防止丢包导致小车一直跑。

6. 稳定性处理:断连重连、心跳与UI线程安全

6.1 连接断开的检测与重连

蓝牙连接在实际使用中并不稳定,尤其是小车移动的时候,手机和模块之间的距离、角度变化都可能导致信号波动。连接断开是常态,不是异常。所以你的App必须能检测到断开,并且提供重连机制。

检测断开的方式前面提过,读线程的read()返回-1或者抛异常就说明断了。这时候要更新UI状态,把按钮置灰,提示用户重新连接。重连可以手动触发,也可以自动重试。自动重试要注意加退避策略,比如第一次隔1秒重试,第二次隔2秒,第三次隔4秒,避免疯狂重连耗电。

6.2 心跳机制的必要性

有时候连接并没有真正断开,但数据已经发不过去了,这种情况叫"假连接"。TCP有keepalive机制,但蓝牙SPP没有标准的心跳。所以我们需要自己在应用层做心跳:Android端每隔一段时间(比如2秒)发一个心跳帧,STM32收到后回一个应答帧。如果连续几个心跳周期没收到应答,就判定连接失效,主动断开重连。

心跳帧可以用一个专门的命令码,比如0x00,参数随意。STM32收到0x00就回一个0x00。这样双方都能确认链路是通的。心跳间隔不要太短,否则浪费带宽;也不要太长,否则断连检测不及时。2秒是个比较平衡的值。

6.3 UI线程与后台线程的边界

Android开发有一条铁律:不能在主线程做网络或IO操作。蓝牙的connect、read、write都是IO操作,必须放在子线程。但UI更新必须在主线程。所以你需要一个机制在两者之间传递数据,Handler是最经典的选择。

我见过一些代码直接在读线程里更新TextView,在低版本Android上可能侥幸能跑,但在高版本上会直接抛CalledFromWrongThreadException。所以老老实实用Handler或者runOnUiThread(),不要图省事。

另外,Activity销毁的时候要记得关闭Socket和线程,否则会造成内存泄漏,而且Socket没关的话,下次连接可能失败。在onDestroy()里做清理是基本操作:

@Override protected void onDestroy() { super.onDestroy(); isConnected = false; try { if (socket != null) socket.close(); } catch (IOException e) { // 忽略关闭异常 } }

6.4 一些实测中的经验

实际跑起来之后,我发现几个值得分享的点。第一,手机的方向不要挡着蓝牙天线,很多手机的蓝牙天线在机身上部或下部,握持姿势不对会明显影响距离。第二,HC-05的板载天线增益有限,空旷环境下10米左右比较稳,隔一堵墙就掉到3-5米,所以小车别跑太远。第三,如果发现指令延迟明显,先检查发送频率是不是太高,再检查STM32的主循环是不是被循迹任务占满了导致串口处理不及时。

还有一个容易被忽略的点:Android的省电策略。有些手机在息屏后会限制后台线程的运行,导致蓝牙数据收发变慢甚至停止。解决办法是在App里申请WAKE_LOCK权限,或者在开发阶段保持屏幕常亮。这个在真机测试时经常遇到,模拟器上反而不会出现。

7. 把遥控和循迹揉在一起:模式切换的设计

7.1 两种模式的共存逻辑

小车既要能自动循迹,又要能手动遥控,这两者不能同时生效,否则会打架。所以需要一个模式切换机制。我的做法是在STM32端维护一个模式变量,通过蓝牙指令切换。默认上电是循迹模式,收到遥控指令后切到手动模式,收到特定指令再切回循迹。

模式切换的时候要注意电机的状态。从循迹切到手动,应该先让电机停一下,避免突然的指令冲突导致电机抖动。从手动切回循迹,也要给循迹算法一点时间重新采集传感器数据、稳定下来。

7.2 遥控模式下的速度控制

手动遥控时,速度控制是个体验关键点。如果只有"全速前进"和"停止"两个状态,操作起来会很生硬,小车要么不动要么猛冲。更好的做法是用PWM调速,把速度分成若干档,或者用一个滑块连续调节。

在协议里,调速命令的参数字节就是PWM占空比,0-255对应0-100%。Android端可以用SeekBar让用户拖动调速,拖动时实时发送调速命令。STM32端收到后更新PWM比较寄存器的值。这样操作起来就顺滑多了。

7.3 循迹模式下的状态回传

循迹模式下,小车是自主运行的,但用户可能想知道它现在在干什么。这时候可以用上双向通信:STM32定期把传感器状态、当前速度、是否检测到黑线等信息打包发回Android端,App上实时显示。这样即使不控制,也能监控小车状态。

回传的数据帧可以用和下行指令不同的命令码区分,比如0x80开头表示状态上报。Android端的读线程收到后解析并更新UI。这个功能不是必须的,但加上之后项目的完整度会高很多,也更像一

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询