恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
嵌入式Flash存储管理:SFUD+FAL+flashDB三件套移植实战
首页
资讯中心
/
嵌入式Flash存储管理:SFUD+FAL+flashDB三件套移植实战
嵌入式Flash存储管理:SFUD+FAL+flashDB三件套移植实战
发布时间:2026/10/3 9:37:03
搞嵌入式这几年数据存储这一块一直是让我头疼的大头。早期做项目图省事直接在代码里定义一个const数组指向Flash地址读出写入全靠自己算偏移结果每一个新项目都要重写一遍驱动遇上Flash型号更换还得挨个寄存器去啃数据手册。后来我在STM32上引入了SFUDFALflashDB这套组合才真正把存储这块从“能用”做到“用得舒服”。这套组合并不是什么新概念RT-Thread生态里用得很广但不依赖RTOS也能在裸机上跑得很稳。这篇文章我会把整个移植过程、分区设计思路、KV与日志存储的调用方式以及我在实际项目中踩过的一连串坑全部写出来。适合正在做产品化开发、被Flash驱动和存储管理折腾得够呛的工程师参考。先说清楚一个观点如果你只是在学习阶段拿块开发板随便存几个参数那直接操Flash寄存器完全够用。但当你开始做需要OTA升级、运行日志记录、断点续传、频繁修改配置参数的产品时裸写Flash的方式绝对会反噬你。地址越界没人提醒你扇区擦除时机不对导致数据丢失Flash寿命被白白耗尽这些问题要等到量产后才暴露那基本就是事故级的问题了。三件套的存在就是为了把你的存储操作变成对分区和数据的逻辑管理而不是继续在地址层面“裸奔”。1. 三个库各管一段为什么这么分工才合理很多初学者一上来就蒙了SFUD和FAL看着都能操作FlashflashDB又说是数据库到底该用哪个其实这三者分工明确一条链路下来各管各的谁也替代不了谁。1.1 直接操作Flash的三个痛点先回忆一下直接操作Flash的典型流程。你要写一个参数得先找到一块空闲区域确认地址没被别的模块占用然后判断这个扇区需不需要先擦除再按页写入。Flash的特性是只能把1写成0要想从0变回1就必须擦除整个扇区一般Nor Flash的扇区是4KB擦除一次的时间在几十到几百毫秒不等。痛点一地址管理全靠人工。多个模块共用一颗Flash时你得手工划分地址区间写死宏定义。后续某个模块的数据量涨了要重新划分区间就得同步改所有相关宏稍有不慎就覆盖了别的模块的数据区域。痛点二磨损均衡完全没有。Flash的擦写寿命是有限次数现在主流Nor Flash一般是10万次。如果你把某个配置参数的存储地址固定住每次都往同一个扇区写那这个扇区很快会报废。而且很多芯片的坏块管理是粗粒度的整个扇区失效后你得在代码里做扇区替换逻辑这工程量不小。痛点三掉电保护很难做。一段数据只写了一部分就掉电了下次上电读出来的是半截数据你怎么判断这条数据是否有效CRC校验可以解决一部分问题但写完之后CRC还没落盘就掉电了呢这些边界问题业务代码里处理起来非常痛苦。1.2 SFUD统一的Flash读写接口SFUD全称是Serial Flash Universal Driver项目地址在GitHub上的sunny6257/SFUD。它解决的问题非常纯粹让你用一套API操作市面上绝大多数串行Flash芯片不用关心具体的命令码和寄存器差异。SFUD内置了一张Flash芯片表涵盖W25Q系列、AT25系列、GD25系列、MX25系列等常用型号。上电后调用sfud_init它会自动通过JEDEC ID识别芯片型号然后加载对应芯片的参数。你的业务代码只需要调用sfud_err sfud_write(const sfud_flash *flash, uint32_t addr, size_t size, uint8_t *data); sfud_err sfud_erase(const sfud_flash *flash, uint32_t addr, size_t size); sfud_err sfud_read(const sfud_flash *flash, uint32_t addr, size_t size, uint8_t *data);这三个接口就能搞定所有读写操作。换了Flash芯片只要新芯片在支持列表里驱动代码一行都不用改。这是SFUD最大的价值它把“芯片差异”这个维度从你的项目里隔离掉了。1.3 FAL把Flash切成逻辑分区FAL是Flash Abstraction LayerRT-Thread团队开源的一个Flash抽象层GitHub地址是RT-Thread/FAL。它的作用是在Flash之上再包一层将一个物理Flash划分成若干逻辑分区。为什么要分区以实际项目为例一颗8MB的Flash我把最前面的2MB分给Bootloader加App固件中间1MB分给OTA下载缓存区后面1MB分给参数存储剩余4MB分给运行日志。每个分区有独立的名称逻辑上互不干扰。App要访问日志分区直接通过分区名logs操作完全不需要知道它在物理Flash的哪个地址。FAL最实用的设计是fal_cfg.h里的分区表#define FAL_PART_TABLE \ { \ {FAL_PART_MAGIC_WROD, app, W25Q64, 0, 2*1024*1024, 0}, \ {FAL_PART_MAGIC_WROD, ota, W25Q64, 2*1024*1024, 1*1024*1024, 0}, \ {FAL_PART_MAGIC_WROD, param, W25Q64, 3*1024*1024, 1*1024*1024, 0}, \ {FAL_PART_MAGIC_WROD, logs, W25Q64, 4*1024*1024, 4*1024*1024, 0}, \ }以后不管底层Flash怎么改分区逻辑层的代码完全不用动。1.4 flashDB让数据存储进入“数据库”模式flashDB是ARMINK大神开源的嵌入式数据库继承自早期的EasyFlash项目。按官方定义它是一款支持KV键值对和时序数据TSDBTime Series Database的轻量级数据库。到了这一层你的操作方式就相当优雅了。存一个配置参数不用关心它被存在哪个扇区、有没有做过磨损均衡你只需要int temp 25; fdb_err_t result fdb_kv_set(kvdb, temperature, temp, sizeof(temp));读的时候int read_temp 0; fdb_kv_get(kvdb, temperature, read_temp, sizeof(read_temp));flashDB内部做了四个关键事情一是磨损均衡每次写新值不会覆盖旧位置而是写到下一个空白位置循环使用整个分区二是掉电保护每条记录都有状态标记和校验信息半截写入的记录在下次启动时会被当成无效数据跳过三是垃圾回收当分区快写满的时候自动搬运有效数据并擦除旧扇区四是索引管理维护一张KV目录表快速定位某个键的最新值在哪个扇区。TSDB则专门用于日志和采样数据采用了环形队列设计写满后从最老的数据开始覆盖非常适合跑长期运行日志。2. SFUD移植从CubeMX到第一个可用的Flash驱动三件套的移植顺序有讲究先SFUD再FAL最后才是flashDB。因为下面是上面的基础底层跑不通上层再折腾也没用。2.1 硬件连接与工程准备SFUD本身是纯C代码不依赖RTOS任何STM32工程都能直接加入源码。以我常用的STM32F407W25Q64为例硬件上SPI1连接Flash片选脚用PB0。用CubeMX配置SPI1模式设为Full-Duplex Master速率建议先保守一点初始用2Mbps左右后续再逐步提高。对于W25Q64这种老牌芯片其实跑到36MHz都问题不大但在稳定性验证前不建议一上来就拉满。在spi.c里实现两个回调函数spi_write_read_byte和spi_write_read_bytes。SFUD的接口层设计得很薄主要就是通过这两个函数和片选引脚交互。从GitHub拉取SFUD源码将sfud文件夹和sfud_port.c、sfud_port.h加入工程。2.2 SFUD配置裁剪在初始化之前先看sfud_cfg.h里的宏。默认配置可以直接用但有几个宏值得关注#define SFUD_USING_SFDP // 是否使用SFDP探测引脚一般建议开启可以通过SFDP拿到芯片的标准参数 #define SFUD_USING_FLASH_INFO_TABLE // 内置Flash信息表用于快速匹配已知芯片型号 #define SFUD_USING_MSP // 使用平台抽象层开启后可以自定义擦写超时等功能我实际项目里会打开SFUD_USING_SFDP打开SFUD_USING_FLASH_INFO_TABLE。原因很简单SFDP能让驱动自动适配更多未知型号万一将来选了颗国产新Flash即使没收录在信息表里也能通过SFDP读出基本参数不至于完全跑不起来。2.3 初始化与验证在你的主函数初始化流程中调用#include sfud.h static sfud_flash *g_flash NULL; void sfud_demo_init(void) { sfud_err ret sfud_init(); if (ret ! SFUD_SUCCESS) { // 初始化失败处理异常 while (1); } g_flash sfud_get_device_table()[0]; if (g_flash NULL) { while (1); } }然后做一次全容量读写验证。读回JEDEC ID打印出来和芯片手册对比printf(Flash manufacturer: 0x%02X\r\n, g_flash-chip.manufacturer); printf(Flash capacity: %lu KB\r\n, g_flash-chip.capacity_kib);我踩过的一个坑是SPI分频配置太高导致读ID超时。在F407上SPI1挂在APB2总线时钟84MHz如果预分频器设成2SPI时钟就有42MHz。W25Q64在标准SPI模式下的最高频率是50MHz部分厂商的芯片会保守一些虽然看上去没超但实际信号线如果长了加上逻辑分析仪探头影响就有可能出现ID读取错误或者死活匹配不上芯片表。项目里别省这几分钟把速率先放到10MHz以下验证一轮确定没问题再往上调。3. FAL对接分区表设计才是核心工作SFUD跑通之后接着上FAL。FAL本身不干live事它是在SFUD上面加一层“翻译官”。3.1 FAL与SFUD的绑定FAL通过fal_flash_dev结构体来关联底层Flash设备。在移植文件里要这样写#include fal.h #include sfud.h static int sfud_flash_read(long offset, uint8_t *buf, size_t size); static int sfud_flash_write(long offset, const uint8_t *buf, size_t size); static int sfud_flash_erase(long offset, size_t size); const struct fal_flash_dev sfud_flash0 { .name W25Q64, .addr 0, .len 8 * 1024 * 1024, // 8MB .blk_size 4096, // 扇区大小 .ops { .init NULL, // 这里SFUD已经初始化过了可以不填 .read sfud_flash_read, .write sfud_flash_write, .erase sfud_flash_erase, }, .write_gran 1, // 最小写入粒度单位字节 };读写回调里的计算逻辑很简单FAL传入的地址offset是相对整个Flash的绝对地址而SFUD的API接收的也是绝对地址所以直接透传即可static int sfud_flash_read(long offset, uint8_t *buf, size_t size) { sfud_err ret sfud_read(g_flash, (uint32_t)offset, size, buf); return (ret SFUD_SUCCESS) ? size : -1; } static int sfud_flash_write(long offset, const uint8_t *buf, size_t size) { sfud_err ret sfud_write(g_flash, (uint32_t)offset, size, (uint8_t *)buf); return (ret SFUD_SUCCESS) ? size : -1; } static int sfud_flash_erase(long offset, size_t size) { sfud_err ret sfud_erase(g_flash, (uint32_t)offset, size); return (ret SFUD_SUCCESS) ? size : -1; }3.2 partition表设计的三个原则设计分区表的时候最容易拍脑袋我这里说几个原则都是实际项目里验证过的。第一固件区大小要和Bootloader约定死。App分区的起始地址和大小必须和Bootloader的跳转地址、固件打包工具的参数完全一致。如果出现不一致最典型的现象就是App能烧录但跑不起来或者跑起来之后一升级就成砖。第二参数区和日志区之间要留隔离扇区。比如参数区1MB日志区4MB中间的物理地址还是连续的但逻辑上最好再划一个“reserve”分区出来哪怕这个分区从头到尾不用。为什么因为分区表将来要调整或者某个分区擦除超限了需要临时借用相邻区域时有个空白分区做缓冲不用改别的分区的地址。第三擦除粒度要遵循物理芯片。FAL的blk_size必须和真实Flash的扇区大小一致。W25Q64的四字节扇区就是4KB块擦除是64KB。如果你把blk_size填成64KBFAL执行擦除操作时就会以64KB为最小单位去调SFUD的接口SFUD再按照底层协议去做擦除逻辑上是能工作但会浪费擦除时间。我一般建议直接用最小擦除扇区作为blk_size也就是4KB这样上层的灵活性最大。3.3 通过FAL命令验证分区标准版FAL提供了一个fal命令集合配合mshRT-Thread的shell可以用字符串命令来操作。裸机环境下没有shell我建议在调试阶段做一个简单的命令行菜单把FAL的fal_probe、fal_show_part_table、fal_part_read这些接口暴露出来调用。验证思路特别简单先在某个分区写一串已知数据再读出来比对。比如往param分区的地址0处写FAL_TEST然后读取打印如果一致说明FAL层工作正常。这里一定不要跳过FAL层的读写回调一旦有隐藏问题后面flashDB跑得再花哨也逃不掉数据错乱的雷。4. flashDB移植KVDB与TSDB的落地姿势flashDB的官方仓库在GitHub上叫FlashDB里面已经有非常完整的示例工程。但官方示例基本都是基于RT-Thread的而裸机STM32工程需要自己编写移植层。4.1 下载源码与核心配置flashDB源码结构不算复杂核心代码在src目录下面有fdb.c、fdb_kvdb.c、fdb_tsdb.c等文件。需要关注的配置文件是fdb_cfg.h宏不多但每个都影响运行行为#define FDB_USING_KVDB #define FDB_USING_TSDB #define FDB_KVDB_FILE_MODE // 是否启用“文件模式”简单场景可以关掉我自己的裸机工程配置长这样#define FDB_USING_KVDB #define FDB_USING_TSDB #define FDB_WRITE_GRAN 1 // 最小写入粒度W25Q64按字节写是1 #define FDB_BIG_ENDIAN 0 // STM32是小端4.2 KVDB初始化与启动在main函数中按顺序初始化先SFUD再FAL最后flashDB#include flashdb.h struct fdb_kvdb kvdb; void kvdb_init(void) { fdb_err_t result fdb_kvdb_init(kvdb, param_store, param, NULL, NULL); if (result ! FDB_NO_ERROR) { // 初始化失败 } }fdb_kvdb_init的第三个参数传入的param正是FAL分区表里定义的分区名。这一步很有意思说明flashDB并不是直接面对物理Flash而是通过FAL分区来管理空间所以分区大小、地址这些事flashDB一概不管它只关心“在这个分区里把你的数据管好”。初始化完成后就是日常的Set和Get操作。特别注意一点fdb_kv_set的第一个参数传的是struct fdb_kvdb *同一个KVDB实例内存放所有KV数据。每个KV都有一个键名字符串键名长度不能超过一定限制默认在fdb_cfg.h里设置的是FDB_KV_NAME_MAX我习惯设为128字节太长了浪费Flash索引空间太短了将来升级不好扩展。4.3 KVDB的写放大与扇区选择用过flashDB的人都会发现一个现象明明一个配置项只写了4字节但Flash的扇区状态会发生变化甚至有时候整个4KB扇区都会被重写。这是正常的因为flashDB的磨损均衡和掉电保护机制决定了它不能用覆盖写的方式。它内部维护了一个扇区链状态分为Empty、Active、Full、GC三态。每次写KV时它会找Active扇区里的下一个空闲块写入KV头数据CRC然后将旧KV标记为失效。当Active扇区写满后切到下一个Empty扇区当Active旧扇区变成Full等待垃圾回收。理解了这个机制你就明白为什么KVDB分区不能设计得太小。一个4KB的扇区如果每条KV平均要占据头信息一般16字节左右 数据长度 对齐间隙可能一个扇区撑死只能容纳几十条记录。如果分区只有几十KB频繁修改参数会很快触发垃圾回收而垃圾回收要做的事包括扫描全分区、搬运有效KV、擦除扇区整个过程耗时可能达到几百毫秒在实时性要求高的场景里非常危险。我的建议是KV分区尽可能给大一点。统计一下你的参数总量如果所有KV数据加起来只有2KB那分区给到64KB已经足够128KB更稳妥。Flash便宜的时候别抠门分区的容量容错比代码里的任何优化都实在。4.4 TSDB做日志环形缓冲区不再用手写TSDB的初始化更简单struct fdb_tsdb tsdb; void tsdb_init(void) { fdb_err_t result fdb_tsdb_init(tsdb, log_store, logs, NULL, NULL); if (result ! FDB_NO_ERROR) { // 初始化失败 } }写入一条日志struct fdb_blob blob; int log_data[4] {temperature, humidity, power, status}; fdb_blob_make(blob, log_data, sizeof(log_data)); fdb_tsl_append(tsdb, blob);读取全部日志则使用迭代器操作fdb_tsl_iter_t iter; fdb_tsl_iter_init(tsdb, iter); while (fdb_tsl_iter_next(iter)) { struct fdb_blob blob; int data[4]; fdb_blob_make(blob, data, sizeof(data)); fdb_tsl_iter_blob_read(iter, blob); // 处理data数组 }TSDB的环形特性让它特别适合记录系统运行日志不用人为删除旧数据写满了自动从头覆盖。需要注意的是TSDB每条记录的时间戳flashDB默认会使用芯片的RTC如果没有RTC可以用一个软件递增计数器替代。我在某些低功耗设备上就直接用了系统滴答时钟作为时间戳虽然掉电后会复位但在设备生命周期统计场景里也够用了。5. 避坑合集实测中踩过的七个典型坑这部分是我最想写的因为很多坑不是看文档能看出来的必须踩过才知道。5.1 SPI引脚冲突与复用导致初始化失败我最早做的一款设备用了SPI1和SPI2Flash挂在SPI1上。按照常规思路配置好后sfud_init死活过不去报JEDEC ID错误。查了两天最后才发现SPI1的SCK引脚PB3和JTDO复用冲突调试器还在用SWO功能导致SPI时钟信号被干扰。解决办法是在配置SPI之前先禁用JTAG功能只保留SWD__HAL_AFIO_REMAP_SWJ_NOJTAG();很多国产STM32兼容芯片默认会把JTAG全部引脚释放但原厂ST的芯片必须要做这个操作。如果你用的是STM32F1系列这个坑大概率会碰到。5.2 对齐问题写缓冲区要4字节对齐某次在F407上测试用SFUD写入一个局部变量数组结果数据错乱随机丢字节。排查后发现问题是DMA模式下的缓冲区不是4字节对齐导致的。SFUD的底层SPI传输一般走DMA而STM32的DMA对内存缓冲区有对齐要求。虽然大多数情况下不对齐也能工作但偶发性错误排查起来极其痛苦。这个问题的正统解法是在SPI的DMA初始化之前将缓冲区内存对齐到4字节#ifndef __align4 #define __align4 __attribute__((aligned(4))) #endif static __align4 uint8_t sfud_dma_buffer[256];如果是裸机跑并且没有开DMA不用太担心对齐。但只要是开了DMA这个坑几乎必然会出现别问我怎么知道的。5.3 FAL分区表末尾没有加结尾标志fal_cfg.h的分区表格式里最后一个分区后面必须有一个FAL最后一个分区标志有的版本里叫FAL_PART_TABLE_END有的版本是自动探测空位。我刚上手时漏了这个导致FAL在枚举分区时越界读到垃圾数据结果分区列表错乱flashDB初始化直接卡死。这个是纯粗心排错过程很浪费时间。现在我的习惯是在分区表定义前写一个编译期检查#if (FAL_PART_TABLE_END ! 0) #error Missing FAL partition table end marker #endif5.4 flashDB掉电测试随机丢最新的KV这是我花了两周才定位到的问题。测试中反复上下电偶尔会发现最后一次写入的KV值丢失读出来还是旧值。看源码发现原因在掉电时机fdb_kv_set内部要先找到Active扇区的空白位置写入KV头数据CRC后再更新KV索引这期间如果掉电有可能索引还没更新就断电了下次启动时扫描会认为这条新数据为半截记录从而回退到旧值。这个问题没有完美的软件解法因为掉电时刻无法预测。行业内的做法通常是两段提交和镜像双备份策略但flashDB为了轻量并没有实现这么复杂。你能做的是业务层面容错对关键配置写两次读的时候比较两个版本选择状态正常的那个或者在掉电检测电路上加大电容给flashDB留出写完最后一条记录的时间。5.5 日志分区太小导致TSDB频繁覆盖早期关键日志日志分区大家喜欢往小了分以为能省Flash。但TSDB是环形覆盖结构分区太小意味着早期日志会很快被覆盖掉。如果设备间隔离一个月你回头看日志可能只能看到最近三天的记录这对故障分析的帮助就很有限了。容量规划的粗算公式很简单每条日志大小假设20字节数据8字节时间戳8字节头信息36字节一天日志量如果每10秒记一条一天8640条约310KB一个月日志量约9.3MB所以跑运行日志的分区尽量分到8MB以上。如果Flash容量不够那就要从日志频率上做文章降低采样率否则就是白记录。5.6 擦除粒度写错导致GC卡死我在一款国产Flash上遇到过极端情况明明手册写扇区4KB但FAL的blk_size被我填成了64KB导致flashDB的垃圾回收在擦除时调用FAL擦除64KB真实擦除一个块但逻辑上flashDB以为擦掉了4KB扇区索引表对不上最终触发死循环或者分区状态错乱。这个坑的核心是FAL层的blk_size要和flashDB内部配置文件中的扇区大小保持一致。flashDB在fdb_cfg.h里有一个FDB_SECTOR_SIZE的宏有的版本叫FDB_PAGE_SIZE必须等于实际Flash的最小擦除单位。5.7 编译优化等级与时间戳问题还有一次是在开启-O2优化后RTC时间戳读取出现了偶发错误导致TSDB记录的时间乱跳。查到最后是优化导致读取RTC寄存器的时序发生改变可能是多字节读取被拆成了单字节但这个解释也只是猜测后来加了volatile限制和内存屏障彻底解决。所以关于编译优化这一点我建议在涉及Flash/SFUD/DB这些底层驱动的文件里统一使用#pragma GCC optimize (O0)或者放到Keil的Optimization Level 0分组。性能瓶颈不在这种低频存储操作上牺牲一点速度换取稳定非常划算。6. 从能跑到跑稳产线验证与容量规划移植完成、功能调通离可以放心发布还有一段距离。这个阶段最忌讳的是跑一次demo没问题就直接量产。6.1 掉电老化测试方案任何带掉电存储的设备都要做这个测试上电后写入一条KV或TSDB日志然后随机断电。断电时机要尽量随机人为手工断电覆盖不了所有情况最好做一个控制板自动循环上电等0到3秒随机时间后断电再上电检查数据。我一般跑500次循环以上如果数据损坏率超过0就需要认真排查原因。500次循环耗时可能一两天看起来慢但这是没法省的等产品在用户手里出现存储问题成本要高好几个数量级。6.2 Flash寿命预估表以W25Q64为例擦写寿命典型值10万次。假设你的KV分区128KB共有32个扇区。每次写好一个KV时如果KV尺寸约64字节按flashDB的写入原理大约写入1块区域时对应一次擦写。那么KV分区的有效写入次数大约是32扇区×10万次320万次每天写入100次可以用32000天约87年完全够用。但如果你的KV是200字节一条且写入频率是每秒一次那寿命就短多了。计算时我有一个习惯把使用寿命除以3作为安全冗余。因为Flash的标称擦写次数是特定测试条件下的值高温环境、频繁擦写都会加速衰减。6.3 容量不足时的处理策略设备运行几年后Flash容量会越来越吃紧。特别是TSDB日志数据环形覆盖会不断吃掉旧日志这是设计时的预期行为。但如果KV分区满了flashDB会不断尝试垃圾回收但有效数据太多回收不掉导致后续的fdb_kv_set一直返回FDB_SAVED_FULL错误。遇到这种情况业务层要做降级处理把最不重要的KV标记为可删除定期清理历史运行次数、历史累计时长这类只增不减的数据或者将这些累计数据转为TSDB利用环形覆盖来自动控制容量。这些都是场景化的妥协方案但比让整个设备陷入存储死锁要好得多。7. 一点后续拓展思路SFUDFALflashDB这套架构跑顺之后你会发现自己对存储模块的迁移能力大大增强。一个很自然的扩展方向是接入OTA升级流程Bootloader里用FAL管理固件分区App收到升级包写入ota分区校验通过后设置标志位并重启Bootloader再把ota分区的内容搬运到app分区。这整个过程共用同一个FAL分区表逻辑上非常顺滑。另一个方向是配合Fernil库等MCU上的文件系统如果你需要以文件粒度的方式管理日志可以在这些分区上挂文件系统但要注意文件系统和KVDB/TSDB不能同时使用同一个分区否则会产生冲突。这一点只需牢记FAL分区表的“一区一用”原则即可。我自己现在做新项目默认就是SFUDFAL再根据数据特性决定用flashDB还是文件系统。三件套不是唯一选择但它在轻量、稳定、开箱即用这几个维度上找到了很好的平衡。如果你正在嵌入式存储上焦头烂额不妨按这篇文章的步骤一步步来最后你会回来谢这套方案的。