目录
前言
项目分析
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库,供大家移植在自己的项目上。