这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 嵌入式开发 » MCU » 【AtomS3R-M12】基于ESP32的文本转换SVG网页服务器

共1条 1/1 1 跳转至页

【AtomS3R-M12】基于ESP32的文本转换SVG网页服务器

高工
2026-11-11 09:06:35     打赏

第一章 项目介绍:

(1)设备上电后自动连接指定 Wi-Fi,并以 STA 模式加入局域网。

(2)在设备上启动 HTTP 服务器,监听 80 端口,向浏览器提供网页与 SVG 图像。

(3)网页显示程序运行时间,格式为时、分、秒,并保证大约每秒更新一次。

(4)SVG 图形每秒重新生成并刷新,图形内容包含运行时间标注与随机折线。

(5)页面标题与正文一级标题统一为“文本转换SVG autor:keyboard”。

(6)在不增加额外硬件的前提下,保证刷新节奏稳定、页面不闪烁、浏览器不使用缓存旧图。

第二章 设计思路

2.1 总体架构

项目采用典型的浏览器与服务器架构,但这里的服务器并不是一台独立的计算机,而是运行在 ESP32 上的轻量级 HTTP 服务。设备上电后先完成 Wi-Fi 连接,再注册若干 URL 处理函数并启动服务器;浏览器通过串口打印出的 IP 地址访问首页,页面加载完成之后由页面内的 JavaScript 定时发起小请求,从设备获取最新的运行时间,同时刷新页面中的 SVG 图片。

整个系统只涉及三类资源:首页“/”返回一个包含标题、时间文本占位符和图片标签的 HTML 页面;“/uptime”返回纯文本形式的时、分、秒;“/test.svg”返回动态生成的 SVG 图形。三者职责清晰,互不耦合,既方便调试,也方便后续替换其中的任意一部分。

2.2 页面与 SVG 生成方案

首页 HTML 没有使用外部文件或文件系统,而是直接在 handleRoot 函数中用 snprintf 拼接成一个字符缓冲区后一次性发送。这样做省去了文件系统的挂载流程,减少了 Flash 占用,也避免了文件系统损坏带来的启动异常;代价是页面规模不能太大,因此本项目把样式与脚本都压缩到最短。

SVG 图形由 drawGraph 函数用 String 逐行拼接生成。图形尺寸固定为 400 像素宽、150 像素高,最底层是一个浅色矩形作为背景,中间用一组随机折线模拟波形,最后叠加一行运行时间文字。由于每一条线段的纵坐标都通过 rand() 随机产生,每次请求得到的图形都不相同,这种每次都不一样的视觉效果正好可以用来肉眼确认图像确实在刷新,而不是浏览器缓存的旧图。

2.3 自动上传运行时间的设计

“自动上传”在这里指的是设备端按固定节奏把运行时间推送到浏览器,而不需要用户手动刷新页面。实现上经历了两个阶段。

第一阶段采用最直接的做法:在 HTML 头部写入 meta refresh,让浏览器每隔固定秒数重新加载整个页面。这个方案实现简单,但整页刷新意味着浏览器要重新请求页面、重新请求图片、重新解析页面结构,不仅存在明显闪烁,而且会给只支持单客户端的 ESP32 WebServer 带来额外的连接压力。

第二阶段改成局部轮询:页面只加载一次,页面内用 setInterval 注册一个 1000 毫秒的定时器。每次触发时做两件事,一是用 fetch 请求 /uptime 接口,把返回的时间字符串写入页面中的 span 元素;二是把图片元素的 src 重新设置为 /test.svg 并附加当前时间戳。这样既没有整页刷新带来的闪烁,也把每秒的请求量控制在两个很小的 GET 请求之内。

2.4 缓存控制策略

浏览器默认会对页面与图片做缓存,如果不做处理,SVG 图片很可能一直显示第一次请求的结果,看起来就像刷新失效。为此项目在两个层面同时处理:服务端在页面与 SVG 的响应中都加入 Cache-Control: no-cache, no-store, must-revalidate 响应头,明确告知浏览器不要复用缓存;客户端在拼接图片地址时追加 t 参数,取值是当前毫秒时间戳,使每次请求的 URL 都不相同,从机制上绕开任何中间缓存。双重保险之后,即使更换浏览器或经过代理,也能保证看到的永远是最新生成的图形。

2.5 六秒刷新问题的定位与分析

改造初期曾出现一个奇怪的现象:代码里写入的刷新间隔明明是一秒,实际观察却发现页面大约每六秒才更新一次。排查思路是先把现象量化,再回到框架源码中寻找与等待时间有关的常量。最终在 ESP32 Arduino Core 的 WebServer 实现中找到了原因:这个 HTTP 服务器同一时刻只服务一个客户端,并且在下列两种情况下会一直等待,最长等待时间由常量决定。客户端已经建立连接但请求数据尚未到达时,最多等待 HTTP_MAX_DATA_WAIT,即 5000 毫秒;响应发送完毕等待客户端关闭连接时,最多等待 HTTP_MAX_CLOSE_WAIT,同样是 5000 毫秒。

浏览器在使用 meta refresh 整页刷新时,会额外建立预连接或复用一个即将关闭的连接。ESP32 一旦接受了一条暂时没有数据的连接,就会在这条连接上阻塞约五秒,真正需要响应的刷新请求只能排在后面,于是“一秒刷新间隔”叠加“五秒阻塞”就变成了肉眼看到的约六秒一次。

定位结论明确了改进方向:不要依赖整页刷新,改用页面内定时轮询,把连接次数降下来并让每条连接保持短命;同时关闭 Wi-Fi 省电模式以降低响应延迟。改造完成后,刷新节奏恢复为稳定的每秒一次。

2.6 网络与稳定性设计

ESP32 默认开启 Wi-Fi 省电模式,射频会在空闲时进入休眠,唤醒需要额外时间,表现为偶发的请求延迟。项目中在启动网络之前调用 WiFi.setSleep(false) 关闭省电,牺牲少量功耗换取稳定的响应速度,这对插电运行的展示类设备是完全值得的。

此外,页面建议通过串口打印的 IP 地址访问,而不是通过设备名 esp32.local 访问。域名解析在 Windows 上偶尔会引入额外的解析延迟,直接使用 IP 可以排除这一变量,也便于把问题定位在服务端还是网络侧。

第三章 程序流程图与代码编写步骤

3.1 程序总体流程图

上电
  ↓
setup(): 初始化 LED、串口、Wi-Fi
  ↓
连接 Wi-Fi(阻塞等待)
  ↓
启动 mDNS(esp32.local)
  ↓
注册路由 → 启动 HTTP 服务器
  ↓
loop(): 持续处理客户端请求
  ├─ GET /          → 返回 HTML 页面(含运行时间 + SVG 图)
  ├─ GET /test.svg  → 返回随机生成的 SVG 折线图
  ├─ GET /inline    → 返回纯文本 "this works as well"
  └─ 其他路径       → 返回 404 及请求详情

3.2 请求处理流程图

浏览器发起请求
  │
  ▼
服务器解析请求行与请求头
  │
  ▼
按 URI 匹配注册的处理函数
  ├── "/"         → handleRoot:拼装 HTML 页面,写入时间初值与图片标签
  ├── "/uptime"   → handleUptime:返回时、分、秒纯文本
  ├── "/test.svg" → drawGraph:生成随机折线 SVG,并叠加运行时间文字
  └── 其他 URI    → handleNotFound:返回 404 提示页面
  │
  ▼
浏览器用 JavaScript 每秒请求一次,更新文本并刷新图片

3.3 主要函数职责

(1)formatUptime:基于 millis() 计算运行时长,换算成时、分、秒并格式化为两位数字,供首页、时间接口与 SVG 三处复用,是全项目唯一的时间格式化入口。

(2)handleRoot:拼装首页 HTML,内容包括标题“文本转换SVG autor:keyboard”、运行时间占位元素、SVG 图片标签以及每秒轮询的脚本,最后以 UTF-8 编码发送。

(3)handleUptime:以纯文本形式返回当前运行时间,并附加禁止缓存的响应头,供页面脚本轮询使用。

(4)drawGraph:动态生成 400 像素宽、150 像素高的 SVG 图形,包含背景矩形、随机折线以及运行时间文字,返回时声明 image/svg+xml 类型。

(5)handleNotFound:对未注册的 URI 返回 404 提示,同时把请求方法、参数等信息回显出来,便于调试。

(6)setup:完成引脚初始化、串口初始化、Wi-Fi 连接、mDNS 启动、路由注册与服务器启动。

(7)loop:主循环中持续调用 server.handleClient(),并用极短的 delay 让出 CPU,保证系统任务可以正常调度。

3.4 代码编写步骤

(1)新建工程:在 Arduino IDE 中新建名为 04AdvancedWebServer 的工程,开发板选择 M5Stack AtomS3R 或通用 ESP32-S3 型号,串口波特率设为 115200。

(2)引入头文件:包含 WiFi.h、NetworkClient.h、WebServer.h 与 ESPmDNS.h,并声明监听 80 端口的 WebServer 对象。

(3)配置网络:修改 ssid 与 password 两个常量,填入现场可用的 2.4GHz Wi-Fi 名称与密码,注意 ESP32 不支持 5GHz 频段。

(4)编写时间格式化函数:新增 formatUptime,用 millis() 换算时、分、秒并格式化为两位数字,后续三处调用统一走该函数。

(5)编写首页处理函数:用 snprintf 拼出完整 HTML,包含页面标题、时间 span、带 id 的图片标签以及每秒轮询的 JavaScript 片段。

(6)编写时间接口:handleUptime 返回纯文本时间,并带上禁止缓存的响应头,返回类型声明为 text/plain,字符集声明为 utf-8。

(7)编写图形处理函数:drawGraph 用 String 逐条拼接 SVG 元素,随机生成折线并叠加运行时间文字,返回时带上禁止缓存的响应头。

(8)注册路由并启动服务器:在 setup 中依次注册 /、/uptime、/test.svg 与 404 处理函数,随后调用 server.begin() 启动服务。

(9)编写主循环:loop 中不断调用 server.handleClient(),并保留极短的 delay 以让出 CPU。

(10)编译烧录并验证:编译无误后烧录,打开串口监视器确认 IP 地址,用浏览器访问并观察时间与图形是否稳定地每秒变化。

第四章 程序代码

/*
   Copyright (c) 2015, Majenko Technologies
   All rights reserved.

   Redistribution and use in source and binary forms, with or without modification,
   are permitted provided that the following conditions are met:

 * * Redistributions of source code must retain the above copyright notice, this
     list of conditions and the following disclaimer.

 * * Redistributions in binary form must reproduce the above copyright notice, this
     list of conditions and the following disclaimer in the documentation and/or
     other materials provided with the distribution.

 * * Neither the name of Majenko Technologies nor the names of its
     contributors may be used to endorse or promote products derived from
     this software without specific prior written permission.

   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
   ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
   WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
   DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
   ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
   (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
   LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
   ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
   (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
   SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

#include <WiFi.h>
#include <NetworkClient.h>
#include <WebServer.h>
#include <ESPmDNS.h>

const char *ssid = "航天科宇";
const char *password = "88888888";

WebServer server(80);

const int led = 13;

void formatUptime(char *buf, size_t len) {
  unsigned long sec = millis() / 1000;
  int hr = sec / 3600;
  int min = (sec / 60) % 60;
  int s = sec % 60;
  snprintf(buf, len, "%02d:%02d:%02d", hr, min, s);
}

void handleRoot() {
  digitalWrite(led, 1);
  char up[16];
  char temp[1200];
  formatUptime(up, sizeof(up));

  snprintf(
    temp, sizeof(temp),

    "<html>\
  <head>\
    <meta charset='utf-8'/>\
    <title>文本转换SVG autor:keyboard</title>\
    <style>\
      body { background-color: #cccccc; font-family: Arial, Helvetica, Sans-Serif; Color: #000088; }\
    </style>\
  </head>\
  <body>\
    <h1>文本转换SVG autor:keyboard</h1>\
    <p>程序运行时间: <span id='up'>%s</span></p>\
    <img id='svg' src='/test.svg?t=0' width='400' height='150' />\
    <script>\
      setInterval(function () {\
        fetch('/uptime').then(function (r) { return r.text(); }).then(function (t) {\
          document.getElementById('up').textContent = t;\
        }).catch(function () {});\
        document.getElementById('svg').src = '/test.svg?t=' + Date.now();\
      }, 1000);\
    </script>\
  </body>\
</html>",

    up
  );
  server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");
  server.send(200, "text/html; charset=utf-8", temp);
  digitalWrite(led, 0);
}

void handleUptime() {
  digitalWrite(led, 1);
  char up[16];
  formatUptime(up, sizeof(up));
  server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");
  server.send(200, "text/plain; charset=utf-8", up);
  digitalWrite(led, 0);
}

void handleNotFound() {
  digitalWrite(led, 1);
  String message = "File Not Found\n\n";
  message += "URI: ";
  message += server.uri();
  message += "\nMethod: ";
  message += (server.method() == HTTP_GET) ? "GET" : "POST";
  message += "\nArguments: ";
  message += server.args();
  message += "\n";

  for (uint8_t i = 0; i < server.args(); i++) {
    message += " " + server.argName(i) + ": " + server.arg(i) + "\n";
  }

  server.send(404, "text/plain", message);
  digitalWrite(led, 0);
}

void setup(void) {
  pinMode(led, OUTPUT);
  digitalWrite(led, 0);
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);  //关闭省电模式,降低响应延迟
  WiFi.begin(ssid, password);
  Serial.println("");

  // Wait for connection
  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());

  if (MDNS.begin("esp32")) {
    Serial.println("MDNS responder started");
  }

  server.on("/", handleRoot);
  server.on("/uptime", handleUptime);
  server.on("/test.svg", drawGraph);
  server.on("/inline", []() {
    server.send(200, "text/plain", "this works as well");
  });
  server.onNotFound(handleNotFound);
  server.begin();
  Serial.println("HTTP server started");
}

void loop(void) {
  server.handleClient();
  delay(2);  //allow the cpu to switch to other tasks
}

void drawGraph() {
  String out = "";
  char temp[100];
  char label[80];
  char up[16];
  formatUptime(up, sizeof(up));

  out += "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" width=\"400\" height=\"150\">\n";
  out += "<rect width=\"400\" height=\"150\" fill=\"rgb(250, 230, 210)\" stroke-width=\"1\" stroke=\"rgb(0, 0, 0)\" />\n";
  out += "<g stroke=\"black\">\n";
  int y = rand() % 130;
  for (int x = 10; x < 390; x += 10) {
    int y2 = rand() % 130;
    sprintf(temp, "<line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke-width=\"1\" />\n", x, 140 - y, x + 10, 140 - y2);
    out += temp;
    y = y2;
  }
  out += "</g>\n";
  snprintf(label, sizeof(label), "<text x=\"10\" y=\"20\" font-size=\"14\" fill=\"black\">Uptime: %s</text>\n", up);
  out += label;
  out += "</svg>\n";

  server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");
  server.send(200, "image/svg+xml; charset=utf-8", out);
}

4.1 代码结构说明

整个工程只有一个源文件,按职责可以分成四个部分:头文件与全局对象、时间与页面处理函数、网络初始化与路由注册、主循环与图形绘制函数。为了便于阅读,代码中为每一处关键逻辑都写了简短注释。

(1)头文件与全局对象:引入 Wi-Fi、客户端、Web 服务器与 mDNS 四个库,声明监听 80 端口的 WebServer 对象,同时保留 LED 引脚定义,用于指示请求处理状态。

(2)时间处理:formatUptime 是唯一的时间格式化入口,内部基于 millis() 计算,避免多处重复实现导致格式不一致。

(3)页面与接口:handleRoot 生成首页,handleUptime 返回时间文本,两者都设置 Cache-Control 响应头。

(4)图形与主循环:drawGraph 动态生成 SVG 图形,loop 中调用 handleClient 处理请求。

05-1 软件调试界面.png

05-2 网页端.png

第五章 调试注意事项

(1)Wi-Fi 频段限制:ESP32 只支持 2.4GHz 频段,若路由器只开启 5GHz,或双频合一策略把设备引导到 5GHz 频段,设备会一直停留在连接阶段,串口持续打印点号。遇到这种情况应先在路由器上确认存在独立的 2.4GHz 热点。

(2)账号密码:ssid 与 password 必须替换为现场可用的值,密码中的特殊字符要完整保留,避免复制时被截断或误加空格。

(3)串口输出:烧录后务必打开串口监视器并将波特率设为 115200,只有确认打印出 IP 地址,才说明网络与服务器都已经就绪。

(4)访问地址:优先使用串口打印的 IP 地址访问,尽量避免使用 esp32.local,以排除域名解析延迟对刷新节奏的干扰。

(5)浏览器缓存:如果发现时间在变但图形不变,说明图片被缓存,应检查响应头是否包含 no-cache,以及图片地址是否携带了时间戳参数。

(6)单客户端限制:ESP32 的 WebServer 同一时刻只能处理一个客户端,尽量不要在同一页面同时发起大量并发请求,也不要在电脑上同时开多个标签页盯同一个设备,否则容易出现排队等待。

(7)五秒阻塞现象:若再次出现刷新间隔明显大于设定值的情况,应首先怀疑浏览器建立的无数据连接占用了服务器,可参考 WebServer.h 中的 HTTP_MAX_DATA_WAIT 与 HTTP_MAX_CLOSE_WAIT 两个常量进行分析,而不是盲目调整刷新间隔。

(8)缓冲区长度:snprintf 的缓冲区大小要留足余量,页面长度增加时要同步调大,否则内容会被截断,而且不一定有明显报错。

(9)中文编码:源文件需以 UTF-8 保存,响应头需声明 charset=utf-8,页面头部也要有 meta charset 声明,三者缺一都可能导致中文乱码。

(10)LED 指示:示例代码中的 LED 引脚按通用开发板写成 13,M5AtomS3R 的板载彩色灯由 WS2812 驱动,普通的 digitalWrite 不会点亮,需要按实际板型改为对应驱动方式,或直接把该功能视为占位。

(11)长时间运行:millis() 在约 49.7 天后会溢出归零,本项目每秒刷新一次,长时间运行后时间会重新从零开始计数,若需要长期运行应改用 RTC 或网络对时。

(12)随机数:rand() 未显式播种时序列固定,每次上电的折线形状会重复,若希望上电后图形不同,可在 setup 中调用 randomSeed 播种。

六:后记:

本项目以极小的代码量完成了一个具备实用价值的嵌入式 Web 展示功能:设备充当服务器,浏览器充当显示器,运行时间与图形每秒更新一次,刷新节奏稳定、内容不带缓存、中文显示正常。通过对六秒刷新问题的定位与改造,也加深了对 ESP32 WebServer 工作方式、HTTP 缓存机制与浏览器连接行为的理解。这些经验可以直接复用到后续的物联网监控类项目中。

代码如下:

04AdvancedWebServer.zip




关键词: AtomS3R-M12     文本     网页    

共1条 1/1 1 跳转至页

回复

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