42. RTC—实时时钟

本章参考资料:《STM32F103增强型系列数据手册》、库帮助文档《stm32f10x_stdperiph_lib_um.chm》以及 《STM32F10X-中文参考手册》的《电源控制PWR》及《实时时钟RTC》章节。

42.1. RTC实时时钟简介

STM32的RTC外设(Real Time Clock),实质是一个掉电后还继续运行的定时器。从定时器的角度来说,相对于通用定时器TIM外设,它十分简单, 只有很纯粹的计时和触发中断的功能;但从掉电还继续运行的角度来说,它却是STM32中唯一一个具有如此强大功能的外设。 所以RTC外设的复杂之处并不在于它的定时功能,而在于它掉电还继续运行的特性。

以上所说的掉电,是指主电源VDD断开的情况,为了RTC外设掉电继续运行,必须接上锂电池给STM32的RTC、 备份发卡通过VBAT引脚供电。当主电源VDD有效时,由VDD给RTC外设供电; 而当VDD掉电后,由VBAT给RTC外设供电。但无论由什么电源供电,RTC中的数据都保存在属于RTC的备份域中, 若主电源VDD和VBAT都掉电,那么备份域中保存的所有数据将丢失。备份域除了RTC模块的寄存器, 还有42个16位的寄存器可以在VDD掉电的情况下保存用户程序的数据,系统复位或电源复位时,这些数据也不会被复位。

从RTC的定时器特性来说,它是一个32位的计数器,只能向上计数。它使用的时钟源有三种,分别为高速外部时钟的128分频(HSE/128)、 低速内部时钟LSI以及低速外部时钟LSE;使HSE分频时钟或LSI的话,在主电源VDD掉电的情况下,这两个时钟来源都会受到影响, 因此没法保证RTC正常工作。因此RTC一般使用低速外部时钟LSE,在设计中,频率通常为实时时钟模块中常用的32.768KHz, 这是因为32768 = 215,分频容易实现,所以它被广泛应用到RTC模块。在主电源VDD有效的情况下(待机), RTC还可以配置闹钟事件使STM32退出待机模式。

42.2. RTC外设框图剖析

RTC架构图

框图中浅灰色的部分都是属于备份域的,在VDD掉电时可在VBAT的驱动下继续运行。 这部分仅包括RTC的分频器,计数器,和闹钟控制器。若VDD电源有效,RTC可以触发RTC_Second(秒中断)、 RTC_Overflow(溢出事件)和RTC_Alarm(闹钟中断)。从结构图可以分析到,其中的定时器溢出事件无法被配置为中断。 若STM32原本处于待机状态,可由闹钟事件或WKUP事件(外部唤醒事件,属于EXTI模块,不属于RTC)使它退出待机模式。 闹钟事件是在计数器RTC_CNT的值等于闹钟寄存器RTC_ALR的值时触发的。

在备份域中所有寄存器都是16位的, RTC控制相关的寄存器也不例外。它的计数器RTC_CNT的32位由RTC_CNTL和RTC_CNTH两个寄存器组成,分别保存定时计数值的低16位和高16位。 在配置RTC模块的时钟时,通常把输入的32768Hz的RTCCLK进行32768分频得到实际驱动计数器的时钟 TR_CLK =RTCCLK/32768= 1 Hz, 计时周期为1秒,计时器在TR_CLK的驱动下计数,即每秒计数器RTC_CNT的值加1。

由于备份域的存在,使得RTC核具有了完全独立于APB1接口的特性, 也因此对RTC寄存器的访问要遵守一定的规则。

系统复位后,默认禁止访问后备寄存器和RTC,防止对后备区域(BKP)的意外写操作。 执行以下操作使能对后备寄存器和RTC的访问:

(1) 设置RCC_APB1ENR寄存器的PWREN和BKPEN位来使能电源和后备接口时钟。

(2) 设置PWR_CR寄存器的DBP位使能对后备寄存器和RTC的访问。

设置后备寄存器为可访问后,在第一次通过APB1接口访问RTC时,因为时钟频率的差异,所以必须等待APB1与RTC外设同步, 确保被读取出来的RTC寄存器值是正确的。若在同步之后,一直没有关闭APB1的RTC外设接口,就不需要再次同步了。

如果内核要对RTC寄存器进行任何的写操作,在内核发出写指令后,RTC模块在3个RTCCLK时钟之后,才开始正式的写RTC寄存器操作。 由于RTCCLK的频率比内核主频低得多,所以每次操作后必须要检查RTC关闭操作标志位RTOFF,当这个标志被置1时,写操作才正式完成。

当然,以上的操作都具有库函数,读者不必具体地查阅寄存器。

42.3. UNIX时间戳

在使用RTC外设前,还需要引入UNIX时间戳的概念。

如果从现在起,把计数器RTC_CNT的计数值置0,然后每秒加1, RTC_CNT什么时候会溢出呢?由于RTC_CNT是32位寄存器, 可存储的最大值为(232-1),即这样计时的话,在232秒后溢出,即它将在今后的136年时溢出:

N = 232/365/24/60/60 ≈136年

假如某个时刻读取到计数器的数值为X = 60*60*24*2,即两天时间的秒数,而假设又知道计数器是在2011年1月1日的0时0分0秒置0的, 那么就可以根据计数器的这个相对时间数值,计算得这个X时刻是2011年1月3日的0时0分0秒了。而计数器则会在(2011+136)年左右溢出, 也就是说到了(2011+136)年时,如果我们还在使用这个计数器提供时间的话就会出现问题。在这个例子中,定时器被置0的这个时间被称为计时元年, 相对计时元年经过的秒数称为时间戳,也就是计数器中的值。

大多数操作系统都是利用时间戳和计时元年来计算当前时间的,而这个时间戳和计时元年大家都取了同一个标准——UNIX时间戳和UNIX计时元年。 UNIX计时元年被设置为格林威治时间1970年1月1日0时0分0秒,大概是为了纪念UNIX的诞生的时代吧, 而UNIX时间戳即为当前时间相对于UNIX计时元年经过的秒数。因为unix时间戳主要用来表示当前时间或者和电脑有关的日志时间(如文件创立时间,log发生时间等), 考虑到所有电脑文件不可能在1970年前创立,所以用unix时间戳很少用来表示1970前的时间。

在这个计时系统中,使用的是有符号的32位整型变量来保存UNIX时间戳的,即实际可用计数位数比我们上面例子中的少了一位, 少了这一位,UNIX计时元年也相对提前了,这个计时方法在2038年1月19日03时14分07秒将会发生溢出,这个时间离我们并不远。 由于UNIX时间戳被广泛应用到各种系统中,溢出可能会导致系统发生严重错误,届时,很可能会重演一次“千年虫”的问题,所以在设计预期寿命较长的设备需要注意。

在网络上搜索“UNIX时间戳”可找到一些网站提供当前实时的UNIX时间戳,见图 某些网站显示的实时UNIX时间戳

某些网站显示的实时UNIX时间戳

42.4. 与RTC控制相关的库函数

STM32标准库对RTC控制提供了完善的函数,使用它们可以方便地进行控制,本小节对这些内容进行讲解。

42.4.1. 等待时钟同步和操作完成

RTC区域的时钟比APB时钟慢,访问前需要进行时钟同步,只要调用库函数RTC_WaitForSynchro即可,而如果修改了RTC的寄存器, 又需要调用RTC_WaitForLastTask函数确保数据已写入,见 代码清单:RTC-1

代码清单:RTC-1 等待时钟同步和操作完成
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* @brief  等待RTC寄存器与APB时钟同步 (RTC_CNT, RTC_ALR and RTC_PRL)
* @note   在APB时钟复位或停止后,在对RTC寄存器的任何操作前,必须调用本函数
* @param  None
* @retval None
*/
void RTC_WaitForSynchro(void)
{
    /* 清除 RSF 寄存器位 */
    RTC->CRL &= (uint16_t)~RTC_FLAG_RSF;
    /* 等待至 RSF 寄存器位为SET */
    while ((RTC->CRL & RTC_FLAG_RSF) == (uint16_t)RESET) {
    }
}

/**
* @brief  等待上一次对 RTC寄存器的操作完成
* @note   修改RTC寄存器后,必须调用本函数
* @param  None
* @retval None
*/
void RTC_WaitForLastTask(void)
{
    /* 等待至 RTOFF 寄存器位为SET*/
    while ((RTC->CRL & RTC_FLAG_RTOFF) == (uint16_t)RESET) {
    }
}

这两个库函数主要通过while循环检测RTC控制寄存器的RSF和RTOFF位实现等待功能。

42.4.2. 使能备份域说及RTC配置

默认情况下,RTC所属的备份域禁止访问,可使用库函数PWR_BackupAccessCmd使能访问,见 代码清单:RTC-2

代码清单:RTC-2 使能备份域访问
1
2
3
4
5
6
7
8
9
/**
* @brief  使能对 RTC 和 backup 寄存器的访问.
* @param   ENABLE 或 DISABLE.
* @retval None
*/
void PWR_BackupAccessCmd(FunctionalState NewState)
{
    *(__IO uint32_t *) CR_DBP_BB = (uint32_t)NewState;
}

该函数通过PWR_CR寄存器的DBP位使能访问,使能后才可以访问RTC相关的寄存器,然而若希望修改RTC的寄存器, 还需要进一步使能RTC控制寄存器的CNF位使能寄存器配置,见 代码清单:RTC-3

代码清单:RTC-3 进入和退出RTC配置模式
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @brief  进入 RTC 配置模式 .
* @param  None
* @retval None
*/
void RTC_EnterConfigMode(void)
{
    /* 设置 CNF 位进入配置模式 */
    RTC->CRL |= RTC_CRL_CNF;
}

/**
* @brief  退出 RTC 配置模式 .
* @param  None
* @retval None
*/
void RTC_ExitConfigMode(void)
{
    /* 清空  CNF 位退出配置模式 */
    RTC->CRL &= (uint16_t)~((uint16_t)RTC_CRL_CNF);
}

这两个库函数分别提供了进入和退出RTC寄存器的配置模式,一般情况下它们由库函数调用。

42.4.3. 设置RTC时钟分频

使用RCC相关的库函数选择RTC使用的时钟后,可以使用库RTC_SetPrescaler进行分频, 一般会把RTC时钟分频得到1Hz的时钟,见 代码清单:RTC-4

代码清单:RTC-4 设置RTC时钟分频
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/**
* @brief  设置RTC分频配置
* @param  PrescalerValue: RTC 分频值.
* @retval None
*/
void RTC_SetPrescaler(uint32_t PrescalerValue)
{
    RTC_EnterConfigMode();
    /* 设置 RTC 分频值的 MSB  */
    RTC->PRLH = (PrescalerValue & PRLH_MSB_MASK) >> 16;
    /* 设置 RTC 分频值的 LSB  */
    RTC->PRLL = (PrescalerValue & RTC_LSB_MASK);
    RTC_ExitConfigMode();
}

在函数中,使用RTC_EnterConfigMode和RTC_ExitConfigMode进入和退出RTC寄存器配置模式, 配置时把函数参数PrescalerValue写入到RTC的PRLH和PRLL寄存器中。

42.4.4. 设置、获取RTC计数器及闹钟

RTC外设中最重要的就是计数器以及闹钟寄存器了,它们可以使用RTC_SetCounter、 RTC_GetCounter以及RTC_SetAlarm库函数操作,见 代码清单:RTC-5

代码清单:RTC-5 设置RTC计数器及闹钟
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* @brief  设置 RTC 计数器的值 .
* @param  CounterValue: 要设置的RTC计数器值.
* @retval None
*/
void RTC_SetCounter(uint32_t CounterValue)
{
    RTC_EnterConfigMode();
    /* 设置 RTC 计数器的 MSB  */
    RTC->CNTH = CounterValue >> 16;
    /* 设置 RTC 计数器的 LSB  */
    RTC->CNTL = (CounterValue & RTC_LSB_MASK);
    RTC_ExitConfigMode();
}

/**
* @brief  获取 RTC 计数器的值 .
* @param  None
* @retval 返回RTC计数器的值
*/
uint32_t RTC_GetCounter(void)
{
    uint16_t tmp = 0;
    tmp = RTC->CNTL;
    return (((uint32_t)RTC->CNTH << 16 ) | tmp) ;
}

/**
* @brief  设置 RTC 闹钟的值 .
* @param  AlarmValue: 要设置的RTC闹钟值.
* @retval None
*/
void RTC_SetAlarm(uint32_t AlarmValue)
{
    RTC_EnterConfigMode();
    /* 设置 RTC 闹钟的 MSB  */
    RTC->ALRH = AlarmValue >> 16;
    /* 设置 RTC 闹钟的 LSB  */
    RTC->ALRL = (AlarmValue & RTC_LSB_MASK);
    RTC_ExitConfigMode();
}

利用RTC_SetCounter可以向RTC的计数器写入新数值,通常这些数值被设置为时间戳以更新时间。

RTC_GetCounter函数则用于在RTC正常运行时获取当前计数器的值以获取当前时间。

RTC_SetAlarm函数用于配置闹钟时间,当计数器的值与闹钟寄存器的值相等时, 可产生闹钟事件或中断,该事件可以把睡眠、停止和待机模式的STM32芯片唤醒。

42.5. 利用RTC提供北京时间

从上面的分析可知,RTC外设是个连续计数的计数器,利用它提供的时间戳,可通过程序转换输出实时时钟和日历的功能, 修改计数器的值则可以重新设置系统当前的时间和日期。由于它的时钟配置系统(RCC_BDCR寄存器)是在备份域,在系统复位或从待机模式唤醒后RTC的设置维持不变, 而且使用备份域电源可以使RTC计时器在主电源关掉的情况下仍然运行,保证时间的正确。

42.5.1. 硬件设计

开发板中与RTC相关的硬件设计见图 RTC硬件电路

RTC硬件电路

原理图中的右上角是备份域的供电电路,在本开发板中提供了一个钮扣电池插槽,可以接入型号为CR1220的钮扣电池,该型号的钮扣电池电压为3.2V, 图中的BAT54C双向二极管可切换输入到STM32备份域电源引脚VBAT的供电,当主电源正常供电时,由稳压器输出的3.3V供电,当主电源掉电时,由钮扣电池供电 。

原理图下方的是本开发板采用的LSE晶振电路,此处使用的晶振频率为32.768KHz,RTC外设可以使用LSE作为时钟,把它进行分频得到1Hz的RTC计时时钟。

注意

本实验默认使用LSI内部时钟,使用内部时钟时,即使安装了钮扣电池,主电源掉电后时间是不会继续走的,只会保留上次断电的时间。 若要持续运行,需要修改bsp_rtc.h文件,使用RTC_CLOCK_SOURCE_LSE宏,切换成使用LSE外部时钟。

42.5.2. 软件设计

本小节讲解的是“RTC—实时时钟/RTC—万年历实验”实验,请打开配套的代码工程阅读理解,本工程与RTC底层驱动相关的文件为bsp_rtc.c/h, 在底层驱动之上我们添加了从开源代码中获取的bsp_calendar.c/h和bsp_date.c/h文件,用于万年历的计算。

42.5.2.1. 程序设计要点

  1. 初始化RTC外设;

  2. 设置时间以及添加配置标志;

  3. 获取当前时间;

42.5.2.2. 代码分析

RTC实验配置相关宏定义

在这个RTC实验中的bsp_rtc.h文件中添加了一些宏定义用于切换工程的配置,见 代码清单:RTC-6

代码清单:RTC-6:RTC实验配置相关的宏定义(bsp_rtc.h文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//是否使用LCD显示日期
#define USE_LCD_DISPLAY

//使用LSE外部时钟 或 LSI内部时钟
//#define RTC_CLOCK_SOURCE_LSE
#define RTC_CLOCK_SOURCE_LSI

#define RTC_BKP_DRX          BKP_DR1
// 写入到备份寄存器的数据宏定义
#define RTC_BKP_DATA         0xA5A5
//北京时间的时区秒数差
#define TIME_ZOOM       (8*60*60)

以上代码定义的宏介绍如下:

  • USE_LCD_DISPLAY:这个宏可以用于切换本工程是否使用液晶屏显示时间,把它注释掉可以关闭液晶显示,方便移植到没有液晶的应用中。

  • RTC_CLOCK_SOURCE_LSE/LSI:这两个宏用于选择使用LSE作外部时钟还是LSI作外部时钟。 提供两种选择主要是因为STM32的LSE晶振在批量产品时容易不起振, 而LSI则在主电源关闭后计时时间不会继续增加。

  • RTC_BKP_DRX和RTC_BKP_DATA:这两个宏用于在备份域寄存器设置RTC已配置标志,本实验中初始化RTC后,向备份域寄存器写入一个数字, 若下次芯片上电检测到该标志,说明RTC之前已经配置好时间,所以不应该再设置RTC,而如果备份域电源也掉电,备份域内记录的该标志也会丢失, 所以芯片上电后需要重新设置时间。这两个宏的值中,BKP_DR1是备份域的其中一个寄存器,而0xA5A5则是随意选择的数字,只要写入和检测一致即可。

  • TIME_ZOOM:这个宏用于设置时区的秒数偏移,例如北京时间为(GMT+8) 时区,即相对于格林威治时间(GMT) 早8个小时, 此处使用的宏值即为8个小时的秒数(8*60*60),若使用其它时区,修改该宏即可。

关于这些宏的作用,在后面的C源代码中都会有体现。

初始化RTC

在本工程中,我们编写了RTC_Configuration函数对RTC进行初始化,见 代码清单:RTC-7

代码清单:RTC-7:RTC_Configuration函数(bsp_rtc.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/*
* 函数名:RTC_Configuration
* 描述  :配置RTC
* 输入  :无
* 输出  :无
* 调用  :外部调用
*/
void RTC_Configuration(void)
{
    /* 使能 PWR 和 Backup 时钟 */
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE);

    /* 允许访问 Backup 区域 */
    PWR_BackupAccessCmd(ENABLE);

    /* 复位 Backup 区域 */
    BKP_DeInit();

//使用外部时钟还是内部时钟(在bsp_rtc.h文件定义)
//使用外部时钟时,在有些情况下晶振不起振
//批量产品的时候,很容易出现外部晶振不起振的情况,不太可靠
#ifdef  RTC_CLOCK_SOURCE_LSE
    /* 使能 LSE */
    RCC_LSEConfig(RCC_LSE_ON);

    /* 等待 LSE 准备好 */
    while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET) {
    }

    /* 选择 LSE 作为 RTC 时钟源 */
    RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);

    /* 使能 RTC 时钟 */
    RCC_RTCCLKCmd(ENABLE);

    /* 等待 RTC 寄存器 同步
    * 因为RTC时钟是低速的,内环时钟是高速的,所以要同步
    */
    RTC_WaitForSynchro();

    /* 确保上一次 RTC 的操作完成 */
    RTC_WaitForLastTask();

    /* 使能 RTC 秒中断 */
    RTC_ITConfig(RTC_IT_SEC, ENABLE);

    /* 确保上一次 RTC 的操作完成 */
    RTC_WaitForLastTask();

    /* 设置 RTC 分频: 使 RTC 周期为1s  */
    /* RTC period = RTCCLK/RTC_PR = (32.768 KHz)/(32767+1) = 1HZ */
    RTC_SetPrescaler(32767);

    /* 确保上一次 RTC 的操作完成 */
    RTC_WaitForLastTask();

#else

    /* 使能 LSI */
    RCC_LSICmd(ENABLE);

    /* 等待 LSI 准备好 */
    while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) == RESET) {
    }

    /* 选择 LSI 作为 RTC 时钟源 */
    RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);

    /* 使能 RTC 时钟 */
    RCC_RTCCLKCmd(ENABLE);

    /* 等待 RTC 寄存器 同步
    * 因为RTC时钟是低速的,内环时钟是高速的,所以要同步
    */
    RTC_WaitForSynchro();

    /* 确保上一次 RTC 的操作完成 */
    RTC_WaitForLastTask();

    /* 使能 RTC 秒中断 */
    RTC_ITConfig(RTC_IT_SEC, ENABLE);

    /* 确保上一次 RTC 的操作完成 */
    RTC_WaitForLastTask();

    /* 设置 RTC 分频: 使 RTC 周期为1s ,LSI约为40KHz */
    /* RTC period = RTCCLK/RTC_PR = (40 KHz)/(40000-1+1) = 1HZ */
    RTC_SetPrescaler(40000-1);

    /* 确保上一次 RTC 的操作完成 */
    RTC_WaitForLastTask();
#endif

}

在这个初始化函数里,分成了使用LSE和LSI的初始化配置,这两种配置的初始化过程类似,都直接调用了前面介绍的各种RTC相关的库函数

这个初始化的流程如下:使用RCC_APB1PeriphClockCmd使能PWR和BKP区域(即备份域)的时钟系统,使用PWR_BackupAccessCmd设置允许对BKP区域的访问,使能LSE时钟或LSI时钟,选择LSE或LSI作为RTC的时钟源并使能RTC时钟,利用库函数RTC_WaitFor Synchro对备份域和APB进行同步,用RTC_ITConfig使能秒中断,使用RTC_SetPrescaler分频配置把RTC时钟频率设置为1Hz。那么RTC每个时钟周期都会产生一次中断对RTC的每一个初始化参数都是使用相应的库函数来配置的。

经过这样的配置后,RTC每秒产生一次中断事件,实验中在中断设置标志位以便更新时间。

时间管理结构体

RTC初始化完成后可以直接往它的计数器写入时间戳,但是时间戳对用户不友好,不方便配置和显示时间, 在本工程中,使用bsp_date.h文件的rtc_time结构体来管理时间, 见 代码清单:RTC-8

代码清单:RTC-8:时间管理结构体的定义
1
2
3
4
5
6
7
8
9
struct rtc_time {
    int tm_sec;
    int tm_min;
    int tm_hour;
    int tm_mday;
    int tm_mon;
    int tm_year;
    int tm_wday;
};

这个类型的结构体具有时、分、秒、日、月、年及星期这7个成员。当需要给RTC的计时器重新配置或显示时间时,使用这种容易接受的时间表示方式。

在配置RTC时,使用这种类型的变量保存用户输入的时间,然后利用函数由该时间求出对应的UNIX时间戳,写入RTC的计数器;RTC正常运行后, 需要输出时间时,利用函数通过RTC的计数器获取UNIX时间戳,转化成这种友好的时间表示方式保存到变量输出。

其实在C语言标准库ANSI C中,也具有类似这样的结构体struct tm ,位于标准的time.h文件中, 而具有以上功能的转化函数则为mktime和localtime,它们分别把tm格式的时间转化成时间戳和用时间戳转化成tm格式。 而在本实验中直接使用了开源的万年历算法源码,便于修改和学习。

时间格式转换

在本实验中,tm格式转时间戳使用mktimev函数,时间戳转tm格式使用to_tm函数,这两个函数都定义在bsp_date.c文件中, 见 代码清单:RTC-9

代码清单:RTC-9:mktimev和to_tm函数(bsp_date.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/* Converts Gregorian date to seconds since 1970-01-01 00:00:00.
* Assumes input in normal date format, i.e. 1980-12-31 23:59:59
* => year=1980, mon=12, day=31, hour=23, min=59, sec=59.
*
* [For the Julian calendar (which was used in Russia before 1917,
* Britain & colonies before 1752, anywhere else before 1582,
* and is still in use by some communities) leave out the
* -year/100+year/400 terms, and add 10.]
*
* This algorithm was first published by Gauss (I think).
*
* WARNING: this function will overflow on 2106-02-07 06:28:16 on
* machines were long is 32-bit! (However, as time_t is signed, we
* will already get problems at other places on 2038-01-19 03:14:08)
*
*/
u32 mktimev(struct rtc_time *tm)
{
    if (0 >= (int) (tm->tm_mon -= 2)) { /* 1..12 -> 11,12,1..10 */
        tm->tm_mon += 12;   /* Puts Feb last since it has leap day */
        tm->tm_year -= 1;
    }

    return (((
            (u32) (tm->tm_year/4 - tm->tm_year/100 + tm->tm_year/400 +
            367*tm->tm_mon/12 + tm->tm_mday) + tm->tm_year*365 - 719499
            )*24 + tm->tm_hour /* now have hours */
            )*60 + tm->tm_min /* now have minutes */
        )*60 + tm->tm_sec; /* finally seconds */
}



void to_tm(u32 tim, struct rtc_time * tm)
{
    register u32    i;
    register long   hms, day;

    day = tim / SECDAY;     /* 有多少天 */
    hms = tim % SECDAY;     /* 今天的时间,单位s */

    /* Hours, minutes, seconds are easy */
    tm->tm_hour = hms / 3600;
    tm->tm_min = (hms % 3600) / 60;
    tm->tm_sec = (hms % 3600) % 60;

    /* Number of years in days */ /*算出当前年份,起始的计数年份为1970年*/
    for (i = STARTOFTIME; day >= days_in_year(i); i++) {
        day -= days_in_year(i);
    }
    tm->tm_year = i;

    /* Number of months in days left */ /*计算当前的月份*/
    if (leapyear(tm->tm_year)) {
        days_in_month(FEBRUARY) = 29;
    }
    for (i = 1; day >= days_in_month(i); i++) {
        day -= days_in_month(i);
    }
    days_in_month(FEBRUARY) = 28;
    tm->tm_mon = i;

    /* Days are what is left over (+1) from all that. *//*计算当前日期*/
    tm->tm_mday = day + 1;

    /*
    * Determine the day of week
    */
    GregorianDay(tm);
}

关于日期计算的细节此处不作详细分析,其原理是以1970年1月1日0时0分0秒为计时基点,对日期和以秒数表示时间戳进行互相转化,转化重点在于闰年的计算。

这两个函数都是以格林威治时间(GMT)时区来计算的,在调用这些函数时我们会对输入参数加入时区偏移的运算,进行调整。

配置时间

有了以上的准备,接下来学习一下Time_Adjust函数,见 代码清单:RTC-10

代码清单:RTC-10:Time_Adjust函数(bsp_rtc.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
* 函数名:Time_Adjust
* 描述  :时间调节
* 输入  :用于读取RTC时间的结构体指针(北京时间)
* 输出  :无
* 调用  :外部调用
*/
void Time_Adjust(struct rtc_time *tm)
{

    /* RTC 配置 */
    RTC_Configuration();

    /* 等待确保上一次操作完成 */
    RTC_WaitForLastTask();

    /* 由日期计算时间戳并写入到RTC计数寄存器 */
    RTC_SetCounter(mktimev(tm)-TIME_ZOOM);

    /* 等待确保上一次操作完成 */
    RTC_WaitForLastTask();
}

Time_Adjust函数用于配置时间,它先调用前面的RTC_Configuration初始化RTC,接着调用库函数RTC_SetCounter向RTC计数器写入要设置时间的时间戳值, 而时间戳的值则使用mktimev函数通过输入参数tm来计算,计算后还与宏TIME_ZOOM运算,计算时区偏移值。此处的输入参数tm是北京时间, 所以“mktimev(tm)-TIME_ZOOM”计算后写入到RTC计数器的是格林威治时区的标准UNIX时间戳。

检查并配置RTC

上面的Time_Adjust函数直接把参数写入到RTC中修改配置,但在芯片每次上电时,并不希望每次都修改系统时间, 所以我们增加了RTC_CheckAndConfig函数用于检查是否需要向RTC写入新的配置,见。

代码清单:RTC-11:RTC_CheckAndConfig函数(bsp_rtc.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/*
* 函数名:RTC_CheckAndConfig
* 描述  :检查并配置RTC
* 输入  :用于读取RTC时间的结构体指针
* 输出  :无
* 调用  :外部调用
*/
void RTC_CheckAndConfig(struct rtc_time *tm)
{
    /*在启动时检查备份寄存器BKP_DR1,如果内容不是0xA5A5,
    则需重新配置时间并询问用户调整时间*/
    if (BKP_ReadBackupRegister(RTC_BKP_DRX) != RTC_BKP_DATA) {
        printf("\r\n\r\n RTC not yet configured....");
        printf("\r\n\r\n RTC configured....");

        /* 使用tm的时间配置RTC寄存器 */
        Time_Adjust(tm);

        /*向BKP_DR1寄存器写入标志,说明RTC已在运行*/
        BKP_WriteBackupRegister(RTC_BKP_DRX, RTC_BKP_DATA);
    } else {

        /* 使能 PWR 和 Backup 时钟 */
        RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE);

        /* 允许访问 Backup 区域 */
        PWR_BackupAccessCmd(ENABLE);

        /*LSE启动无需设置新时钟*/

#ifdef RTC_CLOCK_SOURCE_LSI
        /* 使能 LSI */
        RCC_LSICmd(ENABLE);

        /* 等待 LSI 准备好 */
        while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) == RESET) {
        }
#endif

        /*检查是否掉电重启*/
        if (RCC_GetFlagStatus(RCC_FLAG_PORRST) != RESET) {
            printf("\r\n\r\n Power On Reset occurred....");
        }
        /*检查是否Reset复位*/
        else if (RCC_GetFlagStatus(RCC_FLAG_PINRST) != RESET) {
            printf("\r\n\r\n External Reset occurred....");
        }

        printf("\r\n No need to configure RTC....");

        /*等待寄存器同步*/
        RTC_WaitForSynchro();

        /*允许RTC秒中断*/
        RTC_ITConfig(RTC_IT_SEC|RTC_IT_ALR, ENABLE);

        /*等待上次RTC寄存器写操作完成*/
        RTC_WaitForLastTask();
    }
    /*定义了时钟输出宏,则配置校正时钟输出到PC13*/
#ifdef RTCClockOutput_Enable

    /* 禁止 Tamper 引脚 */
    /* 要输出 RTCCLK/64 到 Tamper 引脚,  tamper 功能必须禁止 */
    BKP_TamperPinCmd(DISABLE);

    /* 使能 RTC 时钟输出到 Tamper 引脚 */
    BKP_RTCOutputConfig(BKP_RTCOutputSource_CalibClock);
#endif

    /* 清除复位标志 flags */
    RCC_ClearFlag();

}

在本函数中,会检测备份域寄存器RTC_BKP_DRX内的值是否等于RTC_BKP_DATA而分成两个分支。

若不等,说明之前没有配置RTC所以直接调用Time_Adjust函数初始化RTC并写入时间戳进行计时,配置完成后向备份域寄存器RTC_BKP_DRX写入值RTC_BKP_DATA作为标志, 这样该标志就可以指示RTC的配置情况了,因为备份域不掉电时,RTC和该寄存器的值都会保存完好,而如果备份域掉电,那么RTC配置和该标志都会一同丢失;

若本函数的标志判断相等,进入else分支,不再调用Time_Adjust函数初始化RTC,而只是使用RTC_WaitForSynchro和RTC_ITConfig同步RTC域和APB以及使能中断, 以便获取时间。如果使用的是LSI时钟,还需要使能LSI时钟,RTC才会正常运行,这是因为当主电源掉电和备份域的情况下,LSI会关闭,而LSE则会正常运行,驱动RTC计时。

转换并输出时间

RTC正常运行后,可以使用Time_Display函数转换时间格式并输出到串口及液晶屏, 见 代码清单:RTC-12

代码清单:RTC-12:Time_Display函数(bsp_rtc.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/*
* 函数名:Time_Display
* 描述  :显示当前时间值
* 输入  :-TimeVar RTC计数值,单位为 s
* 输出  :无
* 调用  :内部调用
*/
void Time_Display(uint32_t TimeVar,struct rtc_time *tm)
{
    static uint32_t FirstDisplay = 1;
    uint32_t BJ_TimeVar;
    uint8_t str[200]; // 字符串暂存

    /*  把标准时间转换为北京时间*/
    BJ_TimeVar =TimeVar + TIME_ZOOM;

    to_tm(BJ_TimeVar, tm);/*把定时器的值转换为北京时间*/

    if ((!tm->tm_hour && !tm->tm_min && !tm->tm_sec)  || (FirstDisplay)) {
        GetChinaCalendar((u16)tm->tm_year, (u8)tm->tm_mon, (u8)tm->tm_mday, str);
        printf("\r\n 今天新历:%0.2d%0.2d,%0.2d,%0.2d", str[0], str[1], str[2],  str[3]);

        GetChinaCalendarStr((u16)tm->tm_year,(u8)tm->tm_mon,(u8)tm->tm_mday,str);
        printf("\r\n 今天农历:%s\r\n", str);

    if (GetJieQiStr((u16)tm->tm_year, (u8)tm->tm_mon, (u8)tm->tm_mday, str))
            printf("\r\n 今天农历:%s\r\n", str);

        FirstDisplay = 0;
    }

    /* 输出时间戳,公历时间 */
    rintf(" UNIX时间戳 = %d 当前时间为: %d年(%s年) %d月 %d日 (星期%s)  %0.2d:%0.2d:%0.2d\r",TimeVar,
    tm->tm_year, zodiac_sign[(tm->tm_year-3)%12], tm->tm_mon, tm->tm_mday,
        WEEK_STR[tm->tm_wday], tm->tm_hour,
        tm->tm_min, tm->tm_sec);

#ifdef  USE_LCD_DISPLAY

    //时间戳
    sprintf((char *)str," UNIX TimeStamp = %d ",TimeVar);

    ILI9341_DispStringLine_EN(LINE(3),(char*)str);

    //日期
    sprintf((char *)str," Date: %d-%d-%d       ",
            tm->tm_year,
            tm->tm_mon,
            tm->tm_mday);
    ILI9341_DispStringLine_EN(LINE(5),(char*)str);

    //生肖
    sprintf((char *)str," Chinese %s year      ",en_zodiac_sign[(tm->tm_year-3)%12]);

    ILI9341_DispStringLine_EN(LINE(6),(char*)str);

    //星期
    sprintf((char *)str," %s                  ",en_WEEK_STR[tm->tm_wday]);

    ILI9341_DispStringLine_EN(LINE(7),(char*)str);

    //时间
    sprintf((char *)str," Time:  %0.2d:%0.2d:%0.2d",
            tm->tm_hour,
            tm->tm_min,
            tm->tm_sec);

    ILI9341_DispStringLine_EN(LINE(8),(char*)str);
#endif

}

本函数的核心部分已加粗显示,主要是使用to_tm把时间戳转换成日常生活中使用的时间格式,to_tm以BJ_TimeVar作为输入参数, 而BJ_TimeVar对时间戳变量Time_Var进行了时区偏移,也就是说调用Time_Display函数时,以RTC计数器的值作为TimeVar作为输入参数即可, 最终会输出北京时间。

利用to_tm转换格式后,调用bsp_calendar.c文件中的日历计算函数,求出星期、农历、生肖等内容,然后使用串口和液晶屏显示出来。

中断服务函数

一般来说,上面的Time_Display时间显示每秒中更新一次,而根据前面的配置,RTC每秒会进入一次中断, 本实验中的RTC中断服务函数见 代码清单:RTC-13

代码清单:RTC-13:中断服务函数(stm32f10x_it.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/**
* @brief  This function handles RTC interrupt request.
* @param  None
* @retval None
*/
void RTC_IRQHandler(void)
{
    if (RTC_GetITStatus(RTC_IT_SEC) != RESET) {
        /* Clear the RTC Second interrupt */
        RTC_ClearITPendingBit(RTC_IT_SEC);

        /* Enable time update */
        TimeDisplay = 1;

    /* Wait until last write operation on RTC registers has finished */
        RTC_WaitForLastTask();
    }
}

RTC的秒中断服务函数只是简单地对全局变量TimeDisplay置1,在main函数的while循环中会检测这个标志,当标志为1时, 就调用Time_Display函数显示一次时间,达到每秒钟更新当前时间的效果。

main函数

代码清单:RTC-14:RTC例程中的main函数(main.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/*时间结构体,默认时间2000-01-01 00:00:00*/
struct rtc_time systmtime= {
    0,0,0,1,1,2000,0
};

extern __IO uint32_t TimeDisplay ;
/**
* @brief  主函数
* @param  无
* @retval 无
*/
int main()
{
//可使用该宏设置是否使用液晶显示
#ifdef  USE_LCD_DISPLAY

    ILI9341_Init ();         //LCD 初始化
    LCD_SetFont(&Font8x16);
    LCD_SetColors(RED,BLACK);

    ILI9341_Clear(0,0,LCD_X_LENGTH,LCD_Y_LENGTH);

    ILI9341_DispStringLine_EN(LINE(0),"        BH RTC demo");
#endif

    USART_Config();

    Key_GPIO_Config();

    /* 配置RTC秒中断优先级 */
    RTC_NVIC_Config();
    RTC_CheckAndConfig(&systmtime);

    while (1) {
        /* 每过1s 更新一次时间*/
        if (TimeDisplay == 1) {
            /* 当前时间 */
            Time_Display( RTC_GetCounter(),&systmtime);
            TimeDisplay = 0;
        }

        //按下按键,通过串口修改时间
        if ( Key_Scan(KEY1_GPIO_PORT,KEY1_GPIO_PIN) == KEY_ON  ) {
            struct rtc_time set_time;

            /*使用串口接收设置的时间,输入数字时注意末尾要加回车*/
            Time_Regulate_Get(&set_time);
            /*用接收到的时间设置RTC*/
            Time_Adjust(&set_time);

            //向备份寄存器写入标志
            BKP_WriteBackupRegister(RTC_BKP_DRX, RTC_BKP_DATA);
        }
    }
}

main函数的流程非常清晰,初始化了液晶、串口等外设后,调用RTC_CheckAndConfig函数初始化RTC,若RTC是第一次初始化,就使用变量systmtime中的默认时间配置, 若之前已配置好RTC,那么RTC_CheckAndConfig函数仅同步时钟系统,便于获取实时时间。在while循环里检查中断设置的TimeDisplay是否置1, 若置1了就调用Time_Display函数,它的输入参数是库函数RTC_GetCounter的返回值,也就是RTC计数器里的时间戳, Time_Display函数把该时间戳转化成北京时间显示到串口和液晶上。

使用串口配置时间

在main函数的44-54行,是一个按键检测分支,当检测到开发板上的KEY1被按下时,会调用Time_Regulate_Get函数通过串口获取配置时间, 然后把获取得的时间输入到Time_Adjust函数把该时间写入到RTC计数器中,更新配置, Time_Regulate_Get函数内容见 代码清单:RTC-15

代码清单:RTC-15:Time_Regulate_Get函数(bsp_rtc.c文件)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
* 函数名:Time_Regulate_Get
* 描述  :保存用户使用串口设置的时间,
*         以便后面转化成时间戳存储到RTC 计数寄存器中。
* 输入  :用于读取RTC时间的结构体指针
* 注意  :在串口调试助手输入时,输入完数字要加回车
*/
void Time_Regulate_Get(struct rtc_time *tm)
{
    uint32_t temp_num = 0;
    uint8_t day_max=0 ;
    printf("\r\n=========================设置时间==================");
    do {
        printf("\r\n  请输入年份(Please Set Years),
            范围[1970~2038],输入字符后请加回车:");
        scanf("%d",&temp_num);
        if (temp_num <1970 || temp_num >2038) {
            printf("\r\n 您输入的数字是:%d,不符合要求",temp_num);
        } else {
            printf("\n\r  年份被设置为: %d\n\r", temp_num);
            tm->tm_year = temp_num;
            break;
        }
    } while (1);

    do {
        printf("\r\n  请输入月份(Please Set Months):范围[1~12],输入字符后请加回车:");
        scanf("%d",&temp_num);
        if (temp_num <1 || temp_num >12) {
            printf("\r\n 您输入的数字是:%d,不符合要求",temp_num);
        } else {
            printf("\n\r  月份被设置为: %d\n\r", temp_num);
            tm->tm_mon = temp_num;
            break;
        }
    } while (1);
    /*...以下省略日期、时间获取的代码*/
}

Time_Regulate_Get函数的本质是利用重定向到串口的C标准数据流输入函数scanf获取用户输入,若获取得的数据符合范围, 则赋值到tm结构体中,在main函数中再调用Time_Adjust函数把tm存储的时间写入到RTC计数器中。

注意

必须强调的是,使用scanf通过串口输入时,每次输入完毕后都要加入回车,这样才能正常接收,见图 使用串口配置时间的注意事项

使用串口配置时间的注意事项

42.5.3. 下载验证

配置工程中bsp_rtc.h的宏可以修改使用LSE还是LSI的晶振、是否使用液晶显示以及是选择时区。配置好工程后编译并下载程序到开发板,复位, 可看到串口或液晶输出按默认配置的时间,按下KEY1,参考前面的说明图,可以通过串口修改时间。