0. 前情回顾
第一篇发的是开箱与开发环境,记录了几件事:
环境搭建踩的 4 个坑(MSYSTEM 导致 idf.py 只打印警告就退出、PATH 传不进子进程、ESP_IDF_VERSION 未设导致 NoneType 崩溃、ar.exe 文件锁需串行构建)
Flash 容量标称与实际不符(2MB → 16MB),要改 CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
首烧翻车:直接用官方 blink 示例点 RGB,常亮青色 —— 根因是 GPIO60 挂的是 WS2812,电平翻转发不出协议帧,换成 led_strip(RMT 后端)才正常
这篇进入正题。我申请时写的方案是"多协议智能环境监测网关",第一步是把采集端搭起来。

等了小半个月,板子加上配套模块终于凑齐了。这次用的是四类传感器,特意覆盖了三种不同的接口类型:
| BH1750 | 光照度(lx) | I2C | 数字输出,免标定 |
| BMP280 | 气压 / 温度 / 海拔 | I2C | 与 BH1750 共用一条总线 |
| DHT22(AM2302) | 温度 / 湿度 | 单总线 | 靠 GPIO 时序通信,不走 I2C |
| MQ-2 | 可燃气体 / 烟雾 | 模拟量(ADC) | 输出 0~5V,必须分压 |
另外还有一块 3.2 寸 ST7789 SPI 屏,打算后面做本地数据显示用。
为什么特意挑这三类接口:
I2C 是数字传感器最常见的方式,一次能挂多个
单总线只有一根数据线,时序全靠软件,是不同的技术难点
模拟量走 ADC,能验证 MCU 的模拟前端能力
三种都跑通,"多传感器采集"这个说法才算立得住。
2. 引脚规划
接线之前先做了一轮完整的引脚规划。这一步比想象中重要 —— S31 的引脚有几个容易踩的坑。
2.1 最终分配
| BH1750 + BMP280 | SDA / SCL | GPIO47 / GPIO48 |
| DHT22 | DATA | GPIO4 |
| MQ-2 | AO(分压后) | GPIO42 |
| ST7789 | SCLK / MOSI / CS / DC / RST / BL | GPIO35 ~ GPIO40 |
板子上的 J2 排针印的是引脚名(47、48、3V3、G 这种),直接照着找就行,不用数脚位。
2.2 三个必须避开的区域
① 以太网独占 GPIO5 / 6 / 8 ~ GPIO19
这块板的以太网是千兆 RGMII(YT8531 PHY),占用情况:
| SMI MDC / MDIO | GPIO5 / GPIO6 |
| PHY Reset | GPIO7 |
| RGMII TX 数据 + 控制 + 时钟 | GPIO8 ~ GPIO13 |
| RGMII RX 时钟 + 控制 + 数据 | GPIO14 ~ GPIO19 |
GPIO5 / 6 / 8~19 全被吃掉。 不过后来查官方板卡用户指南发现,这一段没有引到 J2 排针上,所以实际接线不会撞,但写代码时不能按 GPIO 序号随便挑。
② 控制台串口占 GPIO58 / GPIO59(J2 上的 TX0 / RXD),接外设会搅乱日志。
③ GPIO0 是 strapping 脚,上电电平影响启动模式,别用。
2.3 一个意外的限制:ADC 只有一档衰减
MQ-2 走 ADC,本来是很常规的事,但查源码时发现 S31 的 ADC 有两个限制:
限制一:只有一档衰减,且参数被硬件忽略
/* components/soc/esp32s31/include/soc/soc_caps.h */
#define SOC_ADC_ATTEN_NUM (1U)
/* adc_ll.h 里的实现 —— 注意函数体是空的 */
adc_oneshot_ll_set_atten(...) { (void)atten; }
也就是说软件传 ADC_ATTEN_DB_12 是无效的,量程是固定的,不像 ESP32-S3 那样可以在 0/2.5/6/12dB 之间选。
限制二:曲线拟合校准尚未实现
/* components/esp_adc/esp32s31/include/adc_cali_schemes.h */
// TODO: [ESP32H31] ← 方案被注释掉,标注了待办
拿不到准确的 mV 值。
实际影响:MQ-2 只能做相对变化观测(比如阈值报警、趋势追踪),不能给出准确的浓度或电压。代码里我也是按这个前提设计的 —— 输出 raw 和归一化比值,不承诺绝对物理量。
3. 工程结构

工程按"每个外设一个文件"的方式拆开,避免所有东西堆在 main.c 里:
| board_config.h | 所有引脚集中定义,驱动里不硬编码 |
| i2c_bus.c / .h | I2C 总线初始化 + 地址扫描 |
| bh1750.c / .h | 光照传感器 |
| bmp280.c / .h | 气压 / 温度 / 海拔 |
| dht22.c / .h | 温湿度(单总线) |
| mq2.c / .h | 可燃气体(ADC) |
| main.c | 采集调度与打印 |
为什么引脚要集中:调试阶段改接线很正常,集中在一处改一次就行,不用在六个文件里找。
4. 关键代码
4.1 I2C 总线扫描 —— 接线自检
两个 I2C 传感器共用一条总线,接线对不对是第一个要确认的事。与其一个个试,不如直接扫描:
void i2c_bus_scan(void)
{
int found = 0;
for (uint8_t addr = 0x08; addr < 0x78; addr++) {
/* probe 成功即表示设备应答 */
if (i2c_master_probe(s_bus, addr, 50) == ESP_OK) {
ESP_LOGI(TAG, " device found: 0x%02X", addr);
found++;
}
}
ESP_LOGI(TAG, "scan done, %d device(s) on bus", found);
}
上电时跑一次,BH1750 应该在 0x23(或 0x5C),BMP280 在 0x76(或 0x77)。接错线一眼就能看出来,比逐个模块排查快得多。
4.2 DHT22 为什么没用 RMT 驱动
DHT22 是单总线器件,靠微秒级时序通信。ESP-IDF 官方有个 espressif/dht 组件,但它是基于 RMT 外设实现的。
问题在于:S31 目前是 preview 阶段,RMT 驱动在 soc 层还不完整 —— components/soc/esp32s31/ 目录下没有 rmt_periph.c,soc_caps.h 里也找不到 RMT 通道数相关的宏。
所以改用 GPIO 位操作 + 微秒计时自己解时序,零外设依赖:
/* 起始信号:拉低 ≥1ms(手册要求),然后释放 */
portDISABLE_INTERRUPTS();
gpio_set_level(DHT_GPIO, 0);
dht_delay_us(DHT_START_LOW_US); /* 1.2ms,留 20% 余量 */
gpio_set_level(DHT_GPIO, 1);
dht_delay_us(DHT_RELEASE_US); /* 释放 30us 让从机接管 */
/* 应答序列是三段,必须全部走完才能进入第一位数据:
* 1) 从机拉低约 80us
* 2) 从机拉高约 80us
* 3) 高电平结束(下降沿)—— 这是第 1 位的起始
* 若漏掉第 3 段,进位循环时总线仍在高电平,
* 第一次等待高电平会立即返回,把应答高电平误当成数据位。
*/
这里有个真实的坑:我第一版只检测了"低 80us → 高 80us"两段,漏了等待高电平结束。结果就是总线上还在高电平时就进循环,第一个数据位被误判,读出来的全是错的。补上第三段检测才正常。
另一个细节是起始信号用微秒级忙等而不是 vTaskDelay:
/* vTaskDelay 的粒度是 tick(1000Hz 下 1 tick = 1ms),
* pdMS_TO_TICKS(2) 的实际延时取决于调用时刻距下一个 tick 边界多远,
* 最坏情况可能不足 1ms,踩到 DHT22 起始脉冲的下限。 */
这个坑不做嵌入式时序的人容易忽略:vTaskDelay 的延时是不精确的,它能保证"至少睡这么久",但实际醒来时刻受 tick 影响。对毫秒级要求无所谓,对 1ms 这种边界值就危险了。
4.3 BMP280 用官方补偿算法
气压传感器的原始值需要经过一堆标定系数补偿才能变成有意义的数值。这部分我直接照搬了 Bosch 官方数据手册的补偿公式(double 精度版本),没有做简化:
/* 照搬 Bosch 数据手册 BST-BMP280-DS001 第 3.11 节
* "Compensation formulae in double precision" */
static int32_t bmp280_compensate_temp(int32_t adc_T, double *t_fine_out);
static uint32_t bmp280_compensate_press(int32_t adc_P, double t_fine);
为什么强调这一点:网上很多简化版本用 float 或者省略高阶项,短时间看不出问题,但气压变化本身很微弱(气象级变化可能只有几个 hPa),精度损失会直接影响海拔计算。多算几十行代码换准确度,值。
配置上选了手册推荐的"室内导航"档:温度 2 倍过采样 + 气压 16 倍过采样 + 4 阶滤波。
4.4 采集调度:软失败设计
主循环里每个传感器独立采集、独立失败:
static void sample_once(void)
{
/* DHT22 */
if (s_status.dht22_ok) {
dht22_data_t d = {0};
if (dht22_read(&d) == ESP_OK) {
ESP_LOGI(TAG, "[DHT22 ] T=%.1f C RH=%.1f %%", ...);
} else if (err == ESP_ERR_INVALID_STATE) {
/* 距上次不足 2s,跳过即可,不算错误 */
} else {
ESP_LOGW(TAG, "[DHT22 ] read failed");
}
} else {
ESP_LOGI(TAG, "[DHT22 ] n/a"); /* 没接的模块打 n/a,不重试 */
}
/* BH1750 / BMP280 / MQ-2 同理 ... */
}
两个设计考虑:
没接的传感器打印 n/a 而不是反复重试 —— 硬件没插,重试一万次也没用,只会刷屏
启动时探测一次后就记住状态 —— 某个模块没接好不影响其他模块和主循环
这样调试时接一步、测一步,出问题范围很小。
5. 小结与下一步
本期完成的:
硬件资源梳理 —— 四类传感器覆盖 I2C / 单总线 / ADC 三种接口
引脚规划 —— 摸清了以太网独占区、控制台串口占用,以及 ADC 的两个限制
系统工程搭建 —— 六模块拆分、引脚集中管理
四个驱动全部写完 —— 含两个真实踩坑(DHT22 应答序列漏相、vTaskDelay 精度不足)
下一步:
逐个上板验证传感器读数,记录实测数据
打通以太网与 Wi-Fi,把采集数据送出去
我要赚赏金
