0%

STM32——蓝牙BLE通信,用蓝牙无线控制小灯

从有线到无线——蓝牙来了

上篇博客我们学完了 DMA + 空闲中断,串口收发效率达到了极致。但一直是有线连接

这篇,我们把线拔掉——用蓝牙实现无线通信。

先回顾一下过去几篇的通信方式演进:

1
2
3
4
串口轮询:  STM32 ←──USB线──→ 电脑串口助手   CPU全程搬运
串口中断: STM32 ←──USB线──→ 电脑串口助手 CPU收到中断才处理
DMA+空闲: STM32 ←──USB线──→ 电脑串口助手 DMA自动搬运,CPU彻底解放
第1蓝牙BLE: STM32 ←→ 蓝牙模块 ~~无线~~ 手机/电脑 本篇!

蓝牙基础

经典蓝牙 vs BLE

蓝牙分为两种:

经典蓝牙 (Classic) 低功耗蓝牙 (BLE)
典型设备 蓝牙耳机、音箱 运动手环、体测设备
数据传输方式 持续传输(音频流) 间歇性同步(传感器数据)
功耗 较高 极低(纽扣电池可用数月)
我们的模块 — BT24(BLE串口透传模块)

主机与从机

蓝牙通信分两端:

1
2
3
4
5
6
7
8
9
10
11
┌─────────────┐         ┌─────────────────┐
│ 主机 (Master) │ ←扫描─→ │ 从机 (Slave) │
│ 手机/电脑 │ ←连接─→ │ STM32 + BT24 │
│ │ ←数据─→ │ 广播自己的信息 │
└─────────────┘ └─────────────────┘

建立连接过程:
1. 从机向外广播(Advertising):"我是BT24,我可以串口透传"
2. 主机扫描附近蓝牙设备
3. 主机发起连接请求
4. 连接建立 → 双方通过约定协议通信

蓝牙串口透传模块——BT24

蓝牙协议本身很复杂(GAP 广播、GATT 服务、Service/Characteristic 等),但keysking的学习套件里有一枚 BT24 蓝牙串口透传模块。

“透传” 的意思很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
手机 app 发送 "R1"
│
▼ (蓝牙无线)
BT24 模块 收到 "R1"
│
▼ (串口 TX → STM32 RX)
STM32 USART3 收到 "R1" ← 原封不动!

反过来也一样:
STM32 发送 "OK"
│
▼ (串口 TX → BT24 RX)
BT24 模块 收到 "OK"
│
▼ (蓝牙无线)
手机 app 收到 "OK" ← 原封不动!

模块把复杂的蓝牙协议全部封装起来,对外只暴露一个串口。对 STM32 来说,操作蓝牙模块 = 操作串口——前面学的所有串口知识(轮询、中断、DMA、不定长接收)全部可以直接复用。


硬件连接

学习板上专门为蓝牙模块引出了 USART3 的接口:

1
2
3
4
5
6
7
8
9
10
学习板                       BT24 蓝牙模块
┌──────┐ ┌──────┐
│ 5V ├───────────────────┤ VCC │
│ GND ├───────────────────┤ GND │
│ TX ├───────────────────┤ RX │
│ RX ├───────────────────┤ TX │
└──────┘ └──────┘
▲ ▲
└─ TX/RX 交叉连接 ──────────┘
└─ 5V供电 + 共地

使用学习套件附赠的 4pin 杜邦线,一端接学习板的 USART3 接口,另一端接蓝牙模块。务必注意线序:VCC ↔ 5V,GND ↔ GND,TX ↔ RX(交叉),RX ↔ TX(交叉)。


CubeMX 配置

新建工程

芯片选 STM32F103C8T6,工程名设为 Ble。

配置调试接口

System Core → SYS → Debug: Serial Wire(老规矩)。

配置 USART3——蓝牙通信通道

在 Connectivity → USART3 中,Mode 设为 Asynchronous:

参数 值 说明
Baud Rate 9600 ⚠️ BT24 模块默认波特率是 9600,不是 115200!
Word Length 8 Bits 标准配置
Parity None 无校验
Stop Bits 1 1位停止位

image-20260730220537770

为什么是 9600 而不是 115200? BT24 蓝牙模块出厂默认波特率是 9600。蓝牙无线传输速率有限,9600 已经足够。如果设成 115200,模块和 STM32 的波特率不匹配,收到的将全是乱码。

开启 USART3 中断 + DMA

在 NVIC Settings 中勾选 USART3 global interrupt。image-20260730220606895

在 DMA Settings 中,点击 Add 添加两个 DMA 通道:image-20260730220624044

USART3_TX(发送):

参数 值
Direction Memory To Peripheral
Priority Low
Mode Normal
Data Width Byte

USART3_RX(接收):

参数 值
Direction Peripheral To Memory
Priority Low
Mode Normal
Data Width Byte

CubeMX 会自动分配 DMA1 的通道 2 给 USART3_RX,通道 3 给 USART3_TX。

配置 LED 引脚

三个 LED 和之前完全一样:

引脚 User Label
PB0 LED_RED
PA7 LED_GREEN
PA6 LED_BLUE

均设为 GPIO_Output(推挽输出、无上下拉、低速)。

生成代码

Project Manager 中 Toolchain 选 CMake,点击 Generate Code。


VS Code 中编写核心代码

和上篇(DMA+空闲中断)代码的对比

这篇的代码和上篇很多一样——只是串口从 USART2 换成了 USART3,波特率从 115200 换成了 9600,然后再加上了数据包解析逻辑。

变化点 第10集 (DMA串口) 本篇 (蓝牙)
使用的串口 USART2 USART3
波特率 115200 9600(BT24 默认)
接收函数 HAL_UARTEx_ReceiveToIdle_DMA 相同
回调函数 HAL_UARTEx_RxEventCallback 相同
新增 — 数据包解析(包头+长度+校验和)

main.c 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/* USER CODE BEGIN PV */
// 存放蓝牙接收数据的数组——向上篇一样设为50字节,给不定长数据留足空间
uint8_t receiveData[50];
/* USER CODE END PV */

/* USER CODE BEGIN 0 */
// 串口空闲中断回调函数——蓝牙数据到了自动调用
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if(huart==&huart3) // ① 判断是USART3(蓝牙)
{
HAL_UART_Transmit_DMA(&huart3, receiveData, Size); // ② 回显收到的数据

// ③ 验证数据包:包头必须是 0xAA
// 数据包长度位必须等于实际接收长度
if(receiveData[0]==0xAA && receiveData[1]==Size)
{
uint8_t sum=0;

// ④ 计算校验和:把包头到数据最后一位之前的所有字节相加
for(int i=0;i<Size-1;i++)
sum+=receiveData[i];

// ⑤ 验证校验和:计算出来的和 == 数据包最后一位?
if(sum==receiveData[Size-1])
{
// ⑥ 解析数据内容:从第2字节开始,每2个字节为一组
for(int i=2;i<Size-1;i+=2)
{
GPIO_PinState state = GPIO_PIN_SET; // 默认亮
if (receiveData[i+1] == 0x00) // 每组的第2字节是0x00→灭
state = GPIO_PIN_RESET;

// ⑦ 每组的第1字节决定控制哪个灯
switch (receiveData[i])
{
case 0x01: // 0x01 = 红色
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, state);
break;
case 0x02: // 0x02 = 绿色
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, state);
break;
case 0x03: // 0x03 = 蓝色
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, state);
break;
}
}
}
}

// ⑧ 重新启动接收 + 关闭DMA过半中断(和上篇一样)
HAL_UARTEx_ReceiveToIdle_DMA(&huart3, receiveData, sizeof(receiveData));
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx, DMA_IT_HT);
}
}
/* USER CODE END 0 */

int main(void)
{
// …… HAL_Init / SystemClock_Config / MX_GPIO_Init / MX_DMA_Init / MX_USART3_UART_Init ……

/* USER CODE BEGIN 2 */
// 启动蓝牙串口不定长DMA接收
HAL_UARTEx_ReceiveToIdle_DMA(&huart3, receiveData, sizeof(receiveData));
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx, DMA_IT_HT);
/* USER CODE END 2 */

while (1)
{
// 空的——蓝牙数据由中断+DMA驱动
}
}

代码讲解

用到的 HAL 函数:

函数 作用
HAL_UARTEx_ReceiveToIdle_DMA(&huart3, buf, maxlen) 启动 USART3 的不定长 DMA 接收(空闲中断触发回调)
HAL_UART_Transmit_DMA(&huart3, buf, len) 用 DMA 从 USART3 发送数据(回显给蓝牙模块)
__HAL_DMA_DISABLE_IT(&hdma_usart3_rx, DMA_IT_HT) 关闭 DMA 接收过半中断(防重复回调)
HAL_GPIO_WritePin(...) 老朋友——控制 LED

逻辑流程(按回调函数中的执行顺序):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
蓝牙数据到达 USART3
│
▼
① 判断是 huart3(蓝牙串口)触发的
│
▼
② 回显收到的数据(发回蓝牙模块→手机上看到回显)
│
▼
③ 验证包头:receiveData[0] 是否为 0xAA?
验证长度:receiveData[1] 是否等于 Size?
│ ✅ 通过
▼
④ 计算校验和:把前 Size-1 个字节累加
│
▼
⑤ 比对校验和:sum == receiveData[Size-1]?
│ ✅ 通过
▼
⑥ 解析数据:从 receiveData[2] 开始,
每2字节一组的格式是 [灯号, 状态]
│
▼
⑦ 控制LED:0x01→红 / 0x02→绿 / 0x03→蓝
0xFF→亮 / 0x00→灭
│
▼
⑧ 重新启动接收 + 关闭过半中断

通信协议设计

本次的数据包格式比之前(”R1、G0”文本协议)更正规——采用了十六进制二进制协议:

数据包结构

1
2
3
4
5
┌────────┬────────┬──────────┬──···──┬────────┐
│ 包头 │ 长度 │ 数据段 │ 校验和 │
│ 0xAA │ N │ (N-3)字节 │ 1字节 │
│ 1字节 │ 1字节 │ 变长 │ 累加取低8位│
└────────┴────────┴──────────┴──···──┴────────┘
位置 字段 说明
receiveData[0] 包头 (Header) 固定 0xAA,标识一帧数据的开始
receiveData[1] 数据包长度 包括包头、长度位、数据和校验和的总字节数
receiveData[2] ~ [Size-2] 数据段 (Payload) 每2字节一组:[灯号, 状态],可有多组
receiveData[Size-1] 校验和 (Checksum) 前 Size-1 字节的累加和,取低8位

数据段编码

灯号 含义
0x01 🔴 红色 LED
0x02 🟢 绿色 LED
0x03 🔵 蓝色 LED
状态 含义
0xFF 亮起
0x00 熄灭

示例

让红灯亮起:

1
2
0xAA  0x05  0x01  0xFF  0xAF
包头 长度=5 红灯 亮 校验和(=0xAA+0x05+0x01+0xFF 的低8位)

让红灯熄灭、绿灯亮起:

1
2
0xAA  0x07  0x01  0x00  0x02  0xFF  0x9D
包头 长度=7 红灯 灭 绿灯 亮 校验和

同时控制红蓝两灯(红灯亮、蓝灯灭):

1
2
0xAA  0x07  0x01  0xFF  0x03  0x00  0xAD
包头 长度=7 红灯 亮 蓝灯 灭 校验和

校验和(Checksum)的原理

“校验和”是一种最简单也最常用的数据完整性校验方法:

1
2
3
4
5
6
7
8
9
10
11
12
发送方:
0xAA + 0x05 + 0x01 + 0xFF = 0x1AF
取低8位 → 0xAF ← 填入数据包最后一位

接收方(STM32):
收到 [0xAA, 0x05, 0x01, 0xFF, 0xAF]
重新计算:0xAA+0x05+0x01+0xFF = 0x1AF → 低8位 = 0xAF
比对:收到的 0xAF == 计算的 0xAF ✅ 数据正确!

如果传输中某一位出错,比如 0x01 变成了 0x02:
重新计算:0xAA+0x05+0x02+0xFF = 0x1B0 → 低8位 = 0xB0
比对:收到的 0xAF ≠ 计算的 0xB0 ❌ 数据错误→丢弃此包

蓝牙调试——波特律动串口助手的蓝牙模式

这次不用插 USB 线了!

电脑端(蓝牙串口助手)

keysking 在波特律动串口助手上专门添加了蓝牙调试功能:

  1. 确保电脑蓝牙已开启
  2. 打开波特律动串口助手
  3. 点击蓝牙图标 → 从普通串口模式切换为蓝牙串口模式
  4. 点击选择蓝牙 → 扫描附近蓝牙设备
  5. 找到 BT24 → 点击配对
  6. 配对成功后,就可以像用普通串口一样收发数据了!

如果扫描不到 BT24,可以按一下蓝牙模块上的复位按钮再试。

自动计算校验和

手动算校验和太痛苦。波特律动串口助手支持自动计算校验和:

  • 点击校验算法选择 → 选择 校验和 (Checksum)
  • 每次发送数据时自动计算并附加校验字节
  • 再也不用打开计算器了!

手机端(蓝牙调试助手)

也可以用手机上的 BLE 调试 app:

  1. 先在电脑端断开蓝牙连接(或按模块复位键)
  2. 手机开启蓝牙 + 授予 app 精确定位权限
  3. 配套资料包中找到 BT24 蓝牙调试助手.apk
  4. 安装后选择透传模式 → 搜索 BLE → 找到 BT24 → 连接
  5. 输入十六进制数据即可发送

效果测试

测试1:电脑蓝牙 → 让红灯亮起

在波特律动串口助手(蓝牙模式)的发送区输入 Hex:

1
AA 05 01 FF AF

点击发送 → 🔴 红灯亮起!同时收到回显 AA 05 01 FF AF。

测试2:自动校验和 → 红灯灭 + 绿灯亮

输入 Hex(注意长度改为 7):

1
AA 07 01 00 02 FF

选择校验和算法后,助手自动在末尾补上 9D → 发送 → 🔴 红灯灭,🟢 绿灯亮!

测试3:手机蓝牙 → 全灯控制

手机上输入 Hex AA 07 01 FF 02 FF 03 FF + 自动校验和 → 三灯全亮!✅

发送内容 效果
AA 05 01 FF + 校验和 🔴 红灯亮
AA 05 01 00 + 校验和 🔴 红灯灭
AA 05 02 FF + 校验和 🟢 绿灯亮
AA 05 03 FF + 校验和 🔵 蓝灯亮
AA 07 01 FF 02 00 + 校验和 🔴 红灯亮 + 🟢 绿灯灭
AA 07 01 FF 02 FF 03 FF + 校验和 三灯全亮

效果展示视频:

https://www.bilibili.com/video/BV1gaGc6WEYQ/?vd_source=5a111e8d49f70e532d36ddb73be3d0aa


解读初始化代码

MX_USART3_UART_Init —— 蓝牙串口初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
static void MX_USART3_UART_Init(void)
{
huart3.Instance = USART3; // (1) 使用USART3(不是USART2)
huart3.Init.BaudRate = 9600; // (2) ⚠️ 波特率9600,不是115200!
huart3.Init.WordLength = UART_WORDLENGTH_8B;
huart3.Init.StopBits = UART_STOPBITS_1;
huart3.Init.Parity = UART_PARITY_NONE;
huart3.Init.Mode = UART_MODE_TX_RX;
huart3.Init.HwFlowCtl = UART_HWCONTROL_NONE;
huart3.Init.OverSampling = UART_OVERSAMPLING_16;
if (HAL_UART_Init(&huart3) != HAL_OK)
Error_Handler();
}
行 代码 和上篇的区别
(1) .Instance = USART3 🔴 USART2 → USART3(蓝牙模块接在 USART3 上)
(2) .BaudRate = 9600 🔴 115200 → 9600(BT24 模块默认波特率)
其余 数据位8、停止位1、无校验…… ✅ 与之前完全相同

⚠️ 波特率必须匹配——BT24 默认 9600,如果把 STM32 的 USART3 设成 115200,两边波特率不同,通信会完全失败。

MX_DMA_Init —— DMA 初始化

1
2
3
4
5
6
7
8
9
10
11
12
static void MX_DMA_Init(void)
{
__HAL_RCC_DMA1_CLK_ENABLE();

/* DMA1_Channel2_IRQn → USART3_RX */
HAL_NVIC_SetPriority(DMA1_Channel2_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(DMA1_Channel2_IRQn);

/* DMA1_Channel3_IRQn → USART3_TX */
HAL_NVIC_SetPriority(DMA1_Channel3_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(DMA1_Channel3_IRQn);
}
对比 上篇(USART2) 本篇(USART3)
RX 通道 DMA1_Channel6 DMA1_Channel2
TX 通道 DMA1_Channel7 DMA1_Channel3

每个 USART 外设的 DMA 请求绑定到不同的 DMA 通道。USART3_RX 对应 DMA1 的通道 2,USART3_TX 对应通道 3——这是 STM32 硬件设计固定的。

USART3 的引脚映射

USART3 的 TX 和 RX 分别使用 PB10 和 PB11(不像 USART2 用 PA2/PA3)。在 HAL_UART_MspInit 中可以看到:

1
2
3
4
5
6
7
8
// 在 stm32f1xx_hal_msp.c 中
GPIO_InitStruct.Pin = GPIO_PIN_10; // PB10 → USART3_TX
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; // 复用推挽输出
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

GPIO_InitStruct.Pin = GPIO_PIN_11; // PB11 → USART3_RX
GPIO_InitStruct.Mode = GPIO_MODE_INPUT; // 浮空输入
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

代码结构总览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
全局变量区:
receiveData[50] ← 蓝牙接收缓冲区
huart3 ← USART3 句柄(蓝牙通道)
hdma_usart3_rx / _tx ← DMA 通道句柄

main():
HAL_Init()
SystemClock_Config()
MX_GPIO_Init() ← LED 引脚配置
MX_DMA_Init() ← DMA 通道2/3 初始化
MX_USART3_UART_Init() ← USART3 9600波特率
HAL_UARTEx_ReceiveToIdle_DMA() ← 启动蓝牙接收
__HAL_DMA_DISABLE_IT(HT) ← 关闭过半中断
while(1) { } ← 空的!蓝牙由中断驱动

回调函数:
HAL_UARTEx_RxEventCallback:
├ 验证包头 0xAA + 长度
├ 计算校验和
├ 比对校验和
├ 解析每2字节:[灯号,状态]
├ 控制LED (0x01/0x02/0x03 + 0xFF/0x00)
├ 重启接收 (ReceiveToIdle_DMA)
└ 关闭过半中断 (__HAL_DMA_DISABLE_IT)

和上一篇代码的三大变化

# 变化 第10集 本篇
1 串口号 USART2 USART3(蓝牙模块接在这里)
2 波特率 115200 9600(BT24 模块默认)
3 数据解析 无(直接回显) 新增:包头验证 + 长度验证 + 校验和 + 多组指令解析

核心的 DMA+空闲中断接收机制 —— HAL_UARTEx_ReceiveToIdle_DMA + HAL_UARTEx_RxEventCallback —— 完全一样。这说明一通百通,掌握了串口通信的底层机制后,换一个外设(蓝牙模块)只是换一个串口号和波特率而已。


关于数据包解析的补充说明

本篇把数据包的解析直接放在了中断回调函数里。这种方式简单直接,适合以下场景:

  • 数据包发送频率不高
  • 每包数据量不大
  • 没有复杂的粘包/拆包问题

但如果数据包发送很快、两包间隔极短,或者在实际产品中需要处理更复杂的情况,就需要:

  • 建立循环缓冲区(Ring Buffer)缓存收到的数据
  • 把数据解析逻辑搬到 main() 的 while 循环中
  • 考虑粘包(多包粘在一起)、断包(一包分两次收到)等情况

keysking 提到了配套资料包中的 UART_Command 例程展示了这种更健壮的做法,有详细注释,需要的小伙伴可以参考。


总结

这篇博客实现了三个跨越:

  1. 从有线到无线 —— 通过 BT24 蓝牙透传模块,STM32 可以和手机/电脑无线通信
  2. 从文本协议到二进制协议 —— 从 “R1”, “G0” 字符串进化为 0xAA + 长度 + 数据 + 校验和 的正规二进制协议
  3. 从简单到健壮 —— 增加了包头验证、长度验证、校验和验证三重数据完整性保障
新知识点 说明
蓝牙基础 经典蓝牙 vs BLE、主机/从机、广播/扫描/连接
蓝牙串口透传 BT24 模块将蓝牙协议封装为串口,STM32 操作串口=操作蓝牙
USART3 配置 学习板第三个串口,PB10(TX)/PB11(RX),9600波特率
二进制通信协议 包头(0xAA) + 长度 + 变长数据段 + 校验和
校验和 (Checksum) 累加取低8位,发送方/接收方各自计算后比对
波特律动蓝牙模式 串口助手可直接连蓝牙模块,支持自动校验和
中断回调中解析数据 简易方案,适合教学场景;生产环境建议用循环缓冲区

和上一篇的关系:除了串口号从 USART2 变成 USART3、波特率从 115200 变成 9600、以及新增了数据包解析逻辑外,DMA+空闲中断的核心机制完全一样。这说明真正掌握了前面的知识后,新外设的接入变得非常简单。


参考资源