这一章节我们要讲的主要内容是 RTC 实时时钟,对应手册,是第 16 章的位置。
实时时钟这个东西,本质上是一个定时器,但是这个定时器,是专门用来产生年月日时分秒,这种日期和时间信息的。所以学会了 STM32 的 RTC,你就可以在 STM32 内部拥有一个独立运行的钟表,想要记录或读取日期和时间,就可以通过操作 RTC 来实现。
那 RTC 这个外设呢,比较特殊,它和备份寄存器 BKP、电源控制 PWR 这两章的关联性比较强,在 RTC 这一章,BKP 和 PWR 也会经常来串门,所以我们这章节,就把 BKP 和 RTC 放在一起介绍,这样整体思路会比较清晰,PWR 电源控制,我们下章节再介绍。
然后,我们这一大章节,分三小节来介绍。第一小节,会单独介绍一下时间戳这个东西,这也是个蛮有意思的知识点。想要使用这款 STM32 的 RTC,学习时间戳的知识点还是非常必要的;第二小节,我们就学习 BKP 和 RTC 外设的结构;最后,第三小节,就是写代码,来完成程序现象了。这就是本章节的安排。
好,那先看一下我们最终的程序现象,本节一共有两个实例代码,12-1 读写备份寄存器,也就是读写 BKP;12-2 实时时钟,就是 OLED 显示年月日时分秒了。
先看一下第一个代码,这里,我们要在 STLINK 上再引出一根 3.3V 的电源,接到 VBAT 引脚,这根线就模拟一个电池的电源。一般情况下,VBAT 是电池供电口,需要接备用电池,但是我们目前套件里没有电池,所以就直接引出一根 3.3V 电源线来,也是一样的效果。那看一下显示屏,这个程序的目的是,在 BKP 备份寄存器写入两个数据,然后再把它们读出来,显示一下,目前 W 是写的内容,我们还没有写入数据,R 是读的内容,默认读出来都是 0。然后,我们可以按一下按钮,这时就在 2 个备份寄存器中,分别写入了 1234 5678,之后,读出来,也是 1234 5678,写入和读出是一样的,没问题。那继续按按键,我们会改变数据,再写入进去,下面读出来,和写入一样,都没问题。其实 BKP 备份寄存器和上一节学的 Flash 存储器类似,都是用来存储数据的,只是 Flash 的数据是真正的掉电不丢失,而 BKP 的数据,是需要 VBAT 引脚接上备用电池来维持的,只要 VBAT 有电池供电,即使 STM32 主电源断电,BKP 的值也可以维持原状。
那我们试一下,拔掉 STM32 板子最下面这个主电源的正极引脚,现在 STM32 断电,但是 VBAT 有电,可以维持 BKP 的数据,再次上电后,在没有写数据的情况下,直接读出 BKP,它的数据和断电之前是一样的,这说明 BKP 的数据在主电源断电后,得到了保持,并且在系统复位后,可以按下复位键,BKP 的数据也不会复位,那如果我们把 VBAT 的电池断电,再次拔掉主电源,重新上电,BKP 的数据就清零了,因为 BKP 本质上,并不能完全掉电不丢失,它的数据,需要 VBAT 引脚提供备用电池来维持,这就是 BKP 备份寄存器的特性。如果你的 STM32 接了备用电池,那 BKP 可以完成一些主电源掉电时,保存少量数据的任务,这就是第一个代码的现象。
其实备份寄存器和 VBAT 引脚的存在,更多的是为了服务 RTC 的,所以我们接着看第二个代码,实时时钟。这就是实时时钟的现象,第一行是日期,目前是给的一个测试时间,2023 年 1 月 2 日,第二行是时间,目前是 0 时 0 分 xx 秒,第三行是时间戳的秒计数器,目前是 16 亿多,这个什么意思,等会儿就来学习。第四行是 RTC 预分频器的计数值,这个先看一下就行,用途我们写代码的时候再研究,这就是我们这个实时时钟的显示。
当然实时时钟,光有显示还不够,为了保证时间不出错,他还要有其他特性。首先是复位,既然你在计时,总不能每次复位都重新设置时间吧,我们按下复位键,可以看到,时间会继续运行,不会复位。然后,实时时钟,在系统主电源断电后,它还需要继续运行,就像我们手机一样,关机后,里面的时钟还必须要继续走,要不然时间就错了,是吧,所以只要在 VBAT 接上了备用电源,我们再断开系统主电源,然后插上,可以看到时间数据不会丢失,并且,在主电源断开的时间里,RTC 会继续走时,不会因为主电源断电而暂停,这就是 RTC 实时时钟的程序现象。可以发现,RTC 这个复位和主电源掉电后,数据不丢失,就是借用 BKP 来实现的,所以 RTC 和 BKP 关联程度是比较高的,这就是实时时钟的程序现象。
另外在这里,还要提几个在测试程序的时候,遇到的硬件 bug。
首先是,有的芯片,我给主电源断电后,VBAT 的电源还会给微弱地整个系统供电,这导致我主电源拔掉后,电源指示灯和 OLED 屏幕还会微弱的亮着,这是一个问题,当然这个问题其实也不影响最终的实验现象。
然后是还有的芯片,在进行 RTC 实验时,会出现 RTC 晶振不起振的情况,这会导致程序卡死在等待晶振起振的地方,这个问题还没找到完美的解决方法。但是在学习过程中,也是可以有一些替代方法可以使用的,所以这些问题先给大家提个醒,替代方法,我们后续写代码的时候再说。
好,那程序现象我们就看到这里。
1. Unix 时间戳
在这一小节,我将会介绍,时间戳是什么东西,为什么要使用时间戳来计时。然后 UTC 和 GMT 是什么东西,这一块就是一些科普性质的知识点。然后就是时间戳里的秒计数器和日期时间数据如何互相转换,这涉及到 C 语言中的 time.h 这个官方函数库。这里我会在 DevC++ 这个软件里,一一调用这些函数,来给大家演示它们的用法。
所以我们本小节的任务有两个。
- 了解时间戳,它到底是什么东西。
- 会使用 C 语言 time.h 里面的这些函数进行时间戳各种形式数据的转换。
那本小节的内容,其实是计算机领域的一个通用知识点,不特别应用在 STM32 中,所以学完本小节,你之后在其他地方,说不定也能用得到。好,那我们来看一下
1.1 Unix 时间戳简介
Unix 时间戳最早是在 Unix 系统使用的,所以叫 Unix 时间戳。之后很多由 Unix 演变而来的系统,也都继承了 Unix 时间戳的规定。目前 Linux、Windows、安卓这些系统它们底层的计时系统,都是使用的 Unix 时间戳。所以在我们现在计算机世界的底层,Unix 时间戳还是在扮演着重要的角色的。
- Unix 时间戳(Unix Timestamp),它的定义是从 UTC/GMT 的 1970 年 1 月 1 日 0 时 0 分 0 秒开始所经过的秒数,不考虑闰秒。
这里大家可能有些疑问:
第一,UTC/GMT,这个是什么东西。
第二,闰年、闰月,这些我们听得比较多,但是这个闰秒,是个什么东西呢。
这两个知识点,我们等会儿再介绍。
现在这句话,我们简单理解一下,意思就是,时间戳是一个计数器数值,这个数值表示的是一个从 1970 年 1 月 1 日 0 时 0 分 0 秒开始,到现在,总共所经过的秒数,所以时间戳这个计时系统,和我们常用的年月日时分秒这个计时系统有很大差别。年月日时分秒计时系统是每 60 秒,进位一次,记为 1 分钟,每 60 分钟进位 一次,记为 1 小时,之后继续进位,就是日、月、年了。而时间戳计时系统就比较简单粗暴了,它定义 1970 年 1 月 1 日 0 时整为 0 秒,之后,就只用最基本的秒来计时,永不进位,60s 就是 60s,100s 就是 100s,一千秒、一万秒、一亿秒,无论这个数有多大,我都不进位,始终都只用秒来计时。所以从 1970 年计到现在,这个时间戳的秒数已经非常大了,目前这个秒数,已经来到了 16 亿这个数量级了。对于人类来说,这个 16 亿秒,肯定是又难记又难理解;但是对于计算机来说,一个永不进位的秒,无论是存储,还是计算,都是非常方便的。所以时间戳在计算机程序的底层,应用非常广泛,时间戳的秒计数器和日期时间,可以互相转换,在计算器的底层,我们使用秒计数器来计时,需要给人类观看时,我们就转换为年月日时分秒这样的格式就行了。
那使用这样一个很大的秒数来表示时间,有很多好处。
第一,就是简化硬件电路,我们在设计 RTC 硬件电路的时候,直接弄一个很大的秒寄存器就行了,不需要再考虑什么年月日寄存器、进位,大月小月、平年闰年这些东西了。对于硬件电路设计来说,是非常友好的。
第二,就是在进行一些时间间隔的计算时,非常方便。比如 1 月 1 号 8 点到 3 月 1 号 18 点之间间隔了多少小时啊?这个如果用年月日时分秒来计算的话,需要考虑的东西就比较多了。但如果用秒计数器来算的话,我们只需要把两个时刻的秒数相减,再除一个小时的秒数,就可以很快计算两个时刻的间隔了。
第三,就是存储方便,存储秒数,一个比较大的变量就行了,存储年月日时分秒的话,就得很多变量了。
那当然使用秒计数器来表示时间,也有坏处。
就是比较占用软件资源,在每次进行秒计数器和日期时间转换时,软件都要进行一通比较复杂的计算,这会占用一些软件资源,那这就是使用时间戳的一些好处和坏处。
时间戳存储在一个秒计数器中,秒计数器为32位/64位的整型变量
那计算机为了存储这样一个永不进位的秒数,这个数据变量类型还是要定义大一些,对吧,这个变量类型,在不同系统中,定义是不一样的。在早期的 Unix 系统中,这个秒数大多是用 32 位有符号的整形变量来存储的。32 位有符号数,所能表示的最大数字是 232/2 - 1 = 21 亿多,这其实是有溢出风险的,因为目前到 2023 年,时间戳已经计到 16 亿了,再过一些年,32 位有符号数,就存不下这么大的数字了。那根据计算,32 位有符号数的时间戳会在 2038 年的 1 月 19 号溢出,到时候,采用 32 位有符号数存储时间戳的设备,计时系统就会因为数据溢出而出错,这可能会导致很多不健全的计算机程序崩溃,这就是 2038 年危机,大家感兴趣的话可以网上搜一搜。那当然,随着操作系统和设备的更新换代,目前的手机电脑等设备,基本上都已经采用 64 位的数据来存储时间戳了,64 位的时间戳,能存储的时间范围非常非常的大,总之,对于人类来说,完全可以高枕无忧了。最后我们本节 STM32 中的 RTC,可以看一下手册,可以看到,它核心的计时部分是一个 32 位的可编程计数器,这说明我们这款 STM32,它的时间戳是 32 位的数据类型,32 位的时间戳,这表示我们这个 STM32 也会在 2038 年出现 bug 吗?实际上并不会,因为根据研究,这个时间戳在 STM32 程序中定义的其实是无符号的 32 位,无符号 32 位最大数值是 232 - 1,计算一下,要到 2106 年才会溢出,虽然不是高枕无忧,但是有生之年,八成是不用担心。好,这就是时间戳的存储格式和溢出风险的分析。
世界上所有时区的秒计数器相同,不同时区通过添加偏移来得到当地时间
我们知道,地球上不同经度,它的时间是不一样的,穿过英国伦敦的经线,我们把它叫做本初子午线,这个位置的时间是一个时间标准。我们时间戳所说的 1970 年 1 月 1 日 0 时 0 分 0 秒,也是指的伦敦时间的 0 时 0 分 0 秒。那其他地方呢,可以分为 24 个时区,每偏差一个时区,时间就要加或减一个小时,我们处理不同时区的方式是所有时区共用一个时间戳的秒计数器,也就是在伦敦秒计数器是 0,在北京也是 0,然后根据不同时区,我们再添加小时的偏移即可。比如秒计数器的 0 对应伦敦时间的 0 点,那中国,使用北京时间,处于东 8 区的位置,对应北京的时间,就是 8 点。这就是时间戳对不同时区的处理方式。
那最后看一下下面这个图,总结一下上面的知识点。
图中这个箭头,代表的是一个时间轴。在这个时间轴上,我们要定义一个起点,时间戳从这个起点开始计时,这个起点是人为规定的,当时的设计者选择了伦敦时间的 1970 年 1 月 1 日 0 点。
对于 1970 年之前的时间,时间戳是无法表示的,那时间戳有两种表现形式。
一种是它的基本形式,也就是永不进位的秒计数器,从 0 开始,一直往后,每过 1s,加一个数;
另一种就是秒计数器经过计算,翻译出来的日期和时间了,比如 0s,对应伦敦时间 1970 年 1 月 1 日 0 点,然后秒计数器一直计啊计,比如计到这个 10 亿秒的时候,就对应伦敦时间 2001 年 9 月 9 日 1 时 46 分 40 秒。
那我咋知道 10 亿秒对应这个日期的时间呢?这背后要经过一些比较复杂的计算。比如先算一年有多少秒,得到现在是哪一年,然后再算一天有多少秒,得到现在是一年的第几天,然后再计算现在是几月几号,最后再计算是几时几分几秒。这里面还需要考虑大月小月、平年闰年这些特殊情况。
所以可以想到,这个计算是非常麻烦的,但是好在,这个计算步骤是固定的。而且,C 语言官方已经帮我们把程序写好了,这就是我们等会要学的 time.h 这个模块。这里面就有现成的,秒计数器转换日期时间,日期时间转换秒计数器这些函数。所以这里,我们只要会调用 time.h 的函数,就可以知道这些秒计数器和日期时间的对应关系了。至于计算步骤,我们不用过多了解,感兴趣的话可以自行研究。那有了 time.h 里的函数,这个秒计数器的计算,就非常简单了。比如 1672588795 这个秒数调用函数一计算对应的伦敦时间就是 2023 年 1 月 1 日 15 点 59 分 55 秒,那最后一行,在伦敦时间的基础上,得到北京时间,就比较简单了,每个秒计数器对应的伦敦时间,再加上 8 个小时就是对应的北京时间,这就是这个 Unix 时间戳整个的设计思路。
最后可以给大家推荐一个网站工具,比如在百度直接搜索 Unix 时间戳,然后就可以看到很多时间戳在线转换工具,我们打开网站,里面就有别人做好的转换工具。比如显示的是现在这个时刻对应的秒计数器,就是这么多秒;然后时间戳,就是秒计数器,你输入多少秒,点转换,它就能告诉你,对应的北京时间是多少;然后你输入一个日期时间,点转换,它就能告诉你对应的秒计数器是多少。当然这里好像只能转换北京时间,比如给个 0s,因为是北京时间,它对应的就是 8 点,这个我们也应该清楚是怎么回事,这就是这个时间戳在线工具。大家写代码的时候,可以参考这个工具来进行验证,这个了解一下。
好,时间戳的基础知识我们就了解这么多。
1.2 UTC/GMT
这里主要就是两个科普的内容,我们来了解一下 GMT、UTC 是什么东西,为什么会有闰秒这个现象。
首先看一下 GMT(Greenwich Mean Time,格林尼治标准时间/格林威治标准时间/格林威治平均时间)是一种以地球自转为基础的时间计量系统。它将地球自转一周的时间间隔等分为24小时,以此确定计时标准。
格林尼治 是一个地名,位于英国伦敦,所以如果你对格林尼治这个名字不熟悉,可以简单理解,它就是伦敦标准时间。格林尼治这个地方,有个天文台,可以通过观察天上的太阳和星星,来确定地球的自转和公转。
那可以看出,这种计时方法非常符合我们的直觉,一天的定义,就是地球自转一周,然后一天等分 24 小时,再等分 60 分钟,再等分 60 秒,这样就能确定时间基础了。当然我这里是简单的理解,具体过程也能会更复杂一些。
那 GMT 是以前全球计时的时间标准,大家都遵循 GMT 的标准,不同时区再加上对应的小时偏移,这样全球各地的时间就能确定下来了。但为什么说 GMT 是以前的时间标准呢?这是因为 GMT 有一个棘手的问题,就是地球自转一周的时间,其实是不固定的,由于潮汐力,地球活动等原因,地球目前是越转越慢的。那你再根据一天的时间来定义时间基准,这个时间基准就是在不断变化的。比如你把一天等分为 24 小时对应的秒数,地球越转越慢,那你定义 1s 的时间,是不是也就越来越长啊,一个不固定的时间基准,对科学研究影响非常大。比如我们说光速是多少 m/s,声速是多少 m/s,前提是 1s 到底是多长,必须是一个恒定不变的量,所以说,为了时间的定义更标准,科学家又提出了新的计时系统,叫做 UTC。
UTC(Universal Time Coordinated,协调世界时)是一种以原子钟为基础的时间计量系统。它规定 铯133 原子基态的两个超精细能级间在零磁场下跃迁辐射9,192,631,770周所持续的时间为1秒。当原子钟计时一天的时间与地球自转一周的时间相差超过0.9秒时,UTC会执行闰秒来保证其计时与地球自转的协调一致
原子钟是当前计时最精确的装置,上千万年才误差 1 s,所以使用原子钟提供的时间,具有定义明确,恒定不变这些好的特征。也就是使用原子钟计时,1s 到底是多长,我们就可以定死了。那最初,我们确定的这个参数,它定义 1s 的时长,是和 1970 年的 GMT 保持一致的。那现在问题又来了,我们以一个恒定不变的秒来计时,但是地球自转越来越慢,这样记下去,计时的一天和自转的一天就会出现偏差,时间长一些,可能中午 12 点,太阳就不是最高的位置。或者时间再长一些,计时的白天黑夜就会和现实的白天黑夜颠倒,这是我们不能忍受的,虽然说地球自转变慢的过程非常缓慢,误差大到白天黑夜颠倒,那得很久很久了,但是科学家对精度的极致追求,不能容忍哪怕 1s 的偏差,所以在原子钟计时系统的基础上,我们得加入闰秒的机制,来消除计时一天和地球自转一周的误差。闰秒的操作流程就是当原子钟计时一天的时间,与地球自转一周的时间相差超过 0.9s 时,UTC 会执行闰秒来保证其计时与地球自转的协调一致。所谓闰秒,就是计时标准是恒定不变的,但是地球越转越慢,误差超过 0.9s 时,我的计时系统就多走一秒,来等一下地球的自转,比如上一次闰秒的时刻是北京时间 2017 年 1 月 1 日 7 时 59 分 59 秒,在下一秒时,时钟会出现 7 时 59 分 60 秒,一分钟总共是 61 秒,这就是闰秒的操作。恒定的时间标准加上闰秒机制的设计,就能保证 UTC 既满足科学研究的需要,又满足人类生活的需要,这就是协调世界时的设计思路。
UTC 是现行的时间标准,它比 GMT 更加严谨,但是闰秒机制的设计,可能也会造成一些程序 bug,所以大家要有这个准备,就是一分钟可能会出现 61s 的情况。那在平时的生活中,大多不会追求极致的严谨,所以这时 GMT 和 UTC 可以看成是一样的。像我们手机电脑的时间设置里,可能就是说,我们当前的北京时间是 GMT+8 或者 UTC+8,这都是可以的,这就说明我们使用的是东 8 区的时间。好,这就是 UTC 和 GMT 的介绍还有闰秒机制产生的原因了,那再看时间戳的定义,UTC/GMT 的 1970 年 1 月 1 日 0 时 0 分 0 秒实际上就是格林尼治的当地时间,也就是伦敦时间,不考虑闰秒,说明目前这个时间戳对闰秒没有适应性,每次产生闰秒时,时间戳的时间和国家授时中心的标准时间就会产生 1s 的偏差,这个了解一下。
那时间戳的基础知识,大家就清楚了,接下来就是实践部分。
1.3 时间戳转换
我们来学习,时间戳中秒计数器和日期时间,如何进行相互转换。这时我们需要用到 time.h 模块。
C 语言的 time.h 模块提供了时间获取和时间戳转换的相关函数,可以方便地进行秒计数器、日期时间和字符串之间的转换,使用还是非常方便的,直接调函数,填参数,就行了。
在 time.h 里,主要有以下这么多函数:
并不是全部,还有两个不太重要的函数没列出来。
函数 | 作用 |
---|---|
time_t time(time_t*); | 获取系统时钟 |
struct tm* gmtime(const time_t*); | 秒计数器转换为日期时间(格林尼治时间) |
struct tm* localtime(const time_t*); | 秒计数器转换为日期时间(当地时间) |
time_t mktime(struct tm*); | 日期时间转换为秒计数器(当地时间) |
char* ctime(const time_t*); | 秒计数器转换为字符串(默认格式) |
char* asctime(const struct tm*); | 日期时间转换为字符串(默认格式) |
size_t strftime(char*, size_t, const char*, const struct tm*); | 日期时间转换为字符串(自定义格式) |
如何去学习这些函数呢?其实网上也有很多的教程,大家自学的时候,都可以去网上搜索相关资源。重要的函数,我给大家演示一下,这些函数中,数标记的三个最为重要。其中, gmtime 就是秒计数器转换为 GMT,格林尼治日期时间的函数;localtime 就是秒计数器转换为当地日期时间的函数,就是在 gmtime 的基础上加一个时区偏移,所以这两个函数,是非常相似的;mktime 就是日期时间转换为秒计数器的函数,这个就只有当地的时间。有了这 3 个函数,我们就可以进行时间戳的转换了。
这个图就清晰地显示了每个函数的作用。就是在各种数据类型之间进行转换,为了明白函数的用途,我们首先得清楚,这 3 种数据类型都是什么意思。
- 秒计数器数据类型,它的数据类型名,叫做 time_t。time_t 其实是一个 typedef 重命名的类型,如果不是特别声明(
define(_USE_32BIT_TIME_T)
),我们要用 32 位的秒计数器类型,那么默认情况下,time_t,就是 __time64_t,然后 __time64_t 实际上就是 __int64,所以 time_t,实际上就是 int64 类型,是一个 64 位有符号的整型数据。所以可以看出,使用的是 64 位的秒计数器,不用担心溢出问题,这就是 time_t 数据类型。可以用来存储时间戳中那个一直自增的秒数 - 日期时间数据类型,类型名是 struct tm,这两个词组合在一起,代表一个结构体类型名。这个 tm 结构体,我们也可以在 time.h 里找到定义,它是一个封装的结构体类型,结构体的成员有
struct tm
{int tm_sec;//秒,取值范围 0~59int tm_min;//分钟,取值范围 0~59int tm_hour;//小时,取值范围 0~23int tm_mday;//一个月的几号,取值范围 1~31int tm_mon;//从 1 月开始的第几个月,取值范围 0~11,如果是 1 月,它的值是 0;一直到 12 月值是 11,所以这个参数值 + 1 才是我们所说的月份int tm_year;//从 1900 年的第几年,所以这个参数值加上 1900,才是我们所说的年份。另外注意,这个年份的偏移是 1900,我们时间戳的起点是 1970,这两个年份不一样,注意一下,所以这个参数最小值,就应该是 70。int tm_wday;//从周末开始的星期几,取值范围 0~6。0 表示周末,1 表示周一,2 表示周二,一直到 6,表示周六int tm_yday;//从 1 月 1 号开始的第几天,取值范围 0~365,这个参数我们平常用的不多int tm_isdst;//是否使用夏令时,+1 表示使用夏令时,0 表示不使用夏令时,-1 表示不知道。
};
夏令时这个东西,欧美地区的大部分国家,还有其它地区的少部分国家,都还在使用,我国最初也使用了一段时间,但是现在我国已经不用夏令时了,所以我们对夏令时这个东西可能比较陌生。夏令时简单来说,就是为了鼓励大家夏天的时候早睡早起、节约用电而设计的,感兴趣的话,大家自己再研究,这里就不给大家详细介绍了。
好,这个日期时间结构体,我们就了解了。它里面就是这样一个个,表示年月日时分秒,星期等内容的数据。当然这个结构体的定义,在形式上和我们 STM32 库函数里的方法有所区别,我们 STM32 中,使用的是 typedef struct {} 新名字;
这样的形式定义的,这里没有使用 typedef,而是在花括号前给结构体起了一个名字,叫 tm,这样在使用的时候,数据类型名,就是两个词 struct tm,然后跟着的是变量名,这样的方式也是可以的,和我们 STM32 库函数里的方式是一样的效果,这个大家了解一下。
那这就是日期时间结构体的内容,我们就清楚了。
- 字符串数据类型,类型名是 char*,就是 char 型数据的指针,用来指向一个表示时间的字符串,这个等会可以给大家演示。
好,3 种数据类型,我们就准备好了。接下来我们来使用函数,尝试一下数据类型的转换,可以看到,这些函数中,大量的出现了指针的操作,不熟悉指针操作的话,建议再看一下我空间里的指针教程,要不然你不容易理解这些函数的用法。其实像这些官方的模块,真的是遍地是指针,自己写程序的话,为了方便理解,一般用指针还是比较少的,但是耐不住别人都用指针,所以指针,大家还是要好好学一学的。
-
time_t time(time_t*);
作用是获取系统时钟。返回值是 time_t,表示当前系统时钟的秒计数器值;参数是 time_t*,这是一个输出参数,输出的内容和返回值是一样的,所以这个函数可以通过返回值获取系统时钟,也可以通过输出参数获取系统时钟。
这个函数,在电脑里,可以直接读取电脑的时间,但是在 STM32 里是用不了的,因为 STM32 是一个离线的裸机系统,它也不知道现在是啥时间。 -
struct tm* gmtime(const time_t*);
将秒计数器的值转换为格林尼治日期时间,也就是伦敦时间。参数是 const time_t*,秒计数器指针类型,是输入参数;返回值 struct tm* 是日期时间结构体指针类型。 -
struct tm* localtime(const time_t*);
秒计数器转换当地时间,这个函数和 gmtime 的使用方法是一样的,只是 localtime 会根据时区自动添加小时的偏移。 -
time_t mktime(struct tm*);
就是上面两个函数的逆过程了,它是将日期时间转换为秒计数器,当然 mktime 传入的日期时间,需要是当地的。参数是日期时间结构体指针类型;返回值 time_t 是秒计数器类型。
另外再说明一下,mktime 的参数前面并没有加 const,实际上这个参数既是输入参数,也是输出参数。它内部的工作过程是:日期时间结构体,里面由年月日时分秒星期等数据,但是仅通过年月日时分秒,就足以算出秒计数器了,你填的星期参数,实际上是不作为输入参数的;相反,这个函数在算出秒数的同时,还会顺便算一下当前年月日是星期几,然后回填到结构体里面的星期之中,所以使用这个函数,给定一个年月日,我们可以很方便的计算对应的是星期几,这个功能大家可以自己试一试,我就不再演示了
实际上这个 time.h 里面重要的部分,我们就已经讲完了,也就是秒计数器和日期时间计算比较麻烦,我们需要用这些现成的函数。
下面这三个函数,实际上就是把时间转换为字符串表示,这就比较简单了,我们不用它的函数,也能很方便的操作,如果你需要用的话,我们也演示一下使用方法。
char* ctime(const time_t*);
就是把秒计数器转换为 char* 格式的字符串,使用默认的格式。char* asctime(const struct tm*);
就是把日期时间转换为字符串,使用默认的格式。size_t strftime(char*, size_t, const char*, const struct tm*);
日期时间转换为字符串(自定义格式),这个函数就比较高级了,它的作用和 asctime 是一样的,但是可以自定格式。它总共有四个参数,前面两个参数,需要传入一个字符数组和数组长度,第三个参数,需要给定指定的格式字符串,第四个参数,把 time_date 传进去就行了。
#include <stdio.h>
#include <time.h>time_t time_cnt;
struct tm time_date;
char* time_str;int main(void) {
//1.//time_cnt = time(NULL); //1. 参数不需要的话可以给 NULL,这样就得到了时间(当前时间戳的秒数)//time(&time_cnt); //2. 用输出参数来获取,这两条语句的效果是一样的。time_cnt = 1672588795; //3. 另外我们可以手动给它一个数值printf("%d\n", time_cnt);//2.//gmtime(&time_cnt); //根据传进去的数值,函数内部就会经过一通计算,返回值就是日期时间了time_date = *gmtime(&time_cnt); //1. 结构体变量互相赋值//struct tmj* ptime_date;//ptime_date = gmtime(&time_cnt); //2. 结构体指针互相赋值printf("%d\n", time_date.tm_year + 1900);//从 1900 年经过的年数printf("%d\n", time_date.tm_mon + 1);//从 1 月经过的月数printf("%d\n", time_date.tm_mday);printf("%d\n", time_date.tm_hour);printf("%d\n", time_date.tm_min);printf("%d\n", time_date.tm_sec);printf("%d\n", time_date.tm_wday);//3.time_date = *localtime(&time_cnt); //这个函数内部会根据当前电脑的设置,自动判断我们处于哪个时区,然后把时间添加时区偏移后,输出出来//localtime 函数判断我们在东 8 区,就自动把时间加了 8 个小时输出,显示是 23 点,这与给定的北京时间是一致的。printf("%d\n", time_date.tm_year + 1900);printf("%d\n", time_date.tm_mon + 1);printf("%d\n", time_date.tm_mday);printf("%d\n", time_date.tm_hour);printf("%d\n", time_date.tm_min);printf("%d\n", time_date.tm_sec);printf("%d\n", time_date.tm_wday);//4.time_cnt= mktime(&time_date); //这个函数就会经过一通计算,给我们返回对应的秒计数器的值printf("%d\n", time_cnt);//可以发现最终的秒数和最初的秒数是一样的,这说明 mktime 给它传入当地时间,是正确的,不是依据伦敦时间来进行的//5.time_str = ctime(&time_cnt);//返回值是 char* 的字符串printf(time_str);//西方国家的格式习惯,我们中国,一般不用这么奇怪的格式,所以,这个函数我们用的不多time_str = asctime(&time_date);//实际上是同样的效果,只是它的参数不一样而已printf(time_str);//最终这两个函数运行的效果是完全一样的,这两个函数,了解即可char t[50];strftime(t, 50, "%H-%M-%S", &time_date);//第三个参数可以参考函数定义中的格式定义表,实际上这个就类似于 printf 第一个参数的格式字符串。左边是占位符格式,右边是解释和实例,比如我们想写小时,就是 %H,分钟,就是 %M,秒,就是 %S,其他这些格式大家都可以一一尝试。在程序中第三个参数给个字符串。//%什么 是占位符,打印时会替换为后面时间的具体值,其他的符号,会保留原始内容。打印的字符串,通过前两个参数到指定一个数组里。这就是这个函数的作用。printf(t);//可以看到,它就按照我们指定的格式来打印字符串了。return 0;
}
好,到这里,我们这个 time.h 的部分重要函数,就讲完了,然后剩下,time.h 里还有几个函数没讲到。大家可以在函数库里自行学习,主要就是这个 clock 函数,可以用来计算程序执行了多长时间,然后 difftime,可以计算两个时间之间的差值。其他的函数,好像都提到过,当然最重要的函数,还是 localtime 和 mktime 这两个,这是整个 time.h 里最复杂的函数,也是我们 STM32 的 RTC 程序会用到的,所以这两个重点掌握,其他的了解即可。
那本小节的两个任务,我们就完成了,一个是了解 Unix 时间戳,另一个是会进行时间戳不同数据类型的转换,这就是本小节的内容。
2. BKP 和 RTC 的外设部分
当然我们本节的重点是 RTC,所以 BKP 这部分内容比较少,要求也不高。大家知道 BKP 是什么,然后会读写这些数据寄存器就行了。之后 RTC 的部分呢,就需要我们重点掌握了。这个等会再细讲。
2.1 BKP 简介
那我们先看 BKP 的部分,首先看一下简介。
BKP(Backup Registers)备份寄存器/后备寄存器
BKP 用途:可用于存储用户应用程序数据。
BKP 就是一些存储器,可以储存自定义数据,想存啥就存啥。
BKP 特性:当VDD(2.0~3.6V)电源被切断,他们仍然由VBAT(1.8~3.6V)维持供电。当系统在待机模式下被唤醒,或系统复位或电源复位时,他们也不会被复位。
这里的 VDD 就是系统的主电源,供电电压是 2.0~3.6V;VBAT(V Battery)就是备用电池电源,供电电压是 1.8~3.6V。可以看一下引脚定义表中标红色的部分就是供电引脚,下面这三组,VDD 和 VSS_1、2、3 是内部数字部分电路的供电;上面这一组 VDDA 和 VSSA 是内部模拟部分电路的供电,那这四组以 VDD 开头的供电,都是系统的主电源,在正常使用 STM32 时,这四组供电全部都需要接到 3.3V 的电源上。最后,上面这还有一个引脚,VBAT 这就是备用电池供电引脚,如果要使用 STM32 内部的 BKP 和 RTC,这个引脚就必须接备用电池,用来维持 BKP 和 RTC,在 VDD 主电源掉电后的供电,当然这里备用电池只有一根正极的供电引脚,接电池时,电池正极接到 VBAT,电池负极和主电源的负极接在一起,共地,就行了。
然后看一下我们最小系统板的原理图,这里可以看到 VBAT 引脚,直接通过排针引出来了,这个引脚,就位于我们板子右上角的地方,引脚标号是 VB,或者 VBAT;另外这里可以看出,如果不接电池的话,VBAT 引脚是悬空的,当然,STM32 参考手册里建议的是,如果没有外部电池,建议 VBAT 引脚接到 VDD,就是 VBAT 和 主电源接到一起,并且再连接一个 100nF 的滤波电容,这是手册里的建议,大家要是自己设计电路的话,可以注意一下这个问题。
好,那这样,这个 VDD 主电源和 VBAT 备用电源,我们就清楚了。
- VBAT 的作用就是当 VDD 断电时,BKP 会切换到 VBAT 供电,这样可以继续维持 BKP 里面的数据,如果 VDD 断电,VBAT 也没电呢,那 BKP 里的数据就会清零,因为 BKP 本质上是 RAM 存储器,没有掉电不丢失的能力。
- 然后后面一句的意思是待机唤醒或者复位时,BKP 的数据保持原样,这个特性是显然要有的。要不然,你说你 VDD 掉电保持数据,结果 VDD 一上电复位,你数据也跟着清除了,那掉电保持,就没有意义了。
这就是 BKP 存储器的特性。
BKP 的几个额外的功能:(这些功能大家了解即可,我们本节暂时不涉及)
- TAMPER 引脚产生的侵入事件将所有备份寄存器内容清除
TAMPER 是一个接到 STM32 外部的引脚,它的位置可以参考一下引脚定义表,这里可以看到 PC13-TAMPER-RTC 也就是 PC13、TEMPER、RTC 这 3 个功能共用一个引脚,引脚位置,就是 VBAT 旁边的 2 号引脚。这个 TEMPER 引脚是一个安全保障设计,比如如果你做一个安全系数非常高的设备,设备需要有防拆功能,然后 BKP 里也存储了一些敏感数据,这些数据不能被别人窃取或者篡改,那你就可以使能这个 TAMPER 引脚的侵入检测功能。设计电路时,TAMPER 引脚可以先加一个默认的上拉或者下拉电阻,然后引一根线到你的设备外壳的防拆开关或触点,别人一拆开你的设备,触发开关,就会在 TAMPER 引脚产生上升沿或者下降沿,这样 STM32 就检测到侵入事件了,这时 BKP 的数据会自动清零,并且申请中断,你在中断里,还可以继续保护设备,比如清除其他存储器数据,然后设备锁死,这样来保障设备的安全;另外主电源断电后,侵入检测仍然有效,这样即使设备关机,也能防拆,这就是 TAMPER 侵入检测的功能,大家了解一下。
- RTC 引脚输出 RTC 校准时钟、RTC 闹钟脉冲或者秒脉冲
RTC 引脚刚才看过了,也是在 PC13 这个位置,这就是 RTC 时钟输出的功能,RTC 的校准时钟,闹钟或者秒脉冲的信号,可以通过 RTC 引脚输出。其中,外部用设备测量 RTC 校准时钟,可以对内部 RTC 微小的误差进行校准;然后,闹钟脉冲或者秒脉冲可以输出出来,为别的设备提供这些信号,这是 RTC 时钟输出的功能。因为 PC13、TEMPER 和 RTC 这 3 个引脚共用一个端口,所以这 3 个功能,同一时间,只能使用一个。
- 存储RTC时钟校准寄存器
这个可以配合上面这个校准时钟输出的功能,结合一些测量方法,可以对 RTC 进行校准。那这两个功能,实际上就是 RTC 的配置,我觉得放在 RTC 那个外设的地方应该比较合适。当然 RTC 和 BKP 关联程序比较高,设计者目前就是把这两个 RTC 的功能放在 BKP 里了,这个大家知道一下。
那 BKP 的介绍和基本功能,即使上面这些。最后看一下,BKP 中,用户数据的存储容量:
- 在中容量和小容量设备里,BKP 是 20 个字节。
- 在大容量和互联型设备里,BKP 是 84 个字节。
我们使用的 C8T6 是中容量设备,BKP 就是 20 个字节。所以可以看出,BKP 的容量其实非常小,一般只能用来存储少量的参数,那这就是 BKP 的简介,我们就介绍到这里。
下面看一下 BKP 的基本结构。
这个图中橙色部分,我们可以叫做后备区域。BKP 处于后备区域,但后备区域不只有 BKP,还有 RTC 的相关电路也位于后备区域,STM32 后备区域的特性就是当 VDD 主电源掉电时,后备区域仍然可以由 VBAT 的备用电池供电,当 VDD 主电源上电时,后备区域供电会由 VBAT 切换到 VDD,也就是主电源有电时,VBAT 不会用到,这样可以节省电池电量。然后 BKP 是位于后备区域的,BKP 里主要有数据寄存器、控制寄存器、状态寄存器和 RTC 时钟校准寄存器这些东西,其中数据寄存器是主要部分,用来存储数据的,每个数据寄存器都是 16 位的,也就是,一个数据寄存器可以存 2 个字节,那对于中容量和小容量的设备,里面有 DR1、DR2、一直到 DR10 总共 10 个数据寄存器,那一个寄存器存两个字节,所以容量是 20 个字节,就是上面说的 20 字节。
然后对于大容量和互联型设备,里面除了 DR1 到 DR10 还有 DR11、DR12、一直到 DR42,总共 42 个数据寄存器,容量是 84 个字节,就是上面说的 84 字节。然后,BKP 还有几个功能,就是左边这里的侵入检测,可以从 PC13 位置的 TAMPER 引脚引入一个检测信号,当 TAMPER 产生上升沿或者下降沿时,清除 BKP 所有的内容,以保证安全;时钟输出,可以把 RTC 的相关时钟,从 PC13 位置的 RTC 引脚输出出去,供外部使用,其中,输出校准时钟时,再配合校准寄存器,可以对 RTC 的误差进行校准。
好,以上这些就是 BKP 这个外设的结构和功能。内容总体来说也不是很多,大家了解一下。
2.2 RTC 简介
那接下来,我们就继续来学习这个 RTC 外设。还是先看一下简介。
RTC(Real Time Clock)实时时钟
在 STM32 中,RTC 是一个独立的定时器,可为系统提供时钟和日历的功能
RTC 实时时钟,一般就是指提供年月日时分秒这种日期时间信息的计时装置。51 单片机的 DS1302 是外置的 RTC 芯片,这个芯片可以独立计时,我们需要设置时间或读取时间,就通过通信协议向它发送或接收数据来完成;那在我们 STM32 内部,有这个 RTC 的外设,所以 STM32 可以在内部直接实现 RTC 的功能,这样就不用再外挂 RTC 芯片了,当然 RTC 芯片所必要的元件,比如备用电池、RTC 晶振这些东西就要接到 STM32 上了。
RTC 和时钟配置系统处于后备区域,系统复位时数据不清零,VDD(2.0~3.6V)断电后可借助VBAT(1.8~3.6V)供电继续走时。
这个特性就和之前的 BKP 是一样的了。为了保持时钟能一直连续运行不出错,在主电源断电后,RTC 走时肯定不能停下来,在系统复位时,RTC 时间值肯定也不能复位,那为了实现这些功能,VBAT 接上备用电池就是必须的了。主电源断电后,VBAT 的电池可以继续维持 BKP 和 RTC 的运行。
32位的可编程计数器,可对应Unix时间戳的秒计数器
这一点可以对照 RTC 框图来理解,可以看到,这里负责计时的装置只有一个 32 位的秒计数器。如果你没学过我们上一小节讲的 Unix 时间戳,可能就会非常疑惑了,你想,这不是一个实时时钟外设么?那年呢,月呢,日呢,小时呢,分钟呢,之前学习 DS1302 的时候,那里面可是有一堆寄存器的,什么年月日时分秒,各种日期时间的信息,都一目了然,写入对应寄存器就是修改时间,读取对应寄存器就是获取时间。然后到这里,你咋就只给我一个秒呢?这让我怎么用,初学者看到这,可能会有这个疑惑。另外整个手册里,也都没有提到时间戳这个东西,所以如果你不了解时间戳相关的操作,那确实不太好用这个 RTC。但是,我们经过上一小节的学习,应该一眼就能看明白了这个是什么意思。
显然,这个 32 位可编程计数器,就对应的是时间戳里的秒计数器。在读取时间时,我们先得到这个秒数,然后使用 time.h 模块里的 localtime 函数,就能立刻知道年月日时分秒的信息了;在写入时间时,我们先填充年月日时分秒信息到 struct tm 结构体,然后用 mktime 函数,得到秒数,再写入到这个 32 位计数器即可。
这样,操作这个秒计数器的思路是不是就很清晰了,那得益于时间戳的设计,这个硬件电路就得到了极大的简化。你看要想实现年月日时分秒的计时,我们只需要一个 32 位的秒计数器即可,什么年月日,小时分钟的寄存器,都不需要再设计了,硬件也不再需要考虑大月小月、平年闰年这些特殊情况的,直接一个秒,一直加就行了,这无疑极大的简化了硬件电路的设计。那当然,硬件简化了,压力就来到了软件这边,我们每次读取和写入秒计数器时,都要进行时间戳的转换,这需要消耗一定的软件计算资源,这就是这个 32 位可编程计数器的设计。
20位的可编程预分频器,可适配不同频率的输入时钟
这可以继续对照 RTC 框图来理解。这里 32 位的计数器,显然 1s 要自增一次,所以这个地方,驱动计数器的时钟,需要是一个 1 Hz 的信号;但是实际提供给 RTC 模块的时钟,也就是这里的 RTCCLK,一般频率都比较高。所以显然,我们需要在这之间加一个分配器,给 RTCCLK 降一降频率,保证分频器输出给计数器的频率为 1 Hz,这样计时才是正确的,对吧。
那为了适配各种频率的 RTCCLK 呢,这里就加了一个 20 位的分频器,可以选择对输入时钟进行 1~220 这么大范围的分频,这样就可以适配不同频率的输入时钟,这就是这个可编程分频器的作用。
可选择三种RTC时钟源:
- HSE 时钟除以 128(通常为 8MHz/128)
- LSE 振荡器时钟(通常为 32.768KHz)
- LSI 振荡器时钟(40KHz)
这 3 个时钟,可以选择其中一个,接入到这里的 RTCCLK,那这 3 个时钟都是什么意思呢?我们可以看一下之前定时器这里讲过的 RCC 时钟树,这个图就是整个芯片的时钟系统,整个芯片可以有 4 个时钟源,右下角写了,HSE,高速外部时钟信号;HSI,高速内部时钟信号;LSI,低速内部时钟信号;LSE,低速外部时钟信号。这些时钟字母,你就记住,H(High) 开头是高速,L(Low) 开头是低速,E(External) 结尾是外部,I(Internal) 结尾是内部,高速低速,内部外部一组合,就是 4 种情况。这里高速时钟,一般供内部程序运行和主要外设使用;低速时钟,一般供 RTC、看门狗这些东西使用。
那对于我们本节的 RTC 呢,我们可以看到下面有一个指向通往 RTC 的箭头,就是 RTCCLK,RTCCLK 有 3 个来源:
- 第一个是 OSC 引脚接的 HSE,外部高速晶振,这个晶振是主晶振,我们一般都用的 8 MHz,8 MHz 进来,通过 128 分频,可以产生 RTCCLK 信号。为什么要先 128 分频呢?这是因为这个 8 MHz 的主晶振太快了,如果不提前分频,直接给 RTCCLK,后续即使再通过 RTC 的 20 位分频器,也分不到 1 Hz 这么低的频率,所以 8 MHz 提前先进行 128 分频,后续 20 位的分频器,再进行一个适当的分频,就可以输出 1 Hz 的信号给计数器了,这是第一路来源,HSE 的时钟。
- 然后中间这一路,时钟来源是 LSE,外部低速晶振,我们在 OSC32 这两个引脚,接上外部低速晶振,这个晶振产生的时钟,可以直接提供给 RTCCLK,这个 OSC32 的晶振,是内部 RTC 的专用时钟。这个晶振的值,也不是随便选的,通过跟 RTC 有关的晶振,都是统一的数值,就是 32.768 KHz。为什么选择这个数值呢?一方面是,32 KHz 这个值附近的频率,是这个晶振工艺比较合适的频率,你要说非要做一个 1 Hz 的晶振,那可能是做不出来,或者做出来了,但体积很大,性能很差;另一方面是,32768,这是一个 2 的次方数,215 = 32768,所以 32.768 KHz,即 32768 Hz,经过一个 15 位分频器的自然溢出,就能很方便的得到 1 Hz 的频率。自然溢出的意思就是设计一个 15 位的计数器,这个计数器不用设置计数目标,直接从 0 计到最大值,就是计到 32767,计满后自然溢出,这个溢出信号就是 1 Hz,自然溢出的好处,就是不用再额外设计一个计数目标了,也不用比较计数器是不是计到目标值了,这样可以简化电路设计。所以目前在 RTC 电路中,基本都是清一色的 32.768 KHz 的晶振,你只要看到 32.768 KHz 的晶振,它八成就是提供给 RTC 的,这是第二路。
- 最后看第三路时钟源,来自于 LSI,内部低速 RC 振荡器。LSI,固定是 40 KHz,如果选择 LSI 当作 RTCCLK,后续再经过 40K 的分频,就能得到 1 Hz 的计数时钟了。当然内部的 RC 振荡器,一般精准度没有外部晶振高,所以 LSI 给 RTCCLK,可以当作一个备选方案,另外,LSI 还可以提供给看门狗,这个了解一下,之后我们介绍看门狗的时候再说。
那这 3 个时钟源呢,我们最常用的就是中间这一路,外部 32.768 KHz 的晶振,提供 RTCCLK 的时钟。
第一个原因就是,中间这一路,32.768 KHz 的晶振,本身就是专供 RTC 使用的,上下这两路,其实是有各自的任务。上面这一路,主要作为系统主时钟;下面这一路,主要作为看门狗时钟。它们只是顺带作为备选当作 RTC 的时钟,这么不专心的时钟,我们自然很少用它了。
另外一个更重要的原因就是,只有中间这一路的时钟,可以通过 VBAT 备用电池供电。上下两路时钟,在主电源断电后,是停止运行的,所以要想实现 RTC 主电源掉电继续走时的功能,必须得选择中间这一路的 RTC 专用时钟。如果选择的是上下两路时钟,主电源断电后,时钟就暂停了,这显然会导致走时出错。
所以这 3 路时钟,我们主要选择中间这一路,上下两路在特殊情况下,可以作为备选方案。这就是这 3 路时钟的介绍和选择问题。
这个 RTC 的简介我们就介绍完了。接下来我们来看一下这个 RTC 的框图。