基于 FRDM-MCXN236 的 RTC 掉电保持多功能电子时钟
一、项目简介
本项目基于 NXP FRDM-MCXN236 开发板,使用芯片内部位于 VBAT 常电域的独立 RTC(IRTC),
实现了一个主电源掉电后时间不丢失的多功能电子时钟。
系统以 SSD1306 OLED 作为人机界面,通过板载两个用户按键完成"进入编辑界面 → 逐字段调整 →
退出"的完整交互流程,可修改年 / 月 / 日 / 时 / 分 / 秒,并提供"一键重置日期"功能;
同时通过调试串口每秒输出一次当前时间,便于上位机记录和验证。
二、硬件介绍
2.1 主控与外设
项目 | 说明 |
|---|---|
主控 | NXP MCXN236(Arm Cortex-M33,最高 150 MHz,带 TrustZone) |
开发板 | FRDM-MCXN236 |
RTC | 片上 IRTC(Independent RTC),位于 VBAT 常电域 |
显示 | 0.96" SSD1306 OLED,128×64,软件 I2C 驱动 |
调试串口 | LPUART4,115200-8-N-1,走板载调试 USB |
按键 | 板载用户按键 SW2、SW3(均为低电平有效) |
2.2 资源分配
资源 | 引脚 | 说明 |
|---|---|---|
按键 SW2 | PIO0_20(C8) | 低有效,内部上拉使能。运行界面长按=进入编辑;编辑界面短按=换字段、长按=退出 |
按键 SW3 | PIO0_6(C14) | 低有效,内部上拉使能。编辑界面短按=当前字段+1、长按=重置日期 |
OLED SCL | PIO1_0 | 软件 I2C 时钟线 |
OLED SDA | PIO1_2 | 软件 I2C 数据线 |
串口 RX | PIO1_8(A1) | LPUART4,复用功能 FC4_P0(alt2) |
串口 TX | PIO1_9(B1) | LPUART4,复用功能 FC4_P1(alt2) |
指示灯 | PIO4_18(N10) | 板载红色 LED |
2.3实物展示
开发板正面

开发板背面

系统展示

三、设计思路
3.1 系统框图

3.2 四个子系统
① RTC 子系统(核心)
- 时钟源:
CLOCK_SetupClk16KClocking(kCLOCK_Clk16KToVbat | kCLOCK_Clk16KToMain)
给 VBAT 域和主域同时提供 16.384 kHz 时钟。 - 掉电保持:IRTC 寄存器与计数器由 VBAT 引脚供电。只要 VBAT 有电,
主电源 VDD 掉电后计数器继续走动、寄存器内容不丢。 - 冷/热启动判别:用
kIRTC_InvalidFlag判断 VBAT 域是否掉过电。
只有冷启动才重新初始化并写入默认时间;热复位直接沿用正在走动的时间。
这一点至关重要——IRTC_Init()内部会做软复位,热复位时调用它会把保持的时间清掉。
② 显示子系统
- 软件 I2C 驱动 SSD1306,无需外设 I2C 控制器,两根普通 GPIO 即可。
- 采用 1024 字节显存,先组织画面、再整屏推送,
避免逐点写屏产生的撕裂。 - 刷新策略:一帧 1024 字节走软件 I2C 约需 25~30 ms,远大于 10 ms 的按键扫描节拍。
因此不做定时刷屏,而是比较"上一次画上去的内容"和"这一拍算出来的内容",
只有变化时才推屏。这样按键扫描的节拍是干净的,不会因为刷屏而漏检按键。
③ 按键子系统
- 10 ms 节拍轮询 + 连续 3 次采样一致(30 ms)消抖。
- 每个键产生两类事件:短按与长按,二者互斥。
- 判定策略:长按在按住满 800 ms 时立即上报(不必等松手,手感更好),
同时置位longFired;短按在松手那一拍才上报,且要求本次按住期间longFired为假。
④ 串口子系统
- 每隔 100 拍(约 1 s)打印一次完整日期时间与当前模式;
- 所有按键事件即时打印,便于观察状态机迁移是否符合预期。
四、功能实现与主要代码片段
4.1 RTC 初始化与掉电保持(核心)
APP_RTC_Init() 是整个项目最关键的一段。它只在冷启动或时间非法时才写默认时间,
热复位直接沿用正在走的时间。
static status_t APP_RTC_Init(void)
{
irtc_config_t irtcConfig;
irtc_datetime_t current_time;
bool needInitTime = false;
/* 1. 使能 16K/32K 与 RTC0 模块时钟 (RTC 在 VBAT 域, 这一步必不可少) */
CLOCK_SetupClk16KClocking(kCLOCK_Clk16KToVbat | kCLOCK_Clk16KToMain);
CLOCK_EnableClock(kCLOCK_Rtc0);
/* 2. 判断是否是冷上电(VBAT 失电会置位 InvalidFlag) */
if ((IRTC_GetStatusFlags(RTC) & (uint32_t)kIRTC_InvalidFlag) != 0U)
{
needInitTime = true;
}
else
{
/*
* INVAL 未置位, 说明 VBAT 域一直有电、时间仍在走动。
* 但仍做一次合法性检查, 防止首次烧录时读到从未写入过的脏数据
* (IRTC 的年基准是 2112, 合法范围 1984 ~ 2239)。
*/
IRTC_GetDatetime(RTC, ¤t_time);
if ((current_time.year == 2112U) ||(current_time.year < 1984U) || (current_time.year > 2239U) || (current_time.month < 1U) ||
(current_time.month > 12U) || (current_time.day < 1U) || (current_time.day > 31U) ||
(current_time.hour > 23U) || (current_time.minute > 59U) || (current_time.second > 59U))
{
needInitTime = true;
}
}
if (needInitTime)
{
/* 3. 冷启动: 解锁写保护 -> 软复位 -> 配置 -> 写入默认时间 */
IRTC_GetDefaultConfig(&irtcConfig);
if (kStatus_Success != IRTC_Init(RTC, &irtcConfig)) { return kStatus_Fail; }
if (kStatus_Success != IRTC_SetDatetime(RTC, &default_time)) { return kStatus_Fail; }
}
else
{
/* 4. 热复位 / VBAT 未掉电: 沿用正在走动的时间, 绝不调用 IRTC_Init
* (IRTC_Init 内部有软复位, 会把保持的时间清掉) */
PRINTF("[RTC] Warm reset detected: keeping running time.\r\n");
}
...
}
要点:kIRTC_InvalidFlag 是"VBAT 域掉过电"的硬件标志,是最可靠的冷/热启动判据。
它是读时间之前必须先查的——IRTC_GetDatetime() 开头是一段无上限的忙等
(while ((base->STATUS & RTC_STATUS_INVAL_BIT_MASK) != 0U) { }),
INVAL 一直置位就会死循环,表现出来的现象是"芯片挂了、调试器连不上"。
4.2 改时间的统一收尾:解锁写保护(最容易踩的坑)
IRTC 的寄存器有写保护机制,解锁后仅保持约 2 秒可写,之后硬件自动上锁。
上锁状态下调用 IRTC_SetDatetime() 不会报错,但也什么都不写——
现象就是"按键没反应",而且极难排查。
因此把所有"改时间"的收尾统一抽成一个函数,保证不会漏掉解锁:
/*
* 所有"改时间"的收尾都走这里, 顺序是:
* 钳位"日" -> 重算星期 -> 解锁写保护 -> 写回 IRTC
*/
static status_t RTC_Commit(irtc_datetime_t *t)
{
uint8_t days = DaysInMonth(t->year, t->month);
/* 改过年/月之后, 原来的"日"可能越界 (如 1/31 加一个月变成 2/31) */
if (t->day > days) { t->day = days; }
if (t->day < 1U) { t->day = 1U; }
/* 日期被跳变后, IRTC 里存的星期会与日期不符, 重算一次 */
t->weekDay = WeekDayOf(t->year, t->month, t->day);
if (kStatus_Success != IRTC_SetWriteProtection(RTC, false))
{
PRINTF("[RTC] Unlock write protection failed\r\n");
return kStatus_Fail;
}
return IRTC_SetDatetime(RTC, t);
}
4.3 按键短按 / 长按的互斥判定
static void BUTTON_Poll(button_t *btn)
{
uint8_t raw = (GPIO_PinRead(btn->gpio, btn->pin) != 0U) ? 1U : 0U;
bool pressedNow = false;
btn->shortPressEvent = false;
btn->longPressEvent = false;
/* --- 消抖计数器: 连续 3 拍一致才认账 --- */
if (raw != btn->sample) { btn->sample = raw; btn->count = 1U; }
else if (btn->count < DEBOUNCE_TICKS){ btn->count++; }
/* --- 状态跳变 --- */
if ((btn->count >= DEBOUNCE_TICKS) && (btn->sample != btn->stable))
{
btn->stable = btn->sample;
if (btn->stable == 0U) /* 按下 */
{
pressedNow = true;
btn->hold = 0U;
btn->longFired = false;
}
else /* 松开: 没到过长按阈值才算短按 */
{
if (!btn->longFired) { btn->shortPressEvent = true; }
btn->hold = 0U;
}
}
/* --- 长按计时: 到阈值立刻上报, 不用等松手 --- */
if ((btn->stable == 0U) && (!pressedNow))
{
btn->hold++;
if ((!btn->longFired) && (btn->hold >= LONG_PRESS_TICKS)) /* 80 拍 = 800 ms */
{
btn->longFired = true;
btn->longPressEvent = true;
}
}
}
4.4 字段调整与范围回绕
每个字段在自己的合法范围内回绕,避免出现非法时间:
static status_t RTC_AdjustField(edit_field_t field, int32_t step)
{
irtc_datetime_t t;
uint8_t days;
int32_t v;
IRTC_GetDatetime(RTC, &t);
switch (field)
{
case FIELD_YEAR: /* 1984 ~ 2239 之间回绕 */
v = (int32_t)t.year + step;
while (v < (int32_t)YEAR_MIN) { v += (int32_t)YEAR_SPAN; }
while (v > (int32_t)YEAR_MAX) { v -= (int32_t)YEAR_SPAN; }
t.year = (uint16_t)v;
break;
case FIELD_MONTH:
v = ((int32_t)t.month - 1 + step) % 12;
t.month = (uint8_t)(((v + 12) % 12) + 1);
break;
case FIELD_DAY: /* 按"当月实际天数"回绕 */
days = DaysInMonth(t.year, t.month);
v = ((int32_t)t.day - 1 + step) % (int32_t)days;
t.day = (uint8_t)(((v + (int32_t)days) % (int32_t)days) + 1);
break;
case FIELD_HOUR: v = ((int32_t)t.hour + step) % 24; t.hour = (uint8_t)((v + 24) % 24); break;
case FIELD_MINUTE: v = ((int32_t)t.minute + step) % 60; t.minute = (uint8_t)((v + 60) % 60); break;
case FIELD_SECOND: v = ((int32_t)t.second + step) % 60; t.second = (uint8_t)((v + 60) % 60); break;
default: return kStatus_InvalidArgument;
}
return RTC_Commit(&t); /* 收尾统一在这里: 钳位 -> 重算星期 -> 解锁 -> 写入 */
}
4.5 日期合法性与星期重算
IRTC_SetDatetime() 会先做格式校验,日期非法就直接返回 kStatus_InvalidArgument 且什么都不写。
所以"日"的合法性必须由软件保证:
/* 返回某年某月的天数 (含闰年判断) */
static uint8_t DaysInMonth(uint16_t year, uint8_t month)
{
static const uint8_t daysPerMonth[13] = {0U,31U,28U,31U,30U,31U,30U,31U,31U,30U,31U,30U,31U};
uint8_t days = 0U;
if ((month >= 1U) && (month <= 12U))
{
days = daysPerMonth[month];
/* 4 年一闰、百年不闰、400 年再闰 */
if ((month == 2U) && ((((year % 4U) == 0U) && ((year % 100U) != 0U)) || ((year % 400U) == 0U)))
{
days = 29U;
}
}
if (days == 0U) { days = 31U; } /* 防御: 脏数据时不要除零 */
return days;
}
/*
* 由年月日反算星期 (0 = 周日)。
* IRTC 的 DAYS 寄存器里存了 DOW 字段, 硬件每天自动 +1,
* 但日期被手动跳变后就会对不上, 所以写完日期顺手把它重算一次。
*/
static uint8_t WeekDayOf(uint16_t year, uint8_t month, uint8_t day)
{
static const uint8_t offset[12] = {0U, 3U, 2U, 5U, 0U, 3U, 5U, 1U, 4U, 6U, 2U, 4U};
uint16_t y = year;
if (month < 3U) { y--; }
return (uint8_t)((y + (y / 4U) - (y / 100U) + (y / 400U) + offset[month - 1U] + day) % 7U);
}
4.6 长按 SW3 一键重置日期
因为日期只能向上增加,不是循环,当增加多了,就需要重置,只需要重置日期,时间部分原样保留:
/*
* 长按 SW3: 把日期 (年/月/日) 恢复成上电默认值 default_time 的日期,
* 时/分/秒 保持当前值不动。默认日期只有一个来源, 改了它这里跟着变。
*/
static status_t RTC_ResetDate(void)
{
irtc_datetime_t t;
IRTC_GetDatetime(RTC, &t);
t.year = default_time.year;
t.month = default_time.month;
t.day = default_time.day;
return RTC_Commit(&t);
}
4.7 OLED内容变了才刷
/* 每拍算出四行要显示的内容, 和上次画上去的比对, 只有变化时才推屏 */
FormatTitleLine(field, titleBuf, sizeof(titleBuf));
FormatDateLine(&now, field, dateBuf, sizeof(dateBuf));
FormatTimeLine(&now, field, timeBuf, sizeof(timeBuf));
FormatStatusLine(field, statusBuf, sizeof(statusBuf));
if ((strcmp(titleBuf, lastTitle) != 0) || (strcmp(dateBuf, lastDate) != 0) ||
(strcmp(timeBuf, lastTime) != 0) || (strcmp(statusBuf, lastStatus) != 0))
{
OLED_Clear();
OLED_ShowString(0, 0, titleBuf);
OLED_ShowString(0, 2, dateBuf);
OLED_ShowString(0, 4, timeBuf);
OLED_ShowString(0, 6, statusBuf);
OLED_Refresh(); /* 一帧 1024 字节, 约 25~30 ms */
(void)strcpy(lastTitle, titleBuf);
(void)strcpy(lastDate, dateBuf);
(void)strcpy(lastTime, timeBuf);
(void)strcpy(lastStatus, statusBuf);
}
编辑时用方括号标出正在调整的字段
编辑字段 | 日期行 | 时间行 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
五、 效果展示
运行界面:

编辑界面:

开发软件展示:

导入示例:

使用LinkServer烧录程序:

串口显示:

六、调试过程与踩过的坑
这一节是整个项目最花时间的部分,按踩坑顺序记录。
坑 1:RTC 没有 16 kHz 时钟 → 读寄存器挂死总线
现象:程序执行到 IRTC_GetDatetime() 就"死"了,调试器报
remote failure reply,连寄存器都读不出来。
原因:IRTC 位于 VBAT 常电域,IRTC_GetDefaultConfig() 在 MCXN236 上默认选择的时钟源是
16.384 kHz,但代码里从来没有给 VBAT 域使能这个时钟。
没有时钟的寄存器接口第一次被访问就可能挂住总线,内核卡死。
解决:在访问任何 RTC 寄存器之前先执行
CLOCK_SetupClk16KClocking(kCLOCK_Clk16KToVbat | kCLOCK_Clk16KToMain);
坑 2:IRTC_GetDatetime() 是无上限忙等
现象:即使时钟配好了,只要 INVAL 位还置着,IRTC_GetDatetime() 就永远自旋。
原因:查 SDK 源码 fsl_irtc.c:338
while ((base->STATUS & RTC_STATUS_INVAL_BIT_MASK) != 0U) { }
解决:先查 kIRTC_InvalidFlag,再决定要不要读时间;并且在冷启动分支里
一定要调用 IRTC_SetDatetime()——正是这个写操作(写 SECONDS 寄存器)才把 INVAL 清掉、
启动计数器。
顺带一提:官方例程里的 IRTC_Enable32kClkDuringRegisterWrite() 在 MCXN236 上是被条件编译掉的
(MCXN236_features.h 定义了 FSL_FEATURE_RTC_HAS_NO_GP_DATA_REG = 1),不要照抄。
坑 3:寄存器写保护 2 秒后自动上锁 → 按键"没反应"
现象:初始化时写时间成功,但后来用按键改时间,串口打印一切正常、也不报错,
时间就是不动。
原因:IRTC_SetWriteProtection() 的注释里写得很明白:
When the registers are unlocked, they remain in unlocked state for 2 seconds,
after which they are locked automatically.
IRTC_Init() 解锁过一次,但 2 秒后硬件自动上锁了。之后所有 IRTC_SetDatetime()
都因为写保护而静默失败——不报错、不写值。
解决:每次写入前都重新解锁,并且把"解锁 + 写入"抽成 RTC_Commit() 统一处理,
避免将来新增写路径时漏掉这一步。
七、心得体会
1. 数据手册和 SDK 源码比"看起来像那么回事"的代码可靠得多。
这个项目里最耗时的三个坑(缺 16 kHz 时钟、INVAL 忙等、写保护 2 秒自动上锁),
没有一个能从编译报错里看出来。它们的共同点是硬件静默失败:不报错、不崩溃,
只是"没效果"。最后都是靠翻 fsl_irtc.c 的源码和注释才定位到的。
以后再遇到"代码逻辑没错但硬件没反应"的情况,第一反应应该是去读驱动的实现,而不是继续改业务代码。
2. 驱动里那些看起来多余的注释,往往就是坑的地址。IRTC_SetWriteProtection() 那句 "remain in unlocked state for 2 seconds,
after which they are locked automatically",一开始被我当成背景知识跳过去了,
结果它正是"按键改不了时间"的根因。
README 里也提过 IRTC_Enable32kClkDuringRegisterWrite() 在 MCXN236 上是被条件编译掉的,
照抄官方例程同样会踩坑——官方例程也要看清目标芯片的 feature macro。
3. "掉电保持"这件事,软件只占一半。
代码层面做对了两件事就很干净:VBAT 域给时钟、热复位不重新初始化。
但真正的决定因素是 VBAT 引脚有没有电。这让我意识到,
嵌入式项目里"需求能不能实现"经常不取决于代码写得多好,而取决于硬件条件是否具备。
4. 交互设计上,手势是会互相打架的资源。
两个按键要承载 6 个字段的调整、界面切换和重置功能,一开始想用"按一下换字段 + 按住连发 + 长按退出",
结果三种手势互相冲突:长按退出时短按会误触发、长按重置时连发会误触发。
最后砍掉连发、把短按改成松手判定才理顺。
功能不是越多越好,手势预算是有上限的,宁可少一个便利功能,也不要让操作变得不确定。
5. 关于可维护性的一点小设计。
把所有"改时间"的收尾(钳位日 → 重算星期 → 解锁写保护 → 写回 IRTC)抽成一个 RTC_Commit(),
看起来只是少写了几行,实际价值在于:将来新增任何改时间的路径都不会漏掉"解锁"这一步。
这个坑太隐蔽,靠人记住是不可靠的,只能靠结构来保证。
项目链接:
通过网盘分享的文件:frdm_mcxn236_blank.zip
链接: https://pan.baidu.com/s/1PHUOXEd-B1PU-iTt20It-A?pwd=f7py 提取码: f7py