1. 为什么选JW01-CO2-V2.2做STM32环境监测项目1.1 从需求出发空气质量监测的刚需场景室内空气质量这几年越来越受关注尤其是CO2浓度这个指标。人在密闭房间里待久了会犯困、注意力下降本质上就是CO2浓度升高导致的。一般室外大气CO2浓度在400ppm左右室内通风良好时大概500-800ppm一旦超过1000ppm人就开始有明显感觉了超过2000ppm就属于比较糟糕的环境了。我做过好几个基于STM32的环境监测项目从最开始的DHT11温湿度到后来的PM2.5传感器再到CO2模块踩了不少坑。CO2传感器选型这块市面上常见的有MH-Z19系列、SCD30、CCS811还有今天要聊的JW01-CO2-V2.2。选JW01的原因很简单便宜、串口输出、协议简单、精度对于DIY项目完全够用。它用的是NDIR非色散红外原理寿命长不像电化学传感器那样容易老化漂移。这个项目适合谁如果你正在做基于STM32的毕业设计、课程设计或者想自己搭一个空气质量检测的小设备又或者你手头正好有JW01模块不知道怎么驱动那这篇内容基本能帮你把路走通。我用的方案是STM32F103C8T6最小系统板加JW01-CO2-V2.2加I2C OLED显示整套成本不到50块钱但做出来的东西实用性很强。1.2 硬件选型背后的逻辑先说主控。STM32F103C8T6这颗芯片不用多介绍了72MHz主频、64KB Flash、20KB RAM对于这种传感器读取加显示的应用绰绰有余。关键是便宜最小系统板十块钱出头资料多Keil5和标准库的生态非常成熟。有人问要不要上HAL库我的建议是如果你刚开始学STM32标准库更容易理解底层寄存器操作如果你已经熟悉了HAL库开发效率更高。这个项目我用标准库来写因为串口中断接收这块标准库的代码更直观。JW01-CO2-V2.2这个模块有几个版本V2.2是较新的固件版本默认波特率9600输出格式是主动上报模式每500ms左右发一帧数据。模块供电3.3V到5V都行但注意它的串口电平是3.3V的直接接STM32的USART引脚没问题不用电平转换。模块预热时间大概需要3分钟刚上电时读数会从高值慢慢降下来这是NDIR传感器的正常特性不是坏了。OLED屏用0.96寸I2C接口的SSD1306四根线搞定VCC、GND、SCL、SDA。I2C的好处是省引脚STM32的PB6和PB7正好可以复用为I2C1。有人喜欢用SPI的OLED刷新率确实高一些但I2C在400KHz速率下刷一屏也就十几毫秒对于CO2数据这种秒级更新的场景完全够用。2. JW01-CO2-V2.2通信协议深度拆解2.1 串口帧格式详解JW01-CO2-V2.2的串口协议不复杂但有几个细节如果不注意读出来的数据就是乱的。模块主动上报模式下每帧固定9个字节格式如下字节序号内容说明00xFF帧头高字节10x01帧头低字节20x86命令字表示CO2数据30x00数据高字节保留40x00数据低字节保留5浓度高字节CO2浓度高位6浓度低字节CO2浓度低位70x00保留8校验和前面所有字节求和取低8位CO2浓度的计算公式是浓度 高字节 × 256 低字节。比如收到0x01和0xF4那就是1×256244500ppm。校验和的计算方式是把第0到第7字节相加取最低8位。这个校验很有必要因为串口传输在电机、继电器等干扰源附近容易出错没有校验的话可能显示一个离谱的数值。2.2 为什么用中断接收而不是轮询很多教程用轮询方式读串口就是在主循环里不断查RXNE标志位。这种方式在简单场景能用但有两个问题一是浪费CPU时间二是如果主循环里有延时或者OLED刷新占用时间长了可能漏掉字节导致帧错位。我的做法是用USART2的接收中断每收到一个字节就存进缓冲区同时用一个状态机来判断帧头。具体逻辑是检测到0xFF后下一个字节如果是0x01就认为一帧开始了然后连续收9个字节最后校验。校验通过就更新全局的CO2浓度变量校验失败就丢弃重新找帧头。这种状态机加中断的方式即使主循环在刷OLED或者做其他事情串口数据也不会丢。STM32F103的USART2中断优先级可以设高一点保证及时响应。2.3 模块的几种工作模式JW01-CO2-V2.2除了主动上报模式还支持问答模式。主动上报是模块每隔一段时间自动发数据问答模式是你发命令它才回。对于环境监测这种需要持续读数的场景主动上报更省事。模块背面有个跳线或者焊盘可以切换模式具体看你的模块版本V2.2默认是主动上报。还有一个校准功能。NDIR传感器用久了会有零点漂移模块支持手动校准和自动校准。手动校准是发特定命令自动校准是模块根据环境判断。我一般建议在室外新鲜空气中上电让模块自己走自动校准流程这样零点更准。如果放在室内密闭环境上电它可能把高浓度当成零点后面读数就偏低了。3. 硬件连接与工程搭建实操3.1 接线方案与注意事项先列一下完整的接线表STM32引脚连接目标说明PA2 (USART2_TX)JW01的RX发送引脚本项目基本不用PA3 (USART2_RX)JW01的TX接收CO2数据PB6 (I2C1_SCL)OLED的SCLI2C时钟线PB7 (I2C1_SDA)OLED的SDAI2C数据线3.3V模块VCC和OLED VCC供电GND模块GND和OLED GND共地这里有个坑要注意JW01模块的TX要接STM32的RX别接反了。我见过好几个同学接反了然后说读不到数据查了半天。另外模块的供电最好独立走线不要和OLED共用一根细杜邦线OLED刷新时电流波动可能影响模块工作。还有一点STM32F103的USART2默认引脚是PA2和PA3但如果你用的是某些最小系统板PA2/PA3可能被其他功能占用了需要检查一下板子原理图。如果冲突了可以用重映射功能把USART2映射到PD5和PD6不过F103C8T6没有PD口所以还是老老实实用PA2/PA3。3.2 Keil5工程配置要点新建工程这块我用的是标准库V3.5.0。步骤大致是新建工程、选芯片型号STM32F103C8、添加启动文件、添加标准库的.c文件、配置头文件路径、勾选Create HEX File。需要添加的标准库文件包括stm32f10x_rcc.c、stm32f10x_gpio.c、stm32f10x_usart.c、stm32f10x_i2c.c还有misc.c用于中断优先级配置。如果你用到了延时函数还需要stm32f10x_tim.c或者自己写SysTick延时。有个细节标准库的stm32f10x_conf.h文件里要把用到的外设头文件include进来否则编译会报错。很多人新建工程后编译一堆错误八成是conf.h没配好。时钟配置方面F103C8T6外部晶振是8MHz经过PLL 9倍频到72MHz。USART2挂在APB1总线上APB1的时钟是36MHz。计算波特率的时候要注意这个36MHz不是72MHz。9600波特率的计算是36000000 / (16 × 9600) 234.375取整234实际波特率是36000000 / (16 × 234) 9615.38误差0.16%完全在允许范围内。3.3 I2C OLED驱动移植OLED驱动我用的是一份常见的SSD1306驱动包含oled.c和oled.h。移植的时候主要改两个地方一是I2C的引脚定义二是I2C的读写函数。如果你用的是硬件I2C直接调用标准库的I2C函数就行如果用软件I2C那就用GPIO模拟时序。我实测下来软件I2C在STM32F103上跑400KHz没问题但要注意延时函数的精度。如果你用SysTick做延时delay_us的误差要控制好否则I2C时序可能不满足。硬件I2C更稳但F103的硬件I2C有个著名的死锁问题就是在某些异常情况下SCL被拉低不放。规避方法是加超时检测或者干脆用软件I2C。OLED的显存是128×64位也就是1KB。刷新的时候先写页地址再写列地址然后连续写数据。全屏刷新一次大概需要十几毫秒对于CO2数据每秒更新一次的场景完全不会闪烁。4. 核心代码实现与关键逻辑4.1 串口中断接收状态机先看串口初始化的关键代码void USART2_Init(u32 bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // PA2 TX GPIO_InitStructure.GPIO_Pin GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // PA3 RX GPIO_InitStructure.GPIO_Pin GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate bound; 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); 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); }中断服务函数里用状态机处理u8 rx_buf[9]; u8 rx_cnt 0; u16 co2_value 0; void USART2_IRQHandler(void) { if(USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { u8 data USART_ReceiveData(USART2); USART_ClearITPendingBit(USART2, USART_IT_RXNE); if(rx_cnt 0) { if(data 0xFF) rx_buf[rx_cnt] data; } else if(rx_cnt 1) { if(data 0x01) rx_buf[rx_cnt] data; else rx_cnt 0; } else { rx_buf[rx_cnt] data; if(rx_cnt 9) { // 校验 u8 sum 0; for(u8 i 0; i 8; i) sum rx_buf[i]; if(sum rx_buf[8]) { co2_value rx_buf[5] * 256 rx_buf[6]; } rx_cnt 0; } } } }这段代码的核心思路是只有收到正确的帧头才开始存数据中途任何异常都重置状态。校验和通过才更新co2_value这样即使有干扰也不会显示错误数据。4.2 OLED显示布局设计显示这块我设计了一个简单的界面第一行显示CO2 Monitor标题第二行显示CO2浓度数值第三行显示单位ppm第四行显示一个简单的状态指示。void OLED_ShowCO2(u16 co2) { char buf[16]; OLED_ShowString(0, 0, CO2 Monitor, 16); sprintf(buf, %4d ppm, co2); OLED_ShowString(0, 2, buf, 16); if(co2 800) OLED_ShowString(0, 4, Status: Good, 16); else if(co2 1500) OLED_ShowString(0, 4, Status: Fair, 16); else OLED_ShowString(0, 4, Status: Poor, 16); }sprintf用的时候注意栈空间F103C8T6的RAM只有20KBsprintf本身占用的栈不小。如果发现程序跑飞可以把buf改成全局变量或者用更轻量的格式化函数。4.3 主循环与数据更新策略主循环的逻辑很简单初始化外设然后每隔500ms刷新一次OLED。但这里有个细节不要每次循环都刷OLED因为I2C刷新需要时间频繁刷新可能导致串口中断响应延迟。我的做法是用一个计数器每500ms更新一次显示。int main(void) { delay_init(); NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); USART2_Init(9600); OLED_Init(); OLED_Clear(); u32 last_update 0; while(1) { if(millis() - last_update 500) { last_update millis(); OLED_ShowCO2(co2_value); } } }millis()函数用SysTick实现每1ms中断一次累加计数。这样主循环不阻塞串口中断随时能响应。5. 调试过程中踩过的坑与排查技巧5.1 读不到数据怎么办这是最常见的问题。排查顺序我总结了一个表现象可能原因排查方法完全无数据TX/RX接反交换PA2和PA3的接线数据乱码波特率不对确认模块是9600还是其他偶尔有数据接触不良检查杜邦线和排针数据全0模块未预热等待3分钟以上校验失败干扰或丢字节检查中断优先级和缓冲区我遇到过一次模块TX接的是STM32的PA2结果一直读不到。后来发现是PA2配置成了复用推挽输出但那是TX引脚RX应该配成浮空输入。改过来就好了。还有一种情况是模块供电不足。JW01工作电流大概十几毫安但启动瞬间可能更大。如果你用STM32板子上的3.3V给模块供电而板子的LDO输出能力有限可能导致模块工作不稳定。建议单独供电或者用5V供电模块支持5V。5.2 OLED不显示或花屏OLED花屏一般是I2C时序问题。如果你用软件I2C检查延时是否足够。400KHz的I2C周期是2.5微秒半周期1.25微秒你的delay_us要能准确延时1微秒左右。如果延时函数不准时序就乱了。还有一种花屏是初始化序列不对。SSD1306的初始化命令有一长串不同厂家的屏可能略有差异。如果花屏可以试试调整对比度设置命令或者重新上电。I2C地址也要注意SSD1306一般是0x78或0x7A取决于SA0引脚。大部分模块默认0x78但有些是0x7A。如果屏幕完全不亮先确认地址。5.3 数据跳变严重CO2读数偶尔跳一下是正常的NDIR传感器本身有噪声。但如果频繁大幅跳变可能是以下原因一是模块附近有气流比如风扇直吹二是供电纹波大三是串口干扰导致校验通过但数据错了概率低。我的处理方式是在软件里加一个滑动平均滤波。连续读5次去掉最大最小值剩下的取平均。这样显示出来的数值就平稳多了。#define FILTER_LEN 5 u16 co2_buf[FILTER_LEN]; u8 co2_idx 0; u16 co2_filter(u16 new_val) { co2_buf[co2_idx] new_val; if(co2_idx FILTER_LEN) co2_idx 0; u16 sum 0, max 0, min 0xFFFF; for(u8 i 0; i FILTER_LEN; i) { sum co2_buf[i]; if(co2_buf[i] max) max co2_buf[i]; if(co2_buf[i] min) min co2_buf[i]; } return (sum - max - min) / (FILTER_LEN - 2); }5.4 中断优先级冲突如果你同时用了多个中断比如串口接收、定时器、I2C要注意优先级分组。我一般用NVIC_PriorityGroup_22位抢占优先级、2位子优先级。串口接收中断设成抢占优先级1定时器设成2这样串口能及时响应。如果串口中断被其他中断阻塞太久可能丢字节。丢一个字节整帧就废了得等下一帧。所以串口中断服务函数里不要做耗时操作只存数据、置标志位处理逻辑放到主循环。6. 功能扩展与项目升级方向6.1 加个WiFi模块上传数据如果你想做物联网版本可以加一个ESP8266或者ESP32模块通过串口把CO2数据发到手机或者云平台。ESP8266用AT指令就行STM32再开一个串口跟它通信。数据格式可以用JSON比如{co2:650,temp:25.3}。这里要注意的是ESP8266发送数据时电流较大可能对JW01的串口造成干扰。建议ESP8266单独供电或者在电源上加个大电容滤波。6.2 增加温湿度补偿NDIR传感器的读数受温度影响虽然JW01内部有温度补偿但如果你想要更高精度可以外接一个SHT30或者AHT20温湿度传感器在软件里做二次补偿。补偿算法可以用查表法或者简单的线性修正。6.3 数据记录与曲线显示如果你用带Flash的STM32或者外接SD卡可以把CO2数据按时间戳记录下来然后在OLED上画一个简单的曲线图。曲线显示用OLED的绘图函数把历史数据映射到屏幕坐标上。这个功能对于分析室内空气质量变化趋势很有用。6.4 低功耗优化如果是电池供电的场景可以用STM32的停止模式定时唤醒读一次数据。JW01模块本身功耗不低大概十几毫安所以低功耗主要靠间歇供电。用一个MOS管控制模块电源读数据前上电读完断电。但注意模块每次上电都需要预热频繁断电会影响精度所以这种方式适合对精度要求不高的场景。7. 一些实操心得与建议这个项目我从头到尾做了大概一周大部分时间花在调试串口和OLED上。总结几条经验第一串口接收一定要用中断加状态机不要用轮询。轮询在简单测试时能用但实际运行中很容易丢帧。第二模块预热时间不能省。我一开始上电就看数据发现读数从2000多慢慢降到500左右还以为模块坏了。后来查资料才知道NDIR传感器需要预热。第三OLED的I2C地址和初始化序列要确认清楚。不同批次的模块可能不一样拿到手先用I2C扫描程序扫一下地址。第四电源质量很重要。我用USB供电时数据很稳换成某个劣质充电宝就偶尔跳变。后来在模块电源脚并了一个100uF电容问题解决。第五代码里加个看门狗。STM32的独立看门狗IWDG配置简单防止程序跑飞。尤其是你在主循环里加了sprintf这种函数栈溢出风险还是有的。最后说一句这个项目虽然简单但涉及的知识点不少串口通信、中断、I2C、OLED驱动、数据滤波。对于想入门STM32传感器开发的朋友来说是一个很好的练手项目。做完这个你再去做其他传感器基本就是换协议的事底层框架可以直接复用。
