这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 嵌入式开发 » MCU » 【AtomS3R-M12】网页端与ESP32串口通讯测试

共1条 1/1 1 跳转至页

【AtomS3R-M12】网页端与ESP32串口通讯测试

高工
2026-11-11 10:07:34     打赏

第一章 项目介绍

本项目基于 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

06-1 调试截图.png


第五章 调试注意事项

烧录前先将代码中的 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 格式校验,任何一环不一致都会导致数据错乱;

自动发送逻辑必须考虑完善的停止条件。在每次循环前检查停止标志与发送个数,保证用户随时可以中断,避免程序陷入失控状态;

良好的界面反馈能显著减少调试时间。网页日志区实时记录每条指令的发送结果与时间戳,使问题复现和定位更加高效。





关键词: AtomS3R-M12     网页     通讯    

共1条 1/1 1 跳转至页

回复

匿名不能发帖!请先 [ 登陆 注册 ]