JW01二氧化碳传感器,搭配一块几十块钱的Arduino Uno,是我做环境监测方案时最常用、也最不容易翻车的一套组合。这个项目说白了就三件事:传感器把CO2浓度通过串口发出来,Arduino把一帧一帧的数据拆开,最后在OLED屏上显示出来。整个逻辑不复杂,特别适合刚接触Arduino、想做个有实际用途的小设备的人练手,也适合在办公室、教室这种封闭空间里快速搭一个空气质量参考终端。
这篇就按我平时的操作习惯,从选型思路、接线、代码解析到排查问题,完整走一遍。代码是可直接编译烧录的完整版,还加了DHT22温湿度模块,如果你手头没有DHT22,也能直接忽略那一部分,不影响CO2读取。
1. 先把思路理清楚:JW01到底适合做什么
1.1 为什么CO2监测要用NDIR传感器
很多人第一次选CO2传感器时会纠结:电化学的便宜,NDIR的贵,到底买哪种。我的建议是,如果做室内空气质量监测,预算允许的情况下尽量选NDIR,比如JW01。
NDIR的原理是红外非色散吸收,传感器里有一个红外光源和一个探测器,二氧化碳分子会对特定波长的红外光产生吸收,通过检测光强的衰减程度来反推浓度。这个过程不涉及化学反应,所以传感器寿命长、响应稳定、不容易被其他气体干扰。电化学传感器虽然便宜,但寿命通常只有两三年,而且会受温度湿度影响,用于长时间监测需要频繁校准。
JW01常见的模块分两类,一类是UART串口输出,一类是PWM占空比输出。我个人更推荐串口版,因为读取逻辑简单,一条线发数据,Arduino用SoftwareSerial就能读,解析帧也就十几行代码。PWM版需要用pulseIn去测高电平时间,还要依赖公式换算,精度和调试成本都高一些。
1.2 Arduino Uno的串口资源怎么分配
Arduino Uno只有一个硬件串口,也就是接USB的那组,通常在烧录程序后用来跟电脑通信。如果直接把JW01接到硬件串口的RX/TX上,烧录程序时可能会有冲突,调试时也不方便同时看串口监视器的输出。
所以我一般会把JW01接到软串口上,用SoftwareSerial库模拟一个串口。软串口对时序要求比较高,但只要波特率是9600,且代码里没有长时间阻塞操作,数据读取是很稳定的。如果以后换成ESP32这类多硬件串口的芯片,那更简单,直接用Serial1、Serial2就行,代码结构基本不用变。
1.3 串口版和PWM版怎么选
串口版的优势是数据已经处理成浓度值,直接解析帧结构就能得到ppm,精度和稳定性由传感器保证,我们只需要做校验和判断,防止乱码影响显示。
PWM版的优势是接线更少,一根信号线就能读数,但Arduino读PWM精度受pulseIn测量误差影响,特别是在多任务循环里,容易测得不准。如果你已经买了PWM版的JW01,也可以做,后面我会单独讲一下思路和公式,但如果你还没买,直接买串口版能省不少事。
2. 动手前准备:物料清单、接线和库
2.1 物料清单与接线表
这个项目的硬件非常精简,核心元器件有这些:
| 物料 | 型号/规格 | 数量 | 备注 |
|---|---|---|---|
| 主控板 | Arduino Uno R3 | 1块 | 其他兼容板也可以 |
| CO2传感器 | JW01串口版 | 1个 | 注意区分串口/PWM丝印 |
| OLED显示屏 | 0.96寸 I2C SSD1306 | 1块 | 128x64,四针或六针均可 |
| 温湿度传感器 | DHT22(可选项) | 1个 | 增强环境监测功能 |
| 蜂鸣器 | 有源/无源均可 | 1个 | 可选,用于浓度超标提醒 |
| 面包板 | 400孔 | 1块 | 方便快速搭建 |
| 杜邦线 | 公对公、公对母 | 若干 | 尽量短一点,减少干扰 |
接线方面,我按Arduino Uno的标准引脚来列一份表,照着接基本不会错:
| JW01引脚 | 连接到 |
|---|---|
| VCC | Arduino 5V |
| GND | Arduino GND |
| TXD(传感器发送) | Arduino 数字引脚2(软串口RX) |
| RXD(传感器接收) | Arduino 数字引脚3(软串口TX) |
OLED屏用的是I2C接口:
| OLED引脚 | 连接到 |
|---|---|
| VCC | Arduino 5V |
| GND | Arduino GND |
| SCL | Arduino A5 |
| SDA | Arduino A4 |
DHT22的数据脚我接在数字引脚4。蜂鸣器接数字引脚13,正极接引脚,负极接GND,如果是有源蜂鸣器,直接给高电平就会响。
注意:JW01模块上不同批次的丝印可能会标成TXD/RXD,也可能标成T/R,接线前一定先看模块背面的说明。TXD是发送端,要接Arduino的接收端,也就是我上面说的引脚2,接反了读不到数据。
2.2 库文件安装
代码里用到了几个第三方库,如果你之前没装过,在Arduino IDE的“库管理器”里搜索并安装即可。
| 库名称 | 搜索关键字 | 用途 |
|---|---|---|
| SoftwareSerial | Arduino自带的库 | 模拟串口 |
| Adafruit SSD1306 | adafruit/SSD1306 | SSD1306驱动 |
| Adafruit GFX | adafruit/GFX | OLED绘图 |
| DHT sensor library | adafruit/DHT | DHT22驱动 |
一个容易踩的坑是,只装了Adafruit SSD1306是不够的,它依赖Adafruit GFX,如果没装GFX库,编译时会在很多莫名其妙的地方报错。装上之后记得重启IDE,让库被正确扫描。DHT库也可以不装,只要你不用DHT22。
2.3 供电和电平的几点提醒
JW01传感器的工作电压通常是5V,启动瞬间电流稍大,Arduino Uno的板载5V稳压芯片可以提供足够电流,直接从USB供电就行。但如果你同时接了OLED、DHT22、蜂鸣器和JW01,总电流其实差不多到200mA上下,USB口完全撑得住。
另外一个容易忽略的问题:如果你不是用Arduino Uno,而是用ESP32或其他3.3V主控板,JW01的串口输出电平是5V,直接接到3.3V引脚上有可能烧引脚。这种情况要么选支持3.3V供电的变体型号,要么加一个电平转换模块。Uno就完全不用担心这个问题。
3. 完整代码与逐段解析
3.1 完整可直接编译烧录的代码
下面这份代码是我实际在用的版本,包含CO2读取、DHT22温湿度读取、OLED显示和浓度报警。数据刷新间隔是2秒,刚好够人眼观察,也不会让软串口和DHT读取之间产生明显阻塞。
#include <SoftwareSerial.h> #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> #include "DHT.h" #define CO2_RX_PIN 2 #define CO2_TX_PIN 3 #define DHT_PIN 4 #define BUZZER_PIN 13 #define ALARM_CO2 1000 #define OLED_ADDR 0x3C SoftwareSerial co2Serial(CO2_RX_PIN, CO2_TX_PIN); Adafruit_SSD1306 display(128, 64, &Wire, -1); DHT dht(DHT_PIN, DHT22); uint8_t rxFrame[9]; uint8_t rxIndex = 0; uint16_t co2PPM = 0; unsigned long lastScreenUpdate = 0; unsigned long lastFrameAt = 0; void setup() { Serial.begin(9600); co2Serial.begin(9600); dht.begin(); pinMode(BUZZER_PIN, OUTPUT); digitalWrite(BUZZER_PIN, LOW); if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) { Serial.println(F("SSD1306 init failed")); while (1); } display.clearDisplay(); display.setTextSize(1); display.setTextColor(WHITE); display.setCursor(20, 25); display.print(F("CO2 Monitor")); display.display(); delay(1000); } void loop() { while (co2Serial.available()) { uint8_t b = co2Serial.read(); if (rxIndex == 0 && b != 0xFF) { continue; } rxFrame[rxIndex++] = b; if (rxIndex >= 9) { rxIndex = 0; if (parseCO2Frame()) { co2PPM = getCO2PPM(); lastFrameAt = millis(); } } } if (millis() - lastFrameAt > 10000) { digitalWrite(BUZZER_PIN, HIGH); } else { digitalWrite(BUZZER_PIN, LOW); } if (millis() - lastScreenUpdate > 2000) { lastScreenUpdate = millis(); float t = dht.readTemperature(); float h = dht.readHumidity(); if (isnan(t) || isnan(h)) { t = 0; h = 0; } updateOLED(t, h); } } bool parseCO2Frame() { uint8_t sum = 0; for (int i = 0; i < 8; i++) { sum += rxFrame[i]; } if (rxFrame[0] != 0xFF || rxFrame[1] != 0x04) { return false; } if ((sum & 0xFF) != rxFrame[8]) { return false; } return true; } uint16_t getCO2PPM() { uint16_t ppm = ((uint16_t)rxFrame[2] << 8) | rxFrame[3]; if (ppm > 5000) { ppm = 5000; } return ppm; } void updateOLED(float t, float h) { display.clearDisplay(); display.setTextSize(2); display.setTextColor(WHITE); display.setCursor(0, 0); display.print(F("CO2")); display.setTextSize(3); display.setCursor(18, 24); display.print(co2PPM); display.print(F("ppm")); display.setTextSize(1); display.setCursor(0, 56); display.print(t, 1); display.print(F("C ")); display.print(h, 1); display.print(F("%")); display.display(); if (co2PPM >= ALARM_CO2) { tone(BUZZER_PIN, 2000, 200); } }如果屏幕上的ppm数值超过三位,显示可能会超出边界,我在实际使用时会把数字字号从3下调到2,或者在代码里判断一下位数再决定显示位置。这个不影响读取逻辑,你按自己屏幕效果微调就行。
3.2 JW01数据帧解析与校验原理
JW01串口输出的串口参数是9600波特率、8位数据、无校验、1位停止位。它每秒钟会输出一帧数据,一帧刚好9个字节,格式如下:
| 字节位置 | 含义 | 示例值 |
|---|---|---|
| 0 | 帧头 | 0xFF |
| 1 | 气体类型 | 0x04(CO2) |
| 2 | 浓度高字节 | 0x00 |
| 3 | 浓度低字节 | 0xC8 |
| 4 | 保留字 | 0x00 |
| 5 | 保留字 | 0x00 |
| 6 | 保留字 | 0x00 |
| 7 | 保留字 | 0x00 |
| 8 | 校验和 | 0xCD |
浓度计算方式是把第2字节左移8位,再和第3字节按位或起来,得到的就是ppm值。比如高字节是0x00、低字节是0xC8,那浓度就是0x00C8,也就是200ppm。
校验和的计算逻辑是前8个字节求和,取低8位,然后和第9字节比较。比如前面的示例帧:0xFF + 0x04 + 0x02 + 0x00 + 0xC8 + 0x00 + 0x00 + 0x00 = 0x1CD,取低8位就是0xCD,刚好匹配。代码里parseCO2Frame()做的事情就是这个。校验这一步非常关键,因为软串口在读取过程中偶尔会吃到干扰字节,如果不做校验,屏幕上可能出现乱跳的离谱数值。
3.3 显示逻辑与报警阈值设计
报警阈值我默认设成了1000ppm,这个数值其实是参考室内空气质量标准的典型值。超过1000ppm,很多人会觉得头闷、注意力下降,所以作为报警点是合理的。你也可以根据自己的需求改ALARM_CO2,比如家里有小宝宝,可以设到800ppm更保守一些。
报警逻辑里有一个细节值得说一下:lastFrameAt是用来记录最后一次成功解析到有效帧的时间,如果超过10秒没有收到有效帧,我会让蜂鸣器一直响。为什么这么做?因为传感器如果松线了或者模块坏了,用户往往看着屏幕上的旧数据还以为是正常,这个超时报警能快速提醒你检查接线。蜂鸣器我用的无源蜂鸣器,用tone()函数可以发出2000Hz的短音,如果是5V有源蜂鸣器,直接digitalWrite(BUZZER_PIN, HIGH)就能响。
3.4 如果你手上的版本是PWM输出
有的JW01模块标的是PWM输出,这类模块通常有一个OUT引脚,输出脉冲宽度和CO2浓度成比例。读取思路是用pulseIn()测量高电平和低电平的持续时间,然后根据模块手册上的公式换算成ppm。大致思路是这样:
#define PWM_PIN 5 void setup() { Serial.begin(9600); pinMode(PWM_PIN, INPUT); } void loop() { unsigned long tHigh = pulseIn(PWM_PIN, HIGH); unsigned long tLow = pulseIn(PWM_PIN, LOW); unsigned long tCycle = tHigh + tLow; if (tCycle == 0) { return; } // 占空比 -> ppm,具体公式以你的模块手册为准 float duty = (float)tHigh / tCycle; int ppm = duty * 5000; Serial.print("ppm="); Serial.println(ppm); delay(1000); }这里我特别提醒一句:不同批次JW01 PWM模块的占空比范围和对应量程可能不同,有的对应0~2000ppm,有的对应0~5000ppm,你拿到模块先查手册确认,别直接套网上公式,否则误差会很大。这也是我更推荐串口版的原因,串口版不需要关心这些细节。
4. 实操实录与问题排查
4.1 从零到出数的一次完整过程
我第一次搭这个项目时,从拆开模块到屏幕出数,大约花了一个小时。如果材料齐、库装好,5分钟真的够用。完整流程是这样:先把面包板上的VCC/GND排好,JW01、OLED、DHT22都先不插,单独给面包板供上5V电源,然后用万用表确认几个位置电压正常,避免短路。接着插OLED,先单独跑一下I2C扫描程序,确认屏幕地址是0x3C还是0x3D。然后用我刚才那份代码,先把DHT22不接,跑一个纯读CO2的版本,打开串口监视器,波特率调成9600,等一两秒就能看到传感器输出。
我第一次测试时,串口监视器一片空白,排查了一圈发现是JW01的TXD和RXD两个引脚接反了。模块上TXD要接Arduino的RX引脚,RXD要接Arduino的TX引脚,也就是交叉接线,很多新手都在这里栽过。
4.2 常见问题速查表
我把实操中遇到概率最高的几个问题整理成了一张表,方便你照着排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 串口监视器无输出 | JW01接线反了 | 检查TXD是否接Arduino引脚2 |
| 串口监视器大量乱码 | 波特率不对,或软串口引脚不是2/3 | 确认代码中9600波特率 |
| 屏幕显示CO2但是数值不变 | JW01未上电/数据线松 | 重新插一遍,最好用热熔胶固定 |
| 数值出现5000封顶且不稳定 | 校验失败或模块预热不足 | 上电预热5分钟再看 |
| OLED不亮,或白屏 | I2C地址不是0x3C | 用I2C扫描改地址为0x3D |
| 空气中数值低于300ppm | 模块还没预热完 | 等待10~30分钟稳定 |
4.3 读数不稳定的背后原因
很多人在串口监视器里看到CO2数值来回跳,第一反应是传感器坏了。其实JW01串口版本身输出是每秒一次,相对平滑,如果数值在一个固定范围内波动,比如420~480ppm之间浮动,那是正常现象,空气中CO2浓度本来就是动态变化的。
如果数值出现离谱跳变,比如从480直接跳到2000再跳回500,多半是数据传输被干扰了。面包板上的长杜邦线相当于天线,Arduino的数字引脚2和3在接收软串口时最怕高频噪声。解决方法是把杜邦线剪短、让JW01尽量靠近Arduino,还可以在Arduino的5V和GND之间并联一个100uF电解电容,帮助过滤电源毛刺。
还有一种常见情况是代码里长时间调用delay(),比如我一开始在loop里加了一个delay(1000),导致软串口读取时时序错乱,数据帧被切碎。后来改成用millis()做定时刷新,问题就消失了。这也是我上面的代码里没有死等delay的原因。
4.4 一个容易忽略的细节:传感器预热
NDIR传感器上电后需要预热,JW01模块刚通电的前几分钟,读数往往明显偏低或者偏高,等热稳定之后才会进入正常状态。我第一次用的时候,上电就读,显示380ppm,还挺高兴,结果过了二十分钟再看变成430ppm,才知道刚才根本没读完。
所以如果你要做对比实验,比如测开窗前后浓度变化,记得先让设备稳定运行至少10分钟,再做记录。放在固定位置长期通电,比每次用完断电、用前再开更稳定。
5. 扩展方向:让监测系统真正可用
5.1 接ESP32,把监测数据送到手机上看
Arduino Uno适合做原型,但如果想长期部署,我更推荐把代码迁移到ESP32上。ESP32有多个硬件串口,直接把JW01的TXD接到ESP32的RX2引脚就行,代码里把SoftwareSerial改成Serial2,其余解析逻辑完全复用。ESP32支持WiFi,可以把CO2、温度、湿度数据通过HTTP请求发送到局域网内的网页服务,手机浏览器直接访问。我后来做的一个版本就是把数据传到一个轻量的内网网页上,在手机上随时能看到当前浓度曲线,不需要额外装App。
实际上,如果你已经是Arduino IDE的环境,在开发板管理器中添加ESP32支持后,选型时选“ESP32 Dev Module”,烧录流程和Uno一样,代码兼容性很好。这也是从入门到进阶最平滑的一条路。
5.2 数据记录和趋势曲线
Arduino Uno自身没有时间戳能力,断电后时间会丢,所以长期连续记录数据不如用电脑或者树莓派来收。我常用的做法是,Arduino每隔几秒通过硬件串口发送一行CSV格式的文本,比如1622550000,430,24.5,55,电脑端用Python的pyserial读取并落盘,再用matplotlib或者Grafana画曲线。
如果你不想碰代码以外的东西,也可以直接用Arduino IDE自带的“串口绘图器”,选好波特率和串口,它能把串口输出里的数值自动绘制成实时波形图。不过串口绘图器一次只能显示一个数值流,我一般会让CO2单独一行输出,这样图表看起来最直观。
5.3 多场景部署:办公室、教室、卧室
我在办公室放了一台,实测效果很明显。工位坐满人、门窗关闭一小时后,CO2经常突破1500ppm,人会有明显的闷胀感。开窗通风5分钟,浓度能降到500ppm左右。这个过程如果你只用体感去感受,根本说不清什么时候该开窗,屏幕上一目了然。
在卧室用的话,我建议把设备放在床头附近,但不要正对空调出风口或者窗户。放在书桌上、距离人体0.5到1米左右最有参考价值。需要注意的是JW01传感器本身有轻微发热,不要把它直接贴墙或者塞进柜子里,否则局部空气流动不畅,读数会偏高。把它架空放置,周围留一点空间,数据才真实。
如果要做成一个小盒子长期使用,可以再加一个0.5A的USB电源适配器给Arduino供电,不用一直插着电脑。OLED屏幕建议关掉默认的屏幕常亮,改成每隔几秒刷新一次,长期运行能减少屏幕老化。
这几天我把这套系统放在办公桌上连续跑了48小时,整体非常稳定,没有出现一次数据断流。中间唯一一次蜂鸣器报警,是下午会议室人多、门关了十几分钟,CO2冲到1000ppm以上,挺好用的。最后再分享一个小技巧:当你想验证传感器读数是否正常,可以对着传感器正常呼气一小口,数值会在几秒内明显上升,然后慢慢回落,这说明整个链路是通的。如果连这个反应都没有,就回头查接线和串口配置。数据可靠了,这套5分钟搭好的环境监测系统才算真正能用。