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: partition@f4000 { 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 SDK,FDS(Flash 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
这一点特别重要。键盘按键、旋钮调节、菜单切换,这些都是高频事件。如果每按一次都写 Flash,Flash 会非常快被写坏,而且写入过程阻塞或异步时,界面会卡顿。
我建议的写入策略是:
- 用户修改参数后,先只在 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 和批量制造时需要的那部分可靠性设计。