简介:这是一套面向嵌入式初学者与STM32开发者的WiFi网络授时时钟完整工程源码,主控采用STM32F103C8T6,搭配ESP-12F WiFi模块、PCF8563时钟芯片、按键与OLED显示屏,可实现网络授时、天气与时间信息获取及本地显示,适合练手物联网时钟类项目。压缩包共101个文件,约217KB,以36个c源文件与39个h头文件为核心,另含8个汇编文件及uvprojx、uvoptx等Keil工程配置、hex烧录文件与bat脚本,覆盖串口调试、SysTick精准延时、ESP8266初始化与配网、API接口调用解析、OLED显示、时钟芯片读写、按键扫描及定时刷新等模块,其中定时器设定5分钟更新一次天气与时间数据。目前已有1330人学习下载。读者可据此理清WiFi配网与API解析流程,掌握I2C时序敏感接口的延时处理思路,并直接编译烧录验证,快速搭建属于自己的网络授时时钟。
1. 拆开一个 WiFi 授时时钟项目:从联网对时到天气显示的完整链路
拿到一个压缩包,名字叫「基于WiFi的网络授时时钟设计V1.0」,第一反应不是急着解压,而是先想清楚它到底解决什么问题。普通电子时钟最大的毛病就是走时不准,用久了每天差几秒,一个月下来能偏好几分钟,手动校准又麻烦。这个项目干的事很直接:让时钟自己连上 WiFi,从网络时间服务器拉取标准时间,顺带把天气信息也抓下来显示在屏幕上。适合谁?适合手上有 ESP8266 或 ESP32 开发板、想做一个桌面时钟的嵌入式初学者,也适合想研究 NTP 协议和 HTTP 数据解析的开发者。它不是一个玩具 Demo,而是一个能真正摆在桌上用的完整小系统。
2. 系统架构与硬件选型:主控、屏幕、供电怎么搭
2.1 主控芯片的选择逻辑
这个项目的核心是 WiFi 联网能力,所以主控必须自带无线模块。市面上常见的选择有两类:ESP8266 和 ESP32。ESP8266 便宜、生态成熟,做纯联网时钟绰绰有余;ESP32 贵一点,但双核、内存大、外设多,后续想加蓝牙或语音功能不用换板子。项目源码里大概率用的是 ESP8266,因为这类授时时钟的教程和开源方案绝大多数以它为基础。
我一般会先确认开发板的具体型号,因为不同模组的引脚定义不一样。比如 ESP-12E 和 ESP-01 的可用 GPIO 数量差很多,前者有十几个可用引脚,后者只有两三个。如果源码里用了 SPI 接口驱动屏幕,那基本可以确定是 ESP-12E 或 NodeMCU 这类引脚丰富的模组。
# 查看项目文件结构,先搞清楚源码组织方式 ls -la # 典型输出: # src/ -- 主程序目录 # lib/ -- 第三方库 # include/ -- 头文件 # platformio.ini -- PlatformIO 配置文件 # 或者 .ino 文件直接在根目录(Arduino IDE 风格)上面这个命令是拿到项目后的第一步。如果是 PlatformIO 工程,会有platformio.ini文件,里面记录了开发板型号、框架类型和依赖库;如果是 Arduino IDE 工程,通常是一个.ino主文件加若干.h头文件。两种结构的编译方式不同,后面会细说。
2.2 显示模块与驱动方式
时钟项目离不开屏幕。常见的搭配是 0.96 寸或 1.3 寸 OLED 屏,驱动芯片多为 SSD1306 或 SH1106,接口有 I2C 和 SPI 两种。I2C 只需要两根线,接线简单但刷新率低;SPI 需要四到六根线,接线麻烦但刷得快,适合需要频繁更新天气图标的场景。
源码里如果用到了Adafruit_SSD1306或U8g2这两个库,那基本就是 OLED 方案。U8g2 支持的种类更多,但内存占用也更大,ESP8266 上跑起来需要留意剩余内存。我见过不少人在这翻车——屏幕驱动库选得太重,加上 WiFi 和 NTP 客户端之后内存不够,程序跑几分钟就重启。
注意:ESP8266 的可用堆内存大约 40KB 左右,选库的时候要留余量。U8g2 用精简模式(只启用需要的字体和驱动)能省不少空间。
2.3 供电与外壳的工程考量
桌面时钟需要长期通电,供电稳定性直接影响使用体验。ESP8266 工作电流在 70mA 到 300mA 之间波动,WiFi 发射瞬间电流会冲高,如果用劣质 USB 线或充电头,电压跌落会导致重启。常见做法是选 5V/1A 以上的电源,配合板载的 AMS1117 稳压芯片降到 3.3V。
外壳方面,3D 打印是最灵活的方案,但要注意留出散热孔和屏幕开窗。如果用亚克力板拼接,记得给 USB 供电口留位置。这些细节源码里不会写,但实际做的时候绕不过去。
3. 核心代码拆解:NTP 对时、天气抓取与屏幕刷新
3.1 WiFi 连接与 NTP 时间同步
整个系统启动后的第一件事是连 WiFi。这段代码通常放在setup()函数里,逻辑不复杂但有几个容易忽略的点。
#include <ESP8266WiFi.h> #include <WiFiUdp.h> #include <NTPClient.h> const char* ssid = "你的WiFi名称"; const char* password = "你的WiFi密码"; WiFiUDP ntpUDP; // 使用阿里云 NTP 服务器,国内访问延迟低 NTPClient timeClient(ntpUDP, "ntp.aliyun.com", 8 * 3600, 60000); void setup() { Serial.begin(115200); WiFi.begin(ssid, password); // 等待连接,最多等 20 秒 unsigned long startTime = millis(); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); if (millis() - startTime > 20000) { Serial.println("WiFi连接超时,重启设备"); ESP.restart(); // 超时重启,避免死等 } } Serial.println("WiFi已连接"); timeClient.begin(); timeClient.update(); // 首次同步 }这段代码的关键参数有三个:NTP 服务器地址、时区偏移量、更新间隔。8 * 3600表示东八区,单位是秒;60000表示每 60 秒同步一次。有人会问为什么不设更短的间隔,因为 NTP 服务器有请求频率限制,太频繁会被拒绝响应。60 秒是个比较稳妥的值,时钟走时精度完全够用。
超时重启这个逻辑是我后来加的。早期版本里while循环没有退出条件,WiFi 连不上就永远卡在那,屏幕黑着,用户以为设备坏了。加上超时判断之后,至少能通过重启再试一次。
3.2 天气数据的 HTTP 请求与 JSON 解析
天气功能需要向天气 API 发 HTTP 请求,拿到 JSON 数据后解析出温度和天气状况。源码里大概率用的是ESP8266HTTPClient和ArduinoJson这两个库。
#include <ESP8266HTTPClient.h> #include <ArduinoJson.h> // 天气 API 地址,这里用的是一个示例接口 const String weatherUrl = "http://api.example.com/weather?city=beijing&key=你的KEY"; void fetchWeather() { HTTPClient http; http.begin(weatherUrl); int httpCode = http.GET(); if (httpCode == 200) { String payload = http.getString(); DynamicJsonDocument doc(1024); // 分配 1KB 内存存放 JSON DeserializationError error = deserializeJson(doc, payload); if (!error) { // 根据实际 API 返回结构提取字段 float temp = doc["data"]["temperature"].as<float>(); String weather = doc["data"]["weather"].as<String>(); Serial.printf("温度: %.1f°C, 天气: %s\n", temp, weather.c_str()); } else { Serial.println("JSON解析失败"); } } else { Serial.printf("HTTP请求失败, 状态码: %d\n", httpCode); } http.end(); // 必须释放连接,否则内存泄漏 }这里有几个实战中总结出来的点。第一,DynamicJsonDocument的大小要根据实际返回数据来定,1024 字节对大多数天气接口够用,但如果返回字段特别多,需要加大到 2048。第二,http.end()必须调用,不然每次请求都会占用一个连接,几次之后内存就爆了。第三,天气 API 的返回结构千差万别,源码里用的接口不一定还能用,需要根据实际接口文档调整字段路径。
我一般会先用浏览器或 Postman 请求一次 API,把返回的 JSON 看清楚,再写解析代码。直接照着源码抄字段路径,大概率会解析失败。
3.3 屏幕刷新与时间格式化
屏幕显示部分需要把 NTP 返回的 Unix 时间戳转换成可读的时分秒,再连同天气信息一起画到屏幕上。
#include <U8g2lib.h> // 使用 I2C 接口的 SSD1306 128x64 屏幕 U8g2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE); void displayTime() { timeClient.update(); unsigned long rawTime = timeClient.getEpochTime(); time_t t = rawTime; struct tm *timeInfo = localtime(&t); char timeStr[9]; strftime(timeStr, sizeof(timeStr), "%H:%M:%S", timeInfo); char dateStr[12]; strftime(dateStr, sizeof(dateStr), "%Y-%m-%d", timeInfo); u8g2.clearBuffer(); u8g2.setFont(u8g2_font_logisoso24_tn); // 大字体显示时间 u8g2.drawStr(10, 40, timeStr); u8g2.setFont(u8g2_font_6x10_tf); // 小字体显示日期 u8g2.drawStr(10, 58, dateStr); u8g2.sendBuffer(); }strftime的格式化字符串决定了显示样式,%H是 24 小时制,%I是 12 小时制。字体选择影响显示效果和内存占用,logisoso24这个字体比较大,显示时间很醒目,但会占用几 KB 的 Flash 空间。如果编译后提示空间不足,可以换成u8g2_font_helvB18_tn这类稍小的字体。
刷新频率方面,每秒刷一次屏幕就够了。有人设成 100ms 刷一次,结果屏幕闪烁严重,因为 I2C 的传输速度跟不上。u8g2.sendBuffer()是阻塞操作,刷新太快会拖慢主循环。
4. 编译烧录与运行验证:从源码到能用的时钟
4.1 开发环境搭建与依赖安装
这个项目有两种可能的工程结构,对应的编译方式不同。如果是 Arduino IDE 工程,需要手动安装依赖库;如果是 PlatformIO 工程,platformio.ini里已经声明了依赖,一条命令就能拉齐。
; platformio.ini 典型内容 [env:nodemcuv2] platform = espressif8266 board = nodemcuv2 framework = arduino lib_deps = olikraus/U8g2@^2.35.7 bblanchon/ArduinoJson@^6.21.3 arduino-libraries/NTPClient@^3.2.1 monitor_speed = 115200board字段要和实际开发板一致,nodemcuv2对应 NodeMCU 1.0 版本。lib_deps里列出了所有第三方库,PlatformIO 会自动下载。如果用的是 Arduino IDE,需要在库管理器里逐个搜索安装,注意版本号尽量和源码要求的一致,版本差异可能导致 API 不兼容。
# PlatformIO 编译和烧录命令 pio run # 编译 pio run --target upload # 编译并烧录 pio device monitor # 打开串口监视器烧录时如果提示端口占用,检查串口监视器是否还开着。ESP8266 烧录需要拉低 GPIO0 进入下载模式,NodeMCU 开发板上有自动下载电路,直接插 USB 就行;如果是裸模组,需要手动接线。
4.2 串口调试与常见启动日志解读
烧录完成后打开串口监视器,波特率设为 115200。正常启动会看到这样的日志:
# 正常启动日志示例 .......... WiFi已连接 IP地址: 192.168.1.100 NTP同步成功: 14:30:25 温度: 22.5°C, 天气: 晴如果卡在..........不动,说明 WiFi 连不上。先检查 SSID 和密码有没有写错,注意大小写和特殊字符。如果密码里有$或#这类符号,在 C++ 字符串里可能需要转义。另一个常见原因是路由器开了 MAC 地址过滤,把开发板的 MAC 地址加入白名单即可。
如果 NTP 同步失败,串口会打印超时信息。先确认 NTP 服务器地址能不能 ping 通,再检查防火墙有没有拦截 UDP 123 端口。有些公司网络会封掉这个端口,换成ntp.tencent.com或cn.pool.ntp.org试试。
4.3 屏幕不亮或显示异常的排查
屏幕完全不亮,先查接线。I2C 接口的 SDA 和 SCL 不能接反,VCC 和 GND 也不能搞错。用万用表量一下屏幕的供电电压,3.3V 和 5V 的屏幕不能混用,接错可能烧掉驱动芯片。
屏幕亮但显示乱码,通常是 I2C 地址不对。SSD1306 的默认地址是0x3C,但有些模块是0x3D。用 I2C 扫描程序确认一下实际地址:
#include <Wire.h> void setup() { Wire.begin(); Serial.begin(115200); Serial.println("I2C扫描中..."); for (byte addr = 1; addr < 127; addr++) { Wire.beginTransmission(addr); if (Wire.endTransmission() == 0) { Serial.printf("发现设备: 0x%02X\n", addr); } } }把扫描到的地址填到U8g2的构造函数里就行。如果扫描不到任何设备,检查上拉电阻,有些模块自带 4.7K 上拉,有些没有,需要外接。
5. 避坑与常见问题:那些源码里不会告诉你的细节
5.1 内存不足导致反复重启
现象:设备运行几分钟后自动重启,串口打印Fatal exception或rst cause:4。
原因:ESP8266 内存有限,WiFi 协议栈、NTP 客户端、JSON 解析库、屏幕缓冲区同时占用,剩余内存不足时触发看门狗复位。
解决:减少DynamicJsonDocument的大小,把不用的全局变量改成局部变量,屏幕刷新频率从每秒一次降到每两秒一次。用ESP.getFreeHeap()打印剩余内存,低于 10KB 就要警惕了。
5.2 NTP 时间偏差几小时
现象:时钟显示的时间比实际时间差 8 小时或差几分钟。
原因:时区偏移量设置错误,或者 NTP 服务器返回的是 UTC 时间没有做转换。
解决:确认NTPClient构造函数的第三个参数是8 * 3600(东八区)。如果差几分钟,检查更新间隔是不是设得太长,设备启动后第一次同步可能因为网络延迟有误差,等下一次同步就会修正。
5.3 天气数据一直不更新
现象:屏幕上的天气信息始终是初始值,串口没有报错但数据不变。
原因:天气 API 有请求频率限制,或者 API Key 过期了。
解决:先看串口有没有打印 HTTP 状态码,如果是 401 或 403,说明 Key 有问题;如果是 429,说明请求太频繁。把天气更新间隔从 60 秒改成 10 分钟,大多数免费 API 都能接受这个频率。
5.4 烧录失败提示「Failed to connect」
现象:pio run --target upload时报错,提示无法连接开发板。
原因:串口被占用、驱动没装、或者开发板没进入下载模式。
解决:关掉所有串口监视器和其他可能占用串口的软件。Windows 设备管理器里确认 CH340 或 CP2102 驱动正常。按住开发板上的 FLASH 键再点上传,等出现Connecting...再松开。
5.5 屏幕显示残影或闪烁
现象:屏幕上有淡淡的旧内容残留,或者刷新时明显闪烁。
原因:clearBuffer()没有调用,或者刷新频率太高导致 I2C 总线拥堵。
解决:确保每次绘制前调用u8g2.clearBuffer(),绘制完成后调用u8g2.sendBuffer()。刷新间隔不要低于 500ms,I2C 的默认速率是 100kHz,传一屏 128x64 的数据需要几十毫秒。
6. 二次开发方向:从能用到好用,几个改造思路
6.1 增加自动亮度调节
OLED 屏在晚上太亮,刺眼。加一个光敏电阻,根据环境光调整屏幕亮度。U8g2库支持setContrast()方法,值从 0 到 255。
#define LDR_PIN A0 void adjustBrightness() { int ldrValue = analogRead(LDR_PIN); // 读取光敏电阻值 // 将模拟值映射到对比度范围,晚上最低 20,白天最高 255 int contrast = map(ldrValue, 0, 1023, 20, 255); u8g2.setContrast(contrast); }光敏电阻接在 A0 引脚上,和固定电阻组成分压电路。map()函数把 0-1023 的模拟值映射到 20-255 的对比度范围。这个改动成本很低,但体验提升明显。
6.2 接入更多数据源
天气只是开始,还可以接入空气质量、室内温湿度(加 DHT22 传感器)、甚至股票指数。关键是把数据获取和显示逻辑解耦,每个数据源写一个独立的获取函数,主循环里按不同间隔调用。
struct DisplayData { String time; String date; float temperature; String weather; int aqi; // 新增空气质量 }; DisplayData currentData; void loop() { static unsigned long lastWeatherUpdate = 0; static unsigned long lastAqiUpdate = 0; displayTime(); // 每秒刷新时间 if (millis() - lastWeatherUpdate > 600000) { // 每 10 分钟更新天气 fetchWeather(); lastWeatherUpdate = millis(); } if (millis() - lastAqiUpdate > 1800000) { // 每 30 分钟更新空气质量 fetchAqi(); lastAqiUpdate = millis(); } }用结构体统一管理显示数据,屏幕刷新只读结构体,不关心数据从哪来。这样加新数据源的时候不用改显示代码,扩展性更好。
6.3 用 Web 配网替代硬编码
源码里 WiFi 账号密码是写死的,换网络就要重新烧录,很不方便。改成 Web 配网之后,设备首次启动时开一个热点,手机连上去填 WiFi 信息,保存到 Flash 里。
#include <WiFiManager.h> void setup() { WiFiManager wifiManager; // 连接失败时自动进入配网模式,热点名称为 "ClockAP" bool connected = wifiManager.autoConnect("ClockAP"); if (!connected) { Serial.println("配网失败,重启"); ESP.restart(); } Serial.println("配网成功"); }WiFiManager库会自动处理热点创建、页面渲染和凭据保存。第一次使用时用手机连上ClockAP这个热点,浏览器会自动弹出配置页面,填入 WiFi 名称和密码即可。之后每次启动都会自动连接,连不上才会再次进入配网模式。
6.4 验证改造效果的方法
每次改完代码,不要只看屏幕显示正常就完事。我一般会做三件事:第一,串口打印关键变量,确认数据流没问题;第二,用ESP.getFreeHeap()监控内存,确保没有持续下降;第三,让设备连续跑 24 小时,观察有没有重启或时间漂移。
有个习惯我保持了很长时间:每次改完 WiFi 相关代码,都会把路由器重启一次再测试。因为 ESP8266 在路由器重启后重连的逻辑容易出问题,提前发现比用户反馈再修要好。从那以后我每次做联网项目,都强制走一遍「断网重连」的测试流程。希望这些经验帮到你,少走一些弯路。
本文还有配套的精品资源,点击获取