尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

NRF52840蓝牙键盘开发:OLED UI与Flash参数分离实战

NRF52840蓝牙键盘开发:OLED UI与Flash参数分离实战 标题NRF52840 蓝牙键盘开发OLED UI 设计与 Flash 分区参数分离实战第四期NRF52840 蓝牙键盘做到第四期这期的重点转向了软件架构。前几期把硬件、蓝牙通信跑通之后剩下的核心问题基本都集中在人机交互和数据存储上屏幕要显示连接状态、按键层、电池电量和配置菜单用户改过的按键映射、背光设置断电之后还得保住。所以本次实战把 OLED UI 设计、Flash 分区、参数分离三件事放在一起做。这个项目在硬件上并不复杂主控是 nRF52840显示模块用 128x64 分辨率的 SSD1306 OLED开发环境走 nRF5 SDK。真正花时间的是软件组织方式OLED 的帧缓冲满量也就 1KBFlash 页大小 4KB、整片擦写寿命按数据手册标注通常在一万次量级这两条限制决定了 UI 不能随便刷新、参数不能频繁存盘。文章接下来会给出这套软件架构的完整设计思路包括驱动层、UI 状态机、Flash 分区示例以及参数读写验证流程。这个题目真正值得反复看的地方在于它提供了一个可以复用的嵌入式软件模板。不管你现在用的是 nRF52840、STM32 还是 51 单片机OLED 驱动、菜单状态机、Flash 掉电保存这三块问题的解法是相通的。后面凡是新项目要加屏幕、加配置保存都可以直接拿这套思路套一套。1. 核心能力速览先给结论再展开细节。这个第四期项目不是纯教程演示它是一套已经在蓝牙键盘上跑通的软件方案核心能力可以归纳为下面几点。能力项说明主控平台Nordic nRF52840Cortex-M4F 64MHz1MB Flash256KB RAM显示方案SSD1306 OLED128x64 分辨率I2C/SPI 接口帧缓冲 1KBUI 框架状态机 页面表驱动支持连接状态页、菜单页、参数配置页存储方案Flash 分区管理参数区与日志区分离可选 FDS 或自定义记录掉电保存按键映射、背光亮度、蓝牙配对信息等配置参数独立存储软件工具链nRF5 SDK / nRF Connect SDKKeil、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 / SH1106128x64I2C 或 SPI人机交互显示调试器J-Link / DAPLink烧录和调试连接线I2C/SPI 杜邦线VCC、GND、SDA/SCL显示屏供电和通信OLED 模块建议优先选 I2C 版本引脚占用少接线简单对键盘这种紧凑设备友好。如果对刷新率要求高比如以后要加动画效果再考虑 SPI 版本因为 SPI 时钟更高数据吞吐量比 I2C 大不少。第四期项目的 UI 以状态页面和菜单为主不是高频动画I2C 驱动 128x64 屏幕能做到 10~20 帧左右的整体刷新实际使用足够。软件开发环境按 nRF52840 常用配置准备组件推荐配置SDKnRF5 SDK 17.x 及以上或 nRF Connect SDK编译器ARMCC、GCC 或 Segger Embedded Studio烧录工具nrfjprog、J-Link Commander 或 IDE 内置烧录协议栈SoftDevice S140BLE 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 换成 SPIUI 层代码一行都不用改。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 版本调整分区起始地址示例大小说明SoftDevice0x00000约 152KBBLE 协议栈地址由 SoftDevice 固件决定Application0x26000 起约 800KB主程序参数存储区Flash 末尾 1~2 个页4~8KB用户配置参数日志区参数区前4~8KB调试日志、运行计数参数区和日志区分开的逻辑是日志会被频繁擦写如果和参数区共用一块 Flash日志写多了可能把参数区覆盖掉。分区之后日志区随便擦参数区只存重要的配置擦写频率低寿命更有保障。6.2 FDS 与自定义存储方案对比nRF5 SDK 里自带 Flash Data StorageFDS组件它提供键值对存储、磨损均衡、垃圾回收和掉电安全保护适合保存少量、更新频率不高的用户参数。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 读取无 ACKOLED 供电不足或上拉电阻缺失示波器测量 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 分区 参数分离的组合建议直接收藏备用。后面做任何需要屏幕和掉电保存的单片机项目都可以先拿这套架构打底再按项目需要裁剪功能比每次都从零搭一遍要快得多。
返回列表