一、项目介绍
这个项目是基于NXP FRDM-MCXN236 开发板完成的一个 RTC 电子时钟。核心思路很简单:利用 MCXN236 内部集成的独立实时时钟(IRTC)外设来获取精确的时间信息,通过板载的两个用户按键 SW2 和 SW3 来设置和调整时间,最终通过串口实时输出当前的年、月、日、时、分、秒。整个系统不需要外接任何显示模块,所有时间信息都通过 LPUART4 串口打印到电脑端的串口助手上,简洁直观。
为什么选这个题目?说实话,RTC 是每个 MCU 开发者迟早都要面对的外设,但很多人(包括我之前)对它的理解只停留在"调用个库函数读时间"的层面。这次借 Funpack 活动的机会,我想把 RTC 从里到外搞清楚——它的时钟源从哪来、掉电后为什么还能走、写保护是怎么回事、1Hz 中断怎么用。做完这个项目,这些问题都有了实实在在的答案。
项目完成了任务1要求的全部五项功能:使用 RTC 获取系统时间、显示当前时分秒信息、使用按键实现时间调整、通过串口输出当前时间、掉电后时间能够保持。另外我还加了星期自动计算(蔡勒公式)、RTC 有效性检测、按键消抖等细节处理,让整个时钟用起来更顺手。
二、使用到的硬件介绍
整个项目用到的硬件全部在FRDM-MCXN236 开发板上,没有外接任何模块,非常干净。
硬件 | 型号/参数 | 用途 |
主控MCU | MCXN236VDF,Cortex-M33,150MHz,1MB Flash,352KB RAM | 系统控制核心 |
RTC 外设 | MCXN236 内置 IRTC(独立实时时钟) | 时间计数与掉电保持 |
按键SW2 | GPIO0 Pin20(P0_20),板载用户按键 | 切换编辑模式(时/分/秒) |
按键SW3 | GPIO0 Pin6(P0_6),板载用户按键 | 编辑模式下数值+1 |
调试串口 | LPUART4,P1_8(TX)/P1_9(RX),115200bps | 时间信息输出 |
调试器 | 板载MCU-Link(CMSIS-DAP) | 烧录、调试、串口一线通 |
RTC 备用电源 | VBAT 引脚(板载超级电容/电池) | 掉电后RTC 继续运行 |
表1 硬件资源一览表
FRDM-MCXN236 这块板子比我之前用的 MCXA153 强不少——Cortex-M33 内核跑到 150MHz,1MB 的双组 Flash 还支持 ECC,352KB ECC RAM,还有 SmartDMA 加速器和 EdgeLock 安全子系统。不过对这个 RTC 时钟项目来说,这些高性能资源大部分都在"休息",真正干活的就是 IRTC 这个独立外设和两个 GPIO 按键。但这也恰恰说明了 IRTC 的独立性——它有自己的电源域和时钟源,主 CPU 就算停了它也能继续走。

图1 FRDM-MCXN236 开发板实物
三、方案框图与项目设计思路

图2 系统总体框图
整个系统的架构非常清晰,可以分成三个部分来看:
第一部分是时间源——MCXN236 内部的 IRTC 外设。IRTC 有自己独立的 32.768kHz 外部晶振作为时钟源,经过 32768 分频后得到精确的 1Hz 秒信号,驱动内部的秒、分、时、日、月、年计数器逐级进位。IRTC 运行在独立的 VBAT 电源域,主电源掉电后由 VBAT 供电继续计时,这就是掉电保持的硬件基础。
第二部分是人机交互——两个板载按键。SW2 负责模式切换,在"运行→编辑时→编辑分→编辑秒→运行"之间循环;SW3 负责在编辑模式下对当前选中的时间位做 +1 操作(时%24、分%60、秒%60)。两个按键都配置为内部上拉输入,低电平有效,软件做了 50ms 消抖处理。
第三部分是信息输出——LPUART4 串口。系统初始化时打印开机信息和按键说明,之后在运行模式下每秒(由 IRTC 的 1Hz 中断触发)读取一次 RTC 时间并打印到串口。进入编辑模式后,每次按键操作也会立即打印当前状态,方便用户确认调整结果。串口波特率 115200,8 位数据位,无校验,1 位停止位。
设计思路上,我刻意把时间获取和时间显示解耦:IRTC 硬件负责精确计时(1Hz 中断只设一个标志位,不在中断里做任何耗时操作),主循环负责轮询标志位、读 RTC、打印时间。这样即使串口打印慢一点,也不会影响 RTC 的计时精度。按键处理也放在主循环里轮询,用状态机的方式管理编辑模式,逻辑清晰且不容易出 bug。
四、RTC(IRTC)工作原理详解

图3 RTC (IRTC) 工作原理框图
要把这个时钟项目做好,得先搞懂IRTC 到底是怎么工作的。MCXN236 的 IRTC(Independent Real-Time Clock)是一个完全独立的实时时钟外设,所谓"独立"体现在三个方面:独立的时钟源、独立的电源域、独立的寄存器接口。
4.1时钟源与计时原理
IRTC 的时钟源是外部 32.768kHz 晶振。为什么是 32.768kHz 这个奇怪的数字?因为 32768 = 2^15,经过 15 级二分频后正好得到 1Hz 的秒信号,用二进制计数器实现起来非常方便,不需要复杂的除法电路。晶振输出的 32.768kHz 方波进入 IRTC 内部的预分频器,分频得到 1Hz,然后驱动秒计数器从 0 数到 59,溢出后向分计数器进 1,分计数器溢出向时计数器进 1,以此类推,时(0-23)、日(1-31,自动处理大小月和闰年)、月(1-12)、年(可到 2239 年)逐级进位。整个过程完全由硬件自动完成,CPU 不需要干预。
4.2掉电保持原理(VBAT电源域)

图4 RTC 掉电保持原理(VBAT 独立电源域)
RTC 最神奇的地方就是掉电后时间还能继续走,这靠的就是 VBAT 电源域。MCXN236 内部有一个独立的 VBAT 电源域,IRTC 的所有寄存器和计数器都在这个域里。正常工作时,VBAT 由主电源 VDD 供电(同时给板载的超级电容或纽扣电池充电);当主电源掉电后,VBAT 自动切换到备用电源供电,IRTC 继续运行,而 CPU 和其他外设则全部断电停止工作。
需要注意的是,VBAT 电源域的功耗非常低(微安级甚至纳安级),一颗小小的纽扣电池就能让 RTC 跑好几年。但这个电源域只能给 IRTC 供电,不能给 CPU 或其他外设供电,所以掉电后串口肯定是没输出的,但时间一直在走,重新上电后读出来的时间就是准确的。
4.3写保护与有效性检测
IRTC 有一个写保护机制,默认情况下时间寄存器是只读的,防止误写导致时间错乱。要设置时间,必须先调用 IRTC_SetWriteProtection(RTC, false) 关闭写保护,设置完成后硬件会自动重新开启写保护。这个机制在我的 APP_ApplyDatetime() 函数里体现得很清楚——每次设置时间前都先关写保护。
另外,IRTC 有一个 Invalid Flag(无效标志)。当 RTC 第一次上电、VBAT 完全掉电(备用电池也没电了)或者检测到时钟故障时,这个标志会被置位,表示当前时间不可信。我的代码在启动时会检查这个标志和年份范围(2020-2239),如果时间无效就加载默认时间 2026/8/22 17:00:00 并写入 RTC;如果时间有效就直接读取并继续运行,这就是掉电恢复的逻辑。
4.4 1Hz中断
IRTC 支持 1Hz 中断,每秒钟触发一次。这个中断的处理函数 RTC_IRQHandler() 非常简单——只做一件事:把全局变量 s_tick1Hz 设为 true,然后清除中断标志。不在中断里做任何耗时操作(比如串口打印),这是嵌入式开发的基本原则。主循环里轮询这个标志位,发现为 true 就去读 RTC 时间并打印,然后把标志位清掉。这样既保证了每秒一次的精确触发,又不会因为中断里做太多事而影响系统响应。
五、调试软件介绍、软件流程图及关键代码
5.1开发与调试环境
开发环境用的是NXP 官方的 MCUXpresso IDE(基于 Eclipse),免费下载使用。SDK 直接在 IDE 里导入 FRDM-MCXN236 的 board package,时钟配置、引脚复用、外设初始化都可以用 Config Tools 图形化生成。系统时钟配置为 PLL0 150MHz(BOARD_BootClockPLL150M),由 FIRC 48MHz 经过 PLL0 倍频得到,AHB 总线时钟也是 150MHz。调试用板载 MCU-Link,一根 USB-C 线搞定供电、烧录和串口调试,非常方便。
5.2软件流程图

图5 软件主程序流程图
主程序的流程如上图所示:上电后依次初始化硬件(时钟、串口、引脚)、按键(SW2/SW3 上拉输入)、IRTC(关闭写保护、清除状态标志)。然后检查 RTC 时间是否有效——无效则加载默认时间并写入,有效则直接读取。接着使能 1Hz 中断并打印开机信息,进入主循环。主循环里做两件事:轮询处理按键(SW2 切模式、SW3 调数值),检查 1Hz 中断标志并在运行模式下打印时间。
5.3按键状态机

图6 按键状态机(SW2 切换模式 / SW3 调整数值)
按键处理用了一个简单的状态机。SW2 按下时在四个状态之间循环切换:运行(Run) → 编辑时(Hour) → 编辑分(Minute) → 编辑秒(Second) → 回到运行(Run)。从编辑秒回到运行时,会把调整后的时间写入 RTC 并保存。在编辑模式下,SW3 按下对当前选中的位 +1(时对 24 取模,分和秒对 60 取模),并立即写入 RTC。运行模式下 SW3 无效。每次按键都做了 50ms 软件消抖,用的是 SDK 提供的 SDK_DelayAtLeastUs() 函数。
5.4关键代码片段
RTC 初始化与 1Hz 中断:
static void APP_InitRtc(void)
{
irtc_config_t irtcConfig;
uint32_t flags;
IRTC_GetDefaultConfig(&irtcConfig);
if (IRTC_Init(RTC, &irtcConfig) == kStatus_Fail)
{
for (;;) { } /* 初始化失败,死循环 */
}
/* 关闭写保护,清除状态标志 */
(void)IRTC_SetWriteProtection(RTC, false);
IRTC_DisableInterrupts(RTC, kIRTC_AlarmInterruptEnable);
flags = IRTC_GetStatusFlags(RTC);
if (0U != flags)
{
IRTC_ClearStatusFlags(RTC, flags);
}
}
/* 1Hz 中断处理函数:只设标志位,不做耗时操作 */
void RTC_IRQHandler(void)
{
uint32_t flags = IRTC_GetStatusFlags(RTC);
if (0U != (flags & kIRTC_1hzFlag))
{
s_tick1Hz = true;
}
if (0U != flags)
{
(void)IRTC_SetWriteProtection(RTC, false);
IRTC_ClearStatusFlags(RTC, flags);
}
SDK_ISR_EXIT_BARRIER;
}
RTC 时间有效性检测与默认时间加载:
static bool APP_IsRtcTimeValid(void)
{
irtc_datetime_t datetime;
/* 检查无效标志 */
if (0U != (IRTC_GetStatusFlags(RTC) & kIRTC_InvalidFlag))
{
return false;
}
IRTC_GetDatetime(RTC, &datetime);
/* 检查年份范围是否合理 */
return (datetime.year >= 2020U) && (datetime.year <= 2239U);
}
static void APP_SetDefaultDatetime(irtc_datetime_t *datetime)
{
datetime->year = 2026U;
datetime->month = 8U;
datetime->day = 22U;
datetime->hour = 17U;
datetime->minute = 0U;
datetime->second = 0U;
/* 蔡勒公式计算星期 */
datetime->weekDay = APP_CalcWeekDay(datetime->year,
datetime->month, datetime->day);
}
按键处理(SW2 切模式 + SW3 +1):
static void APP_HandleButtons(irtc_datetime_t *datetime,
app_edit_mode_t *mode)
{
static bool sw2LastState = false;
static bool sw3LastState = false;
/* SW2:切换编辑模式 */
if (APP_ButtonJustPressed(BOARD_SW2_GPIO, BOARD_SW2_GPIO_PIN,
&sw2LastState))
{
SDK_DelayAtLeastUs(50000U, CLOCK_GetCoreSysClkFreq()); /* 消抖50ms */
switch (*mode)
{
case kAppEditMode_Run:
*mode = kAppEditMode_Hour; break;
case kAppEditMode_Hour:
*mode = kAppEditMode_Minute; break;
case kAppEditMode_Minute:
*mode = kAppEditMode_Second; break;
default:
*mode = kAppEditMode_Run;
APP_ApplyDatetime(datetime); /* 保存时间到RTC */
break;
}
IRTC_GetDatetime(RTC, datetime);
APP_PrintStatusLine("[TIME]", datetime, *mode);
}
/* SW3:编辑模式下数值 +1 */
if ((*mode != kAppEditMode_Run) &&
APP_ButtonJustPressed(BOARD_SW3_GPIO, BOARD_SW3_GPIO_PIN,
&sw3LastState))
{
SDK_DelayAtLeastUs(50000U, CLOCK_GetCoreSysClkFreq());
switch (*mode)
{
case kAppEditMode_Hour:
datetime->hour = (datetime->hour + 1U) % 24U; break;
case kAppEditMode_Minute:
datetime->minute = (datetime->minute + 1U) % 60U; break;
case kAppEditMode_Second:
datetime->second = (datetime->second + 1U) % 60U; break;
default: break;
}
APP_ApplyDatetime(datetime); /* 立即写入RTC */
PRINTF("[SW3] %s +1 -> ", APP_GetEditModeName(*mode));
APP_PrintClock(datetime);
PRINTF("\r\n");
}
}
主循环:
int main(void)
{
irtc_datetime_t datetime;
app_edit_mode_t editMode = kAppEditMode_Run;
bool firstBoot = false;
BOARD_InitHardware();
APP_InitButtons();
APP_InitRtc();
/* 打印开机信息 */
PRINTF("\r\n========================================\r\n");
PRINTF(" FRDM-MCXN236 Electronic Clock\r\n");
PRINTF("========================================\r\n");
PRINTF("SW2: switch hour/minute/second\r\n");
PRINTF("SW3: +1 while editing\r\n");
PRINTF("RESET: simulate power loss, RTC keeps time\r\n");
PRINTF("----------------------------------------\r\n");
/* 检查RTC时间有效性,无效则加载默认时间 */
if (!APP_IsRtcTimeValid())
{
APP_SetDefaultDatetime(&datetime);
APP_ApplyDatetime(&datetime);
firstBoot = true;
PRINTF("[BOOT] RTC not ready, default time loaded\r\n");
}
else
{
IRTC_GetDatetime(RTC, &datetime);
PRINTF("[BOOT] RTC time restored after reset\r\n");
}
APP_PrintStatusLine(firstBoot ? "[START]" : "[RESUME]",
&datetime, editMode);
PRINTF("----------------------------------------\r\n");
/* 使能1Hz中断 */
IRTC_EnableInterrupts(RTC, kIRTC_1hzInterruptEnable);
EnableIRQ(RTC_IRQn);
while (1)
{
APP_HandleButtons(&datetime, &editMode);
/* 每秒触发一次,运行模式下打印时间 */
if (s_tick1Hz && (editMode == kAppEditMode_Run))
{
s_tick1Hz = false;
IRTC_GetDatetime(RTC, &datetime);
PRINTF("[TICK] ");
APP_PrintClock(&datetime);
PRINTF("\r\n");
}
else if (s_tick1Hz)
{
s_tick1Hz = false; /* 编辑模式下只清标志,不打印 */
}
}
}
六、功能展示图及说明

图7 串口输出示例(含开机/计时/按键设置全过程)
上面这张图是实际运行时串口输出的完整截图,可以看到整个时钟的工作流程:
- 开机后先打印分隔线和项目标题 "FRDM-MCXN236 Electronic Clock",然后是按键说明(SW2 切换模式、SW3 调整数值、RESET 模拟掉电)。
- 接着打印 "[BOOT] RTC time restored after reset",表示 RTC 时间有效,从掉电前的状态恢复。如果是第一次上电或者 VBAT 完全掉电,会打印 "[BOOT] RTC not ready, default time loaded" 并加载默认时间。
- 然后打印当前时间 "[RESUME] 2026/08/22 17:00:05",进入正常运行模式。
- 之后每秒打印一行 "[TICK] HH:MM:SS",时间精确到秒。
- 按下 SW2 进入编辑模式,打印 "[SW2] Enter time setup, now editing hour" 和当前时间状态行,状态行末尾会提示 "<< editing hour, press SW3 to +1 >>"。
- 按下 SW3,小时 +1,打印 "[SW3] hour +1 -> 18:00:08"。
- 再按 SW2 切换到编辑分,继续按 SW3 调整,最后按 SW2 回到运行模式,打印 "[SW2] Time setup done, clock resumes",时钟继续从调整后的时间开始走。
掉电保持是这个项目最核心的功能之一。测试方法很简单:让时钟正常运行一段时间,记住当前时间,然后长按RESET复位键(模拟掉电),因为没有电池。等十几秒后再松开复位键,重新打开串口,会看到打印的"[BOOT] RTC time restored after reset" 和恢复后的时间——这个时间正好等于断电前的时间加上断电的时长,说明 RTC 在掉电期间一直在精确计时。这个功能在实际产品中非常重要,比如电表、水表、温控器等设备,断电后时间不能乱,靠的就是 RTC 的 VBAT 掉电保持。
七、项目中遇到的难题及解决方法
7.1 RTC初始化失败的问题
一开始写IRTC_Init() 的时候,偶尔会返回 kStatus_Fail,程序进入死循环。查了参考手册和 SDK 代码才发现,IRTC_Init() 内部会检查 Invalid Flag,如果这个标志被置位(比如第一次上电、VBAT 掉电后),初始化就会失败。解决方法是在初始化失败时不要直接死循环,而是先清除 Invalid Flag,然后重新初始化,或者像我最终的代码那样,在 APP_InitRtc() 之后单独检查有效性并加载默认时间。实际上 SDK 的 IRTC_Init() 在某些版本里会自动处理 Invalid Flag,但为了保险起见,我在应用层又做了一次有效性检测。
7.2写保护导致时间设置不生效
刚开始做按键设置时间的时候,按下SW3 调整数值后,LCD(哦不对,这个项目没有 LCD,是串口)——串口打印的时间确实变了,但下一秒 1Hz 中断读出来的时间又变回原来的值了。查了半天发现是写保护的问题:IRTC 默认开启写保护,直接调用 IRTC_SetDatetime() 是写不进去的。必须先调用 IRTC_SetWriteProtection(RTC, false) 关闭写保护,设置完成后硬件会自动重新开启。我在 APP_ApplyDatetime() 函数里加了这一步,问题就解决了。这个写保护机制虽然有点麻烦,但确实能防止误写导致时间错乱,设计得很合理。
7.3 1Hz中断里打印导致时间不准
最开始图省事,直接在RTC_IRQHandler() 里调用 PRINTF() 打印时间。结果发现时间偶尔会跳秒或者延迟——因为串口打印是个耗时操作(115200 波特率下打印一行十几个字符要几毫秒),在中断里做这件事会影响中断响应时间,甚至可能导致下一个 1Hz 中断被延迟。后来改成了"中断只设标志位,主循环轮询打印"的标准做法,时间就精确了。这也再次印证了嵌入式开发的一条基本原则:中断服务函数里只做最必要、最快速的操作,耗时的事情放到主循环里去做。
7.4按键消抖
按键刚做好的时候,按一次SW2 经常会跳两三个模式——这是典型的按键机械抖动问题。机械按键在按下和松开的瞬间会有几毫秒的抖动,导致 GPIO 读到多次电平变化。解决方法是软件消抖:检测到按键按下后延时 50ms 再读取一次电平,如果仍然是按下状态才认为是有效按键。我用的是 SDK 提供的 SDK_DelayAtLeastUs(50000, ...) 函数,消抖 50ms 后按键就非常稳定了。另外用 APP_ButtonJustPressed() 函数做了边沿检测(只在按下的瞬间触发一次,按住不放不会重复触发),这也是按键处理的标准做法。
八、心得体会
做完这个RTC 电子时钟项目,最大的感受是:看似简单的东西,真正做透了并不简单。RTC 这个外设,以前在别的项目里也用过,无非就是调个库函数读时间,但这次从原理层面把它搞清楚之后,对很多细节有了全新的认识。
比如32.768kHz 晶振为什么是这个频率——以前只知道是 RTC 常用的晶振,现在明白了 32768 = 2^15,15 级二分频正好得到 1Hz,用二进制计数器实现起来零误差。再比如 VBAT 电源域——以前只知道 RTC 掉电能走,现在明白了它有独立的电源域和时钟源,CPU 断电不影响它,而且功耗极低,一颗纽扣电池能用好几年。还 有写保护机制——以前觉得这东西多余,现在理解了它是防止误写的重要保障,时间寄存器如果被误写,整个时钟就乱了。
另一个收获是对"中断里做什么"这个问题有了更深刻的理解。以前写中断服务函数,有时候图省事就把打印、计算都放进去了,结果各种奇怪的问题。这次 1Hz 中断里打印导致时间不准的问题,让我真正体会到了"中断要短、要快"这条原则的实际意义。现在再写中断,第一反应就是"只设标志位,剩下的交给主循环"。
FRDM-MCXN236 这块板子也给我留下了不错的印象。150MHz 的 Cortex-M33、1MB Flash、352KB RAM,资源非常充裕,板载 MCU-Link 调试器一线通非常方便,MCUXpresso IDE 和 Config Tools 的配合也很流畅。虽然这个项目只用了它很小一部分资源(IRTC + 两个 GPIO + 一个串口),但通过这个项目熟悉了板子的开发环境和基本流程,后面做更复杂的项目(比如任务2的环境数据采集)就有基础了。
最后,Funpack 这种"给块板子、定个任务、自己折腾"的活动形式真的很适合嵌入式学习者。有明确的任务目标,但实现方式完全自由,做完之后还有返还激励,比自己瞎学效率高多了。这个项目虽然难度只有两颗星,但把 RTC 这个外设从原理到实践彻底搞清楚了值了。