ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F103C8T6 OLED万年历:RTC闰年与DHT11温湿度设计

STM32F103C8T6 OLED万年历:RTC闰年与DHT11温湿度设计 简介一套基于STM32F103C8T6单片机完成的智能万年历工程方案面向单片机课程设计、毕业设计以及嵌入式入门爱好者解决OLED屏下年月日时分秒显示、闰年自动跳转和实时温湿度测量等问题。代码采用模块化组织集成OLED显示驱动、RTC实时时钟、DS18B20温度采集、DHT11湿度采集以及LED、蜂鸣器提示等外设控制便于直接移植或学习底层驱动编写也适合在此基础上按课设要求继续扩展。资源包共88个文件主要包含C语言源码.c/.h、STM32标准外设库、启动文件、OLED取模字库及Keil工程配置.uvprojx/.uvoptx另有Proteus原理接线图、编译生成的HEX固件和《基于STM32的智能万年历设计.docx》文档说明压缩包整体约1.77MB结构清晰。目前已有581人学习下载能够帮助初学者从硬件接线、外设初始化到RTC闰年处理逻辑完整复现一个带温湿度显示的嵌入式小项目是课设参考和入门实践的不错选择。1. 为什么这个标题值得做stm32f103c8t6 OLED 万年历并非简单组合拿到“基于 stm32f103c8t6 单片机万年历OLED 显示年月日时分秒、闰年自动调整、实时温湿度测量”这类标题经验少的人会以为只是把时钟和传感器驱动凑在一起。实际上手会发现三层问题第一RTC 时钟源选错时间走几天就偏差几十秒第二日历逻辑里缺了闰年2 月 28 日晚上会直接跳到 3 月 1 日第三OLED 整屏刷新导致视觉闪烁DHT11 在定时器中断和主循环交替时会偶发读不到数据。这些问题的共同点是外设不是“接上就能用”每一条总线和时间基准都在约束整体设计。做这类项目最适合的顺序是先弄清时间源与日历算法再谈显示和传感器。读者如果是正在做课设或者想基于 c8t6 最小系统板搭一台桌面时钟的工程师这套方案可以顺序走一遍。拿到资料时我会先核对四样东西最小系统板原理图、引脚分配表、Keil 或 IAR 工程目录、外设接线定义缺一不可。2. 把秒数变成日历stm32f103c8t6 的 RTC 与闰年自动调整2.1 为什么选 LSE 而不是 LSI时钟源决定万年历误差范围stm32f103c8t6 内部有两个可给 RTC 使用的时钟源。LSI 是约 40kHz 的内部 RC 振荡器出厂精度通常在 1% 到 3%温度变化时还会漂移。用它做秒基准实际每秒钟可能是 0.97 秒或 1.03 秒一天累积误差就是几分钟和标题里“万年历”的要求不在一个精度等级。LSE 是 32.768kHz 的外部晶振经 RTC 预分频器分频后得到准确的 1Hz常见做法是优先选 LSE。这里有一个容易翻车的点不少 stm32f103c8t6 最小系统板没有焊接 LSE 晶振和两颗 6pF 负载电容。原理图上只要有 OSC32_IN / OSC32_OUT实物却是空焊RTC 初始化就会一直等 LSE 就绪信号程序卡死在等待循环里。拿到板子先确认这两个引脚有没有对应元件再继续写代码。2.1.1 HAL 库初始化 RTC 的完整序列使用 HAL 库时初始化顺序是使能电源时钟和备份域时钟开启 LSE等待稳定然后调用HAL_RTC_Init。下面这段补齐了 CubeMX 生成之外的显式 LSE 使能逻辑void MX_RTC_Init_for_LSE(void) { __HAL_RCC_PWR_CLK_ENABLE(); __HAL_RCC_BKP_CLK_ENABLE(); HAL_PWR_EnableBkUpAccess(); // 允许访问备份域寄存器 __HAL_RCC_LSE_CONFIG(RCC_LSE_ON); // 打开外部 32.768kHz 晶振 while (__HAL_RCC_GET_FLAG(RCC_FLAG_LSERDY) RESET) { // 等待 LSE 就绪实际工程可加超时计数避免卡死 } __HAL_RCC_RTC_CONFIG(RCC_RTCCLKSOURCE_LSE); // RTC 时钟源选 LSE __HAL_RCC_RTC_ENABLE(); // 使能 RTC 外设 hrtc.Instance RTC; hrtc.Init.AsynchPrediv 0x7F; // 127 分频 hrtc.Init.SynchPrediv 0xFF; // 255 分频 hrtc.Init.OutPut RTC_OUTPUTSOURCE_NONE; HAL_RTC_Init(hrtc); }分频参数说明一下STM32F103 的 RTC 预分频由异步和同步两级组成AsynchPrediv 0x7F表示 128 分频SynchPrediv 0xFF表示 256 分频两者相乘得到 32768正好把 32.768kHz 变成 1Hz。同步分频器还会在每秒末产生更新事件可以映射成秒中断后面做日历自增就用它。2.2 设置初始时间和读取当前时间的 HAL 调用F103 的 RTC 时间值保存在备份域日期寄存器也在备份域。HAL 库提供了HAL_RTC_SetTime与HAL_RTC_SetDate。实际工程里我会在首次上电时检查一个备份寄存器标志判断是否已经初始化过避免每次上电都重设时间。void rtc_set_calendar(RTC_TimeTypeDef *time, RTC_DateTypeDef *date) { HAL_RTC_SetTime(hrtc, time, RTC_FORMAT_BIN); HAL_RTC_SetDate(hrtc, date, RTC_FORMAT_BIN); HAL_PWR_EnableBkUpAccess(); HAL_RTCEx_BKUPWrite(hrtc, RTC_BKP_DR1, 0x20A4); // 写入已初始化标志 }读取时有一个 HAL 库的常见坑F103 的 RTC 没有独立影子寄存器必须先调用HAL_RTC_GetTime用返回值里的时、分、秒再去HAL_RTC_GetDate读日期顺序反了日期会滞后一个周期。2.3 用日期自增函数实现闰年自动调整F103 的 RTC 硬件并不会自动判断闰年日期进位需要软件兜底。常见的“自动调整”实现是维护一个日历结构体在 1Hz 秒中断里调用一次自增函数。它的核心问题只有一个这个月的最后一天是多少号。年份条件二月天数说明普通年28无特别处理能被 4 整除但不能被 100 整除29常见闰年能被 400 整除29世纪闰年如 2000代码里用一个数组存平年每月的天数二月的天数通过闰年判断动态补齐static const uint8_t month_days_tab[12] {31,28,31,30,31,30,31,31,30,31,30,31}; static uint8_t is_leap_year(uint16_t year) { if (year % 4 0 year % 100 ! 0) return 1; if (year % 400 0) return 1; return 0; } void calendar_next_second(calendar_t *ct) { uint8_t dom; dom month_days_tab[ct-month - 1] ((ct-month 2) ? is_leap_year(ct-year) : 0); if (ct-second 60) { ct-second 0; if (ct-minute 60) { ct-minute 0; if (ct-hour 24) { ct-hour 0; if (ct-day dom) { ct-day 1; if (ct-month 12) { ct-month 1; ct-year; } } } } } }这段逻辑放在 1Hz 秒中断里每次侵入时间很短不用除法、不用查表CPU 开销可以忽略。month - 1是数组下标dom算出来就是本月最大天数。跨日时判断day dom而不是day dom这样即使某次中断被延迟一拍也不会把日期卡在同一天。写这段代码时容易踩的坑是年份偏移HAL 库的RTC_DateTypeDef.Year是相对于 2000 年的比如 2024 年要存 24。如果直接拿这个值去参与闰年判断2024 会被当成 200024表面看也是闰年但遇到 2100 年这类世纪年就会错判。所以日历结构体里要保存真实年份只在读写 RTC 寄存器时做换算。2.4 秒中断里自增还是在主循环里读寄存器很多方案在 main loop 里直接调用HAL_RTC_GetTime读取这里要提醒RTC 的计数器是异步计数的主循环读到的值可能跳变配合上面的自增函数会出现重复进位。我一般的做法是让 RTC 的秒中断接管日历自增OLED 和串口只负责显示结构体中的值。主循环和中断访问同一个结构体时显示端读到的瞬时值可能不一致但只需要把首页刷新放在秒变化之后或者用临界区读一次完整快照。3. OLED 显示与区域刷新HAL 库驱动 I2C 屏幕做万年历3.1 0.91 寸 OLED 的 I2C 接线与 SSD1306 初始化标题里的 OLED 最常见的是 0.91 寸 128x32控制器 SSD1306接口选 I2C。SDA 和 SCL 接 I2C1也就是 PB7/PB6VCC 接 3.3VGND 共地。模块上通常已经带好上拉电阻不需要外部再加。HAL 库点亮 OLED 屏幕的全流程分三步初始化 I2C 外设、发送 SSD1306 初始化序列、往显存填充数据。I2C 地址要确认一下大多数模块是 0x787 位地址 0x3C。如果屏幕不亮先用逻辑分析仪看 SDA 上有没有起始位再检查模块背面的地址选择电阻。uint8_t ssd1306_init_cmds[] { 0xAE, // 关闭显示 0xD5, 0x80, // 显示时钟分频 0xA8, 0x1F, // 0.91 寸是 32 行复用比设 31 0xD3, 0x00, // 显示偏移 0 0x40, // 起始行 0 0x8D, 0x14, // 电荷泵开启 0x20, 0x00, // 水平地址模式 0xA1, // 段重映射 0xC8, // 扫描方向 0xDA, 0x02, // COM 引脚配置128x32 用 0x02 0xAF // 开启显示 };发送命令时给最高位加 0x00写显存数据时给最高位加 0x40这是 SSD1306 区分命令和数据的方式。0xA8 0x1F在 128x32 上是刚需如果直接照抄 128x64 例程里的0xA8 0x3F屏幕会整体偏移并出现底部滚动毛刺。Proteus 仿真里的 OLED 很多是 SPI 模型I2C 版要确认模型是否支持把驱动换成 SPI 版后初始化序列大部分一致只有写字节的时序函数不同。3.2 ASCII 字模与 128x32 显存布局128x32 的显存一共 512 字节按 4 页排列每页是 8 个像素高度。显示一个 6x8 的 ASCII 字符时字模只需要 8 个字节。取模软件我一般用 PCtoLCD2002设置阴码、列行式、6x8输出结果就是一个二维数组const uint8_t asc6x8[95][8] { // 空格 {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // ! {0x00,0x00,0x5F,0x00,0x00,0x00,0x00,0x00}, // 后续字符按 ASCII 顺序排列 };写显存时按页和列定位字符的 y 坐标除以 8 得到页号x 坐标就是列号。把字模逐字节拷贝到oled_buf对应位置后需要调用刷新函数把 buffer 推到屏幕否则改的只是内存。0.91 寸屏只有 4 页整屏刷新一次也就是 512 字节但真正变化的内容往往只有几个字符。3.3 每秒只刷新秒位区域刷新避免闪烁对日历显示来说分钟和小时不需要每秒重刷真正变化的只有秒。整屏刷新的代价是发送 512 字节到 OLEDI2C 在 400kHz 下大约占用 10ms 总线时间如果这段时间又在跑 DHT11 时序会产生相互干扰。常见做法是把秒位单独圈出来做一个区域刷新函数void OLED_Refresh_Range(uint8_t page_start, uint8_t col_start, uint8_t page_end, uint8_t col_end) { for (uint8_t page page_start; page page_end; page) { I2C_WriteCmd(0xB0 page); // 设置页地址 I2C_WriteCmd(0x00 (col_start 0x0F)); // 列地址低四位 I2C_WriteCmd(0x10 (col_start 4)); // 列地址高四位 for (uint8_t col col_start; col col_end; col) { I2C_WriteData(oled_buf[page * 128 col]); } } }这个函数只重写秒位所在的 6 列 4 页单次传输只有 24 个字节。实际效果是秒数跳变时看不到整屏闪烁I2C 总线占用时间降到微秒级给 DHT11 的读取留出干净时隙。如果后续想升级成指针式电子钟也是基于这套显存架构只是把 6x8 字模换成圆弧绘制函数秒针的刷新区域会更小。4. 实时温湿度测量DHT11 读取稳定化与 OLED 显示联动4.1 选 DHT11 还是 SHT30先看总线时序的容忍度如果标题对成本敏感DHT11 是够用的湿度分辨率 1%温度分辨率 1 度采样间隔建议 2 秒以上。但它的时序很敏感主机拉低起始脉宽要求 18ms 以上每一位的 0/1 判断靠 26~70us 的高电平宽度区分读取期间不能被打断否则位采样会错。SHT30 走标准 I2C带 CRC 校验时序容忍度高价格大概是 DHT11 的三四倍。做万年历这类桌面设备我一般建议 DIY 选 DHT11因为难度集中在“如何把不稳定的时序写稳”如果做带量产品就换 SHT30省下后续排错的工时。4.2 DHT11 的 GPIO 模拟时序与超时状态机DHT11 用一根数据线双向通信。主机先把引脚拉低至少 18ms再拉高 20~40us随后释放总线DHT11 响应时先拉低 80us再拉高 80us然后输出 40 位数据。每位数据以 50us 低电平开始高电平持续 26~28us 表示 0持续 70us 表示 1。uint8_t dht11_read(dht11_data_t *d) { uint8_t buf[5] {0}; uint8_t crc 0; GPIO_InitTypeDef gpio {0}; gpio.Pin DHT11_PIN; gpio.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出模拟双向口 gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(DHT11_PORT, gpio); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); delay_ms(20); // 起始信号至少 18ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); delay_us(30); gpio.Mode GPIO_MODE_INPUT; // 切换输入模式 HAL_GPIO_Init(DHT11_PORT, gpio); __disable_irq(); // 临界区避免中断干扰位采样 if (wait_level(DHT11_PIN, GPIO_PIN_RESET, 100) ! 0 || wait_level(DHT11_PIN, GPIO_PIN_SET, 100) ! 0 || wait_level(DHT11_PIN, GPIO_PIN_RESET, 100) ! 0) { __enable_irq(); return 1; // 无响应 } for (uint8_t i 0; i 40; i) { wait_level(DHT11_PIN, GPIO_PIN_SET, 100); // 等数据位低电平结束 uint32_t t get_tick_us(); // 记录高电平起始 wait_level(DHT11_PIN, GPIO_PIN_RESET, 100); // 等高电平结束 uint32_t dt get_tick_us() - t; buf[i / 8] (buf[i / 8] 1) | ((dt 40) ? 1 : 0); // 40us 阈值 } __enable_irq(); crc (buf[0] buf[1] buf[2] buf[3]) 0xFF; if (crc ! buf[4]) return 2; // 校验失败 d-humidity buf[0]; // 湿度整数 d-temperature buf[2]; // 温度整数 return 0; }这段代码的关键在等电平函数它内部基于 DWT 计数器做微秒计时超时值 100us 足够覆盖正常的高电平宽度又不会在总线悬空时死等。dt 40这个阈值是经验值建议在 35~50us 之间调。如果环境干扰大可以连读三次取中间值但注意每次读取间隔要大于 1 秒。这里有一个和 OLED 联动的调试点如果矩阵按键或定时器中断刚好在 DHT11 读取期间触发传感器这一帧就废了下次读取又隔了 2 秒在 OLED 上看起来就像“读不到数据”。解决办法就是 40 位采样期间关闭中断读取失败时继续显示上次有效值而不是显示 0。4.3 每秒采集一次并输出串口调试信息温湿度不需要和秒同步更新1 秒采样一次足够。主循环里维护一个节拍计数间隔到 1000ms 才调用dht11_read。串口调试是很实用的手段重定向fputc到 USART1把 RTC 时间和温湿度打包打印判断传感器是不是真的读到了数据int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; } // 主循环中每秒执行一次 printf(T%d H%d rtc%02d:%02d:%02d\r\n, dht.temperature, dht.humidity, cal.hour, cal.minute, cal.second);在 Keil MDK 里使用 printf 要勾选 Use MicroLIB否则链接时找不到半主机相关符号IAR 环境下不需要额外配置。串口中断接收有个经典坑F103 的HAL_UART_Receive_IT只接收一次就停需要再次开启接收。调试打印建议降到每 5 秒一次或者只在按键事件里打印避免阻塞式的HAL_UART_Transmit拖住主循环造成显示和采样节奏错乱。5. 按键调时、看门狗与闰年边界的三步验证5.1 矩阵按键扫描与调时状态机接入矩阵按键之后OLED 没反应的排查顺序是先确认按键引脚有没有配置内部上拉再用示波器看按下瞬间电平变化最后看扫描周期。串行扫描 4x4 矩阵时如果一次扫描耗掉 20ms又恰好在 DHT11 读取窗口里关断中断传感器就会周期性失败。调时功能我用两个按键加一个状态机实现KEY_SET 进入调整状态并切换字段KEY_UP 让当前字段加一长按 1 秒可以连续累加。调整结束重新调用HAL_RTC_SetTime和HAL_RTC_SetDate同时更新日历结构体。进入调整状态 - 高亮当前字段 KEY_SET 循环切换年 - 月 - 日 - 时 - 分 - 秒 - 退出 KEY_UP 让当前字段加一到上限回绕注意调整过程中 RTC 仍在走秒退出时必须“先读当前值再改目标字段”写完立即同步日历结构体否则显示和硬件时间会产生几秒偏差。5.2 用 IWDG 防止传感器读取卡死DHT11 的等待函数如果不是超时而是死等一旦传感器损坏整个系统会卡死在读取里。给 IWDG 配上 2 秒溢出主循环每 500ms 喂一次狗异常时系统自动复位hiwdg.Instance IWDG; hiwdg.Init.Prescaler IWDG_PRESCALER_64; // LSI 约 40kHz分频后约 625Hz hiwdg.Init.Reload 1250; // 溢出时间约 2 秒 HAL_IWDG_Init(hiwdg); // 主循环固定位置执行 HAL_IWDG_Refresh(hiwdg);喂狗要放在主循环的固定位置不要放在 DHT11 读取流程之后。如果读取本身卡死放在后面的喂狗永远不会执行看门狗也就失去意义。5.3 用三个边界场景验证闰年自动调整最后做三组时间设置每组等待跨天或跨年场景初始时间预期结果普通月月末2024-04-30 23:59:555 秒后跳到 2024-05-01闰年二月2024-02-28 23:59:55跳到 2024-02-29而不是 03-01跨年2024-12-31 23:59:55跳到 2025-01-01年份进位把calendar_next_second单独摘出来在 PC 上做单元测试直接喂这三种输入比反复烧录固件效率高很多。验证通过后再记录断电重上电后的走时误差LSE 方案走 24 小时通常偏差不超过 2 秒如果偏差在 10 秒以上检查晶振负载电容和 AsynchPrediv/SynchPrediv 分频值。最后复位后还要再跑一次 23:59:55 的跨年场景确认自动进位在中断里没有被其他外设拖过 1 秒。本文还有配套的精品资源点击获取
返回列表