标题:NRF52840 蓝牙键盘开发:OLED UI 设计与 Flash 分区参数分离实战(第四期)
NRF52840 蓝牙键盘做到第四期,这期的重点转向了软件架构。前几期把硬件、蓝牙通信跑通之后,剩下的核心问题基本都集中在人机交互和数据存储上:屏幕要显示连接状态、按键层、电池电量和配置菜单,用户改过的按键映射、背光设置断电之后还得保住。所以本次实战把 OLED UI 设计、Flash 分区、参数分离三件事放在一起做。
这个项目在硬件上并不复杂,主控是 nRF52840,显示模块用 128x64 分辨率的 SSD1306 OLED,开发环境走 nRF5 SDK。真正花时间的是软件组织方式:OLED 的帧缓冲满量也就 1KB,Flash 页大小 4KB、整片擦写寿命按数据手册标注通常在一万次量级,这两条限制决定了 UI 不能随便刷新、参数不能频繁存盘。文章接下来会给出这套软件架构的完整设计思路,包括驱动层、UI 状态机、Flash 分区示例,以及参数读写验证流程。
这个题目真正值得反复看的地方,在于它提供了一个可以复用的嵌入式软件模板。不管你现在用的是 nRF52840、STM32 还是 51 单片机,OLED 驱动、菜单状态机、Flash 掉电保存这三块问题的解法是相通的。后面凡是新项目要加屏幕、加配置保存,都可以直接拿这套思路套一套。
1. 核心能力速览
先给结论,再展开细节。这个第四期项目不是纯教程演示,它是一套已经在蓝牙键盘上跑通的软件方案,核心能力可以归纳为下面几点。
| 能力项 | 说明 |
|---|---|
| 主控平台 | Nordic nRF52840,Cortex-M4F @ 64MHz,1MB Flash,256KB RAM |
| 显示方案 | SSD1306 OLED,128x64 分辨率,I2C/SPI 接口,帧缓冲 1KB |
| UI 框架 | 状态机 + 页面表驱动,支持连接状态页、菜单页、参数配置页 |
| 存储方案 | Flash 分区管理,参数区与日志区分离,可选 FDS 或自定义记录 |
| 掉电保存 | 按键映射、背光亮度、蓝牙配对信息等配置参数独立存储 |
| 软件工具链 | nRF5 SDK / nRF Connect SDK,Keil、SES 或 GCC 编译,J-Link 烧录 |
| 调试方式 | SWD 断点调试、串口日志、OLED 实机显示验证 |
| 可迁移性 | OLED 驱动和 Flash 分区思路可迁移到 STM32、GD32、51 单片机 |
表格里的主频、Flash、RAM 来自 nRF52840 芯片公开规格,1KB 帧缓冲来自 128x64 分辨率下 8bit 像素的显存计算。其余工程层面的参数,比如 FDS 实际占用的页数、日志区大小、菜单页面数量,都需要按照你自己的工程配置来确认。
这套方案在实际产品里解决的是一个很典型的矛盾:用户希望设备有配置能力,但单片机资源有限,不能把配置数据随便堆在代码段里,也不能每次按键修改都直接擦写 Flash。所以第四期的核心目标非常明确:把 UI 和存储做成两个独立的软件模块,UI 只管页面显示和输入事件,存储模块只负责参数读写,两者通过统一接口通信。
2. 适用场景与使用边界
先说适合谁。这个方案最适合三类人:第一类正在用 nRF52840 做蓝牙键盘、小键盘或者桌面控制设备的开发者,可以直接复用这套 UI 和存储架构;第二类用 STM32 或国产单片机做小屏产品的工程师,虽然芯片不同,但 OLED 驱动、状态机菜单、Flash 参数分离的思路完全一致,只要改掉底层接口就能搬过去;第三类是学习单片机裸机架构的初学者,这个项目能讲清楚代码分层的重要性,而不是把所有功能堆在一个 main.c 里。
解决的问题也很具体。OLED 屏接上单片机之后,很多人会遇到两种尴尬:一是屏幕能亮,但页面结构乱,菜单多了以后切换逻辑和显示代码纠缠在一起,新加一个配置项要改好几个文件;二是参数存不住,配置放 RAM 里断电就丢,放在 EEPROM 模拟区里又担心擦写次数不够、格式化麻烦。这套方案用一个状态机管 UI,用一个带分区概念的存储层管参数,两边解耦。
但不适合什么场景也要说清楚。如果产品只需要开机显示一个固定 Logo,不需要用户交互,那引入一套菜单状态机是过度设计;如果参数只有两三个 BOOL 值,直接存 Flash 单页、用两个 Magic Number 做校验也够用,没必要把 FDS 组件拉进来。这个项目的目标是中等复杂度配置管理,比如几十个可调参数、多层菜单、多组配置模板,这种量级用它才划算。
还要注意合规和安全边界。蓝牙键盘涉及无线通信模块,量产上市要过蓝牙认证和无线电型号核准,这个属于产品化阶段的事,项目验证阶段可以暂时不碰,但做产品前必须规划。所有保存到 Flash 的参数,不要放账户密码、支付凭据这类敏感数据;芯片 Flash 没有硬件加密保护,直接明文存储有被读出风险。涉及 OLED 显示的内容也要注意,不要展示未经授权的品牌 Logo、商标或受版权保护的图片素材。
3. 硬件平台与开发环境准备
这一节把开发环境列成一个可操作的清单。准备开始之前,先确认四件事:芯片型号、开发板、显示屏、调试器。
nRF52840 是 Nordic 的旗舰 BLE SoC,片上集成 2.4GHz 射频、ARM Cortex-M4F 内核、1MB Flash 和 256KB RAM,同时支持 BLE 5.0、NFC、USB 2.0、多个 SPI/I2C/UART 外设。做蓝牙键盘它在连接稳定性和外设资源上都够用,尤其是一边跑 BLE 协议栈、一边跑 OLED UI、一边处理 HID 报告,这个负载对它来说还在舒适区。
硬件准备清单:
| 硬件 | 型号/规格 | 用途 |
|---|---|---|
| 主控开发板 | nRF52840 DK 或第三方核心板 | 主控平台 |
| OLED 显示屏 | SSD1306 / SH1106,128x64,I2C 或 SPI | 人机交互显示 |
| 调试器 | J-Link / DAPLink | 烧录和调试 |
| 连接线 | I2C/SPI 杜邦线,VCC、GND、SDA/SCL | 显示屏供电和通信 |
OLED 模块建议优先选 I2C 版本,引脚占用少,接线简单,对键盘这种紧凑设备友好。如果对刷新率要求高,比如以后要加动画效果,再考虑 SPI 版本,因为 SPI 时钟更高,数据吞吐量比 I2C 大不少。第四期项目的 UI 以状态页面和菜单为主,不是高频动画,I2C 驱动 128x64 屏幕能做到 10~20 帧左右的整体刷新,实际使用足够。
软件开发环境按 nRF52840 常用配置准备:
| 组件 | 推荐配置 |
|---|---|
| SDK | nRF5 SDK 17.x 及以上,或 nRF Connect SDK |
| 编译器 | ARMCC、GCC 或 Segger Embedded Studio |
| 烧录工具 | nrfjprog、J-Link Commander 或 IDE 内置烧录 |
| 协议栈 | SoftDevice S140,BLE 5.0 所需 |
| 调试工具 | J-Link、串口助手、逻辑分析仪(抓 I2C 时序) |
如果你不打算跑完整 BLE 协议栈,只想先单独验证 OLED UI 和 Flash 分区,也可以在一个裸机工程里做。这样编译更快、调试更简单,先把软件模块调通,再合并到带 SoftDevice 的蓝牙工程里。实际做的时候,推荐先用裸机工程验证全部功能,最后再切到带协议栈的工程,能少踩很多时序和内存布局上的坑。
4. OLED 底层驱动设计
OLED 驱动是整个 UI 设计的地基。很多人直接把 SSD1306 的初始化序列和绘图函数扔进 main.c,短时间能亮,但画面一复杂、菜单一多就失控。第四期项目把驱动拆成了两层:底层只负责像素填充和命令收发,上层 UI 只管逻辑和布局。
4.1 驱动接口抽象
对于 SSD1306 这类控制器,I2C 方式只需要两条信号线,SPI 方式需要 CLK、DIN、DC 和 CS。先定义一个统一的显示驱动接口,屏蔽 I2C 和 SPI 的差异:
typedef struct { void (*init)(void); void (*set_cursor)(uint8_t page, uint8_t col); void (*write_cmd)(uint8_t cmd); void (*write_data)(uint8_t data); void (*fill_ram)(uint8_t *buf, uint16_t len); } oled_driver_t;实际使用时,根据硬件配置选择 I2C 或 SPI 实现,把函数指针指向对应驱动。UI 层完全不用关心底层是 I2C 还是 SPI,只要调用这个接口就能完成显示。这种抽象最大的好处是,后续要换屏幕型号,或者从 I2C 换成 SPI,UI 层代码一行都不用改。
4.2 帧缓冲与局部刷新
128x64 分辨率、单色 1bit 存储,完整帧缓冲只要 128 * 64 / 8 = 1024 字节,也就是 1KB。可以直接放在 RAM 里,不占 Flash。所有绘图操作先写缓冲,再统一刷屏,避免一边绘图一边刷新造成的闪烁。
#define OLED_WIDTH 128 #define OLED_HEIGHT 64 #define OLED_PAGES (OLED_HEIGHT / 8) uint8_t g_framebuf[OLED_WIDTH * OLED_PAGES]; // 1024 bytes void oled_clear(void) { memset(g_framebuf, 0, sizeof(g_framebuf)); } void oled_flush(void) { // 按页传输到 SSD1306 的 GDDRAM for (int page = 0; page < OLED_PAGES; page++) { oled_set_cursor(page, 0); oled_write_data(&g_framebuf[page * OLED_WIDTH], OLED_WIDTH); } } void oled_draw_pixel(uint8_t x, uint8_t y, uint8_t color) { if (x >= OLED_WIDTH || y >= OLED_HEIGHT) { return; } if (color) { g_framebuf[x + (y / 8) * OLED_WIDTH] |= (1 << (y % 8)); } else { g_framebuf[x + (y / 8) * OLED_WIDTH] &= ~(1 << (y % 8)); } }注意这个局部刷新的问题:每次调用 oled_flush 会把全部 1KB 重新发送到屏幕,在 I2C 模式下整体刷新一次要传输约 1KB 数据,按 400kHz I2C 算大约需要几毫秒。菜单切换和状态刷新这个速度可以接受,但如果要做动态动画,就要改成脏矩形刷新,只重绘变化的区域。第四期 UI 以静态页面和菜单为主,全量刷新够用,后期做动画再加脏矩形也不迟。
4.3 字体与中文显示
OLED 显示字符需要字库。ASCII 字体可以按 8x8 或 8x16 取模,中文按 16x16 取模。建议把字模数组放到 Flash 的只读区,用 const 修饰,避免占用宝贵的 RAM。
// 8x8 ASCII 字库示例,实际数据由取模工具生成 static const uint8_t font8x8[][8] = { // 'A' 的字模数据 { 0x00, 0x3C, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x00 }, // 其他字符... }; void oled_draw_char(uint8_t x, uint8_t y, char ch, uint8_t color) { const uint8_t *glyph = font8x8[ch - ' ']; for (int row = 0; row < 8; row++) { for (int col = 0; col < 8; col++) { oled_draw_pixel(x + col, y + row, (glyph[row] >> col) & 0x01 ? color : !color); } } }中文 16x16 字模同理,只是取模宽度从 8 变成 16,每个字符占 32 字节。如果字库比较大,几千个常用汉字会占几百 KB Flash,这时要考虑字库是否放外置 Flash 或只内置用到的字。在键盘这种 UI 文字比较固定的场景,按需裁剪字模是更务实的做法。
5. UI 框架与菜单状态机设计
OLED 屏只有 128x64,一屏能放的信息有限,所以 UI 必须围绕状态切换来组织。第四期项目的做法是做一个轻量级状态机:屏幕当前显示哪个页面,读哪个输入事件,切换到哪个状态,全部由状态表驱动。这样做的好处是新增页面时只要在状态表里加一项,不用改主循环的 if-else 嵌套。
5.1 页面状态定义
先枚举所有页面,再定义事件和状态转移结构体。
typedef enum { PAGE_MAIN, // 主界面:连接状态、电量 PAGE_MENU, // 菜单:配置列表 PAGE_KEYMAP, // 按键映射设置 PAGE_RGB, // 背光颜色/亮度设置 PAGE_SLEEP, // 睡眠时间设置 PAGE_ABOUT, // 关于/版本信息 PAGE_MAX } page_id_t; typedef enum { EVENT_NONE = 0, EVENT_CONFIRM, // 确认键 EVENT_BACK, // 返回键 EVENT_NEXT, // 下一个菜单项 EVENT_PREV, // 上一个菜单项 EVENT_VALUE_UP, // 参数值增大 EVENT_VALUE_DOWN // 参数值减小 } ui_event_t; typedef struct { void (*enter)(void); void (*render)(void); void (*handle)(ui_event_t event); } page_t;页面表可以这样组织:
static const page_t g_pages[PAGE_MAX] = { { page_main_enter, page_main_render, page_main_handle }, { page_menu_enter, page_menu_render, page_menu_handle }, { page_keymap_enter, page_keymap_render, page_keymap_handle }, { page_rgb_enter, page_rgb_render, page_rgb_handle }, { page_sleep_enter, page_sleep_render, page_sleep_handle }, { page_about_enter, page_about_render, page_about_handle }, };主循环里的事件分发就非常干净:
page_id_t g_cur_page = PAGE_MAIN; void ui_process_event(ui_event_t evt) { g_pages[g_cur_page].handle(evt); } void ui_render(void) { oled_clear(); g_pages[g_cur_page].render(); oled_flush(); }菜单项进入子页面、返回上级页面的逻辑都只发生在 handle 函数内部,比如确认键进入某项配置,返回键回到上级。这样一个文件就是一个页面,互不干扰,代码量翻倍也是线性增长,不会陷入面条代码。
5.2 菜单渲染与参数编辑
菜单页的渲染思路是:维护一个当前选中索引,只显示可视区域内的菜单项。128x64 高度能显示 8 行 8x16 的字号,或者 4 行 16x16 的中文字号。菜单滚动采用"选中项高亮 + 上下移动"的方式,超出首尾边界才翻页。
typedef struct { const char *name; // 菜单项名称 int32_t value; // 当前值 int32_t min; int32_t max; int32_t step; void (*on_change)(int32_t new_value); // 值变化时的回调 } menu_item_t; menu_item_t g_menu_keymap[] = { { "Layer 1", 0, 0, 3, 1, layer1_change_cb }, { "Layer 2", 1, 0, 3, 1, layer2_change_cb }, };菜单项用结构体数组描述,渲染函数根据索引循环绘制,输入事件修改选中项的 value,触发回调函数把新值写入参数管理层。这样菜单 UI 和参数存储就完全解耦了,菜单只管显示和编辑,参数层负责真正保存。
5.3 OLED UI 与按键输入联动
蓝牙键盘的输入来源不止矩阵键盘,还有可能接编码器、滚轮、侧键。UI 层不需要关心按键物理位置,只需要把物理事件转换成上面定义的 ui_event_t。比如旋钮顺时针转为 EVENT_VALUE_UP,逆时针转为 EVENT_VALUE_DOWN,确认键按下为 EVENT_CONFIRM。
void key_event_to_ui(uint8_t key_code, bool pressed) { if (!pressed) { return; } switch (key_code) { case KEY_UP: ui_process_event(EVENT_PREV); break; case KEY_DOWN: ui_process_event(EVENT_NEXT); break; case KEY_ENTER: ui_process_event(EVENT_CONFIRM); break; case KEY_BACK: ui_process_event(EVENT_BACK); break; default: break; } ui_render(); }这里有个小优化:事件触发后立即重绘整个页面,但只重绘当前页面,不重绘所有页面。因为状态机保证任意时刻只有一个页面处于激活态,渲染函数也只调用当前页面的 render,开销被控制在最小范围。
6. Flash 分区与参数分离设计
OLED UI 解决的是"怎么显示"的问题,Flash 分区和参数分离解决的是"配置怎么存"的问题。这一块如果设计不好,后面会出现参数多了存不下、频繁擦写导致 Flash 报废、升级固件后参数丢失等各种问题。
6.1 为什么要做分区
nRF52840 的 Flash 是 1MB,页大小 4KB。整个 Flash 不能当作一个大杂烩来用,它天然要承担几个不同职责:存放 Bootloader、存放 SoftDevice、存放 Application、存放掉电参数、存放日志或调试信息。分区就是提前把这些区域用地址范围划分好,避免互相覆盖。
一个典型的 nRF52840 蓝牙键盘 Flash 布局如下,具体地址和大小要按链接脚本和 SoftDevice 版本调整:
| 分区 | 起始地址(示例) | 大小 | 说明 |
|---|---|---|---|
| SoftDevice | 0x00000 | 约 152KB | BLE 协议栈,地址由 SoftDevice 固件决定 |
| Application | 0x26000 起 | 约 800KB+ | 主程序 |
| 参数存储区 | Flash 末尾 1~2 个页 | 4~8KB | 用户配置参数 |
| 日志区 | 参数区前 | 4~8KB | 调试日志、运行计数 |
参数区和日志区分开的逻辑是:日志会被频繁擦写,如果和参数区共用一块 Flash,日志写多了可能把参数区覆盖掉。分区之后,日志区随便擦,参数区只存重要的配置,擦写频率低,寿命更有保障。
6.2 FDS 与自定义存储方案对比
nRF5 SDK 里自带 Flash Data Storage(FDS)组件,它提供键值对存储、磨损均衡、垃圾回收和掉电安全保护,适合保存少量、更新频率不高的用户参数。FDS 的核心 API 是 fds_record_write、fds_record_find、fds_record_update 和 fds_record_delete,使用前需要先 fds_init 并注册事件回调。
FDS 的选型建议:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 参数少、更新不频繁、重视可靠性 | FDS | 官方组件,磨损均衡,掉电保护 |
| 参数结构固定、更新频繁、需要快速访问 | 自定义页管理 | 可以自己控制页擦写时机 |
| 需要多个独立存储块 | FDS 键值 + 分文件 | 不同功能模块用不同 Record 类型 |
如果只是做键盘配置保存,优先选 FDS。它的缺点是 API 是异步的,写记录时要等事件回调返回结果,代码比同步写复杂一些。但换来的是擦写寿命均匀分布和掉电安全性,这笔交易在消费电子产品里是划算的。
6.3 参数结构设计与版本管理
参数分区之前,先设计参数结构体。一个核心原则:参数结构必须带版本号和校验值。
#define PARAMS_MAGIC 0x5A5A #define PARAMS_VERSION 3 typedef struct { uint16_t magic; uint16_t version; uint32_t crc32; uint8_t keymap_layers[4][16]; // 4 层按键映射 uint8_t rgb_brightness; // RGB 亮度 0~255 uint8_t rgb_mode; // RGB 模式 uint16_t sleep_timeout_s; // 睡眠超时,单位秒 uint8_t reserved[32]; // 预留字段,便于扩展 } ble_keyboard_params_t;magic 用于识别数据块是否有效,version 用于后续结构升级。每次读取参数时,先检查 magic 和 version,再校验 CRC32。如果校验失败,就回退到默认参数。这样即使 Flash 数据被写坏,或者固件升级后参数结构变了,程序也不会崩溃,只会恢复默认配置。
bool params_load(ble_keyboard_params_t *out) { uint32_t content_crc = 0; // 从 Flash 读取参数区 flash_read_params((uint8_t *)out, sizeof(ble_keyboard_params_t)); if (out->magic != PARAMS_MAGIC || out->version != PARAMS_VERSION) { return false; } // CRC 计算需要排除 crc32 字段本身 content_crc = crc32_calc((uint8_t *)out + offsetof(ble_keyboard_params_t, crc32), sizeof(ble_keyboard_params_t) - offsetof(ble_keyboard_params_t, crc32)); return content_crc == out->crc32; }参数分离的另外一个好处是:固件升级时,如果参数区放在独立地址且不参与 App 固件区覆盖,升级之后设备能继续保留用户配置,体验会好很多。反过来,如果参数和固件代码混在同一段 Flash 区域,每次升级都会把配置冲掉,用户就要重新设置一遍键盘,这在小批量个人设备上能容忍,但要量产就是明显的体验缺陷了。
6.4 FDS 读取和写入示例
如果选择 FDS,参数写入的主要流程是:先定义 Record 类型,再写 JSON 或二进制数据到对应 Key。示例:
#include "fds.h" #define BLE_KB_FILE 0x0001 #define BLE_KB_PARAMS_REC 0x0001 static void fds_evt_handler(fds_evt_t const * p_evt) { switch (p_evt->id) { case FDS_EVT_INIT: // 初始化完成,可以开始读写 break; case FDS_EVT_WRITE: // 写完成 break; case FDS_EVT_UPDATE: // 更新完成 break; case FDS_EVT_DELETE: // 删除完成 break; case FDS_EVT_GC: // 垃圾回收完成 break; default: break; } } void params_init(void) { ret_code_t ret = fds_register(fds_evt_handler); APP_ERROR_CHECK(ret); ret = fds_init(); APP_ERROR_CHECK(ret); } ret_code_t params_save(ble_keyboard_params_t *params) { fds_record_t record; fds_record_desc_t desc; fds_find_token_t token = {0}; // 先查询是否已存在 bool found = (fds_record_find(BLE_KB_FILE, BLE_KB_PARAMS_REC, &desc, &token) == FDS_SUCCESS); record.file_id = BLE_KB_FILE; record.key = BLE_KB_PARAMS_REC; record.data.p_data = params; record.data.length_words = (sizeof(ble_keyboard_params_t) + 3) / 4; if (found) { return fds_record_update(&desc, &record); } else { return fds_record_write(&desc, &record); } }FDS 写入是异步的,所以调用完 params_save 之后不能立刻认为数据已经落盘,要等服务端回调确认。对键盘来说,用户修改完一项配置后不会立刻断电,这个时间窗口通常足够完成写入;但如果用户改完参数立刻拔电池,就存在写丢风险,需要在回调里把"写入完成"状态反馈给 UI,或者做一个简单的掉电检测电路来延迟断电。
6.5 自定义页管理方案
如果不想引入 FDS,也可以自己管理一个 4KB 的参数页。基本思路是:每页开头写一个页头标记,后面按固定结构存参数记录。擦写前先读整页到 RAM,再修改,再整页擦除写入。这种方法实现简单、代码量小,缺点是 Flash 磨损不均衡,每次修改都会擦写同一个页,一万次寿命很快就用完了。所以更实际的工程做法是准备两个页做乒乓切换:当前写到 A 页,下次修改写 B 页,再下次回 A 页,这样寿命翻倍,配合页头里的序列号还能实现掉电恢复。
#define PARAM_PAGE_ADDR_A 0x000F0000 #define PARAM_PAGE_ADDR_B 0x000F1000 #define PAGE_SIZE 0x1000 uint32_t g_write_seq = 0; void params_write_custom(ble_keyboard_params_t *params) { uint32_t target_addr = (g_write_seq & 0x01) ? PARAM_PAGE_ADDR_B : PARAM_PAGE_ADDR_A; params->magic = PARAMS_MAGIC; params->version = PARAMS_VERSION; params->crc32 = crc32_calc((uint8_t *)params + 4, sizeof(*params) - 4); flash_page_erase(target_addr); flash_write_bytes(target_addr, (uint8_t *)params, sizeof(*params)); g_write_seq++; }这个方案逻辑直白,适合想完全掌控 Flash 管理的开发者。缺点是掉电保护要自己做,如果擦除后写入前掉电,整页数据就没了,所以实际工程还要加两个页的备份机制,至少保证有一份数据是完整的。
7. 参数分离的功能验证流程
设计完 Flash 分区和参数存储,接下来要上板验证。这里给出一套可以照做的验证流程,覆盖默认参数、参数修改、掉电持久化、参数损坏恢复四个维度。
| 测试项 | 操作步骤 | 预期结果 | 判断标准 |
|---|---|---|---|
| 默认参数加载 | 烧录全新固件,首次开机 | OLED 显示默认配置界面 | 无异常乱码,默认参数生效 |
| 参数修改 | 进入菜单修改 RGB 亮度 | 修改后 OLED 显示新值 | 当前值即时更新 |
| 参数持久化 | 修改参数后断电 10 秒,重新上电 | 参数保持上次修改值 | 不需要重新设置 |
| 参数损坏恢复 | 手动向参数地址写入 0xFF,重启 | 设备恢复默认参数 | 不卡死,不白屏,能回到主界面 |
| Flash 擦写计数 | 连续修改参数 100 次,观察 Flash 状态 | 剩余擦写次数正常下降 | 没有异常固定地址重复擦写 |
| 多页滚动 | 菜单超过 8 项,上下滚动 | 列表能滚动显示全部项 | 无闪烁、无乱序 |
重点说参数损坏恢复这个测试。做法很简单,在调试器里把参数区的第一个 4KB 页整页填成 0xFF,模拟 Flash 被写坏的情况。然后复位设备,观察程序行为。如果参数加载函数正确检查了 magic、version 和 crc32,程序应该能检测到数据无效并自动载入默认参数,而不是进入异常中断。这个测试建议固件每次改动后都跑一遍,因为参数校验逻辑一旦被改动误伤,表现出的故障会非常隐蔽。
参数写入时序的测试也要做。正确做法是:UI 上修改参数,只更新 RAM 中的当前值,不在每次按键时都写 Flash;而是在退出菜单、切换状态或超时 3~5 秒后,才触发一次参数保存。这样既能减少 Flash 擦写次数,又能避免用户在菜单里连续调节时反复触发写操作。
我的建议是,在代码里加一个参数保存标志位,只在参数真正变化且经过软件防抖之后才调用 params_save 接口。
bool g_params_dirty = false; void menu_item_on_change(int32_t new_value) { // 更新 RAM 中的当前参数值 g_runtime_params.some_value = new_value; // 标记需要保存 g_params_dirty = true; } void app_idle_loop(void) { static uint32_t last_save_tick = 0; // 每 100ms 轮询一次,如果参数脏标记置位且距离上次保存超过 3 秒,则触发保存 if (g_params_dirty && (current_tick() - last_save_tick > 3000)) { params_save(&g_runtime_params); g_params_dirty = false; last_save_tick = current_tick(); } }这样一个简单的延迟保存逻辑,就能把 Flash 擦写次数降低一个数量级。对键盘来说,用户改完配置一般不会在两三秒内连续改,这个策略既可靠又省 Flash。
8. 资源占用与性能观察
嵌入式项目最怕的是功能做完才发现资源不够,所以资源占用要边做边看,不能等最后再统计。
首先看 Flash 和 RAM 占用。编译完成后,在 Keil、SES 或 GCC 的 Map 文件里可以查到代码段、只读数据段、可读写数据段的占用。一个带 SSD1306 驱动、基础 UI 状态机、FDS 参数存储的工程,Flash 占用通常在几十 KB 量级,RAM 占用会因为 1KB 帧缓冲和 FDS 缓冲区增加几 KB,具体数字取决于你启用了多少功能,不同编译选项差异很大,建议以自己工程编译结果为准。
Flash 空间不足时,优先检查是不是引入了大体积字库。一个全量 16x16 中文字库接近 1MB,会把整个 nRF52840 占满。键盘 UI 只需要少量汉字,建议用字模裁剪工具只生成菜单里出现的文字,或者使用字体压缩方案,把常用字做成子集,能省下大量空间。
RAM 占用方面,最明显的开销是 OLED 帧缓冲 1KB。如果不做动画,可以把帧缓冲缩小到只保存当前菜单页面的可视区域,比如 128x16 甚至 128x8,减少 RAM 占用。另一种做法是直接从 Flash 读取字模画点,不建立全尺寸帧缓冲,但这样绘图会有闪烁,对视觉效果要求不高的小屏幕可以接受。
性能观察重点放在两个地方:一是 OLED 刷新耗时,二是 Flash 擦写阻塞时长。
OLED 刷新耗时可以通过 GPIO 翻转测量。在 oled_flush 函数入口拉高一个测试引脚,函数退出拉低,用逻辑分析仪看高电平持续时间,就能得到一次全量刷新的时间。I2C 模式下这个时间通常有几毫秒,SPI 模式会更快。如果发现 UI 操作时有一定卡顿,优先检查是不是每次按键都做了全量刷新,改成按需刷新后会有明显改善。
Flash 擦写的性能影响也值得关注。nRF52840 的 Flash 页擦除和写入是阻塞的,页面擦除过程芯片无法执行其他代码。在带 BLE 协议栈的工程里,如果擦页时间过长,可能导致蓝牙连接事件处理延迟,严重时触发协议栈 assert。所以参数保存不要放在蓝牙事件处理回调里执行,要放到空闲任务或主循环中,避免阻塞协议栈的关键路径。
常见性能观察方法汇总:
| 观察对象 | 方法 | 优化目标 |
|---|---|---|
| OLED 刷新耗时 | GPIO 翻转 + 逻辑分析仪 | 减少全量刷新次数 |
| Flash 擦写阻塞 | 中断回调内做高电平标记 | 避免在中断/BLE 事件中擦写 |
| CPU 负载 | 高优先级定时器累加计数 | 保证 BLE 协议栈时间片充足 |
| 功耗 | 电流计串联电源 | 缩短 OLED 亮屏时间,延长睡眠 |
个人经验是,这类带屏、带无线、带参数存储的小设备,最容易超预算的资源不是 CPU 主频,而是 RAM 和 Flash 空间,开发过程中要保证每个模块的资源占用都有人跟进统计,防止后期集成时空间告急。
9. 常见问题与排查方法
OLED、Flash 分区、参数分离这三个模块在实际开发中会碰到不少坑。下面把高频问题整理成排查清单。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| OLED 不亮 | I2C 地址错误或接线错误 | 用逻辑分析仪抓 I2C 时序,确认地址是 0x3C 还是 0x3D | 检查硬件连接,确认模块地址 |
| OLED 有亮但无内容 | 复位引脚持续低电平或初始化时序不对 | 检查 RST 引脚电平,测量供电是否稳定 | 按 SSD1306 数据手册重写初始化序列 |
| 屏幕显示乱码 | 字模取模方式和显示格式不匹配 | 打印显示缓冲区,对比取模数据 | 统一取模工具参数,调整字模排列 |
| 菜单切换后花屏 | 帧缓冲未清零就刷新 | 检查切换页面时是否调用 oled_clear | 在 render 前强制清屏 |
| 参数保存后重启丢失 | 参数写入未等到回调完成就断电 | 看串口日志确认 FDS 写是否成功 | 增加保存完成提示或掉电延迟 |
| Flash 频繁擦写导致参数区失效 | 参数保存触发条件过于频繁 | 统计一天内的擦写次数 | 增加参数保存防抖和延迟保存 |
| 带 BLE 协议栈后程序卡死 | Flash 擦写阻塞了协议栈处理 | 检查擦写是否在主循环之外执行 | 把保存任务挪到空闲优先级或后台任务 |
| 升级固件后参数丢失 | 升级过程覆盖了参数区 | 检查链接脚本和 DFU 包地址 | 参数区独立划分,升级脚本不擦参数页 |
| I2C 读取无 ACK | OLED 供电不足或上拉电阻缺失 | 示波器测量 SDA/SCL 波形 | 补充上拉电阻,改善供电 |
OLED 驱动的问题优先检查硬件连接,再检查初始化序列。很多模块厂商提供的初始化代码写得很随意,搬过来能用但不保证稳定,建议对照 SSD1306 官方手册逐条核对。
Flash 和参数相关的问题,核心手段是打开串口日志。FDS 初始化、写入、更新、删除的每个事件都打印状态码,参数加载成功或失败也打一条日志。这样在实机上能直接看到问题是出在写入阶段还是读取阶段,不用反复猜测。
另外要提一个很容易被忽略的问题:链接脚本。参数区如果在链接脚本里没有预留地址,编译器就可能把变量或函数放到参数区地址上,运行时写入覆盖了整个 App。在做 Flash 分区时必须先确认链接脚本的 FLASH 起始地址和长度与分区规划一致,否则后面所有参数保存都是隐患。
10. 最佳实践与使用建议
把第四期项目的开发经验总结成几条可以直接落地的实践建议。
第一,UI 和存储一定要分层。UI 层不要直接调用 Flash 擦写函数,存储层也不要关心菜单长什么样。中间用回调函数或事件解耦,比如菜单项修改后通过 on_change 回调把新值交给存储层处理。这样做的好处是,后续如果把 OLED 换成 LCD,或者把参数存储从 FDS 换成自定义方案,改动可以限制在单层内部,不会牵一发动全身。
第二,参数结构设计要从一开始就考虑版本升级。建议预留一个 reserved 数组,哪怕当前版本用不到也留着。产品后期加一个新功能时,直接扩展 reserved 字段,把 PARAMS_VERSION 加 1,旧参数数据还能被代码识别和迁移,不需要清空用户配置。这个做法在量产产品的 OTA 升级场景尤其重要。
第三,Flash 参数保存一定要做延迟写和掉电保护。不要在每次按键或每次修改时都写 Flash,而是等用户操作停止一段时间后再写。可以引入一个 3 到 5 秒的定时器,配合脏标记实现。如果产品允许,最好在硬件上增加掉电检测电容或 ADC 监控电源电压,检测到掉电后把 RAM 中最新参数写入 Flash,能最大程度避免数据丢失。
第四,日志功能要保留到开发后期。开发阶段串口日志随便打,正式固件可以关掉,但代码里的日志宏不要删。调试线上问题时打开宏重新编译一版,就能拿到完整运行轨迹,能省很多排查时间。
第五,菜单页面数量要克制。128x64 小屏一屏显示的信息量有限,菜单层级建议不超过三层:主界面、二级菜单、参数编辑。层级太深会显著增加按键操作次数,键盘用户基本无法接受在一个 1.3 寸屏幕上频繁翻页找设置项。如果确实有很多配置项,优先提供"恢复默认配置"功能,让用户可以一键回退。
11. 总结与下一步
这一期做的三件事非常明确。OLED UI 从那块 1KB 帧缓冲开始,完成了底层驱动、字体显示、页面状态机三层设计;Flash 分区把 1MB Flash 划分成协议栈区、应用区、参数区和日志区,互不干扰;参数分离通过版本号、CRC 校验和延迟保存,让用户配置能安全地跨重启、跨升级保留。
对准备复刻这套方案的人来说,最值得先跑通的是 OLED 驱动和页面状态机。先不接 Flash,在裸机上显示几个页面、切换一下状态,确认显示屏和 UI 框架没有问题了,再叠加 FDS 参数存储。这样每一步的排查范围都很小,不容易一出问题就多模块相互干扰。
最容易踩的坑集中在两处。一处是 I2C OLED 的初始化时序和地址,不同的模块厂商给的初始化序列千差万别,最好是拿着示波器抓一遍时序确认;另一处是 FDS 的异步特性,刚接触时容易在回调还没返回时就断电,导致参数丢失。这两个问题解决了,整个项目基本就稳了。
后续可以扩展的方向很多。OLED 页面可以加动画和进度条,参数区可以支持多套配置模板,用户通过组合键切换不同按键层,再配合 nRF52840 的 USB 功能实现有线和无线的双模切换。如果还要继续深入,可以考虑加一个简单的 DFU 分区设计,让键盘后期能通过手机 App 或网页直接升级固件,那才是把参数分离和分区管理方案的价值发挥到最大。
这套 OLED UI + Flash 分区 + 参数分离的组合,建议直接收藏备用。后面做任何需要屏幕和掉电保存的单片机项目,都可以先拿这套架构打底,再按项目需要裁剪功能,比每次都从零搭一遍要快得多。