这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 嵌入式开发 » MCU » 【AtomS3R-M12】设计简易的网页时钟

共2条 1/1 1 跳转至页

【AtomS3R-M12】设计简易的网页时钟

高工
2026-11-05 20:46:50     打赏

前言:在官方 HelloServer 例程的基础上拓展为“网页时钟”——浏览器打开设备页面后,当前系统时间每秒刷新一次。

第一章 项目介绍

1.1 项目背景

ESP32-S3 自带 WiFi,Arduino-ESP32 核心包内置 WebServer 库,官方例程 HelloServer 演示了最小可用的 HTTP 服务器。本项目以该例程为骨架,拓展出一个有实际用途的小作品:网页时钟。设备连上家里或办公室的 2.4 GHz 热点后,自动通过 NTP(网络授时协议)把系统时钟校准为北京时间,并在 80 端口提供网页服务。同一局域网内的任何电脑或手机,用浏览器打开设备地址,就能看到每秒刷新一次的当前时间。

1.2 项目功能

自动连接指定 WiFi 热点(站模式),拿到局域网 IP 后打印到串口。通过 configTime() 启动 NTP 对时,把系统时钟校准到东八区。浏览器访问 http://esp32.local/(或设备 IP)可打开网页,标题为“ESP32S3R autor:keyboard”,页面上的时间每秒自动刷新。提供 /time 接口返回文本格式的当前时间,可供其他程序直接调用。未注册的任意路径返回 404 并回显请求信息,便于调试。

1.3 硬件与软件环境

硬件:M5 AtomS3R(ESP32-S3 主控),USB-C 供电与烧录。开发环境:Arduino IDE 2.x,esp32 核心 3.x(NetworkClient.h 仅存在于 3.x)。网络要求:2.4 GHz、WPA2 热点;NTP 需要热点能访问外网(UDP 123 端口)。

1.4 工程文件

03HelloServer.ino:全部源代码,含 WiFi 连接、NTP 对时、路由注册与四个处理函数。

第二章 设计思路

2.1 总体架构

整个系统只有三个角色:浏览器负责显示与定时拉取,ESP32 上的 HTTP 服务器负责应答,系统时钟负责提供时间。服务器内部是一张“网址 → 函数”的路由表,主循环不停地轮询有没有新请求,有就查表调用对应函数,把内容发回去。一句话概括:连 WiFi → NTP 校时 → 挂路由表 → 主循环轮询应答。没有多线程,也没有事件回调,只有“注册”和“轮询”两个动作。

2.2 关键技术选型

页面每秒刷新用“轮询”而不是推送:页面脚本用 setInterval 每秒 fetch 一次 /time。同步版 WebServer 一次只服务一个连接,轮询对它最友好;SSE/WebSocket 需要换异步库,对本项目属于过度设计。页面存 Flash 不占 RAM:整页 HTML 用 PROGMEM 声明,输出用 send_P 直接从 Flash 读,不产生中间拷贝。时间用 NTP 而不是开机计时:开机计时断电就归零,NTP 校准后显示的是真实时间,断电重启也能自动恢复。取时间设 20 ms 超时:服务器是单线程的,回调里绝不能无限等待,否则所有请求都会卡住。

2.3 核心机制说明

单线程轮询:handleClient() 只在被调用时处理一个连接,多浏览器同时访问会排队。本项目请求处理只有几毫秒,轮询完全够用。路径全等匹配:server.on("/time", …) 只匹配 /time 本身,不支持通配符。send 的三个动作:一次调用同时写完状态行、响应头与正文;每个回调分支必须恰好调用一次。delay(2):把 CPU 让给 FreeRTOS 调度器,避免看门狗复位。NTP 不阻塞:configTime() 只登记参数并启动后台任务,getLocalTime() 读的是本地 RTC,网络抖动不影响主循环。

2.4 数据流


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

3.1 程序流程图


3.2 软件代码编写步骤

从官方例程改到当前版本,实际按以下顺序进行,每步都可单独验证:跑通原版例程:改 SSID 与密码,烧录后确认串口打印 IP、浏览器能打开根路径。加内嵌网页:定义 INDEX_HTML(PROGMEM + 原始字符串),把 handleRoot 改为 send_P 输出页面,验证浏览器能看到标题。加时间接口:新增 handleTime(),先用 millis() 返回开机计时,验证 /time 有响应。接入 NTP:加 time.h,setup 里 configTime(8*3600, 0, …),handleTime 改用 getLocalTime + strftime,加“时间同步中”分支。页面每秒刷新:页面脚本里加 refresh() 与 setInterval(refresh, 1000),验证时间每秒变化。补全细节:页面失败分支显示“设备离线”,Content-Type 加 charset=utf-8 防中文乱码。全量回归:四条路由逐条验证,连续请求 50 次确认无重启。

第四章 程序代码

以下为完整源代码(03HelloServer.ino),已包含全部注释。

4.1 头文件与全局定义

#include <WiFi.h>          // WiFi 连接管理:模式设置、发起连接、查询状态与 IP
#include <NetworkClient.h> // 网络客户端抽象(core 3.x),WebServer 内部依赖它
#include <WebServer.h>     // HTTP 服务端:路由注册、请求解析、响应发送
#include <ESPmDNS.h>       // mDNS 域名解析,让 esp32.local 指向本机 IP
#include <time.h>          // struct tm、getLocalTime()、strftime() 等时间函数

// WiFi 凭据:必须是 2.4 GHz 热点(ESP32-S3 不支持 5 GHz)。
// 中文 SSID 依赖本文件以 UTF-8 编码保存,否则字节不对就连不上。
const char *ssid = "航天科宇";
const char *password = "88888888";

// HTTP 服务端对象,监听 80 端口。
// 内部维护一张“路径 → 处理函数”的路由表,靠 server.on() 往里登记。
WebServer server(80);

// 板载指示灯引脚:每次处理请求时闪一下,作为“服务器正在被访问”的直观反馈。
// 注意:AtomS3R 的 GPIO13 没有外接 LED 时看不到闪烁,属正常现象。
const int led = 13;

4.2 内嵌网页

4.3 三个处理函数

/*
 * 根路径处理:GET /
 * send_P 与 send 的区别:send_P 直接从 Flash 读取 PROGMEM 数据输出,
 * 不会先把整页拷进 RAM,适合这种较大的静态字符串。
 * Content-Type 必须带 charset=utf-8,否则浏览器里的中文标题会乱码。
 */
void handleRoot() {
  digitalWrite(led, 1);                                        // 请求开始:点亮 LED
  server.send_P(200, "text/html; charset=utf-8", INDEX_HTML);  // 200 + 整页 HTML
  digitalWrite(led, 0);                                        // 响应发出:熄灭 LED
}

/*
 * 时间接口:GET /time
 * getLocalTime(&t, 20):从本地 RTC 读取当前时间,最多等待 20 ms。
 *   - 返回 true:已同步,用 strftime 格式化为 YYYY-MM-DD HH:MM:SS 输出;
 *   - 返回 false:NTP 还没同步完成(上电头几秒常见),回“时间同步中”。
 * 设超时的原因:整个服务器是单线程的,回调里绝不能无限等待,
 * 否则这一等待期间所有其他请求都会卡住。
 */
void handleTime() {
  digitalWrite(led, 1);
  struct tm t;
  if (!getLocalTime(&t, 20)) {          // 20 ms 超时,避免长时间占住回调
    server.send(200, "text/plain; charset=utf-8", "时间同步中");
  } else {
    char buf[32];
    strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &t);
    server.send(200, "text/plain; charset=utf-8", buf);
  }
  digitalWrite(led, 0);
}

/*
 * 兜底处理:所有未注册的路径都会进到这里,返回 404。
 * 顺带把请求信息回显出来,方便调试:
 *   server.uri()     请求路径(不含查询串)
 *   server.method()  请求方法(GET/POST)
 *   server.args()    查询参数个数
 *   server.argName(i)/arg(i)  第 i 个参数的名字和值
 */
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);
}

4.4 setup 与 loop

/*
 * setup():上电后只执行一次,按固定顺序完成六件事。
 * 顺序不能乱:必须先连上 WiFi 才有 IP;必须注册了路由再 begin 监听。
 */
void setup(void) {
  pinMode(led, OUTPUT);          // LED 引脚设为输出
  digitalWrite(led, 0);          // 初始为熄灭
  Serial.begin(115200);          // 串口波特率 115200,串口监视器要设成一致

  // —— 第 1 步:以站模式(STA)连接热点 ——
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.println("");

  // 死等连接成功:每 500 ms 打印一个点。
  // 注意:这是官方例程的“教学写法”,没有超时。热点不在时程序会一直停在这里,
  // 实际项目应改成带超时/自动重连的状态机。
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  // —— 第 2 步:打印连接结果,拿到局域网 IP ——
  Serial.println("");
  Serial.print("Connected to ");
  Serial.println(ssid);
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());

  // —— 第 3 步:注册 mDNS,之后可以用 http://esp32.local/ 访问 ——
  if (MDNS.begin("esp32")) {
    Serial.println("MDNS responder started");
  }

  // —— 第 4 步:启动 NTP 对时 ——
  // 参数:UTC 偏移秒数(东八区 8*3600)、夏令时偏移(中国为 0)、
  // 最多三个授时服务器(任一可用即可)。
  // configTime 只登记参数并启动后台 SNTP 任务,立即返回、不阻塞。
  configTime(8 * 3600, 0, "ntp.aliyun.com", "ntp.tencent.com", "pool.ntp.org");
  Serial.println("NTP sync started");

  // —— 第 5 步:注册路由表(路径与处理函数的对应关系)——
  server.on("/", handleRoot);        // 首页:网页
  server.on("/time", handleTime);    // 时间接口
  server.on("/inline", []() {        // 官方例程保留的匿名函数演示
    server.send(200, "text/plain", "this works as well");
  });
  server.onNotFound(handleNotFound); // 未命中任何路由时的兜底处理

  // —— 第 6 步:开始监听,服务器正式对外服务 ——
  server.begin();
  Serial.println("HTTP server started");
}

/*
 * loop():主循环,被 Arduino 框架反复调用。
 * server.handleClient() 每执行一次就检查一次有没有新请求:
 * 有则解析、匹配路由、调用处理函数、发回响应;没有则立即返回。
 * delay(2) 把 CPU 让给 FreeRTOS 调度器,让 WiFi 任务和空闲任务得以运行,
 * 否则长时间运行可能触发看门狗复位。
 */
void loop(void) {
  server.handleClient();
  delay(2);  //allow the cpu to switch to other tasks
}

第五章 调试注意事项

5.1 烧录前的检查

esp32 核心必须是 3.x,否则编译报找不到 NetworkClient.h。热点必须 2.4 GHz、WPA2,且能访问外网;5 GHz 热点 ESP32-S3 搜不到。源文件以 UTF-8 保存,否则中文 SSID 与中文页面会变成错误字节。串口监视器设 115200,与 Serial.begin(115200) 一致。

5.2 烧录后的判据

串口应依次出现三行关键输出,缺一不可:IP address: 192.168.10.230NTP sync startedHTTP server started只有一串点号:没连上热点,检查 SSID、密码、频段,不要继续往下测。有 IP 但网页时间一直是“时间同步中”:热点无法访问外网 NTP,或 UDP 123 被拦。

5.3 路由验证

/:浏览器能看到标题“ESP32S3R autor:keyboard”,时间每秒变化。/time:直接返回文本时间;刚上电几秒内显示“时间同步中”属正常。/inline:返回 this works as well,验证匿名路由仍在。任意不存在路径:返回 404 并回显 URI、Method、Arguments。

5.4 常见坑

回调里忘写 send:浏览器一直转圈。每个分支必须恰好调用一次 send。中文乱码:Content-Type 没写 charset=utf-8。命令行里参数丢失:PowerShell 中 URL 要加双引号,否则 & 被当成分隔符。esp32.local 打不开:换浏览器验证;命令行常解析不了 mDNS。回调里加延时:单线程服务器会被拖死,等待逻辑必须挪出回调或设超时。改完代码忘了重新烧录:页面没变化时先确认固件版本。

第六章 调试心得

6.1 串口是最可靠的【第一现场】

一开始串口只打印点号,浏览器自然打不开。先盯住串口三行判据再测网页,能把“网络问题”和“代码问题”一次性分开,省去来回猜的时间。

6.2【时间同步中】这个分支救过场

最早没加未同步分支时,刚上电页面会显示一段错误时间,看起来像 bug。加上“时间同步中”后,现象变成了可解释的状态。教训:凡是有中间状态的异步过程,都要给一个明确的过渡输出。

6.3 单线程不是缺陷,是约束

曾尝试在回调里延时等 NTP 就绪,结果整个服务器卡死。接受“一次只服务一个请求”这个约束后,设计反而清晰了:回调快进快出,耗时的事挪出去。约束先于技巧。

6.4 轮询往往够用

一开始想用 WebSocket 做“服务器主动推送”,后来发现页面每秒 fetch 一次的开销对这台服务器微乎其微,实现还简单得多。先选能跑通的最简方案,把复杂度留给真正需要它的需求。

6.5 时间以设备为准

调试时手机和电脑时区不同,显示对不上,排查半天才发现页面时间来自设备而非浏览器。涉及到“两端各自有状态”的功能,要先明确以哪一端为准,再谈对不对。

6.6 小结

这个项目虽小,但把嵌入式 Web 开发的几个关键概念都走了一遍:路由表、轮询应答、Flash 页面、NTP 授时、单线程约束。把官方例程吃透再加一个真实功能,是性价比最高的练手路径。





关键词: AtomS3R-M12     网页时钟    

院士
2026-11-06 16:58:00     打赏
2楼

谢谢分享,学习了。


共2条 1/1 1 跳转至页

回复

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