1. 为什么这个项目值得做:CO2监测的底层逻辑
1.1 一个被低估的环境指标
如果你和我一样,平时捣鼓Arduino十有八九是在玩LED、舵机、超声波测距,突然有一天要做一个环境监测系统,大概率第一个想到的是温湿度传感器。这没错,但等你真正把DHT11接上去、屏幕上能显示温度和湿度之后,你会发现一个问题:这套系统根本回答不了"这屋子现在闷不闷、适不适合长时间待着"。
二氧化碳浓度才是那个真正决定室内舒适度、甚至影响注意力和睡眠质量的指标。密闭卧室睡一晚上,CO2浓度轻松飙到2000ppm以上,人会头昏脑涨;办公区下午两三点,人一多、窗户一关,900到1200ppm是常事。很多写字楼里所谓的"空气质量差",与其说是PM2.5的问题,不如说根本就是CO2超标。之前我做过一个JYW01(有些商家标JW01)模块的室内监测项目,从接线到跑通demo只花了不到五分钟,但深入之后发现里面的门道远比想象的多:NDIR测量原理、UART帧解析、传感器校准、数据漂移,每一个都值得单独拎出来讲清楚。这篇文章把这套东西完整记录下来,适合刚接触Arduino的新手,也适合那些准备把传感器真正用起来的进阶玩家。
1.2 JW01传感器的工作原理解密
市面上卖的JW01模块,绝大多数是NDIR原理的CO2传感器。NDIR全称Non-Dispersive InfraRed,翻译过来叫"非色散红外",核心组件就三样:一个红外光源、一个空气可以自由进出的气室、一个只让特定波长红外光通过的滤光片加探测器。
CO2分子会强烈吸收4.26微米波长的红外光。光源发出的红外线穿过气室时,有一部分会被中间的CO2"吃掉",探测器看到的光强就变弱了。浓度越高,被吃掉的光越多,传感器再把光强衰减量换算成ppm数值。你可以把它理解成隔着雾看路灯:雾越浓,灯光越模糊,CO2就是那团雾。NDIR的好处是抗干扰能力强、寿命比较长,不会像电化学传感器那样用两年就需要换敏感电极。
1.3 先搞清楚你手里的JW01是哪个版本
这是最容易踩坑的一步。JW01模块市面上存在好几个版本,外观长得差不多,但输出接口完全不同。我见过的至少有三种:
- UART串口版:模块上带TX、RX引脚,按固定协议输出数字信号,精度最高,也是本文主要讲解的方案。
- PWM版:通过高电平脉冲宽度来表示浓度值,周期通常是1004ms,需要测脉宽换算。
- 模拟电压版:带AO引脚,输出电压范围一般是0.4V到2V,对应0到5000ppm,接Arduino的模拟口读取。
版本判断方法很简单:看丝印。模块背面或者说明书上会明确标注"UART""PWM"或"Analog Output"。如果你手上只有裸板没有说明书,还有个土办法:上电后用万用表量AO引脚(如果有)的电压,电压会随着你朝传感器哈气而明显升高,那就是模拟版;如果板上只有四根针且标注了TX、RX,那就是UART版。这一步一定不要省,很多新手照着别人的接线图接完发现读数全是0,最后才发现是版本不对。下面所有内容默认你用的是UART版,如果你手里是PWM或模拟版,可以直接跳到3.4节看备用方案。
2. 硬件准备与接线:动手前的最后一步检查
2.1 材料清单与选型建议
做这套环境监测系统,核心硬件非常简单,我列个清单,都是手边常备的东西:
| 器件 | 型号/规格 | 数量 | 说明 |
|---|---|---|---|
| 主控板 | Arduino Uno(或其他兼容板) | 1 | Nano也可以,接线逻辑一样 |
| CO2传感器 | JW01 UART版 | 1 | 注意版本,确认是串口输出 |
| 面包板 | 400孔或630孔均可 | 1 | 方便临时接线 |
| 杜邦线 | 公对公、公对母各若干 | 若干 | 建议备不同颜色方便区分 |
| USB线 | A转B方口线(Uno用) | 1 | 顺便给板子供电 |
| 可选 | OLED屏、LED、蜂鸣器、继电器 | 若干 | 进阶联动用,后面会讲 |
选型建议:如果预算允许,优先买带UART输出的版本,因为串口数据可以读到精确的整数ppm,调试时也能直接看到原始字节流,学习价值高。模拟版的换算受电源电压波动影响比较大,精度差一些,但胜在接线简单,应急时可用。
2.2 针脚定义与接线表
JW01 UART版模块引脚一般就四个(有些会额外引出VCC、GND双电源引脚,功能一样):VCC、GND、TX、RX。
接线表如下:
| JW01引脚 | 接Arduino引脚 |
|---|---|
| VCC | 5V |
| GND | GND |
| TX | 数字引脚10(软串口RX) |
| RX | 数字引脚11(软串口TX) |
| (可选)AO | A0(仅模拟版需要) |
这里有个容易翻车的点:为什么不把传感器的TX直接接Arduino的0号引脚(硬串口RX)?理论上可以,而且接线更省心。但Arduino Uno的0号和1号引脚同时连接着板载USB转串口芯片,你烧录程序时,USB串口和传感器会抢这两根线,经常导致上传失败或者烧录过程中传感器数据把编程器搞懵。我建议宁可多用两个数字口,也不要去碰硬件串口,图个清静。
另一个细节是电平匹配。JW01模块大多设计为5V TTL电平,和Uno的5V系统直接对接没问题。如果你用的是3.3V版本的模块(有些低功耗版支持3.3V供电),接5V供电的Uno时需要做电平转换,或者干脆换一块ESP32、STM32那种原生3.3V主控,否则长期运行有烧坏RX引脚的风险。
2.3 给传感器的"3秒预热"与上电观察
接线完成后,先别急着插USB线,养成一个习惯:上电前再核对一遍VCC和GND。反接电源是传感器第一杀手,炸了只能再买一块。
通电之后,观察模块上有没有指示灯亮起,用手靠近传感器通风格栅,等一两分钟。第一次上电时,传感器内部的红外光源需要预热稳定,这时候串口输出的数值往往会偏高,甚至达到800到1000ppm。别慌,这不是坏了,是没稳定。等它跑个三五分钟,数值会慢慢回落,最终稳定在400到600ppm附近——这个范围正好是大气中CO2的正常浓度。
怎么确认它开始正常输出数据了?最简单的验证方法:先打开Arduino IDE自带的串口监视器,把波特率设置成9600(下文代码里会说明为什么是9600),如果能看到规律出现的一堆十六进制字符,比如"\xFF\x01\x86..."这样开头的内容,恭喜你,传感器在喊话了。如果什么都看不到,大概率是TX、RX接反了,把它俩对调再试一次。
3. 完整代码:从串口字节流到可读的ppm
3.1 两种数据输出模式:主动上报 vs 查询应答
接好线之后,你要先确认手里的传感器是哪种工作模式,因为代码逻辑完全不同。JW01这类NDIR模块常见的串口模式有两种:
- 主动上报模式:传感器上电后自发地每隔大概2秒向外发送一帧数据,不需要主控做任何操作,你只要蹲在串口上收就行。
- 查询应答模式:传感器平时不吭声,你给它发一条指定的查询指令,它才回你一帧数据。这种方式的好处是主控可以按自己的节奏采集,不会错过数据,也不会有堆积。
怎么判断?通电后打开串口监视器,如果不需要发任何字节就能看到数据帧,那就是主动上报;如果一片空白,发一条查询指令才有反应,那就是查询应答。下面代码我会把两种模式都写出来,你根据实际测试结果二选一即可。
3.2 UART模式完整代码(附逐行注释)
先上查询应答模式的完整代码,这是最稳妥的方案。我用的是SoftwareSerial软串口,把传感器挂在10、11号引脚上:
#include <SoftwareSerial.h> // 定义软串口引脚 #define CO2_RX 10 // 接传感器的TX #define CO2_TX 11 // 接传感器的RX SoftwareSerial co2Serial(CO2_RX, CO2_TX); // 查询指令。很多NDIR二氧化碳模块都用这一条标准指令 byte queryCmd[9] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79}; byte buf[9]; unsigned long lastQueryTime = 0; const unsigned long queryInterval = 2000; // 每2秒查询一次 void setup() { Serial.begin(9600); // 硬件串口,接电脑,用于调试输出 co2Serial.begin(9600); // 软串口,接JW01传感器 Serial.println("JW01 CO2 Sensor Demo Start"); } void loop() { // 到时间了就发一次查询指令 if (millis() - lastQueryTime >= queryInterval) { lastQueryTime = millis(); co2Serial.write(queryCmd, 9); } // 检查软串口缓冲区,凑齐9个字节再处理 if (co2Serial.available() >= 9) { for (int i = 0; i < 9; i++) { buf[i] = co2Serial.read(); } // 帧头校验:FF 01 86,这是标准CO2应答帧的标志 if (buf[0] == 0xFF && buf[1] == 0x01 && buf[2] == 0x86) { int co2 = (buf[3] << 8) | buf[4]; // 高字节左移8位,再或上低字节 // 校验和验证:从第2个字节累加到第8个字节,取反加一 byte sum = 0; for (int i = 1; i < 8; i++) { sum += buf[i]; } sum = (~sum) + 1; if (sum == buf[8]) { Serial.print("CO2: "); Serial.print(co2); Serial.println(" ppm"); } else { Serial.println("Checksum error"); } } } }如果你测试发现传感器是主动上报模式,那更简单,把setup里的串口初始化保留,然后把loop整个换成下面这段:
void loop() { if (co2Serial.available() >= 9) { for (int i = 0; i < 9; i++) { buf[i] = co2Serial.read(); } if (buf[0] == 0xFF && buf[1] == 0x01 && buf[2] == 0x86) { int co2 = (buf[3] << 8) | buf[4]; Serial.print("CO2: "); Serial.print(co2); Serial.println(" ppm"); } } }注意这里我没有校验校验和,因为主动上报模式下数据量不大,主控闲着也是闲着,建议还是把校验逻辑加上,防止误码导致显示异常数值。
3.3 十六进制解析原理:为什么CO2是"两个字节"
很多新手看到(buf[3] << 8) | buf[4]这行代码会懵:为什么ppm值要拆成两个字节?因为CO2量程通常是0到5000ppm,甚至到10000ppm,一个字节最多只能表示0到255,一个byte根本装不下,所以协议设计者用两个字节来装浓度:高字节放在第4个字节位置,低字节放在第5个字节位置。
换算方法就是代码里那行:高字节左移8位(相当于乘以256),再按位或上低字节。比如高字节是0x02(十进制2),低字节是0x58(十进制88),结果就是2×256+88=600,也就是600ppm。
校验和的作用是防止传输过程中数据被干扰破坏。JW01这类模块常见的校验算法是:从第二个字节(0x01)开始,到第八个字节结束,把这7个字节累加,然后取反再加一。如果传感器端生成的校验字节和主控这边算出来的结果一致,说明这一帧数据在传输过程中没被破坏;不一致就果断丢掉,不要显示,否则你会看到偶尔蹦出来的几百几千的离谱读数。
3.4 模拟输出模式代码(备用方案)
如果你拿到手的模块是模拟电压版,没有串口,代码更短:
#define AO_PIN A0 void setup() { Serial.begin(9600); } void loop() { int raw = analogRead(AO_PIN); // 读取0~1023的原始值 float voltage = raw * (5.0 / 1023.0); // 换算成实际电压 // 假设0.4V对应0ppm,2V对应5000ppm,按线性关系换算 float co2 = (voltage - 0.4) * 5000.0 / (2.0 - 0.4); if (co2 < 0) co2 = 0; Serial.print("CO2: "); Serial.print(co2); Serial.println(" ppm"); delay(2000); }模拟版最大的问题就是精度受限。Arduino的analogRead分辨率只有10位,也就是0到1023,对应0到5V,每一步的电压分辨率大约5毫伏,换算成CO2大约是15ppm。看着还行,但电源电压稍微波动一下,读数就开始跳了。所以我只把它当备用方案,有条件还是建议用UART版。
4. 从"读数"到"系统":可视化与联动控制
4.1 加一块OLED屏幕显示实时CO2浓度
串口监视器里的数字只有插着电脑才能看到,日常使用总得有个屏幕。我常用的方案是一块0.96寸I2C接口的SSD1306 OLED屏,128×64分辨率,四根线接上就能用。
接线:OLED的VCC接5V(部分版本是3.3V,注意看模块背面丝印),GND接GND,SCL接Arduino的A5,SDA接A4。Uno的I2C引脚固定在这两个位置上,别接错了。
库文件用Adafruit SSD1306和Adafruit GFX,Arduino库管理器里搜索即可安装。显示代码的核心部分:
#include <Wire.h> #include <Adafruit_SSD1306.h> #include <Adafruit_GFX.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); // 大多数OLED地址是0x3C display.clearDisplay(); display.setTextSize(2); display.setTextColor(SSD1306_WHITE); } void displayCO2(int co2) { display.clearDisplay(); display.setCursor(10, 20); display.print("CO2:"); display.print(co2); display.println("ppm"); display.display(); }在之前的loop代码里,拿到有效的co2值后调用displayCO2(co2)即可。如果显示不出来,先跑一遍I2C地址扫描程序,确认设备地址到底是0x3C还是0x3D,不要盲目按网上教程硬试。
4.2 设置两级阈值:提醒小心与强制通风
有屏幕还不够,真正实用的环境监测系统必须有告警机制。我建议分两级阈值:小于800ppm算良好,800到1200ppm算临界,超过1200ppm算超标。
为什么选1200这个数?室内空气质量标准GB/T 18883里推荐的CO2日平均浓度上限是1000ppm,但实际测量中办公室下午经常到1200甚至更高,考虑到一般居住场景,1200作为告警线比较合理,没那么容易误报。
代码逻辑很简单,在解析出co2后加判断:
#define GREEN_LED 5 #define YELLOW_LED 6 #define RED_LED 7 #define BUZZER 8 void setAlert(int co2) { if (co2 < 800) { digitalWrite(GREEN_LED, HIGH); digitalWrite(YELLOW_LED, LOW); digitalWrite(RED_LED, LOW); digitalWrite(BUZZER, LOW); } else if (co2 < 1200) { digitalWrite(GREEN_LED, LOW); digitalWrite(YELLOW_LED, HIGH); digitalWrite(RED_LED, LOW); digitalWrite(BUZZER, LOW); } else { digitalWrite(GREEN_LED, LOW); digitalWrite(YELLOW_LED, LOW); digitalWrite(RED_LED, HIGH); digitalWrite(BUZZER, HIGH); } }蜂鸣器如果一直响会非常烦人,实用做法是加一个"响两秒停十秒"的间歇逻辑,用一个变量记录上次响的时间,在loop里判断。这个留给你自己扩展,思路和串口查询定时是一样的。
4.3 与继电器联动控制新风系统
再进一步,把CO2读数和继电器结合,就能实现"CO2超标自动开新风"的效果。继电器模块的接线是:IN引脚接Arduino数字口,VCC和GND接5V和GND,输出侧接你家的新风风扇或者排气扇。代码只需要在setAlert的第三档里,把digitalWrite(RELAY, HIGH)和LOW加上即可。
这里必须强调一句:继电器输出侧如果是220V强电,操作前务必断电,接线要用电工胶带做好绝缘,并且不要用面包板来过渡强电线,整个项目建议只控制低压直流风扇。我做实验用的是12V的电脑散热风扇,接了一个30A的固态继电器,既安全又能模拟真实风机的开关逻辑。如果你完全没接触过强电,就不要碰220V了,用低压方案验证逻辑就好。
5. 实测结果与数据解读:你的传感器在"说谎"吗
5.1 拿到数据后我做的第一件事:户外校准
很多人的流程是:接线、烧代码、看数字、发朋友圈,完事。但真正做过几次测量之后你会发现,同一块传感器,今天在户外读数是430ppm,过两天却变成520ppm,数值明显漂了。这是因为NDIR传感器的零点会受温度、器件老化和气室污染影响,出厂标定只能保证全新状态下是准的。
所以传感器到手,如果条件允许,先把它拿到室外通风处(没风的日子阳台也行),上电稳定至少10分钟,记录当前读数。如果读数是400到500ppm之间,说明传感器状态正常,不需要额外校准。如果读数明显偏高或偏低,比如持续显示600ppm以上,可能需要做一次零点校准。不同的模块校准方式不一样,常见的有两种:一是模块板上有一个小按键,长按几秒进入校准模式;二是通过串口发送特定的零点校准指令。具体操作一定要以你买的模块说明书为准,不要照搬我这里的通用说法,因为厂家不同,指令差异很大。
有一个更重要的知识点必须提醒:很多NDIR模块带ABC自动校准功能(Automatic Baseline Correction),它的逻辑是让传感器每24小时自动找一次"最低值"作为零点。这个功能在正常通风环境下很省心,但如果你的传感器长期放在密闭房间里,房间里的最低CO2浓度可能也有1000ppm,ABC就会把1000当成本底零点"修正"掉,这样你的真实读数是1500,屏幕上却只显示500。怎么避开?如果你要长期监测密闭环境,关掉ABC,或者在每周固定时间把传感器拿到户外通一次风让它重新校准。很多商家不会在详情页里提这个坑,但实际影响很大。
5.2 一组真实的室内实测数据
为了让你对CO2数值有个直观概念,我放一组我自己实测的数据,用的是校准后的JW01,测量间隔1分钟,不同场景下的典型值:
| 测试场景 | CO2浓度 | 备注 |
|---|---|---|
| 户外公园,微风 | 420-480 ppm | 可作为校准基准 |
| 办公室工位早晨开窗 | 550-650 ppm | 刚通风完比较理想 |
| 办公室下午,8人长期关窗 | 1100-1500 ppm | 下午两三点浓度最高 |
| 卧室,关窗睡一晚 | 1800-2600 ppm | 两个人睡一间更夸张 |
| 厨房做饭,门窗紧闭 | 2000-3000 ppm | 燃气燃烧会产生大量CO2 |
| 室内开新风/风扇10分钟后 | 600-800 ppm | 降幅非常明显 |
看到这些数据你就会明白,室内CO2超标不是理论问题,而是每天都在发生的现实。我做完这套系统后,第一件事就是把卧室的通风策略改了,睡前窗户留条缝,早晨起来头昏的情况基本消失。
5.3 数值异常的排查思路
如果你跑起来之后发现读数不正常,别急着怀疑传感器坏了,按这个清单一项项排查:
- 数值一直是0或者固定不变:大概率是TX、RX接反,或软串口引脚定义写反了。检查
SoftwareSerial co2Serial(CO2_RX, CO2_TX)这行,第一个参数是Arduino的RX引脚,必须接传感器的TX。 - 数值跳变剧烈,比如从600突然跳到3000又跳回700:检查供电电压是否稳定,试着用独立5V电源给传感器供电,同时确认模块底部有没有短路虚焊。
- 数值比正常高200到400ppm且长期不下降:参考5.1节,做一次户外校准或ABC自动校准。
- 串口监视器能看到乱码但解析不出来:波特率或帧头不对,确认你的模块协议是不是
FF 01 86开头,有些模块帧头不同,需要看手册改代码。 - 刚上电的半小时数值一直在缓慢爬升:这是传感器在预热和自校准,属于正常现象,等数值稳定后再记录。
6. 避坑实录:我在JW01项目里踩过的四个坑
6.1 坑一:SoftwareSerial和USB串口打架
第一次做的时候我图省事,把传感器的TX直接接到了Uno的0号引脚上,结果上传代码时一直报错,报错信息里还夹杂着一些乱码。查了半天才反应过来,0号引脚同时连着板载的USB转串口芯片,软件串口数据直接灌进了USB串口里,把上传流程冲垮了。
解决办法有两条:一是全程用10、11号这种普通数字口做软串口;二是如果你必须用硬件串口,比如传感器数据量大需要稳定接收,那就换Mega这类有多个硬件串口的板子,把传感器接在Serial1上,把USB调试留在Serial。我现在做项目基本都是这个思路,Uno跑跑简单demo可以,稍微复杂一点就换板。
6.2 坑二:电源不稳导致读数漂移
有段时间传感器读数总在550到700之间周期性摆动,一开始以为是传感器质量问题,后来用万用表量了一下Arduino的5V输出,发现电压在4.85V到4.95V之间浮动。传感器内部红外光源的工作电流大,瞬时波动直接影响探测精度,读数自然跟着跳。
后来我把传感器从Arduino的5V引脚挪到一个独立5V电源模块上,GND还是和Arduino共地,读数立刻稳多了。如果你不想多加一个电源,至少检查一下USB供电的质量,尽量插在电脑主板USB口或者质量好一点的充电器上,别用那种接出来一堆线、带好几个外设的USB HUB。这个坑在新手项目里极其常见,很多人最后怀疑传感器坏了,其实是电源先不行。
6.3 坑三:传感器贴在墙角,测了个寂寞
第一次做固定安装时,我把传感器粘在墙角一个书柜后面,理由是走线方便、不挡害。结果读数显示室内CO2永远是400到500ppm,身处房间里的我明显觉得闷却看不到数据上涨。原因很简单:墙角空气流通差,人呼出的CO2主要积聚在房间中部和上半部,藏在书柜后面的传感器根本接触不到新鲜空气样本。
正确做法是把传感器放在离地面0.8到1.5米高度、靠近人员活动区域但不会被直吹到的地方。你可以用一个USB延长线底座把传感器放在桌面上,尽量避免贴墙、贴地、放在空调出风口正下方、被窗帘遮挡这四种位置。同样的硬件,摆位不同,读数天差地别。
6.4 坑四:校验和到底怎么算
有次我手写代码时把校验和计算写成了对全部9个字节累加,结果无论怎么算都和传感器返回的校验字节对不上,一度怀疑传感器坏了。后来翻协议才发现,校验和是从第二个字节开始算到第八个字节,第一个帧头0xFF不参与累加,最后一个校验字节自己也不算进去。
正确算法我再说一遍:取buf[1]到buf[7]共7个字节,累加到一个byte变量里,让它自然溢出,然后取反加一,得到的值和buf[8]比对。这里要注意,很多语言中byte溢出是自动截断的,但如果你用的是int类型累加,就要手动& 0xFF,否则校验值永远不对。这类帧头校验错误不会导致程序崩溃,但会让你百思不得其解。
6.5 几个零碎但关键的小贴士
- 拔插传感器的串口线之前,最好先把Arduino断电。带电拔插虽然大概率没事,但静电和瞬态电压容易把模块引脚打坏,我已经损失过一块板子了。
- 如果模块支持ABC自动校准,又需要长期密闭监测,记得去查手册关掉它,或者定期户外通风校准,否则数据会被人为"压平"。
- 买模块时尽量选有说明书或数据手册的店铺,哪怕多花十块钱。省钱的代价是你可能需要花一晚上逆向协议,而协议里的帧头、校验方式、量程这些参数,没有文档只能靠猜。
- 软件串口在9600波特率下传9个字节,耗时大约9.4毫秒,完全够用。但如果你同时跑OLED刷新、继电器开关等操作,要注意别在读取数据帧的过程中做耗时操作,否则会丢字节,导致每次都要等下一个2秒周期才能重新凑齐一帧。
按这个流程走一遍,从拆快递到串口监视器里看到稳定的ppm数值,确实用不了5分钟。但真正让这套系统从"能显示数字"变成"有用的环境监测工具",靠的是后续的校准、摆位和联动设计。我个人建议新手别急着上220V继电器那一套,先把UART解析、校验和、OLED显示跑通,理解每一个字节的含义,再考虑联动也不迟。这套代码和协议思路,换到MH-Z19、SenseAir S8这些其他主流NDIR模块上也基本通用,最大的区别也就是查询指令和量程参数不同。后面你还可以把数据通过ESP32传到本地网络,做历史曲线和远程推送,那又是另一个值得折腾的项目了。