简介:一套基于STM32F103RCT6的嵌入式综合应用工程,面向电子信息、物联网及自动化专业学生和工程技术人员。工程以标准库驱动为核心,整合模拟IIC接口的OLED显示屏与GYBMP280大气压传感器,通过串口3将气压浮点数据按自定义协议封装为字节流,并经DL-20 zigbee模块无线发送,实现大气压实时采集、显示与传输,可直接用于物联网课程设计或项目原型开发。压缩包共194个文件,约10.7MB,主要包含C源文件、头文件、工程配置文件、编译生成的axf/hex烧录文件及mp4演示视频等;工程模块化分类,模拟IIC驱动、传感器驱动与主逻辑分离,方便移植和二次开发。目前已有443人学习下载,适合需要完整参考代码与工程模板的开发者参考。读者可获得可直接编译通过的完整工程,包含OLED显示驱动、GYBMP280气压采集、自定义串口协议封装、zigbee发送等关键代码,并配有演示视频,能快速理解从传感器读取到无线传输的完整数据链路。
1. 一块屏、一块气压板、一路无线串口:这套组合到底在做什么
把 GY-BMP280 读出来的温度和气压同时送给 OLED 和 ZigBee 无线链路,听起来门槛不高,实际做起来最容易栽的却是最基础的三件事:I2C 地址、串口交叉接线、帧格式设计。这里要解决的是一台典型的环境采集节点:STM32F103RCT6 负责采样和逻辑,0.96 寸 OLED 负责本地显示,USART3 接一块 DL-20 类型的 ZigBee 透传模块,把同一份数据发到几十米外的协调器。选 F103RCT6 而不是 C8T6,通常因为引脚数量和 256KB Flash 有余量,后面接汉字字库或加传感器都不用换芯片。这套链路适合仓库环境监控、机房温度节点、农业大棚展示屏这类低上报频率场景。先不看应用层协议,把单片机到模块的物理链路和心理模型搭对,后面所有问题都能用串口助手定位。
2. STM32F103RCT6 的系统接线:I2C1 上的 OLED 与 GY-BMP280、USART3 下的 DL-20
2.1 默认引脚映射:PB6/PB7 和 PB10/PB11
STM32F103RCT6 的 I2C1 默认在 PB6(SCL)和 PB7(SDA),USART3 默认在 PB10(TX)和 PB11(RX)。这里最容易被绕进去的是重映射:有人为了给别的外设让路,把 USART3 重映射到 PD8/PD9,结果在调试时和 SWD 引脚冲突,烧一次就连不上。多数环境采集板根本不需要重映射,用 CubeMX 默认分配就行。两块传感器模块并联在同一路 I2C1 上,地址不同就互不干扰。
| 总线 | 引脚 | 对端设备 | 电平 |
|---|---|---|---|
| I2C1 SCL | PB6 | OLED SCL、GY-BMP280 SCL | 3.3V |
| I2C1 SDA | PB7 | OLED SDA、GY-BMP280 SDA | 3.3V |
| USART3 TX | PB10 | DL-20 模块 RXD | TTL 3.3V |
| USART3 RX | PB11 | DL-20 模块 TXD | TTL 3.3V |
DL-20 这类 ZigBee 模块对单片机来说就是一个串口外设:写入 TX 的数据会被模块无线转发,协调器端的串口原样读出。所以接线只要三根:PB10 接模块 RXD,PB11 接模块 TXD,GND 必须共地。共地这件事在小板上经常被忽略,两根线“看起来能通”,但模块和单片机参考地不一致时,串口数据会随机乱码。
2.2 GY-BMP280 的地址由 SDO 决定
GY-BMP280 是 BMP280 芯片加稳压、电平转换和上拉电阻组成的模块。芯片的 SDO 引脚直接决定 I2C 地址:SDO 接低电平时地址是 0x76,接高电平时是 0x77。大多数成品的 GY-BMP280 模块默认把 SDO 拉低,地址就是 0x76。OLED 的 SSD1306 驱动默认地址是 0x3C。两个设备挂同一路 I2C 没有冲突,因为 I2C 靠地址寻址,SCL/SDA 直接并联即可。
上电后在初始化代码里做一次在线检测,能同时验证接线和地址:
uint8_t detected = 0; if (HAL_I2C_IsDeviceReady(&hi2c1, 0x76 << 1, 2, 100) == HAL_OK) { detected |= 0x01; // BMP280 在线 } if (HAL_I2C_IsDeviceReady(&hi2c1, 0x3C << 1, 2, 100) == HAL_OK) { detected |= 0x02; // OLED 在线 }注意 HAL 库第二个参数要求传入 8 位地址,也就是 7 位地址左移一位,所以代码里写的是0x76 << 1。HAL_I2C_IsDeviceReady会发送一个起始条件和设备地址,等待 ACK;第三个参数是重试次数,第四个是超时时间。如果检测不通过,先量 SDA/SCL 是否有上拉,再看地址是不是 0x77。0x91 寸 128x32 小屏的地址也经常是 0x3C,不用单独改代码,改的是后面显示初始化里的页地址范围。
2.3 DL-20 模块的串口参数以模块手册为准
ZigBee 协议栈和组网完全在模块内部完成,用户能接触到的只有串口参数和少量配置命令。新买回来的模块出厂波特率常见是 9600 8N1,但不同厂商的方案差异很大,新大陆这类 ZigBee 方案的原厂模块,默认波特率可能是 115200。所以第一步不要直接焊进单片机,先把模块用 USB 转 TTL 接到电脑,打开串口调试助手,发送一个空帧或厂商规定的测试命令,确认实际波特率。模块自带的天线附近不要走 I2C 排线,实测发现 ZigBee 发射瞬间对 OLED 的 I2C 通信有明显干扰,表现为屏幕偶发花屏。天线和屏线保持 3cm 以上距离,比加滤波电容更有效。
3. 用 CubeMX 配置 USART3 和 I2C1:STM32F103RCT6 HAL 工程最小集
3.1 CubeMX 里的配置速查
在 STM32CubeMX 中新建 F103RCT6 工程后,按下面这张表过一遍配置,避免在引脚复用和时钟树上反复折腾。
| 分组 | 配置项 | 推荐值 | 原因 |
|---|---|---|---|
| SYS | Debug | Serial Wire | 防止 SWD 引脚被 GPIO 复用 |
| RCC | HSE | Crystal/Ceramic | 配合外部 8MHz 晶振 |
| I2C1 | I2C | Enabled, 100000Hz | 长排线兼容性最好 |
| USART3 | Asynchronous | 9600 8N1 | 与 DL-20 模块出厂默认一致 |
| 时钟树 | PLL | 72MHz | F103 最高主频 |
生成代码后,main里的初始化顺序一般是:
MX_GPIO_Init(); MX_I2C1_Init(); MX_USART3_Init();MX_GPIO_Init放在前面是必须的,因为 I2C 和串口的引脚复用配置都在这个函数里完成。如果你在 CubeMX 里先配 I2C 后配 USART,生成的代码顺序会自动调整,不用手动改。
3.2 I2C 时钟先跑 100kHz,别急着上 400k
SSD1306 和 BMP280 都宣称支持 400kHz,但模块和单片机之间只要出现超过 10cm 的杜邦线、模块上拉电阻缺失、OLED 排线屏蔽差,400kHz 就会出现花屏或读出全 0xFF。HAL 库的好处是时钟配置自动换算,把ClockSpeed从 400000 改成 100000,重新生成代码即可。从 400k 降到 100k 不会对这两个传感器造成性能瓶颈,BMP280 一次转换本身要几毫秒,OLED 刷一帧也花不了多少时间。
3.3 生成代码后检查两处:GPIO 模式和数据位
HAL 生成的 I2C 引脚模式应当是GPIO_MODE_AF_OD(复用开漏),如果手动改成推挽输出,SDA 将无法被从机拉低,通信必然失败。USART3 的 TX 是GPIO_MODE_AF_PP,RX 是输入浮空,这两个不要动。
USART3 配置里不要勾选OVER8以外的选项,F103 的OVER8 = DISABLE对应 16 倍过采样,兼容性更好。如果模块要求 8E1(偶校验),HAL 初始化结构体里的Parity改为UART_PARITY_EVEN,同时WordLength会自动变成 9 位,因为校验位占一位。直接改 CubeMX 里的 Word Length 是改不出来的,必须通过选择 Parity 联动。
4. 打通 BMP280 采样、OLED 显示到 zigbee 发送的主循环
4.1 连续读 BMP280 温度与气压
BMP280 上电后要从校准寄存器 0x88 起连续读取 24 字节,这些数据每颗芯片都不同,属于出厂标定值。之后配置ctrl_meas(0xF4)为 0xFF,对应压力过采样 x16、温度过采样 x16、正常模式。数据准备好后,读取 0xF7、0xF8、0xF9 拼出 20 位原始压力值,再读 0xFA、0xFB、0xFC 拼出 20 位原始温度值。
#define BMP280_ADDR 0x76 #define BMP280_REG_ID 0xD0 #define BMP280_CTRL_MEAS 0xF4 uint8_t bmp280_init(void) { uint8_t id = 0; HAL_I2C_Mem_Read(&hi2c1, BMP280_ADDR << 1, BMP280_REG_ID, 1, &id, 1, 100); if (id != 0x58) return 0; // BMP280 芯片 ID 固定为 0x58 uint8_t cfg = 0xFF; HAL_I2C_Mem_Write(&hi2c1, BMP280_ADDR << 1, BMP280_CTRL_MEAS, 1, &cfg, 1, 100); return 1; }HAL_I2C_Mem_Read的参数依次是 I2C 句柄、8 位设备地址、寄存器地址、寄存器地址长度(1 字节)、数据缓冲区、读取长度、超时时间。ID 校验是移植时最容易省掉的步骤,但芯片地址错了之后,读出来的 ID 往往是 0xFF,这一句能帮你排除一半硬件问题。正常模式下 BMP280 转换时间约 20ms,主循环里不要连续读,至少延时 30ms 再取数。
4.2 温度补偿与气压转 hPa
BMP280 的原始值必须结合校准参数计算。温度补偿的核心是t_fine,它不仅用于温度输出,也用于气压修正。这里给出精简实现:
static int16_t dig_T1, dig_T2, dig_T3; static int32_t t_fine; float bmp280_read_temp(void) { uint8_t buf[3]; int32_t adc_T; HAL_I2C_Mem_Read(&hi2c1, BMP280_ADDR << 1, 0xFA, 1, buf, 3, 100); adc_T = (int32_t)(((uint32_t)buf[0] << 12) | ((uint32_t)buf[1] << 4) | ((uint32_t)buf[2] >> 4)); if (adc_T & 0x80000) adc_T |= 0xFFF00000; // 20 位补码转 int32 float var1 = ((float)adc_T / 16384.0f - (float)dig_T1 / 1024.0f) * (float)dig_T2; float var2 = (((float)adc_T / 131072.0f - (float)dig_T1 / 8192.0f) * ((float)adc_T / 131072.0f - (float)dig_T1 / 8192.0f)) * (float)dig_T3; t_fine = (int32_t)(var1 + var2); return (var1 + var2) / 5120.0f; }dig_T1是 uint16_t,dig_T2、dig_T3是 int16_t,从 0x88 开始按小端读出,直接指针转换或位拼接都行。adc_T的处理是一个容易忽略的细节:20 位补码最高位是符号位,不扩展到 int32 的话,零下温度会算成很大的正数。气压公式更啰嗦,需要在 datasheet 附录里查dig_P1到dig_P9共 9 个参数的完整公式;如果只是做环境展示,可以用(adc_P / 256.0f) * 0.125f做定性显示,误差约 1-2 hPa,对标题这套演示链路足够。
4.3 OLED 显示:写命令与写数据的封装
SSD1306 的控制字节 0x00 表示后续是命令,0x40 表示后续是显存数据。封装两个函数,之后所有显示函数都基于这两个接口:
void oled_send(uint8_t type, uint8_t *data, uint16_t len) { uint8_t buf[130]; buf[0] = type; memcpy(&buf[1], data, len); HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, len + 1, 100); } void oled_cmd(uint8_t cmd) { oled_send(0x00, &cmd, 1); }buf[130]可以容纳一整页 128 字节外加控制字节,避开了 GCC 对变长数组的兼容问题。HAL_I2C_Master_Transmit的最后一个参数 100 是超时毫秒数,I2C 总线卡死时这个函数会阻塞 100ms 后返回错误,不会永久死机。每次调用oled_send都会完整传递控制字节加数据,SSD1306 内部会自动区分命令和数据。要显示汉字,需要准备 16x16 点阵字库,把汉字模按行扫描放入const uint8_t数组,显示时逐页写入。
4.4 zigbee 发送帧设计:固定长度二进制帧
给 ZigBee 链路传数据,不要用"temp=25.5\n"这种字符串,接收端解析变长字符串的成本高且容易错位。做一个 11 字节固定长度帧:
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | 0xAA | 1 | 帧头 1 |
| 1 | 0x55 | 1 | 帧头 2 |
| 2 | 长度 | 1 | 从命令字到 CRC 的总字节数 |
| 3 | 命令字 | 1 | 0x01 表示环境数据 |
| 4-5 | 温度 | 2 | int16,单位 0.1℃ |
| 6-9 | 气压 | 4 | uint32,单位 0.01 hPa |
| 10 | CRC | 1 | 累加和 |
void env_send(float temp, float press_hpa) { uint8_t frame[11]; int16_t t10 = (int16_t)(temp * 10); uint32_t p100 = (uint32_t)(press_hpa * 100); uint8_t sum = 0; frame[0] = 0xAA; frame[1] = 0x55; frame[2] = 8; frame[3] = 0x01; frame[4] = t10 >> 8; frame[5] = t10 & 0xFF; frame[6] = p100 >> 24; frame[7] = p100 >> 16; frame[8] = p100 >> 8; frame[9] = p100 & 0xFF; for (int i = 2; i < 10; i++) sum += frame[i]; frame[10] = sum; HAL_UART_Transmit(&huart3, frame, 11, 100); }HAL_UART_Transmit是阻塞发送,会等 11 个字节全部进入发送移位寄存器或超时。把温度放大 10 倍、气压放大 100 倍再转整数,是为了避免浮点转字符串的%f开销,接收端按同样倍率还原即可。累加和从长度字段开始算,这样帧头变化不影响校验逻辑。
4.5 用串口调试助手验证无线链路
协调器接 USB 转 TTL(CH340 或 FTDI 方案),打开串口调试助手,波特率设 9600,勾选十六进制显示。正常会看到AA 55 08 01 ...开头的一串字节。出现乱码时,先看 ZigBee 模块的波特率是否真的是 9600,再看 STM32 的 TX 是否接到模块的 RXD。串口助手关闭再打开时,ZigBee 模块可能因为 DTR 电平变化而复位,这是正常现象,不算故障。两个节点之间距离超过 50 米或隔着混凝土墙时,ZigBee 发射会频繁重试,串口数据会出现几百毫秒的停顿,把发送周期加长到 2 秒以上即可。
5. 串口烧写失败、OLED 卡死、zigbee 乱码的现场排查
5.1 串口烧写失败:Boot0 引脚画没画对,CH340 驱动装没装对
用串口给 STM32F103RCT6 下载程序,BOOT0 必须拉高、BOOT1 保持低,芯片才会从系统存储器启动进入 ISP。开发板上 BOOT0 通常经 10k 电阻下拉,通过跳线帽拉高;烧写完务必恢复低电平,否则复位后程序不运行。Windows 设备管理器看不到 CH340 端口时,先卸载旧驱动再重装;FTDI 方案的调试器还要注意,占用串口的软件必须全部关闭,否则烧写软件打不开端口。烧写软件里勾选“DTR 低电平复位 / RTS 高电平进入 Boot”,CH340 会自动控制 BOOT0 和复位脚,能省掉频繁插拔跳线帽的操作。
5.2 加了 OLED 函数就卡死,先查这三处
“初始化 OLED 之后 main 不再运行”是高频问题,三个原因按概率排:
第一,I2C 没有 ACK 响应。模块没供上电、SDA/SCL 接反、地址不对,都会让HAL_I2C_Master_Transmit反复尝试。第二,局部变量栈溢出。128x8 字节的 OLED 显存如果定义在函数内,再加上多层调用栈,F103RCT6 的 48KB RAM 会被瞬间吃光。把显存数组改成全局变量或加static。第三,SWD 引脚被复用。CubeMX 里没选 Serial Wire,烧录一次后第二次连接失败,表现像“加了 OLED 就坏”。把这三项按顺序查,能覆盖绝大多数死机案例。
| 现象 | 原因 | 快速确认方法 |
|---|---|---|
| 初始化卡死 | I2C 无 ACK | 量 SCL/SDA 电平,查模块供电 |
| 调用后 HardFault | 局部数组过大 | 改全局变量后再试 |
| 第二次烧写失败 | SWD 复用 | CubeMX 里开 Serial Wire |
5.3 zigbee 串口乱码和数据断档
ZigBee 模块串口乱码,先排除波特率不一致。STM32 侧改huart3.Init.BaudRate后必须重新HAL_UART_Init(),只改结构体不重新初始化是常见失误。其次是共地问题,模块和单片机分别供电时,GND 不连会导致随机错位。最后看天线:手握住天线时信号强度变化很大,数据会断;把天线垂直摆放并用尼龙柱固定,避免贴在金属外壳上。如果模块支持 RSSI 查询,在最终安装位置实测信号强度,小于 -70dBm 时考虑加协调器点位或换成外置天线。
6. 串口粘包状态机:让 zigbee 接收端一劳永逸
6.1 基于字节状态机的帧解析
ZigBee 透传本质是字节流,一帧数据可能被拆成两段到达,也可能两帧连在一起到达。接收端不要按固定长度等待,用状态机逐字节解析:
typedef enum { S_HEAD1, S_HEAD2, S_LEN, S_DATA, S_CRC } frame_state_t; static frame_state_t rx_state = S_HEAD1; static uint8_t rx_buf[16], rx_len, rx_idx, rx_sum; void handle_byte(uint8_t c) { switch (rx_state) { case S_HEAD1: if (c == 0xAA) rx_state = S_HEAD2; break; case S_HEAD2: if (c == 0x55) { rx_state = S_LEN; rx_sum = 0; rx_idx = 0; } else rx_state = S_HEAD1; break; case S_LEN: rx_len = c; rx_sum += c; rx_state = (rx_len >= 4 && rx_len <= 12) ? S_DATA : S_HEAD1; break; case S_DATA: rx_buf[rx_idx++] = c; rx_sum += c; if (rx_idx >= rx_len - 1) rx_state = S_CRC; break; case S_CRC: if (rx_sum == c) { // rx_buf[0] 是命令字,rx_buf[1..] 是数据 } rx_state = S_HEAD1; break; } }rx_len表示从命令字到 CRC 的总字节数,所以状态机在S_LEN之后只需要收rx_len - 1字节就进入 CRC 校验。最后一个字节就是累加和,相等才认为帧有效。这个状态机不依赖串口是否一次性发完整包,噪声字节最多让它回到S_HEAD1,不会造成后续帧全部错位。调用位置放在HAL_UART_RxCpltCallback里,配合HAL_UART_Receive_IT单字节接收,能保证 9600 波特率下每个字节都不丢。想更进一步,可以把HAL_UART_Receive_IT换成 DMA 空闲中断,但状态机做前置过滤后再批量处理,对这套低频环境数据是更稳的做法。
6.2 发送周期的工程取值
主循环里把采集周期设 2 秒,OLED 每秒刷新一次,ZigBee 每两秒发一帧。这个频率下模块发射队列不会堆积,电池供电时电流也压得住。需要更快时,把发送间隔缩到 200ms 是大多数 CC2530 方案的极限,此时HAL_UART_Transmit的超时要从 100 改成 20,否则一帧还没发完,下一帧已经排队。接收端如果出现连续丢帧,不要急着加缓冲区,先看发送端有没有覆盖上一帧数据——阻塞发送在低波特率下耗时约 11ms,主循环里必须留够这个时间。想要进一步降低丢包,可以连续发送同一帧三次,接收端用帧号去重,这是 ZigBee 透明传输场景里性价比最高的可靠性手段。
本文还有配套的精品资源,点击获取