Funpack5-4 - 基于 FRDM-MCXN236 与 Zephyr 的 OLED 多功能时钟终端
该项目使用了FRDM-MCXN236,实现了OLED 多功能时钟终端的设计,它的主要功能为:通过 DS1307 实时时钟模块提供时间信息,OLED 显示,支持:秒表、倒计时、闹钟、设置时钟功能。
标签
Funpack活动
Zephyr
FRDM-MCXN236
学嵌入式的Momo
更新2026-10-16
7

项目介绍

本项目在 NXP FRDM-MCXN236 开发板上,基于 Zephyr RTOS 实现了一个 128×64 单色 OLED 的多功能桌面时钟终端。整个系统只用板载两个按键操作,界面由 LVGL 绘制,时间来自 DS1307 实时时钟模块(带纽扣电池,掉电走时),闹钟设置与开机次数保存在模块自带的 24C32 EEPROM 中,掉电不丢。

主要功能

系统以时钟界面为常驻画面,其余功能从主菜单进入,共四项:秒表、倒计时、闹钟、设置时钟。秒表用于计时,倒计时按设定长度倒数,闹钟在指定时刻提醒并可以单独开关,设置时钟用于校准时间与日期;全部操作只用板上的两个按键完成。

  • 时钟:日期与星期、大号“时:分:秒”(自绘七段数字)、闹钟状态;作为默认开机界面。
  • 秒表:分:秒大字 + 百分秒,支持启动 / 停止 / 复位,100 ms 刷新一次。
  • 倒计时:默认 10 秒,可自由设置长度;显示剩余分:秒与进度条,到点后 LED 与画面同时闪烁提示。
  • 闹钟:独立开关与时间设置,保存于 EEPROM;到点后红色 LED 每 250 ms 闪烁、屏幕闪烁闹钟时间,按 SW3 停止。
  • 时间设置:“滚轮选择器”交互,分时间页(时 / 分 / 秒)与日期页(年 / 月 / 日,年份)。
  • 状态保存:闹钟设置与开机计数存 EEPROM;时间由 DS1307 维持;倒计时长度与“本次已响铃”标记放 RAM。

硬件介绍

器件清单

器件

型号 / 关键参数

作用

主控开发板

NXP FRDM-MCXN236
MCXN236,Cortex-M33 @150 MHz,1 MB Flash / 192 KB RAM + 96 KB SRAM

运行 Zephyr 与 LVGL;板载 MCU-Link 调试器(SWD + 虚拟串口)

OLED 显示屏

SSD1306,0.96 英寸,128×64 单色,I²C 地址 0x3C

显示全部界面

实时时钟模块

DS1307,I²C 地址 0x68,CR2032 备份电池

提供时间与日期,掉电继续走时

模块上的 EEPROM

24C32,4 KB,32 字节页,I²C 地址 0x50

保存闹钟设置与开机次数

按键

板载 SW2(P0_20)、SW3(P0_6)

全部人机交互

指示灯

板载 RGB LED 的红色通道(P4_18,低电平点亮)

闹钟 / 倒计时响铃提示

接线说明

OLED 模块引脚

MCU 引脚

说明

SDA

P3_18

软件(位翻转)I²C 总线,开漏 + 内部上拉,100 kHz

SCL

P3_17

VCC

P1_7

由 GPIO 供电,固件上电时拉高(高驱动强度)

GND

P2_9

由 GPIO 供电,固件上电时拉低

DS1307 模块引脚

MCU 引脚

说明

SDA

P4_0

硬件 I²C2(LPI2C2),同时挂 DS1307(0x68)与 24C32(0x50)

SCL

P4_1

VCC / GND

3V3 / GND

模块自带电源与上拉,注意上拉电平必须是 3.3 V

三个引脚相关的设计决定

为什么 OLED 用软件 I²C:P3_17 在本芯片上没有任何 LPI2C 功能——它唯一的 flexcomm 信号是 FC8_P3,而 MCXN236 不存在该信号。因此设备树里使用 Zephyr 的 gpio-i2c 驱动,用两个 GPIO 位翻转出 I²C 时序。

为什么 VCC / GND 也接到 GPIO:把模块的四根线全部集中到相邻排针,接线上更省事。代价是面板的供电电流要流过引脚,所以固件用 SYS_INIT 在上电时先拉低 GND、再以高驱动强度拉高 VCC,并等待 250 ms 让面板稳定。

为什么禁用 SAI1:开发板默认把 P3_17 / P3_18 / P1_7 / P2_9 划给了 SAI1(音频)引脚组,恰好就是本项目要用的四个脚。虽然本工程没有启用音频驱动,仍在设备树中显式关闭 &sai1,避免以后打开音频时把引脚“抢”走。

方案框图与设计思路

系统硬件框图

以 MCU 为中心:左侧四类外设按接口类型分开——SSD1306 OLED 走软件位翻转 I²C,DS1307 与 24C32 走硬件 I²C2,两个按键作为 GPIO 输入接入 gpio-keys,红色 LED 及 OLED 的 VCC / GND 由 GPIO 输出驱动。

软件层次

系统自上而下分六层:最上面是本项目自己的应用层(单个 main.c),向下依次是 LVGL 图形库、显示驱动(ssd1306 + 软件位翻转 I²C)、Zephyr 子系统与驱动、Zephyr 内核,最后落到硬件。虚线以上是本项目编写的代码,虚线以下由 LVGL、Zephyr 以及芯片/开发板厂商提供。

设计思路

1、统一的两键交互模型。只有两个按键,与其在每个界面各写一套逻辑,不如固定语义:SW2 负责“移动 / 切换字段”,长按 SW2 = 返回或取消;SW3 负责“确认 / 执行”,在编辑器中为 +1 且长按连加。每个界面底部用两行 8 px 提示文字说明当前按键含义,用户不需要记菜单。

2、用枚举 + 统一重绘管理界面。所有界面写成 enum app_screen,ui_update() 的处理方式是“先把所有可选对象隐藏,再按当前界面打开需要的对象并填内容”。虽然看起来“笨”,但任何界面切换都不会留下上一屏的残留,调试时也容易定位。

3、手写布局,不用自动排布。面板是 1 bpp,且位翻转 I²C 下一次全屏刷新要花约 100 ms(1 KB × 8 位 ÷ 100 kHz)。这意味着界面必须做到“只重绘真正变化的像素”。LVGL 的嵌套容器 / flex 排布会引入额外的重排与整块重绘,早期还出现过“嵌套容器尺寸算成 0×0、内容完全不显示”的问题,最终所有对象都按固定坐标直接放在屏幕上;周期刷新路径上的每处样式与文字修改都做了“值没变就跳过”的保护。

4、大号数字自己画。LVGL 内置位图字体最大 8×16,而 8 px 宽的 UNSCII 字体在 1 bpp 下最清晰。于是时钟、秒表、倒计时、闹钟的时间全部用“七段矩形”绘制(每段一个小矩形),并抽象出六位(时:分:秒)与四位(分:秒 / 时:分)两套布局,四个界面共用同一份代码。

5、信息按“是否掉电保留”分层。时间交给带电池的 DS1307;闹钟设置与开机计数写进 EEPROM;倒计时长度、秒表计时、“本次已响铃”标记放在 RAM(重启即恢复默认),这样既避免频繁擦写 EEPROM,也让行为可预期。

6、把“便于检查”做进设计。Kconfig 提供“开机直接进入指定界面”的选项,以及一个“自动轮播”演示模式(每个界面停留数秒、秒表与倒计时自动运行),再配合后面介绍的显存导出手段,任何界面都能在无人操作的情况下被独立确认。

调试软件、软件流程图与关键代码

调试与验证工具

  • west / CMake / Ninja + Zephyr SDK:构建与烧录,west build -b frdm_mcxn236 mcxn236-oled -p、west flash --runner jlink --device=MCXN236VDF --speed 2000。
  • SEGGER J-Link Commander V9.64:通过 SWD 读写目标 RAM 与外设寄存器、复位芯片,用 savebin 把一段内存导出成文件。
  • 串口终端(虚拟串口):板载 LPUART4 经 MCU-Link 输出,115200,用于查看启动自检信息与每次界面切换、动作日志。
  • 按键注入(自研):向 GPIO0 的方向寄存器 PDDR(0x50096054)写入对应位把按键引脚拉低即可模拟“按下”,写 0 释放(SW2 = bit20,SW3 = bit6),于是可以脚本化地完成“按键 → 界面 → 状态”的全流程自测,无需人手按键。
  • 画面导出(自研):暂停内核后读取 LVGL 的绘制缓冲区 buf0_0(8 字节调色板头 + 位图,MSB 优先,位为 1 表示点亮),解码成字符画;另写了一个七段识别脚本,直接读出屏幕上的数字,用于核对界面内容。

软件流程图

程序分两部分:主程序流程——上电后先完成 OLED 供电时序与 LVGL 初始化,接着建立界面对象并做一次外设自检(I²C 扫描、RTC 校验、EEPROM 开机计数、载入闹钟),再显示启动界面、做首次闹钟检查,随后进入 10 ms 一轮的主循环(按键轮询 + lv_timer_handler())。


主循环中的按键处理:按键事件由 gpio-keys 消抖后进入消息队列,取出后按“短按 / 长按 / 长按连发”三种情况分派到四个动作函数。

关键代码

(1)设备树:软件 I²C + SSD1306 + DS1307/24C32

设备树声明总线与外设:OLED 挂在 gpio-i2c 上(位翻转总线,两个引脚按开漏 + 上拉配置),屏幕参数(128×64、扫描方向、预充电)写在 ssd1306 节点里;DS1307 与 24C32 挂在硬件 I²C2 上,各自用一行 compatible 选中驱动。

/* OLED:P3_17/P3_18 上没有 LPI2C,改用 gpio-i2c 位翻转总线 */
oled_i2c: oled-i2c {
compatible = "gpio-i2c";
scl-gpios = <&gpio3 17 (GPIO_OPEN_DRAIN | GPIO_PULL_UP)>;
sda-gpios = <&gpio3 18 (GPIO_OPEN_DRAIN | GPIO_PULL_UP)>;
clock-frequency = <I2C_BITRATE_STANDARD>; /* 100 kHz */

ssd1306: ssd1306@3c {
compatible = "solomon,ssd1306";
reg = <0x3c>;
width = <128>; height = <64>;
segment-remap; com-invdir; prechargep = <0x22>;
};
};

/* DS1307 RTC 模块走硬件 I2C2:SDA=P4_0,SCL=P4_1 */
&flexcomm2_lpi2c2 {
status = "okay";
pinctrl-0 = <&pinmux_flexcomm2_i2c>;
ds1307: rtc@68 { compatible = "maxim,ds1307"; reg = <0x68>; };
eeprom0: eeprom@50 { compatible = "atmel,at24"; reg = <0x50>;
size = <4096>; pagesize = <32>; address-width = <16>; };
};

(2)OLED 供电时序(启动优先级用编译期断言保护)

面板由两个 GPIO 供电,上电顺序由程序控制:先配置 GND 并拉低,再以高驱动强度把 VCC 拉高,然后等待 250 ms 让面板内部稳定。这段代码放在 POST_KERNEL 阶段的 SYS_INIT 中,并用两条 BUILD_ASSERT 把“晚于 GPIO 控制器、早于 display 驱动”的顺序约束交给编译期检查。

#define OLED_POWER_INIT_PRIORITY 55
/* 供电必须晚于 GPIO 控制器、早于 display 驱动去探测面板 */
BUILD_ASSERT(OLED_POWER_INIT_PRIORITY > CONFIG_GPIO_INIT_PRIORITY, ...);
BUILD_ASSERT(OLED_POWER_INIT_PRIORITY < CONFIG_DISPLAY_INIT_PRIORITY, ...);

static int oled_power_on(void)
{
gpio_pin_configure_dt(&oled_gnd, GPIO_OUTPUT_INACTIVE); /* 先接好地 */
gpio_pin_configure_dt(&oled_vcc,
GPIO_OUTPUT_ACTIVE | KINETIS_GPIO_DS_ALT); /* 再供电 */
k_msleep(250); /* 面板从 GPIO 取电,等待过短会首帧通信失败 */
return 0;
}
SYS_INIT(oled_power_on, POST_KERNEL, OLED_POWER_INIT_PRIORITY);

(3)七段数字:一张段码表 + “值没变就不重绘”

大号时间是使用七个矩形拼出来的:seg_map 给出 0–9 每个数字要点亮哪几段,seg_set_digit() 按位设置每段的背景不透明度。关键在于“值没变就跳过”这个判断——LVGL 只要重新设置样式就一定会触发重绘,而本面板全屏刷新要 100 ms,所以每秒的刷新只把真正变化的段标脏。

/* bit0 上段,顺时针,bit6 中段 */
static const uint8_t seg_map[10] = {0x3f,0x06,0x5b,0x4f,0x66,0x6d,0x7d,0x07,0x7f,0x6f};

static void seg_set_digit(int pos, int value)
{
uint8_t mask = (value >= 0 && value <= 9) ? seg_map[value] : 0;

for (int s = 0; s < 7; s++) {
lv_opa_t opa = (mask & BIT(s)) ? LV_OPA_COVER : LV_OPA_TRANSP;

/* 重新设置样式一定会触发重绘,因此值没变就跳过:
1 s 一次的刷新只脏一小块,刷新开销从 100 ms 降到几毫秒 */
if (lv_obj_get_style_bg_opa(seg_rect[pos][s], LV_PART_MAIN) != opa) {
lv_obj_set_style_bg_opa(seg_rect[pos][s], opa, LV_PART_MAIN);
}
}
}

(4)滚轮选择器:三行数值 + 活动列反白 + 滚动动画

每列显示上、中、下三个值,中间行套一个方框,正在编辑的列整体反白(白底黑字)——用颜色来指示焦点。改值时先把整列下移一行,再用 120 ms 动画移回原位,视觉上就是轮子转过一格;修改文字与位置前都判断是否真的变化,避免多余的整块重绘。

/* 每列显示 上/中/下 三个值,中间行放在方框里;活动列反白(白底黑字) */
for (int c = 0; c < WHEEL_MAX_COLS; c++) {
...
lv_obj_set_style_bg_opa(label, focus ? LV_OPA_COVER : LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_text_color(label, focus ? lv_color_black() : lv_color_white(),
LV_PART_MAIN);
}

/* 改一个值:先把整列下移一行,再动画回到原位 —— 视觉上就是“轮子转了一格” */
static void wheel_roll(int col)
{
wheel_roll_cb((void *)(intptr_t)col, WHEEL_ROW_H);
lv_anim_init(&a);
lv_anim_set_var(&a, (void *)(intptr_t)col);
lv_anim_set_values(&a, WHEEL_ROW_H, 0);
lv_anim_set_duration(&a, 120);
lv_anim_set_exec_cb(&a, wheel_roll_cb);
lv_anim_start(&a);
}

(5)按键状态机:短按 / 长按 / 长按连发

一个结构体记录“是否按下、按下时刻、是否已经算过长按”,就能区分三种动作:松开时按住不足 800 ms 记为短按;按住超过 800 ms 触发长按,并置位 hold_done,使这次松开不再算短按(否则一次长按会同时触发两个动作);SW3 在长按 500 ms 后每 150 ms 连发一次,用于在编辑器中快速加一。

while (k_msgq_get(&button_msgq, &ev, K_NO_WAIT) == 0) {
if (ev.code == BTN_NEXT_CODE) {
if (ev.pressed) { next.down = true; next.down_at = uptime; next.hold_done = false; }
else if (next.down) {
next.down = false;
if (!next.hold_done) on_next_tap(); /* 长按过的松开不再算短按 */
}
} else if (ev.code == BTN_SELECT_CODE) {
if (ev.pressed) { select.down = true; select.down_at = uptime;
select.last_repeat = uptime; on_select_tap(); }
else select.down = false;
}
}

if (next.down && !next.hold_done && (uptime - next.down_at) >= BTN_HOLD_MS) { /* 800 ms */
next.hold_done = true;
on_next_hold(); /* 返回 / 取消 */
}
if (select.down && (uptime - select.down_at) >= BTN_REPEAT_DELAY_MS && /* 500 ms */
(uptime - select.last_repeat) >= BTN_REPEAT_PERIOD_MS) { /* 150 ms */
select.last_repeat = uptime;
on_select_hold(); /* 编辑器里快速加一 */
}

LVGL 根据用户操作和当前页面分发处理。程序的定时逻辑都挂到 LVGL 的定时器:

  • clock_timer_cb: 读 RTC 更新 clock_now;当前在时钟界面时刷新显示;比较闹钟,用 minute_stamp 保证同一分钟只响一次,到点调用 bell_start(BELL_ALARM)
  • tick_timer_cb:倒计时到点判定(bell_start(BELL_TIMER));响铃界面自愈(bell 有效却不在响铃界面就切回);运行时刷新秒表与倒计时界面
  • bell_timer_cb:翻转红色 LED(P4_18)提示响铃;在响铃界面时让闹钟时间以 500 ms 周期闪烁


(6)闹钟触发:用“分钟戳”保证每分钟只响一次

闹钟判断每秒执行一次,但“同一分钟只响一次”不能只比较时分——那样到点之后的每一秒都会重新触发。这里把年月日时分压成一个单调递增的 minute_stamp,只有它与上次响铃记录不同时才启动响铃,因此既不会重复响,也不会漏掉下一次。

/* 把年月日时分压成一个单调递增的整数,用来记录“本次是否已经响过” */
static int64_t minute_stamp(const struct rtc_time *tm)
{
return (((int64_t)(tm->tm_year + 1900) * 12 + tm->tm_mon) * 31 + tm->tm_mday) * 1440
+ tm->tm_hour * 60 + tm->tm_min;
}

static void clock_timer_cb(lv_timer_t *timer) /* 1 s 周期 */
{
if (rtc_get_time(rtc, &now) == 0 && rtc_time_is_valid(&now)) {
clock_now = now; clock_valid = true;
}
if (screen == SCR_CLOCK) ui_update();
if (!alarm.enabled || bell != BELL_NONE || !clock_valid) return;

if (clock_now.tm_hour == alarm.hour && clock_now.tm_min == alarm.minute) {
int64_t stamp = minute_stamp(&clock_now);
if (stamp != alarm_fired_stamp) { /* 同一分钟内不重复触发 */
alarm_fired_stamp = stamp;
bell_start(BELL_ALARM);
}
}
}

(7)响铃界面“自愈”:只要在响铃,就一定显示响铃界面

100 ms 定时器除了刷新计时器界面,还负责一条兜底规则:只要响铃标志有效、而当前显示的不是响铃界面,就强制切回响铃界面。这样无论响铃由哪条路径触发(到点、倒计时结束、开机瞬间),用户都能看到提示并用 SW3 停止。

static void tick_timer_cb(lv_timer_t *timer)       /* 100 ms 周期 */
{
if (timer_running && k_uptime_get() >= timer_end_at) {
timer_running = false; timer_remaining_ms = 0;
bell_start(BELL_TIMER);
return;
}

/* 响铃状态必须拥有屏幕,否则用户无法用 SW3 停止(第六节难题 7) */
if (bell != BELL_NONE && screen != SCR_RINGING) {
show_screen(SCR_RINGING);
return;
}
...
}

(8)启动顺序:先画启动界面,再做首次闹钟检查

先画启动界面,再做首次闹钟检查。若顺序相反,开机时正好到点会先切到响铃界面、随即被启动界面覆盖,而响铃标志仍然有效,于是 LED 一直闪却没有入口可停。首次读取放在创建定时器之前,也让第一个时钟界面立刻拿到真实时间,而不会短暂显示“no RTC”。

        start_screen();
/* 首次读 RTC;如果此刻正好到点,响铃界面会盖在启动界面之上,
而不是被启动界面盖掉 —— 顺序反过来就是“开机即响铃且无法关闭”的根源 */
clock_timer_cb(NULL);
lv_timer_create(clock_timer_cb, 1000, NULL);
lv_timer_create(tick_timer_cb, 100, NULL);
lv_timer_create(bell_timer_cb, 250, NULL);

项目中遇到的难题与解决方法

1、OLED 上电后首次通信间歇性失败。现象:冷启动约一半概率在初始化时报 -EIO。原因:面板由 GPIO 供电,固件只等了 20 ms,面板内部还没稳定。解决:把等待改为 250 ms,之后冷启动 6/6、复位启动 6/6 全部成功。

2、屏幕“全是噪点”,实际是启动就死机。现象:上电后显示一片随机点阵。原因:为了省 RAM 曾把 LVGL 内存池从 64 KB 调到 16 KB,而界面对象已增加到约 100 个,创建对象失败返回 NULL 后被解引用,触发 MPU FAULT(MMFAR=0x0),面板上留下的是上次未刷新的缓存内容。解决:内存池恢复 64 KB;随后又暴露主线程栈溢出(4 KB → 16 KB)。

3、按键完全没有反应。现象:把按键接到 LVGL 的 encoder 输入设备后,即使回调里正确上报了 LV_KEY_ENTER 也没有任何响应。原因:LVGL 的编码器路径依赖 indev->state,而读键回调没有维护该状态。解决:先按规范补上状态,随后彻底放弃 LVGL 的 group/encoder 方案,改成应用自己处理按键状态机——两个按键的场景下,代码更短、行为也更可预测。

4、文字看不见、字体像噪点。现象:标签位置正确但屏幕没有内容,或文字边缘糊成一片麻点。原因:LVGL 默认主题按“浅色背景 + 深色文字”绘制,而本界面是黑底;另外抗锯齿的 Montserrat 字体在 1 bpp 下被阈值化,效果就是噪点。解决:显式把屏幕与文本颜色设为白色;字体统一改用 UNSCII 8×8 位图字体(顺带把 16 px 字体关掉以省 Flash)。

5、日期行溢出屏幕。现象:16 px 字体下“Sat 03 Oct 2026”共 15 字符需要 240 px,远超 128 px。原因:把大字体用在了长文本上。解决:所有文字统一 8 px,并把每一行提示压缩到 16 字符以内(128 px ÷ 8 px)——这条约束后来直接决定了界面提示的措辞。

6、滚轮选择器完全不显示。现象:数值与文字都设置正确,屏幕上却只有一条高亮横条。原因:早期把每列的三行数值放进“每列一个容器”,容器的尺寸算成了 0×0,子对象自然不绘制。解决:去掉容器,直接把标签放在屏幕上,动画改为修改标签自身的 y 坐标;顺带把高亮从“横条 + 光标”改成“中间行方框 + 活动列反白”,更直观。

7、断电重启后闹钟响铃无法关闭。现象:开机时如果当前时间正好等于闹钟,LED 一直闪烁,屏幕上却没有任何可操作的入口。原因:main() 里的“首次闹钟检查”发生在“绘制启动界面”之前——检查触发响铃并切到响铃界面,随后启动界面又把它覆盖掉,而 bell 标志仍然有效,于是 LED 持续闪烁、但用户再也进不到能停止它的界面。解决分两步:① 调整启动顺序,先画启动界面、再做首次检查;② 在 100 ms 定时器里加入“自愈”逻辑——只要 bell 非空且当前不是响铃界面,就强制切回响铃界面,从根本上保证“响铃一定可被停止”。修复后实测复现该场景:开机即显示响铃界面,按 SW3 正常停止,LED 熄灭。

心得体会

UI 设计比想象中要复杂,第一次用LVGL,本来想利用它的组件简化开发,但是实际遇到不少问题,编译时间也长了不少,对简单 UI 可能更轻量的方案会更合适。

AI 测试时候非常聪明,可以通过 JLink做按键注入、显存导出、RAM 变量读取,结合起来就可以做自动化测试。但是有些逻辑要多步操作才能进入(例如进入时钟设置页面要按4次按键),这时候通过 Kconfig 让页面直接显示反而更简单,不会中途错误进入其他页面而导致卡住测试。


附件下载
zephyr.elf
firmware
funpack-5-4-clock.tar
code
团队介绍
DDL Fighter
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号