第一章 项目介绍
本项目基于 ESP32(M5AtomS3R)开发板,设计并实现了一个"网页端与 ESP32 串口通讯测试"系统。传统的串口调试助手需要在电脑上安装专用软件,而本方案将串口测试工具搬到了浏览器网页端:用户只需让 ESP32 接入局域网 WiFi,就可以通过电脑或手机浏览器访问测试页面,录入需要发送的字符串并下发给 ESP32,ESP32 接收后通过串口将数据打印出来。
项目主要功能如下:
网页端录入字符串,点击发送按钮将字符串通过 WiFi 网络下发到 ESP32;
ESP32 内置 HTTP 服务器接收数据,并通过串口(波特率 115200)原样打印;
支持最多 10 条指令同时录入,每条指令可单独发送,也可勾选后批量发送;
支持自动发送功能,发送间隔(毫秒)与发送个数均可自由设置,发送个数为 0 时表示一直循环发送;
支持 ASCII 与十六进制(HEX)两种发送模式切换,HEX 模式下网页端会自动校验输入格式;
网页端实时显示发送日志与自动发送进度,操作直观方便。
第二章 设计思路
2.1 总体架构
系统整体数据流向为:浏览器网页 → WiFi 网络 → ESP32 HTTP 服务器 → ESP32 串口。网页文件(HTML/CSS/JavaScript)以字符串常量的形式保存在 ESP32 的 Flash 中(PROGMEM),用户访问根路径时由 ESP32 直接返回,无需任何外部服务器,结构简单、部署方便。
2.2 接口设计
网页与 ESP32 之间约定以下 HTTP 接口:
GET /:返回测试网页;
POST /send:数据下发接口,参数 msg 为要发送的内容,附加参数 hex=1 表示 HEX 模式;
GET /chunks:分块传输演示接口。
网页端通过 fetch 以 application/x-www-form-urlencoded 形式提交数据,中文等字符经 URL 编码传输,ESP32 端自动完成解码,保证中英文均可正确传输。
2.3 网页端设计
网页端采用单页面实现,使用 JavaScript 动态生成 10 条指令输入行,每行包含勾选框、输入框和独立的发送按钮。自动发送通过 async/await 配合延时函数实现:按设定间隔循环发送勾选的指令,达到设定个数后自动停止,过程中可随时点击"停止"按钮中断。HEX 模式下,前端先校验输入只允许 0-9、A-F 且长度为偶数,去除空格后再提交。
2.4 ESP32 端设计
ESP32 端基于 Arduino 框架,使用 WiFi 库连接路由器,使用 WebServer 库搭建 HTTP 服务器并注册路由处理函数,同时使用 ESPmDNS 库注册域名 esp32_chunk_resp.local,实现免记 IP 访问。收到 /send 请求后,根据是否存在 hex 参数分别处理:ASCII 模式直接原样打印字符串;HEX 模式将字符串每两个字符解析为一个字节,再以 %02X 格式输出十六进制文本,避免原始不可打印字节与回显文本交错造成乱码。
第三章 程序流程图与代码编写步骤
3.1 主程序流程图
主程序流程如下(自上而下执行):
开始 → 初始化串口(波特率 115200)→ 设置 WiFi 为 STA 模式并连接路由器 → 循环等待 WiFi 连接成功 → 串口打印 IP 地址 → 启动 MDNS 服务 → 注册 HTTP 路由(/、/send、/chunks)→ 启动 HTTP 服务器 → 进入 loop 主循环 → 调用 server.handleClient() 处理客户端请求 → 返回 loop 继续循环。
3.2 /send 接口处理流程
收到 /send 请求后的处理流程如下:
开始 → 判断是否存在 msg 参数(不存在则返回 400 错误并结束)→ 读取 msg 内容,判断是否存在 hex 参数 → 若是 HEX 模式:判断长度是否为偶数(奇数返回 400 错误并结束),然后每两个字符解析为一个字节,以"RX[HEX] "开头按 %02X 格式依次输出;若为 ASCII 模式:以"RX[ASCII] "开头原样输出字符串 → 向网页返回 200 OK → 结束。
3.3 代码编写步骤
引入 WiFi.h、NetworkClient.h、WebServer.h、ESPmDNS.h 等库文件,并配置 WiFi 名称与密码;
编写 INDEX_HTML 网页字符串,包含指令列表、模式切换、自动发送参数设置与日志显示区;
编写 handleRoot() 函数,返回网页内容;
编写 handleSend() 函数,解析 msg 与 hex 参数,按模式输出到串口;
编写 handleChunks() 函数,实现分块传输演示;
在 setup() 中初始化串口、连接 WiFi、启动 MDNS、注册路由并启动服务器;
在 loop() 中循环调用 server.handleClient() 处理客户端请求。
第四章 程序代码
4.1 完整程序代码(05ChunkWriting.ino)如下:/*
* 网页端与 ESP32 串口通讯测试
*
* 功能说明:
* 1. ESP32 创建 HTTP 服务器(端口 80),并注册 MDNS 服务
* 2. 浏览器访问 http://esp32_chunk_resp.local/ (或串口打印的 IP 地址)
* 打开串口通讯测试网页,支持:
* - 最多 10 条指令录入,可勾选参与批量/自动发送
* - ASCII / 十六进制(HEX) 两种发送模式切换
* - 自动发送(发送间隔、发送个数可设置,个数为 0 表示一直发送)
* 3. 网页通过 POST /send 把指令下发给 ESP32
* 4. ESP32 收到后通过串口(115200)打印出来:
* ASCII 模式:RX[ASCII] <字符串>
* HEX 模式 :RX[HEX] FA FB FC 01
*
* 其它接口:
* /chunks - 分块传输演示
*
* autor:keyboard007
*/
#include <WiFi.h> // WiFi 连接
#include <NetworkClient.h> // 网络客户端
#include <WebServer.h> // HTTP Web 服务器
#include <ESPmDNS.h> // MDNS 域名解析服务
#include "web_page.h" // 网页端代码(INDEX_HTML 字符串),与 ESP32 端逻辑分离
// WiFi 配置(按实际环境修改)
const char *ssid = "航天科宇";
const char *password = "88888888";
// HTTP 服务器对象,监听 80 端口
WebServer server(80);
// 处理 GET / :返回测试网页(HTML 存于 Flash,节省 RAM)
void handleRoot() {
server.send_P(200, "text/html", INDEX_HTML);
}
// 处理 POST /send :接收网页下发的数据,并通过串口输出(支持 ASCII / HEX 两种模式)
// 参数:msg - 数据内容;hex - 可选,存在时表示 HEX 模式
void handleSend() {
if (!server.hasArg("msg")) {
server.send(400, "text/plain", "Missing parameter: msg");
return;
}
String msg = server.arg("msg");
bool isHex = server.hasArg("hex");
if (isHex) {
// HEX 模式:把 "FAFBFC01" 按每两个字符解析为一个字节,以十六进制文本形式输出
int len = msg.length();
if (len % 2 != 0) {
server.send(400, "text/plain", "Invalid HEX length");
return;
}
Serial.print("RX[HEX] ");
for (int i = 0; i < len; i += 2) {
uint8_t b = (uint8_t)strtol(msg.substring(i, i + 2).c_str(), NULL, 16);
Serial.printf("%02X ", b);
}
Serial.println();
} else {
// ASCII 模式:原样输出字符串
Serial.print("RX[ASCII] ");
Serial.println(msg);
}
server.send(200, "text/plain", "OK");
}
// 处理 GET /chunks :分块传输演示(10 秒倒计时,每 500ms 发送一块)
void handleChunks() {
uint8_t countDown = 10;
server.chunkResponseBegin();
char countContent[8];
while (countDown) {
sprintf(countContent, "%d...\r\n", countDown--);
server.chunkWrite(countContent, strlen(countContent));
// 浏览器要等整个响应结束(约 5 秒)才显示倒计时;
// 用 "curl -i esp32_chunk_resp.local:80" 可看到逐块实时发送的效果
delay(500);
}
server.chunkWrite("DONE!", 5);
server.chunkResponseEnd();
}
void setup(void) {
Serial.begin(115200); // 初始化串口,波特率 115200
WiFi.mode(WIFI_STA); // 设为 Station 模式
WiFi.begin(ssid, password); // 连接 WiFi
Serial.println("");
// 等待 WiFi 连接成功
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("");
Serial.print("Connected to ");
Serial.println(ssid);
Serial.print("IP address: ");
Serial.println(WiFi.localIP()); // 打印 IP,浏览器直接访问此地址即可
// 启动 MDNS,之后可通过 http://esp32_chunk_resp.local/ 访问
if (MDNS.begin("esp32_chunk_resp")) {
Serial.println("MDNS responder started");
}
// 注册路由
server.on("/", handleRoot); // 测试网页
server.on("/send", HTTP_POST, handleSend); // 数据下发接口
server.on("/chunks", handleChunks); // 分块传输演示
server.onNotFound([]() {
server.send(404, "text/plain", "Page not found");
});
server.begin();
Serial.println("HTTP server started");
}
void loop(void) {
server.handleClient(); // 处理客户端请求
delay(2); // 让出 CPU,避免看门狗复位
}网页端代码:
/* * web_page.h - 网页端代码(HTML + CSS + JavaScript) * * 本文件包含串口通讯测试的完整网页,以 PROGMEM 字符串形式存储, * 由主程序 05ChunkWriting.ino 通过 #include "web_page.h" 引用, * handleRoot() 通过 server.send_P() 下发给浏览器。 * * 功能: * - 最多 10 条指令录入,支持勾选批量/自动发送 * - ASCII / 十六进制(HEX) 模式切换 * - 发送间隔、发送个数可配置的自动发送 * - 实时发送日志显示 * * autor:keyboard007 */#ifndef WEB_PAGE_H#define WEB_PAGE_H
#include <pgmspace.h>
const char INDEX_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串口通讯测试</title><style> body { font-family: "Microsoft YaHei", sans-serif; max-width: 640px; margin: 24px auto; padding: 0 16px; background: #f5f6fa; color: #2c3e50; } h1 { font-size: 22px; text-align: center; margin-bottom: 2px; } .author { text-align: center; color: #888; font-size: 13px; margin-top: 0; } .panel { background: #fff; border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,.08); } .panel-title { font-size: 14px; color: #666; margin-bottom: 10px; } .cmd-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; } .idx { width: 20px; text-align: right; color: #888; font-size: 13px; } .cmd { flex: 1; padding: 7px 10px; border: 1px solid #ccc; border-radius: 5px; font-family: Consolas, monospace; font-size: 14px; } button { padding: 7px 14px; border: none; border-radius: 5px; background: #0069d9; color: #fff; cursor: pointer; font-size: 14px; } button:hover { background: #0053b3; } button:disabled { background: #9bb8d6; cursor: not-allowed; } .send-one { padding: 7px 10px; font-size: 13px; } .stop { background: #d93a3a; } .stop:hover { background: #b32d2d; } .gray { background: #6c757d; } .gray:hover { background: #5a6268; } .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } .num { width: 80px; padding: 7px; border: 1px solid #ccc; border-radius: 5px; } .hint { color: #999; font-size: 12px; } #log { background: #1e1e1e; color: #9fe870; height: 160px; overflow-y: auto; padding: 8px; border-radius: 6px; font-family: Consolas, monospace; font-size: 12px; white-space: pre-wrap; margin: 0; }</style></head><body> <h1>网页端与ESP32串口通讯测试</h1> <p>autor:keyboard007</p>
<!-- 发送模式切换:ASCII / 十六进制(HEX) --> <div class="panel row"> <span>发送模式:</span> <label><input type="radio" name="mode" id="modeAscii" checked> ASCII</label> <label><input type="radio" name="mode" id="modeHex"> 十六进制(HEX)</label> </div>
<!-- 指令列表:最多 10 条,由 JS 动态生成,勾选后参与批量/自动发送 --> <div> <div>指令列表(勾选后参与批量/自动发送,最多 10 条)</div> <div id="cmdList"></div> </div>
<!-- 自动发送参数设置与操作按钮 --> <div> <div> <label>发送间隔 <input type="number" id="interval" value="1000" min="10" step="10"> ms</label> <label>发送个数 <input type="number" id="count" value="1" min="0"></label> <span>(0 = 一直发送)</span> </div> <div style="margin-top:10px"> <button id="sendChecked">发送勾选指令</button> <button id="autoBtn">开始自动发送</button> <button id="stopBtn" disabled>停止</button> <button id="clearLog">清空日志</button> </div> <div id="progress" style="margin-top:8px"></div> </div>
<!-- 发送日志显示区 --> <div><pre id="log"></pre></div>
<script> const MAX_CMD = 10; // 最大指令条数 const cmdList = document.getElementById('cmdList'); // 指令列表容器 const logEl = document.getElementById('log'); // 日志显示区 const progressEl = document.getElementById('progress'); // 自动发送进度显示 let autoRunning = false; // 自动发送运行标志 const sleep = ms => new Promise(r => setTimeout(r, ms)); // 延时函数
// 生成 10 条指令输入行 for (let i = 0; i < MAX_CMD; i++) { const div = document.createElement('div'); div.className = 'cmd-row'; div.innerHTML = '<input type="checkbox" id="en' + i + '"' + (i === 0 ? ' checked' : '') + '>' + '<span>' + (i + 1) + '</span>' + '<input type="text" id="cmd' + i + '">' + '<button type="button" data-i="' + i + '">发送</button>'; cmdList.appendChild(div); }
// 判断当前是否为 HEX 模式 function isHexMode() { return document.getElementById('modeHex').checked; }
// 模式切换时更新输入框提示文字 function updatePlaceholders() { const ph = isHexMode() ? 'HEX,如: 01 03 0A FF' : 'ASCII 字符串'; document.querySelectorAll('.cmd').forEach(c => c.placeholder = ph); } document.getElementById('modeAscii').addEventListener('change', updatePlaceholders); document.getElementById('modeHex').addEventListener('change', updatePlaceholders); updatePlaceholders();
// 向日志区追加一条带时间戳的记录 function log(msg, cls) { logEl.textContent += '[' + new Date().toLocaleTimeString() + '] ' + msg + '\n'; logEl.scrollTop = logEl.scrollHeight; }
// 发送前校验:非空;HEX 模式下仅允许 0-9 A-F 且长度为偶数 function validate(text) { if (!text) return '内容为空'; if (isHexMode()) { const clean = text.replace(/\s/g, ''); if (!/^[0-9a-fA-F]*$/.test(clean)) return 'HEX 格式错误(仅允许 0-9 A-F)'; if (clean.length % 2 !== 0) return 'HEX 长度必须为偶数'; } return null; }
// 发送单条指令到 ESP32(POST /send;HEX 模式附加 hex=1 标志) async function sendCmd(text) { const err = validate(text); if (err) { log(err + ': ' + text, 'err'); return false; } try { const resp = await fetch('/send', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'msg=' + encodeURIComponent(isHexMode() ? text.replace(/\s/g, '') : text) + (isHexMode() ? '&hex=1' : '') }); if (resp.ok) { log('已发送: ' + text, 'ok'); return true; } log('发送失败 HTTP ' + resp.status + ': ' + text, 'err'); return false; } catch (e) { log('发送失败: ' + e.message, 'err'); return false; } }
// 获取所有勾选且非空的指令 function checkedTexts() { const arr = []; for (let i = 0; i < MAX_CMD; i++) { const cmd = document.getElementById('cmd' + i).value.trim(); if (document.getElementById('en' + i).checked && cmd) arr.push(cmd); } return arr; }
// 根据自动发送状态启用/禁用按钮 function setButtons() { document.getElementById('autoBtn').disabled = autoRunning; document.getElementById('sendChecked').disabled = autoRunning; document.getElementById('stopBtn').disabled = !autoRunning; }
// 手动批量发送:将勾选的指令按顺序各发一次 async function sendCheckedOnce() { const rows = checkedTexts(); if (!rows.length) { log('未勾选任何指令', 'err'); return; } for (const t of rows) await sendCmd(t); }
// 自动发送:按设定间隔循环发送勾选指令,达到发送个数后停止(个数 0 = 一直发送) async function autoLoop() { const interval = Math.max(10, parseInt(document.getElementById('interval').value) || 1000); const count = parseInt(document.getElementById('count').value) || 0; autoRunning = true; setButtons(); let sent = 0; while (autoRunning) { const rows = checkedTexts(); if (!rows.length) { log('未勾选任何指令,自动发送结束', 'err'); break; } for (const t of rows) { if (!autoRunning) break; // 用户点击了停止 if (count > 0 && sent >= count) { autoRunning = false; break; } // 达到发送个数 await sendCmd(t); sent++; progressEl.textContent = '已发送 ' + sent + (count > 0 ? ' / ' + count : '') + ' 条'; if (count > 0 && sent >= count) { autoRunning = false; break; } await sleep(interval); // 等待设定的发送间隔 } } autoRunning = false; setButtons(); }
// 按钮事件绑定 document.getElementById('sendChecked').addEventListener('click', sendCheckedOnce); document.getElementById('autoBtn').addEventListener('click', autoLoop); document.getElementById('stopBtn').addEventListener('click', () => { autoRunning = false; }); document.getElementById('clearLog').addEventListener('click', () => { logEl.textContent = ''; progressEl.textContent = ''; }); // 每条指令右侧的"发送"按钮:单条发送 cmdList.addEventListener('click', e => { if (e.target.classList.contains('send-one')) { sendCmd(document.getElementById('cmd' + e.target.dataset.i).value.trim()); } }); // 输入框内按回车:发送该条指令 cmdList.addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.classList.contains('cmd')) sendCmd(e.target.value.trim()); });</script></body></html>)rawliteral";
#endif // WEB_PAGE_H
第五章 调试注意事项
烧录前先将代码中的 WiFi 名称(ssid)和密码(password)修改为实际可用的路由器参数,注意 WiFi 名称区分大小写;
串口监视器的波特率必须设置为 115200,否则输出为乱码;
烧录完成后先打开串口监视器,查看 ESP32 获取到的 IP 地址,浏览器访问 http://IP地址/ 或 http://esp32_chunk_resp.local/ 打开测试页面;
若无法通过 esp32_chunk_resp.local 域名访问,说明当前设备或网络不支持 mDNS,请直接使用 IP 地址访问;
电脑或手机必须与 ESP32 连接同一路由器(处于同一局域网内);
HEX 模式输入必须为偶数长度的十六进制字符(0-9、A-F),支持空格分隔,例如"FA FB FC 01";
网页发送中文时使用 UTF-8 编码,串口监视器需支持 UTF-8 才能正常显示中文;
自动发送间隔不宜设置过小(建议不小于 10 ms),避免 HTTP 请求过于频繁导致 ESP32 来不及处理;
若网页无法打开,请检查电脑防火墙是否阻止了 80 端口的访问,并确认 ESP32 已正确连接 WiFi(串口会打印 Connected 信息)。
第六章 调试心得
通过本次项目的调试,收获主要有以下几点:
分层排查是定位问题的关键。先确认 WiFi 是否连接成功(串口打印 IP),再确认网页能否打开,最后验证数据下发与串口输出,逐层缩小问题范围,效率最高;
串口打印是最直接有效的调试手段。在关键环节加入 Serial.print 输出(如 WiFi 连接过程、接收到的参数内容等),可以快速判断程序运行状态;
HEX 数据处理要特别注意显示方式。最初将原始字节(Serial.write)与十六进制回显文本(Serial.printf)同时输出到同一串口,导致监视器中出现乱码交错;改为统一输出格式化十六进制文本后问题解决。这让我深刻体会到:调试信息是给人看的,必须保证其可读性;
网页与单片机通讯时,数据编码格式必须前后一致。URL 编码、UTF-8 字符集、HEX 格式校验,任何一环不一致都会导致数据错乱;
自动发送逻辑必须考虑完善的停止条件。在每次循环前检查停止标志与发送个数,保证用户随时可以中断,避免程序陷入失控状态;
良好的界面反馈能显著减少调试时间。网页日志区实时记录每条指令的发送结果与时间戳,使问题复现和定位更加高效。
我要赚赏金
