部Flash讀寫:從LL庫到寄存器,避開掉電丟數(shù)據(jù)的坑)
簡介面向STM32L4系列嵌入式工程師的一份內(nèi)部Flash讀寫示例資源用于解決片內(nèi)Flash擦除、寫入與讀取等常見底層操作需求可作為驅(qū)動層參考直接移植。代碼基于LL庫并以寄存器級配置實現(xiàn)作者已在STM32L452RET6上實際調(diào)通讀寫過程涉及解鎖、擦除、寫入、讀取與狀態(tài)檢查等完整環(huán)節(jié)由于核心邏輯均為寄存器操作遷移到HAL庫時只需按平臺修改對應(yīng)宏定義即可繼續(xù)使用。值得關(guān)注的是在后續(xù)調(diào)試STM32L471VETx時發(fā)現(xiàn)Flash頁碼并非連續(xù)編號通過調(diào)整擦頁函數(shù)的頁碼映射即可成功擦除這一排錯經(jīng)驗可幫助開發(fā)者避開L4x1系列芯片的潛在坑點。資源包非常精簡僅含2個文件C源文件實現(xiàn)Flash讀寫相關(guān)接口頭文件提供函數(shù)聲明與寄存器定義整體壓縮后僅約2KB沒有冗余依賴非常適合直接放入工程復(fù)用也適合對照參考手冊學(xué)習(xí)底層寄存器配置流程。目前已有1935人學(xué)習(xí)下載適用于需要實現(xiàn)片內(nèi)數(shù)據(jù)存儲、參數(shù)保存或基礎(chǔ)引導(dǎo)加載功能的STM32L4開發(fā)者。1. STM32L4xx讀寫內(nèi)部FLASH省下的不止一顆外部存儲芯片STM32L4xx讀寫內(nèi)部FLASH聽起來是老話題但真正用對的人不多。這個例程包把LL庫和寄存器操作兩套實現(xiàn)都放出來了解決的是產(chǎn)品里最實在的問題參數(shù)掉電保存、OTA升級標(biāo)志、Bootloader與應(yīng)用之間的數(shù)據(jù)交換不用外掛SPI Flash芯片省引腳也省PCB面積。適合正在做低功耗采集、表計、小體積便攜設(shè)備又不愿意為幾個字節(jié)數(shù)據(jù)多花一顆Flash的開發(fā)者。核心思路一句話先擦后寫頁擦除64位對齊編程操作期間管好中斷。全文從L4的Flash結(jié)構(gòu)講起到兩套代碼實現(xiàn)、踩坑記錄最后落到一個能直接用的存儲封裝。新手能照著把數(shù)據(jù)存進去熟手能看出哪些地方容易翻車。2. 先看Flash的脾氣再動手地址布局、頁大小與LL庫選型2.1 地址映射與頁大小擦除的最小單位是頁L4系列主Flash從地址0x08000000開始容量常見256KB、512KB、1MB幾種。地址映射不是隨便用的對想拿內(nèi)部Flash當(dāng)存儲的人來說第一步是把整個Flash空間在腦子里切成三塊主存儲區(qū)、系統(tǒng)存儲區(qū)、選項字節(jié)區(qū)。用戶能自由讀寫的是主存儲區(qū)也就是從0x08000000往后那一片系統(tǒng)存儲區(qū)和選項字節(jié)區(qū)不是普通應(yīng)用該碰的地方。L4的頁大小絕大多數(shù)型號是2KB一頁L4系列則放大到8KB。這里的“頁”不是邏輯上的一小段而是Flash控制器擦除操作的最小單位——你沒辦法只擦掉一個字節(jié)想更新一個uint32_t就得先把整頁2KB擦成0xFF再寫。頁索引的計算方式很直接頁索引 (目標(biāo)地址 - 0x08000000) / 頁大小舉個例子0x08008000減去基地址0x08000000得到0x8000換成十進制是32768除以2048就是第16頁。這個換算會在擦除參數(shù)里反復(fù)用到。大容量L4型號還引入了雙Bank設(shè)計典型情況是1MB Flash被拆成兩個512KB的Bank。雙Bank的價值在于程序可以從Bank2運行的同時去擦寫B(tài)ank1這給OTA和自升級留了操作空間。但單Bank型號就沒有這個余量下面避坑章節(jié)會專門講一個與此相關(guān)的死機問題。項目常見L4L4主Flash起始地址0x080000000x08000000頁大小2KB8KBBank劃分單或雙視容量雙Bank編程粒度雙字8字節(jié)以參考手冊為準(zhǔn)用戶可寫區(qū)域主存儲區(qū)主存儲區(qū)這個表只做選型時的粗略參考具體到手上這顆芯片一切以芯片參考手冊的Flash章節(jié)為準(zhǔn)。我一般會把手冊里Flash地址映射那頁截圖放在項目文檔最前面防止后面調(diào)地址時記錯。2.2 LL庫、HAL與寄存器裸操作這里的選型看透明度和代碼體積讀寫Flash有三種路線HAL庫、LL庫、直接操作寄存器。標(biāo)準(zhǔn)外設(shè)庫在L4上已經(jīng)不推薦了老項目維護還能見到新設(shè)計沒必要碰。很多剛從其他平臺轉(zhuǎn)過來的開發(fā)者習(xí)慣直接點開HAL的Flash驅(qū)動但HAL把擦除和編程封成了一個大狀態(tài)機超時處理藏得深中斷模式下邏輯繞出了問題反而不容易看明白。LL庫的定位是寄存器之上的一層薄封裝函數(shù)命名基本就是寄存器位的名字。ll_flash.h里看到LL_FLASH_Unlock、LL_FLASH_Program幾乎能猜到內(nèi)部在操作PEKEYR和CR寄存器。它的好處是接近底層又不逼著你把位域拼接這種容易錯的事親力親為。相比之下純寄存器操作的優(yōu)勢是每個步驟都握在自己手里適合做定制邏輯但代碼可讀性差項目換人維護時壓力大。方案抽象層次代碼量適合場景HAL高偏大快速原型、功能驗證LL低小資源敏感、需要看清細(xì)節(jié)寄存器最低看寫法定制化、底層排錯這個例程包把LL庫和寄存器兩套實現(xiàn)都給了我的建議是開發(fā)階段跑LL庫把業(yè)務(wù)邏輯調(diào)通后續(xù)如果需要極致精簡或者遇到詭異問題再對照寄存器實現(xiàn)逐位排查。兩套一起看還能發(fā)現(xiàn)HAL封裝掩蓋掉的一些細(xì)節(jié)比如解鎖之后控制器其實要忙一小段時間。2.3 三個底層約束先擦后寫、64位對齊、單飛無論用哪套實現(xiàn)底層約束逃不掉。第一條寫入只能把位的1變成0想把0變回1唯一手段是擦除。所以更新一個已寫入過的地址必須先擦整頁否則寫操作等于沒寫讀回還是0xFF。第二條L4的編程粒度是雙字也就是一次寫入固定8字節(jié)目標(biāo)地址必須是8的整數(shù)倍不是想寫幾個字節(jié)就寫幾個字節(jié)。第三條Flash控制器同一時間只能執(zhí)行一個操作擦除或者編程進行期間SR寄存器里的BSY位為1軟件必須等它清掉再做下一步。這三條約束直接決定了代碼的組織方式。寫參數(shù)不能像改RAM變量一樣隨手賦值要先規(guī)劃頁、規(guī)劃地址、組裝8字節(jié)塊。更隱蔽的一點是Flash忙的時候CPU從Flash取指令也會被阻塞——如果擦除的頁里正好跑著當(dāng)前代碼硬件就會“自己卡自己”。3. 讀寫擦除落地LL庫函數(shù)與寄存器操作的兩套實現(xiàn)3.1 解鎖與加鎖PEKEYR順序不能亂Flash控制器上電后處于鎖定狀態(tài)寫PEKEYR寄存器兩個密鑰是解鎖的唯一途徑。LL庫把這步封裝成了LL_FLASH_Unlock但內(nèi)部邏輯就是連續(xù)寫兩次PEKEYR。寄存器版本則是把整個流程攤開給你看void flash_unlock_reg(void) { /* 操作 Flash 之前先確認(rèn)控制器空閑 */ while ((FLASH-SR FLASH_SR_BSY) ! 0) { } /* 按順序?qū)懭雰砂谚€匙順序顛倒則解鎖失敗 */ FLASH-PEKEYR 0x89ABCDEFu; FLASH-PEKEYR 0x02030405u; }這里特別強調(diào)順序是因為Flash控制器內(nèi)部把兩次寫入當(dāng)成一組密鑰序列第二次寫入必須以第一次為前提。如果在兩次寫入之間插入其他Flash操作解鎖狀態(tài)會異常。LL版本代碼更短#include stm32l4xx_ll_flash.h void flash_unlock_ll(void) { /* LL 封裝了向 PEKEYR 寫兩個密鑰的過程 */ LL_FLASH_Unlock(); }解鎖之后建議立即執(zhí)行想要的操作操作完成后馬上LL_FLASH_Lock或往PEKEYR寫0xFFFFFFFF鎖回。習(xí)慣上不保持長時間解鎖狀態(tài)否則一個指針飄移就可能把Flash內(nèi)容改寫。鎖回這一步很容易被新手漏掉雖然大多數(shù)時候功能也能跑但產(chǎn)品的健壯性差一截。3.2 頁擦除觸發(fā)之后必須等待BSY清掉擦除是Flash操作里最耗時的動作2KB一頁典型耗時在幾十毫秒量級。LL庫的擦除流程是配置參數(shù)、調(diào)用LL_FLASH_Erase觸發(fā)、輪詢操作標(biāo)志直到完成int flash_erase_page_ll(uint32_t page_index) { FLASH_EraseInitTypeDef erase_cfg {0}; flash_unlock_ll(); erase_cfg.TypeErase FLASH_TYPEERASE_PAGES; erase_cfg.Page page_index; erase_cfg.NbPages 1; LL_FLASH_Erase(erase_cfg); /* 必須等硬件把整頁清干凈期間不要插入其他 Flash 操作 */ while (LL_FLASH_IsActiveFlagOperation()) { } LL_FLASH_ClearFlagOperation(); flash_lock_ll(); return 0; }參數(shù)里TypeErase從FLASH_TYPEERASE_PAGES和FLASH_TYPEERASE_MASS1當(dāng)中選前者按頁擦后者整Bank擦。Page是頁索引從0開始NbPages表示連續(xù)擦幾頁。如果參數(shù)區(qū)跨了頁邊界把NbPages設(shè)為2或更大即可硬件會順序處理。注意LL_FLASH_Erase只是把配置寫入CR并觸發(fā)真正完成要看操作標(biāo)志。寄存器版本把整個過程拆成位操作int flash_erase_page_reg(uint32_t page_index) { flash_unlock_reg(); /* PER 置1表示頁擦模式SNB 寫入頁號 */ FLASH-CR | FLASH_CR_PER; FLASH-CR (FLASH-CR ~FLASH_CR_SNB_Msk) | ((page_index FLASH_CR_SNB_Pos) FLASH_CR_SNB_Msk); /* START 置1觸發(fā)擦除 */ FLASH-CR | FLASH_CR_START; while ((FLASH-SR FLASH_SR_BSY) ! 0) { } /* 清除擦除模式位恢復(fù)常規(guī)狀態(tài) */ FLASH-CR ~FLASH_CR_PER; flash_lock_reg(); return 0; }寄存器版本有幾個細(xì)節(jié)值得說明。位段操作前要先把SNB區(qū)域清成0用“先與后或”的方式寫入頁號避免殘留值干擾。觸發(fā)之后立刻進入BSY等待循環(huán)越早開等越不容易誤判狀態(tài)。等待完成后把PER清零留著這個位可能會讓后續(xù)的編程操作語義錯亂。3.3 雙字編程為什么不能直接用指針寫入L4不像F1系列可以半字編程它要求一次寫入固定8字節(jié)。LL庫的編程接口長這樣int flash_program_dword_ll(uint32_t dst, uint64_t value) { flash_unlock_ll(); LL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, dst, value); /* 等待編程完成 */ while (LL_FLASH_IsActiveFlagOperation()) { } LL_FLASH_ClearFlagOperation(); flash_lock_ll(); /* 寫后讀校驗值回來了才算成功 */ if (*(volatile uint64_t *)dst ! value) { return -1; } return 0; }有人會想既然Flash地址掛在總線映射上直接對地址解引用賦值不就行了理論上地址總線對Flash的寫入會交給Flash控制器但L4的編程粒度是固定的8字節(jié)裸寫一個4字節(jié)的uint32_t不符合控制器期望結(jié)果不可控。況且普通指針賦值走的是CPU數(shù)據(jù)總線和Flash控制器期望的編程序列對不上典型表現(xiàn)是寫入無效甚至觸發(fā)總線錯誤。正確的寄存器寫法是打開編程允許位再用64位寬度的寫入去觸發(fā)int flash_program_dword_reg(uint32_t dst, uint64_t value) { flash_unlock_reg(); /* PG 置1 打開編程寫入放在最后一步觸發(fā)硬件動作 */ FLASH-CR | FLASH_CR_PG; *(volatile uint64_t *)dst value; while ((FLASH-SR FLASH_SR_BSY) ! 0) { } FLASH-CR ~FLASH_CR_PG; flash_lock_reg(); return (*(volatile uint64_t *)dst value) ? 0 : -1; }兩個版本我都保留了寫后讀校驗。這一步不是可有可無掉電瞬間電壓跌落、Flash老化、時鐘配置偏差都可能導(dǎo)致編程結(jié)果不合預(yù)期沒有校驗就發(fā)現(xiàn)不了。參數(shù)存成亂碼還能通過校驗和補救靜默寫壞數(shù)據(jù)才是最難受的。3.4 參數(shù)存儲封裝地址規(guī)劃、組裝雙字與整頁讀寫有了擦除和雙字編程就可以封裝一個可復(fù)用的參數(shù)存儲模塊。第一步是地址規(guī)劃把Flash結(jié)尾的一頁留出來做參數(shù)區(qū)段地址示例用途應(yīng)用代碼0x08000000 起程序代碼區(qū)參數(shù)區(qū)頁A0x0801F8002KB 參數(shù)存儲參數(shù)區(qū)頁B0x0801F000備份參數(shù)頁具體地址按Flash容量調(diào)整原則是讓參數(shù)頁離代碼區(qū)越遠(yuǎn)越好避免擦除動作波及代碼所在的區(qū)域。數(shù)據(jù)結(jié)構(gòu)直接定義一個固定大小的塊#define PARAM_A_ADDR 0x0801F800u #define PARAM_DATA_LEN 64u typedef struct { uint32_t magic; /* 固定魔數(shù)區(qū)分有效參數(shù)和空Flash */ uint32_t sequence; /* 保存序號用于判斷新舊 */ uint8_t payload[PARAM_DATA_LEN - 8]; } ParamBlock;結(jié)構(gòu)體大小控制在8的整數(shù)倍這樣雙字流可以整齊切分。寫入時先擦頁再把結(jié)構(gòu)體按8字節(jié)切塊逐個編程static int param_write_page(uint32_t page_addr, ParamBlock *blk) { if (flash_erase_page_ll((page_addr - 0x08000000u) / 2048u) ! 0) { return -1; } for (uint32_t off 0; off sizeof(ParamBlock); off 8) { uint64_t d; memcpy(d, (uint8_t *)blk off, 8); if (flash_program_dword_ll(page_addr off, d) ! 0) { return -1; } } return 0; }memcpy在這里解決了結(jié)構(gòu)體成員對齊和雙字組裝的問題避免自己拼位。讀取則直接按地址強轉(zhuǎn)先驗?zāi)?shù)再驗數(shù)據(jù)完整性static int param_read_page(uint32_t page_addr, ParamBlock *blk) { ParamBlock *raw (ParamBlock *)page_addr; if (raw-magic ! 0xA5A5A5A5u) { return -1; } *blk *raw; return 0; }實際項目里還會在塊尾加CRC32我這里用魔數(shù)簡化示意。魔數(shù)的作用是區(qū)分“擦除后的空頁”和“有效參數(shù)頁”空頁全0xFF永遠(yuǎn)不會和魔數(shù)相等。雙頁交替留作備份的思路在第5章給出完整推導(dǎo)。4. 避坑記錄五個讓數(shù)據(jù)變成0xFF或者直接死機的操作4.1 擦除的頁里有正在執(zhí)行的代碼現(xiàn)象程序調(diào)用頁擦除后直接跑飛或者復(fù)位循環(huán)調(diào)試器停在HardFault異常里看PC指針指向Flash區(qū)域的無效值。原因擦除過程會占住Flash控制器而CPU當(dāng)前執(zhí)行取指的代碼正好被擦掉Flash讀到一半數(shù)據(jù)就沒了取回來的指令必然是垃圾。這在單Bank的小容量L4上是典型翻車場景——程序代碼和參數(shù)區(qū)擠在同一片F(xiàn)lash里參數(shù)頁規(guī)劃得太靠前一擦就把代碼連同數(shù)據(jù)的屋脊掀了。解決把參數(shù)區(qū)固定在Flash最后的一頁或兩頁鏈接腳本把代碼安排在前面地址擦除動作只許對參數(shù)頁發(fā)起。如果是雙Bank型號可以把Bootloader放在Bank1、App放在Bank2從Bank2運行App時去擦Bank1的舊固件這屬于OTA的標(biāo)準(zhǔn)做法。寄存器版本擦除函數(shù)的入?yún)㈨撍饕齽?wù)必和鏈接腳本對比過再提交代碼。4.2 寫入前不擦除讀回永遠(yuǎn)是0xFF現(xiàn)象數(shù)據(jù)“寫進去了”讀出來卻是0xFF再次寫入仍然0xFF調(diào)試器看那塊內(nèi)存值紋絲不動。原因Flash編程邏輯只能把位從1寫成0不能反方向恢復(fù)。對一個寫過的地址再次寫入控制器會忽略那些需要 0→1 的位變化所以數(shù)據(jù)等于沒寫。這是所有嵌入式Flash共有的物理特性不是芯片的問題。解決每次編程前對目標(biāo)頁執(zhí)行一次頁擦除。如果只想改4個字節(jié)而整頁其他數(shù)據(jù)要保留正確做法是把整頁讀進RAM在RAM里修改這4個字節(jié)然后擦除整頁再把整個頁從RAM寫回Flash。千萬別圖省事跳過擦除這個省事會讓參數(shù)永遠(yuǎn)停在舊值。4.3 寫Flash時來了中斷程序直接卡死現(xiàn)象擦除或編程過程中一個串口中斷或定時器中斷觸發(fā)后系統(tǒng)不再響應(yīng)任何中斷主循環(huán)也不走了看起來像徹底死機。原因中斷向量表和中斷服務(wù)程序都在Flash里。Flash正忙的時候CPU無法從Flash取指令中斷處理程序根本進不去主循環(huán)的BSY等待又不知道要等多久雙方都在等對方讓出Flash形成死鎖。這個坑在帶RTOS的項目里尤其隱蔽高優(yōu)先級中斷一多就隨機復(fù)現(xiàn)。解決操作Flash期間用臨界區(qū)屏蔽可屏蔽中斷做完再開uint32_t primask __get_PRIMASK(); __disable_irq(); flash_erase_page_ll(page_idx); flash_program_dword_ll(dst, value); __set_PRIMASK(primask);更徹底的做法是把中斷向量表搬到RAM并設(shè)置VTOR讓中斷處理不必訪問Flash。但RAM空間有限多數(shù)項目用臨界區(qū)方案就足夠了代價是擦除期間最長的幾十毫秒內(nèi)系統(tǒng)不響應(yīng)中斷需要評估業(yè)務(wù)能否接受。4.4 低電壓或供電紋波寫入偶爾失敗現(xiàn)象設(shè)備在實驗室常溫下一切正常到了現(xiàn)場偶爾出現(xiàn)參數(shù)丟失復(fù)現(xiàn)概率低日志里查不到規(guī)律一測才發(fā)現(xiàn)掉電瞬間還在寫Flash。原因Flash編程對供電電壓有明確下限要求掉電過程中電壓快速跌落編程操作執(zhí)行到一半就夭折了。表現(xiàn)好的情況是寫后讀校驗發(fā)現(xiàn)不對報錯表現(xiàn)差的情況是數(shù)據(jù)變成半新半舊的混合體。解決用PVD掉電檢測提前預(yù)警電壓跌到閾值之前把關(guān)鍵參數(shù)保存進Flash電源輸入端留足夠容量的儲能電容給保存動作爭取時間。寫入流程里必須加校驗和和數(shù)據(jù)長度回讀寫失敗就標(biāo)記壞頁。這個問題的現(xiàn)象像極了玄學(xué)但根源通常是電源設(shè)計排查時優(yōu)先用示波器看VDD在擦寫瞬間的跌落幅度。4.5 寫保護和讀保護沒關(guān)干凈Flash怎么都不讓動現(xiàn)象解鎖函數(shù)返回正常擦除也觸發(fā)了但操作標(biāo)志一直不完成Flash內(nèi)容紋絲不動個別時候調(diào)試器直接連不上芯片。原因選項字節(jié)里的WRP寫保護區(qū)域覆蓋了目標(biāo)頁或者RDP讀保護等級被設(shè)成了高于0的級別。RDP一旦升級不是簡單地寫寄存器就能降下來Flash硬件直接禁止任何外部訪問。解決先用調(diào)試器讀出選項字節(jié)的狀態(tài)確認(rèn)WRP沒蓋住參數(shù)區(qū)。解除RDP需要執(zhí)行選項字節(jié)擦除和重新編程流程注意修改選項字節(jié)會觸發(fā)系統(tǒng)復(fù)位不能放在業(yè)務(wù)代碼里隨便調(diào)用。這個坑最容易在二手板卡上遇到上一任開發(fā)者調(diào)成保護模式就轉(zhuǎn)交給你排查時首先排除這個因素再談時序。5. 進階驗證把內(nèi)部Flash做成掉電不丟的參數(shù)倉庫5.1 雙頁交替保存兼顧掉電回退和磨損均衡單頁存儲有個固有缺陷擦除過程中掉電整頁數(shù)據(jù)全丟連回退的機會都沒有。我一般用雙頁交替方案A頁寫滿了寫B(tài)頁B頁寫滿了再擦A頁每一時刻都保留一份完整參數(shù)。讀取時比較兩頁的sequence字段誰大用誰。這個方案多花2KB Flash換來掉電安全性和Flash壽命翻倍對參數(shù)頻繁更新的設(shè)備很劃算。5.2 PVD掉電檢測把保存動作放在電壓跌破之前PVD是芯片內(nèi)部的電源電壓檢測器電壓跌到設(shè)定閾值時產(chǎn)生中斷從觸發(fā)到VDD真正跌破Flash編程下限中間有一段可操作的窗口。配合儲能電容這段窗口足夠把幾十字節(jié)的參數(shù)寫完。關(guān)鍵點是保存動作里要關(guān)中斷再擦寫保證Flash操作一氣呵成保存完再進停機模式。5.3 上電自檢讓每一次啟動都驗證存儲合法性啟動流程里讀參數(shù)區(qū)驗?zāi)?shù)、驗CRC兩個頁都失效就恢復(fù)默認(rèn)參數(shù)并重新初始化。這個過程要放在外設(shè)初始化之前否則外設(shè)用了野參數(shù)起跑后面查問題要翻一倍工作量。自檢通過后打印一幀參數(shù)狀態(tài)日志調(diào)試期靠它確認(rèn)存儲鏈路量產(chǎn)期靠它定位返修板。寫到這里回看整個例程包最重要的一句話還是先擦后寫、雙字對齊、寫完讀回、中斷隔離。從那以后我每次交付Flash讀寫代碼都強制走一遍頁對齊檢查、擦除頁確認(rèn)、寫后讀回三大步缺一步寧可不上貨。希望幫到你。本文還有配套的精品資源點擊獲取