蓝牙串口这四个字,是我在嵌入式开发群里被问到最多的一组词。做完一个安卓APP想跟PC交换数据,大多数人第一反应都是搜“蓝牙串口怎么用”,结果搜出来的教程不是单片机端接HC-05模块,就是PC端搞USB转TTL,翻来覆去就差那一步——PC和安卓APP这两台设备之间,到底能不能直接通过蓝牙互相发消息?答案是能,而且链路不复杂:PC把蓝牙变成一个COM口,安卓APP用蓝牙Socket去连这个COM口,数据就通了。这篇教程把这整条链路掰开揉碎,从协议原理讲到Windows配置,再给一份能直接抄的安卓代码,最后附上联调工具和排错清单。适合正在做毕设、课程设计、或者想给手头小工具加一个无线调试通道的人。
1. 蓝牙串口到底是什么:先说清SPP协议与虚拟串口的底层逻辑
关于蓝牙串口的资料不少,但多数是“照着敲就能跑”的教程,对原理一笔带过。我建议还是先花十分钟搞清楚它背后的东西,因为后面遇到的问题,多半出在原理没吃透。
蓝牙串口对应的蓝牙配置文件叫 SPP(Serial Port Profile),它的职责就是“把无线的数据流伪装成一个串口”。SPP 下面走的是 RFCOMM 协议,RFCOMM 又是在 L2CAP 之上做的一层逻辑通道。对应用程序而言,你不需要关心射频层怎么跳频、怎么编码,只需要记住一个结论:SPP 是一条建立在蓝牙链路上的双向数据管道,接收端看到的是一堆字节流,跟有没有真的串口硬件没有任何关系。
这里关键来了:既然底层是字节流,那“串口”里的波特率、数据位、停止位这些概念,在 SPP 场景里还有什么意义?
1.1 一个容易被误解的概念:波特率到底管不管用
先说结论:在“PC虚拟蓝牙串口 对 安卓APP”这种场景下,PC端设置的波特率对数据传输几乎没有影响。这个结论和很多人的直觉相反,但它是真的。
原因在于,蓝牙串口并不是把数据“调制”到电线上,而是把数据打包成 L2CAP 报文,通过 2.4GHz 频段发出去。波特率这个概念原本是针对UART物理链路的,用于约定每秒传输多少个二进制码元;当两端都是“逻辑串口”时,没有物理信号需要约定速率,所以两端即使设置不同的波特率,数据也照样能传过去。
我见过有同学在PC串口助手里把波特率从9600改成115200,安卓那边也没写任何波特率配置,照样收发正常。真正需要关心波特率的场景是:PC接了一个USB转TTL,TTL接了一个HC-05蓝牙模块,HC-05对面是单片机——这种链路里,PC和HC-05之间、HC-05和单片机之间才存在真实的UART速率匹配问题。为了便于理解,我用表格比较一下:
| 对比项 | 物理串口(UART) | 蓝牙SPP虚拟串口 |
|---|---|---|
| 传输介质 | 导线、PCB走线 | 2.4GHz射频 |
| 收发角色 | TX/RX固定方向 | 双向对等,双方都能主动发 |
| 波特率 | 两端必须一致 | 不影响透传,配置只是占位 |
| 数据格式 | 起始位+数据位+停止位 | 无固定帧格式,原样传字节 |
| 典型连接 | 单片机、传感器、PC COM口 | 手机APP、PC蓝牙适配器 |
搞明白这一点,你后面调试时就不会在波特率这个变量上空耗时间。把有限的精力放在协议、权限和连接流程上,效率会高很多。
1.2 通信角色:谁做Server,谁做Client,UUID是什么
SPP连接跟TCP有点相似,也有服务端和客户端的角色区分。在我们的场景里,推荐PC作为服务端,安卓作为客户端。具体分工是:
- PC端:Windows蓝牙协议栈把蓝牙串行端口服务暴露出来,等待其他设备来连接,对应“传入端口”(Incoming COM Port)。
- 安卓端:APP主动扫描、配对、连接到PC暴露出的这个服务,对应“客户端”。
为什么不让安卓做服务端、PC做客户端?技术上当然也可以,安卓里可以用 BluetoothServerSocket 开一个RFCOMM服务,PC通过“传出COM端口”来连接它。但实际开发中PC做服务端更顺,原因有两个:第一,Windows暴露SPP服务操作简单,控制面板点点就行;第二,安卓APP多数是用户主动点击连接某个设备,天然是客户端思维,代码写起来也清晰。
再说UUID。UUID在SPP里的作用是“服务标识符”,可以理解成蓝牙世界里的端口号。当你让安卓连接一个SPP设备时,需要指定一个UUID,告诉协议栈你要找哪种服务。SPP的标准服务UUID是:
00001101-0000-1000-8000-00805F9B34FB这个值不是随便填的,它是蓝牙SIG为Serial Port服务指定的固定值。如果写错,最常见的现象是 connect() 抛 IOException,或者连接成功后立刻断开。初学者踩这个坑的并不少,后面排错章节我会详细展开。
2. PC端准备:把蓝牙变成COM口的完整配置
在这个需求里,PC的角色很特殊:它不是一个真实串口设备,而要通过蓝牙协议栈对外提供一个COM口服务。Windows内置了蓝牙串行端口支持,不需要装第三方驱动,但很多人找不到入口在哪里,这步是整条链路里最没文档、也最容易卡住的部分。
2.1 确认蓝牙硬件与驱动
首先确认你的PC有没有蓝牙。笔记本基本都带,台式机不一定。打开“设备管理器”,展开“蓝牙”节点,能看到类似“Intel(R) Wireless Bluetooth(R)”或“Realtek Bluetooth Adapter”就说明硬件有。如果只有蓝牙“未知设备”或黄色感叹号,先装驱动、更新系统再继续。
驱动没问题后,把PC的蓝牙设为“可被其他设备发现”。在Windows 10/11里,进入“设置 -> 蓝牙和其他设备”,把蓝牙开关打开,然后找到“蓝牙设备发现”选项,选择“高级”,确保“让手机和电脑连接此设备”这类选项打开。有的版本默认就能被发现,有的版本需要手动改。
另外,在同一个页面下方一般还有“更多蓝牙选项”,点开之后就是传统的“蓝牙设置”对话框,这里面藏着COM端口配置入口。我建议提前记一下这个路径,等会儿创建传入端口就能少走弯路。
2.2 创建传入COM端口的操作步骤(Windows 10/11实测路径)
下面是完整的实测路径,一步步来:
- 打开“控制面板”,进入“硬件和声音”,点击“设备和打印机”。
- 在窗口里找到你的蓝牙适配器。它通常显示为一个带蓝牙图标的独立设备,名字类似“Intel(R) Wireless Bluetooth(R)”,不是鼠标、键盘,也不是打印机。
- 在蓝牙适配器上右键,选择“Bluetooth 设置”。
- 弹出“Bluetooth 设置”对话框后,切换到“COM端口”选项卡。
- 点击“添加”,在弹出的向导里选择“传入(Incoming)”,然后根据提示选择端口和允许连接的对象,点击“确定”。
- 回到“COM端口”选项卡,列表里就多了一个形如“COM7”的传入端口,状态为“可用”。
如果右键菜单里只有“属性”没有“Bluetooth 设置”,或者属性里没有COM端口选项卡,就从“设置 -> 蓝牙和其他设备 -> 更多蓝牙选项”进入传统对话框,同样能打开“COM端口”选项卡。
创建好传入端口之后,可以到“设备管理器 -> 端口(COM和LPT)”里确认,存在“Bluetooth Serial Port (COM7)”这样的条目,说明节点识别成功。
关键提醒:方向一定要选“传入”。如果误选了“传出”,意味着PC会主动去连某个外部设备,那就要求对面先开了SPP服务才能在连接;而安卓APP如果不写ServerSocket,默认并不会对外广播RFCOMM服务,所以PC用传出去连安卓,九成会失败。
这里有一个很常见的场景:手机扫描到了PC的蓝牙,点击连接,但PC右下角弹出一个配对请求或“允许访问”的提示框。如果你没有点“允许”,本次连接会被系统拒绝。联调时务必观察屏幕角落,该点就点。
2.3 没有蓝牙的替代方案:USB蓝牙适配器与串口模块
如果你的台式机没有蓝牙,或者板载蓝牙驱动实在搞不定,可以用一个USB蓝牙适配器。这种适配器一般十几到几十块钱,插上后Windows自动识别,然后按上面的步骤同样能创建传入COM口。选购时注意一定要支持SPP(Serial Port Profile),市面上绝大部分BT4.0/BT5.0的免驱适配器都支持。
如果连适配器都暂时没有,还有一个兜底方案:用一个HC-05/HC-06蓝牙串口模块,通过USB转TTL小板接到PC。这样USB转TTL会生成一个物理COM口,HC-05对外提供SPP服务,安卓APP连上HC-05的蓝牙,PC端串口助手操作COM口,数据就通了。
但这条路有一个注意事项:PC端的波特率必须和HC-05模块配置的波特率一致,默认一般是9600或115200,因为PC到HC-05之间是真实的UART链路。这个方案适合“临时应急”,长期做项目还是建议优先走“PC蓝牙适配器”方案,干净、稳定、不需要接线,也省掉了物理层的波特率匹配问题。
3. 安卓APP端开发:从权限配置到Socket收发全流程
PC端准备好了,接下来是安卓端。我会给出一套能直接跑通的最小工程,代码用Java写,因为很多做课程设计和嵌入式方向的同学对Java更熟,Kotlin的版本逻辑完全一样。整体流程分三块:权限、扫描连接、收发数据。
3.1 AndroidManifest权限配置(区分Android 12前后)
蓝牙相关的权限在Android 12(API 31)前后有重大变化,这是初学者最容易踩坑的地方。如果 targetSdk 在 30 及以下,需要在 AndroidManifest.xml 里声明:
<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.ACCESS_FINE_LOCATION" />注意,Android 6到11之间,扫描蓝牙必须申请定位权限,因为系统担心APP通过蓝牙获取位置信息。而且只给权限还不够,部分国产ROM(小米、华为、OPPO等)还要求定位开关本身处于打开状态,否则扫描结果为空。
如果 targetSdk 是 31 及以上,权限声明改成:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />同时建议保留旧的 BLUETOOTH / BLUETOOTH_ADMIN(加上maxSdkVersion="30"),这样老机型也能正常跑。运行时申请权限的代码,我习惯封装成一个方法:
private void requestBtPermissions() { if (Build.VERSION.SDK_INT >= 31) { requestPermissions(new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT}, 101); } else { requestPermissions(new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, 101); } }3.2 扫描、配对、连接三步走的核心代码
获取蓝牙适配器的经典写法是先判空:
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); if (adapter == null) { // 设备不支持蓝牙 return; } if (!adapter.isEnabled()) { startActivityForResult(new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), 1); }扫描设备,通过广播接收 ACTION_FOUND 收集结果:
private final BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (BluetoothDevice.ACTION_FOUND.equals(action)) { BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); if (device != null && device.getName() != null) { // 添加到设备列表,UI刷新 } } } }; IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND); registerReceiver(receiver, filter); adapter.startDiscovery();提示:如果你的 compileSdk 是 33 或更高,动态注册广播时建议带上 RECEIVER_EXPORTED 标志,例如
registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED),否则部分机型会报错。
连接部分的代码,需要放到子线程里执行,不能在主线程直接调用 connect():
private void connect(BluetoothDevice device) { new Thread(() -> { try { BluetoothSocket socket = device.createRfcommSocketToServiceRecord( UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")); socket.connect(); // 连接成功,保存socket,启动收发线程 } catch (IOException e) { e.printStackTrace(); } }).start(); }这里再强调一次:UUID 必须是 00001101-0000-1000-8000-00805F9B34FB,它是SPP的专属标识。开发过程中如果发现连接不了,第一件事就是检查这一行有没有被改错。
3.3 数据收发线程:输入流循环与UI回显
连接建立后,收发数据就是读写两个流。发送端比较简单:
public void sendBytes(byte[] data) { OutputStream os = bluetoothSocket.getOutputStream(); os.write(data); os.flush(); }接收端需要开启一个循环读线程,因为对端随时可能发数据过来。一个常见的坑是:在主线程里读流,一收到数据就卡死UI或者直接崩。所以接收放到后台线程,通过 runOnUiThread 把数据回显到界面:
private void startReceiveThread() { new Thread(() -> { byte[] buffer = new byte[1024]; int bytes; InputStream is = bluetoothSocket.getInputStream(); while (!stopFlag) { try { bytes = is.read(buffer); if (bytes > 0) { byte[] data = new byte[bytes]; System.arraycopy(buffer, 0, data, 0, bytes); runOnUiThread(() -> appendMsg("收到: " + new String(data))); } } catch (IOException e) { break; } } }).start(); }读写共用一把锁可以避免并发写导致的异常,尤其当你在UI线程和定时任务里同时发数据时。如果你打算在多个线程向同一个Socket写数据,最好给 OutputStream 加 synchronized。
3.4 自定义数据帧协议:解决粘包、丢包和误码
在PC和安卓之间传输数据,最简单的是直接发字符串,很多课程设计就这么干了。但在实际需求里,如果数据量大或需要精确控制,就必须定义协议帧。因为蓝牙串口本质是流式传输,接收端没有“分包”的概念,发方连续发多条消息时,收方可能一次性读到多条粘在一起,也可能因为读缓冲拆分导致一条消息分两次收到。
我常用的一个极简帧结构是:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 0xAA 0x55 |
| 命令 | 1字节 | 0x01开、0x02关等 |
| 长度 | 1字节 | 数据域长度 |
| 数据 | n字节 | 业务数据 |
| 校验 | 2字节 | CRC16,低字节在前 |
发送端把帧打包后一次write出去,接收端维护一个“接收缓冲区”,边读边解析:先找帧头,再读长度,凑齐“帧头+命令+长度+数据+校验”一整帧后,校验CRC,通过则回调业务层,否则丢弃并重新同步帧头。这个过程不复杂,但能有效解决粘包问题。
如果你只是收发ASCII文本,联调阶段可以跳过协议设计,但正式做项目前建议补上,这是我从“实验室能跑”到“现场能稳定跑”之间感触最深的一点。
4. PC与安卓联调:从串口助手到双向通信的完整验证
开发完成,正式联调。先准备工具。
4.1 可实际下载使用的调试工具清单
PC端串口助手,我用得最多的是XCOM和SSCOM。
- XCOM:正点原子出品,免费,支持字符串和HEX收发,界面清爽,适合我们这个场景。
- SSCOM:老牌串口调试助手,功能同样齐全,支持定时发送、文件发送。
- 友善串口助手:在调试蓝牙模块的教程里出场率也高。
安卓端如果只是想快速验证蓝牙链路,不写代码也能测,用 Serial Bluetooth Terminal、蓝牙调试器,或者 nRF Connect 的串口功能,随便选一个装上。这些工具可以模拟APP去连接PC,帮助我们判断问题出在链路层还是应用层。
有一点要提醒:PC端串口助手里那个“波特率”设置项,因为前面讲的SPP特性,保持默认9600即可。改成115200也不会影响安卓端接收,不需要在这个选项上纠结。
4.2 联调步骤与预期结果
完整联调步骤如下:
- PC上按第2章的步骤创建好传入COM口,假设是COM7。
- 打开XCOM,选择COM7,默认波特率9600,点“打开串口”。
- 安卓手机上打开自己写的APP,开始扫描蓝牙。
- 扫描到PC蓝牙名称(一般是电脑名称,如“DESKTOP-xxxx”),点击连接。
- 此时PC端Windows会弹出配对确认或“允许设备连接”的提示,点允许。若没有弹窗,可能手机端已经自动配对成功。
- 安卓APP显示“Connected”,PC串口助手显示COM7已打开。
- 在安卓APP的输入框里输入“Hello PC”,点击发送;PC端XCOM的接收区出现相同内容,说明安卓到PC方向通。
- 在XCOM的发送区输入“Hello Android”,点击发送;安卓APP的TextView里出现相同内容,说明PC到安卓方向通。
到这一步,整条蓝牙串口链路就算打通了。如果两边收不到,不要急着改代码,先按第5章的排查链路走一遍。
4.3 模拟场景:把流程串起来
我给一个很多人在课程设计里会遇到的需求:用手机APP远程告诉PC“打开一个开关”。约定协议:安卓发送 0xAA 0x55 0x01 0x01 0x00 0xCRCH 0xCRCL 表示开;发送 0xAA 0x55 0x02 0x01 0x00 0xCRCH 0xCRCL 表示关。PC端串口助手用HEX模式接收,看到合法的整帧就打印“LED ON”或“LED OFF”。
用这个例子跑一遍,既验证了协议解析,又验证了链路稳定性,还能顺手在报告里截图,比单纯发文本更有说服力。如果PC端想自动处理数据,就用C#或Python写一个小上位机读COM口解析帧,代码量都不大,核心逻辑就是读串口、按帧解析、执行动作。
5. 踩坑实录:连接失败、乱码、断连的完整排查链路
最后这部分是我最想写的,因为这些问题在论坛上反复出现,但很少有文章给出完整的排查顺序。我按现象分类,每条都给出根因和解决方案。
5.1 扫描不到PC蓝牙设备
安卓端扫描不到PC,先确认PC蓝牙是否开启了“可被发现”。在Windows 10/11上,如果“蓝牙设备发现”没设为“高级”,手机可能扫不到,需要先进“设置 -> 蓝牙和其他设备”调整。这一步经常被忽略。
其次,有些廉价USB蓝牙适配器只支持BLE,不支持经典蓝牙的SPP,不过扫描阶段一般还是能扫到,只是连接阶段会失败。所以扫描不到更多是发现模式或驱动问题,建议优先改发现设置。
最后,安卓设备的定位开关如果没打开,扫描结果也可能为空,这是Android 6到11的系统限制,跟PC无关。我见过有人调了半天PC,最后发现手机定位被关了。
5.2 连接成功立刻断开或完全连不上
这类问题按优先级排查:
- UUID写错。必须使用 00001101-0000-1000-8000-00805F9B34FB。
- PC端创建的是传出端口而不是传入端口。传出端口等PC主动去连别的设备,安卓去连它,协议栈没有暴露SPP服务,连接建立不起来。
- 配对完成后没有点Windows的“允许”提示。有些电脑会弹出类似“蓝牙设备希望连接并输入PIN码”的框,忽略会导致连接被拒。
- PC端串口助手没有先打开COM口。虽然连接不一定依赖COM口被打开,但实测中先打开COM口再让安卓连接,成功率更高,建议养成这个顺序。
- 距离太远或干扰严重。蓝牙经典版有效距离一般在10米左右,隔墙、USB3.0设备扎堆、Wi-Fi拥堵都可能造成连接不稳定。
5.3 能连上但数据乱码或收不到
连接成功但数据乱码,先确认编码。PC串口助手如果默认使用ANSI,而安卓new String()默认按UTF-8解码,中文就会乱。解决办法是统一两端编码,要么PC端选择UTF-8,要么安卓端用GBK接收。最稳妥的方式是先在HEX模式下收发纯字节验证链路,字节无误再谈文本编码。
收不到数据,在没有协议的情况下,常见原因是发送端写了但没 flush。OutputStream.write 并不保证立即发到链路,必须调用 flush(),这点在蓝牙Socket上表现很明显。
如果自己定义了协议帧,出现“偶发丢帧”或“解析出错”,几乎都是接收端没有做“缓存+分帧”处理。连续发送两条消息时,底层可能把它们合并成一个 read 返回;如果你只按一次 read 出来的数据去解析,自然就错了。
5.4 关于延迟和吞吐量的一点实际经验
蓝牙SPP主打可靠性,不是高吞吐。如果你通过它传输大文件或者高频传感器数据,会明显感受到延迟。我在实际项目里用它做遥控指令和仪表盘下发,单帧几百字节,频率控制在20Hz以内,运行很稳定。如果需求是高画质视频流或大量日志,SPP不适合,建议换Wi-Fi方案。
还有一点,安卓APP在后台时蓝牙Socket很容易被系统回收,尤其国产ROM。如果要做长期连接,别忘了申请前台服务或保活机制。
到这里,整个PC与安卓APP的蓝牙串口通讯链路已经讲完了。从协议原理、PC端口配置、安卓端代码,到联调工具和排错方案,基本覆盖了我自己从零到一踩过的所有环节。最后想提醒一句:不管你的开发经验多深,联调阶段先拿第三方调试工具把链路验证干净,再动自己的代码,能少走一半弯路。蓝牙串口就是这么个东西,原理一句话,实操全是细节,把它跑通一次,你后面做任何蓝牙设备控制类的项目都会顺手很多。