当前位置: 首页 > article >正文

基于STM32HAL库的万年历系统

目录

前言

项目分析

CubeMX配置

工程文件结构

App文件夹

Lib文件夹

库文件代码

myrtc.c

myrtc.h

oled库&字符库

knob.c

knob.h

业务逻辑代码

task_main.c

task_main.h


前言

        本篇博客来做一个简易的万年历系统,需要用到旋转编码器0.96寸OLED屏幕,以及STM32内部的RTC时钟;旋转编码器和RTC时钟在我的HAL库教程中有讲——HAL库零基础入门


项目分析

        程序应当分为两种主要模式:普通模式与设置模式;普通模式下程序从RTC中获取当前的unix时间戳,然后将其按照一定的格式显示在OLED屏幕上;按下旋转编码器,可以进入设置模式,会有光标在数据下闪烁,顺时针旋转旋钮数据增加,逆时针减少;再次按下旋钮切换到下一个参数的修改,依次修改年、月、日、时、分、秒后再次按下旋钮就回到普通模式,将设置好的时间转换为戳写入到RTC中,同时自动设置好星期,并显示在OLED屏幕上。

        整体框架如下,十分简单:

        我们要写出三个模块的库,且模块之间相互独立,减少耦合性;不过模块库的代码依旧会与其需要的硬件操作耦合,尽可能让其具有方便的移植性;最后应用层代码调用各个库,实现最终的逻辑。


CubeMX配置

        先来到System Core中的SYS,将Debug设置为Serial Wire:

        然后来到RCC,将两个时钟源都设置为晶振,RTC需要用到LSE(低速外部时钟):

        来到Timers里的RTC,勾选Activate Clock Source,开启RTC:

        来到顶部的时钟设置,Clock Configuration,选择主频为72MHz,然后将RTC的时钟源设置为LSE:

        我们的旋钮接在了TIM1的TI1FP1和TI2FP2,即PA8和PA9,因此回到主界面,进入Timers里的TIM1,选择组合通道(Combined Channels)为编码器模式(Encoder Mode)

        然后来到下面的详细设置,由于编码器一个脉冲,计数器会计数两次,因此设置为2分频;为了让其顺时针旋转时计数值增加,反转时计数值减少,将通道二的极性反转一下,波形翻转(注意:有些编码器可能正转的时候计数值会增加,那就保持默认即可):

        旋钮的按键引脚我接在PB15,设置为GPIO的上拉输入模式即可,这里不演示了。

        我们还要用到OLED屏幕,来到Connectivity的I2C1设置,开启I2C,并在下面的详细设置中将I2C速度设置为快速模式(Fast Mode)避免发送时间太长影响按钮的轮询判断:

        为了调试还可以自行开启串口,打印调试信息,使用最简单的轮询模式发送即可。

        最后来到Project Manager中的Code Generator,勾选为每个外设生成单独的.c/.h文件:

        一切就绪就可以生成代码了。


工程文件结构

        在工程文件夹下新建App文件夹以及Lib文件夹:

App文件夹

        该文件夹里写的是应用层代码,以后大型工程可能会用到FreeRTOS等操作系统,通常会将业务逻辑解耦分成一个个Task任务,这次我们只有一个主任务,因此创建task_main.c以及task_main.h文件:


Lib文件夹

        该文件夹用来放置我们的库代码,,如下:


库文件代码

myrtc.c

        这在我的HAL库教程博客里有详细的解释,请移步相关博客,这里不再赘述:

#include "myrtc.h"

#define RTC_INIT_FLAG 0xAAAA

// 进入RTC配置模式(关闭写保护)
static HAL_StatusTypeDef RTC_EnterInitMode(RTC_HandleTypeDef *hrtc)
{
  uint32_t tickstart = 0U;

  tickstart = HAL_GetTick();
  // 等待RTC就绪
  while ((hrtc->Instance->CRL & RTC_CRL_RTOFF) == (uint32_t)RESET)
  {
    if ((HAL_GetTick() - tickstart) >  RTC_TIMEOUT_VALUE)
    {
      return HAL_TIMEOUT;
    }
  }

  // 解除寄存器写保护
  __HAL_RTC_WRITEPROTECTION_DISABLE(hrtc);


  return HAL_OK;
}

// 退出RTC配置模式(恢复写保护)
static HAL_StatusTypeDef RTC_ExitInitMode(RTC_HandleTypeDef *hrtc)
{
  uint32_t tickstart = 0U;

  // 恢复寄存器写保护
  __HAL_RTC_WRITEPROTECTION_ENABLE(hrtc);

  tickstart = HAL_GetTick();
  // 等待配置完成
  while ((hrtc->Instance->CRL & RTC_CRL_RTOFF) == (uint32_t)RESET)
  {
    if ((HAL_GetTick() - tickstart) >  RTC_TIMEOUT_VALUE)
    {
      return HAL_TIMEOUT;
    }
  }

  return HAL_OK;
}

// 原子读取32位计数器(处理跨16位读取时的翻转)
static uint32_t RTC_ReadTimeCounter(RTC_HandleTypeDef *hrtc)
{
  uint16_t high1 = 0U, high2 = 0U, low = 0U;
  uint32_t timecounter = 0U;

  high1 = READ_REG(hrtc->Instance->CNTH & RTC_CNTH_RTC_CNT);
  low   = READ_REG(hrtc->Instance->CNTL & RTC_CNTL_RTC_CNT);
  high2 = READ_REG(hrtc->Instance->CNTH & RTC_CNTH_RTC_CNT);

  // 高位变化时重新读取低位
  if (high1 != high2)
  {
    /* In this case the counter roll over during reading of CNTL and CNTH registers,
       read again CNTL register then return the counter value */
    timecounter = (((uint32_t) high2 << 16U) | READ_REG(hrtc->Instance->CNTL & RTC_CNTL_RTC_CNT));
  }
  else
  {
    /* No counter roll over during reading of CNTL and CNTH registers, counter
       value is equal to first value of CNTL and CNTH */
    timecounter = (((uint32_t) high1 << 16U) | low);
  }

  return timecounter;
}

// 原子写入32位计数器
static HAL_StatusTypeDef RTC_WriteTimeCounter(RTC_HandleTypeDef *hrtc, uint32_t TimeCounter)
{
  HAL_StatusTypeDef status = HAL_OK;

  /* Set Initialization mode */
  if (RTC_EnterInitMode(hrtc) != HAL_OK)
  {
    status = HAL_ERROR;
  }
  else
  {
    // 写入高16位
    WRITE_REG(hrtc->Instance->CNTH, (TimeCounter >> 16U));
    // 写入低16位
    WRITE_REG(hrtc->Instance->CNTL, (TimeCounter & RTC_CNTL_RTC_CNT));

    // 退出时自动同步
    if (RTC_ExitInitMode(hrtc) != HAL_OK)
    {
      status = HAL_ERROR;
    }
  }

  return status;
}

// 设置RTC时间(Unix时间戳格式)
HAL_StatusTypeDef sakabu_RTC_SetTime(struct tm *time)
{
	uint32_t unixTime = mktime(time);
	return RTC_WriteTimeCounter(&hrtc, unixTime);
}

// 获取RTC时间(返回tm结构体指针)
struct tm* sakabu_RTC_GetTime(void)
{
	time_t unixTime = RTC_ReadTimeCounter(&hrtc);
	return gmtime(&unixTime);
}

// RTC初始化(首次上电加载默认时间)
void sakabu_RTC_Init(void)
{
						//读取备份寄存器的函数,10个寄存器
	uint32_t initFlag = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1);
	if(initFlag == RTC_INIT_FLAG) return;
	
	if (HAL_RTC_Init(&hrtc) != HAL_OK)
    {
        Error_Handler();
    }
	
    // 设置默认时间:2025-01-01 23:59:55
	struct tm time = {
		//年要存储的是年份和1900年的差值
		.tm_year = 2025 - 1900,//2025
		//月份的取值是0~11,代表1到12月
		.tm_mon = 1-1,//1月
		.tm_mday = 1,
		.tm_hour = 23,
		.tm_min = 59,
		.tm_sec = 55,
	};
	sakabu_RTC_SetTime(&time);
    // 设置初始化标记
	HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, RTC_INIT_FLAG);
}

myrtc.h

#ifndef __MYRTC_H__
#define __MYRTC_H__

#include "stm32f1xx_hal.h"
#include "rtc.h"
#include "time.h"

HAL_StatusTypeDef sakabu_RTC_SetTime(struct tm *time);
struct tm* sakabu_RTC_GetTime(void);
void sakabu_RTC_Init(void);

#endif

oled库&字符库

        我是用的别人写好的驱动库,我上传到我的资源了,把oled和font的.c/.h文件放入Lib文件夹下即可,使用方法如下:

        ● STM32初始化IIC完成后调用OLED_Init()初始化OLED. 注意STM32启动比OLED上电快, 可等待20ms再初始化OLED
        ● 调用OLED_NewFrame()开始绘制新的一帧
        ● 调用OLED_DrawXXX()系列函数绘制图形到显存 调用OLED_Printxxx()系列函数绘制文本到显存
        ● 调用OLED_ShowFrame()将显存内容显示到OLED


knob.c

        为旋钮封装一个模块库,代码思路如下:

        注意这里的正转反转任务,旋钮具体要做什么工作,应该是由负责具体业务逻辑的主任务规定的,层次较低的模块库,只能被更高层调用,而且业务逻辑实现在模块库的话,模块库就没有任何可移植性了,因此我们自己写一个回调函数,通过指针注册的方式,在库内提供一个函数指针变量(或数组),上层在使用对应库时,将此函数指针指向某函数,当模块库发生某事件时需要通知上层代码时,就调用此函数指针指向的函数即可

#include "knob.h"

#define COUNTER_INIT_VALUE 65535/2  // 编码器中点值(防溢出设计)
#define BTN_DEBOUNCE_TICKS 10       // 按键消抖时间阈值(单位:ms)

typedef enum {Pressed, Unpressed} BtnState;

// 设置编码器计数器值
void setCounter(int value)
{
	__HAL_TIM_SetCounter(&htim1, value);  // 直接操作硬件定时器
}

// 获取当前编码器计数值
uint32_t getCounter(void)
{
	return __HAL_TIM_GetCounter(&htim1);  // 读取硬件定时器
}

// 获取按键状态(带电平转换)
BtnState getBtnState(void)
{
	return HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_15) == GPIO_PIN_RESET ? Pressed : Unpressed;
}

// 获取系统时钟基准
uint32_t getTick(void)
{
	return HAL_GetTick();  // 用于时间间隔计算
}

// 回调函数指针容器
KnobCallback onForwardCallback = NULL;   // 正转事件回调
KnobCallback onBackwardCallback = NULL;  // 反转事件回调
KnobCallback onPressedCallback = NULL;   // 按键事件回调

// 注册正转回调
void Knob_SetForwardCallback(KnobCallback callback)
{
	onForwardCallback = callback;  // 绑定用户逻辑
}

// 注册反转回调
void Knob_SetBackwardCallback(KnobCallback callback)
{
	onBackwardCallback = callback; // 绑定用户逻辑
}

// 注册按键回调
void Knob_SetPressedCallback(KnobCallback callback)
{
	onPressedCallback = callback;  // 绑定用户逻辑
}

// 编码器初始化(启动捕获)
void Knob_Init(void)
{
	HAL_TIM_Encoder_Start(&htim1, TIM_CHANNEL_ALL);  // 启动编码器模式
	setCounter(COUNTER_INIT_VALUE);                  // 初始化到中间位置
}

// 主检测循环(需周期性调用)
void Knob_Loop(void)
{
	/* 旋转检测逻辑 */
	uint32_t counter = getCounter();
	// 与初始值比较判断方向
	if(counter > COUNTER_INIT_VALUE)        // 正转条件
	{
		if(onForwardCallback != NULL) onForwardCallback();  // 触发正转事件
	}
	else if(counter < COUNTER_INIT_VALUE)   // 反转条件
	{
		if(onBackwardCallback != NULL) onBackwardCallback();// 触发反转事件
	}
	setCounter(COUNTER_INIT_VALUE);         // 重置计数器(相对式编码器模式)

	/* 按键检测逻辑 */
	BtnState btnState = getBtnState();
	static uint8_t callbackState = 0;  // 防重复触发标记(0-待触发 1-已触发)
	static uint32_t pressedTime = 0;   // 按下时刻记录
	
	if(btnState == Pressed)
	{
		if(pressedTime == 0)  // 首次按下记录时间
			pressedTime = getTick();
		// 达到消抖时间且未触发过回调
		else if(callbackState == 0 && getTick() - pressedTime > BTN_DEBOUNCE_TICKS)
		{
			if(onPressedCallback != NULL) onPressedCallback();  // 触发按键事件
			callbackState = 1;  // 标记已触发
		}
	}
	else  // 按键松开
	{
		pressedTime = 0;       // 重置时间记录
		callbackState = 0;     // 重置触发标记
	}
}

knob.h

#ifndef __KNOB_H__
#define __KNOB_H__

#include "tim.h"

typedef void (*KnobCallback)(void);

void Knob_Init(void);

void Knob_Loop(void);

void Knob_SetForwardCallback(KnobCallback callback);

void Knob_SetBackwardCallback(KnobCallback callback);

void Knob_SetPressedCallback(KnobCallback callback);

#endif

业务逻辑代码

        我们最终的main.c函数很简单,都是调用我们创建的task_main.c里面封装好的函数,这样函数结构清晰明确:

        MainTaskInit里面是各个模块的初始化函数,后面MainTask就是各个模块需要循环检测的函数。我们在写task_main.c之前,先来到main.c的MX_RTC_Init()函数,这是CubeMX自动生成的RTC初始化函数,在我的HAL库教程里说过,里面有个小小的bug,导致我们复位的时候,RTC会停止运行一小会,修改如下:

        将下面的初始化代码移到上面的注释对中,然后直接return跳出函数,下面的if分支就是导致RTC停止运行的原因,我把它放在了自己封装的RTC库里,在myrtc.c里的sakabu_RTC_Init中,上电先判断备份寄存器里面是否有我们写入的标志数据,如果没有表示我们从来没有设置过RTC,或者是因为断电(包括VBAT引脚)导致备份寄存器清零;没有的话就初始化RTC,这个if分支调用一次就好了。


task_main.c

#include "task_main.h"

#define CURSOR_FLASH_INTERVAL 500  // 光标闪烁周期(单位:ms)

// 星期显示文本缓存
char weeks[7][10] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};

// 日历状态机
typedef enum {
	CalendarState_Normal, // 正常显示模式
	CalendarState_Setting // 时间设置模式
} CalendarState;

// 可调节时间参数枚举
typedef enum {
	Year,    // 年
	Month,   // 月
	Day,     // 日
	Hour,    // 时
	Minute,  // 分
	Second   // 秒
} SettingState;

// 光标位置结构体(包含起点终点坐标)
typedef struct {uint8_t x1; uint8_t y1; uint8_t x2; uint8_t y2;} CursorPosition;

// 各时间参数对应的光标位置(基于字符尺寸计算)
CursorPosition cursorPosition[6] = {
	{24 + 0 * 8, 17, 24 + 4 * 8, 17},  // Year(年)坐标
	{24 + 5 * 8, 17, 24 + 7 * 8, 17},  // Month(月)坐标
	{24 + 8 * 8, 17, 24 + 10 * 8, 17}, // Day(日)坐标
	{16 + 0 * 12, 45, 16 + 2 * 12, 45},// Hour(时)坐标
	{16 + 3 * 12, 45, 16 + 5 * 12, 45},// Minute(分)坐标
	{16 + 6 * 12, 45, 16 + 8 * 12, 45},// Second(秒)坐标
};

CalendarState calendarState = CalendarState_Normal;  // 当前日历状态
SettingState settingState = Year;                     // 当前设置项
struct tm settingTime;                                // 时间设置缓冲区

// 旋钮正转回调(增加值)
void onKnobForward(void)
{
	if(calendarState == CalendarState_Setting)
	{
		switch(settingState)
		{
			case Year:  // 年+1(基准1900)
				settingTime.tm_year++;
				break;
			case Month: // 月+1(0-11循环)
				settingTime.tm_mon++;
				if(settingTime.tm_mon > 11)
					settingTime.tm_mon = 0;
				break;
			case Day:   // 日+1(1-31循环)
				settingTime.tm_mday++;
				if(settingTime.tm_mday > 31)
					settingTime.tm_mday = 1;
				break;
			case Hour:  // 时+1(0-23循环)
				settingTime.tm_hour++;
				if(settingTime.tm_hour > 23)
					settingTime.tm_hour = 0;
				break;
			case Minute:// 分+1(0-59循环)
				settingTime.tm_min++;
				if(settingTime.tm_min > 59)
					settingTime.tm_min = 0;
				break;
			case Second:// 秒+1(0-59循环)
				settingTime.tm_sec++;
				if(settingTime.tm_sec > 59)
					settingTime.tm_sec = 0;
				break;
		}
	}
}

// 旋钮反转回调(减少值)
void onKnobBackward(void)
{
	if(calendarState == CalendarState_Setting)
	{
		switch(settingState)
		{
			case Year:  // 年-1(最低1970年)
				settingTime.tm_year--;
				if(settingTime.tm_year < 70)
						settingTime.tm_year = 70;
				break;
			case Month: // 月-1(0-11循环)
				settingTime.tm_mon--;
				if(settingTime.tm_mon < 0)
					settingTime.tm_mon = 11;
				break;
			case Day:   // 日-1(1-31循环)
				settingTime.tm_mday--;
				if(settingTime.tm_mday < 0)
					settingTime.tm_mday = 31;
				break;
			case Hour:  // 时-1(0-23循环)
				settingTime.tm_hour--;
				if(settingTime.tm_hour < 0)
					settingTime.tm_hour = 23;
				break;
			case Minute:// 分-1(0-59循环)
				settingTime.tm_min--;
				if(settingTime.tm_min < 0)
					settingTime.tm_min = 59;
				break;
			case Second:// 秒-1(0-59循环)
				settingTime.tm_sec--;
				if(settingTime.tm_sec < 0)
					settingTime.tm_sec = 59;
				break;
		}
	}
}

// 旋钮按压回调(状态切换)
void onKnobPressed(void)
{
	if(calendarState == CalendarState_Normal)
	{
		settingTime = *sakabu_RTC_GetTime(); // 载入当前时间到缓冲区
		settingState = Year;                 // 重置设置项
		calendarState = CalendarState_Setting;// 进入设置模式
	}
	else
	{
		if(settingState == Second)            // 最后一个设置项
		{
			sakabu_RTC_SetTime(&settingTime); // 提交设置到RTC
			calendarState = CalendarState_Normal;// 返回正常模式
		}
		else
			settingState++;                   // 切换到下一设置项
	}
}

// 时间显示格式化(OLED输出)
void ShowTime(struct tm *time)
{
	char str[30];
	// 日期显示:年-月-日(年基准1900,月+1显示)
	sprintf(str, "%d-%d-%d", time->tm_year + 1900, time->tm_mon + 1, time->tm_mday);
	OLED_PrintASCIIString(24, 0, str, &afont16x8, OLED_COLOR_NORMAL);

	// 时间显示:HH:MM:SS(两位数格式)
	sprintf(str, "%02d:%02d:%02d", time->tm_hour, time->tm_min, time->tm_sec);
	OLED_PrintASCIIString(16, 20, str, &afont24x12, OLED_COLOR_NORMAL);

	// 星期显示(居中布局)
	char *week = weeks[time->tm_wday];
	uint8_t x_week = (128 - (strlen(week) * 8)) / 2;  // 计算居中位置
	OLED_PrintASCIIString(x_week, 48, week, &afont16x8, OLED_COLOR_NORMAL);
}

// 光标闪烁效果(仅设置模式显示)
void showCursor(void)
{
	static uint32_t startTime = 0;
	uint32_t diffTime = HAL_GetTick() - startTime;
	
	if(diffTime > 2 * CURSOR_FLASH_INTERVAL)  // 重置计时周期
		startTime = HAL_GetTick();
	else if(diffTime > CURSOR_FLASH_INTERVAL) // 后半周期显示光标
	{
		CursorPosition position = cursorPosition[settingState];
		OLED_DrawLine(position.x1, position.y1, position.x2, position.y2, OLED_COLOR_NORMAL);
	}
}

// 系统初始化(外设启动)
void MainTaskInit(void)
{
	HAL_Delay(20);  // 等待硬件稳定
	OLED_Init();     // 显示模块初始化
	sakabu_RTC_Init();// RTC初始化
	Knob_Init();     // 编码器初始化
	// 绑定回调函数
	Knob_SetForwardCallback(onKnobForward);
	Knob_SetBackwardCallback(onKnobBackward);
	Knob_SetPressedCallback(onKnobPressed);
}

// 主循环任务(持续执行)
void MainTask(void)
{
	Knob_Loop();       // 处理编码器事件
	OLED_NewFrame();   // 准备新显示帧
	
	if(calendarState == CalendarState_Normal)  // 正常模式
	{
		struct tm *now = sakabu_RTC_GetTime(); // 获取实时时间
		ShowTime(now);                         // 显示当前时间
	}
	else  // 设置模式
	{
		ShowTime(&settingTime);  // 显示设置中的时间
		showCursor();            // 显示闪烁光标
	}
	
	OLED_ShowFrame();  // 刷新整屏显示
}

task_main.h

#ifndef __TASK_MAIN_H__
#define __TASK_MAIN_H__

#include "myrtc.h"
#include "oled.h"
#include "usart.h"
#include "knob.h"

#include <stdio.h>
#include <string.h>

void MainTask(void);

void MainTaskInit(void);

#endif // __TASK_MAIN_H__

        至此这个简易的万年历系统就结束了,还有一些地方可以优化的:例如不同月份有不同的天数,我只判断大于31日可能会导致设置的时间与真实时间有出入,包括闰年的判断等等;但更多的是提供模块封装的思路,包括给大家提供封装好的RTC库和旋钮knob库,供大家移植在自己的项目上。


http://www.kler.cn/a/539042.html

相关文章:

  • 什么是中间件中间件有哪些
  • 【Python】元组
  • 网站快速收录策略:提升爬虫抓取效率
  • ZooKeeper 的典型应用场景:从概念到实践
  • Swipe横滑与SwipeItem自定义横滑相互影响
  • Ollama 部署 DeepSeek-R1 及Open-WebUI
  • 【开源免费】基于SpringBoot+Vue.JS乐享田园系统(JAVA毕业设计)
  • 数据库创库建表处理
  • 人工智能-A*算法与卷积神经网络(CNN)结合实现路径规划
  • 四边形网格处理——沿Edge遍历 矩形域顶点提取
  • TestContext 框架核心机制详解
  • PHP中的魔术方法
  • 激活函数和激活函数汇总
  • 滑动窗口核心算法解决字符串问题(最小覆盖子串/字符串排列/异位词/最长无重复子串)
  • [vue3] Ref Reactive
  • 如何在Python中使用内置函数
  • 【Golang学习之旅】Go + Redis 缓存设计与优化(项目实战)
  • 2.9学习总结
  • 从零开始了解人工智能:核心概念、GPT及 DeepSeek 探索
  • 使用cursor开发python调用deepseek本地模型实现本地AI对话
  • 如何学习多智能体系统协调(如自动驾驶车协同避让)
  • Linux:安装 node 及 nvm node 版本管理工具(ubuntu )
  • jvm view
  • 【LeetCode Hot100 堆】第 K 大的元素、前 K 个高频元素
  • 智慧城市节水管理信息系统项目解决方案
  • 在阿里云ECS上一键部署DeepSeek-R1