在许多嵌入式项目绕不开短距离无线通信,而蓝牙依然是成本与功耗折中的一个稳妥选择。在 Linux 环境下,这一切绕不开 BlueZ——它既是官方蓝牙协议栈,也是一套庞大而沉默的工具集。
这里小哥记录下一个蓝牙通信应用的完整过程:从最基础的配对连接,到建立经典蓝牙串口通道,再到直接与 BLE 设备交换数据。
看完本文,你至少可以让你手头开发板中的蓝牙设备“动起来”了。
1. 实验环境与准备工作
我的板子是自己构建的 Yocto 系统,内核 5.15,外接一个 CSR8510 芯片的 USB 蓝牙适配器。几乎所有免驱适配器都依赖 btusb 内核模块,插入后系统应当自动加载。可以用两条命令快速确认硬件就位:
dmesg | grep Bluetoothhciconfig -a
这里 hciconfig是专门用于控制主机控制器接口(HCI)的蓝牙设备,类似于网络中的 ifconfig,其中-a:显示所有蓝牙设备的详细配置信息(包括活动和非活动设备)。
第一条能看到内核识别到适配器的日志,第二条会列出 hci0 接口详情。如果 hciconfig 提示命令未找到,说明尚未安装 bluez 和 bluez-tools,如果是ubuntu就直接用包管理器补上即可(例如 apt install bluez bluez-tools)。嵌入式平台就需要交叉编译安装了,接着启动系统守护进程并确认其运行:
systemctl start bluetoothsystemctl status bluetooth
这些做完,硬件层面的准备就告一段落。
BlueZ 的用户空间守护进程 bluetoothd 会在后台等待命令,它通过 D-Bus 对外暴露所有功能,而我们常用的 bluetoothctl 只是一个文本交互客户端。对初学者而言,可以把这层关系简单理解为:bluetoothctl 负责把人类意图翻译成 D-Bus 消息,bluetoothd 再据此操作硬件。
2. 用 bluetoothctl 完成配对与连接
进入蓝牙命令行管理:
sudo bluetoothctl
终端会进入 [bluetooth]# 提示符。常规操作流程几乎固定,依次执行:
power onagent ondefault-agentscan on
power on开启蓝牙适配器的电源,使设备进入工作状态。 对应状态:适配器从 Powered: no 变为 Powered: yes。
agent on启动一个 蓝牙代理(agent)。代理负责处理与配对、认证相关的用户交互请求(例如输入 PIN 码、确认配对请求等)。 如果没有开启代理,配对时系统可能无法自动响应,导致连接失败。
default-agent将刚才激活的代理注册为 系统默认代理,意味着所有需要人机交互的蓝牙配对请求都会自动通过这个代理来处理(例如自动接受配对请求),通常与 agent on 配合使用。
scan on启动 设备扫描。适配器会主动侦听周围蓝牙设备发出的广播包(Advertising packets),并将发现的设备列出来, 扫描的目的是 发现附近的蓝牙设备(如鼠标、耳机、手机等)。
此时适配器开始广播扫描,周边处于可发现状态的设备会逐条出现,每一条都带有 MAC 地址和名称。找到目标设备后,记下地址,比如 11:22:33:44:55:66,然后关闭扫描以免占用资源:
scan offpair 11:22:33:44:55:66
配对过程可能提示输入 PIN 码或确认数字。经典蓝牙设备(如 HC-05 模块)默认 PIN 一般是 1234,可以通过 agent 交互完成。配对成功后还需要信任并连接:
trust 11:22:33:44:55:66connect 11:22:33:44:55:66
看到 Connection successful,说明物理链路已经建立。可以用 info 11:22:33:44:55:66 查看设备支持的服务列表。此时如果断开连接,下次可直接在 bluetoothctl 里执行 connect,已信任设备会跳过配对环节。至此,最基本的蓝牙连接已经打通。
3. 像操作串口一样收发数据
经典蓝牙的串口端口协议(SPP)本质是在 L2CAP 之上模拟一个无流控的串口,对于需要透明传输的应用来说非常实用。连接好一个 SPP 设备(例如配置为从机的 HC-05 模块)后,接下来要做的就是把它的 SPP 通道绑定到本地设备节点。
首先确认设备提供的串口通道编号。SPP 一般走 RFCOMM 通道 1,也可以用 sdptool browse 查看服务记录。然后绑定:
sudo rfcomm bind 0 11:22:33:44:55:66 1
这条命令会在 /dev 下创建 rfcomm0。从现在起,任何能够操作串口的程序都可以直接打开这个设备,无需关心底层蓝牙细节,这又回到了大家熟悉的设备操作了。在一个终端里保持监听:
sudo cat /dev/rfcomm0
再开另一个终端,向设备写入数据:
echo "hello from i.MX6" | sudo tee /dev/rfcomm0
如果蓝牙模块的另一端连接着一个串口调试助手,会立刻看到 “hello from i.MX6”。反之,对方发来的数据也会实时出现在 cat 的输出里。这本质上就完成了一条无线串口线。
使用结束后需要释放绑定,避免下次绑定失败:
sudo rfcomm release 0
注意,RFCOMM 绑定的设备文件不带波特率属性,因为蓝牙串口仿真不处理硬件流控和时序。如果后续需要用 minicom 这类工具操作,可以忽略其端口设置,直接打开即可。不过有些工具的交互逻辑会假定波特率存在,这时直接用 cat/echo 反而更可靠。
4. BLE 设备:进入 GATT 菜单直接读写特征
低功耗蓝牙(BLE)的设备交互不再模拟串口,而是基于属性协议(ATT)的 GATT 客户端/服务器模型。bluetoothctl 内置了一个 GATT 子菜单,可以交互式地浏览服务与特征,并完成读写操作。
以一块典型 BLE 传感器标签为例,首先扫描 BLE 设备,需要指明 le 参数:
scan le
发现目标设备后,同样执行 trust 和 connect。
连接成功后,输入:
menu gatt
终端会切换为 [bluetooth]# 下的 GATT 子模式。此后的命令都用于操作服务清单。先列出所有属性:
list-attributes
输出会是一长串类似这样的 D-Bus 对象路径:
/org/bluez/hci0/dev_11_22_33_44_55_66/service000a/org/bluez/hci0/dev_11_22_33_44_55_66/service000a/char000b/org/bluez/hci0/dev_11_22_33_44_55_66/service000a/char000d...
每个特征都会显示 UUID 和属性标志(read、write、notify 等)。如果想读取某个特征值,先用 select-attribute 选中,再用 read 获取:
select-attribute /org/bluez/hci0/dev_11_22_33_44_55_66/service000a/char000bread
返回值会以十六进制字符串呈现。例如设备名称特征 2A00,读出来是 54 65 6d 70 20 31,解码后就是 Temp 1。如果特征支持通知,可以这样开启:
notify on
此后一旦传感器主动上报数据,终端会持续打印形如 [CHG] Attribute ... Value: ... 的消息。要写特征值也很直接:
select-attribute /.../char000dwrite 0x01
这里写入的值需要根据目标特征的具体协议来定,比如向某个配置特征写入 0x01 使能通知。操作结束后,输入 back 退出 GATT 菜单,回到主界面。整个过程不需要额外工具,仅靠 bluetoothctl 便完成了 GATT 的发现、读取、写入和通知订阅,用于快速验证 BLE 协议非常便捷。
5.C语言代码控制
bluetoothctl 的交互式操作适合调试与手工测试,但若要嵌入应用逻辑,就需要考虑程序化接口。
一种轻量方案是编写 shell 脚本,利用 bluetoothctl 的 -- 参数传入单条命令,或者结合 expect 工具模拟交互。但更具工程规范的做法是直接与 BlueZ 的 D-Bus API 对话:Python 下可以使用 pydbus 或更轻量的 bleak 库;C 语言项目则可直接基于 libdbus 或 GLib 的 GDBus 进行调用。这些 API 能够完整控制扫描、配对、GATT 操作,并且能高效地处理多设备并发。
下面是一个使用 HCI Socket 直接与蓝牙控制器交互的 C 语言程序示例。它可以程序化地执行 BLE 扫描,而无需依赖 bluetoothctl 交互式命令。
#include<stdio.h>#include<stdlib.h>#include<unistd.h>#include<signal.h>#include<sys/socket.h>#include<bluetooth/bluetooth.h>#include<bluetooth/hci.h>#include<bluetooth/hci_lib.h>staticvolatileint running = 1;voidsigint_handler(int sig){ running = 0;}intmain(int argc, char **argv){int dev_id = 0; // 使用 hci0int sock;int len;unsignedchar buf[HCI_MAX_EVENT_SIZE];structhci_filterflt;int scan_time = 5; // 默认扫描 5 秒 le_set_scan_parameters_cp scan_params_cp; le_set_scan_enable_cp scan_enable_cp;if (argc > 1) { scan_time = atoi(argv[1]); }// 信号处理,支持 Ctrl+C 退出 signal(SIGINT, sigint_handler);// 1. 获取 HCI 设备的 socket sock = hci_open_dev(dev_id);if (sock < 0) { perror("打开 HCI socket 失败");return1; }// 2. 设置过滤器,只接收 LE Meta 事件(包含广播包) hci_filter_clear(&flt); hci_filter_set_ptype(HCI_EVENT_PKT, &flt); hci_filter_set_event(EVT_LE_META_EVENT, &flt);if (setsockopt(sock, SOL_HCI, HCI_FILTER, &flt, sizeof(flt)) < 0) { perror("设置过滤器失败"); close(sock);return1; }// 3. 设置 LE 扫描参数(被动扫描,间隔 0x30,窗口 0x30,无重复过滤)memset(&scan_params_cp, 0, sizeof(scan_params_cp)); scan_params_cp.type = 0x01; // 被动扫描(不发送扫描请求) scan_params_cp.interval = htobs(0x30); // 扫描间隔 (0x30 = 48 * 0.625ms = 30ms) scan_params_cp.window = htobs(0x30); // 扫描窗口 (必须 <= 间隔) scan_params_cp.own_bdaddr_type = 0x00; // 公共地址 scan_params_cp.filter_policy = 0x00; // 接受所有广播if (hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_PARAMETERS,sizeof(scan_params_cp), &scan_params_cp) < 0) { perror("设置扫描参数失败"); close(sock);return1; }// 等待命令完成if (hci_command_complete(sock, NULL, 1) < 0) { perror("等待命令完成超时"); close(sock);return1; }// 4. 启用扫描memset(&scan_enable_cp, 0, sizeof(scan_enable_cp)); scan_enable_cp.enable = 0x01; // 启用 scan_enable_cp.filter_dup = 0x00; // 不去重(显示所有包)if (hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE,sizeof(scan_enable_cp), &scan_enable_cp) < 0) { perror("启用扫描失败"); close(sock);return1; }if (hci_command_complete(sock, NULL, 1) < 0) { perror("等待启用命令完成超时"); close(sock);return1; }printf("正在扫描 BLE 设备 %d 秒... (按 Ctrl+C 提前结束)\n", scan_time);printf("%-20s %-8s %s\n", "设备地址", "RSSI", "数据(前8字节)");// 5. 循环读取事件time_t start = time(NULL);while (running && (time(NULL) - start) < scan_time) { len = read(sock, buf, sizeof(buf));if (len < 0) { perror("读取事件失败");break; }// 解析 HCI 事件 evt_le_meta_event *meta = (evt_le_meta_event*)(buf + (1 + HCI_EVENT_HDR_SIZE));if (buf[0] == HCI_EVENT_PKT && buf[1] == EVT_LE_META_EVENT) {if (meta->subevent == EVT_LE_ADVERTISING_REPORT) { le_advertising_info *adv = (le_advertising_info*)(meta->data);int rssi = (signedchar)adv->data[adv->length];char addr[18]; ba2str(&adv->bdaddr, addr);// 打印设备地址和 RSSI,以及部分原始数据printf("%-20s %-8d ", addr, rssi);for (int i = 0; i < 8 && i < adv->length; i++) {printf("%02X ", adv->data[i]); }printf("\n"); } } }// 6. 停止扫描memset(&scan_enable_cp, 0, sizeof(scan_enable_cp)); scan_enable_cp.enable = 0x00; // 禁用 hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE,sizeof(scan_enable_cp), &scan_enable_cp); hci_command_complete(sock, NULL, 1); close(sock);printf("\n扫描结束。\n");return0;}
注意以上只是简单演示了一下扫描过程,通过 HCI socket 和 libbluetooth 库,打开 hci0 设备,设置被动扫描参数,接收并打印广播包中的设备地址、RSSI 及原始数据,扫描预设时间后自动停止。编译时需链接 -lbluetooth。
如果要进一步交互,因直接操作 HCI socket 实现 GATT 通信过于复杂,故采用 D-Bus 完成,通过 D-Bus 与 BlueZ 通信,建立连接后发现服务与特征,并对特征值进行读取或写入(如读电池电量、发送命令)。
持续获取嵌入式实战干货,关注、标星 公众号不错过每一篇技术解析~