资讯详情

资讯详情

NRF52840蓝牙键盘UI与存储设计:OLED交互和Flash参数分区落地实践

NRF52840 蓝牙键盘做到这个阶段很多人会遇到一个共同问题BLE 通信已经通了按键也能上报但产品还停留在“工程师自己看得懂”的状态。真正决定使用体验的是两块看起来不相关的模块一块 OLED 屏幕和一个能把配置保存下来的 Flash 分区。这篇就围绕 NRF52840 蓝牙键盘讲清楚 OLED UI 设计和 Flash 分区参数分离的完整落地过程。OLED 不是点个灯也不是在显示屏上画画而是要把状态、菜单、按键操作串成一条稳定的交互链路Flash 也不只是存数据还要想清楚哪些数据能存、存在哪里、怎么防止掉电写坏。这套内容适合已经点亮过蓝牙、正在做键盘体验优化的人。如果你前面还没有跑通 HID 上报建议先把通信链路稳定下来再看这里的 UI 和存储设计。我会按自己实测时的顺序写先搭显示框架再规划 Flash然后拆参数最后把两者联动起来。里面涉及的地址和参数是通用示例实际工程要以你自己的 SDK 版本和链接脚本为准。1. 这一期到底要解决什么问题1.1 OLED UI 不是“点亮屏幕”而是处理一条交互链路很多新手拿到 SSD1306 这类 OLED 模块第一件事是找驱动库然后写一句OLED_ShowString(0, 0, Hello)屏幕亮了就很兴奋。等真做到蓝牙键盘项目里会发现这远远不够。键盘上的 OLED 要展示什么东西通常是几类信息比如电池电量、蓝牙连接状态、当前键位布局、传输模式、自定义宏状态、省电倒计时。这些状态不是静态的而是会随着蓝牙连接、断开、按键组合而变化。如果只是在主循环里不停刷新整屏屏幕能亮但按键扫描和 BLE 上报会被拖慢甚至出现键盘卡顿。所以这一期先把 OLED UI 当成一条链路来理解输入事件、状态变化、界面刷新、底层驱动。UI 设计是画好这些界面和状态之间的跳转不是只画一帧图。1.2 Flash 分区和参数分离解决什么问题蓝牙键盘用一段时间后会出现一个需求用户换了键位布局改了背光亮度调了空闲休眠时间。这些配置如果只在 RAM 里保存断电就没了。要想下次开机还保留就必须把配置写进 Flash。但 Flash 不是普通变量。它按页擦除、按字节写入还有擦写次数限制而且 NRF52840 内部 Flash 同一个位置不能无限地写。直接定义一个二维数组当“掉电保存区”用几天下就可能出问题。于是需要做 Flash 分区和参数分离。Flash 分区解决的是“代码放哪里、协议栈放哪里、参数放哪里”的布局问题。参数分离解决的是“哪些参数要固化、怎么存储、怎么灰度升级”的问题。两者结合才能让键盘的配置系统稳定、可扩展、不容易被写坏。1.3 阅读这篇内容需要什么基础我不打算从 GPIO 点亮 OLED 开始讲。你需要先具备这些基础会用 NRF52840 的 SDK 或 Zephyr 新建工程跑通过 BLE 键盘或 HID 设备的最小 demo知道 I2C 或 SPI 是什么能看懂 OLED 驱动代码了解 Flash 的基本概念页、扇区、擦除、写入如果你只是在 51 单片机上跑过 OLED思路也可以复用但 NRF52840 有 SoftDevice 或协议栈存在Flash 操作比裸机 MCU 要谨慎得多。后面会专门说这个坑。2. 先把 OLED 显示框架搭稳2.1 确认屏幕类型和驱动时序键盘项目里最常见的是 128x64 或 128x32 的 OLED控制芯片一般是 SSD1306。通信接口有 I2C 和 SPI 两种。第一次拿到模块先看背面的丝印确定是 I2C 还是 SPI 版本再确认 I2C 地址是 0x3C 还是 0x3D。不少工程卡在“屏幕不亮”其实是地址写错。我一般先把官方或常用 SSD1306 驱动跑通然后用OLED_Clear()和OLED_ShowString()验证基本显示。能显示之后不要急着画菜单先确认三件事刷新一屏需要多长时间I2C 速度是多少屏幕正常亮度下持续刷新会不会对功耗有明显影响SSD1306 属于被动发光 OLED不是上电就亮必须通过 I2C/SPI 发送初始化指令。之前有人问“OLED 屏幕连上电源就亮吗”对 SSD1306 这类模块不能这么理解它需要软件初始化。所以排查不亮问题时先查地址、初始化序列和电源引脚。2.2 分区域刷新和帧缓冲区在 NRF52840 上主频不算低Flash 也够大但 OLED 刷新不是免费的。I2C 刷新一屏 128x64 数据看起来不大实际会占用大量时间如果放在按键中断里处理会让 BLE 上报出现抖动。我的做法是在内存里维护一个帧缓冲区然后只把变化的部分发给屏幕。比如电量从 80% 变成 79%只需要更新屏幕左上角电池图标的 1/4 区域而不是重新发送整屏数据。这样 I2C 数据量降低BLE 时序更稳定。当你使用 SSD1306 时可以封装成下面的思路typedef struct { uint8_t buffer[128 * 8]; // 128x64 按页排列共 8 页 } oled_display_t; void oled_update_area(int x, int y, int width, int height); void oled_render_full(void); void oled_clear_area(int x, int y, int width, int height);oled_update_area只把变化的矩形区域转换到页地址窗口然后通过 I2C 写入。UI 层不要直接操作底层寄存器只维护 buffer。这样后续换 SPI 屏幕或者换分辨率上层不用大改。2.3 用页面表管理界面菜单系统如果写成if(key KEY_UP) { screen SCREEN_MENU; }一串判断后面会很难维护。我建议用页面表结构把每个界面抽象成一个独立对象。typedef enum { SCREEN_NONE 0, SCREEN_MAIN, SCREEN_MENU, SCREEN_KEYMAP, SCREEN_BRIGHTNESS, SCREEN_ABOUT, SCREEN_MAX } screen_id_t; typedef struct { void (*enter)(void); void (*exit)(void); void (*render)(void); void (*event)(uint8_t key_event); } page_t; static const page_t page_table[SCREEN_MAX] { [SCREEN_MAIN] { main_enter, main_exit, main_render, main_event }, [SCREEN_MENU] { menu_enter, menu_exit, menu_render, menu_event }, [SCREEN_KEYMAP] { keymap_enter, keymap_exit, keymap_render, keymap_event }, [SCREEN_BRIGHTNESS] { brightness_enter, brightness_exit, brightness_render, brightness_event }, };页面切换时先调用当前页的exit再改变当前screen_id最后调用新页面的enter和render。事件分发的时候只需要把按键事件交给当前页面函数。这个模式无论是在裸机还是 RTOS 里都很好用。它的好处是每个界面功能独立增加新菜单不需要改主循环只需要在page_table里加一项然后写对应的三个函数。2.4 放在主循环还是独立任务如果工程用裸机我建议把 OLED 刷新放到主循环里用一个“脏标记”判断是否需要刷新volatile bool g_ui_dirty false; while (1) { scan_keys(); process_key_event(); if (g_ui_dirty) { ui_render_current(); g_ui_dirty false; } handle_ble_events(); }如果用了 FreeRTOS可以让 UI 跑在一个低优先级任务里。BLE 回调仍然要保持在中断上下文之外处理。OLED 的 I2C 通信不要放在 BLE 事件中断里否则可能阻塞协议栈。还有一个容易忽略的地方UI 刷新任务里不要用vTaskDelay(1)高频刷新。屏幕内容不变化时应该不刷新或者只做低频率的时钟刷新。键盘本身是低功耗设备OLED 又是耗电大户UI 刷新频率应该和屏幕内容变化频率挂钩。3. Flash 分区先想好数据能放在哪3.1 1MB 闪存不等于可以随便写NRF52840 内部 Flash 是 1MB这个容量看着不小但要注意两点第一代码、协议栈、启动代码会占掉一部分第二Flash 写入前必须先擦除擦除以页为单位不能单字节擦除。还要注意Flash 有寿命限制。芯片手册上常见标称是擦写 1 万次左右。如果你把某个固定地址当成“配置变量”反复写比如每次按键都写一次当前状态那么这个页很快就会达到寿命上限。实际工程要按更保守的方式设计不能把整机的可靠性压在一两个页上。所以要先做分区规划。分区不是把地址随便分配而是把功能模块需要的空间、写入频率、掉电可靠性都考虑进去。3.2 参考分区布局下面是一个概念性分区参考不是所有工程的绝对答案。实际地址要以你的链接脚本和 SoftDevice 版本为准。0x00000000 ───────────────── MBR / 协议栈或引导保护区 这部分由芯片或 SDK 管理 0x00010000 ───────────────── 应用固件区 存放编译后的代码 0x000A0000 ───────────────── 用户参数区 保存键位布局、亮度、休眠时间等配置 0x000B0000 ───────────────── OTA / 日志预留区 0x00100000 ───────────────── 1MB Flash 结束我特意不写精确数值因为不同 SoftDevice 版本和 SDK 配置会导致应用起始地址不同。你需要打开工程里的链接脚本.icf或.ld文件确认实际 Flash 布局。做分区时建议遵循这些原则代码区归代码区不要再往里塞数据参数区和代码区保持一定间隔避免误擦高频写入的日志单独放一页不跟配置混在一起OTA 预留区单独规划避免升级时覆盖参数3.3 链接脚本和 SoftDevice 的边界NRF52840 使用 SoftDevice 时协议栈会占用 Flash 顶部或底部区域。这个区域不能随意写入否则会破坏蓝牙协议栈。很多人遇到“一写 Flash 蓝牙就断开”或者“死机”多数就是写到了协议栈保护区。怎么确认边界打开编译生成的.map文件看最后一个 Flash 符号和 RAM 符号。或者在链接脚本里看以下行FLASH (rx) : ORIGIN 0x000XXXXX, LENGTH 0xYYYYY RAM (rwx) : ORIGIN 0x200XXXXX, LENGTH 0xYYYYY不同工程差异很大不要直接复制网上的地址。你的用户参数区必须在应用固件区之后并且在 Flash 尾部预留区之前。如果你用 Zephyr 开发也有dts里的 flash 分区节点。分区可以定义成/delete-node/ storage_partition; flash0 { partitions { compatible fixed-partitions; #address-cells 1; #size-cells 1; storage_partition: partitionf4000 { label storage; reg 0xf4000 0x4000; }; }; };这种方式比裸地址更清晰也让代码不依赖绝对地址。3.4 谁负责擦除和写入Flash 写入不是memcpy。你必须调用 Flash 擦写接口。这一点最容易踩坑。在裸机环境下可以使用 Nordic 的nrf_nvmc驱动#include nrf_nvmc.h #define PARAM_PAGE_ADDR 0x000A0000 #define PARAM_PAGE_SIZE 4096 void save_param_to_flash(const void *data, uint32_t size) { // Flash 写入前必须先擦除整页 nrf_nvmc_page_erase(PARAM_PAGE_ADDR); nrf_nvmc_write_bytes(PARAM_PAGE_ADDR, data, size); }但是如果工程启用了 SoftDevice不能用nrf_nvmc直接写应用分区。SoftDevice 有专门的 Flash 管理接口比如fstorage或 FDS。直接绕过协议栈操作 Flash可能造成硬错误或协议栈崩溃。所以先问自己一个问题你这个工程到底运行在裸机模式还是带 SoftDevice 的 BLE 模式这个问题的答案决定了你会用哪一套 Flash 操作 API。4. 参数分离从“写死键位”到可配置4.1 要拆出来哪些参数参数分离的第一步是先找出哪些值不应该写死。对蓝牙键盘来说常见参数包括键位布局 ID比如 QWERTY、Dvorak、Colemak轮询率或回报率按键消抖时间、连击延时背光亮度OLED 屏幕超时时间空闲休眠时间宏文件或自定义按键序列设备名称后缀或广播名称中的用户标识这些值放在代码里作为#define也不是不行但每次改配置都要重新编译固件非常不用户友好。更好的做法是把它们统一放进一个配置结构体运行时可以读取和修改保存后永久生效。4.2 使用结构体和版本号配置参数不应该用一堆零散全局变量。散落的uint8_t backlight_brightness、uint16_t sleep_timeout很容易在保存时漏掉一个。我习惯定义一个app_config_t结构体typedef struct { uint32_t magic; uint16_t version; uint16_t keymap_id; uint8_t backlight_brightness; uint8_t oled_timeout_sec; uint16_t idle_sleep_sec; uint16_t debounce_ms; uint8_t adv_name[16]; uint8_t reserved[32]; } app_config_t;结构体末尾加reserved数组是为了后续扩展不破坏存储布局。magic是校验标记version是配置版本。读取的时候如果magic不对或者version不匹配就说明配置无效应该回退到默认值。这一步非常关键。没有版本号以后你加一个配置项旧固件保存的数据又读进新固件结构体长度不一致轻则读错参数重则越界。4.3 默认值、运行值和持久化值三层分离我在工程里会分成三层默认值编译期常量出厂状态运行值当前生效的配置放在 RAM持久化值保存在 Flash 里的配置系统启动后先把运行值从 Flash 读出来。如果 Flash 无数据或数据校验失败就把默认值拷贝到运行值。界面和逻辑只操作运行值。只有当用户修改配置时才把运行值写回 Flash。static app_config_t g_config; static const app_config_t g_default_config { .magic 0xA5A5A5A5, .version 1, .keymap_id 0, .backlight_brightness 100, .oled_timeout_sec 30, .idle_sleep_sec 300, .debounce_ms 5, };使用运行值的好处是Flash 写入失败不会让当前配置立刻失效用户修改后即使没有保存成功键盘仍然能用只是重启后回到旧配置。4.4 使用 FDS 或者自建分区读写如果你用的是带 SoftDevice 的 Nordic SDKFDSFlash Data Storage是一个很合适的方案。它帮你管理记录和写入不需要自己处理擦写平衡。FDS 的使用大致是初始化、注册回调、写入记录、读取记录static void fds_evt_handler(fds_evt_t const * const p_fds_evt) { if (p_fds_evt-result NRF_SUCCESS) { // 写入完成 } } void config_init(void) { ret_code_t rc fds_register(fds_evt_handler); if (rc NRF_SUCCESS) { rc fds_init(); } } void config_save(app_config_t *config) { fds_record_t record; record.file_id 0x0001; record.key 0x0001; record.data.p_data config; record.data.length_words (sizeof(app_config_t) 3) / 4; fds_record_write(NULL, record); }读取时fds_record_desc_t desc; fds_find_token_t token {0}; fds_record_t record; if (fds_find_and_read(0x0001, 0x0001, desc, record) NRF_SUCCESS) { memcpy(g_config, record.p_data, sizeof(g_config)); }FDS 的优点是封装了记录管理、擦除均衡和垃圾回收。但要注意FDS 写入请求是异步的你不能在fds_record_write()后立刻再写同一条记录必须先等回调通知完成。如果你的工程不用 SoftDevice也可以自定义一个简单的页管理逻辑写入时先擦除整页再把新的结构体拷贝进去。因为 Flash 写入没有原子性的“修改字节”你要自己保证不会出现写一半的乱数据。最简单的方式是每个结构体开头放 magic写完校验整个结构体的 CRC读取时发现校验失败就重置。4.5 写入时机不要每次按键都写 Flash这一点特别重要。键盘按键、旋钮调节、菜单切换这些都是高频事件。如果每按一次都写 FlashFlash 会非常快被写坏而且写入过程阻塞或异步时界面会卡顿。我建议的写入策略是用户修改参数后先只在 RAM 里生效连续修改时只更新 RAM用户停止操作后启动一个延时保存任务延时期间如果又出现修改重新计时延时结束后才写入 Flash比如实现一个 3 秒延迟保存uint32_t g_config_dirty_time 0; void config_mark_dirty(void) { g_config_dirty_time get_tick_ms(); } void config_poll_save(void) { if (g_config_dirty_time ! 0 (get_tick_ms() - g_config_dirty_time) 3000) { config_save(g_config); g_config_dirty_time 0; } }这个函数放在主循环或者低优先级任务里避免在按键中断里直接写 Flash。5. UI 和 Flash 参数怎么联动5.1 菜单修改 - 运行值更新 - 屏幕刷新现在把 UI 和参数合起来。在配置界面里用户按上下键调整亮度屏幕要立刻看到亮度条变化此时修改的是g_config.backlight_brightness属于 RAM 运行值。界面刷新函数读取运行值绘制矩形条或数字。流程如下用户按某个组合键进入配置菜单UI 状态机切换到对应页面按键事件触发g_config某个字段变化设置g_ui_dirty true主循环刷新 UI配置参数变化后调用config_mark_dirty()等用户停止操作延迟写入 Flash这样 UI 和 Flash 之间没有强耦合不会出现“一改参数屏幕就卡”的情况。5.2 参数变更后延时保存和低功耗策略OLED 本身耗电。如果不做处理屏幕会一直亮着直到电池耗尽。我建议把 OLED 关屏也纳入参数逻辑比如oled_timeout_sec配置 30 秒30 秒没有按键就清屏或者进入低功耗模式。注意OLED 关屏后如果有按键按下要立即唤醒并重新点亮。为了不把“点亮屏幕”的逻辑写在每一个按键分支里我会把按键事件统一交给 UI 事件处理函数由它判断是否需要先点亮屏幕。低功耗策略还要考虑 Flash 延迟保存的场景。如果屏幕已经关闭配置还没有保存此时不能直接进入睡眠。必须先完成保存再执行睡眠流程。否则用户改了亮度还没来得及保存就休眠重启后配置丢失。5.3 开机加载、校验和回退系统上电后在初始化 BLE 之前先加载配置void config_load(void) { if (config_valid_in_flash()) { config_read_from_flash(g_config); } else { g_config g_default_config; } }如果读取到的magic不正确或者version不是当前支持版本就说明 Flash 里的数据可能来自旧固件或者写了一半。此时不能乱用应该回退默认值。同时 UI 上可以显示一屏启动信息比如Keymap: QWERTY Battery: 85% BLE: Ready Firmware: v4.2这些信息同时来自运行值、协议栈状态和 UI 逻辑。5.4 需要认真处理的边界情况第一个边界情况是断电。用户正在保存配置时突然拔掉电池或断电Flash 可能停在半写状态。解决办法是靠magic和校验值兜底不要让系统假设数据一定是完整的。第二个边界情况是连续修改同一个参数。比如亮度从 0 调到 255中间值不用全部保存只需要保存最终值。所以延迟保存策略在这里很重要。第三个边界是配置版本升级。新固件的配置结构体可能多了两个字段。读取旧版本数据时需要做迁移而不是直接memcpy到新结构体。哪怕只是加字段也要判断version把旧字段迁移过来新字段用默认值填充。这些看起来琐碎但都能在长期使用中避免“莫名其妙丢配置”的问题。6. 常见问题排查清单6.1 OLED 不亮或显示异常先看以下顺序确认模块电源是 3.3V不是 5V确认 I2C 或 SPI 引脚和初始化代码一致确认屏幕 I2C 地址是 0x3C 还是 0x3D确认复位引脚是否拉高确认初始化序列是否被放在了主循环里重复执行确认 I2C 总线上是否有上拉电阻我遇到过很多次“屏幕不亮”最后发现是 GPIO 配置错了或者 I2C 地址多写了一位。先用逻辑分析仪或示波器看 SCL/SDA 波形能省很多时间。6.2 写 Flash 之后蓝牙断开或者系统卡死这个问题的常见原因是带 SoftDevice 时用了裸机 Flash 驱动写入地址落在协议栈保护区或应用未授权区域。排查顺序检查写入地址是不是在链接脚本允许的 Flash 范围内确认工程是否启用 SoftDevice确认写入 API 是不是用了sd_flash_write或 FDS 这类受管接口检查 FDS 是否已经初始化完成检查 Flash 写入是否和 BLE 事件发生冲突如果在裸机下没有 SoftDevice也要确认写的是用户参数区而不是代码区。擦除了代码区程序直接跑飞。6.3 保存后重启参数丢失不要先怀疑 Flash 坏了。先检查保存和读取用的地址是否一致。很多工程保存到页 1读取时读页 2自然读不到。再检查magic和version。如果写入的是新版本结构体读取时判断版本号错误也会回退默认值。还有一种情况写入是异步的代码写完fds_record_write()立刻断电数据还没真正落盘。需要等写入完成回调再允许断电或睡眠。6.4 屏幕刷新造成按键延迟键盘按键扫描的实时性要求高OLED 刷新如果占用太长时间按键延迟就会变大。改进思路包括把 OLED 刷新放到主循环不放中断只更新局部区域不全屏刷新使用 I2C DMA 或硬件 SPI 减少 CPU 占用提高 I2C 时钟但要确认 OLED 模块支持屏幕内容不变时完全不刷新要是刷新频繁仍然卡可以进一步降低刷新频率或者把菜单动画关掉。键盘 UI 不需要炫酷动画稳定性优先。6.5 分区规划不够用怎么办参数区不够用不要简单把地址往后挪。要看全局 Flash 布局。如果代码区占了太多先检查优化等级和未使用代码。如果日志区太大可以压缩。如果 OTA 预留区很大可以暂时借用但要保证后续升级时不会覆盖用户参数。更好的办法是使用文件系统或 FDS 这类抽象层让多条记录动态分配减少手动管理地址的负担。7. 落地建议先跑通单模块再合进整机7.1 模块验证顺序我会建议按下面顺序验证单独验证 OLED 驱动确保屏幕亮、显示正常单独验证 Flash 写入保存一组临时数据重启读取把配置结构体和默认值加进去测试改成参数后能保存把 OLED UI 页面状态机接上测试按键切换页面把参数修改和 UI 刷新联动测试延时保存最后再把 BLE 状态显示接进来比如电量、连接状态不要一开始就把所有功能都打开。我见过不少工程UI 还没稳定就接电量采集结果显示和通信互相干扰排查起来非常痛苦。7.2 给固件升级留个口子如果后续有 OTA 升级需求Flash 分区规划时就要把 OTA 区域和参数区分开。否则升级固件的时候可能把用户配置文件覆盖掉。这也不是非要第一版就做完整 OTA。但至少预留一个分区位置并为固件版本号增加一个 UI 显示页面。这样后续开发不用推翻重来。7.3 留着日志和诊断信息产品阶段可以没有日志但开发阶段一定要有日志输出。UI 切换、参数保存、Flash 写入回调、BLE 状态变化都打一句日志。出了问题先看日志比反复猜原因高效得多。我一般会在 Flash 写入回调里打一行[config] save result: 0如果写入失败日志里会显示错误码能快速判断是 FDS 空间不足、还是地址非法、还是协议栈冲突。7.4 一段经验收尾这一期里讲到的方案不一定是最先进的但足够稳。NRF52840 的 Flash 空间不小可它仍然是嵌入式资源要按页擦除、按块规划。OLED UI 再好看也不能牺牲键盘的响应速度和续航。把参数分离做好后续加功能、换布局、做升级都会轻松很多。如果你刚把 BLE 键盘跑通下一步就把配置存储和 UI 交互拆开来做。先用默认值把 UI 调顺再把 Flash 写入接进去最后才考虑 OTA 和批量制造时需要的那部分可靠性设计。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →