Funpack5-4 - 基于 FRDM-MCXN236 开发板,实现一个电子时钟系统
该项目使用了FRDM-MCXN236 开发板,实现了电子时钟系统的设计,它的主要功能为:主电源掉电后时间不丢失以及能够显示当前时间并支持时间设置功能的电子时钟。
标签
嵌入式系统
Funpack活动
显示
开发板
一两风
更新2026-10-16
20

基于 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实物展示

开发板正面

IMG_20261005_095135.jpg

开发板背面

IMG_20261005_094944.jpg

系统展示

IMG_20261005_101354.jpg

三、设计思路

3.1 系统框图

image.png

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, &current_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);
}

编辑时用方括号标出正在调整的字段

编辑字段

日期行

时间行

YEAR

[2026]-09-26

17:30:00

MONTH

2026-[09]-26

17:30:00

DAY

2026-09-[26]

17:30:00

HOUR

2026-09-26

[17]:30:00

MIN

2026-09-26

17:[30]:00

SEC

2026-09-26

17:30:[00

五、 效果展示

运行界面:

IMG_20261005_094130.jpg

编辑界面:

IMG_20261005_102935.jpg

开发软件展示:

image.png

导入示例:

image.png

使用LinkServer烧录程序:

image.png

串口显示:

image.png

六、调试过程与踩过的坑

这一节是整个项目最花时间的部分,按踩坑顺序记录。

坑 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



附件下载
frdm_mcxn236_blank.zip
项目程序
团队介绍
团结一心
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号