文章目錄
- 摘要
- 問題一、內核啟動流程
- 1.1 ARM內核
- 上電復位與BootROM執行?
- 啟動代碼(Startup Code)執行
- 跳轉到用戶程序
- 1.2 內存管理
- 問題二、C語言基礎
- 2.1 常量指針和指針常量區別
- 2.2.函數指針和指針函數區別
- 2.3 關鍵字Volatile
- 2.4 隊列結構體數據
摘要
嵌入式C語言中,一些常見的問題總結。
問題一、內核啟動流程
1.1 ARM內核
Reset_Handler PROCEXPORT Reset_Handler [WEAK]IMPORT SystemInitIMPORT __mainLDR R0, =SystemInitBLX R0LDR R0, =__mainBX R0ENDP
上電復位與BootROM執行?
-
?復位向量定位?:
芯片上電后,ARM內核從固定地址(通常為
0x00000000
)讀取復位向量(Reset Vector)。該地址存放初始棧指針(MSP)?? 和復位處理函數地址(Reset_Handler)??。 -
?BootROM初始化?:
內置的BootROM固件執行以下操作:
-
初始化時鐘、內存控制器、關閉看門狗和中斷。
-
根據啟動模式(如Flash/SD卡啟動)加載下一階段代碼(如Bootloader或用戶程序)。
-
啟動代碼(Startup Code)執行
啟動代碼(通常由工具鏈或芯片廠商提供,如IAR或Keil的startup_*.s
)依次完成以下任務:
-
?堆棧初始化?:
- 設置主堆棧指針(MSP),指向棧頂地址(如
sfe(CSTACK)
)。
- 設置主堆棧指針(MSP),指向棧頂地址(如
-
?硬件初始化?:
- 調用
SystemInit()
函數,配置系統時鐘、FPU(若啟用)、電源管理模塊,并關閉看門狗。
- 調用
-
?數據段初始化?:
-
?
.data
段(初始值非零的全局變量):從Flash拷貝初始值到RAM。 -
?
.bss
段(未初始化或初始值為零的全局變量):將對應RAM區域清零。
-
-
?中斷向量表重定位?:
- 將中斷向量表從Flash復制到RAM(可選),并更新VTOR(向量表偏移寄存器)。
跳轉到用戶程序
-
?調用
main
函數?:通過
__main
或__iar_program_start
(IAR)等函數完成C運行時環境初始化,最終跳轉至用戶main()
函數入口。 -
硬件層?:BootROM初始化基礎外設,加載啟動代碼。
-
?固件層?:啟動代碼配置堆棧、時鐘、數據段,建立C運行環境。
-
?軟件層?:跳轉
main()
執行用戶邏輯。
1.2 內存管理
對于FLASH來說,存在有
RW-DATA:可讀可寫數據,也就是全局變量,指的是存儲的初始值。例如int x = 10;初始值是:10。
因為全局變量需要整個聲明周期使用,因此在運行的時候,直接將這一部分復制到RAM空間,方便快速調用,相當于是用空間換時間。
RO-DATA:表示只讀數據,指的是常量數據。如:const修飾的全局變量,字符串字面量,以及編譯器生成的常量表。通常不需要搬運到RAM,這是因為RO-DATA在運行期間是不會修改的,無需使用RAM的可讀可寫特性,相當于是節約RAM的使用空間,同時Flash掉電不丟失數據,保證常量持久化。
若RO-DATA被高頻訪問?(如實時解碼表),且Flash訪問速度慢(如某些NAND Flash),可將其復制到高速RAM(如片內SRAM)以加速訪問。
ZI-DATA:僅統計未初始化/零初始化的全局和靜態變量,未初始化的全局變量默認狀態是0。
相對應的在啟動的時候,需要將RW-DATA和ZI-DATA復制到RAM空間,RAM空間是用來運行的空間,RAM從上到下是:棧空間、堆空間、.data、.bss段。
在程序運行的時候,裸機嵌入式系統(如 STM32)??:CPU 直接從 Flash 讀取指令執行(程序編譯后,函數代碼(機器指令)存儲在 .text
段),無需搬運到 RAM(稱為 XIP 技術)。函數調用時,棧幀(Stack Frame)保存 ?返回地址、參數、局部變量,而非函數代碼本身,那而對于函數來說:
函數調用? → ?動態輕量化?:用棧幀實現“按需分配、自動回收”,以極低成本支持嵌套調用、遞歸、中斷等動態場景。
在調用我們寫的程序函數的時候,我們需要處理函數內部的局部變量、還需要知道一件事情那就是我們調用以后還需要返回到調用的地方(返回地址,函數執行完應該跳回的位置)、還有參數傳遞(需傳遞給被調函數的值)以及寄存器保護(防止被調用函數破壞調用者寄存器)。這些內容每一個函數調用都是不一樣的,并且還是循環調用,那么我們就需要設計一個位置最好是能反復應用,相當于是一個車站,可以供天南海北的人去往各地,然后又能回來。那么這個地方就是RAM的棧空間,或者是堆空間。
在棧空間:為當前調用的函數分配一個棧幀,該棧幀是一個連續的內存塊,包含上述所有信息,并且這個是自動管理的,函數進入時棧幀入棧,退出時自動釋放(僅需移動棧指針SP),避免手動內存管理錯誤。遞歸或多層調用時,棧幀按調用順序疊加,后調用的先釋放(LIFO特性)。
棧溢出(遞歸過深)會導致程序崩潰,棧空間耗盡會覆蓋其他內存區域(如全局變量)。此外:棧幀的隔離也會保證程序穩定。
問題二、C語言基礎
2.1 常量指針和指針常量區別
常量指針:char const * p : 構成是 const 是常量,而 * p 是指針,因此是常量指針叫法。
首先 * p 是一個指針,也就是一個地址,直接分析似乎不是那么好分析,姑且先將常量作為重點,也就是最中這個地址存的數據是不能被改變的,所以常量是在前面。
應用在:函數參數傳遞只讀數據(如字符串、配置結構),避免內部誤改。
指針常量 char * const p:指針常量,就理解成指針是一個常量,那么這就簡單了,指針是常量,也就是地址是常量,那么就說明地址是不能改變的,所以是右定向,這樣就只需要刷新地址上面的數據就可以了。
應用在:固定資源訪問(如硬件寄存器地址不可變,但需修改寄存器值)。
2.2.函數指針和指針函數區別
函數指針:就是我們聲明了一個指針,只不過指針類型是函數。
指針函數:本質是函數,返回類型為指針,用于返回地址(如動態內存地址)。
?類型? | ?聲明形式? | ?關鍵語法特征? |
---|---|---|
函數指針 | int (*ptr)(int, int) | * 與指針名用括號綁定:(*ptr) |
指針函數 | int* func(int size) | * 緊貼返回類型:int* |
?維度? | ?函數指針? | ?指針函數? |
---|---|---|
本質 | 變量(存儲地址) | 函數(返回地址) |
內存位置 | 數據段(全局)或棧(局部) | 代碼段(函數體) |
安全風險 | 錯誤跳轉導致崩潰 | 返回無效指針(懸空指針、內存泄漏) |
典型用途 | 回調、插件系統、狀態機 | 內存分配、硬件抽象、數據查詢 |
-
函數指針?:
-
需確保調用函數與指針簽名一致(參數類型、返回值);
-
避免懸空指針:若指向的函數被釋放,調用會導致崩潰;
-
-
?指針函數?:
-
?禁止返回局部變量地址?:函數退出后局部變量失效,返回其地址引發未定義行為;
-
?動態內存需手動釋放?:返回
malloc
的指針時,調用者必須free
避免內存泄漏。
-
并且為函數指針成員(如SendBefor
、SendOver
、ReciveNew
)賦值為0
,本質上是將其初始化為空指針(NULL
)?,表示該指針當前不指向任何有效的函數。
0
在指針上下文中等價于NULL
(標準庫中通常定義為((void*)0)
),表示無指向目標。
例如:g_tUart0.SendBefor = 0;
等價于 g_tUart0.SendBefor = NULL;
,表明該回調函數當前未設置。
2.3 關鍵字Volatile
-
-
禁止編譯器優化?:
編譯器在優化代碼時可能將變量值緩存在寄存器中(假設其未被修改)。
volatile
強制每次訪問變量時直接讀寫內存地址,確保獲取最新值。
-
-
?應對“外部修改”??:
在嵌入式系統中,以下場景需用
volatile
:-
?硬件寄存器?(如串口狀態寄存器):值可能被硬件自動更新。
-
?中斷共享變量?:主程序與中斷服務程序(ISR)共同訪問的變量(如
usRxCount
)。 -
?多任務共享變量?:在RTOS中,多個任務間共享的變量。
-
-
?在結構體中的應用?
結構體中
usTxWrite
、usRxCount
等成員用__IO
修飾,是因為:-
它們可能被中斷服務程序修改?(如接收數據時更新指針)。
-
主循環需實時感知其變化?(如判斷是否有新數據)。
-
本質就是?硬件寄存器(如串口狀態寄存器)通常被映射到特定的物理內存地址,即 ?內存映射I/O(Memory-Mapped I/O)?,?也就是硬件設計時將串口控制器的寄存器(如STM32的狀態寄存器 USART_SR
、數據寄存器 USART_DR
)分配固定的物理地址(如USART_DR寄存器地址 0x40004400
)程序通過讀寫這些內存地址,等同于直接操作硬件寄存器。
之前我們文章中也說明了,CPU中包含通用數據寄存器,并且還是循環使用的,我覺得在這里可以銜接起來理解,用于暫存運算數據(如 MOV R0, [R1]
將內存數據加載到寄存器R0),這樣就穿起來了。
而對于有些硬件寄存器是外設的物理存儲單元,與CPU寄存器無關,就是上面這一段說的如串口狀態寄存器通常被映射到特定的物理內存地址,這個如何映射的過程,我覺得暫時可以先不用深究,因為觸及到芯片核心了,并且之前我也有在文章中提到。
那么知道了上面的知識,我們才能理解關鍵字volatile
具體是啥意思。想想一下我們的應用場景,假設我們的串口在很快的速度發送數據,我們使用數組或者鏈表、隊列去接收數據,然后正常的流程是CPU的通用寄存器通過串口的硬件寄存器獲取的數據存到R0,然后再進行處理,這樣就會有一個問題,串口數據很快,因為CPU需要轉手的原因存在一種情況這邊跟不上接受的速度,可能會導致部分數據混亂或者時序出問題。
總結一下就是下面的情況:
-
緩存到寄存器?:假設狀態寄存器值不變,將首次讀取的結果緩存到CPU寄存器,后續讀取直接復用緩存值(而非重新訪問內存地址)。
-
?刪除“冗余”操作?:若多次寫入同一寄存器(如循環中寫數據寄存器),編譯器可能合并為最后一次寫入(認為中間寫入無效)。
程序無法感知硬件實時狀態變化,例如:
-
等待串口數據時,因狀態寄存器
RXNE
位未被更新,陷入死循環。 -
發送數據時,因寫入操作被合并,實際只發送最后一字節。
那么這個時候關鍵字就應運而生,直接禁用緩存,CPU通過內存訪問指令?(如LDR)直接操作硬件寄存器,而非將數據復制到CPU寄存器后操作。
此前也有分析過這個地方, 就是關于按鍵信息的標志位,也是通過硬件獲取的,那么如果沒有使用關鍵字就會導致在編譯的時候被優化掉。
?成員類別? | ?成員名? | ?功能說明? |
---|---|---|
?通信配置? | Com | 標識串口號(如COM1 ),用于選擇物理外設。 |
?緩沖區管理? | *pTxBuf | 指向發送緩沖區的指針,存儲待發送的數據。 |
*pRxBuf | 指向接收緩沖區的指針,存儲已接收的數據。 | |
usTxBufSize | 發送緩沖區總容量(單位:字節)。 | |
usRxBufSize | 接收緩沖區總容量(單位:字節)。 | |
?指針與計數器? | usTxWrite | 發送緩沖區寫入位置索引(下次寫入的位置)。 |
usTxRead | 發送緩沖區讀取位置索引(下次發送的位置)。 | |
usTxCount | 待發送數據的剩余字節數?(= usTxWrite - usTxRead )。 | |
usRxWrite | 接收緩沖區寫入位置索引(新數據存儲位置)。 | |
usRxRead | 接收緩沖區讀取位置索引(用戶已讀取位置)。 | |
usRxCount | 接收緩沖區中未讀取的新數據字節數?(= usRxWrite - usRxRead )。 | |
?回調函數? | SendBefore() | 發送前回調(用于切換RS485為發送模式)。 |
SendOver() | 發送完成回調(用于切換RS485為接收模式)。 | |
ReciveNew(_byte) | 收到單字節數據時觸發,用于實時處理或協議解析。 |
2.4 隊列結構體數據
在創建不同結構體隊列的時候,原本是想使用隊列通用函數統一管理,但是發現在通用管理中,我們固定了該數據結構體的類型,這樣就導致如果使用不同的結構體就會很麻煩。除非我們在通用結構體中盡可能的廣泛包含對應的數據類型。這樣似乎就可能導致冗余?因為本身我們想通過通用隊列降低盡可能多的語法糖,但是反而會增加。
仔細分析隊列處理函數,我們可以發現并沒有很復雜的邏輯,換句話會說,我們直接實現相關函數也不是不可以。其實使用隊列主要就是對數據的兩次判斷,一次是判空,一次是判滿,
判空指的是:讀的速度很快,接受的很慢。
判滿指的是:讀的速度或者處理的速度比較慢,接收的就很快。
也就是說在裝填的時候,我們怕隊列滿,所以需要判滿。
在讀取的時候,我們怕隊列空,所以需要判空。
對于使用隊列作為數據緩沖,是嵌入式開發中最常見的形式。但在超高吞吐量(雙緩沖)、極低延遲(無鎖緩沖)或**極簡需求(靜態變量)**時需選用替代方案。
先進先出(FIFO) ,First-in First-out。
隊列數據必須包含四個成員:
隊頭索引、隊尾索引、隊列長度、隊列緩存數組,其他的可以根據自行需要進行適當擴展。
并且在使用的時候必須要進行隊列判滿和判空 判斷。
緩沖區不是免檢區。
-
數據完整性的保障?
-
?隊列滿時寫入?:若不判斷隊列滿狀態,新數據會覆蓋未處理的舊數據,導致數據丟失(如傳感器實時數據流被覆蓋)。
-
?隊列空時讀取?:若未判斷空狀態,可能讀取無效數據或隨機內存值,引發程序邏輯錯誤。
-
-
?資源沖突與系統穩定性?
-
?緩沖區溢出?:持續寫入未判滿會導致緩沖區溢出,可能破壞相鄰內存區域(如棧溢出),造成系統崩潰。
-
?多線程/中斷場景?:在中斷服務程序(ISR)中,若未同步判滿/空邏輯,可能因競爭條件(Race Condition)引發數據錯亂。
-
-
?性能與效率優化?
- ?避免無效操作?:判空可跳過無意義的出隊操作,減少CPU浪費;判滿可觸發流控機制(如暫停生產者線程),避免忙等待。
在C語言中,?取模(Modulo)和取余(Remainder)本質上是同一個操作,均使用 %
運算符實現,且語言標準未明確區分兩者。但在處理負數時,C語言的取余行為與數學上的模運算存在差異。也就是需要手動去處理這個取模和取余的區別。因為取模必須為正。
在正數的時候取模和取余是沒有任何區別的。因為都是正數,不存在有什么正負號區別。
C語言取余規則(%
運算符)
符號規則?:余數的符號始終與被除數(第一個操作數)相同,與除數無關。
- 示例:
-10 % 3 = -1
(被除數-10
為負 → 結果負)10 % -3 = 1
(被除數10
為正 → 結果正)-10 % -3 = -1
(被除數-10
為負 → 結果負)
模運算是得到的結果是非負的。
![[Pasted image 20250717150958.png]]
所以在負數取模和取余是有區別的
![[Pasted image 20250717151027.png]]
模運算一定是正的結果。
取余運算要根據被除數正負號來確定。
模運算或者取余對于正數來說可以回到起點,這樣就形成了一種將線性數組映射為環形結構,相當于原本是一個線性的可能需要無線遞增的數組,當我定義了兩個指針以后,尾指針或者是頭指針遍歷到最后一個以后,我取模運算,這樣相當于是數組的下標又變成了零,這樣就又可以重新填充數據或者是覆蓋原來數據,等于可以循環使用這個數組。這是因為數學的取模運算實現了循環。也就是這句話:”循環隊列通過模運算將線性數組映射為環形結構。“
何為映射,描述了兩個集合元素之間的一種定向對應關系。循環隊列通過模運算將線性數組映射為環形結構,應該怎么理解這個映射?
本質是通過數學同余關系在邏輯上實現索引的循環回繞,從而用普通數組模擬環形存儲。
模運算(a % n
)的核心性質是將任意整數映射到范圍 [0, n-1]
的同余類中。
- ?索引回繞公式?:
- 向后移動:
新索引 = (當前索引 + 步長) % 數組長度
- 向前移動:
新索引 = (當前索引 - 步長 + 數組長度) % 數組長度
- 向后移動:
- ?示例?(數組長度
n=5
):- 當前索引
4
,向后移動一步:(4+1) % 5 = 0
(回到數組起點) - 當前索引
0
,向前移動一步:(0-1+5) % 5 = 4
(跳至數組末尾)
- 當前索引
在實際使用過程中還會有一些問題:
假如我們設計的數組長度是10,那么其實使用數組下標訪問的時候是:09,那么如果我們訪問到最后一個的試試其實是a[9],那么使用這個數字進行取余或者取模操作得到的還是9,并不會回到原點,因此就需要將我們的idex手動+1取余操作。或者從另外一個角度,我們第一次訪問的肯定是0位置這個數據,但是接下來就需要訪問1這個位置的數據,好像不對這樣想~,不管如果是單純的回到原點都是可以的。
但是另外一點解決不了,那就是重合的時候不能區分哪一種情況。
第一種是讀的速度追上寫的速度,這樣就會造成空。
第二種是寫的速度追上讀的速度,這樣就會造成滿。
無外乎這兩種情況,所以要根據這兩個情況進行分析。
循環隊列分析及應用-CSDN博客
在分析讀的速度追上寫的速度的時候,此時的數組一定是有很多的空余空間的,所以寫的下一位一定是空的。
在分析寫的速度追上讀的速度的時候,此時的數組一定是沒有剩余空間了,并且此時兩個數組的下標是重合的。那么在下標重合之前一定有一個動作是寫位置和讀位置有一個空白間隔。那么這個==寫后面有一個空白位置其實就是和上一個讀的速度追上寫的速度情況保持了一致,也就是能否在這個情況下進行分析,從而找到區別這兩種情況的辦法。
為什么要這樣,我們的目的是控制變量法,就是區分相同情況的兩個事件,那么就一定需要找到他們兩個事件的共同點,從而在共同點的情況下去分析區別這兩個情況的,本例子的共同點就是:寫后面有一個空白位置==。
在第一種情況下,大前提一定是在讀數據的時候觸發的,那么此時只要滿足
QueueStatus_t QueuePop(QueueType_t *queue, uint8_t *pdata)
{if(queue->head == queue->tail){return QUEUE_EMPTY;}*pdata = queue->buffer[queue->head];queue->head = (queue->head + 1) % queue->size;return QUEUE_OK;
}
我們不用關心寫后面的空白位置,因為在這種空的情況下是一定滿足的,所以只需要這樣判斷那么一定就是該數據要空了。
接著在考慮第二種情況,此時的大前提一定是在寫數據的時候,這個時候注意了,寫數據的時候如果我們不干預,他會一只寫,畢竟任何限制都沒有無外乎進行循環操作。就算寫滿了空閑位置,還沒有來得及讀,一樣會繼續寫,覆蓋掉未處理的時候,只要不加限制。所以這種情況我們要人為的制造出來一個:寫后面有一個空白位置,因為只有這個保持一致,我們才能和第一種情況區分,否則沒有同維度下的判斷,我覺得類比是沒有意義的。
所以我們的代碼寫的思路首先是將寫數組的下標進行自增1。此時index指向的就是下一個當前寫數據的 下一個位置。
uint32_t index = (queue->tail + 1) % queue->size;
由于我們認為規定了,在寫數據的時候,相對于當前位置的下一個位置必須為空,這個寫有意義,否則就是沒意義的。那么相反當在寫數據的時候,相對于當前位置的下一個位置是當前正在讀數據的位置時候,就表明此時數組已經滿了。所以我們就找他的想反面,那我們為什么不直接找“在寫數據的時候,相對于當前位置的下一個位置必須為空”這種情況吶,因為這個情況不好把握呀,很多種情況都滿足,也就是說寫的時候不好控制,但是我們可以控制溢出條件,因為溢出條件只有一種,那就是“那么相反當在寫數據的時候,相對于當前位置的下一個位置是當前正在讀數據的位置時候,就表明此時數組已經滿了” 所以才有了這段代碼
uint32_t index = (queue->tail + 1) % queue->size;if (index == queue->head){return QUEUE_OVERLOAD;}queue->buffer[queue->tail] = data;queue->tail = index;return QUEUE_OK;
這個條件其實就是表明我現在寫數據的位置已經到了我們規定不能寫數據的位置,所以要退出函數。
如果覺得我的內容對您有幫助,希望不要吝嗇您的贊和關注,您的贊和關注是我更新優質內容的最大動力。
專欄介紹
《嵌入式通信協議解析專欄》
《PID算法專欄》
《C語言指針專欄》
《單片機嵌入式軟件相關知識》
《FreeRTOS源碼理解專欄》
《嵌入式軟件分層架構的設計原理與實踐驗證》
文章源碼獲取方式:
如果您對本文的源碼感興趣,歡迎在評論區留下您的郵箱地址。我會在空閑時間整理相關代碼,并通過郵件發送給您。由于個人時間有限,發送可能會有一定延遲,請您耐心等待。同時,建議您在評論時注明具體的需求或問題,以便我更好地為您提供針對性的幫助。
【版權聲明】
本文為博主原創文章,遵循 CC 4.0 BY-SA 版權協議。這意味著您可以自由地共享(復制、分發)和改編(修改、轉換)本文內容,但必須遵守以下條件:
署名:您必須注明原作者(即本文博主)的姓名,并提供指向原文的鏈接。
相同方式共享:如果您基于本文創作了新的內容,必須使用相同的 CC 4.0 BY-SA 協議進行發布。
感謝您的理解與支持!如果您有任何疑問或需要進一步協助,請隨時在評論區留言,筆者一定知無不言,言無不盡。