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