:從GPIO到串口的中文手冊)
1. 為什么我要寫一份寄存器級別的白話手冊剛接觸STM32那會兒我和大多數(shù)人一樣從庫函數(shù)入手。點個燈調(diào)個串口復(fù)制粘貼幾行代碼就能跑起來感覺挺爽。但時間一長問題就來了一旦遇到庫函數(shù)沒覆蓋到的場景或者程序跑飛了想查底層原因整個人就懵了??磪⒖际謨园蓾M屏的寄存器名和位定義像在讀天書看庫函數(shù)源碼吧層層封裝下來早就不知道最終寫進硬件的是什么。后來我逼著自己花了兩周時間把GPIO、時鐘、中斷、串口這幾個最常用的外設(shè)從寄存器層面重新捋了一遍。過程很痛苦但捋完之后很多以前模棱兩可的概念一下子清晰了。比如為什么配置GPIO要先開時鐘為什么中斷優(yōu)先級要分組為什么串口波特率要算那個奇怪的公式。這些東西庫函數(shù)幫你做了但你不知道它做了什么出了問題就無從下手。這份手冊就是那兩周折騰的產(chǎn)物。它不是參考手冊的翻譯也不是庫函數(shù)的中文注釋而是我用大白話把寄存器配置的邏輯講清楚告訴你每個關(guān)鍵位是干什么的為什么這么設(shè)不這么設(shè)會怎樣。適合已經(jīng)能用庫函數(shù)跑通基礎(chǔ)例程但想進一步理解底層機制的朋友。如果你連開發(fā)環(huán)境都還沒搭好建議先找個點燈教程跑一遍再回來。2. 從零理解STM32的寄存器模型2.1 寄存器到底是什么為什么非要直接操作它寄存器就是芯片內(nèi)部一個個有特定功能的存儲單元每個單元有固定的地址CPU通過讀寫這些地址來控制外設(shè)。你可以把它想象成一棟大樓里的一個個開關(guān)面板每個面板上有一排開關(guān)每個開關(guān)控制一盞燈或者一個設(shè)備。庫函數(shù)相當(dāng)于一個物業(yè)管家你告訴他“我要開大廳的燈”他去幫你找到對應(yīng)的開關(guān)面板撥動正確的開關(guān)。但有時候管家不知道你要開哪盞燈或者他動作太慢你就得自己去撥。直接操作寄存器的好處有三個一是執(zhí)行效率高沒有函數(shù)調(diào)用的開銷二是控制精確每個位的狀態(tài)你心里有數(shù)三是排查問題時能直接看到硬件層面的狀態(tài)。壞處也很明顯可讀性差容易出錯不同型號之間可能有差異。所以我的建議是日常開發(fā)用庫函數(shù)沒問題但關(guān)鍵路徑和出問題的時候一定要能看懂寄存器。2.2 存儲器映射外設(shè)寄存器住在哪里STM32的地址空間是4GB從0x00000000到0xFFFFFFFF。其中外設(shè)寄存器的地址是固定映射的比如GPIOA的基地址是0x40010800GPIOB是0x40010C00依次類推。每個外設(shè)占一段地址段內(nèi)再按功能劃分成多個寄存器。比如GPIOA這段里偏移0x00是CRL偏移0x04是CRH偏移0x0C是ODR。你不需要死記這些地址寫代碼的時候用宏定義或者結(jié)構(gòu)體指針來訪問就行。但你要知道這個映射關(guān)系是存在的這樣看到地址的時候不會慌。我習(xí)慣在調(diào)試的時候把關(guān)鍵寄存器的地址和值打印出來對照參考手冊的位定義表一眼就能看出問題。2.3 位帶操作讓寄存器讀寫像變量一樣簡單STM32有一個很有意思的機制叫位帶它把某些地址區(qū)域的每一位映射到一個單獨的地址上。你往那個地址寫1對應(yīng)的位就是1寫0對應(yīng)的位就是0。這樣你就不需要讀-改-寫整個寄存器了直接操作單個位既安全又方便。位帶區(qū)域有兩塊外設(shè)區(qū)從0x40000000到0x400FFFFFSRAM區(qū)從0x20000000到0x200FFFFF。映射公式是別名地址 別名基址 (字節(jié)偏移 × 32) (位號 × 4)。聽起來復(fù)雜但用宏定義包一下用起來就跟普通變量一樣。比如#define BITBAND(addr, bitnum) ((addr 0xF0000000) 0x2000000 ((addr 0xFFFFF) 5) (bitnum 2)) #define MEM_ADDR(addr) (*((volatile unsigned long *)(addr))) #define BIT_ADDR(addr, bitnum) MEM_ADDR(BITBAND(addr, bitnum))然后你就可以寫B(tài)IT_ADDR(GPIOA_ODR_Addr, 5) 1;來單獨設(shè)置PA5輸出高電平。這個技巧在需要頻繁操作單個IO口的時候特別好用代碼簡潔執(zhí)行也快。3. GPIO寄存器配置的完整拆解3.1 時鐘使能為什么第一步永遠是開時鐘很多新手會犯一個錯誤直接配置GPIO寄存器然后發(fā)現(xiàn)沒反應(yīng)。原因就是沒有使能對應(yīng)端口的時鐘。STM32為了省電外設(shè)時鐘默認是關(guān)閉的你不開時鐘外設(shè)根本不工作你寫進去的值也無效。時鐘使能寄存器是RCC_APB2ENRGPIOA到GPIOG都在這個寄存器里控制。比如要使能GPIOA就把bit2置1RCC-APB2ENR | (1 2);這里有個細節(jié)用|而不是因為其他位可能已經(jīng)被其他外設(shè)占用了你直接賦值會把別人的時鐘關(guān)掉。這個坑我踩過當(dāng)時調(diào)一個項目串口突然不工作了查了半天才發(fā)現(xiàn)是配置GPIO時把串口時鐘給覆蓋了。3.2 模式選擇輸入還是輸出推挽還是開漏GPIO的配置寄存器是CRL和CRHCRL管0到7號引腳CRH管8到15號引腳。每個引腳占4個位2個MODE位和2個CNF位。MODE決定輸出速度或輸入模式CNF決定具體的工作方式。輸出模式下MODE可以設(shè)2MHz、10MHz、50MHz這個速度指的是輸出驅(qū)動電路的響應(yīng)速度不是信號頻率。一般來說點燈、驅(qū)動繼電器這種低速場景用2MHz就夠了速度越高功耗和噪聲越大。CNF可以設(shè)推挽、開漏、復(fù)用推挽、復(fù)用開漏。推挽就是能輸出高低電平開漏只能輸出低電平高電平需要外部上拉。I2C這種總線必須用開漏因為多個設(shè)備要共享一條線。輸入模式下MODE固定為00CNF可以設(shè)模擬輸入、浮空輸入、上拉/下拉輸入。模擬輸入用于ADC采樣浮空輸入用于外部已經(jīng)有上下拉的場景上拉/下拉輸入用于按鍵這種需要確定默認電平的場景。我一般用結(jié)構(gòu)體來配置清晰不容易錯GPIOA-CRL ~(0xF (4 * 5)); // 清除PA5的配置 GPIOA-CRL | (0x3 (4 * 5)); // PA5設(shè)為推挽輸出50MHz3.3 輸出電平控制ODR、BSRR和BRR的區(qū)別控制輸出電平有三個寄存器ODR、BSRR和BRR。ODR是直接讀寫整個端口BSRR是原子操作置位和復(fù)位BRR是專門復(fù)位。ODR的問題在于讀-改-寫不是原子操作如果你在中斷里也操作同一個端口可能會沖突。BSRR就解決了這個問題低16位寫1置位高16位寫1復(fù)位寫0無效。比如要同時設(shè)置PA5高、PA6低GPIOA-BSRR (1 5) | (1 (16 6));BRR只能復(fù)位功能上BSRR的高16位就能替代。我個人的習(xí)慣是初始化的時候用ODR設(shè)置默認電平運行過程中用BSRR做原子操作。3.4 復(fù)用功能什么時候需要配置AFIO當(dāng)你把GPIO配置成復(fù)用功能時比如串口的TX/RX、SPI的SCK/MOSI除了設(shè)置GPIO的CNF為復(fù)用模式還需要配置AFIO。AFIO負責(zé)把外設(shè)的信號映射到具體的引腳上。比如串口1默認是PA9和PA10但如果你想重映射到PB6和PB7就需要操作AFIO_MAPR寄存器。另外外部中斷的引腳選擇也要通過AFIO_EXTICR來配置。這個寄存器組容易被忽略但用到復(fù)用功能時必不可少。4. 中斷與NVIC的寄存器級理解4.1 NVIC是什么為什么中斷優(yōu)先級要分組NVIC是嵌套向量中斷控制器它管理所有中斷的使能、優(yōu)先級和響應(yīng)。STM32的中斷優(yōu)先級分搶占優(yōu)先級和響應(yīng)優(yōu)先級搶占優(yōu)先級高的可以打斷正在執(zhí)行的低搶占優(yōu)先級中斷響應(yīng)優(yōu)先級只在同時掛起時決定誰先執(zhí)行。優(yōu)先級分組決定了搶占和響應(yīng)各占幾位。比如分組2就是2位搶占、2位響應(yīng)各有4個級別。分組方式通過SCB-AIRCR寄存器的PRIGROUP位設(shè)置。這個設(shè)置全局生效一般整個項目只設(shè)一次。我見過有人把分組設(shè)來設(shè)去結(jié)果中斷行為混亂。記住分組是全局的設(shè)一次就好然后所有中斷的優(yōu)先級都按這個分組來分配。4.2 中斷使能的三層開關(guān)一個中斷要能正常工作需要打開三層開關(guān)外設(shè)自身的中斷使能位、NVIC的使能位、全局中斷使能位。少一層都不行。外設(shè)的中斷使能位在各自的控制寄存器里比如串口接收中斷是USART_CR1的RXNEIE位。NVIC的使能位在ISER寄存器組里每個中斷號對應(yīng)一位。全局中斷使能是CPSR的I位用__enable_irq()打開。調(diào)試的時候如果中斷不觸發(fā)就按這三層逐一排查。我遇到過外設(shè)中斷使能了、NVIC也開了但全局中斷被關(guān)掉的情況查了好久才想起來。4.3 中斷服務(wù)函數(shù)的編寫要點中斷服務(wù)函數(shù)要短小精悍只做最緊急的事其他處理放到主循環(huán)里。進入中斷后先清除中斷標志位否則會反復(fù)觸發(fā)。清除標志位的操作有的是寫1清零有的是寫0清零要看參考手冊。另外中斷服務(wù)函數(shù)里不要用延時不要做浮點運算不要調(diào)用可能阻塞的函數(shù)。如果確實需要處理復(fù)雜邏輯設(shè)個標志位讓主循環(huán)去處理。5. 串口寄存器的配置與波特率計算5.1 串口初始化的寄存器步驟串口配置涉及三個寄存器CR1、CR2、CR3和BRR。CR1控制字長、校驗、使能等CR2控制停止位CR3控制流控BRR設(shè)置波特率。典型配置流程先設(shè)置CR1的M位為08位數(shù)據(jù)清除PCE無校驗設(shè)置TE和RE使能發(fā)送和接收設(shè)置UE使能串口。然后設(shè)置CR2的STOP位為001個停止位。最后計算并寫入BRR。5.2 波特率計算那個奇怪的公式怎么來的波特率計算公式是波特率 fPCLK / (16 × USARTDIV)。其中fPCLK是串口掛載總線的時鐘頻率USARTDIV是一個定點數(shù)整數(shù)部分在BRR的高12位小數(shù)部分在低4位。比如fPCLK是72MHz要得到115200波特率USARTDIV 72000000 / (16 × 115200) 39.0625。整數(shù)部分是39小數(shù)部分是0.0625 × 16 1。所以BRR的值是(39 4) | 1 0x271。實際寫代碼的時候直接用公式算就行float usartdiv (float)72000000 / (16 * 115200); uint16_t mantissa (uint16_t)usartdiv; uint16_t fraction (uint16_t)((usartdiv - mantissa) * 16 0.5); USART1-BRR (mantissa 4) | fraction;注意加0.5是為了四舍五入減少誤差。波特率誤差太大會導(dǎo)致通信不穩(wěn)定一般要求誤差在2%以內(nèi)。5.3 串口狀態(tài)寄存器的讀取與錯誤處理串口的狀態(tài)在SR寄存器里常用的位有TXE發(fā)送數(shù)據(jù)寄存器空、RXNE接收數(shù)據(jù)寄存器非空、TC發(fā)送完成、ORE溢出錯誤、FE幀錯誤、PE校驗錯誤。發(fā)送數(shù)據(jù)時先等TXE置1然后寫DR。接收數(shù)據(jù)時等RXNE置1然后讀DR。如果出現(xiàn)ORE說明數(shù)據(jù)還沒讀走新的數(shù)據(jù)就來了需要先讀SR再讀DR來清除。我習(xí)慣在接收中斷里先判斷ORE如果溢出就丟棄數(shù)據(jù)并清除標志避免死循環(huán)。6. 調(diào)試寄存器程序時的常見坑與排查思路6.1 程序跑飛了怎么定位程序跑飛最常見的原因是訪問了未映射的地址或者空指針。用調(diào)試器連上看HardFault_Handler里的堆棧信息找到出錯前的PC值反匯編看看是哪條指令。如果是寄存器操作檢查地址是否正確時鐘是否使能。另一個常見原因是棧溢出尤其是用了大數(shù)組或者遞歸??梢栽趩游募锇褩4笮「拇笠稽c試試。6.2 寄存器寫不進去值怎么辦先確認時鐘開了沒有這是最常見的原因。然后確認外設(shè)是否處于復(fù)位狀態(tài)有些外設(shè)需要先解除復(fù)位才能配置。再確認寫保護有些寄存器需要先寫特定的鍵值才能修改。如果還不行用調(diào)試器直接看寄存器的值確認是寫不進去還是寫進去了但被硬件改了。有些狀態(tài)位是只讀的寫進去也沒用。6.3 不同型號之間的寄存器差異STM32有F1、F4、H7等多個系列寄存器地址和位定義有差異。比如F1的GPIO配置是CRL/CRHF4是MODER/OTYPER/OSPEEDR/PUPDR。寫代碼的時候盡量用宏定義或者條件編譯來隔離差異方便移植。我一般會建一個硬件抽象層把寄存器操作封裝成函數(shù)上層業(yè)務(wù)代碼不直接碰寄存器。這樣換芯片的時候只需要改底層。7. 從寄存器到庫函數(shù)的思維轉(zhuǎn)換7.1 庫函數(shù)幫你做了什么以GPIO初始化為例庫函數(shù)GPIO_Init()內(nèi)部做了這些事使能時鐘、計算CRL/CRH的偏移、根據(jù)參數(shù)拼裝配置值、寫入寄存器。它幫你處理了位運算和邊界條件但代價是代碼體積大、執(zhí)行慢。理解庫函數(shù)的實現(xiàn)能幫你在出問題的時候快速定位。比如你發(fā)現(xiàn)GPIO配置不對可以直接去看GPIO_Init()的源碼對照它寫的寄存器和你的預(yù)期是否一致。7.2 什么時候該用寄存器什么時候該用庫我的經(jīng)驗是初始化階段用庫函數(shù)快速開發(fā)關(guān)鍵路徑和中斷服務(wù)函數(shù)用寄存器保證效率和確定性調(diào)試階段直接看寄存器定位問題。比如點燈這種簡單操作用寄存器一行代碼就夠了沒必要調(diào)庫函數(shù)。但配置一個復(fù)雜的串口用庫函數(shù)能省很多事不容易出錯。7.3 混合使用的注意事項庫函數(shù)和寄存器操作可以混用但要注意順序和狀態(tài)。比如你先用庫函數(shù)初始化了GPIO然后又用寄存器改了CRL這時候庫函數(shù)內(nèi)部的緩存可能和實際寄存器不一致。所以混用的時候要么全用庫函數(shù)要么全用寄存器要么在混用后重新初始化。我一般是在庫函數(shù)初始化的基礎(chǔ)上用寄存器做微調(diào)。比如庫函數(shù)初始化了串口我用寄存器改一下BRR來微調(diào)波特率。這樣既享受了庫函數(shù)的便利又保留了寄存器的靈活性。8. 我個人的寄存器學(xué)習(xí)路徑建議如果你也想從庫函數(shù)進階到寄存器我的建議是先選一個最簡單的外設(shè)比如GPIO把它的所有寄存器都看一遍然后自己寫一個點燈程序不用庫函數(shù)。跑通之后再選串口自己算波特率自己配中斷。這樣一步步來不要貪多。過程中肯定會遇到各種問題這很正常。我的做法是每遇到一個問題就把它記下來包括現(xiàn)象、排查過程、最終原因。積累多了你會發(fā)現(xiàn)很多問題都是類似的排查速度會越來越快。另外參考手冊不用從頭讀到尾用到哪個外設(shè)就查哪個章節(jié)。讀的時候重點看寄存器描述和位定義表例子代碼可以跳過。配合庫函數(shù)源碼一起看理解會更深刻。最后說一個我自己的習(xí)慣我會在代碼里給每個關(guān)鍵的寄存器操作加上注釋寫明這個操作的目的和依據(jù)。這樣過幾個月再回來看也能快速回憶起當(dāng)時的思路。這個習(xí)慣幫我省了很多重新理解代碼的時間。