ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F103RCT6+OLED+BMP280+Zigbee环境监测节点开发

STM32F103RCT6+OLED+BMP280+Zigbee环境监测节点开发 简介基于STM32F103RCT6单片机与标准库开发的物联网嵌入式工程源码面向电子信息、物联网、自动化方向学生及工程技术人员解决大气压数据实时采集、OLED屏显示以及通过DL-20 ZigBee模块无线发送等问题。压缩包共194个文件、约10.7MB涵盖C源文件、头文件、Keil工程配置、编译产物hex/axf、链接映射及演示视频MP4等工程采用模块化组织各驱动独立分层便于定位与二次开发。目前已有443人学习下载。代码包含模拟IIC驱动的4针OLED显示模块、GYBMP280大气传感器驱动模块并实现自定义通信协议将气压浮点数转换为字节数据经串口3发送给ZigBee模块程序编译通过可直接使用OLED实时显示大气压数据。整体目录清晰可作为课程设计、毕业设计或物联网项目原型参考。1. 为什么把 BMP280、OLED 和 Zigbee 绑在 F103RCT6 上做室内环境监测节点时最常见组合就是 STM32F103RCT6 读 GYBMP280 的温度和气压OLED 现场显示再用 DL-20 Zigbee 模块把数据送到另一端的接收机。很多人第一反应是传感器精度决定成败实际上这套系统里最先卡住你的往往是串口3的引脚分配、Zigbee 模块的波特率配对和两个 I2C 设备抢总线。STM32F103RCT6 有 256KB Flash 和 48KB RAM跑这套裸机逻辑绰绰有余多出来的 Flash 正好放字库和后期协议栈。这篇按“接线 → 初始化 → 驱动 → 发送 → 排错”的顺序写适合正在做课设或小型无线传感节点的人也适合只想确认 HAL 库 I2C 和中断发送怎么配合的工程师。2. 硬件接线与 CubeMX 初始化OLED、GYBMP280 与串口3 的配置顺序2.1 引脚分配先避开 USART3 的两个默认引脚STM32F103RCT6 是 LQFP64 封装PB10/PB11 是 USART3 的默认引脚但这组引脚同时复用了 I2C2。如果你图省事把 OLED 的 SCL/SDA 挂在 PB10/PB11 上USART3 就必须重映射到 PD8/PD9重映射后还要额外开启 AFIO 时钟CubeMX 能自动处理但手动写库的人很容易漏掉这一句。我一般会把 I2C 外设全部放在 I2C1 的 PB8/PB9 上让 OLED 和 GYBMP280 共用一条总线USART3 保持默认映射这样硬件接线最直观代码里不用碰重映射逻辑。外设引脚说明OLED SCLPB8I2C1_SCL接模块 SCKOLED SDAPB9I2C1_SDA接模块 SDAGYBMP280 SCLPB8与 OLED 并联GYBMP280 SDAPB9与 OLED 并联USART3_TXPB10接 DL-20 模块的 RXDUSART3_RXPB11接 DL-20 模块的 TXDVCC/GND3.3V/0V所有模块共地提示GYBMP280 模块上的 SDO 引脚决定 I2C 地址悬空时是 0x76接 3.3V 时是 0x77。OLED 的 7 位地址常见为 0x3C换算成 HAL 库用的 8 位写地址是 0x78。两个设备地址不能相同才能挂在同一条 I2C 总线上。选 I2C 而不是 SPI 接口的 OLED原因很简单GYBMP280 模块默认以 I2C 工作两边并联只占两个 GPIO。OLED 的刷新速度对显示温度气压这类字符串没有任何压力64x48 的显存数据量在 100kHz 的 I2C 下也只要几十毫秒完全够用。如果你手头只有 SPI 接口的 OLED也不是不行但那样 PB8/PB9、PB12-PB15 都会被占用留给后续传感器的引脚就少了。2.2 用 STM32CubeMX 生成工程的 7 个关键步骤这里用 STM32CubeMX 生成 HAL 工程步骤固定照着点一遍后面所有代码都能直接套进去。新建项目选择 STM32F103RCTx注意不要选成 C8T6虽然两者 Pin 兼容但 Flash 大小不同工程里外设配置也会有差异。RCC 页面里把 HSE 设为 Crystal/Ceramic Resonator给 PLL 提供外部时钟源。SYS 页面里把 Debug 设为 Serial Wire。如果保持 No Debug第一次能下载程序第二次 SWD 可能就连不上只能拉 BOOT0 进 ISP 模式擦除。打开 I2C1速率选 100kHz。OLED 和 BMP280 都不是高速设备400kHz 快不了多少反而对线材和上拉电阻更敏感。打开 USART3模式选 Asynchronous波特率先填 115200。如果后面发现 DL-20 模块出厂是 9600再回到这里改或者用 AT 指令把模块改成 115200。时钟树里把 HCLK 配到 72MHz。USART3 挂在 APB1 上APB1 时钟 36MHzHAL 库自动根据这个时钟计算波特率分频系数。Project Manager 里勾选 Generate peripheral initialization as a pair of .c/.h files然后点 GENERATE CODE。生成完工程后不要急着写 OLED 驱动。先确认 I2C 总线能访问到 GYBMP280uint8_t id 0; if (HAL_I2C_Mem_Read(hi2c1, 0x76 1, 0xD0, 1, id, 1, 100) HAL_OK) { if (id 0x58) { // BMP280 的 ID 寄存器固定为 0x58说明 I2C 地址和接线都对 } }逻辑说明HAL_I2C_Mem_Read 的第一个参数是 I2C 句柄第二个是 8 位设备地址0x76 左移一位是因为 HAL 库要求最低位为读写标志位第三个是寄存器地址1 表示寄存器地址是 8 位后面跟着数据指针、长度和超时。如果读到 0x58说明传感器和单片机之间已经通了。OLED 没有 ID 寄存器但可以用 HAL_I2C_IsDeviceReady 扫描地址确认它在线这个函数会发一个地址帧并等待 ACK。如果无法下载程序除了检查 BOOT0 是否 10K 下拉还要确认驱动。ST-LINK 在 Win10/Win11 下偶尔出现 Unknown Device重装 ST-LINK USB Driver 比重启电脑更有效。这部分和 CH340 类似都是设备驱动问题。2.3 上电前先测一帧“假”数据CubeMX 生成的工程默认不会在 main 里写用户逻辑我会先放一个最简单的发送测试确认 USART3 到 DL-20 的链路是通的uint8_t test_byte 0xA5; while (1) { HAL_UART_Transmit(huart3, test_byte, 1, 100); HAL_Delay(500); }这段代码把 0xA5 每隔 500ms 从 USART3 发出去。如果 DL-20 已经进入透传模式另一端的 Zigbee 节点连接 USB 转 TTL 后串口调试助手应该周期性收到 0xA5。如果收不到先不要碰 OLED 和传感器而是确认三件事PB10 引脚有没有波形、DL-20 供电是否稳定、两边的 PAN ID 和信道是否一致。这一步把多设备系统切成两段验证能把后面排查范围缩小一大半。“先测假数据再引真数据”是我做 Zigbee 节点固定的流程很多人一上来就跑完整程序出错后四个设备互相猜反而浪费更多时间。3. 用 HAL 库驱动 OLED 与 GYBMP280从寄存器到可用的显示数据3.1 OLED0.96 寸 SSD1306 的 I2C 驱动最小集0.96 寸 OLED 的驱动芯片基本都是 SSD1306I2C 接口下每次传输的第一个字节是控制字节0x00 表示后续字节是命令0x40 表示后续字节是显存数据。网上流传的各种 OLED 驱动包括江协科技视频配套代码本质都是围绕这两个写函数展开的。#define OLED_ADDR (0x3C 1) static void oled_write_cmd(uint8_t cmd) { uint8_t buf[2] {0x00, cmd}; HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, 2, 100); } static void oled_write_data(uint8_t dat) { uint8_t buf[2] {0x40, dat}; HAL_I2C_Master_Transmit(hi2c1, OLED_ADDR, buf, 2, 100); }参数说明OLED_ADDR 是 HAL 库的 8 位地址0x3C 左移一位等于 0x78。buf[0] 是控制字节buf[1] 是命令或数据。HAL_I2C_Master_Transmit 一次发 2 个字节超时给 100ms如果 I2C 总线上有设备没 ACK会在这个函数里返回 HAL_ERROR不会死等。初始化命令序列每个模块基本一致void oled_init(void) { const uint8_t init_cmds[] { 0xAE, 0x20, 0x00, 0xC8, 0x40, 0x81, 0x7F, 0xA1, 0xA6, 0xA8, 0x3F, 0xD3, 0x00, 0xD5, 0x80, 0xD9, 0xF1, 0xDA, 0x12, 0xDB, 0x40, 0x34, 0x35, 0x8D, 0xAF }; for (uint8_t i 0; i sizeof(init_cmds); i) { oled_write_cmd(init_cmds[i]); } }命令表里 0xAE/0xAF 是关显示和开显示0x81 后跟的 0x7F 是对比度0xA8 后跟的 0x3F 是屏的 multiplex ratio对应 64 行。如果你用的是 0.91 寸 128x32 的 OLED要把 0x3F 改成 0x1F否则画面会整体错位。这个差异经常被人忽略屏幕“亮了但内容乱”基本都是这个原因。显示字符串前要先定位到显存页。SSD1306 把 128x64 显存分成 8 页每页 128 字节定位命令是 B0 到 B7 加列地址void oled_set_pos(uint8_t x, uint8_t y) { oled_write_cmd(0xB0 y); // 页地址 0~7 oled_write_cmd(x 0x0F); // 列地址低 4 位 oled_write_cmd(0x10 | (x 4)); // 列地址高 4 位 }y 是页号不是像素行号。0 是第一行2 是第三行中间隔一行能避免两行字贴在一起。字符取模按 6x8 做每个 ASCII 字符占 6 字节显示函数如下void oled_show_string(uint8_t x, uint8_t y, char *s) { while (*s) { oled_set_pos(x, y); for (uint8_t i 0; i 6; i) { oled_write_data(font6x8[*s - 0x20][i]); } s; x 6; } }这里最容易踩的坑是列地址不会自动换行。SSD1306 的列指针写一个字节自动加一但写满一整行后不会自己跳到下一行所以每个字符前都重新设置坐标。如果忘了这一步第二行字符会覆盖到第一行末尾看起来像花屏。3.2 GYBMP280 读取与校准温度比气压先出来BMP280 出厂时每颗芯片的校准参数都不同存在 0x88 到 0xA1 的寄存器里。上电后必须先读这 24 字节后续温度气压计算全靠它们。关键寄存器如下寄存器地址说明ID0xD0固定值 0x58用于确认通信正常ctrl_meas0xF4工作模式、温度/气压过采样config0xF5滤波器系数、转换周期press0xF720 位气压原始值连续 3 字节temp0xFA20 位温度原始值连续 3 字节初始化代码typedef struct { uint16_t dig_T1; int16_t dig_T2, dig_T3; uint16_t dig_P1; int16_t dig_P2, dig_P3, dig_P4, dig_P5, dig_P6, dig_P7, dig_P8, dig_P9; } bmp280_cal_t; static bmp280_cal_t bmp280_cal; static int32_t t_fine; void bmp280_init(void) { uint8_t calib[24]; HAL_I2C_Mem_Read(hi2c1, 0x76 1, 0x88, 1, calib, 24, 100); bmp280_cal.dig_T1 (uint16_t)((calib[1] 8) | calib[0]); bmp280_cal.dig_T2 (int16_t)((calib[3] 8) | calib[2]); bmp280_cal.dig_T3 (int16_t)((calib[5] 8) | calib[4]); bmp280_cal.dig_P1 (uint16_t)((calib[7] 8) | calib[6]); bmp280_cal.dig_P2 (int16_t)((calib[9] 8) | calib[8]); bmp280_cal.dig_P3 (int16_t)((calib[11] 8) | calib[10]); bmp280_cal.dig_P4 (int16_t)((calib[13] 8) | calib[12]); bmp280_cal.dig_P5 (int16_t)((calib[15] 8) | calib[14]); bmp280_cal.dig_P6 (int16_t)((calib[17] 8) | calib[16]); bmp280_cal.dig_P7 (int16_t)((calib[19] 8) | calib[18]); bmp280_cal.dig_P8 (int16_t)((calib[21] 8) | calib[20]); bmp280_cal.dig_P9 (int16_t)((calib[23] 8) | calib[22]); uint8_t ctrl 0x55; // normal mode, 温度气压各 4 倍过采样 HAL_I2C_Mem_Write(hi2c1, 0x76 1, 0xF4, 1, ctrl, 1, 100); }HAL_I2C_Mem_Read 一次连续读 24 字节从 0x88 开始正好覆盖全部校准参数。校准参数的小端字节序要转成 16 位整数读出来先用内存里的数据填充结构体。ctrl_meas 写 0x55表示 normal 模式温度和气压都是 4 倍过采样这个组合在精度和转换时间上比较均衡大约 7ms 完成一次转换。温度读取float bmp280_read_temperature(void) { uint8_t raw[3]; HAL_I2C_Mem_Read(hi2c1, 0x76 1, 0xFA, 1, raw, 3, 100); int32_t adc (int32_t)((raw[0] 8 | raw[1]) 4 | raw[2] 4); int32_t var1 (((adc 3) - ((int32_t)bmp280_cal.dig_T1 1)) * (int32_t)bmp280_cal.dig_T2) 11; int32_t var2 (((((adc 4) - (int32_t)bmp280_cal.dig_T1) * ((adc 4) - (int32_t)bmp280_cal.dig_T1)) 12) * (int32_t)bmp280_cal.dig_T3) 14; t_fine var1 var2; return (float)((t_fine * 5 128) 8) / 100.0f; }BMP280 的温度原始值是 20 位三字节里 raw[2] 的低 4 位无效所以代码里((raw[0] 8 | raw[1]) 4 | raw[2] 4)先把三字节拼成一个 20 位无符号整数。t_fine 是数据手册公式里的中间变量气压计算也要用到它所以必须存成 static 全局变量。返回的是摄氏度例 25.34 表示 25.34℃。气压读取float bmp280_read_pressure(void) { uint8_t raw[3]; HAL_I2C_Mem_Read(hi2c1, 0x76 1, 0xF7, 1, raw, 3, 100); int32_t adc (int32_t)((raw[0] 8 | raw[1]) 4 | raw[2] 4); int64_t var1 ((int64_t)t_fine) - 128000; int64_t var2 var1 * var1 * (int64_t)bmp280_cal.dig_P6; var2 var2 ((var1 * (int64_t)bmp280_cal.dig_P5) 17); var2 var2 (((int64_t)bmp280_cal.dig_P4) 35); var1 ((var1 * var1 * (int64_t)bmp280_cal.dig_P3) 8) ((var1 * (int64_t)bmp280_cal.dig_P2) 12); var1 ((((int64_t)1 47) var1) * (int64_t)bmp280_cal.dig_P1) 33; if (var1 0) return 0; int64_t p 1048576 - adc; p (((p 31) - var2) * 3125) / var1; var1 ((int64_t)bmp280_cal.dig_P9 * (p 13) * (p 13)) 25; var2 ((int64_t)bmp280_cal.dig_P8 * p) 19; p ((p var1 var2) 8) ((int64_t)bmp280_cal.dig_P7 4); return (float)p / 100.0f; }气压计算全走 int64中间变量不用浮点才能保证 0.01 hPa 级别的精度。注意这段代码依赖 t_fine如果你在调用气压前没先读温度t_fine 还是 0算出来的气压会偏。所以上层逻辑必须先调 bmp280_read_temperature 再调 bmp280_read_pressure这个顺序不能反。3.3 把传感器数值格式化到 OLED有了温度和气压接下来就是把它们变成 OLED 能显示的字符串。直接用 sprintf 加 %f 在嵌入式里会拉进整个浮点打印库占用几 KB Flash我一般避免这样做void update_oled(float temp, float pres) { char line[16]; uint16_t t100 (uint16_t)(temp * 100); uint16_t p10 (uint16_t)(pres * 10); snprintf(line, sizeof(line), T:%d.%02d, t100 / 100, t100 % 100); oled_show_string(0, 0, line); snprintf(line, sizeof(line), P:%d.%d, p10 / 10, p10 % 10); oled_show_string(0, 2, line); }温度拆成整数和小数两部分打印气压保留一位小数第一行显示温度第二行显示气压。这样整个工程只需要整数格式化生成的代码量小很多。OLED 显示频率不用太高500ms 刷一次足够人眼看起来也是连续变化的而且不会和 Zigbee 发送抢 I2C 总线。4. 串口3 DL-20 Zigbee 模块发送数据帧、AT 配置与中断发送4.1 先把 DL-20 的串口参数对齐DL-20 是我这块 Zigbee 透传模块的型号它和多数 Zigbee 模块一样出厂默认可能是 9600 波特率。拿到模块第一步不是直接接单片机而是用 USB 转 TTL 线直连把模块和 STM32 的 USART3 调到一致。连接关系USB 转 TTL 的 TXD 接模块 RXDRXD 接模块 TXDGND 和 VCC 分别对应共地、供电。3.3V 供电时电流不大但发射瞬间会有几十毫安的脉冲最好在模块的 VCC 和 GND 之间加一个 10uF 电容。AT 指令配置是这类模块常见的设置方式。以我手头这块 DL-20 为例串口助手选 9600发送“”停顿约一秒后会返回 OK 表示进入 AT 模式。常用指令如下指令作用进入 AT 命令模式ATUART115200,8,1,0设置波特率 115200、8 数据位、1 停止位、无校验ATPANID1234设置 PAN ID两个模块必须一致ATCH0x0A设置信道两端必须一致ATEXIT退出 AT 模式进入透明传输注意进入 AT 模式后不能直接发数据同样的字节会被当作 AT 指令解析。ATUART 改完波特率后串口助手也要立刻切到新波特率否则下一条指令会乱码。这套指令并非所有 Zigbee 模块通用但思路一致先读模块出厂默认参数确认 PAN ID 和信道再用 AT 指令统一成一套固定值。串口驱动方面CH340 的 Windows 驱动有时候会被系统更新替换掉设备管理器里能看到 COM 口但串口助手打不开重新安装官方 CH340 驱动一般能解决。Linux 下内核自带 CH340 驱动但当前用户必须在 dialout 组里否则打开 /dev/ttyUSB0 会报 Permission denied。FTDI 芯片的 USB 转 TTL 线在这些场景下兼容性更好尤其是串口频率精度要求高的时候FTDI 的时钟误差通常更小。4.2 组帧让接收端不串行透明传输模式下Zigbee 模块只负责把字节流从一个点搬到另一个点模块本身不理解“一包数据”的边界。接收端拿到的可能是连续字节流如果应用层不定义帧结构遇到干扰就没法判断哪些字节属于同一帧。所以我习惯在裸机代码里固定数据帧格式偏移字段长度说明0帧头 110xAA1帧头 210x552功能码10x01 表示环境传感数据3长度1从功能码到 CRC 前的字节数固定 64设备 ID10x01 表示节点 15-6温度20.01℃ 为单位大端7-8气压20.1 hPa 为单位大端9CRC1功能码到气压字段的 XOR 累加值组帧代码uint8_t sensor_frame[10]; void build_frame(uint8_t dev_id, uint16_t temp_x100, uint16_t pres_x10) { sensor_frame[0] 0xAA; sensor_frame[1] 0x55; sensor_frame[2] 0x01; sensor_frame[3] 6; sensor_frame[4] dev_id; sensor_frame[5] (temp_x100 8) 0xFF; sensor_frame[6] temp_x100 0xFF; sensor_frame[7] (pres_x10 8) 0xFF; sensor_frame[8] pres_x10 0xFF; uint8_t crc 0; for (uint8_t i 2; i 9; i) { crc ^ sensor_frame[i]; } sensor_frame[9] crc; }长度字段固定为 6因为功能码 1 字节、设备 ID 1 字节、温度 2 字节、气压 2 字节正好 6 字节。CRC 从功能码开始算到气压字段XOR 校验只在噪声不严重的环境里够用如果节点要布在工业现场应该换成 CRC8 多项式 0x31多写十几行代码可靠性明显不同。4.3 串口3 中断发送不阻塞 OLED 刷新直接用 HAL_UART_Transmit 阻塞发送 10 字节在 115200 波特率下不到 1ms其实对系统影响不大。但这套代码里 USART3 和 I2C1 的中断优先级如果没配好OLED 的 I2C 通信会被反复打断表现出来就是“OLED 偶尔卡一下”。我用中断发送比较多因为它能避免主循环长时间停在 UART 发送里。void send_sensor_frame(uint8_t dev_id, uint16_t temp_x100, uint16_t pres_x10) { build_frame(dev_id, temp_x100, pres_x10); HAL_UART_Transmit_IT(huart3, sensor_frame, sizeof(sensor_frame)); }使用中断发送有三个注意点。第一sensor_frame 必须是全局或 static因为 HAL_UART_Transmit_IT 只把缓冲区指针交给外设发送真正发生在中断里等函数返回后局部数组被销毁发送的内容就会变成随机内存数据。第二调用频率要低于发送耗时115200 波特率下 10 字节大约 0.9ms如果主循环短于这个时间HAL 底层还没发完上一包第二包会把状态覆盖导致只发出去半包。第三必须确保 stm32f1xx_it.c 里有 USART3_IRQHandler 并调用 HAL_UART_IRQHandler(huart3)否则中断发送状态永远结束不了后续 HAL_UART_Transmit_IT 会一直返回 HAL_BUSY。主循环的调度我一般这样写while (1) { float temp bmp280_read_temperature(); float pres bmp280_read_pressure(); update_oled(temp, pres); send_sensor_frame(0x01, (uint16_t)(temp * 100), (uint16_t)(pres * 10)); HAL_Delay(500); }500ms 的温度气压采样周期对室内环境监测足够Zigbee 透传也不会因为数据量太大出现拥堵。如果以后要加按键节点把 HAL_Delay 换成非阻塞的任务调度否则按键消抖会占用发送窗口。5. 联调时最先要查的三个坑卡死、乱码和“发不出去”5.1 加了 OLED 函数后程序卡死先检查 I2C 等待现象很典型单独点灯正常初始化 OLED 后程序就跑飞串口偶尔还能收到几个乱码。最直接的原因是 I2C 的一次调用超时后HAL 层的状态机没有复位后续所有 I2C 操作都卡在等待硬件标志。对策是每个 I2C 调用都检查返回值一旦超时就调 HAL_I2C_DeInit 和 HAL_I2C_Init 重新初始化总线。另一个常见原因是你用的是 0.91 寸 128x32 OLED初始化序列里 0xA8 后面的 multiplex ratio 参数还是 0x3F导致画面错位看起来像花屏。先用逻辑分析仪或示波器看 SCL/SDA 波形如果 SCL 有连续方波而 SDA 一直拉高多半是设备地址错了。5.2 串口助手显示乱码波特率、电平和接地乱码第一优先查波特率。DL-20 模块如果在 AT 模式下被改成 9600而 STM32 的 USART3 还按 115200 发接收端只会看到 0x00 或者 0xFF。第二查电平Zigbee 模块的 TX/RX 一般是 3.3V有些 USB 转 TTL 线没有电平转换芯片5V 的 TX 会让模块的 RX 误判。第三查地线模块、单片机和 USB 转 TTL 三者不共地时偶尔能收一两个字节数据量一上来就全是乱码。CH340 驱动更新后串口助手报“串口被占用”去设备管理器里把 COM 口删掉再重新扫描往往比装驱动更快。5.3 一个能少走一小时弯路的验证顺序我建议按这个顺序排查先用 USB 转 TTL 直连两块 DL-20 模块串口助手里互发 0xA5确认模块本身能透明传输然后把 STM32 的 USART3 TX 单独接到 USB 转 TTL跑 2.3 节里的循环发送程序确认单片机发送正常最后才把 DL-20 接回串口3在接收端看帧头。这样做能把“单片机问题”和“模块问题”直接分开不用同时怀疑四个设备。真正调通后Zigbee 模块发射瞬间电流会明显拉高如果供电线过长VCC 跌落超过 0.3V丢帧率和 CRC 错误会突然增加。给 DL-20 的 VCC 和 GND 之间并一个 100uF 电解电容比换更粗的线效果更直接。接收端用带时间戳的串口数据记录仪存日志能看到丢包发生的时刻和间隔。如果每帧的 AA 55 01 06 开头都对唯独最后的 CRC 偶尔不对先量模块供电纹波再检查串口助手的 HEX 显示是否开了字节对齐最后才怀疑 Zigbee 模块本身。本文还有配套的精品资源点击获取
返回列表