【ESP32-S3 Web串口调试OTA:打造你的无线调试利器】
ESP32-S3 Web串口调试OTA:打造你的无线调试利器
引言
在嵌入式开发中,调试信息的获取和固件升级往往是两个最频繁的操作。传统方式依赖于串口线,不仅束缚了设备的部署位置,而且在多设备调试时极为不便。本文将带你使用 ESP32-S3 和 PlatformIO 构建一个集 Web 串口调试 与 OTA 固件升级 于一体的可视化控制台,只需浏览器即可实时查看日志、发送命令、上传新固件,彻底摆脱线缆束缚。
资源地址:https://download.csdn.net/download/VOR234/93095605
1. 项目概览
1.1 功能目标
- Web 串口调试:通过 WebSocket 实时转发
printf/print输出,在网页上以终端风格显示日志,支持时间戳开关。 - 双向交互:可在网页输入框发送命令,ESP32 端接收并执行(如控制 LED、查询状态),硬件串口同步打印接收到的命令。
- OTA 固件升级:通过网页选择
.bin文件,一键上传并自动烧录到空闲 OTA 分区,设备重启后生效,支持失败回滚。 - 美观 UI:暗色主题,自适应布局,实时显示设备 IP 和运行时间,连接状态一目了然。
1.2 技术栈
| 组件 | 选型 | 说明 |
|---|---|---|
| 硬件 | ESP32-S3 WROOM N8R8 (8MB Flash, 8MB PSRAM) | 性能强劲,支持大容量 OTA |
| 框架 | Arduino (PlatformIO) | 开发便捷,库生态丰富 |
| Web 服务器 | ESPAsyncWebServer | 异步非阻塞,性能优异 |
| WebSocket | 原生 AsyncWebSocket | 实时双向通信,日志无延迟 |
| OTA | Update 库 (ESP-IDF 封装) | 支持分区切换、回滚 |
| 前端 | 纯 HTML+CSS+JS | 无需额外框架,轻量快速 |
1.3 硬件购买
目前这是我使用的ESP32S3官方硬件👍👍👍(小小的身材有大大的力量)只需要35元加摄像头麦克风79元,后期我会整理相关专栏进行Arduino系统学习😘😘😘。有需要可以购买xiao开发板💕💕💕
-
SeeedXIAO ESP32S3 Sense硬件购买地址:https://s.click.taobao.com/lekazrt

-
ESP32-S3-CAM 核心开发板 N16R8 wifi蓝牙模块 OV2640摄像头硬件购买地址:https://s.click.taobao.com/1PTagos

- ESP32-S3 NANO开发板 虾哥小智AI 蓝牙WiFi核心板N16R8兼容立创S3
【到手价】27.90元
【下单链接】https://s.click.taobao.com/9uSgqVk

上面硬件三选一,我是第三款硬件
2. 分区表设计
ESP32-S3 的 Flash 被划分为多个分区,合理的分区表是 OTA 稳定运行的基础。我们为 8MB Flash 设计了如下分区(partition.csv):
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x380000,
app1, app, ota_1, 0x390000,0x380000,
spiffs, data, spiffs, 0x710000,0x80000,
coredump, data, coredump,0x790000,0x10000,
- app0 / app1:各 3.5 MB,足够容纳包含 LVGL、TensorFlow Lite 等复杂应用。
- spiffs:512 KB,用于存储少量网页资源或配置文件(可按需调整为 LittleFS)。
- otadata:8 KB,记录当前启动的 OTA 分区索引。
- coredump:64 KB,存储崩溃转储,辅助调试。
将分区表文件放在项目根目录,并在 platformio.ini 中指定:
board_build.partitions = partition.csv
3. 核心实现:WebDebugConsole 类
我们将所有功能封装为 WebDebugConsole 类,对外提供简洁的 API,使得 main.cpp 只需几行代码即可集成。
WebDebugConsole.hpp
#pragma once
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Update.h>
#include <functional>
// ========== Web 界面 HTML ==========
static const char WEB_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32-S3 调试控制台</title>
<style>
* { box-sizing: border-box; }
body {
background: #1e1e2f;
color: #cdd6f4;
font-family: 'Fira Code', 'Consolas', monospace;
margin: 0;
padding: 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
h1 {
font-size: 1.5rem;
margin: 0 0 10px 0;
color: #89b4fa;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
h1 small { font-size: 0.9rem; color: #a6adc8; font-weight: normal; }
#log-container {
flex: 1;
background: #11111b;
border: 1px solid #313244;
border-radius: 12px;
padding: 15px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 14px;
line-height: 1.6;
margin-bottom: 15px;
min-height: 200px;
}
#log-container::-webkit-scrollbar { width: 8px; }
#log-container::-webkit-scrollbar-track { background: #1e1e2f; }
#log-container::-webkit-scrollbar-thumb { background: #45475a; border-radius: 4px; }
.log-time { color: #6c7086; }
.input-area {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.input-area input[type="text"] {
flex: 1;
min-width: 150px;
background: #181825;
border: 1px solid #45475a;
border-radius: 8px;
padding: 10px 14px;
color: #cdd6f4;
font-size: 14px;
outline: none;
}
.input-area input[type="text"]:focus { border-color: #89b4fa; }
.btn {
background: #313244;
border: none;
border-radius: 8px;
padding: 10px 20px;
color: #cdd6f4;
font-size: 14px;
cursor: pointer;
transition: 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover { background: #45475a; }
.btn-primary { background: #89b4fa; color: #11111b; }
.btn-primary:hover { background: #74c7ec; }
.btn-success { background: #a6e3a1; color: #11111b; }
.btn-success:hover { background: #94e2d0; }
.btn-danger { background: #f38ba8; color: #11111b; }
.btn-danger:hover { background: #eba0ac; }
.ota-area {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
padding: 10px 0;
border-top: 1px solid #313244;
flex-wrap: wrap;
}
.ota-area input[type="file"] { display: none; }
.file-label {
background: #313244;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
transition: 0.2s;
}
.file-label:hover { background: #45475a; }
#status { color: #a6adc8; font-size: 14px; }
.badge {
background: #45475a;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
}
.footer {
margin-top: 10px;
color: #6c7086;
font-size: 12px;
display: flex;
justify-content: space-between;
}
.options {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
.options label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>
🖥️ Web 调试控制台
<small id="device-ip">连接中...</small>
<span class="badge" id="conn-status">⚪ 未连接</span>
<span class="badge" id="print-status">🟢 打印中</span>
</h1>
<div id="log-container"></div>
<div class="options">
<label>
<input type="checkbox" id="showTimestamp" checked> 显示时间戳
</label>
<button class="btn btn-danger" id="togglePrintBtn">停止打印</button>
</div>
<div class="input-area">
<input type="text" id="cmdInput" placeholder="输入命令,按 Enter 发送">
<button class="btn btn-primary" id="sendBtn">发送</button>
<button class="btn" id="clearBtn">清空日志</button>
</div>
<div class="ota-area">
<span>📦 OTA 升级:</span>
<label for="filePicker" class="file-label">📂 选择固件 (.bin)</label>
<input type="file" id="filePicker" accept=".bin">
<button class="btn btn-success" id="uploadBtn">⬆️ 上传并更新</button>
<span id="status">就绪</span>
</div>
<div class="footer">
<span>ESP32-S3 · WebDebugConsole</span>
<span id="uptime">运行时间:0s</span>
</div>
<script>
(function() {
const logContainer = document.getElementById('log-container');
const cmdInput = document.getElementById('cmdInput');
const sendBtn = document.getElementById('sendBtn');
const clearBtn = document.getElementById('clearBtn');
const statusSpan = document.getElementById('conn-status');
const printStatusSpan = document.getElementById('print-status');
const ipSpan = document.getElementById('device-ip');
const uptimeSpan = document.getElementById('uptime');
const filePicker = document.getElementById('filePicker');
const uploadBtn = document.getElementById('uploadBtn');
const statusMsg = document.getElementById('status');
const showTimestamp = document.getElementById('showTimestamp');
const togglePrintBtn = document.getElementById('togglePrintBtn');
let ws = null;
let reconnectTimer = null;
let printingEnabled = true;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/webserial`;
ws = new WebSocket(url);
ws.onopen = function() {
statusSpan.innerHTML = '🟢 已连接';
statusSpan.style.color = '#a6e3a1';
log('系统', 'WebSocket 连接成功');
};
ws.onmessage = function(event) {
const msg = event.data;
// 检测打印状态广播
if (msg.startsWith('PRINT_STATUS:')) {
const status = msg.substring(13);
if (status === 'ON') {
printingEnabled = true;
printStatusSpan.innerHTML = '🟢 打印中';
printStatusSpan.style.color = '#a6e3a1';
togglePrintBtn.textContent = '停止打印';
togglePrintBtn.className = 'btn btn-danger';
} else if (status === 'OFF') {
printingEnabled = false;
printStatusSpan.innerHTML = '🔴 已暂停';
printStatusSpan.style.color = '#f38ba8';
togglePrintBtn.textContent = '恢复打印';
togglePrintBtn.className = 'btn btn-success';
}
return;
}
log('', msg);
};
ws.onclose = function() {
statusSpan.innerHTML = '🔴 断开';
statusSpan.style.color = '#f38ba8';
log('系统', 'WebSocket 断开,尝试重连...');
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connectWebSocket, 3000);
};
ws.onerror = function(err) {
// 错误触发时关闭处理会自动进行
};
}
function log(prefix, msg) {
const line = document.createElement('div');
if (showTimestamp.checked) {
const time = new Date().toLocaleTimeString();
line.innerHTML = `<span class="log-time">[${time}]</span> ${prefix ? '['+prefix+'] ' : ''}${msg}`;
} else {
line.textContent = prefix ? '['+prefix+'] ' + msg : msg;
}
logContainer.appendChild(line);
logContainer.scrollTop = logContainer.scrollHeight;
}
function sendCommand(cmd) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(cmd);
log('你', cmd);
cmdInput.value = '';
} else {
log('系统', 'WebSocket 未连接,无法发送');
}
}
// ---- 按钮事件 ----
sendBtn.addEventListener('click', () => {
const cmd = cmdInput.value.trim();
if (cmd) sendCommand(cmd);
});
cmdInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendBtn.click();
}
});
clearBtn.addEventListener('click', () => {
logContainer.innerHTML = '';
});
togglePrintBtn.addEventListener('click', () => {
if (printingEnabled) {
sendCommand('STOP_PRINT');
} else {
sendCommand('RESUME_PRINT');
}
});
// ---- OTA 上传 ----
uploadBtn.addEventListener('click', () => {
if (!filePicker.files || filePicker.files.length === 0) {
statusMsg.textContent = '⚠️ 请先选择固件文件';
return;
}
const file = filePicker.files[0];
if (!file.name.endsWith('.bin')) {
statusMsg.textContent = '⚠️ 请选择 .bin 文件';
return;
}
statusMsg.textContent = '⏳ 上传中...';
uploadBtn.disabled = true;
const formData = new FormData();
formData.append('firmware', file);
fetch('/update', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(data => {
statusMsg.textContent = data;
uploadBtn.disabled = false;
if (data.includes('成功') || data.includes('Success')) {
log('OTA', '固件上传成功,设备即将重启...');
setTimeout(() => { location.reload(); }, 3000);
}
})
.catch(err => {
statusMsg.textContent = '❌ 上传失败: ' + err;
uploadBtn.disabled = false;
});
});
// ---- 获取IP和运行时间 ----
fetch('/ip')
.then(res => res.text())
.then(ip => { ipSpan.textContent = ip; });
setInterval(() => {
fetch('/uptime')
.then(res => res.text())
.then(t => { uptimeSpan.textContent = '运行时间:' + t + 's'; });
}, 2000);
connectWebSocket();
})();
</script>
</body>
</html>
)rawliteral";
// ========== WebDebugConsole 类 ==========
class WebDebugConsole {
public:
WebDebugConsole(AsyncWebServer* server) : _server(server), _ws("/webserial") {
_ws.onEvent([this](AsyncWebSocket* s, AsyncWebSocketClient* c, AwsEventType t, void* arg, uint8_t* data, size_t len) {
this->onWsEvent(s, c, t, arg, data, len);
});
}
void begin() {
_server->addHandler(&_ws);
_server->on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send_P(200, "text/html", WEB_HTML);
});
_server->on("/ip", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send(200, "text/plain", WiFi.localIP().toString());
});
_server->on("/uptime", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send(200, "text/plain", String(millis() / 1000));
});
_server->on("/update", HTTP_POST, [](AsyncWebServerRequest* request) {
request->send(200, "text/plain", "Upload finished");
}, [this](AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final) {
this->handleOTA(request, filename, index, data, len, final);
});
}
void loop() {
_ws.cleanupClients();
}
// ---------- 发送消息(不受暂停控制,始终发送) ----------
void print(const String& msg) {
_ws.textAll(msg);
}
void println(const String& msg) {
print(msg + "\n");
}
void printf(const char* format, ...) {
char buffer[256];
va_list args;
va_start(args, format);
vsnprintf(buffer, sizeof(buffer), format, args);
va_end(args);
print(String(buffer));
}
// ---------- 打印控制 ----------
void setPrintingEnabled(bool enabled) {
if (_printingEnabled != enabled) {
_printingEnabled = enabled;
// 广播状态给所有客户端
_ws.textAll(enabled ? "PRINT_STATUS:ON" : "PRINT_STATUS:OFF");
}
}
bool isPrintingEnabled() const {
return _printingEnabled;
}
void onCommand(std::function<void(const String&)> callback) {
_commandCallback = callback;
}
private:
AsyncWebServer* _server;
AsyncWebSocket _ws;
std::function<void(const String&)> _commandCallback = nullptr;
bool _printingEnabled = true;
void onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) {
switch (type) {
case WS_EVT_CONNECT:
Serial.printf("[WebDebug] Client #%u connected\n", client->id());
// 新连接时发送当前打印状态
client->text(_printingEnabled ? "PRINT_STATUS:ON" : "PRINT_STATUS:OFF");
break;
case WS_EVT_DISCONNECT:
Serial.printf("[WebDebug] Client #%u disconnected\n", client->id());
break;
case WS_EVT_DATA: {
AwsFrameInfo* info = (AwsFrameInfo*)arg;
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
data[len] = 0;
String msg = (char*)data;
Serial.print("[WebDebug] Received command: ");
Serial.println(msg);
// 处理内置打印控制命令
if (msg == "STOP_PRINT") {
setPrintingEnabled(false);
// 不调用回调,直接返回
return;
} else if (msg == "RESUME_PRINT") {
setPrintingEnabled(true);
return;
}
if (_commandCallback) {
_commandCallback(msg);
}
}
break;
}
case WS_EVT_ERROR:
Serial.printf("[WebDebug] WebSocket error\n");
break;
}
}
void handleOTA(AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final) {
static bool updateStarted = false;
if (!index) {
if (!Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000)) {
Update.printError(Serial);
request->send(500, "text/plain", "OTA 初始化失败");
return;
}
updateStarted = true;
Serial.println("[WebDebug] OTA update started");
}
if (updateStarted) {
if (Update.write(data, len) != len) {
Update.printError(Serial);
Update.abort();
request->send(500, "text/plain", "写入失败");
updateStarted = false;
return;
}
if (final) {
if (Update.end(true)) {
Serial.println("[WebDebug] OTA update success");
request->send(200, "text/plain", "✅ 固件更新成功,设备重启中...");
delay(100);
ESP.restart();
} else {
Update.printError(Serial);
request->send(500, "text/plain", "OTA 结束失败");
updateStarted = false;
}
}
}
}
};
3.1 类结构
class WebDebugConsole {
public:
WebDebugConsole(AsyncWebServer* server);
void begin(); // 挂载路由,启动 WebSocket
void loop(); // 清理断开的客户端(需在 loop 调用)
void print(const String& msg); // 发送文本(不带时间戳)
void println(const String& msg);
void printf(const char* format, ...);
void onCommand(std::function<void(const String&)> callback); // 命令回调
private:
AsyncWebServer* _server;
AsyncWebSocket _ws;
std::function<void(const String&)> _commandCallback;
void onWsEvent(...);
void handleOTA(...);
};
3.2 WebSocket 通信
WebSocket 路径固定为 /webserial(与前端保持一致)。当收到客户端消息时,会触发 WS_EVT_DATA 事件,解析出文本命令并执行两个动作:
- 硬件串口输出:
Serial.print("[WebDebug] Received command: " + msg); - 用户回调:调用
_commandCallback(msg),让用户自定义命令处理逻辑(如控制 LED)。
发送日志时,调用 _ws.textAll(msg) 广播给所有已连接的网页客户端,实现多端同步查看。
3.3 OTA 固件升级
- 路由
/update使用HTTP_POST方式接收multipart/form-data上传的.bin文件。 - 在
handleOTA函数中,利用Update类进行分区写入:- 调用
Update.begin()指定固件大小(自动选择当前未使用的 OTA 分区)。 - 分块写入数据,直至全部完成。
Update.end(true)完成后,调用ESP.restart()重启,Bootloader 会自动切换到新分区。
- 调用
- 上传过程中,网页会显示进度和结果,成功后将自动重载页面。
3.4 自定义 HTML 页面
静态页面 WEB_HTML 以 PROGMEM 存储在 Flash 中,包含:
- 日志显示区:滚动显示调试信息,支持
white-space: pre-wrap保留换行。 - 时间戳开关:通过复选框控制是否在每条日志前添加
[HH:MM:SS]。 - 命令输入框:回车或点击“发送”即通过 WebSocket 发送。
- OTA 文件选择与上传:选择
.bin文件后点击上传,状态提示实时反馈。
前端 JavaScript 负责:
- 建立 WebSocket 连接,自动重连。
- 解析并渲染日志。
- 处理文件上传,使用
FormData发送 POST 请求。
4. 主程序示例
main.cpp 中只需简单几行即可启用所有功能:
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include "WebDebugConsole.hpp"
const char *ssid = "LSH_24G";
const char *password = "LSH_2025";
AsyncWebServer server(80);
WebDebugConsole webDebug(&server);
void setup()
{
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected! IP: " + WiFi.localIP().toString());
webDebug.begin();
// 自定义命令回调(不受打印暂停影响)
webDebug.onCommand([](const String &cmd)
{
if (cmd == "LED_ON") {
// digitalWrite(LED_BUILTIN, HIGH);
webDebug.println("LED turned ON");
} else if (cmd == "LED_OFF") {
// digitalWrite(LED_BUILTIN, LOW);
webDebug.println("LED turned OFF");
} else {
webDebug.print("Echo: ");
webDebug.println(cmd);
} });
server.begin();
Serial.println("HTTP server started");
}
void loop()
{
static unsigned long lastSend = 0;
// ========== 关键修改:只有打印使能时才发送自动日志 ==========
if (webDebug.isPrintingEnabled() && millis() - lastSend > 1000)
{
// Serial.println("\nWiFi connected! IP: " + WiFi.localIP().toString());
lastSend = millis();
webDebug.printf("Free Heap: %u\n", ESP.getFreeHeap());
webDebug.printf("Uptime: %lu s\n", millis() / 1000);
}
webDebug.loop();
}
5. PlatformIO 配置
platformio.ini 关键配置:
[env:freenove_esp32_s3_wroom]
platform = espressif32
board = freenove_esp32_s3_wroom
framework = arduino
board_build.partitions = partition.csv
build_flags = -DARDUINO_USB_CDC_ON_BOOT=1 ; 启用 USB CDC(若使用 USB 串口)
lib_deps =
ESPAsyncWebServer-esphome
AsyncTCP-esphome
注意:我们并未依赖
WebSerialLite,而是完全自建 WebSocket,避免库兼容问题,代码更可控。
6. 使用演示
6.1 烧录与启动
- 将项目烧录到 ESP32-S3,打开串口监视器查看分配的 IP 地址。
- 确保电脑与 ESP32 在同一局域网。
6.2 访问调试页面
浏览器输入 http://[ESP32-IP]/,即可看到美观的调试控制台:
- 左上角显示设备 IP,连接状态(绿点表示 WebSocket 已连接)。
- 日志区会实时显示
webDebug.print输出的内容。 - 取消“显示时间戳”可隐藏时间,便于复制日志。

6.3 命令交互
在输入框中输入命令(如 LED_ON),点击发送或按回车:
- 网页会显示
[你] LED_ON。 - ESP32 硬件串口打印
[WebDebug] Received command: LED_ON。 - 回调函数处理命令,并返回执行结果(如
LED turned ON)。
6.4 OTA 升级
- 在 PlatformIO 中编译生成
.bin固件(pio build,文件位于.pio/build/freenove_esp32_s3_wroom/firmware.bin)。 - 在网页点击“选择固件”,选中该文件。
- 点击“上传并更新”,进度提示显示“上传中…”,等待几秒后提示成功,设备自动重启。
- 刷新页面,运行时间重置,表示已运行新固件。
7. 效果截图

选择固件"\SerialWeb.pio\build\freenove_esp32_s3_wroom\firmware.bin"
点击更新固件

上传成功重新启动

8. 常见问题与注意事项
- WebSocket 连接失败:检查防火墙是否阻止 80 端口,或 ESP32 与 PC 是否在同一网段。
- OTA 上传失败:确认分区表大小足够(当前 app0/app1 各 3.5MB,若固件超限需调整分区)。
- 时间戳显示异常:前端使用
new Date().toLocaleTimeString(),若时区不正确可自行调整。 - 硬件串口打印:依赖
Serial对象,若使用 USB CDC,请确保build_flags包含-DARDUINO_USB_CDC_ON_BOOT=1。
9. 总结
通过本文,我们成功构建了一个轻量级、可扩展的 Web 调试与 OTA 升级系统。其核心优势在于:
- 零依赖客户端:只需浏览器,无需安装任何软件。
- 实时反馈:WebSocket 保证日志无延迟,命令响应即时。
- 安全可靠:双分区 OTA,升级失败自动回滚。
- 易于定制:通过回调函数可灵活扩展命令集,适配各种项目需求。
你可以将此框架直接集成到自己的 ESP32-S3 项目中,享受无线调试带来的便利。若你需要进一步优化(如增加身份验证、压缩传输),也可在现有代码基础上轻松实现。
项目源码:可基于文中提供的 WebDebugConsole.hpp 和 main.cpp 快速搭建。
扩展建议:
- 支持多客户端同时查看日志,适合团队协作。
- 增加日志级别过滤(Info/Warn/Error)。
- 将 SPIFFS 替换为 LittleFS 提升性能和可靠性。
希望这篇博客能帮助你在嵌入式开发中事半功倍!如有疑问,欢迎留言交流。
更多推荐



所有评论(0)