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

为什么还要把数据写到 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 失效、卡重挂载和传感器错误事件。
自动删除最旧文件有风险,学习项目可以先选择“卡满后停止写入并亮红灯”。设备沉默地覆盖数据,比明确告诉用户存储已满更危险。
建议的验证流程
- 单独运行 I2C 扫描,确认 DS3231 地址可见。
- 读取 RTC 时间,断开主电源几分钟后再次检查。
- 单独挂载 SD,创建一个测试文件并在电脑打开。
- 只记录固定数值,确认 CSV 列数和时间格式。
- 接入真实传感器,测试无效读数。
- 连续记录数小时,检查文件是否能完整读取。
- 在不同写入时刻断电,重新上电后检查文件系统。
- 模拟拔卡、满卡和 RTC 电池失效,确认状态能被发现。
常见问题
| 现象 | 优先检查 |
|---|---|
| SD 挂载失败 | 卡格式、CS 引脚、SPI 接线、供电和模块电平 |
| 容量显示异常 | 卡未正确识别或文件系统不受支持 |
| 每次重启时间倒退 | 启动时无条件 adjust(),或 RTC 电池失效 |
| CSV 出现半行 | 写入时掉电,或未完成刷新与关闭 |
| 文件能写但电脑提示修复 | 供电不稳、频繁强制断电或卡质量问题 |
| 数据列错位 | 字段中出现逗号,或错误路径没有保持固定列数 |
| 运行几天后停止 | 卡满、文件句柄泄漏或错误未统计 |
后续扩展
可以增加 OLED 显示最近读数和存储状态;恢复 WiFi 后批量上传尚未同步的文件;用 NTP 定期校准 RTC;给每行增加设备编号和固件版本;将配置放入 NVS;增加安全卸载按钮和超级电容掉电保护。
如果要快速定位损坏,还可以为每条记录增加校验字段或使用二进制日志。不过二进制格式不再能直接用记事本查看,需要配套解析工具。项目选型仍要看目标:学习和低频环境记录,CSV 的透明性很有价值。
总结
一个能长期工作的离线数据记录器,不只是“把数字写进文件”。它需要可靠时间、固定数据格式、写入结果检查、错误计数、文件轮换和断电测试。
先分别验证传感器、RTC 与 SD,再按采样、校验、格式化、追加和刷新这条链路集成。做到任何一次失败都有状态可查,拔下卡后文件也能被正常分析,这个项目才算从示例代码变成了一个像样的数据记录设备。
参考资料
打赏
如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏支持:王庆岩
浙公网安备 33010602011771号