1. 拿到JW01‑CO2‑V2.2模块后,先搞清楚它到底怎么用
JW01‑CO2‑V2.2这个模块在空气质量检测类项目里出镜率很高,尤其是做毕业设计、环境监测终端、智能新风控制这类场景。它的核心是一颗NDIR(非色散红外)二氧化碳传感器,测量范围通常覆盖400~5000ppm,精度在±50ppm左右,输出方式支持UART串口和PWM两种。很多人拿到手第一反应是“这不就是个串口模块吗,接上RX/TX就能读数据”,但实际调试时会发现——数据要么读不出来,要么读出来全是乱码,要么数值跳得离谱。
这个模块的UART协议有几个关键点需要先弄清楚。默认波特率是9600,数据位8,停止位1,无校验。它每隔一段时间主动往外吐一帧数据,帧格式是固定的9个字节:帧头0x42、0x4D,然后是数据高字节、数据低字节、保留字节、保留字节、校验和、帧尾0x0D、0x0A。校验和的计算方式是从帧头开始到校验和之前所有字节的累加和取低8位。这个协议看起来简单,但如果你用STM32的串口中断去接,很容易因为帧头对齐问题导致数据错位。
我在第一次用这个模块的时候,用的是STM32F103C8T6最小系统板,串口配置成9600‑8‑N‑1,开了接收中断。结果发现串口助手能看到数据,但STM32读出来的值一直在跳。后来用逻辑分析仪抓了一下波形才发现,模块上电后前几秒输出的数据是不稳定的,而且如果串口接收中断里没有做帧头检测,很容易从帧中间开始接收,导致整个解析全乱。所以第一步不是急着写代码,而是先用USB转TTL模块把模块接到电脑上,用串口助手确认模块本身工作正常,记下它输出的原始十六进制数据,这一步能帮你排除掉一半的硬件问题。
还有一个容易被忽略的点:模块的供电。JW01‑CO2‑V2.2标称工作电压是5V,但它的UART电平是3.3V的。如果你直接把模块的TX接到STM32的RX上,STM32的RX引脚是5V容忍的,一般没问题;但如果你用的是3.3V供电的STM32,模块的RX接到STM32的TX时,STM32输出的3.3V高电平模块能不能识别,这个要看模块的输入阈值。实测下来大部分批次是可以的,但如果你遇到通信不稳定,可以在模块RX和STM32 TX之间加一个电平匹配电路,或者直接用5V供电的STM32板子。
提示:模块上电后需要预热约3分钟才能输出稳定数据,刚上电时的数值没有参考价值,不要在初始化完成后立刻读取并显示。
2. STM32串口外设配置:USART2的初始化与中断接收
2.1 为什么选USART2而不是USART1
在STM32F103系列里,USART1挂在APB2总线上,时钟频率72MHz;USART2挂在APB1总线上,时钟频率36MHz。两者都能满足9600波特率的需求,但USART1的引脚是PA9/PA10,通常会被用来做调试串口或者下载口。如果你用USART1接CO2模块,那调试信息就没地方打印了。所以我一般把USART1留给调试串口,USART2用来接JW01模块,引脚用PA2(TX)和PA3(RX)。这样调试和业务数据分开,排查问题的时候方便很多。
USART2的初始化流程和USART1基本一致,但要注意APB1的时钟使能是RCC_APB1PeriphClockCmd,不是APB2。很多人复制USART1的代码改的时候忘了改这个,结果串口死活不工作。下面是我常用的初始化代码:
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_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; 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); }这段代码里有个细节:PA3配置成浮空输入(GPIO_Mode_IN_FLOATING),不是上拉输入。因为模块的TX引脚在空闲时输出高电平,如果你配成上拉,反而可能因为外部高电平和内部上拉冲突导致电平不稳定。浮空输入让模块的TX直接驱动STM32的RX,实测最稳。
2.2 接收中断里怎么做帧头对齐
串口接收中断里最忌讳的就是“收到一个字节就处理一个字节”。JW01模块输出的是9字节的固定帧,如果你在中断里逐字节判断,很容易因为中断延迟或者数据错位导致解析失败。我的做法是在中断里只负责把数据存进一个环形缓冲区,然后在主循环里做帧解析。这样中断服务函数尽量短,不会阻塞其他中断。
#define RX_BUF_SIZE 128 u8 rx_buf[RX_BUF_SIZE]; u16 rx_head = 0; u16 rx_tail = 0; void USART2_IRQHandler(void) { if(USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { u8 data = USART_ReceiveData(USART2); u16 next = (rx_head + 1) % RX_BUF_SIZE; if(next != rx_tail) { rx_buf[rx_head] = data; rx_head = next; } USART_ClearITPendingBit(USART2, USART_IT_RXNE); } }主循环里的解析函数负责从缓冲区里找帧头0x42 0x4D,然后校验后续字节。这里有个经验:不要只找一次帧头就认为对齐了,最好连续找到两帧完整的、校验和正确的数据,再确认通信稳定。因为模块上电初期可能会有半帧数据残留,如果第一次找到帧头就开始解析,很可能解析出一组错误值。
2.3 校验和计算与数据提取
校验和的计算逻辑很简单,但写代码的时候容易把范围搞错。正确的做法是把帧头0x42、0x4D、数据高字节、数据低字节、保留字节1、保留字节2这6个字节累加,取低8位,和第7个字节(校验和)比较。如果相等,说明这一帧有效。CO2浓度值的计算公式是:浓度 = 数据高字节 × 256 + 数据低字节。
u16 JW01_GetCO2(void) { u16 co2 = 0; u8 frame[9]; u16 i; while(rx_tail != rx_head) { // 找帧头 if(rx_buf[rx_tail] == 0x42) { u16 next = (rx_tail + 1) % RX_BUF_SIZE; if(rx_buf[next] == 0x4D) { // 尝试读取9字节 u16 idx = rx_tail; for(i = 0; i < 9; i++) { frame[i] = rx_buf[idx]; idx = (idx + 1) % RX_BUF_SIZE; } // 校验 u8 sum = 0; for(i = 0; i < 6; i++) sum += frame[i]; if(sum == frame[6] && frame[7] == 0x0D && frame[8] == 0x0A) { co2 = frame[2] * 256 + frame[3]; rx_tail = (rx_tail + 9) % RX_BUF_SIZE; return co2; } } } rx_tail = (rx_tail + 1) % RX_BUF_SIZE; } return 0; }这段代码里有个坑:如果缓冲区里的数据不够9字节,直接读取会读到无效数据。所以实际使用时要加一个判断,确保从rx_tail到rx_head之间有至少9个字节。另外,如果校验失败,不要直接把rx_tail跳过9字节,而是只跳过1字节继续找下一个帧头,否则可能漏掉真正的帧。
3. I2C‑OLED显示:把CO2数值实时刷到屏幕上
3.1 OLED选型和I2C地址确认
I2C‑OLED我用得最多的是0.96寸的SSD1306,分辨率128×64,I2C地址通常是0x78(写地址)或0x3C(7位地址)。不同厂家的模块地址可能不一样,有的背面会标注,有的需要自己扫描。如果你不确定地址,可以写一个简单的I2C扫描程序,把总线上所有应答的地址打印出来。STM32的硬件I2C用起来比较麻烦,尤其是F103系列的I2C外设有一些已知的时序问题,所以我一般用软件模拟I2C,引脚随便选两个普通GPIO就行,比如PB6和PB7。
软件I2C的延时函数很关键。延时太短,OLED可能来不及响应;延时太长,刷新率上不去。我一般用SysTick做微秒级延时,延时2微秒左右。下面是一个简单的软件I2C写字节函数:
void I2C_WriteByte(u8 data) { u8 i; for(i = 0; i < 8; i++) { I2C_SCL_L(); if(data & 0x80) I2C_SDA_H(); else I2C_SDA_L(); delay_us(2); I2C_SCL_H(); delay_us(2); data <<= 1; } I2C_SCL_L(); I2C_SDA_H(); delay_us(2); I2C_SCL_H(); delay_us(2); I2C_SCL_L(); }OLED的初始化序列比较长,但都是固定的,直接照搬SSD1306的数据手册或者现成的驱动库就行。需要注意的是,初始化之前要先给OLED上电延时至少100ms,否则可能初始化失败。这个延时很多人会忽略,结果OLED一直不亮,查半天以为是I2C地址错了。
3.2 显示刷新策略:不要每读一次就刷全屏
CO2模块大约每1秒输出一帧数据,如果你每次读到数据就全屏刷新一次OLED,屏幕会闪得厉害,而且I2C总线占用率高,可能影响串口接收。我的做法是:只在数值变化超过一定阈值(比如10ppm)时才刷新显示区域,而且只刷新数值那一行,不清屏。SSD1306支持页地址模式,可以只更新特定页的内容。
具体实现上,我把屏幕分成几行:第一行显示“CO2:”,第二行显示数值和单位“ppm”,第三行显示一个简单的状态条。数值变化时,只重绘第二行的数字部分。这样刷新率可以做到很快,而且屏幕不闪。另外,如果CO2值超过1000ppm,我会把数值颜色反色显示(黑底白字),起到警示作用。SSD1306支持反色显示命令,不需要重新取模。
注意:OLED的I2C通信和串口接收中断不要在同一个优先级上冲突。如果I2C写数据时被串口中断打断,可能导致I2C时序错乱。我一般把串口中断优先级设得比SysTick高,但I2C操作放在主循环里,不在中断里调用。
3.3 显示内容的布局设计
128×64的屏幕虽然小,但合理安排还是能显示不少信息。我通常这样布局:
| 区域 | 内容 | 字体大小 |
|---|---|---|
| 第0~1页 | 标题“CO2 Monitor” | 6×8 |
| 第2~3页 | CO2数值 + “ppm” | 16×16 |
| 第4页 | 状态条(根据浓度变化) | 6×8 |
| 第5~7页 | 预留(可显示温度、湿度) | 6×8 |
16×16的字体需要取模,可以用PCtoLCD2002软件生成。取模方式选“阴码+逐列式+顺向”,这样和SSD1306的显示RAM格式匹配。如果你不想取模,也可以用8×16的字体,虽然小一点但够用。
4. 调试过程中最容易踩的五个坑
4.1 串口接收到的数据全是0xFF
这个问题我遇到过好几次,每次原因都不一样。第一次是模块的TX和RX接反了,模块的TX应该接STM32的RX,结果我接成了TX对TX。第二次是波特率不对,模块默认9600,但我初始化的时候写成了115200。第三次是模块供电不足,我用STM32板子上的3.3V给模块供电,模块启动瞬间电流不够,导致输出乱码。后来换成5V供电就正常了。
排查顺序建议:先确认接线(TX→RX,RX→TX),再确认波特率,最后用万用表量一下模块的VCC电压。如果电压低于4.5V,模块可能工作不正常。
4.2 校验和总是对不上
校验和对不上通常有两种情况:一是帧头找错了,比如把数据里的0x42 0x4D当成了帧头;二是接收过程中丢了字节。第一种情况可以通过连续校验两帧来排除,如果两帧都校验失败,说明帧头定位有问题。第二种情况一般是串口中断被其他高优先级中断打断了,导致接收缓冲区溢出。解决办法是提高串口中断优先级,或者增大环形缓冲区。
还有一种可能是模块输出的帧格式和手册不一致。我遇到过一批模块,帧尾不是0x0D 0x0A,而是0x0A 0x0D。所以校验的时候不要只判断帧尾,重点还是看校验和。
4.3 OLED显示乱码或花屏
OLED花屏一般是初始化序列不对,或者I2C通信速率太快。软件I2C的延时可以适当加大,比如从2微秒改成5微秒试试。另外,OLED的供电也要注意,有些模块标称3.3V,但实际用3.3V驱动时对比度很低,换成5V就正常了。如果花屏只出现在屏幕边缘,可能是显示RAM的页地址设置错了,检查一下页地址范围是不是0~7。
4.4 模块数值跳动大
CO2模块的数值本身会有±30ppm左右的波动,这是正常的。但如果跳动超过100ppm,可能是以下原因:模块附近有人员呼吸(CO2浓度会快速上升)、模块没有预热够、或者模块的进气口被遮挡了。JW01模块底部有个小孔是进气口,不要把它贴在PCB上或者用热熔胶封住。另外,模块要远离发热元件,温度变化会影响红外传感器的读数。
4.5 程序跑一段时间后死机
这个问题比较隐蔽,通常是栈溢出或者中断优先级配置不当导致的。如果你在串口中断里调用了OLED显示函数,而OLED函数里又有延时,中断执行时间过长,可能导致主循环里的其他任务得不到执行。我的建议是:中断里只做数据搬运,所有耗时操作都放到主循环里。另外,检查一下NVIC的优先级分组设置,确保串口中断的抢占优先级高于SysTick,但不要高于硬件错误中断。
5. 从能读到好用:几个提升稳定性的实战技巧
5.1 加一个简单的滑动平均滤波
原始数据跳动是正常的,但显示的时候如果数字一直跳,看起来就不专业。我一般做一个长度为5的滑动窗口,取平均值显示。这样数值变化平滑,又不至于响应太慢。代码很简单:
#define FILTER_LEN 5 u16 co2_buf[FILTER_LEN] = {0}; u8 co2_idx = 0; u16 CO2_Filter(u16 new_val) { u32 sum = 0; u8 i; co2_buf[co2_idx] = new_val; co2_idx = (co2_idx + 1) % FILTER_LEN; for(i = 0; i < FILTER_LEN; i++) sum += co2_buf[i]; return sum / FILTER_LEN; }注意,滤波后的值只用于显示,不要用来做报警判断。报警判断应该用原始值,否则可能因为滤波延迟导致报警不及时。
5.2 模块预热期间显示“Warming up”
模块上电后前3分钟数据不稳定,如果直接显示数值,用户会以为模块坏了。我一般在上电后的前180秒内,OLED上显示“Warming up...”和一个倒计时,倒计时结束后再显示实际数值。这样用户体验好很多,也避免了误判。
5.3 串口通信加超时重试
虽然JW01模块是主动输出,不需要STM32发送指令,但如果你用的是需要请求才回复的版本,就要加超时重试。即使是主动输出模式,也建议在主循环里加一个超时检测:如果超过3秒没有收到有效帧,就在OLED上显示“Sensor Error”,并尝试重新初始化串口。这个机制在长时间运行的项目里很有用,能自动恢复偶发的通信故障。
5.4 电源去耦和PCB布局
如果你是自己画PCB,模块的VCC引脚旁边一定要放一个100nF的陶瓷电容和一个10μF的钽电容。JW01模块内部有红外光源,工作时电流会有脉冲波动,如果电源去耦不好,会影响STM32的ADC或者其他外设。另外,模块的TX/RX走线尽量短,远离晶振和电源开关电路。
5.5 用Keil的逻辑分析仪功能辅助调试
Keil MDK自带的逻辑分析仪可以实时查看变量变化,不需要额外的硬件。你可以把CO2原始值、滤波后的值、串口接收缓冲区的头尾指针都加到逻辑分析仪里,跑起来之后一眼就能看出数据流是否正常。这个功能在调试串口协议的时候特别有用,比打印调试信息直观得多。
6. 把CO2数据用起来:从显示到控制的扩展思路
6.1 阈值报警与继电器控制
CO2浓度超过1000ppm时人就会感到闷,超过2000ppm会明显不适。你可以设两个阈值:1000ppm时OLED显示警告,1500ppm时触发继电器打开新风系统或风扇。继电器模块一般用5V供电,STM32的GPIO输出3.3V可能驱动不了,需要加一个三极管或者用光耦隔离。我一般用S8050三极管做驱动,基极串一个1k电阻,继电器线圈两端反并联一个1N4148续流二极管。
6.2 数据上传到上位机
如果你想把数据传到电脑上画曲线,可以用STM32的USART1做调试串口,把CO2值以固定格式打印出来,比如“CO2: 856 ppm\r\n”。上位机用串口助手或者自己写一个Python脚本解析。Python这边用pyserial库,几行代码就能把数据存成CSV文件,然后用matplotlib画图。这个方案比接屏幕更灵活,适合做数据分析。
6.3 结合RTC做定时记录
STM32F103内部有RTC,配一个32.768kHz的晶振就能走时。你可以每隔10分钟把CO2值和时间戳一起存到EEPROM或者SD卡里,做成一个简易的空气质量记录仪。RTC的初始化要注意:F103的RTC时钟源是LSE(外部低速晶振)或者LSI(内部低速RC),LSE更准但需要晶振起振,LSI不需要外部元件但精度差一些。如果只是做记录,LSI够用了。
6.4 低功耗场景下的考虑
如果你的项目是电池供电的,JW01模块的功耗不低,大概在20mA左右。STM32可以进Stop模式,但模块不能断电,否则每次上电都要重新预热。这种场景下,建议用定时器唤醒STM32,读一次数据就继续睡,OLED也只在唤醒时刷新。不过说实话,JW01模块本身就不适合低功耗场景,如果对功耗有要求,可以考虑其他类型的CO2传感器。
7. 关于这个模块和STM32搭配的一些个人体会
JW01‑CO2‑V2.2这个模块的性价比在百元以内的CO2传感器里算是很能打的,NDIR原理决定了它比那些半导体式的CO2传感器靠谱得多。但它也有自己的脾气:预热时间长、数据有波动、对电源质量敏感。STM32这边,USART2加软件I2C OLED的组合是我用得最顺手的方案,硬件资源占用少,调试也方便。
如果你刚开始做这个项目,我的建议是先把模块用USB转TTL接到电脑上,用串口助手确认数据格式,然后再写STM32代码。不要一上来就焊板子、写程序,那样出了问题你都不知道是硬件问题还是软件问题。另外,Keil的软件仿真虽然能跑逻辑,但串口和I2C这种外设还是要在真实硬件上调试,仿真和实际差距很大。
最后说一个细节:JW01模块的UART输出是3.3V电平,但如果你用5V的STM32板子,STM32的TX输出5V电平到模块的RX,长期看可能会影响模块寿命。稳妥的做法是在模块RX和STM32 TX之间串一个1k电阻,再加一个3.3V的稳压二极管做钳位。这个电路成本不到一毛钱,但能避免很多莫名其妙的通信故障。