ESP32 综合教程:用 microSD 和 DS3231 做离线 CSV 数据记录器

本文由 AI 协助整理,示例以经典 ESP32 DevKit、SPI microSD 模块和 I2C DS3231 为主。不同开发板的可用引脚、启动脚和供电能力不同,请先查板卡原理图。重要数据记录项目不能只凭串口出现“write ok”就宣布胜利,应做断电、满卡和文件损坏测试。

ESP32 离线数据记录器架构

为什么还要把数据写到 SD 卡

把传感器数据发到 MQTT 或云端很方便,但网络并不总是可靠。路由器可能重启,服务器可能维护,设备可能被放到没有 WiFi 的仓库或温室。此时,一张 microSD 卡就是最朴素的离线数据库:容量大、拔下来能直接用电脑读取,CSV 文件还能被 Excel、Python 和各种分析软件打开。

不过“每隔一分钟写一行”并没有表面上那么简单。没有网络时,ESP32 从哪里获得准确时间?写文件过程中突然断电会怎样?卡满了以后程序是否还假装记录成功?文件一直写成一个巨大 CSV,半年后打开会不会先让电脑思考人生?

本教程把项目拆成三条链路:传感器提供数值,DS3231 提供独立时间,microSD 保存按天划分的 CSV。先让每个模块单独通过,再集成到一起。

硬件清单

  • ESP32 DevKit 一块。
  • 3.3V 逻辑兼容的 microSD SPI 模块。
  • 经过正确格式化的 microSD 卡。
  • DS3231 RTC 模块与后备电池。
  • 一个传感器,例如 BME280、SHT30 或 DHT22。
  • 稳定电源和可靠连接线。

microSD 卡写入瞬间会产生电流波动,供电不稳时可能表现为挂载失败、写到一半重启或偶发文件错误。不要用“平时点灯没问题”证明供电一定没问题,点灯和写卡对电源的性格要求不一样。

参考接线

经典 ESP32 DevKit 的常见 VSPI 接法如下:

microSD ESP32 常见引脚
SCK GPIO18
MISO GPIO19
MOSI GPIO23
CS GPIO5
VCC 根据模块规格连接
GND GND

DS3231 使用 I2C:

DS3231 ESP32 常见引脚
SDA GPIO21
SCL GPIO22
VCC 根据模块规格连接
GND GND

这些只是常见默认值,并非所有 ESP32 板型都相同。GPIO5 在部分芯片上还涉及启动配置,成品设计要检查上下拉与外设上电行为。microSD 模块若包含 5V 稳压和电平转换,也要按其说明书供电,不能只看板子上印着“SD MODULE”就靠直觉接线。

软件库和文件格式

SD、SPI 和 Wire 随 Arduino-ESP32 提供。DS3231 示例使用 Adafruit RTClib,可从 Arduino IDE 库管理器安装。

CSV 固定使用这些列:

timestamp,sequence,temperature_c,humidity_pct,status

示例数据:

2026-09-20 14:05:00,17,24.60,53.20,OK
2026-09-20 14:05:30,18,,,SENSOR_ERROR

传感器失败时不写伪造的 0。零度和零湿度可能是真实数值,不能拿它代表错误。保留空字段并增加状态列,后续分析才知道这一行是采集失败,而不是房间突然进入极端环境。

第一步:初始化 RTC

#include <Wire.h>
#include <RTClib.h>

RTC_DS3231 rtc;

bool setupRtc() {
  Wire.begin(21, 22);

  if (!rtc.begin()) {
    Serial.println("RTC not found");
    return false;
  }

  if (rtc.lostPower()) {
    Serial.println("RTC lost power, setting compile time");
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }

  return true;
}

lostPower() 表示 RTC 的振荡器停止标志被设置,常见原因是首次使用或后备电池失效。这里用编译时间进行初始化,只适合学习和首次校准,因为编译、上传和启动会带来时间差。更完善的设备可以在有网络时用 NTP 校时,再写回 RTC;完全离线时则通过串口命令、按键或配置页面设置。

不要在每次启动时都无条件执行 rtc.adjust()。否则每次重启都会把时钟拨回固件编译那一刻,数据记录器便获得了一种很特别的时间旅行能力。

第二步:初始化 microSD

#include <SPI.h>
#include <SD.h>

constexpr int SD_CS = 5;

bool setupSd() {
  SPI.begin(18, 19, 23, SD_CS);

  if (!SD.begin(SD_CS, SPI)) {
    Serial.println("SD mount failed");
    return false;
  }

  if (SD.cardType() == CARD_NONE) {
    Serial.println("No SD card attached");
    return false;
  }

  uint64_t totalMb = SD.cardSize() / (1024ULL * 1024ULL);
  uint64_t usedMb = SD.usedBytes() / (1024ULL * 1024ULL);
  Serial.printf("SD total=%lluMB used=%lluMB\n", totalMb, usedMb);
  return true;
}

第一次测试先只挂载并打印容量。若失败,依次检查卡是否插好、格式、CS 引脚、SPI 接线、模块逻辑电平和供电。不要在初始化失败后继续写文件,否则后面的报错只是第一处问题的回声。

第三步:按日期生成文件名

每天创建一个文件,避免单个 CSV 无限增长:

void makeDailyPath(const DateTime& now, char* output, size_t size) {
  snprintf(output, size, "/data-%04d%02d%02d.csv",
           now.year(), now.month(), now.day());
}

2026 年 9 月 20 日对应 /data-20260920.csv。日期排序和文件名排序保持一致,电脑端整理比较方便。

首次创建文件时写入表头:

bool ensureCsvHeader(const char* path) {
  if (SD.exists(path)) return true;

  File file = SD.open(path, FILE_WRITE);
  if (!file) return false;

  size_t written = file.println(
      "timestamp,sequence,temperature_c,humidity_pct,status");
  file.close();
  return written > 0;
}

第四步:追加一条记录

定义采样结构:

struct SensorSample {
  uint32_t sequence;
  float temperature;
  float humidity;
  bool valid;
};

写入函数:

bool appendSample(const DateTime& now, const SensorSample& sample) {
  char path[32];
  makeDailyPath(now, path, sizeof(path));

  if (!ensureCsvHeader(path)) {
    Serial.println("Create CSV header failed");
    return false;
  }

  File file = SD.open(path, FILE_APPEND);
  if (!file) {
    Serial.println("Open CSV failed");
    return false;
  }

  char timestamp[24];
  snprintf(timestamp, sizeof(timestamp),
           "%04d-%02d-%02d %02d:%02d:%02d",
           now.year(), now.month(), now.day(),
           now.hour(), now.minute(), now.second());

  size_t written = 0;
  if (sample.valid) {
    written = file.printf("%s,%lu,%.2f,%.2f,OK\n",
                          timestamp,
                          static_cast<unsigned long>(sample.sequence),
                          sample.temperature,
                          sample.humidity);
  } else {
    written = file.printf("%s,%lu,,,SENSOR_ERROR\n",
                          timestamp,
                          static_cast<unsigned long>(sample.sequence));
  }

  file.flush();
  file.close();
  return written > 0;
}

这个学习版每次打开、写入、刷新并关闭文件,逻辑清楚,断电时最多影响正在写的一行,但频繁采样会增加开销。若每秒写很多次,可以保持文件打开并批量刷新,不过必须明确可接受的数据丢失窗口,并设计正常关机或掉电保护。

flush() 和 close() 能把缓冲数据提交给文件系统,但无法把普通消费级 SD 卡变成事务数据库。供电在卡内部擦写时突然消失,仍可能损坏当前文件或文件系统。高可靠项目需要更完整的电源保持、预分配、日志设计和恢复测试。

数据记录器可靠写入流程

第五步:组合主程序

bool rtcReady = false;
bool sdReady = false;
uint32_t sequence = 0;
uint32_t writeErrors = 0;
unsigned long lastSampleAt = 0;

SensorSample readSensor() {
  SensorSample sample{};
  sample.sequence = sequence++;

  // 替换为真实传感器读取。
  sample.temperature = 24.6f;
  sample.humidity = 53.2f;
  sample.valid = isfinite(sample.temperature) &&
                 isfinite(sample.humidity) &&
                 sample.temperature >= -40.0f &&
                 sample.temperature <= 125.0f &&
                 sample.humidity >= 0.0f &&
                 sample.humidity <= 100.0f;
  return sample;
}

void setup() {
  Serial.begin(115200);
  rtcReady = setupRtc();
  sdReady = setupSd();
  Serial.printf("RTC=%s SD=%s\n",
                rtcReady ? "OK" : "ERR",
                sdReady ? "OK" : "ERR");
}

void loop() {
  if (millis() - lastSampleAt < 30000) return;
  lastSampleAt = millis();

  if (!rtcReady || !sdReady) {
    Serial.println("Logger not ready");
    return;
  }

  SensorSample sample = readSensor();
  DateTime now = rtc.now();

  if (!appendSample(now, sample)) {
    writeErrors++;
    Serial.printf("Write failed, count=%lu\n",
                  static_cast<unsigned long>(writeErrors));
  } else {
    Serial.printf("Saved seq=%lu\n",
                  static_cast<unsigned long>(sample.sequence));
  }
}

这里用 millis() 控制采样间隔,用 RTC 生成真实时间戳。不要用 delay(30000) 把主循环整段堵住,否则以后加入按钮、网络或状态灯时,它们只能每半分钟获得一次关注。

文件轮换与容量管理

按天分文件只是第一步。长期运行还要设置策略:

  • 启动时检查剩余容量。
  • 低于阈值后停止记录并报警,或删除最旧文件。
  • 删除前确保文件已经上传或不再需要。
  • 限制单日异常高频写入,避免故障时迅速填满卡。
  • 记录启动、RTC 失效、卡重挂载和传感器错误事件。

自动删除最旧文件有风险,学习项目可以先选择“卡满后停止写入并亮红灯”。设备沉默地覆盖数据,比明确告诉用户存储已满更危险。

建议的验证流程

  1. 单独运行 I2C 扫描,确认 DS3231 地址可见。
  2. 读取 RTC 时间,断开主电源几分钟后再次检查。
  3. 单独挂载 SD,创建一个测试文件并在电脑打开。
  4. 只记录固定数值,确认 CSV 列数和时间格式。
  5. 接入真实传感器,测试无效读数。
  6. 连续记录数小时,检查文件是否能完整读取。
  7. 在不同写入时刻断电,重新上电后检查文件系统。
  8. 模拟拔卡、满卡和 RTC 电池失效,确认状态能被发现。

常见问题

现象 优先检查
SD 挂载失败 卡格式、CS 引脚、SPI 接线、供电和模块电平
容量显示异常 卡未正确识别或文件系统不受支持
每次重启时间倒退 启动时无条件 adjust(),或 RTC 电池失效
CSV 出现半行 写入时掉电,或未完成刷新与关闭
文件能写但电脑提示修复 供电不稳、频繁强制断电或卡质量问题
数据列错位 字段中出现逗号,或错误路径没有保持固定列数
运行几天后停止 卡满、文件句柄泄漏或错误未统计

后续扩展

可以增加 OLED 显示最近读数和存储状态;恢复 WiFi 后批量上传尚未同步的文件;用 NTP 定期校准 RTC;给每行增加设备编号和固件版本;将配置放入 NVS;增加安全卸载按钮和超级电容掉电保护。

如果要快速定位损坏,还可以为每条记录增加校验字段或使用二进制日志。不过二进制格式不再能直接用记事本查看,需要配套解析工具。项目选型仍要看目标:学习和低频环境记录,CSV 的透明性很有价值。

总结

一个能长期工作的离线数据记录器,不只是“把数字写进文件”。它需要可靠时间、固定数据格式、写入结果检查、错误计数、文件轮换和断电测试。

先分别验证传感器、RTC 与 SD,再按采样、校验、格式化、追加和刷新这条链路集成。做到任何一次失败都有状态可查,拔下卡后文件也能被正常分析,这个项目才算从示例代码变成了一个像样的数据记录设备。

参考资料

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-21 00:02  潇洒石头  阅读(24)  评论(0)    收藏  举报

导航