国产RISC-V芯片驱动BMP280气压计与OLED屏:I2C多设备通信实战
简介面向嵌入式初学者的 CH32F103C8T6 环境监测小项目基于 RISC-V 内核 MCU 通过 I2C 读取 BMP280 气压、海拔与芯片温度并驱动 0.96 英寸 OLED 实时显示覆盖传感器接线、I2C 通信、显示驱动和工程构建等完整流程。压缩包共 168 个文件约 4.63MB包含 Keil 工程配置文件、C/H 源码、o/axf 等编译产物及 hex 烧录文件目录结构清晰便于直接打开工程或对照学习。项目使用 PB5/PB6 连接 BMP280PB10/PB11 连接 OLED引脚分配直观适合作为 RISC-V 与传感器开发入门案例。已有 167 人浏览学习配套代码兼顾可读性与完整性可帮助新手快速复现环境并在此基础上扩展功能。1. 一颗国产 RISC-V 芯片同时驱动气压传感器和 OLED把工位变成桌面气象站A同学买了一块CH32F103C8T6开发板想干点「能看见结果」的活。折腾一晚上点灯之后他发现最直观的进阶玩法其实是这个组合让一块基于RISC-V内核的国产MCU通过同一根I2C总线挂上BMP280气压传感器和0.96寸OLED屏把大气压强和温度实时刷到屏幕上。这个项目看起来简单但要把I2C多设备管理、传感器校准补偿、OLED显存刷新这几件事串起来恰好覆盖了嵌入式开发最常碰到的几个基本功。读完这篇拆解你能搞明白三颗芯片之间怎么分工、驱动代码每一行在干什么以及为什么很多人在这套组合上翻车的地方往往不是芯片本身而是总线时序和地址配置。它适合刚入门RISC-V开发的学生也适合想快速验证I2C外设驱动写法的工程师。2. 硬件链路与技术选型RISC-V 主控、气压计、OLED 为什么能凑到一起2.1 三颗芯片的分工一颗主控、一个传感器、一块屏CH32F103C8T6是沁恒基于青稞V4A内核设计的RISC-V微控制器指令集属于RV32IMAC主频最高72MHz片上Flash 64KB、SRAM 20KB。它最大的特点是在引脚和大部分外设寄存器层面兼容常见的STM32F103C8T6所以用标准库函数开发时写起来很顺手RCC时钟配置、GPIO_Init这些函数名都能直接沿用迁移成本低。这对于第一次接触RISC-V的人来说是个友好的入口你在别处学过的Cortex-M开发套路在这颗芯片上大部分还能继续用。BMP280是博世的一颗数字气压传感器测量范围300~1100hPa绝对精度大约±1hPa相对精度0.12hPa内置温度传感通过I2C或者SPI接口输出原始数据。它的关键设计在于每颗芯片出厂时都有独立的校准系数烧录在OTP寄存器里软件必须读出来做补偿计算才能拿到准确的气压值——这正是这个项目里最值得研究的一段代码。0.96寸OLED屏则是SSD1306驱动的128×64点阵屏I2C接口模块上通常自带电荷泵电路和上拉电阻不需要额外驱动芯片MCU只需要往它的显存里写数据就能显示内容。这三样东西的巧合之处在于它们都支持I2C总线。于是整个工程的架构就变成一个主控、一条总线、两个从设备。BMP280提供物理量MCU负责读取和换算OLED负责把结果画出来链路非常清晰。2.2 接线与设备地址一条 I2C 总线上怎么挂两个从设备而不冲突I2C总线上每个设备都有独立的7位地址通信时主机通过地址字节选中目标。这个项目里BMP280和OLED虽然挂在同一条总线上但地址不冲突所以可以直接并接。接线方式如下信号接法说明VCC3.3V两个模块都接3.3V别接5VGND共同接地所有设备共地SCLPB6时钟线接上拉电阻SDAPB7数据线接上拉电阻OLED SA0悬空或接地地址为0x3CBMP280 SDO接地地址为0x76BMP280的I2C地址由SDO引脚决定SDO接地时地址是0x76接高电平是0x77。OLED的SA0引脚同样决定地址默认0x3C接高为0x3D。这里两个设备地址分别是0x76和0x3C互不干扰。很多初学者会忽略地址配置直接把模块往总线上一插就跑代码结果两个设备都找不到这是第一个常见坑。上拉电阻方面多数现成模块上已经焊好了10K或者4.7K电阻。如果你用的是没有上拉的裸屏或者自己画的板子必须在SCL和SDA上各加一个2.2K到4.7K的上拉电阻到3.3V否则总线电平拉不上去通信会时好时坏。2.3 开发环境与工程骨架MounRiver Studio 里从新建到烧录开发这个项目用的是MounRiver StudioMRS一套基于Eclipse的免费IDE内置了RISC-V GCC工具链新建工程时可以直接选择CH32F103C8T6。步骤如下打开MRSFile → New → CH32V Project选择芯片型号CH32F103C8T6。工程模板会自动生成main.c、system_ch32f103.c和startup启动文件。把你准备好的BSP驱动文件夹i2c_soft、bmp280、oled拷贝到工程目录下在main.c里include对应头文件。用WCH-Link连接开发板的SWDIO、SWCLK、GND、3V3四个引脚配置好调试器后点击烧录。WCH-Link是沁恒的调试下载器支持SWD协议也内置了串口功能。烧录时要注意的是WCH-Link需要通过固件版本区分RISC-V模式和ARM模式调试CH32F103时必须工作在RISC-V模式。一般IDE里会自动切换但如果插上之后识别不到芯片优先检查这个模式配置。3. 工程结构与 I2C 底层两个外设共用一条总线的通信架构3.1 工程目录结构驱动按外设拆文件的好处我把这个项目的代码分成四层主逻辑、I2C底层、BMP280驱动、OLED驱动。目录结构如下CH32F103_BMP280_OLED/ ├── User/ │ └── main.c # 主循环采集与显示 ├── BSP/ │ ├── i2c_soft.c / h # 软件I2C时序、字节读写、寄存器读写 │ ├── bmp280.c / h # BMP280驱动校准、原始数据读取、补偿计算 │ └── oled.c / h # SSD1306驱动初始化、显存操作、字符显示 ├── Debug/ # MRS生成的编译调试文件 └── CH32F103C8T6.ld # 链接脚本这样拆的核心原因有两个。第一I2C底层被BMP280和OLED共用如果把它写进某个设备驱动里另一个设备就得重复实现一遍单独抽出来两个外设驱动都只调用I2C_ReadRegisters和I2C_WriteRegister这两个通用接口。第二设备驱动之间完全解耦改OLED的显示逻辑不会影响传感器采集后续加新的I2C设备也只是新增一个.c文件。小事上养成边界意识项目大了才不会乱。3.2 软件 I2C 读写实现从位时序到字节收发这里我没有用CH32F103的硬件I2C外设而是用GPIO模拟软件I2C。原因是BMP280和OLED的驱动网上大量是软件I2C写法遇到问题时方便对照逻辑分析仪的量测结果软件I2C还能随便映射到任意引脚不受硬件外设引脚绑定限制。初始化时把PB6和PB7配成开漏输出void I2C_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); SCL_H(); SDA_H(); }开漏输出的含义是输出1时引脚呈高阻状态由外部上拉电阻把电平拉高输出0时引脚直接拉低。这样SCL和SDA天然满足I2C总线的线与特性多个设备不会互相短路。读SDA时先把它置1释放总线再读引脚输入电平即可。接下来是I2C的核心时序函数void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); // SDA在SCL高电平期间拉低产生起始位 I2C_Delay(); SCL_L(); } void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); // SDA在SCL高电平期间拉高产生停止位 I2C_Delay(); } void I2C_WriteByte(uint8_t dat) { for (uint8_t i 0; i 8; i) { if (dat 0x80) SDA_H(); else SDA_L(); dat 1; I2C_Delay(); SCL_H(); // SCL高电平期间从机采样SDA I2C_Delay(); SCL_L(); I2C_Delay(); } SDA_H(); // 释放SDA等待从机ACK I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); } uint8_t I2C_ReadByte(void) { uint8_t dat 0; SDA_H(); // 主机释放SDA由从机驱动 for (uint8_t i 0; i 8; i) { dat 1; SCL_H(); I2C_Delay(); dat | SDA_READ(); // 在SCL高电平时读一位 SCL_L(); I2C_Delay(); } return dat; }I2C_Delay负责产生时序节拍。在72MHz主频下一个简单的空循环大概能产生几微秒的延迟对应标准模式100kHz的总线时序。如果你的逻辑分析仪显示SCL频率接近400kHz甚至更高把循环次数调大一些时序更稳。起始位和停止位的时序特征都是在SCL高电平期间切换SDA电平这是I2C协议里区分数据和起始停止的根本判据抓波形时先对着这一点查。3.3 通用的寄存器读写接口设备和寄存器地址怎么拼有了字节收发函数还要封装两个对上层友好的接口写一个寄存器、连续读多个寄存器。BMP280这类传感器是标准寄存器设备访问方式固定先发设备地址加写方向位再发寄存器地址然后写数据读的时候要发重复起始位并切换为读方向。void I2C_WriteRegister(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) { I2C_Start(); I2C_WriteByte((dev_addr 1) | 0); // 7位地址左移一位末位0表示写入 I2C_WriteByte(reg_addr); I2C_WriteByte(data); I2C_Stop(); } void I2C_ReadRegisters(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buf, uint8_t len) { I2C_Start(); I2C_WriteByte((dev_addr 1) | 0); // 先写方向告诉从机要读哪个寄存器 I2C_WriteByte(reg_addr); I2C_Start(); // 重复起始位 I2C_WriteByte((dev_addr 1) | 1); // 切换为读方向 for (uint8_t i 0; i len; i) { buf[i] I2C_ReadByte(); } I2C_Stop(); }这里最容易被坑的是地址拼接BMP280的设备地址0x76是7位地址而I2C总线上实际发送的是8位字节。必须把0x76左移一位最低位用来表示读写方向所以写方向发的字节是0xEC读方向是0xED。如果你直接把0x76当成地址字节发出去就相当于对0x3B的从机地址发起通信设备当然不会应答。OLED的SSD1306地址0x3C同理写方向0x78读方向0x79。把这个拼接逻辑统一封装在I2C层上层驱动永远传7位地址能避免大量低级错误。4. 核心模块拆解从物理压强到屏幕像素的完整数据链路4.1 BMP280 驱动校准系数补偿才是精度关键BMP280驱动分三个步骤读取校准系数、配置测量模式、读取原始数据并补偿。上电后第一步就是读取位于0x88到0xA1的24字节校准数据。这些系数包含了温度校准的dig_T1到dig_T3和压力校准的dig_P1到dig_P9每颗芯片出厂时标定值都不一样不读直接用会得到离谱的读数。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; int16_t dig_P6, dig_P7, dig_P8, dig_P9; } bmp280_calib_t; static bmp280_calib_t bmp280_calib; void BMP280_ReadCalib(void) { uint8_t buf[24]; I2C_ReadRegisters(BMP280_ADDR, 0x88, buf, 24); bmp280_calib.dig_T1 (uint16_t)((buf[1] 8) | buf[0]); bmp280_calib.dig_T2 (int16_t)((buf[3] 8) | buf[2]); bmp280_calib.dig_T3 (int16_t)((buf[5] 8) | buf[4]); bmp280_calib.dig_P1 (uint16_t)((buf[7] 8) | buf[6]); // 后面dig_P2~dig_P9按同样方式从buf[8]~buf[23]解析 }注意不同系数的符号类型dig_T1和dig_P1是16位无符号数其余都是带符号数强制转换时不能省略。校准数据读取后缓存在全局变量里后续每次补偿计算都会用到。接着配置控制寄存器0xF4把过采样和模式一次写好void BMP280_Init(void) { BMP280_ReadCalib(); // 0x27 温度过采样x1(0x20) | 气压过采样x1(0x04) | normal模式(0x03) I2C_WriteRegister(BMP280_ADDR, 0xF4, 0x27); }参数含义bit7到bit5是温度过采样bit4到bit2是气压过采样bit1到bit0是工作模式。过采样x1表示每次测量取ADC直读值过采样x2以上是芯片内部多次采样取平均数值越大噪声越小但转换时间越长。normal模式让芯片持续测量每次测完自动进入待机适合实时显示场景。原始数据读取和补偿是重点。BMP280的数据寄存器从0xF7开始共6个字节依次是气压MSB、LSB、XLSB、温度MSB、LSB、XLSB每个物理量是20位有符号数据void BMP280_ReadRaw(int32_t *temp_raw, int32_t *press_raw) { uint8_t buf[6]; I2C_ReadRegisters(BMP280_ADDR, 0xF7, buf, 6); *press_raw (int32_t)((buf[0] 12) | (buf[1] 4) | (buf[2] 4)); *temp_raw (int32_t)((buf[3] 12) | (buf[4] 4) | (buf[5] 4)); }20位数据拼接时XLSB只用到高4位所以右移4位丢弃无意义的低4位。拿到原始ADC值后必须经过补偿公式换算才能得到真实的温度和气压值。这里只列温度补偿的核心部分static int32_t t_fine; int32_t BMP280_CompensateTemp(int32_t adc_T) { int32_t var1, var2; var1 (((adc_T 3) - ((int32_t)bmp280_calib.dig_T1 1)) * (int32_t)bmp280_calib.dig_T2) 11; var2 ((((adc_T 4) - (int32_t)bmp280_calib.dig_T1) * ((adc_T 4) - (int32_t)bmp280_calib.dig_T1)) 12 * (int32_t)bmp280_calib.dig_T3) 14; t_fine var1 var2; return (t_fine * 5 128) 8; // 返回单位0.01摄氏度 }关键中间变量t_fine会被气压补偿公式继续使用它本质上是通过ADC原始值反推出的芯片内部温度用来修正压力传感器的温漂。压力补偿公式更长使用了dig_P1到dig_P9共9个系数做多项式拟合出来的结果是带符号的除以100后才是hPa。这是BMP280精度来源的核心逻辑不建议为了省事跳过否则测出来的气压值会随着芯片温度漂移。4.2 SSD1306 驱动初始化序列与显存刷新策略OLED这边的工作分两步初始化控制器然后把要显示的内容写进1KB显存。SSD1306上电后默认处于关闭状态需要发送一串初始化命令。I2C写入命令时第二字节必须是控制字节0x00表示后续是命令写显存数据时第二字节是0x40。void OLED_WriteCommand(uint8_t cmd) { I2C_Start(); I2C_WriteByte(0x3C 1); // OLED地址0x3C写方向 I2C_WriteByte(0x00); // 控制字节本次传输的是命令 I2C_WriteByte(cmd); I2C_Stop(); } void OLED_Init(void) { OLED_WriteCommand(0xAE); // 关闭显示 OLED_WriteCommand(0xD5); OLED_WriteCommand(0x80); // 时钟分频 OLED_WriteCommand(0xA8); OLED_WriteCommand(0x3F); // 64行复用 OLED_WriteCommand(0xD3); OLED_WriteCommand(0x00); // 显示偏移0 OLED_WriteCommand(0x40); // 起始行0 OLED_WriteCommand(0x8D); OLED_WriteCommand(0x14); // 电荷泵开 OLED_WriteCommand(0x20); OLED_WriteCommand(0x00); // 水平寻址模式 OLED_WriteCommand(0xA1); // 段重映射 OLED_WriteCommand(0xC8); // COM扫描方向 OLED_WriteCommand(0xDA); OLED_WriteCommand(0x12); // COM引脚配置 OLED_WriteCommand(0x81); OLED_WriteCommand(0xCF); // 对比度 OLED_WriteCommand(0xD9); OLED_WriteCommand(0xF1); // 预充电 OLED_WriteCommand(0xDB); OLED_WriteCommand(0x40); // VCOMH OLED_WriteCommand(0xA4); // 显示启用显存内容 OLED_WriteCommand(0xA6); // 非反色 OLED_WriteCommand(0xAF); // 打开显示 OLED_Clear(); }初始化后要设置显存写入位置。SSD1306的显存按页划分128×64分辨率对应8页每页8行像素。写入数据前要告知控制器目标列和页地址void OLED_SetPos(uint8_t x, uint8_t page) { OLED_WriteCommand(0x21); OLED_WriteCommand(x); OLED_WriteCommand(0x7F); OLED_WriteCommand(0xB0 | page); }字符显示用的是8×16点阵字模显示一个字符需要写两页数据每页8个字节。主循环里拼好待显示字符串逐字符调用写入函数。由于I2C刷整屏128字节要传输大量数据刷新频繁会闪烁更好的做法是只更新变化区域每次显示数据前先调用OLED_SetPos定位再连续写一行数据的显存字节而不是清屏后全量重绘。4.3 主循环逻辑采集、换算、显示三步定时器驱动主循环的结构非常直接初始化外设然后每秒采集一次BMP280的原始数据经过补偿换算得到温度和气压格式化字符串后刷到OLED指定位置。int main(void) { Delay_Init(); I2C_GPIO_Config(); OLED_Init(); BMP280_Init(); OLED_ShowString(8, 0, BMP280 Weather); OLED_ShowString(16, 1, CH32F103 RISC-V); while (1) { int32_t temp_raw, press_raw; BMP280_ReadRaw(temp_raw, press_raw); int32_t temp BMP280_CompensateTemp(temp_raw); int32_t press BMP280_CompensatePress(press_raw); char line[16]; sprintf(line, T:%d.%02d C, temp / 100, temp % 100); OLED_ShowString(8, 3, line); sprintf(line, P:%ld.%02d hPa, press / 100, press % 100); OLED_ShowString(8, 5, line); Delay_Ms(1000); } }温度补偿返回的单位是0.01摄氏度所以整数值temp/100就是整数部分temp%100是小数部分用整数运算避免了嵌入式里浮点格式化的开销。气压补偿返回的是Pa除以100变成hPa。显示时用固定位置刷写不清屏减少闪烁也节省了I2C总线带宽。如果后续想加高度计或者更多传感器主循环就是天然的扩展点往里加读取和显示即可。5. 避坑记录五个让 RISC-V 工程翻车的实际问题5.1 I2C 设备无应答地址字节的拼接错误现象OLED屏幕没有任何显示BMP280读回来的寄存器值全是0xFF用逻辑分析仪抓总线发现设备始终没有拉低SDA回ACK。原因直接把7位设备地址0x3C或0x76当作总线上的地址字节发送没有左移一位拼接读写方向位导致主机在跟一个完全错误的从机地址通信。解决在I2C底层统一处理写成(dev_addr 1) | 0和(dev_addr 1) | 1上层驱动只传7位地址。这样从到到尾只在一个地方做拼位排查问题时也只需检查这一个函数。5.2 SDA 读不到数据开漏输出变成了高阻悬浮现象读BMP280寄存器时主设备收到的每一位都是1波形上SDA全程高电平看不到从机拉低的迹象。原因GPIO配置成了开漏输出但模块上没有自带外部上拉电阻SDA释放后电平不确定或者误配成了推挽输出读引脚输入电平时读不到真正的总线状态。解决在SCL和SDA上各加2.2K~4.7K上拉电阻到3.3V开漏模式才能正常工作。读SDA之前要先把SDA引脚输出置1释放总线再延时片刻后读取GPIO输入寄存器。5.3 BMP280 压力值漂移严重跳过了校准系数读取现象上电后读到的气压值看起来合理但跑一天下来会缓慢漂移温度变化时尤其明显和真实气象数据对不上。原因补偿公式依赖每颗芯片独立的12个校准系数这些系数存在OTP里必须从0x88~0xA1寄存器读出。跳过这一步直接算原始ADC值温度补偿和压力温漂修正都缺失读数自然不准。解决初始化时调用BMP280_ReadCalib将24字节系数存入全局结构体之后的每次补偿计算都使用这些缓存值。只要总线通信正常这个步骤不会失败但是最容易因为觉得「麻烦」而被省掉。5.4 OLED 花屏或刷新闪屏显存寻址模式没显式配置现象字符错位、上下颠倒、刷新时整屏闪烁明明写入的显存数据是对的。原因SSD1306上电后默认的寻址模式不一定是水平寻址连续写数据时列地址不自增导致后续内容覆盖到错误位置。初始化序列里漏掉了0x20 0x00这段配置。解决在初始化命令中显式设置0x20为0x00水平寻址模式。另外刷新时只更新变化的行区域调用OLED_SetPos定位到目标页和列再连续写该区域数据不要每次都清空全屏重建。5.5 WCH-Link 连接不上调试器工作在错误的模式现象IDE点连接报错识别不到RISC-V芯片但Check按钮能通过。原因WCH-Link在ARM和RISC-V两种模式下行为不同默认或者上次使用时停在了ARM模式无法和CH32F103建立SWD连接。解决MounRiver Studio通常会自动切换调试器模式如果连不上手动检查WCH-Link固件版本并切换到RISC-V模式。连线检查四根线SWDIO、SWCLK、GND、3V3注意共地。还有一个不起眼的坑确认开发板的BOOT跳线在Flash启动位置。6. 二次开发从一个板级 Demo 到多功能桌面气象站6.1 用气压换算海拔升级为高度计BMP280测的是大气压强而气压随海拔升高按指数规律下降。利用国际气压测高公式可以把实测气压换算成海拔高度这在国内气压测高场景里误差通常在几米到十几米范围内适用于室内楼层判断或户外山体粗略测高。float BMP280_CalcAltitude(float pressure_hpa) { return 44330.0f * (1.0f - powf(pressure_hpa / 1013.25f, 0.190295f)); }1013.25是海平面标准气压0.190295是1/5.255的近似值。换算出海拔后可以在OLED第三行显示高度。注意这个值对海平面基准气压敏感如果你知道当地气象站发布的当前海平面气压替换掉1013.25能明显提高精度。6.2 挂载更多 I2C 传感器扩展成温湿度空气质量监测站I2C底层已经封装好了通用的寄存器读写接口新增设备不需要动已有代码。比如加一颗AHT20温湿度传感器它和BMP280一样是7位I2C设备地址0x38新建一个aht20.c按照数据手册的初始化命令和数据读取时序实现即可。主循环里把显示区域划分成温度、湿度、气压、海拔四个区块就是一个能放在工位上的小型气象站。如果觉得显示布局改起来麻烦给OLED驱动加一个简单的快速画线函数能画出数据曲线比数字更直观。6.3 低功耗改造从持续轮询到触发采样BMP280支持forced模式写入0xF4触发一次测量芯片测完自动回睡眠电流比normal模式低很多。配合主控的WFI指令进入待机用定时器周期性唤醒采集并刷新屏幕可以把整机功耗从几毫安降到几十微安级别适合电池供电的场景。改造时要注意forced模式下每次读数据前都要重新写一次0xF4触发测量否则读到的是上一次的结果。从普通Demo走向低功耗产品这个改动是必经之路。这个项目拆到最后真正有价值的东西反而不是某一段代码而是那一整套排查习惯。我从这块板子之后每次新拿到一块国产RISC-V开发板都会先把三件事写进自己的检查清单I2C从机地址有没有左移拼位、总线上拉电阻接没接、调试器固件模式选没选对。这套三板斧帮我省下了大量在总线时序里瞎猜的时间也希望帮到你。本文还有配套的精品资源点击获取