☰
RISC-V单片机CH32F103C8T6:BMP280采集与OLED显示
2026/10/17 3:26:03 网站建设 项目流程

简介:CH32F103C8T6 工程包以国产 RISC-V 内核单片机为主控,完整实现了 BMP280 气压传感器的数据采集与 0.96 英寸 OLED 屏幕显示。系统通过两路 I2C 总线分别挂接传感器与显示屏,可实时读取并刷新大气压强、海拔高度和芯片内部温度,适合嵌入式入门、传感器应用开发、毕业设计以及电子设计竞赛参考。压缩包共包含 168 个文件,以 C 语言源码、头文件、CH32F10x 标准外设库和编译过程生成的中间文件为主,还带有可直接烧录到芯片的 Hex 文件、用于调试的 Axf 文件以及 Keil 工程配置,整体约 4.63MB,目录按外设模块划分,便于定位驱动代码。目前已有 167 人学习下载。借助完整的工程示例,读者可以从头理解 RISC-V 平台上 GPIO 复用、I2C 时序配置、气压数据换算和 OLED 显存刷新等关键环节,也能参照 BMP280 接 PB5/PB6、OLED 接 PB10/PB11 的硬件连接,快速复制到自己的项目中,减少环境搭建和排错成本。

1. 从 STM32 换到 CH32F103C8T6:RISC-V 内核 + BMP280 气压采集 + 0.96 寸 OLED 显示

CH32F103C8T6 是沁恒基于 RISC-V 内核(青稞 V3A)推出的 MCU,硬件引脚和封装上做到了与 STM32F103C8T6 的兼容,这是一颗很容易引起嵌入式开发者好奇的芯片——不用改 PCB 就能把 STM32 方案换成 RISC-V 方案,而外围设备完全复用。这里要拆解的资源是一个典型传感器采集项目:CH32F103C8T6 通过 I2C 接口读取 BMP280 的大气压强和温度数据,再经由软件模拟 I2C 或硬件 I2C 驱动 0.96 寸 OLED(SSD1306)实时显示。这个项目很适合刚接触 RISC-V 平台的开发者,因为它的外设逻辑和 STM32 时代几乎是平移的,难点只在内核配置、时钟树和调试工具链的切换。对于已经在用 STM32 的工程师,它也是一个低成本评估 RISC-V 替代方案的最小切入点。本文会从硬件选型和接线开始,逐步拆到 BMP280 的测量流程、OLED 的驱动写法,最后集中讲这类项目最常见的几个翻车点。

2. 硬件连接与 I2C 总线规划:引脚分配、上拉电阻和电源纹波

2.1 CH32F103C8T6 与 BMP280、OLED 的引脚对应关系

CH32F103C8T6 的封装是 LQFP48,和 STM32F103C8T6 的引脚定义高度重合,但这不代表寄存器操作可以照搬。CH32 的 GPIO 模式配置、复用功能映射和时钟使能位都独立设计过,只是 API 风格刻意贴近标准外设库。做这个项目时,我建议先把 I2C 设备规划清楚:BMP280 和 SSD1306 OLED 都挂在同一条 I2C 总线上,BMP280 的 I2C 地址由 SDO 引脚决定,SDO 接 GND 时地址是 0xEC(8 位写地址),接 VCC 时是 0xEE;OLED 的地址则由 SA0 引脚决定,常见模块默认地址是 0x78(8 位写地址)。两者不冲突,可以共用 I2C1。

以下是我实际使用的引脚分配:

外设引脚CH32F103C8T6 功能说明
I2C1_SCLPB6I2C1 时钟线,复用开漏建议外部接 4.7kΩ 上拉到 3.3V
I2C1_SDAPB7I2C1 数据线,复用开漏建议外部接 4.7kΩ 上拉到 3.3V
BMP280 VCC3.3V电源不能接 5V,绝对最大额定值只有 4.25V
OLED VCC3.3V电源部分模块自带稳压,但统一用 3.3V 最稳
BMP280 CSB3.3VI2C 模式选择接高电平禁用 SPI 模式
BMP280 SDOGND地址选择接 GND 得到地址 0xEC

接线时需要注意,BMP280 的 VCC 对电源纹波很敏感。我实测过用同一个 AMS1117 给 MCU 和传感器供电, OLED 刷新时会有 50mV 左右的纹波尖峰,气压读数会出现 ±0.3hPa 的跳动。后来的做法是给 BMP280 单独加一个 10μF 陶瓷电容靠近 VCC 引脚,读数稳定很多。OLED 模块的供电则建议单独从 PCB 的 3.3V 节点拉线,避免经过传感器附近的窄走线。

2.2 为什么建议先初始化 OLED 再初始化 BMP280

调试这类双 I2C 设备项目时,最忌讳一上电就让所有外设同时工作。我一般把 OLED 的初始化放到最前面,因为它本身就是一个调试输出设备——把 MCU 启动状态、传感器自检结果逐行显示出来。先点亮 OLED,之后 BMP280 初始化成功与否,都能直接看到反馈。

初始化顺序如下:

  1. 使能 GPIOB、I2C1 外设时钟
  2. 配置 PB6、PB7 为开漏复用输出
  3. 初始化 OLED(SSD1306 驱动)为 I2C 从机地址 0x78
  4. 读取 OLED 驱动 IC 的 ID 寄存器校验通信,通常返回 0x3C
  5. 复位 BMP280(通过 I2C 写软复位寄存器 0xE0,值 0xB6)
  6. 等待 2ms 后读取芯片 ID 寄存器 0xD0,期望值是 0x58

从软件角度看,这个顺序的价值是:如果 OLED 白屏,问题大概率在 MCU 配置和 I2C 总线,排查范围立刻缩小一半。如果 OLED 正常而 BMP280 读不到 ID,那问题锁定在 BMP280 的电源、地址或者接线。

2.3 I2C 时钟频率选择的实际考量

CH32F103C8T6 的 I2C1 外设时钟源来自 APB1,默认情况下 APB1 时钟是系统主频的二分频。如果系统主频设为 72MHz,APB1 是 36MHz,I2C 外设时钟即以此为基准。标准库的 I2C 初始化结构体里有一个 I2C_ClockSpeed 字段,标准 I2C 模式(100kHz)和快速模式(400kHz)都支持。

这里有个细节:BMP280 数据手册推荐 I2C 最高工作频率是 3.4MHz(其实它支持高速模式),SSD1306 的 I2C 接口最高标称 1MHz。所以理论上两条设备都能跑 400kHz。但我在实际项目里把 I2C 时钟配置成 100kHz 起步调试,先验证通信稳定性,再逐步提升到 400kHz。原因很简单:如果布线较长或上拉电阻阻值偏大(比如用了 10kΩ 而不是 4.7kΩ),400kHz 下波形上升沿会变缓,误码率明显上升。尤其是 OLED 的大屏数据量,每帧 1KB 左右的显存要连续传输,容不得位错误。

通信稳定后想提速,就把 I2C_ClockSpeed 改成 400000,同时把上拉电阻换成 4.7kΩ。如果示波器观察 SCL 上升沿超过 300ns,需要把上拉电阻再降低到 2.2kΩ,或者缩短总线长度。传感器模块到主控板的杜邦线,我建议不超过 15cm。

3. BMP280 驱动实现:从寄存器配置到温度补偿算法

3.1 芯片 ID 校验与软复位是第一步

BMP280 上电后的默认状态是休眠模式,核心寄存器和校准系数都处于未激活状态。所以驱动代码的第一步不是配置测量模式,而是做 ID 校验和软复位。

uint8_t bmp280_init(void) { uint8_t id = 0; // 软复位:往 0xE0 寄存器写 0xB6 i2c_write_byte(BMP280_ADDR, 0xE0, 0xB6); delay_ms(2); // 读取芯片 ID:寄存器 0xD0,期望值 0x58 i2c_read_byte(BMP280_ADDR, 0xD0, &id); if (id != 0x58) { return 1; // 初始化失败 } // 读取校准系数(24 个字节,寄存器 0x88~0xA1) uint8_t calib[24]; i2c_read_bytes(BMP280_ADDR, 0x88, calib, 24); // 解析为有符号/无符号 16 位整数 bmp280_calib.dig_T1 = (uint16_t)(calib[0] | (calib[1] << 8)); bmp280_calib.dig_T2 = (int16_t)(calib[2] | (calib[3] << 8)); // ... 省略剩余 10 个系数的解析 return 0; }

代码逻辑说明:软复位是一个保险动作,确保传感器从干净状态启动。复位后立刻读 ID 是验证 I2C 总线连接最直接的方法——如果地址错了或者 SDO 接法不对,读回来的值往往是 0xFF 或 0x00。校准系数是每个芯片出厂时烧录的独立补偿参数,存放在寄存器 0x88 开始的 24 个字节里,必须读取并缓存到全局结构体,后续所有温度和气压计算都要用到。

参数说明:寄存器地址 0xE0 是软复位命令寄存器,写入 0xB6 触发复位。0xD0 是 ID 寄存器,BMP280 固定返回 0x58。校准系数解析时特别注意,dig_T1 是无符号 16 位,dig_T2 和 dig_T3 是有符号 16 位,如果直接用无符号类型解析会导致负温度补偿计算出错。

3.2 测量模式配置:C 模式 + 过采样组合

BMP280 有多种测量模式,项目里最常用的是 C(正常)模式——传感器周期性自动测量,MCU 只需读取最新结果。配置寄存器的关键是 ctrl_meas(地址 0xF4),它决定温度/气压过采样率和模式。

// 设置 ctrl_meas 寄存器:温度过采样 x1,气压过采样 x4,正常模式 uint8_t ctrl_meas = (0x01 << 5) | (0x03 << 2) | 0x03; i2c_write_byte(BMP280_ADDR, 0xF4, ctrl_meas); // 设置 config 寄存器:Tstandby 1000ms,IIR 滤波系数 x16 uint8_t config = (0x05 << 5) | (0x00 << 2) | (0x00 << 0); i2c_write_byte(BMP280_ADDR, 0xF5, config);

逻辑说明:ctrl_meas 寄存器的高 3 位是温度过采样(osrs_t),中 3 位是气压过采样(osrs_p),低 2 位是模式。温度过采样 x1 对精度影响不大,因为气压补偿需要温度数据,温度精度要求不高;气压过采样 x4 能把噪声从典型 RMS 0.3Pa 降到约 0.12Pa,代价是单次测量时间增加到约 8.5ms,这个取舍在气象站场景是值得的。IIR 滤波系数 x16 是对输出做低通滤波,能进一步压制高频噪声。

参数说明:config 寄存器的高 3 位是 Tstandby(两次测量间隔),这里设为 1000ms;中 2 位是 IIR 滤波系数选项,0x00 表示 x16 系数;低 2 位必须为 0。实际项目里如果设备要放在固定位置做长期监测,IIR x16 是推荐值;如果做无人机高度测量需要快速响应,IIR 设成 0(关闭)更合适,否则滤波会引入滞后。

3.3 原始数据读取和温压计算:一个完整的测量周期

温度测量结果存放在 0xFA 开始 3 个字节(20 位数据),气压测量结果存放在 0xF7 开始 3 个字节。注意读取顺序:先读气压(寄存器 0xF7 连续 6 字节,因为气压在前),一次 I2C 突发传输把两个结果都读回来。

typedef struct { int32_t temp_raw; uint32_t press_raw; } bmp280_data_t; bmp280_data_t bmp280_read(void) { uint8_t buf[6]; bmp280_data_t data; // 0xF7 开始连续读 6 字节:气压 3 字节 + 温度 3 字节 i2c_read_bytes(BMP280_ADDR, 0xF7, buf, 6); data.press_raw = (((uint32_t)buf[0] << 12) | ((uint32_t)buf[1] << 4) | ((uint32_t)buf[2] >> 4)); data.temp_raw = (((uint32_t)buf[3] << 12) | ((uint32_t)buf[4] << 4) | ((uint32_t)buf[5] >> 4)); return data; } // 温度计算(使用厂商标定系数) int32_t bmp280_compensate_temp(int32_t adc_T, bmp280_calib_t *calib) { int32_t var1, var2, T; var1 = ((((adc_T >> 3) - ((int32_t)calib->dig_T1 << 1))) * ((int32_t)calib->dig_T2)) >> 11; var2 = (((((adc_T >> 4) - ((int32_t)calib->dig_T1)) * ((adc_T >> 4) - ((int32_t)calib->dig_T1))) >> 12) * ((int32_t)calib->dig_T3)) >> 14; T = (var1 + var2 + 50) >> 8; return T; // 单位:0.01°C }

逻辑说明:BMP280 的输出是 20 位无符号整数,但最高位符号位在极少数场景下会用到,所以气压用 uint32_t 存。补偿计算完全照搬数据手册第 4.4 节公式——先算温度补偿中间变量 var1/var2,再合并得到温度。注意公式里大量用了移位运算代替除法,这是因为 RISC-V 内核没有硬件除法指令(青稞 V3A 是 M 扩展才支持整数乘除,但编译器默认不一定启用),移位运算在嵌入式场景更高效。

参数说明:补偿公式中 adc_T >> 3 是压缩原始数据精度,避免中间变量溢出。var2 计算公式里的乘法和移位要严格按照顺序来,错一步最终温度会偏差巨大。这里我把 T 返回为 0.01°C 的整数单位,显示时再除以 100,可以完全避免浮点运算。同理,气压补偿公式里也有类似套路,但公式较长,实际工程中我倾向于直接用 int64_t 中间变量来算,如下:

uint32_t bmp280_compensate_pressure(int32_t adc_P, int32_t t_fine, bmp280_calib_t *calib) { int64_t var1, var2, p; var1 = ((int64_t)t_fine) - 128000; var2 = var1 * var1 * ((int64_t)calib->dig_P6); var2 = var2 + ((var1 * (int64_t)calib->dig_P5) << 17); var2 = var2 + (((int64_t)calib->dig_P4) << 35); var1 = ((var1 * var1 * (int64_t)calib->dig_P3) >> 8) + ((var1 * (int64_t)calib->dig_P2) << 12); var1 = (((((int64_t)1) << 47) + var1)) * ((int64_t)calib->dig_P1) >> 33; if (var1 == 0) return 0; p = 1048576 - adc_P; p = (((p << 31) - var2) * 3125) / var1; var1 = (((int64_t)calib->dig_P9) * (p >> 13) * (p >> 13)) >> 25; var2 = (((int64_t)calib->dig_P8) * p) >> 19; p = ((p + var1 + var2) >> 8) + (((int64_t)calib->dig_P7) << 4); return (uint32_t)p; // 单位:Pa }

这里用 int64_t 完全规避了 32 位溢出的问题,CH32F103C8T6 的 32 位 RISC-V 内核处理 64 位整数运算时会生成一对寄存器组合操作,效率比 PC 低但完全可接受。这是我最常用的写法——省去手工拆解 32 位整数溢出的工夫。

3.4 连续测量的时序控制:避免读到新旧混合数据

BMP280 在正常模式下是自动连续测量的,但 MCU 读取时刻可能落在传感器正在更新寄存器的窗口。解决方法是:读状态寄存器 0xF3 的 bit3(measuring 位),为 1 时表示测量进行中,等待后再读取。不过实际中更稳妥的做法是固定时间间隔读取——比如每隔 500ms 读一次,而 BMP280 在过采样 x4 + Tstandby 1000ms 配置下单次测量约 8.5ms,几乎不会冲突。但如果你把 Tstandby 改成了 100ms(寄存器 0x00),就需要在读取前加一个判断:

uint8_t status = 0; do { i2c_read_byte(BMP280_ADDR, 0xF3, &status); } while (status & 0x08); // measuring 位置 1 则等待

这段代码在实际工程里有个隐患:如果传感器损坏或 I2C 通信卡死,这里会死循环。我一般会加个超时机制,最多等 100ms 就跳出,宁可读旧数据也不阻塞主循环。另外要注意,在强制模式(forced mode)下,每次测量前要重新写 ctrl_meas 寄存器触发测量,然后等转换完成后读取;正常模式则不用,这也是我优选正常模式的原因。

4. 0.96 寸 OLED 显示模块:SSD1306 初始化序列与动态刷新

4.1 SSD1306 初始化序列的关键命令解析

0.96 寸 OLED 模块绝大多数用 SSD1306 驱动芯片,128x64 分辨率,I2C 接口。它内部的 GDDRAM(显示数据 RAM)是 1KB(128x64=8192 bit),MCU 需要把整帧数据写到这个 RAM 里,SSD1306 自动扫描输出到屏幕。初始化命令序列是固定的,但有几个命令的参数必须理解清楚:

void oled_init(void) { // 关闭显示 oled_write_cmd(0xAE); // 设置显示时钟分频/振荡频率 oled_write_cmd(0xD5); oled_write_cmd(0x80); // 设置 multiplex 比率(行数)为 64 oled_write_cmd(0xA8); oled_write_cmd(0x3F); // 设置显示偏移为 0 oled_write_cmd(0xD3); oled_write_cmd(0x00); // 设置显示起始行 oled_write_cmd(0x40); // 设置页面地址模式 oled_write_cmd(0x20); oled_write_cmd(0x02); // 设置列地址范围 oled_write_cmd(0x21); oled_write_cmd(0x00); oled_write_cmd(0x7F); // 设置页地址范围 oled_write_cmd(0x22); oled_write_cmd(0x00); oled_write_cmd(0x07); // 设置对比度 oled_write_cmd(0x81); oled_write_cmd(0xCF); // 设置电荷泵使能 oled_write_cmd(0x8D); oled_write_cmd(0x14); // 设置显示模式为正常 oled_write_cmd(0xA6); // 设置显示开启 oled_write_cmd(0xAF); }

参数说明:0x20 后面的 0x02 表示选择页面寻址模式(page addressing mode),这是 SSD1306 最常用的模式,每个页(page)是 8 行像素,共 8 页,页内从第 0 列到第 127 列逐一映射 RAM 地址。0x21 设置列地址从 0 到 127,0x22 设置页地址从 0 到 7,这两个命令让后续的数据写入从屏幕左上角开始。0x8D 开启电荷泵是让 OLED 面板产生内部高压,不使能的话屏幕永远不亮。对比度 0xCF(默认值)在这类蓝色模块上观感适中,偏暗可以加大到 0xFF。

4.2 单个像素点写成与中文/数字显示的最小实现

页面寻址模式下,写入 SSD1306 的数据依次从当前页的左到右排列,每 8 行像素对应一个字节。要让一个像素亮起,读改写在 MCU 侧完成——先读当前字节(软件层面,只要维护一个显存 buffer),修改对应位,再把整块 buffer 刷过去。

// 显存 buffer:128 列 x 8 页,共 1024 字节 uint8_t oled_buffer[1024]; void oled_set_pixel(uint8_t x, uint8_t y, uint8_t on) { if (x >= 128 || y >= 64) return; uint8_t page = y / 8; uint8_t bit = y % 8; if (on) { oled_buffer[page * 128 + x] |= (1 << bit); } else { oled_buffer[page * 128 + x] &= ~(1 << bit); } } void oled_refresh(void) { for (uint8_t page = 0; page < 8; page++) { oled_write_cmd(0xB0 + page); // 设置页地址 oled_write_cmd(0x00); // 列地址低 4 位 = 0 oled_write_cmd(0x10); // 列地址高 4 位 = 0 // 连续发送该页 128 字节数据 i2c_write_bytes(OLED_ADDR, 0x40, &oled_buffer[page * 128], 128); } }

逻辑说明:oled_buffer 是显存镜像,所有图形/文字操作都先修改 buffer,再一次性刷到屏幕。我看到很多新手直接往 OLED 写命令+数据,不放 buffer,导致闪烁严重——因为显示内容每次刷新都是读回改写,没有任何缓存。维护 buffer 后,刷新只是纯写操作,I2C 传输 1024 字节在 400kHz 下约 20ms,视觉上完全无闪烁。

参数说明:oled_write_cmd 发送的是 0x00 控制字节 + 命令字节,oled_write_bytes 发数据时用 0x40 控制字节。这里 i2c_write_bytes 函数的第一个参数是设备地址,第二个是控制字节,第三个是数据指针。注意 I2C 每次传输前都要发 start 信号,SSD1306 不支持连续写命令多次保持总线。

4.3 在 OLED 上同时显示温度和气压数值的排版方案

项目里显示内容分三行:第一行温度,第二行压强,第三行状态/时间。我用的 6x8 点阵 ASCII 字体,一个字符占 6 列 x 8 行,128 像素宽可以放 21 个字符。排版上把数值放在固定列位置,避免数值位长变化导致跳动:

温度: 25.32 C 气压: 101.325 kPa 状态: OK

字体数据存成一个二维数组字库,每字符 6 字节。显示一个 16 位整数转字符串后的变化,核心是数值转字符串 + 字符绘制:

char buf[20]; // 温度:整数部分 + 小数部分 int temp_int = temp_x100 / 100; int temp_dec = temp_x100 % 100; sprintf(buf, "T:%d.%02dC", temp_int, temp_dec); oled_draw_string(8, 0, buf); // x=8, page=0 // 气压:整数部分以 kPa 为单位 int press_kpa = press_pa / 1000; int press_dec = (press_pa % 1000) / 10; sprintf(buf, "P:%d.%03dkPa", press_kpa, press_dec); oled_draw_string(8, 2, buf); // x=8, page=2

参数说明:温度乘 100 后的整数直接 sprintf 拼字符串,小数部分用 %02d 保证两位宽度(25.00 而不是 25.0)。气压同理,单位 kPa 时整数部分百位级,小数部分保留三位。oled_draw_string 的第二个参数是页号,每页 8 像素高,第一页是 y=0~7,第二页是 y=8~15。注意 6x8 字体在页内有 1 像素的行间距调整需求,实际代码里 char 绘制时 bit 偏移到了 1~7 行,避免字符贴顶。

5. 常见问题排查:I2C 卡死、BMP280 读零和 OLED 花屏的修复记录

5.1 I2C 总线卡死:SCL 高但 SDA 被拉低

现象:程序运行几分钟后,MCU 执行到 I2C 发送函数就停在 while 等待总线空闲,结果读回的传感器数据始终为 0x00。用示波器看 SCL 波形正常,但 SDA 线恒为低电平。

原因:I2C 通信中如果主机在传输数据中途被复位,而从机处在等待数据的状态,总线会被从机持续拉低 SDA;或者主机的地址匹配成功但数据位时序被打断,总线状态机进入了死锁。

解决:在 I2C 初始化后加入总线恢复代码——手动切换 GPIO 模式,将 SDA 引脚配置为推挽输出,然后发送 9 个时钟脉冲,最后再切回开漏模式。这能强制释放被从机锁定的 SDA。在 CH32F103C8T6 上,标准库的 I2C 外设没有内置超时机制,所以软件上要在每次 I2C 操作前检查总线状态,超时重置:

void i2c_bus_recover(void) { GPIO_InitTypeDef gpio_init; // 将 SDA 配置为推挽输出 gpio_init.GPIO_Mode = GPIO_Mode_Out_PP; gpio_init.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio_init); for (int i = 0; i < 9; i++) { GPIO_WriteBit(GPIOB, GPIO_Pin_7, Bit_SET); delay_us(5); GPIO_WriteBit(GPIOB, GPIO_Pin_7, Bit_RESET); delay_us(5); } // 恢复开漏 gpio_init.GPIO_Mode = GPIO_Mode_AF_OD; GPIO_Init(GPIOB, &gpio_init); }

从那以后我每次 I2C 初始化前都强制走一遍总线恢复流程,这个习惯比任何代码防错都管用。

5.2 BMP280 气压读数为 0 且温度正常

现象:温度显示正常(25 度左右),但气压输出恒为 0,补偿前原始数据 adc_P 也是 0。

原因:气压测量寄存器(0xF7~0xF9)读回来的三个字节全零。这通常不是 I2C 通信问题,而是气压传感器部分没有正确上电——BMP280 内部气压传感元件和温度传感元件共用一个 MEMS 结构,但测量使能位如果没设对,气压 ADC 不启动。

解决:重新检查 ctrl_meas 寄存器值,重点看 osrs_p 那三位是否为非零。我遇到过的案例是初始化代码里过采样配置赋值的时候不小心把气压过采样写成了 0x00(关闭),导致气压 ADC 永远不转换。正确值是 SET_BITS(ctrl_meas, 4, 2, 3)——也就是第 4 到第 6 位设为 3(过采样 x4)。调完这个寄存器后重新上电,气压就能正常读取。

5.3 OLED 花屏或刷新残留

现象:OLED 显示内容正常,但新旧数据刷新时出现残影,特别是数字快速变化时有拖尾;另一种是屏幕上半部显示正常,下半部有随机噪点。

原因:花屏现象分两种。残留拖尾是显存没有清干净——新的字符串比旧字符串短,旧字符尾部字节留在 buffer 里,刷新时一起送过去了;随机噪点是 SPI/I2C 时序不稳定导致 SSD1306 的 GDDRAM 写入偶发错位。第二种情况在 CH32F103C8T6 上特别容易出现,因为它的 I2C 没有 DMA 中断优先级仲裁机制,主程序里若有其他中断抢占,可能会拉长数据间隙。

解决:显示字符串前先清空目标区域(把 buffer 中该区域所有字节清零再写新字符串);对于噪点问题,把 OLED 刷新函数放入临界区保护(关中断/开中断),确保 1024 字节的连续传输不被中断打断。从实测看,加临界区后噪点出现的概率从每 5 分钟一次降低到完全消失。

5.4 气压值缓慢漂移,和天气站差距 2hPa

现象:静态放置的气压模块,读数在 1 小时内漂移 1~2hPa,且与本地气象站数据有稳定偏差。

原因:首先确认 BMP280 的出厂校准系数是否已正确加载——如果校准系数读取失败但补偿代码没有检查返回值,会用全零系数计算,结果离谱。其次,气压的绝对精度依赖出厂校准,BMP280 的典型精度是 ±1hPa(950~1050hPa 范围内),并且在快速温度变化时,传感器封装内部应力会导致额外漂移。

解决:软件上做两点:一是在设备启动后预热 5 分钟再开始持续测量,让传感器温度稳定;二是做一次相对校准——把当前读数和已知可靠的参考气压做差,将这个差值存储到 Flash 中作为偏移量,之后测量值减去偏移量。这个方法在非专业场景下能把数值调整到和气象站基本一致,但注意它是经验偏移,不能提升传感器本身的绝对精度。

5.5 RISC-V 内核导致的编译陷阱:浮点运算结果异常

现象:同样的 BMP280 补偿代码在 STM32(Cortex-M3)上运行结果正确,移植到 CH32F103C8T6 后,温度和气压数值偶尔跳变,跳变量看似随机。

原因:CH32F103C8T6 的 RISC-V 内核(青稞 V3A)默认没有硬件浮点单元(部分型号有 FPU,但标准库默认不使能),编译器如果按硬浮点 ABI 编译,会用软件浮点库替代,但这本身不会出错。真正的问题在于 volatile 关键字漏写:BMP280 校准系数是从 I2C 读回的变量,在中断服务程序中如果恰好访问,会被编译器优化缓存,导致结果不一致。另一个更隐蔽的原因是 RISC-V 的整数乘法指令可选(M 扩展),如果编译器没有启用 M 扩展,会调用软乘子程序,而软乘子程序的中间进位在某些旧版本库上存在 bug。

解决:所有被 I2C 或中断读取的全局变量加上 volatile 修饰;在编译选项里显式启用 M 扩展(CFLAGS 加 -march=rv32imc 或 -march=rv32imafc,取决于芯片具体型号)。从实际经验看,启用 M 扩展后整数乘法性能提升明显,补偿计算耗时从约 110μs 降到 18μs。

6. 进阶技巧:用 DMA 刷新 OLED 并把主循环阻塞降到最低

当系统里除了传感器采集还有别的任务(比如按键、串口日志、无线通信)时,I2C 发送 1024 字节显存耗时约 20ms(400kHz),如果让 CPU 死等 I2C 传输完成,这段时间主循环就阻塞了。优化思路有二:一是用 DMA 搬运显存数据到 I2C 发送寄存器,二是把显存刷新和测量计算重叠起来。

CH32F103C8T6 的 I2C1 支持 DMA 请求,数据寄存器地址为 I2C1 + 0x04。配置 DMA 通道时,源地址指向显存数组,目的地址填 I2C1 数据寄存器,方向为内存到外设,传输长度 128 字节每页。注意 DMA 配置有两点容易踩坑:一是必须设置外设地址不自增、内存地址自增;二是 I2C 在 DMA 模式下要开启 last DMA 传输和 next DMA 传输位(I2C_DMAReq 和 I2C_LDMA)。下面是一页数据的 DMA 刷新示例:

// 配置 DMA1 通道 7 用于 I2C1_TX void dma_i2c_setup(void) { DMA_InitTypeDef dma_init; dma_init.DMA_PeripheralBaseAddr = (uint32_t)(I2C1_BASE + 0x04); dma_init.DMA_MemoryBaseAddr = (uint32_t)oled_buffer; dma_init.DMA_Direction = DMA_DIR_PeripheralDST; dma_init.DMA_BufferSize = 128; dma_init.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma_init.DMA_MemoryInc = DMA_MemoryInc_Enable; dma_init.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma_init.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma_init.DMA_Mode = DMA_Mode_Normal; dma_init.DMA_Priority = DMA_Priority_High; dma_init.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel7, &dma_init); I2C_DMACmd(I2C1, ENABLE); DMA_Cmd(DMA1_Channel7, ENABLE); }

这段代码的思路是单个页面用 DMA 传输,页与页之间的地址切换(写命令 0xB0 + 列地址)由 CPU 完成,传输本身不占 CPU。刷新 8 页时,预先在中断回调里把显存内容准备好,然后页面切换命令和高 4 位列地址同步修改到 DMA 源地址——我通常是设一个指针数组存放每页起始地址,然后循环触发 DMA。

从实测数据看,使用 DMA 后主循环单次迭代时长从约 35ms(其中 I2C 传输占了 27ms)降到约 8ms(剩余耗时主要是 BMP280 的 8.5ms 测量等待无法避免)。如果进一步把 Tstandby 调小到 100ms,测量和刷新就可以完全重叠,主循环几乎不被 I2C 阻塞,CPU 利用率从 90% 降到 40% 以下。

最终代码里,我把日志输出到 OLED 的第三行,实时显示测量周期计数和总线状态码,调试时能直接看到卡在哪一步。建议你也保留一个这样的状态指示行——在这类传感器项目中,它比任何 printf 都直观。

这个项目拆下来,我的习惯是每个环节先做最小验证:I2C 总线先单独读 BMP280 ID,OLED 先单独点亮一个像素,确认每个子模块都通了再合在一起跑。结合前面说的总线恢复前置和 DMA 刷新,这套架构跑 48 小时以上不卡死的可能性就很高了。希望这篇文章帮到你,动手遇到问题欢迎带着现象来交流。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询