1. 项目缘起:为什么需要SPP连接Edison与安卓?
几年前,我在一个智能家居的快速原型项目中,遇到了一个典型的“数据桥接”难题。项目核心是一块Intel Edison开发板,它负责采集多个传感器的数据(温湿度、光照、人体感应),而我们需要一个能随时随地查看数据、并能下发简单控制指令的移动端界面。当时,Wi-Fi方案看似直接,但面临几个现实问题:现场Wi-Fi网络不稳定甚至没有;为Edison配置Wi-Fi热点并让手机连接,增加了用户操作的复杂度;更重要的是,在低功耗待机唤醒场景下,Wi-Fi的功耗和连接延迟不太理想。
这时,蓝牙,特别是串行端口配置(Serial Port Profile, SPP)进入了视野。它本质上是在蓝牙通道上模拟了一条古老的、可靠的RS-232串行线。对于开发者而言,这意味着你可以像操作/dev/ttyUSB0或COM3一样,通过读写一个虚拟的串口来收发数据,上层应用几乎无需关心底层的蓝牙射频细节。将Edison通过SPP连接到安卓手机,就等于在两者之间建立了一条双向、稳定、低功耗的专用数据通道。手机无需SIM卡或外部Wi-Fi,直接变身为一个移动数据终端和控制器,这对于户外设备、可穿戴原型、移动机器人或者任何需要设备与手机“直连”的场景,是极其优雅的解决方案。
然而,在实际操作中,从“知道SPP能行”到“稳定跑通”,中间隔着一堆琐碎但关键的细节:Edison上的蓝牙服务如何正确配置并自启动?安卓端如何绕过系统限制,实现稳定的连接和数据读写?协议帧如何设计才能避免粘包?这些正是本篇文章要拆解的核心。
2. Edison端配置:让蓝牙服务“立”起来
要让Edison能被手机发现并连接,首先需要将其配置为一个SPP服务器。这个过程远不止安装一个软件包那么简单,它涉及到Linux蓝牙协议栈的操作、服务的注册以及权限管理。
2.1 系统准备与蓝牙协议栈检查
Edison默认的Yocto系统通常包含了蓝牙工具,但我们需要确保其完整性和可用性。首先通过SSH登录到Edison。
# 更新软件包列表并安装必要的蓝牙工具 opkg update opkg install bluez5-dev bluez5-noinst-tools bluez5-utils安装完成后,检查蓝牙控制器状态:
rfkill list # 你应该看到类似下面的输出,确保蓝牙没有被软阻塞(Soft blocked: no) # 0: phy0: wlan # Soft blocked: no # Hard blocked: no # 1: hci0: Bluetooth # Soft blocked: no # Hard blocked: no # 启动蓝牙服务并设置开机自启 systemctl start bluetooth systemctl enable bluetooth # 查看蓝牙适配器 hciconfig -ahciconfig的输出应显示hci0设备,并且UP RUNNING标志是激活的。如果状态是DOWN,使用hciconfig hci0 up来启动它。
2.2 使用rfcomm绑定SPP服务
传统且最直接的方法是使用rfcomm工具。rfcomm是RFCOMM协议(SPP所基于的协议)的绑定工具,它可以在文件系统创建一个虚拟的串口设备。
首先,我们需要编写一个SPP服务定义文件,告诉系统如何对外提供这个服务。创建文件/etc/systemd/system/spp-server.service:
[Unit] Description=SPP Server via RFCOMM After=bluetooth.service Requires=bluetooth.service [Service] Type=simple ExecStart=/usr/bin/rfcomm watch hci0 1 /sbin/agetty -L ttySPP 115200 vt100 Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target这个服务文件做了几件关键事:
rfcomm watch hci0 1:监听第一个蓝牙适配器(hci0)上的通道1。通道1是SPP的常用预留通道。当有远程设备连接时,rfcomm会创建一个对应的设备文件(通常是/dev/rfcomm0)。/sbin/agetty -L ttySPP 115200 vt100:为创建的/dev/rfcomm0设备附加一个getty会话,并将其符号链接到ttySPP,设置波特率为115200。这步非常关键,它使得这个蓝牙串口看起来和行为上都像一个真正的物理串口,上层应用(如Python的pyserial)可以直接打开/dev/ttySPP进行读写。Restart=on-failure:确保服务意外退出后能自动重启,增强可靠性。
创建好服务文件后,设置权限并启动服务:
chmod 644 /etc/systemd/system/spp-server.service systemctl daemon-reload systemctl start spp-server.service systemctl enable spp-server.service现在,Edison的SPP服务器已经在后台运行并监听连接了。
2.3 配置蓝牙可见性与配对
为了让安卓手机能发现Edison,我们需要设置蓝牙可被发现(Discoverable),并通常建议使用简单配对码(PIN)来提升安全性。
我们可以创建一个脚本/usr/local/bin/setup-bluetooth.sh来完成这些一次性设置:
#!/bin/bash # 设置蓝牙设备名称 hciconfig hci0 name 'MyEdison-SPP' # 设置可被发现模式,超时时间设为0表示持续可见(可根据需要调整) bluetoothctl discoverable on bluetoothctl pairable on # 设置简单的PIN码,这里设为1234 echo “agent on\ndefault-agent\nexit” | bluetoothctl给脚本执行权限并运行一次:chmod +x /usr/local/bin/setup-bluetooth.sh && /usr/local/bin/setup-bluetooth.sh。
注意:在生产环境中,持续“可被发现”存在安全风险。更优的做法是,通过一个物理按钮或某个特定的软件触发信号,临时开启可被发现模式,配对完成后自动关闭。这可以通过在Edison上运行一个小的守护进程,监听GPIO或网络请求来实现。
至此,Edison端的准备工作就完成了。它现在应该会以“MyEdison-SPP”的名称出现在手机的蓝牙设备列表中。
3. 安卓端开发:构建稳定的SPP客户端
安卓端相对复杂,因为Google自Android 4.4(API 19)起,虽然保留了SPP的API支持,但不再在官方文档中重点推荐,转而主推BLE(低功耗蓝牙)。不过,经典蓝牙的SPP API(BluetoothSocket)依然完全可用且稳定,只是需要处理好运行时权限和后台连接稳定性。
3.1 权限声明与特性检查
在AndroidManifest.xml中,必须声明蓝牙权限:
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <!-- 对于Android 12 (API 31)及以上,还需要声明更精确的权限 --> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-feature android:name="android.hardware.bluetooth" android:required="true" />在代码中,需要动态请求这些危险权限(对于Android 6.0+)。同时,在尝试使用蓝牙前,必须检查设备是否支持蓝牙以及是否已开启:
// 以Kotlin为例,Java逻辑类似 val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() if (bluetoothAdapter == null) { // 设备不支持蓝牙 return } if (!bluetoothAdapter.isEnabled) { // 请求用户开启蓝牙 val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) }3.2 设备发现、配对与连接
发现设备是一个异步过程,需要注册一个BroadcastReceiver来监听BluetoothDevice.ACTION_FOUND广播。这里有一个关键点:为了过滤掉大量不相关的BLE设备,我们可以在BroadcastReceiver中检查设备的蓝牙类型BluetoothDevice.DEVICE_TYPE_CLASSIC,或者通过尝试获取其BluetoothClass来判断它是否可能支持SPP(通常具有BluetoothClass.Service.SERIAL_PORT服务)。
找到名为“MyEdison-SPP”的设备后,在发起连接前,通常需要先配对。配对过程可能由系统自动触发,但为了更好的用户体验,可以引导用户通过系统界面完成:
val device: BluetoothDevice = ... // 从广播中获取的设备对象 // 检查配对状态 if (device.bondState != BluetoothDevice.BOND_BONDED) { // 创建配对请求(系统会弹出对话框) device.createBond() }配对成功后,就可以建立SPP连接了。连接必须在后台线程中进行,因为它是一个阻塞式调用:
// 使用已知的SPP UUID val sppUUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") val socket: BluetoothSocket = device.createRfcommSocketToServiceRecord(sppUUID) // 尝试连接 try { socket.connect() // 阻塞调用,必须在子线程执行 // 连接成功! val inputStream: InputStream = socket.inputStream val outputStream: OutputStream = socket.outputStream // 启动单独的线程来循环读取数据 startReadingThread(inputStream) } catch (e: IOException) { // 连接失败处理 Log.e(TAG, "Could not connect to SPP device", e) // 备选方案:尝试反射调用 createRfcommSocket,兼容某些特定设备 try { val m = device.javaClass.getMethod("createRfcommSocket", Int::class.javaPrimitiveType) val fallbackSocket = m.invoke(device, 1) as BluetoothSocket fallbackSocket.connect() // 使用 fallbackSocket... } catch (e2: Exception) { // 备选方案也失败 } }实操心得:
socket.connect()在某些国产定制安卓系统上可能会失败,报“Service discovery failed”之类的错误。这是一个经典的兼容性问题。上面代码中的“备选方案”通过反射直接调用createRfcommSocket(1),绕过了标准的服务发现过程,强制使用通道1连接,这个方法在绝大多数情况下都能奏效,可以说是安卓SPP开发的“保命符”。
3.3 数据读写与连接保活
连接建立后,数据的读写就是典型的流操作。但有几个陷阱需要注意:
- 粘包与拆包:蓝牙串口是流式传输,没有消息边界。如果你发送“HelloWorld”,对方可能一次收到“HelloWorld”,也可能分两次收到“Hello”和“World”。必须在应用层设计简单的协议帧。最常用的方法是“长度+数据”格式:先发送一个固定字节(如2字节)表示后续数据体的长度,再发送数据体。接收方先读长度,再根据长度读取完整的数据体。
- 读写线程分离:必须在独立的线程中进行持续的
inputStream.read()操作,避免阻塞主线程(UI线程)。同时,写操作outputStream.write()也最好放在一个队列中由单独线程处理,或至少确保写操作不会长时间阻塞。 - 连接保活与重连:蓝牙连接可能因距离、干扰或系统省电策略而中断。一个健壮的客户端需要实现心跳机制(定期发送小数据包)来检测连接活性,并在断开时尝试自动重连。重连逻辑需要包含退避策略(例如,断开后等待时间逐渐延长),避免频繁重试耗尽电量。
4. 通信协议设计与数据格式约定
当物理连接建立后,应用层协议是保证双方正确理解数据含义的关键。对于Edison和安卓之间的交互,我们需要定义一套简单高效的指令与数据格式。
4.1 帧结构设计
一个推荐的基本帧结构如下:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头 | 2 | 固定值,如0xAA55,用于标识帧的开始,便于接收方同步。 |
| 数据长度 (L) | 2 | 无符号短整型,表示命令字+数据载荷的总字节数。 |
| 命令字 | 1 | 标识本条消息的类型或意图(如:0x01=上报传感器数据,0x02=控制指令)。 |
| 数据载荷 | L-1 | 变长,具体内容由命令字定义。 |
| 校验和 | 1 | 简单的算术和校验或CRC8校验,用于验证帧在传输过程中的完整性。校验范围通常涵盖从“数据长度”到“数据载荷”结束的所有字节。 |
例如,Edison要上报温度25.6℃和湿度60%,可以设计命令字0x01,数据载荷为4字节:2字节温度(整数,放大10倍,256表示25.6℃),2字节湿度(整数,60表示60%)。那么一帧数据可能是:AA55 0005 01 0100 3C00 XX(其中0005是长度,01是命令字,0100是256的十六进制,3C00是60的十六进制,XX是校验和)。
4.2 数据流解析状态机
在接收端(无论是Edison还是安卓),解析这样的帧不能简单地指望一次read就能拿到完整一帧。必须实现一个状态机(State Machine)来解析字节流。
一个简单的状态机可以包含以下几个状态:
- 寻找帧头:持续读取字节,直到连续两个字节匹配
0xAA55。 - 读取长度:读取接下来的2个字节,解析出长度L。
- 读取数据:根据长度L,读取后续的(命令字+数据载荷)共L个字节。
- 读取校验和:读取1个字节的校验和。
- 校验与处理:计算接收数据的校验和,与收到的校验和比对。如果一致,则将完整的帧交给业务逻辑处理;如果不一致,则丢弃该帧,状态机回到“寻找帧头”状态,并记录错误。
这种设计能有效处理粘包、拆包以及传输中可能出现的字节错误。
5. 实战调试与常见问题排查
理论完备后,实战中总会遇到问题。以下是我在多个项目中总结的排查链路和解决方案。
5.1 Edison端服务启动失败或手机搜不到设备
- 症状:
systemctl status spp-server.service显示失败,或手机蓝牙列表里看不到“MyEdison-SPP”。 - 排查步骤:
- 检查蓝牙硬件状态:
hciconfig -a确认hci0状态为UP RUNNING。如果不是,尝试rfkill unblock bluetooth和hciconfig hci0 up。 - 检查服务日志:
journalctl -u spp-server.service -f查看实时日志。常见错误是rfcomm命令找不到或参数错误。确保rfcomm和agetty的路径正确(使用which rfcomm和which agetty确认)。 - 检查可见性:在Edison上执行
bluetoothctl,然后输入discoverable on和pairable on。确保没有其他进程(如蓝牙守护进程的自动管理)在关闭可见性。 - 防火墙或SELinux:极少数情况下,SELinux可能会阻止蓝牙相关操作。可以临时设置为宽容模式测试:
setenforce 0。但生产环境需配置正确的策略。
- 检查蓝牙硬件状态:
5.2 安卓端连接被拒绝或立即断开
- 症状:
socket.connect()抛出IOException: read failed, socket might closed or timeout, read ret: -1或连接成功但瞬间断开。 - 排查步骤:
- 确认UUID:确保使用的是SPP的标准UUID:
00001101-0000-1000-8000-00805F9B34FB。一个字母都不能错。 - 尝试反射方法:如前文所述,优先使用反射调用
createRfcommSocket(1)的方法进行连接,这是解决大部分安卓机连接问题的关键。 - 检查配对状态:确保在连接前,设备已成功配对。有时系统配对界面看似完成,但
device.bondState可能还未更新。可以尝试在连接前增加一个短暂延迟,或监听BluetoothDevice.ACTION_BOND_STATE_CHANGED广播确认。 - 权限问题:对于Android 12+,确保在连接前已经获得了
BLUETOOTH_CONNECT运行时权限,而不仅仅是在清单中声明。
- 确认UUID:确保使用的是SPP的标准UUID:
5.3 连接不稳定,数据传输时断时续
- 症状:连接成功后,使用一段时间自动断开,或数据收发不完整。
- 排查步骤:
- 距离与干扰:蓝牙经典协议(BR/EDR)的有效距离通常在10米内,且容易被2.4GHz频段的其他设备(如Wi-Fi路由器、微波炉)干扰。确保设备在有效范围内,并远离强干扰源。
- 安卓电源管理:这是最常见的原因之一。安卓系统为了省电,会在应用进入后台后限制其网络活动(包括蓝牙Socket)。你需要:
- 使用
ForegroundService(前台服务)来维持蓝牙连接和通信。 - 在Service中调用
startForeground()并提供一个持续的通知。 - 使用
WakeLock(唤醒锁)来防止CPU休眠,但需谨慎使用,避免耗电过快。
- 使用
- 心跳与超时:实现应用层心跳包。例如,安卓端每30秒发送一个特定命令字(如
0x00)的空帧,Edison收到后回复同样的帧。如果连续3次收不到回复,则认为连接已断,触发重连逻辑。Edison端也应做类似检测。 - 缓冲区处理:确保读写线程的缓冲区大小设置合理,并及时清空。如果接收方处理速度慢,发送方持续快速发送,可能导致内部缓冲区溢出,引发连接重置。
5.4 数据解析错乱或丢包
- 症状:能收到数据,但解析出来的命令字、长度经常不对,或者数据内容混乱。
- 排查步骤:
- 验证帧结构:在开发初期,将收发到的所有原始字节以十六进制形式打印出来(Logcat或Edison的syslog)。对照你设计的帧结构,人工检查几帧数据,确认发送端组帧是否正确。
- 检查状态机逻辑:重点检查接收方状态机的实现。特别是在“寻找帧头”状态,收到
0xAA后下一个字节不是0x55时,状态是否正确回退。一个常见的错误是状态转换逻辑有误,导致一旦失步就无法恢复。 - 校验和算法:确认发送端和接收端的校验和计算算法完全一致。建议使用CRC8等比简单求和更可靠的校验算法。
- 并发访问:确保对
InputStream和OutputStream的访问是线程安全的。避免多个线程同时调用outputStream.write(),这会导致数据交叉写入,形成乱帧。
通过以上五个部分的拆解,从项目动机、服务端配置、客户端开发、协议设计到实战排错,我们完成了一个通过SPP将Edison连接至安卓手机的完整闭环。这套方案虽然基于“古老”的蓝牙经典协议,但其稳定、直接、低延迟的特性,在诸多物联网原型开发、工业数据采集、机器人遥控等场景下,依然是连接嵌入式设备与智能移动终端的高效桥梁。关键在于理解每个环节背后的原理,并准备好应对实际部署中那些“教科书”上不会写的兼容性与稳定性挑战。