1. 项目缘起与整体设计思路
JW01-CO2-V2.2 这颗模块在圈子里其实不算新面孔,但真正把它和 STM32 配合起来做出一套稳定可用的空气质量监测节点,中间要踩的坑比想象中多。我最近刚完成一个基于 STM32F103C8T6 最小系统板的二氧化碳监测项目,用 USART2 跟 JW01-CO2-V2.2 通信,再通过 I2C 接口把数据刷到 0.96 寸 OLED 上,整个链路跑通之后回头整理了一下,发现很多细节在公开资料里要么一笔带过,要么干脆没提。这篇文章就把我从选型、接线、配置到调试的完整过程拆开来讲,适合正在做 STM32 环境监测类项目、毕业设计或者想快速验证传感器方案的朋友参考。
先说清楚这个项目到底在做什么。JW01-CO2-V2.2 是一颗基于 NDIR 非色散红外原理的二氧化碳浓度传感器模块,出厂已经做了标定,直接输出串口数据帧,量程覆盖 400 到 5000 ppm,精度在 ±50 ppm 加 5% 读数以内。它跟 STM32 之间走的是 UART 协议,默认波特率 9600,周期性地往外吐数据包。STM32 这边负责接收、解析、校验,然后把浓度值、温度值(部分固件版本带温度补偿输出)显示到 OLED 屏幕上。整个系统不需要额外的 ADC 采样,也不需要复杂的模拟前端,核心工作量集中在串口接收状态机、数据帧解析和显示刷新逻辑上。
为什么选 USART2 而不是 USART1?这个问题我在一开始也纠结过。STM32F103C8T6 的 USART1 挂在 APB2 总线上,时钟频率 72 MHz,USART2 挂在 APB1 上,时钟 36 MHz。从波特率精度角度看,两者在 9600 波特率下都能做到误差远小于 2%,实际使用没有区别。但 USART1 的 TX/RX 默认引脚是 PA9/PA10,这两个脚同时也是 SWD 调试接口的复用位置附近,虽然不直接冲突,但在最小系统板上 PA9/PA10 经常被引到其他地方或者被板载 LED 占用。USART2 的 PA2/PA3 相对干净,接线方便,而且我习惯把 USART1 留给调试打印用,这样传感器数据和调试信息可以分两个串口走,互不干扰。这个习惯在后期排查问题时特别有用,你可以一边看传感器原始数据,一边用另一个串口打日志。
OLED 选 I2C 接口而不是 SPI,理由更直接:I2C 只占两个引脚,PB6/PB7 或者 PB10/PB11 都行,接线简单,0.96 寸屏刷个几十帧完全够用。SPI 虽然快,但对于这种刷新率要求不高的场景,多出来的引脚和代码复杂度不划算。我用的是 SSD1306 驱动的 128×64 单色屏,市面上最常见的那个版本,库用的是自己精简过的 I2C 驱动加字库,没有上完整的图形库,因为项目里只需要显示几行文字和一个简单的数值刷新,没必要引入额外依赖。
整体硬件清单如下:
| 组件 | 型号/规格 | 数量 | 备注 |
|---|---|---|---|
| 主控 | STM32F103C8T6 最小系统板 | 1 | 蓝色药丸板即可 |
| 二氧化碳模块 | JW01-CO2-V2.2 | 1 | 注意版本号,V2.2 固件有差异 |
| 显示屏 | 0.96 寸 I2C OLED SSD1306 | 1 | 128×64 分辨率 |
| 调试器 | ST-Link V2 | 1 | 用于下载和调试 |
| 电源 | 5V USB 供电 | 1 | 模块需要 5V,STM32 板载稳压出 3.3V |
这里有个关键点:JW01-CO2-V2.2 模块的供电是 5V,而 STM32 的 IO 是 3.3V 电平。模块的 UART 输出电平在 5V 供电下通常是 3.3V 兼容的,我实测过 TX 引脚空闲高电平在 3.3V 左右,直接接 STM32 的 RX 没有问题。但如果你手头的模块批次不同,建议先用万用表量一下 TX 空闲电平,超过 3.6V 就要考虑加电平转换。RX 方向,STM32 的 TX 输出 3.3V 高电平,模块能不能识别要看它的输入阈值,实测 JW01 的 RX 阈值在 2.0V 左右,3.3V 完全能驱动,所以直接对接是安全的。
2. 核心细节解析与实操要点
2.1 JW01-CO2-V2.2 数据帧格式深度拆解
这颗模块的串口协议看起来简单,但如果不把帧格式吃透,解析代码写出来就是各种乱码和跳变。V2.2 版本的固件输出的是 9 字节定长帧,格式如下:
| 字节序号 | 内容 | 说明 |
|---|---|---|
| 0 | 0xFF | 帧头高字节 |
| 1 | 0x01 | 帧头低字节 |
| 2 | 浓度高字节 | CO2 浓度值高 8 位 |
| 3 | 浓度低字节 | CO2 浓度值低 8 位 |
| 4 | 温度值 | 部分固件版本有效,偏移 40 |
| 5 | 保留 | 通常为 0x00 |
| 6 | 校验和 | 前 7 字节累加和取低 8 位 |
| 7 | 0x0D | 帧尾 CR |
| 8 | 0x0A | 帧尾 LF |
浓度值的计算方式是:浓度 = (高字节 << 8) | 低字节,单位是 ppm。比如收到 0x01 0xF4,那就是 500 ppm。温度值的处理稍微绕一点,如果第 4 字节是 0x28,实际温度就是 0x28 - 40 = 0 摄氏度。这个偏移 40 的设计在 V2.2 固件里是固定的,但有些早期批次可能不输出温度或者偏移量不同,建议以实际收到的数据为准。
校验和的计算是前 7 个字节(从帧头到保留字节)的累加和,取低 8 位。我见过有人把帧尾的 0x0D 0x0A 也算进去,结果校验永远对不上。正确的做法是只累加索引 0 到 6 这七个字节。这个细节在模块手册里写得比较隐晦,我是用逻辑分析仪抓了几帧数据之后才确认的。
注意:V2.2 版本和 V2.1 的帧格式有差异,V2.1 是 7 字节帧,没有温度字节。如果你买到的模块固件版本不确定,先抓一帧原始数据看看长度和帧头,再决定解析逻辑。
2.2 STM32 串口接收方案选型:中断 vs DMA vs 轮询
串口接收这块,我试过三种方案,最后选了中断加空闲检测的方式。先说轮询,最简单,在主循环里不断查 RXNE 标志位,但问题是它会阻塞 CPU,OLED 刷新和按键处理都会被拖慢,而且一旦主循环有其他耗时操作,很容易丢帧。DMA 方案看起来很美,配置好之后 CPU 完全不用管,但 JW01 的数据帧是定长 9 字节,DMA 的接收长度不好设,设 9 字节吧,万一中间丢了一个字节,后面所有帧都会错位;设大一点吧,又要在 DMA 传输完成中断里自己找帧头,逻辑反而复杂了。
中断加空闲检测的方案是这样的:每收到一个字节进一次接收中断,把数据塞进环形缓冲区,同时重置一个定时器。当总线空闲超过一个字节的传输时间(9600 波特率下大约 1 ms),触发空闲中断,这时候把缓冲区里的数据取出来做帧解析。STM32F103 的 USART 自带 IDLE 空闲中断,配合接收中断使用非常顺手。这个方案的好处是既能及时响应,又不会因为帧内字节间隔而误判帧结束,而且 CPU 占用率很低。
具体配置上,USART2 的初始化参数如下:
// 使能时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // GPIO 配置 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; // TX GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; // RX GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // USART 配置 USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate = 9600; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, &USART_InitStructure); // 使能接收中断和空闲中断 USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); USART_ITConfig(USART2, USART_IT_IDLE, ENABLE); // NVIC 配置 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); USART_Cmd(USART2, ENABLE);中断服务函数里要区分是接收中断还是空闲中断:
void USART2_IRQHandler(void) { if(USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART2); // 存入环形缓冲区 ring_buffer_write(data); USART_ClearITPendingBit(USART2, USART_IT_RXNE); } if(USART_GetITStatus(USART2, USART_IT_IDLE) != RESET) { // 清除空闲中断标志:先读 SR 再读 DR volatile uint32_t tmp; tmp = USART2->SR; tmp = USART2->DR; (void)tmp; // 标记一帧接收完成 frame_ready = 1; } }这里有个 STM32 的经典坑:IDLE 标志位的清除方式比较特殊,必须先读 SR 寄存器再读 DR 寄存器,顺序不能反。我一开始只读了 SR 没读 DR,结果空闲中断一直重复触发,程序卡在中断里出不来。这个细节在参考手册里有写,但很容易被忽略。
2.3 I2C OLED 驱动要点与刷新策略
OLED 这边用的是软件 I2C,没有用硬件 I2C 外设。原因很简单:STM32F103 的硬件 I2C 出了名的难用,各种死锁和时序问题,而软件 I2C 在 100 kHz 速率下刷 128×64 的屏幕完全够用,代码可控性也强。PB6 做 SCL,PB7 做 SDA,开漏输出加上拉电阻,上拉用 4.7k 到 10k 都行,我手头只有 10k 的,实测也能稳定跑。
SSD1306 的初始化序列比较长,网上有很多现成的版本,但要注意区分 128×64 和 128×32 的配置差异。128×64 的复用率设置是 0x3F,显示时钟分频要设成 0x80,这些参数错了屏幕要么不亮,要么显示区域只有一半。我建议直接找一个验证过的初始化数组,不要自己从头写。
刷新策略上,我没有用全屏缓冲加定时刷新的方式,而是只刷新数值变化的区域。具体做法是:把屏幕分成几个固定区域,比如第一行显示 "CO2:", 第二行显示浓度值,第三行显示温度。每次数据更新时,只重绘浓度值那一行的数字部分,其他区域不动。这样刷新的数据量小,I2C 占用时间短,也不会出现整屏闪烁的问题。实测下来,从收到串口数据到屏幕更新完成,整个过程在 20 ms 以内,人眼完全感觉不到延迟。
实操心得:OLED 的 I2C 地址通常是 0x78(写地址)或 0x3C(7 位地址),如果你用的是 0.91 寸或者其他型号,地址可能是 0x7A。不确定的话写个扫描程序,把 0x00 到 0xFF 都试一遍,能应答的就是正确地址。
3. 完整实操流程与核心环节实现
3.1 硬件接线与上电检查
接线这一步看起来简单,但接错了后面调试能让你怀疑人生。先把所有连线列清楚:
| STM32 引脚 | 连接目标 | 说明 |
|---|---|---|
| PA2 (USART2_TX) | JW01 RX | 交叉连接 |
| PA3 (USART2_RX) | JW01 TX | 交叉连接 |
| PB6 | OLED SCL | 软件 I2C 时钟 |
| PB7 | OLED SDA | 软件 I2C 数据 |
| 5V | JW01 VCC | 模块供电 5V |
| 3.3V | OLED VCC | 屏幕供电 3.3V |
| GND | JW01 GND, OLED GND | 共地 |
上电之前先用万用表蜂鸣档检查一遍 VCC 和 GND 有没有短路,特别是模块的 5V 和 3.3V 不要接反。JW01 模块内部有稳压,但反接大概率会烧。OLED 屏反接一般不会立刻坏,但背光可能不亮。
上电之后先别急着下载程序,用串口助手接在 JW01 的 TX 和 GND 上,波特率 9600,看看有没有数据出来。正常的话你应该能看到周期性的十六进制数据流,帧头是 FF 01,帧尾是 0D 0A。如果什么都没有,检查模块供电是否正常,有些批次的 JW01 需要预热几十秒才开始输出。如果数据是乱码,检查波特率是不是 9600,或者模块是不是被配置成了其他波特率。
3.2 数据帧解析状态机实现
帧解析我写了一个简单的状态机,不依赖空闲中断的标志,而是在主循环里定期检查环形缓冲区。这样做的好处是解析逻辑和中断解耦,调试的时候可以在解析函数里打断点,不会影响串口接收。
typedef enum { STATE_HEADER_H = 0, STATE_HEADER_L, STATE_DATA_H, STATE_DATA_L, STATE_TEMP, STATE_RESERVED, STATE_CHECKSUM, STATE_TAIL_CR, STATE_TAIL_LF } parse_state_t; typedef struct { uint16_t co2; int8_t temp; uint8_t valid; } jw01_data_t; jw01_data_t parse_jw01_frame(uint8_t *buf, uint16_t len) { jw01_data_t result = {0}; static parse_state_t state = STATE_HEADER_H; static uint8_t checksum = 0; static uint8_t data_idx = 0; static uint8_t frame_buf[9]; for(uint16_t i = 0; i < len; i++) { uint8_t byte = buf[i]; switch(state) { case STATE_HEADER_H: if(byte == 0xFF) { frame_buf[0] = byte; checksum = byte; state = STATE_HEADER_L; } break; case STATE_HEADER_L: if(byte == 0x01) { frame_buf[1] = byte; checksum += byte; state = STATE_DATA_H; } else { state = STATE_HEADER_H; } break; case STATE_DATA_H: frame_buf[2] = byte; checksum += byte; state = STATE_DATA_L; break; case STATE_DATA_L: frame_buf[3] = byte; checksum += byte; result.co2 = (frame_buf[2] << 8) | frame_buf[3]; state = STATE_TEMP; break; case STATE_TEMP: frame_buf[4] = byte; checksum += byte; result.temp = (int8_t)(byte - 40); state = STATE_RESERVED; break; case STATE_RESERVED: frame_buf[5] = byte; checksum += byte; state = STATE_CHECKSUM; break; case STATE_CHECKSUM: frame_buf[6] = byte; if(byte == checksum) { state = STATE_TAIL_CR; } else { state = STATE_HEADER_H; } break; case STATE_TAIL_CR: if(byte == 0x0D) { state = STATE_TAIL_LF; } else { state = STATE_HEADER_H; } break; case STATE_TAIL_LF: if(byte == 0x0A) { result.valid = 1; } state = STATE_HEADER_H; break; } } return result; }这个状态机的核心思路是:只有完整走完所有状态并且校验和正确的帧才会被标记为有效。任何一步出错就回到帧头检测状态,重新开始。这样即使中间有噪声或者丢字节,也能在下一帧自动恢复,不会一直错位。
注意:checksum 变量在每次进入 STATE_HEADER_H 时重置,确保每帧的校验和独立计算。我见过有人在帧尾才重置,结果第二帧的校验和把第一帧的数据也算进去了,导致校验永远失败。
3.3 OLED 显示布局与数值刷新
显示部分我设计了一个简单的布局:第一行固定显示 "CO2 Monitor",第二行显示 "CO2: XXXX ppm",第三行显示 "Temp: XX C",第四行显示 "Status: OK" 或者 "Status: ERR"。浓度值每收到一帧有效数据就更新一次,温度值变化不大,可以每 10 帧更新一次减少 I2C 流量。
数值转字符串我用了自己写的u16_to_str函数,没有用sprintf,因为标准库的sprintf会引入比较大的代码体积,而且浮点格式化在 Keil 里还要额外配置。对于整数显示,自己写转换函数更轻量:
void u16_to_str(uint16_t num, char *str) { char temp[6]; int8_t i = 0; if(num == 0) { str[0] = '0'; str[1] = '\0'; return; } while(num > 0) { temp[i++] = '0' + (num % 10); num /= 10; } int8_t j = 0; while(i > 0) { str[j++] = temp[--i]; } str[j] = '\0'; }刷新的时候只更新数字部分,前面的 "CO2: " 和后面的 " ppm" 不动。OLED 的OLED_ShowString函数指定起始坐标和字符串,我只需要计算数字的起始 X 坐标就行。比如 "CO2: " 占 5 个字符宽度,每个字符 6 像素(8x6 字体),那数字就从 X=30 开始画。
3.4 主循环调度与时间管理
主循环的结构很直接:检查帧标志,解析数据,更新显示,然后处理一些状态指示。没有用 RTOS,因为任务太简单了,裸机跑完全没问题。但要注意主循环里不能有长时间的阻塞操作,比如delay_ms(1000)这种,否则串口数据会丢。
我用 SysTick 做了一个 1 ms 的软定时器,主循环里检查时间戳来决定是否执行某些周期性任务:
volatile uint32_t tick_ms = 0; void SysTick_Handler(void) { tick_ms++; } uint32_t get_tick(void) { return tick_ms; } int main(void) { // 初始化... SysTick_Config(SystemCoreClock / 1000); uint32_t last_display = 0; uint32_t last_led = 0; while(1) { // 处理串口数据 if(frame_ready) { frame_ready = 0; uint8_t buf[64]; uint16_t len = ring_buffer_read(buf, sizeof(buf)); jw01_data_t data = parse_jw01_frame(buf, len); if(data.valid) { current_co2 = data.co2; current_temp = data.temp; data_valid = 1; } } // 每 200ms 刷新一次显示 if(get_tick() - last_display >= 200) { last_display = get_tick(); update_display(); } // 每 500ms 翻转一次 LED 指示 if(get_tick() - last_led >= 500) { last_led = get_tick(); GPIO_WriteBit(GPIOC, GPIO_Pin_13, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_13))); } } }显示刷新间隔设 200 ms 是权衡的结果:太快了 I2C 占用高,太慢了数值变化看起来不流畅。200 ms 对应 5 Hz 的刷新率,对于 CO2 浓度这种变化缓慢的量来说完全够用。
4. 常见问题与排查技巧实录
4.1 串口收不到数据或数据乱码
这是最常见的问题,我把它拆成几个排查步骤。首先确认模块有没有在工作:用 USB 转串口模块直接接 JW01 的 TX 和 GND,电脑上开串口助手,9600 波特率,看有没有数据。如果没有,检查模块供电是不是 5V,有些模块 3.3V 也能亮灯但不输出数据。如果有数据但 STM32 收不到,检查 PA3 有没有配置成浮空输入,TX 和 RX 有没有交叉连接。
数据乱码通常是波特率不匹配。JW01 默认 9600,但如果你之前用配置命令改过,可能变成了 115200 或者其他值。恢复默认的方法一般是断电重启,有些版本需要发送特定命令。另外检查 STM32 的系统时钟配置,如果外部晶振没起振,系统时钟默认走内部 8 MHz,USART 的分频计算就会错,波特率偏差大了自然乱码。用示波器或者逻辑分析仪量一下 PA2 的波形,周期应该是 104 us 左右(9600 波特率下一位的时间)。
4.2 校验和经常对不上
校验和错误一般有三个原因:帧格式理解错了、缓冲区里有残留数据、中断处理有问题。先确认你用的模块版本,V2.2 是 9 字节帧,校验和是前 7 字节累加。如果你按 7 字节帧去解析 9 字节的数据,校验和肯定对不上。
缓冲区残留是另一个常见问题。如果环形缓冲区的读写指针没有正确同步,上一帧的尾部数据可能会混到下一帧的头部。我的做法是在空闲中断触发后,把缓冲区里的数据全部读出来交给解析函数,解析函数内部的状态机自己会处理帧头对齐。不要在中断里做解析,中断里只负责搬运数据。
还有一种情况是串口接收中断被其他高优先级中断打断了,导致某个字节丢失。检查 NVIC 的优先级分组设置,确保 USART2 的中断优先级足够高,不会被 SysTick 或者其他中断长时间阻塞。
4.3 OLED 不亮或显示异常
OLED 不亮先查供电和 I2C 地址。用万用表量 VCC 和 GND 之间是不是 3.3V,然后写个 I2C 扫描程序确认地址。如果地址对了但不亮,检查初始化序列里的对比度设置,有些模块默认对比度是 0,需要手动设成 0xCF 或者 0x9F。
显示异常比如花屏、部分区域不显示,通常是初始化参数不对。128×64 的屏幕复用率是 0x3F,128×32 的是 0x1F,设错了显示区域就会错位。还有显示偏移的问题,SSD1306 有一个显示偏移寄存器,默认是 0,但有些模块出厂设了偏移,需要在初始化时写 0x00 纠正。
I2C 通信不稳定导致的花屏,可以在 SCL 和 SDA 上各并一个 100 pF 的电容到地,滤掉高频噪声。软件 I2C 的延时也要注意,太快了 SSD1306 跟不上,我一般设 2 us 左右的延时,对应大约 100 kHz 的速率。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 串口无数据 | 模块未供电或损坏 | 万用表量 VCC | 检查 5V 供电 |
| 数据乱码 | 波特率不匹配 | 逻辑分析仪量位宽 | 确认 9600 波特率 |
| 校验和错误 | 帧格式理解错误 | 抓原始数据对比 | 确认 V2.2 帧格式 |
| OLED 不亮 | I2C 地址错误 | 写扫描程序 | 确认 0x78 或 0x3C |
| 显示花屏 | 初始化参数错误 | 对比标准序列 | 检查复用率和偏移 |
| 数值跳变 | 帧解析错位 | 打印原始帧 | 加状态机对齐帧头 |
| 程序卡死 | 中断标志未清除 | 调试器看 PC | 正确清除 IDLE 标志 |
避坑技巧:调试串口问题时,先把接收到的原始十六进制数据打印到另一个串口或者 OLED 上,肉眼确认帧结构。不要一上来就怀疑代码逻辑,很多时候是硬件接线或者模块配置的问题。我习惯在项目初期加一个 "raw data" 显示模式,把收到的每个字节都显示出来,确认无误后再切换到解析模式。
4.5 长时间运行的稳定性处理
这个项目我连续跑了 72 小时做老化测试,中间遇到过两次数据卡死的情况。排查下来是环形缓冲区的读写指针在极端情况下出现了竞争。虽然主循环和中断的读写操作在 32 位 MCU 上通常是原子的,但如果缓冲区大小不是 2 的幂,取模运算可能被中断打断,导致指针计算错误。
解决办法很简单:把缓冲区大小设成 2 的幂(比如 128 或 256),然后用位与运算代替取模。另外在读写指针的更新上,先更新数据再更新指针,确保中断看到的数据是一致的。如果还是不放心,可以在关键操作前后关中断,但关中断的时间要尽可能短,不要超过几个微秒。
还有一个稳定性问题是 OLED 的 I2C 通信偶尔会超时。软件 I2C 没有超时检测,如果 SDA 被拉低不放,程序就会死在 while 循环里。我加了一个简单的超时计数,每次等待 SCL 或 SDA 变化时最多循环 1000 次,超时就复位 I2C 总线重新开始。这个保护机制在实际运行中触发过几次,都是因为屏幕排线接触不良导致的,加上之后再也没有卡死过。
5. 项目扩展与个人体会
这套东西跑通之后,扩展方向其实很多。最直接的是加一个 SD 卡或者 Flash 芯片做数据记录,把 CO2 浓度按时间戳存下来,后期可以导出成 CSV 做趋势分析。我试过用 W25Q64 做循环存储,每 10 秒写一条记录,8 MB 的容量能存差不多一个月的数据。另一个方向是加无线模块,把数据传到上位机或者手机端,这个就看具体需求了,有线串口和无线方案各有各的适用场景。
显示方面,如果觉得 0.96 寸太小,可以换 1.3 寸的 SH1106 屏,驱动方式和 SSD1306 基本兼容,只需要改一下初始化里的列地址范围。或者上 TFT 彩屏,用 SPI 接口,刷新率更高,能画曲线图,但代码量会大不少。
我在这个项目里最大的体会是:传感器模块的 datasheet 永远只是参考,实际拿到的模块行为可能和手册有出入。JW01 的 V2.2 固件我就遇到过两个批次,一个输出温度值,一个温度字节固定为 0x00。所以调试阶段一定要先抓原始数据,确认帧格式和字段含义,再动手写解析代码。另外,串口通信的稳定性很大程度上取决于中断处理和缓冲区设计,不要为了省事用轮询,后期一定会出问题。OLED 这边,软件 I2C 加超时保护是最稳妥的组合,硬件 I2C 在 F103 上能不碰就不碰。
最后分享一个小技巧:如果你手头没有逻辑分析仪,可以用 STM32 的另一个串口把原始数据转发到电脑上,用串口助手的十六进制显示功能看帧结构。虽然不如逻辑分析仪直观,但排查基本的波特率和帧格式问题足够了。