ESP32 实战教程:做一个带 WebSocket 的远程日志系统
设备放在桌面上时,串口监视器是好朋友;设备装到墙里、屋顶或别人家里后,串口线就变成了哲学问题。远程日志系统的目标不是把所有变量都疯狂打印,而是让设备在网络在线时实时给出关键事件,网络离线时也能留下有限的现场记录。

一、为什么需要结构化日志
Serial.println("error") 在实验阶段很方便,但项目变大后会遇到三个问题:不知道错误来自哪个模块,不知道发生时间,也无法区分普通提示和严重故障。结构化日志至少包含等级、时间戳、模块名和消息,例如:
2026-09-30T10:12:04.512 [WARN] wifi: reconnect attempt=2
2026-09-30T10:12:05.101 [INFO] sensor: temperature=24.6
2026-09-30T10:12:08.900 [ERROR] sd: open failed path=/log/001.txt
日志不是越多越好。高频传感器的每次采样都写一行,会拖慢系统并迅速撑满文件。应该记录状态变化、错误、重连、配置更新和关键业务动作,把“正常重复的事情”用计数器或周期摘要表示。
二、统一日志宏和等级
先让业务代码不依赖具体输出方式。今天日志送串口,明天可能送 WebSocket、文件或 MQTT;如果到处散落 Serial.printf(),迁移会很痛苦。
enum LogLevel : uint8_t { LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR };
const char* levelName(LogLevel level) {
switch (level) {
case LOG_DEBUG: return "DEBUG";
case LOG_INFO: return "INFO";
case LOG_WARN: return "WARN";
default: return "ERROR";
}
}
void logLine(LogLevel level, const char* module, const char* text) {
uint32_t ms = millis();
Serial.printf("[%lu] [%s] %s: %s\\n",
(unsigned long)ms, levelName(level), module, text);
}
#define LOGI(module, text) logLine(LOG_INFO, module, text)
#define LOGW(module, text) logLine(LOG_WARN, module, text)
#define LOGE(module, text) logLine(LOG_ERROR, module, text)
宏只是入口,真正的系统还应该把格式化后的记录复制到队列。注意不要在中断服务函数里调用 String、文件写入或 WebSocket 发送;中断里只置位或投递轻量事件。
三、环形缓冲区避免日志拖垮业务

日志消费者可能暂时不在线,网络发送也可能阻塞。使用固定长度环形缓冲区,可以让业务模块快速把记录放进去;当缓冲区满时,丢弃最低等级的 DEBUG,保留 WARN 和 ERROR。
struct LogRecord {
uint32_t ms;
LogLevel level;
char module[12];
char message[96];
};
constexpr size_t LOG_CAP = 32;
LogRecord ring[LOG_CAP];
size_t head = 0;
size_t count = 0;
void pushLog(const LogRecord& record) {
if (count == LOG_CAP) {
if (record.level < LOG_WARN) return;
// 丢掉最老的一条,为警告/错误腾位置
head = (head + 1) % LOG_CAP;
--count;
}
size_t index = (head + count) % LOG_CAP;
ring[index] = record;
++count;
}
真实多任务项目里,head 和 count 要用互斥锁、临界区或 FreeRTOS 队列保护。环形缓冲区的容量按现场故障密度估算:既不能只放两条,也不要为了“万一”占掉大量 RAM。日志系统本身不应该成为系统内存最大的模块。
四、WebSocket 实时推送
已有 WebSocket 库或 Arduino WebServer 扩展通常可以提供连接回调和广播接口。日志任务从队列取出记录,序列化成短 JSON,再推送给浏览器。浏览器页面可以按等级过滤、暂停滚动、复制单条记录。
// 伪代码:不同 WebSocket 库的接口名称可能不同
void publishLog(const LogRecord& r) {
String json = "{\"ms\":" + String(r.ms);
json += ",\"level\":\"" + String(levelName(r.level)) + "\"";
json += ",\"module\":\"" + String(r.module) + "\"";
json += ",\"message\":\"" + escapeJson(r.message) + "\"}";
websocket.broadcastTXT(json);
}
JSON 转义不能省略。错误消息中如果包含引号、换行或反斜杠,直接拼接会生成非法 JSON,浏览器随后只显示“解析失败”,真正的错误反而被隐藏。可以使用轻量 JSON 库,或写一个只处理这几个字符的转义函数。
远程日志页最好需要登录或一次性口令。日志里经常包含 IP、设备编号、文件路径甚至用户输入,不能因为它是调试页面就完全不设防。WebSocket 也应设置最大连接数和空闲超时,避免一个浏览器标签页开着就长期占用资源。
五、离线时写入 LittleFS

网络断开时,把 WARN/ERROR 写入 LittleFS,等网络恢复后再由用户下载。文件名可以按日期或启动次数分段,例如 /logs/boot-000123.log。不要每条日志都立即同步写闪存,频繁擦写会缩短寿命;可以在缓冲区积累几条后批量写,并在严重错误时强制 flush。
#include <LittleFS.h>
File logFile;
bool openLogFile() {
if (!LittleFS.begin(true)) return false;
logFile = LittleFS.open("/logs/current.log", FILE_APPEND);
return (bool)logFile;
}
void appendOffline(const LogRecord& r) {
if (!logFile) return;
logFile.printf("[%lu] [%s] %s: %s\\n",
(unsigned long)r.ms, levelName(r.level),
r.module, r.message);
if (r.level >= LOG_ERROR) logFile.flush();
}
文件要设置大小上限,例如 64 KB,超过后滚动到下一个编号。空间不足时删除最老的 DEBUG 日志,至少保留最近的 ERROR。错误处理也要记录“日志存储失败”,但不能因为写日志失败而让主业务重启,这就是典型的“调试功能不能影响被调试对象”。
六、时间戳和时钟来源
millis() 适合表示本次启动以来经过了多久,不能代表真实日期。网络在线后可以用 NTP 校时;离线启动则使用 RTC 或在日志中明确标记 boot_ms。混用两种时钟时,日志格式里最好同时保留序号和相对时间,避免重启后出现两个看似相同的时间点。
可以在每次启动记录固件版本、芯片型号、复位原因和剩余堆内存:
void logBoot() {
Serial.printf("boot version=%s reset=%d heap=%u\\n",
FW_VERSION, (int)esp_reset_reason(), ESP.getFreeHeap());
}
这些信息看似琐碎,发生“偶发重启”时却是最有价值的上下文。没有启动记录,远程日志往往只剩下一句“设备不在线”。
七、网络恢复和日志回放
网络重新连上后,不要瞬间把几百条离线日志全部推送出去,这会占满带宽并让浏览器失去响应。可以每秒发送 5 到 10 条,发送成功后再从文件中标记已处理位置。用户网页提供“下载全部”“只看错误”“清空已上传”等按钮,设备端则保留最小可回退文件。
如果设备很重要,日志上传接口应支持断点续传和服务端去重。每条记录带一个单调递增序号,服务器收到重复序号时只保留一份。重试时也要使用退避,避免网络故障期间设备集体同时重连,形成一场不必要的“日志流量高峰”。
八、隐私、安全与性能边界
日志里不要输出密码、完整令牌和个人隐私;调试版本可以输出更多信息,发布版本通过编译宏关闭 DEBUG。WebSocket 和下载接口应使用前面 HTTPS/TLS 文章中的安全连接思路,至少在管理网之外不要暴露无认证的日志页。
性能方面,日志格式化、JSON 序列化和文件写入都需要 CPU 和 RAM。高频采样任务只记录统计值,例如“过去 10 秒收到 16000 个样本,最大值 812”,不要把 16000 行原始数据塞进日志。日志系统要帮助你发现问题,而不是创造新的问题。
九、总结
远程日志的核心不是“把串口搬到浏览器”,而是为设备建立一条有等级、有限缓存、在线推送、离线留存和安全边界的诊断链路。统一入口让业务模块不关心输出渠道,环形缓冲区保护实时任务,LittleFS 负责离线证据,WebSocket 负责现场观察。
当 ESP32 被安装到远处后,日志就是你和设备之间的对话。把这条对话设计好,很多曾经只能“重新烧录试试”的问题,就能变成可复现、可定位、可修复的工程问题。
参考资料
打赏
如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

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