简介:基于STM32F103C8T6单片机打造的智能万年历设计,涵盖OLED屏显示年月日时分秒、闰年自动调整,以及DS18B20与DHT11实时温湿度采集,面向单片机课程设计、毕业设计及物联网入门学习者,可直接作为数字时钟类项目的参考模板。压缩包共88个文件,约1.77MB,以源码(.c/.h)、Keil工程文件(.uvprojx/.uvoptx)为主,辅以原理接线图(.pdsprj)、设计说明文档(.docx)及编译生成的hex文件,通过Keil MDK可直接打开编译烧录,目录区分系统、硬件驱动与标准库,便于快速定位与二次开发。目前已有581人学习下载。内容包含完整的OLED显示驱动、RTC实时时钟配置、温湿度传感器读取及蜂鸣器/LED提示逻辑,配套文档说明接线和设计思路,能够帮助读者从原理到代码完整落地一个多功能桌面时钟,也便于在现有框架上增加闹钟、日期提醒等扩展功能,适合入门级STM32开发者借鉴与扩展。
1. 一个 20 元成本、能随手放床头的 C8T6 万年历
把 stm32f103c8t6 最小系统板、0.96 寸 OLED 和一个 DHT11 拼在一起,就能得到一个不只显示时间的小设备:年月日、时分秒、闰年自动调整、温湿度实时刷新,全部由这枚蓝板子上的 Cortex-M3 完成。它比点灯进阶,比跑 RTOS 简单,正好落在“驱动 I2C 屏幕、读取单总线传感器、维护一套日期换算”三个嵌入式基本功的交点上。标题里最容易被低估的是“闰年自动调整”:STM32F103 内部 RTC 本身只做秒计数,能不能把 2 月自动补成 29 天,取决于你写的日期换算代码,而不是芯片“自动”替你完成。这篇文章按一个课程设计和桌面实用小设备的常见做法,把硬件连接、日历算法、OLED 显示、DHT11 采样和串口校准拆开讲清楚,适合用标准库或 HAL 库做 C8T6 项目的读者照着排错和改功能。
2. 硬件连接:最小系统板引脚功能、OLED 接线与 32.768kHz 晶振
2.1 先用内部 RTC 还是外部 RTC 芯片
万年历类项目通常有两套做法。第一套用 STM32F103C8T6 内部 RTC,外接 32.768kHz 晶振到 PC14/PC15,时间以秒计数器的形式存在备份域里;第二套外挂 DS1302 或 DS3231,由芯片自己完成月份天数和闰年维护。标题里“闰年自动调整”这六个字,两种方案都能满足,但源码里实现的位置完全不同。
常见课程设计和入门项目的源码多数走第一套,因为 C8T6 内部 RTC 不需要额外买芯片,BKP 寄存器还能在复位后保存年份和校准值。缺点也很明显:LSE 晶振如果没焊或者负载电容不对,秒计数会明显偏快或偏慢。如果你只是想要一个到手就能用的桌面万年历,DS3231 带温度补偿,精度更高,但代码里就看不到闰年判断逻辑了。下面按内部 RTC 方案展开,这也是阅读“软件源码+文档说明”这一类压缩包时最需要先弄清的部分。
2.2 引脚映射:从 C8T6 最小系统板引出哪些脚
C8T6 的引脚功能要从最小系统板原理图确认,因为不同板子的丝印和默认跳线有差异。OLED 走 I2C1,默认引脚是 PB6(SCL)和 PB7(SDA);DHT11 的单总线数据脚选 PA1;调试串口用 USART1,PA9 发送、PA10 接收;LSE 晶振必须接在 PC14 和 PC15 上,这两个引脚不能当普通 GPIO 用。
| 外设信号 | STM32F103C8T6 引脚 | 说明 |
|---|---|---|
| OLED SCL | PB6 | I2C1_SCL,复用开漏 |
| OLED SDA | PB7 | I2C1_SDA,复用开漏 |
| DHT11 DATA | PA1 | 单向数据线,需 4.7kΩ~10kΩ 上拉 |
| DHT11 VCC/GND | 3.3V / GND | 模块一般带稳压和上拉,可不外接 |
| 32.768kHz 晶振 | PC14 / PC15 | LSE 输入输出,负载电容 6~12.5pF |
| USART1 TX/RX | PA9 / PA10 | 串口打印和校时 |
在代码里把这些引脚定义抽成宏,后面换引脚只改一个文件。我一般会单独建一个bsp_pin.h,像这样:
#define OLED_SCL_PORT GPIOB #define OLED_SCL_PIN GPIO_Pin_6 #define OLED_SDA_PORT GPIOB #define OLED_SDA_PIN GPIO_Pin_7 #define DHT11_PORT GPIOA #define DHT11_PIN GPIO_Pin_1 #define RTC_LSE_PORT GPIOC #define RTC_LSE_PIN GPIO_Pin_14 | GPIO_Pin_15引脚宏的好处是 OLED 换到 PB8/PB9、DHT11 换到 PA0 时,只动这里。注意 DHT11 的 DATA 脚如果换成 PA1,不要和 BOOT0 跳线、板载 LED 等最小系统板上已被占用的引脚冲突。
2.3 VDDA、VBAT 与掉电走时
C8T6 的 RTC 和 BKP 由 VBAT 引脚供电。最小系统板正常用 USB 的 3.3V 供电时,VBAT 可以接 3.3V;想要拔掉 USB 后时间继续走,就需要给 VBAT 接一个 CR1220 纽扣电池座。没有电池时,断电超过几十毫秒,RTC 计数器就归零了。
提示:很多市面上的 stm32f103c8t6 最小系统板并没有焊接 32.768kHz 晶振,只有 8MHz 主晶振。如果板子上找不到 PC14/PC15 旁边的圆柱晶振,就要自己买一个 32.768kHz 表贴晶振焊上去,否则内部 RTC 只能退而求其次用 LSI,走时误差可能到每天数十秒。
3. 万年历的核心:闰年判断、天数表和 RTC 秒计数换算
3.1 RTC 只做一件事:数秒
STM32F103 内部 RTC 本质上是一个 32 位秒计数器,配合预分频器把 LSE 的 32768Hz 分频成 1Hz。它不区分大小月,也不知道哪年是闰年。所以“闰年自动调整功能”全部要由你自己的代码周期性地把秒计数翻译成年月日时分秒,再交给 OLED。
反过来写时间也是同样流程:把年月日时分秒换算成一个自 2000 年 1 月 1 日 00:00:00 起算的绝对秒数,写入 RTC 计数器。只要换算函数正确,跨月、跨年、闰年这些边界条件就自然成立,不需要写一堆if (month == 2)的硬编码分支。
3.2 闰年判断:一个表达式说清楚规则
公历闰年规则是“四年一闰,百年不闰,四百年再闰”。C8T6 内部 RTC 项目一般只处理 2000 到 2099 年,这个区间内只有 2000、2004、2008 这些年份符合“被 4 整除”的简单规则,但把完整判断写出来,代码才具备通用性:
uint8_t IsLeapYear(uint16_t year) { if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) { return 1; } return 0; }参数year是四位公历年份,返回 1 代表闰年,返回 0 代表平年。注意 2100 年能被 4 整除但不能被 400 整除,所以不是闰年;如果你用内部 RTC 方案,年份建议限制在 2000~2099,否则 2100 年 2 月会是 29 天。外接 DS3231 时这个边界由芯片硬件处理,软件就不用操心。
有了闰年判断,再维护一张平年每月天数表:
const uint8_t s_monthDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};闰年时把s_monthDays[1]从 28 改成 29 即可。
3.3 从秒计数到年月日:可抄的完整换算函数
RTC 计数器每天增加 86400。换算思路是:先取出小时、分钟、秒;再用剩余天数逐年减,得到年份;最后逐月减,得到月份和日期。下面这个函数是万年历源码里最值得逐行看的部分:
void SecondsToDateTime(uint32_t sec, uint16_t *year, uint8_t *month, uint8_t *day, uint8_t *hour, uint8_t *minute, uint8_t *second) { const uint8_t monthDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; uint8_t mDays[12]; uint32_t days = sec / 86400; *second = sec % 60; *minute = (sec / 60) % 60; *hour = (sec / 3600) % 24; uint16_t y = 2000; while (days >= (IsLeapYear(y) ? 366 : 365)) { days -= IsLeapYear(y) ? 366 : 365; y++; } *year = y; for (uint8_t i = 0; i < 12; i++) { mDays[i] = monthDays[i]; } if (IsLeapYear(y)) { mDays[1] = 29; } uint8_t m = 1; while (days >= mDays[m - 1]) { days -= mDays[m - 1]; m++; } *month = m; *day = days + 1; }这段代码先把秒拆成时分秒,再对剩余天数循环减整年。sec / 86400得到自 2000 年 1 月 1 日以来的天数;days >= 366或365的判断会跨过闰年,2 月 29 日的出现与否完全由IsLeapYear决定。最后逐月减天数时,把闰年 2 月补成 29 天。这个函数每秒钟调用一次,消耗的时间在微秒级,C8T6 完全跑得动。
反向换算写时间时用下面这个函数,结构对称:
uint32_t DateTimeToSeconds(uint16_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second) { uint32_t days = 0; const uint8_t monthDays[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; for (uint16_t y = 2000; y < year; y++) { days += IsLeapYear(y) ? 366 : 365; } for (uint8_t m = 1; m < month; m++) { if (m == 2 && IsLeapYear(year)) days += 29; else days += monthDays[m - 1]; } days += day - 1; return days * 86400 + hour * 3600 + minute * 60 + second; }3.4 RTC 初始化:先开备份域写保护,再配 LSE
C8T6 的 RTC 和备份寄存器在同一电源域,初始化时必须先打开备份域访问权限,否则写入无效。以标准外设库为例,初始化流程如下:
void RTC_Init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); // 允许访问备份域 RCC_LSEConfig(RCC_LSE_ON); // 开启 32.768kHz LSE while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET); // 等待晶振稳定 RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); // RTC 时钟源选 LSE RCC_RTCCLKCmd(ENABLE); // 使能 RTC 时钟 RTC_WaitForSynchro(); // 等待 APB1 与 RTC 同步 RTC_SetPrescaler(32768 - 1); // 32768Hz 分频到 1Hz RTC_SetCounter(DateTimeToSeconds(2026, 1, 1, 0, 0, 0)); }RTC_SetPrescaler(32767)是关键参数:LSE 是 32768Hz,预分频值写成 32768-1,计数器才每秒加 1。RTC_SetCounter写入基准时间,之后每次调用RTC_GetCounter()就能读出当前时刻的绝对秒数。如果板子上没有 LSE 晶振,RCC_GetFlagStatus会一直停在 RESET,程序卡死在while里;实际项目都要给这个等待加超时计数。
注意:标准库例程里经常看到
RCC_LSEConfig(RCC_LSE_ON)后不判断是否起振,直接配置时钟,这在晶振没焊时会让 RTC 永远不走。调试时先用示波器或逻辑分析仪看 PC14 引脚有没有 32768Hz 波形,比改代码更快。
3.5 主循环里的时间刷新周期
主循环每秒钟读一次RTC_GetCounter(),调用SecondsToDateTime,把结果放进一个全局结构体供 OLED 用。不要在主循环里直接用delay_ms(1000)制造周期,这样按键扫描和温湿度采集都会卡住。常见做法是用系统滴答定时器做一个 1ms 基础时基,累计到 1000 执行一次时间刷新,到 2000 执行一次 DHT11 读取。
4. OLED 显示:SSD1306 驱动、中文取模与四行布局
4.1 OLED 驱动用硬件 I2C 还是 GPIO 模拟
0.96 寸 128x64 OLED 的驱动芯片是 SSD1306,支持 I2C 和 SPI 两种接口。I2C 四针版本最简单,VCC、GND、SCL、SDA 四根线,I2C 地址默认 0x3C,极少数是 0x3D。标题里这类课程设计源码几乎都提供一份oled.c,底层有两种实现:标准外设库的硬件 I2C1,或者用 PB6/PB7 手动翻转引脚的模拟 I2C。
STM32F103 的硬件 I2C 有一个被吐槽多年的总线错误问题:总线忙标志复位不干净,容易卡死。很多老工程师宁可用 GPIO 模拟 I2C,代码透明,时序自己控制,也方便换成任意引脚。模拟 I2C 的启动信号函数是每个 OLED 驱动的基础:
static void I2C_Start(void) { OLED_SDA_HIGH(); OLED_SCL_HIGH(); delay_us(5); OLED_SDA_LOW(); delay_us(5); OLED_SCL_LOW(); }这个函数先让 SCL 和 SDA 都为高,再把 SDA 拉低,形成 I2C 起始条件。delay_us(5)保证时序满足 SSD1306 的建立时间要求。按键扫描和 OLED 刷新都跑在同一个 CPU 上时,模拟 I2C 要记得在每次传输前重新初始化 GPIO 方向,避免和 DHT11 的单总线切换互相干扰。
如果你用 HAL 库的工程,点亮 OLED 全流程里的初始化序列不需要改,只需要把底层的HAL_I2C_Mem_Write替换成上面这套 GPIO 模拟函数,或者反过来把模拟函数替换成HAL_I2C_Mem_Write(&hi2c1, 0x3C << 1, ...)。
4.2 万年历界面布局:哪一行放时间,哪一行放温湿度
128x64 的 OLED 按 8 像素一行分 8 页,16 像素高的字占用连续两页。常见的四行布局如下:
| 屏幕区域 | 显示内容 | 字体高度 |
|---|---|---|
| 第 0~1 行 | 日期,如 “2026-02-27” | 16 像素 |
| 第 2~3 行 | 时间,如 “12:30:00” | 24 像素 |
| 第 4~5 行 | 温度,如 “T:25C” | 16 像素 |
| 第 6~7 行 | 湿度,如 “H:48%” | 16 像素 |
时间用 24 像素大字体突出显示,日期和温湿度用 16 像素。OLED 显示中文字符需要取模,把“年月日时分温湿”这几个 16x16 汉字按列行式扫描生成数组,代码里用索引查表。ASCII 数字和冒号用 8x16 字体,占半个汉字宽度,排列更整齐。
4.3 刷新控制:固定字符只画一次,数字区域局部更新
整屏全部重绘会让 OLED 闪烁,而且 SSD1306 通过 I2C 写 1024 字节显存,128kbps 速率下整屏刷新要几十毫秒。万年历这种界面,布局恒定,只有数字在变,所以把背景文字在初始化时画一次,之后每次只更新对应坐标的数值区域。
char buf[20]; sprintf(buf, "%04d-%02d-%02d", rtc.year, rtc.month, rtc.day); OLED_ShowString(24, 0, buf, 16); // 第 0 页,日期 sprintf(buf, "%02d:%02d:%02d", rtc.hour, rtc.minute, rtc.sec); OLED_ShowString(16, 2, buf, 24); // 第 2 页,时间 sprintf(buf, "T:%dC", temp); OLED_ShowString(8, 4, buf, 16); // 第 4 页,温度 sprintf(buf, "H:%d%%", humi); OLED_ShowString(8, 6, buf, 16); // 第 6 页,湿度OLED_ShowString的前两个参数是起始坐标,第一个是横向像素位置,第二个是页地址。16 像素字体从第 0 页和第 2 页开始写,24 像素字体则从第 2 页连续写两块显存。每次刷新前先OLED_ClearArea(x, y, width, height)清掉旧数字,或者把变化区域填充成背景色,否则数字从 9 变 10 时,个位旁边会残留一个旧像素点。
5. 实时温湿度测量:DHT11 单总线时序与校验
5.1 DHT11 还是 DHT22:参数怎么选
DHT11 是最常见的温湿度传感器,单总线协议,一次读取返回 40 位数据。它的温度和湿度精度有限,温度 ±2°C,湿度 ±5%RH,采样周期最短 1 秒。标题里的“实时”对这个精度来说完全够用,因为万年历本身属于慢变化显示设备,1~2 秒刷新一次就是实时。
如果换成 DHT22,价格高一些,但湿度精度能到 ±2%RH,温度精度 ±0.5°C,而且同样走单总线协议,代码结构完全一致,只是读取结果的分辨率从 8 位变成 16 位。下面是两个传感器在协议层面上的关键差异:
| 对比项 | DHT11 | DHT22 |
|---|---|---|
| 温湿度数据位 | 各 8 位,整数 | 各 16 位,含小数 |
| 温度精度 | ±2°C | ±0.5°C |
| 湿度精度 | ±5%RH | ±2%RH |
| 最小采样间隔 | 1 秒 | 2 秒 |
| 数据帧格式 | 16bit 湿度 + 16bit 温度 + 8bit 校验 | 一致 |
5.2 单总线时序:起始、应答、40 位数据
DHT11 的数据线是开漏输出,外部有上拉电阻。主机先拉低至少 18ms,然后释放总线,传感器会拉低 80us 应答,再拉高 80us,随后连续输出 40 位数据。每一位以 50us 低电平开始,高电平持续 26~28us 表示 0,持续 70us 表示 1。判断位值的标准做法是在低电平结束后延时约 40us,再读引脚电平。
uint8_t DHT11_Read(uint8_t *humi, uint8_t *temp) { uint8_t data[5] = {0}; uint8_t i; DHT11_GPIO_Output(); GPIO_ResetBits(DHT11_PORT, DHT11_PIN); delay_ms(20); // 起始信号至少 18ms GPIO_SetBits(DHT11_PORT, DHT11_PIN); delay_us(30); // 释放总线,等待应答 DHT11_GPIO_Input(); if (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == SET) { return 0; // 没等到低电平,传感器异常 } while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == RESET); while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == SET); for (i = 0; i < 40; i++) { while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == RESET); delay_us(40); // 采样点 if (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == SET) { data[i / 8] |= (0x80 >> (i % 8)); } while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == SET); } if (data[0] + data[1] + data[2] + data[3] != data[4]) { return 0; // 校验失败,丢弃本次读数 } *humi = data[0]; *temp = data[2]; return 1; }delay_us(40)是判断数据 0 和数据 1 的采样点。数据 0 的高电平只持续 26~28us,40us 时已经回落到低电平;数据 1 的高电平持续 70us,40us 时仍然为高。这个延时用软件循环实现时,要注意编译器优化级别,最好用一个校准过的delay_us函数,否则采样点偏移会导致读出的数据全为 0xFF。每次读取后,data[4]是校验和,必须等于前四个字节之和的末 8 位。
5.3 GPIO 在输入输出之间切换的坑
DHT11 抢答结束后,主机要把 DATA 引脚从推挽输出切回输入模式。如果直接复用GPIO_Mode_Out_PP去读引脚,读到的永远是高电平,因为推挽输出时引脚被输出寄存器钳住。切换代码要完整配置 GPIO 结构体:
void DHT11_GPIO_Input(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = DHT11_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(DHT11_PORT, &GPIO_InitStructure); }模块板一般自带 4.7k~10k 上拉,所以输入模式用浮空输入或者GPIO_Mode_IPU都行。如果是自己搭的分立元件 DHT11,必须检查 PCB 上有没有上拉电阻;没有的话,引脚悬空时读取会随机跳变。
5.4 温湿度刷新与 OLED 的配合
DHT11 的最小采样间隔是 1 秒,连续不间断读取会导致传感器不响应。主循环里用一个 2 秒周期的计数器触发 DHT11 读取,读取成功后把结果写入全局变量;读取失败时保留上次值,OLED 继续显示旧数据,而不是清成空白。这样即使某次时序被中断打断,界面上也不会出现闪烁的温度跳变。
6. 源码组织、串口校准时间与闰年边界验证
6.1 源码和文档说明资料怎么对应
这类压缩包里的“软件源码+文档说明资料”,打开后通常是一份 Keil 工程加一个使用说明 PDF。源码按功能拆分:main.c只管初始化顺序和主循环调度,rtc.c放日期换算和 RTC 配置,oled.c放 SSD1306 驱动和显示布局,dht11.c放单总线读取,delay.c提供微秒和毫秒延时,bsp_pin.h集中管理引脚映射。文档资料一般是三样:原理图或引脚连接表、操作说明(如何设置时间、如何更换引脚)、以及调试记录。
阅读源码的路线建议是:先看main.c的初始化顺序,再看rtc.c里的IsLeapYear和两个换算函数,然后看oled.c的清屏和显示函数,最后看dht11.c的时序。这样遇到问题能快速定位到具体模块,而不是从头到尾逐行读。
6.2 用串口命令校准时间,不需要改代码重新烧录
万年历跑几分钟就会出现秒偏差,实际使用必须能现场校时。常见做法是 USART1 接收一行固定格式的时间命令,解析成功后重新设置 RTC。串口中断收到完整一帧后,调用下面的解析函数:
uint8_t ParseTimeCommand(char *cmd, TimeStruct *t) { uint16_t y; uint8_t mo, d, h, mi, s; if (sscanf(cmd, "%04u-%02u-%02u %02u:%02u:%02u", &y, &mo, &d, &h, &mi, &s) != 6) { return 0; } if (y < 2000 || y > 2099 || mo < 1 || mo > 12 || d < 1 || d > 31) { return 0; } t->year = y; t->month = mo; t->day = d; t->hour = h; t->minute = mi; t->sec = s; return 1; }sscanf的格式串中%04u限定年份至少四位,时间用 24 小时制。解析完调用DateTimeToSeconds得到秒计数,再写回RTC_SetCounter。串口输出用printf重定向到 UART1,开机时打印当前时间和“输入 YYYY-MM-DD HH:MM:SS 校时”的提示,比按键调时直观得多。
6.3 闰年自动调整的三组边界验证方法
只用代码评审无法证明闰年逻辑正确,必须实测。把编译好的程序烧进 C8T6,打开串口,按下面步骤验证:
第一组,闰年跨日。发送2028-02-28 23:59:55,观察 OLED 在 5 秒内从 2 月 28 日 23:59:59 跳到 2 月 29 日 00:00:00。
第二组,闰年跨月。发送2028-02-29 23:59:55,确认下一分钟变成 3 月 1 日 00:00:00,而不是跳到 3 月 2 日。
第三组,平年跨月。发送2026-02-28 23:59:55,确认下一分钟直接是 3 月 1 日。
每次验证后都要重新发送正确的当前时间,避免测试值影响后续走时。如果第二组跳到了 3 月 2 日,问题几乎可以锁定在SecondsToDateTime里闰年 2 月天数补得不对,重点检查mDays[1] = 29的赋值是否在年份循环结束后执行。
另外一个容易忽略的边界是 BKP 寄存器保存的年份。内部 RTC 方案掉电后,如果 VBAT 有电,秒计数器会继续跑,但年份是靠换算函数从 2000 年起算的,并不需要额外保存。若你看到开机年份变成 2000 年,说明 VBAT 电压掉到了复位阈值以下,或者代码在初始化时无条件调用了RTC_SetCounter,把用户之前校准的时间覆盖了。
本文还有配套的精品资源,点击获取