基于STM32F103C8T6的OLED万年历:RTC闰年算法与DHT11传感器实战

基于STM32F103C8T6的OLED万年历:RTC闰年算法与DHT11传感器实战 简介基于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默认引脚是 PB6SCL和 PB7SDADHT11 的单总线数据脚选 PA1调试串口用 USART1PA9 发送、PA10 接收LSE 晶振必须接在 PC14 和 PC15 上这两个引脚不能当普通 GPIO 用。外设信号STM32F103C8T6 引脚说明OLED SCLPB6I2C1_SCL复用开漏OLED SDAPB7I2C1_SDA复用开漏DHT11 DATAPA1单向数据线需 4.7kΩ~10kΩ 上拉DHT11 VCC/GND3.3V / GND模块一般带稳压和上拉可不外接32.768kHz 晶振PC14 / PC15LSE 输入输出负载电容 6~12.5pFUSART1 TX/RXPA9 / 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 初始化先开备份域写保护再配 LSEC8T6 的 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 位。下面是两个传感器在协议层面上的关键差异对比项DHT11DHT22温湿度数据位各 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~28us40us 时已经回落到低电平数据 1 的高电平持续 70us40us 时仍然为高。这个延时用软件循环实现时要注意编译器优化级别最好用一个校准过的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把用户之前校准的时间覆盖了。本文还有配套的精品资源点击获取