APM32F103 RTC定时器驱动:低功耗唤醒与备份域实践
2026/9/12 5:26:45 网站建设 项目流程

简介:APM32F103 RTC定时器标准库驱动已打包成zip资源,面向基于灵动微APM32F1系列单片机进行开发、需要快速实现日期时间管理与周期性任务的嵌入式工程师。压缩包共235个文件、大小5.29MB,其中包含47个头文件、39个C源码文件及40个编译中间文件,另有链接脚本(ld)、工程配置(uvprojx/uvoptx)与调试辅助文件,便于直接导入标准库工程后编译运行。驱动覆盖RTC初始化、时钟源选择、时间日期设置、启动控制、中断配置、写保护开关以及备份寄存器读写等关键操作,并给出可直接运行的示例工程,完整演示了从初始化到中断触发的实现路径,能够帮助开发者绕过配置陷阱、快速集成RTC功能。目前已有100人学习,适合正在学习APM32F103外设驱动或需要快速集成RTC定时器的工程师参考。

1. APM32F103 的 RTC 定时器驱动:为什么不是用 SysTick 做定时

在一个用 APM32F103 做低功耗表计的项目里,最常见的定时需求不是让 LED 闪几下,而是「设备睡下去,过 5 分钟自己醒来采一次数据,再睡回去」。用 SysTick 或者 TIM 定时器当然能延时,但主控进 STOP 模式后内核时钟停了,这些定时器全部失效;只有 RTC 挂在 VBAT 供电的备份域上,主电源断了也能继续走时,并且能用闹钟中断把芯片从 STOP 里唤醒。也就是说,标题里的「RTC 定时器」不是拿 RTC 去计时显示日期,而是把它当成一个永不掉电的长周期定时器来用。这篇讲的是基于 APM32 标准外设库把 RTC 封装成驱动层,覆盖初始化、校时、秒中断、闹钟中断、低功耗唤醒和掉电保持这几个直接能落地的点。适读人群是正在从 STM32F103 迁移到 APM32F103、或者第一次接触 APM32F1 系列标准库的嵌入式工程师。

2. APM32F103 RTC 外设结构:备份域写保护、时钟源与标准库驱动选型

2.1 RTC 不是普通定时器:备份域、VBAT 与写保护

APM32F103 与 STM32F103 的 RTC 外设在寄存器层面高度一致,整个 RTC 单元由三部分组成:一个 32 位向上计数器、一个可编程预分频器、以及一组备份寄存器 BKP_DR。这套东西不挂在 APB2 主时钟域里,而是放在独立的备份域中,由 VBAT 引脚单独供电。主电源 VDD 掉电后,只要 VBAT 上有电池或超级电容,RTC 计数器就继续走,BKP_DR 里的数据也不丢。

「独立供电」的代价是「写保护」。备份域里的寄存器默认是不可写的,必须先打开时钟再解锁,否则写操作会被硬件忽略。这个坑我见过不止一次:有人把RTC_SetPrescaler()放在初始化最前面,结果 RTC 完全不动,查了半天才发现 APB1 上 PWR 和 BKP 的时钟没使能。标准写法是固定三步:

RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); /* 置位 PWR_CR 的 DBP,解锁备份域写保护 */

第一行使能电源接口和备份域的外设时钟,第二行才是真正打开写保护。只要这两句没执行,后面所有RTC_xxxBKP_xxx写函数都是空操作。这也是 RTC 驱动和普通 GPIO/TIM 驱动最大的风格差异:普通外设初始化是「开时钟→配寄存器→用」,RTC 则是「开时钟→解锁→初始化→每次写后都要等上一次写完成」。

另一个容易忽略的点是复位与备份域的关系。芯片的 NRST 复位、看门狗复位都不会复位备份域,所以 RTC 走时在软件复位后仍然保持;但如果是上电复位,且 VBAT 没接、VDD 直接掉电,备份域会丢。驱动里必须通过 BKP_DR 里的标志位来判断「这次上电是冷启动还是热启动」,而不是一上来就把时间重置掉。

2.2 时钟源选型:LSE 32768Hz、LSI 40kHz 与 HSE 分频的取舍

RTC 的定时精度完全取决于输入时钟。APM32F103 的 RTC 时钟无外乎三个来源:外部低速晶振 LSE(32.768kHz)、内部低速 RC 振荡器 LSI(标称 40kHz)、以及 HSE 经 128 分频后的时钟。三者对应关系可以归纳成下面的表。

时钟源标称频率典型分频值优点主要问题
LSE32768 Hz32767精度高,可做到 1Hz 秒中断需要外部晶振与负载电容,起振慢
LSI40 kHz见下方说明无需外部器件,起振快温漂大,且受 F1 兼容设计预分频位宽限制
HSE/12862.5 kHz(8MHz 时)62499可复用主晶振随主频切换变化,低功耗时不可用

RTC 的输出周期公式是T = (PRL + 1) / f_in,PRL 是 15 位预分频值。用 LSE 时写RTC_SetPrescaler(32767),得到32768 / 32768 = 1Hz,这就是秒中断的基础。

LSI 的坑要单独说。F1 兼容内核的 RTC 预分频寄存器在很多参考手册里写的是 15 位,最大只能写 32767。LSI 标称 40kHz,想分频到 1Hz 需要写 39999,这个值在 15 位硬件上落不进去,实际秒中断会变成约 1.22Hz。所以我的建议是:做秒中断、日历这种需要秒对齐的场景,老老实实上 LSE;LSI 只适合那种「大概几分几秒唤醒一次,不需要绝对对齐」的长周期闹钟,或者你愿意在软件里按实测频率做补偿的场合。

2.3 标准库驱动与寄存器版、HAL 版的边界

标题强调「标准库驱动」,这里的标准库指的是 APM32F10x 标准外设库。它和 STM32F1 的 StdPeriph 风格几乎一致,函数命名、参数结构体都能直接对应。存量 STM32F103 项目往 APM32F103 迁移时,RTC 部分基本就是改个头文件的事,这也是为什么 APM32F1 系列很多人宁愿继续用标准库而不是切 HAL。

寄存器版驱动不是不能写,RTC 寄存器就那么几个:CRL、CRH、PRLH/PRLL、CNTH/CNTL、ALRH/ALRL。问题在于 RTC 寄存器访问有硬件状态机,写之前要等 RTOFF、读之前要等 RSF,这些等待逻辑在标准库里已经封好了,自己写寄存器要复刻一遍还容易漏。HAL 版的HAL_RTC_GetTime()接口更重,而且 APM32F103 的 HAL 生态不如标准库成熟。我一般会把 RTC 封装成rtc_drv.c / rtc_drv.h,对外只暴露rtc_drv_init()rtc_drv_set_time()rtc_drv_get_time()rtc_drv_set_alarm()这几个函数,内部再用标准库接口。这样上层业务代码不用知道备份域、RTOFF、RSF 这些细节。

3. 标准库驱动初始化与校时:备份标志、预分频与读取 RTC 时间

3.1 初始化流程:先开备份域写保护,再判断是否首次配置

用标准库驱动写初始化函数,核心不是「每次都把寄存器设一遍」,而是「根据备份标志决定要不要配置」。下面的代码是 APM32F103 上标准的 RTC 初始化套路,可抄进工程直接用。

/* rtc_drv.c —— APM32F103 标准库 RTC 驱动初始化 */ void rtc_drv_init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); /* 解锁备份域写保护 */ RCC_LSEConfig(RCC_LSE_ON); /* 启动外部低速晶振 */ while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET) {} /* 等待起振,生产环境要加超时 */ if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5) { /* 冷启动:备份域无有效配置,做完整初始化 */ BKP_DeInit(); /* 复位备份域寄存器 */ RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); /* RTC 时钟源选 LSE */ RCC_RTCCLKCmd(ENABLE); /* 使能 RTC 时钟 */ RTC_WaitForSynchro(); /* 等待寄存器同步完成 */ RTC_WaitForLastTask(); /* 等待上一次写完成 */ RTC_SetPrescaler(32767); /* 32768 / (32767+1) = 1Hz */ RTC_WaitForLastTask(); RTC_SetCounter(0); /* 秒计数从 0 开始 */ RTC_WaitForLastTask(); BKP_WriteBackupRegister(BKP_DR1, 0xA5A5); /* 写初始化完成标志 */ } else { /* 热启动:备份域已配置,只需重新使能 RTC 时钟 */ RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); RCC_RTCCLKCmd(ENABLE); RTC_WaitForSynchro(); } }

逻辑说明:PWR_BackupAccessCmd(ENABLE)是整段代码的前提,漏掉它后面所有 BKP/RTC 写操作都会静默失败。BKP_DR1被当作配置标志位,写入0xA5A5表示「这份备份域是驱动自己初始化过的」,热启动时读到这个值就跳过全量配置,避免复位后时间被清零。RTC_WaitForSynchro()等的是 RTC 时钟域与 APB1 总线域的同步,RTC_WaitForLastTask()等的是上一次写 RTC 寄存器操作的完成,标准库的RTC_SetCounter()这类函数内部不会自动等待,必须手动调用。

参数说明里有一个容易被忽略的点:RTC_SetPrescaler(32767)的分频参数不是分频系数本身,而是「分频系数减一」。手册上写的是把输入时钟除以PRL + 1,所以 32768Hz 晶振配 32767 才能得到 1Hz,写成 32768 会得到略小于 1Hz 的频率。此外,while等待 LSE 就绪的写法在 LSE 不起振的板上会死等,量产代码里建议加一个超时计数器,超时后切到 LSI 并上报时钟异常。

3.2 设置当前时间与读取 RTC 时间:RTC_SetCounter 与 RTC_GetCounter

APM32F103 的 RTC 没有「年/月/日」寄存器,它只有一个 32 位秒计数器。所谓校时,就是把当前 Unix 时间戳或者从某个基准点开始的秒数写进计数器。读取时间也一样,读出来就是秒计数。设置和读取的代码非常简单,但等待逻辑不能省。

/* 设置当前时间:seconds 为从 1970-01-01 00:00:00 起的秒计数 */ void rtc_drv_set_time(uint32_t seconds) { RTC_WaitForLastTask(); /* 确保前一次写操作已完成 */ RTC_SetCounter(seconds); /* 写入 32 位秒计数 */ RTC_WaitForLastTask(); /* 等待本次写完成 */ } /* 读取当前时间,返回同样的秒计数 */ uint32_t rtc_drv_get_time(void) { RTC_WaitForSynchro(); /* 等待 RTC 时钟域同步到 APB 域 */ return RTC_GetCounter(); /* 读 32 位计数器 */ }

这里要强调RTC_GetCounter()前面的RTC_WaitForSynchro()。RTC 计数器在低速时钟域下运行,APB1 总线去读它时,如果恰好赶上计数器进位,可能读到高低 16 位不一致的数据。RTC_WaitForSynchro()等待的是硬件把 RTC 域的数据同步到总线域,标准库已经把这个等待封装好了;跳过它,你会在某些时刻读到跳变的时间值。

拿到秒计数之后,要显示成时分秒就纯属软件运算了。常见做法是用div连续做三次除法:

uint8_t hour = (seconds / 3600) % 24; uint8_t minute = (seconds / 60) % 60; uint8_t second = seconds % 60;

如果要做日历,需要自己实现公历转秒和秒转公历的算法,或者串口校时时直接传 Unix 时间戳,由上位机换算。我一般倾向在驱动层保持秒计数,把时间转换放到应用层,这样驱动接口更干净,也方便测试时直接比较数值。

3.3 校时的三种方式与边界参数

实际项目中校时常见三种路径,按可靠性排分别是:串口/UART 命令校时、外接高精度时钟源对时、按键手动校时。串口校时最直接,收到一个十进制或十六进制的秒计数后调用rtc_drv_set_time()即可,但要注意写入动作对边界值的容忍度。

RTC_SetCounter()可以接受任何 32 位无符号值,0 和 0xFFFFFFFF 都能写进去,驱动不需要在校验上做太多。真正的边界在应用层:如果你的设备是低频采集场景,RTC 秒计数到 2038 年之后的溢出问题暂时不用管,但如果上位机下发的时间戳是带时区的,驱动层保持 UTC 秒计数、应用层做时区偏移,会比在驱动里直接改时间更省事。

另外,rtc_drv_set_time()的写入不是瞬间完成的。RTC_WaitForLastTask()在 LSE 32768Hz 下通常等几个微秒到十几个微秒,但如果 RTC 时钟源配置异常、频率很低,这个等待可能卡住。生产环境里建议给等待加一个毫秒级超时,超时后返回错误码,而不是让设备死等。

4. RTC 闹钟定时器与秒中断:周期任务、低功耗唤醒与参数配置

4.1 秒中断:最小周期任务的两种触发方式

把 RTC 当定时器用,最基础的是秒中断。APM32F103 的 RTC 有两个中断源:秒中断RTC_IT_SEC和闹钟中断RTC_IT_ALR。秒中断每秒触发一次,适合做 LED 心跳、采集节拍这类低频周期任务;闹钟中断在计数器等于闹钟比较值时触发一次,适合做「到点干什么」的单次定时。

开启秒中断的标准库写法如下:

void rtc_drv_enable_second_irq(void) { RTC_WaitForLastTask(); RTC_ITConfig(RTC_IT_SEC, ENABLE); /* 打开秒中断 */ RTC_WaitForLastTask(); NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel = RTC_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 2; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); } void RTC_IRQHandler(void) { if (RTC_GetITStatus(RTC_IT_SEC) != RESET) { RTC_ClearITPendingBit(RTC_IT_SEC); /* 先清标志再做业务 */ /* 每秒执行的任务:翻转测试引脚、累加计数器等 */ } }

这段代码有两个关键参数。第一个是RTC_IT_SEC必须在 NVIC 配置之前使能,否则中断挂起位来了但 CPU 不响应。第二个是抢占优先级PreemptionPriority = 2,这个值不是固定的,要配合NVIC_PriorityGroupConfig()的分组方式来看。如果工程里把优先级分组设为NVIC_PriorityGroup_2,那抢占优先级可以取 0~3;RTC 中断一般不需要最高优先级,让它排在系统节拍和关键通信之下就行。

秒中断里最容易犯的错是把耗时操作直接塞进去。RTC 中断频率是 1Hz,单次执行时间看起来宽裕,但如果你在中断里做串口打印或者读外部 Flash,一旦被打断的任务正好也在操作共享资源,就会出问题。我的习惯是中断函数里只置标志位,真正的业务逻辑放到主循环的rtc_drv_second_flag轮询里去跑。

4.2 闹钟中断:把 RTC 当长周期定时器用

闹钟中断比秒中断更贴合「RTC 定时器」这个标题。它的原理是拿计数器值和预设的闹钟值做比较,相等时触发RTC_IT_ALR中断。因为计数器是 32 位的,闹钟定时范围可以从秒级一直到 136 年,这是普通定时器完全做不到的。

/* 设置闹钟:alarm_seconds 为期望触发时的秒计数 */ void rtc_drv_set_alarm(uint32_t alarm_seconds) { RTC_WaitForLastTask(); RTC_SetAlarm(alarm_seconds); /* 写入闹钟比较值 */ RTC_WaitForLastTask(); RTC_ITConfig(RTC_IT_ALR, ENABLE); /* 使能闹钟中断 */ RTC_WaitForLastTask(); }

使用闹钟时有一个必须注意的边界:闹钟值必须大于当前计数器值。如果写入的alarm_seconds小于或等于RTC_GetCounter()的当前值,F1 兼容设计中这个条件是「已过去的时间」,闹钟不会等下一轮,而是立即触发或者干脆不触发。所以应用层设置闹钟前,最好先读一次当前时间,按当前时间 + 期望延时来算闹钟值。

闹钟中断的服务函数和秒中断共用同一个RTC_IRQHandler,区别在于判断的位不同:

void RTC_IRQHandler(void) { if (RTC_GetITStatus(RTC_IT_ALR) != RESET) { RTC_ClearITPendingBit(RTC_IT_ALR); /* 到点后的处理:唤醒业务线程、启动采集、输出脉冲等 */ } if (RTC_GetITStatus(RTC_IT_SEC) != RESET) { RTC_ClearITPendingBit(RTC_IT_SEC); /* 秒级任务 */ } }

如果芯片的 RTC 闹钟在内部还映射到了 EXTI 线上,比如 F1 兼容设计中常见的 EXTI17,那中断服务程序里可能还需要清一次EXTI的挂起位,具体看 APM32F103 头文件里EXTI_Line17是否有定义。没有定义就不需要处理,驱动代码里可以留一个防御性的条件编译。

4.3 低功耗配合:STOP 模式下的闹钟唤醒参数

RTC 定时器最典型的应用场景是低功耗。设备进入 STOP 模式后,主频、外设时钟全部停止,但 RTC 仍在 LSE 或 LSI 下运行。闹钟中断可以作为唤醒源把芯片拉起来,整个过程代码如下:

/* 设置闹钟 5 分钟后唤醒,然后进 STOP */ rtc_drv_set_alarm(rtc_drv_get_time() + 300); PWR_EnterSTOPMode(PWR_REGULATOR_ON, PWR_STOPENTRY_WFI); /* 从 STOP 唤醒后,先恢复系统时钟,再清闹钟标志 */ SystemInit(); while (RCC_GetFlagStatus(RCC_FLAG_HSIRDY) == RESET) {}

这里有三个参数需要根据实际需求调整。第一个是PWR_REGULATOR_ON,它表示 STOP 模式下调压器保持开启,唤醒速度快,但功耗相对高;追求更低功耗可以换成PWR_REGULATOR_LOWPOWER,代价是唤醒时间变长。第二个是PWR_STOPENTRY_WFI,它让 CPU 在遇到 WFI 指令时进 STOP;在主循环里调用没问题,如果在中断里调用要特别小心。第三个是唤醒后的时钟恢复,STOP 退出后系统时钟默认回到 HSI,不复位外设时钟可能导致外设工作异常。

下面是几组实际的预分频和闹钟周期参数对照,覆盖标题里「RTC 定时器」最常见的使用场景。

使用场景时钟源预分频值中断选择实际周期备注
心跳/秒计数LSE 32768Hz32767RTC_IT_SEC1s最准,推荐
短定时闹钟LSE 32768Hz32767RTC_IT_ALR秒级到小时级闹钟值 = 当前 + 延时
低功耗长唤醒LSE 32768Hz32767RTC_IT_ALR分钟级到天级STOP 下可唤醒
无晶振低成本LSI 40kHz按实测校准RTC_IT_ALR约 1.22s 基准需要软件补偿温漂

低功耗场景还有一个细节:进 STOP 之前,闹钟中断里如果用到了唤醒后的 GPIO 翻转,最好把 GPIO 配成推挽输出而非开漏,避免唤醒后电平状态不确定。另外,闹钟触发后中断标志不会自动清除,如果唤醒后不清RTC_IT_ALR挂起位,下次进 STOP 会被同一个标志立刻唤醒,设备表现为「睡不下去」。

5. RTC 定时器驱动排错与验证:LSI 温漂、复位掉电与时间毛刺

5.1 复位后时间回到初始值:备份标志与 VBAT 的排查

如果每次上电 RTC 时间都回到初始化值,驱动写的标志没生效。排查顺序是:先确认PWR_BackupAccessCmd(ENABLE)有没有在BKP_WriteBackupRegister()之前执行;再检查 VBAT 引脚是否真正接到了电池或电容,注意很多开发板把 VBAT 直接接 VDD,整板断电备份域照样丢;最后查BKP_DR1地址是否和库头文件里定义一致,有的芯片 BKP 寄存器数量不同,用错索引会写出到别的寄存器。

5.2 每天快慢几分钟:LSI 温漂与预分频校准

用 LSI 做 RTC 时钟源时,温漂导致的累计误差可以用一个简单的软件校准法来修正。先让设备连续运行一小时,同时用秒表或上位机记录参考时间,算出 RTC 计数偏差,然后按 ppm 补偿。比如实测 LSI 为 42.5kHz,而代码里按 40kHz 写的,那就把分频值从 39999 改成42500 - 1,前提是分频寄存器位宽放得下;放不下就走软件补偿:每 N 秒额外跳过一个计数。校准时要有耐心,至少观察 24 小时再下结论,因为环境温度变化会改变 LSI 输出频率,短时间校准在昼夜温差大的场景里不具备代表性。

5.3 验证驱动是否正常的三个步骤

验证 RTC 定时器驱动有没有写对,可以用逻辑分析仪和串口做三件事。第一是秒中断测试:初始化后打开秒中断,中断服务里翻转一个 GPIO,用逻辑分析仪抓波形,理论上应该看到稳定的 1Hz 方波,周期偏差在 LSE 下应该小于几十 ppm;第二是闹钟唤醒测试:设置 10 秒闹钟,进 STOP,用示波器测唤醒后的 GPIO 动作时间,对比是否在当前时间 + 10s + 唤醒时间附近;第三是掉电保持测试:初始化后写时间,然后 VDD 断电、VBAT 保持,重新上电后读时间,两次读数应基本一致,偏差不超过 RTC 在断电期间走时的累计误差。

最后提醒一处:rtc_drv_get_time()读出来的秒计数受RTC_WaitForSynchro()影响,调试时如果发现串口打印的时间偶尔往回跳,先别怀疑 RTC 芯片,多半是读取时没等同步标志。用两次rtc_drv_get_time()连续读并对差值做合理性判断,差值落在 0 或 1 就是正常的,如果出现大范围跳变,再回头检查同步等待和全局中断开关的状态。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询