:CMake+Ninja+Renode從零構(gòu)建第一個程序)
1. 從“光看不練”到真正上手這篇到底要解決什么問題如果你跟著這個系列一路看過來前三篇大概率都在講環(huán)境、講工具鏈、講CMake和Ninja怎么配合、講Renode怎么把STM32F103“假裝”成一塊真芯片??赐曛笮睦锇W癢的想動手寫點東西結(jié)果發(fā)現(xiàn)——一行代碼都沒讓自己敲。這種感受我太懂了就像看別人釣魚看了三小時魚竿都沒摸到。這篇的核心目標很明確把前面鋪墊的所有工具鏈真正串起來讓你親手寫出第一個能在STM32上跑的C程序并且能在Renode里看到它“活”起來。不是點個燈就完事而是讓你理解為什么嵌入式C的項目要這么組織、CMake到底在背后干了什么、Renode是怎么在沒有硬件的情況下模擬出STM32F103的行為的。適合誰看如果你已經(jīng)裝好了CMake、Ninja、ARM工具鏈和Renode但不知道下一步該干嘛這篇就是為你寫的。如果你還沒裝好也沒關(guān)系我會在關(guān)鍵節(jié)點提醒你缺什么、怎么補。整個流程我會按照“一個合格嵌入式開發(fā)者最可能采用的方案”來走所有代碼和配置都可以直接抄作業(yè)。先說一下整體思路。嵌入式C項目和普通PC上的C項目最大的區(qū)別在于你的代碼最終要跑在一塊資源極其有限的芯片上。STM32F103C8T6只有64KB Flash和20KB RAM沒有操作系統(tǒng)沒有標準C運行時庫的完整支持甚至連new和delete都要你自己決定要不要用。所以項目結(jié)構(gòu)必須干凈、可控編譯產(chǎn)物必須精確到字節(jié)。這也是為什么我選擇CMakeNinja而不是Keil或者IAR。Keil當然能用但它的工程文件是二進制格式的沒法用Git做diff團隊協(xié)作時一個人改了配置另一個人根本看不出來。CMake是純文本的每一行配置都清清楚楚配合Ninja的構(gòu)建速度改一行代碼重新編譯只要幾百毫秒。更重要的是CMake能讓你把“編譯給STM32的代碼”和“編譯給PC上跑的測試代碼”放在同一個工程里管理這對嵌入式開發(fā)來說太重要了——你總不想每次改個算法都要燒到板子上才能驗證吧Renode的角色則是另一個關(guān)鍵。STM32F103這塊芯片現(xiàn)在價格波動大而且有時候手頭就是沒有硬件。Renode可以模擬它的Cortex-M3內(nèi)核、外設寄存器、中斷控制器甚至能模擬USART輸出。你編譯出來的elf文件直接丟給Renode就能跑串口輸出會打印到終端里。這意味著你可以在沒有實物的情況下完成80%的代碼邏輯驗證等硬件到了再燒進去做最終測試。注意Renode模擬的是芯片的行為不是電氣特性。比如你配置了一個GPIO輸出Renode會記錄這個引腳的狀態(tài)變化但你沒法用它驗證LED的亮度或者驅(qū)動電路的電流夠不夠。邏輯驗證用Renode電氣驗證必須上真板子。2. 項目骨架搭建從零開始組織一個嵌入式C工程2.1 目錄結(jié)構(gòu)設計背后的邏輯先看整個項目的目錄長什么樣。我試過很多種組織方式最后固定下來的是這一套stm32-cpp-demo/ ├── CMakeLists.txt # 頂層構(gòu)建腳本 ├── cmake/ │ ├── arm-gcc-toolchain.cmake # 交叉編譯工具鏈配置 │ └── stm32f103.cmake # 芯片相關(guān)的編譯選項 ├── src/ │ ├── main.cpp # 主程序入口 │ ├── startup_stm32f103.s # 啟動文件 │ └── system_stm32f103.c # 系統(tǒng)初始化 ├── include/ │ └── board.h # 板級定義 ├── linker/ │ └── stm32f103c8t6.ld # 鏈接腳本 ├── lib/ │ └── cmsis/ # CMSIS頭文件 └── test/ └── test_main.cpp # PC上跑的單元測試為什么這么分src放的是最終要燒進芯片的代碼test放的是在PC上跑的測試代碼兩者共享include里的頭文件。cmake目錄單獨放工具鏈配置是因為交叉編譯的配置和項目本身的構(gòu)建邏輯應該解耦——你換一塊芯片只需要改cmake目錄里的文件CMakeLists.txt主體不用動。linker目錄放鏈接腳本這個文件決定了你的代碼在Flash和RAM里怎么分布。STM32F103C8T6的Flash從0x08000000開始RAM從0x20000000開始這些地址必須和芯片手冊完全一致錯一個字節(jié)都跑不起來。2.2 CMakeLists.txt逐行拆解頂層CMakeLists.txt是整個項目的入口我把它拆成幾塊來看cmake_minimum_required(VERSION 3.20) project(stm32_cpp_demo LANGUAGES C CXX ASM) set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)第一行要求CMake最低版本3.20因為后面用到了target_link_options的一些新特性。LANGUAGES C CXX ASM三個都要開C用于CMSIS和啟動文件C用于業(yè)務邏輯ASM用于啟動文件里的匯編部分。C標準選17而不是20是因為ARM GCC對C20的支持在嵌入式場景下還不夠穩(wěn)定17已經(jīng)夠用了——constexpr、if constexpr、結(jié)構(gòu)化綁定這些都能用編譯出來的代碼體積也可控。if(CMAKE_CROSSCOMPILING) include(${CMAKE_SOURCE_DIR}/cmake/stm32f103.cmake) else() add_executable(test_runner test/test_main.cpp) target_include_directories(test_runner PRIVATE include) endif()這段是關(guān)鍵通過CMAKE_CROSSCOMPILING變量判斷當前是交叉編譯還是本機編譯。交叉編譯時加載芯片相關(guān)的配置本機編譯時只構(gòu)建測試程序。這樣你在PC上敲cmake -B build cmake --build build就能跑測試敲cmake -B build-arm -DCMAKE_TOOLCHAIN_FILEcmake/arm-gcc-toolchain.cmake cmake --build build-arm就能編譯出給STM32的固件。2.3 交叉編譯工具鏈配置的坑cmake/arm-gcc-toolchain.cmake這個文件我踩過不少坑最終版本是這樣的set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR arm) set(TOOLCHAIN_PREFIX arm-none-eabi-) set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g) set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy) set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)CMAKE_SYSTEM_NAME設為Generic而不是Linux因為STM32上根本沒有操作系統(tǒng)。CMAKE_TRY_COMPILE_TARGET_TYPE設為STATIC_LIBRARY是為了讓CMake在檢測編譯器時不去嘗試鏈接可執(zhí)行文件——交叉編譯環(huán)境下鏈接可執(zhí)行文件會失敗因為缺少目標平臺的C庫。CMAKE_C_FLAGS和CMAKE_CXX_FLAGS里必須加-mcpucortex-m3 -mthumb這是Cortex-M3的指令集配置。-mthumb表示使用Thumb指令集STM32F103只支持Thumb不支持ARM模式。忘了加這個編譯出來的代碼直接跑飛。還有一個容易忽略的點-fno-exceptions和-fno-rtti。嵌入式C默認不開異常和運行時類型識別因為這兩個特性會顯著增加代碼體積和運行時開銷。STM32F103的64KB Flash經(jīng)不起這么折騰。如果你確實需要異常處理可以開但要做好代碼體積翻倍的準備。3. 第一個C程序從啟動文件到main函數(shù)3.1 啟動文件里到底發(fā)生了什么startup_stm32f103.s這個匯編文件是芯片上電后執(zhí)行的第一段代碼。它主要干三件事初始化棧指針、初始化中斷向量表、調(diào)用SystemInit和main。棧指針的初始值寫在鏈接腳本里通常是RAM的末尾地址。STM32F103C8T6的RAM是20KB從0x20000000到0x20005000所以棧頂初始值是0x20005000。棧是向下生長的第一個壓棧的數(shù)據(jù)會放在0x20004FFC。中斷向量表的第一個條目是棧頂?shù)刂返诙€條目是復位處理函數(shù)的地址。復位處理函數(shù)里先調(diào)用SystemInit配置時鐘然后調(diào)用__libc_init_array初始化C的全局對象最后跳轉(zhuǎn)到main。注意如果你在C里定義了全局對象它的構(gòu)造函數(shù)會在main之前被調(diào)用。這意味著構(gòu)造函數(shù)里不能依賴任何需要main初始化之后才能用的資源。我見過有人在全局對象的構(gòu)造函數(shù)里初始化串口結(jié)果串口時鐘還沒使能直接卡死。3.2 用C寫一個GPIO翻轉(zhuǎn)程序現(xiàn)在到了真正寫代碼的環(huán)節(jié)。目標很簡單讓STM32F103的PC13引腳以1Hz的頻率翻轉(zhuǎn)。PC13是很多最小系統(tǒng)板上LED連接的引腳。#include cstdint namespace { constexpr std::uint32_t RCC_APB2ENR 0x40021018; constexpr std::uint32_t GPIOC_CRH 0x40011004; constexpr std::uint32_t GPIOC_ODR 0x4001100C; constexpr std::uint32_t GPIOC_BASE 0x40011000; inline void set_bit(volatile std::uint32_t reg, std::uint32_t bit) { reg | (1u bit); } inline void clear_bit(volatile std::uint32_t reg, std::uint32_t bit) { reg ~(1u bit); } } int main() { // 使能GPIOC時鐘 auto rcc_apb2enr *reinterpret_castvolatile std::uint32_t*(RCC_APB2ENR); set_bit(rcc_apb2enr, 4); // 配置PC13為推挽輸出最大速度2MHz auto gpioc_crh *reinterpret_castvolatile std::uint32_t*(GPIOC_CRH); gpioc_crh ~(0xFu 20); gpioc_crh | (0x2u 20); auto gpioc_odr *reinterpret_castvolatile std::uint32_t*(GPIOC_ODR); while (true) { set_bit(gpioc_odr, 13); for (volatile std::uint32_t i 0; i 500000; i) {} clear_bit(gpioc_odr, 13); for (volatile std::uint32_t i 0; i 500000; i) {} } }這段代碼有幾個值得說的點。第一所有寄存器地址都用constexpr定義編譯期就能確定不占RAM。第二用reinterpret_cast把地址轉(zhuǎn)成volatile uint32_t*volatile告訴編譯器這個值可能被硬件修改不要做優(yōu)化。第三set_bit和clear_bit用inline修飾避免函數(shù)調(diào)用開銷。延時用空循環(huán)實現(xiàn)volatile修飾循環(huán)變量防止編譯器把整個循環(huán)優(yōu)化掉。500000次循環(huán)在72MHz主頻下大約是幾十毫秒具體值需要用示波器或者邏輯分析儀測一下才能精確。3.3 鏈接腳本的關(guān)鍵配置stm32f103c8t6.ld決定了代碼和數(shù)據(jù)在內(nèi)存里的布局MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K } SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) } FLASH .data : { *(.data*) } RAM AT FLASH .bss : { *(.bss*) *(COMMON) } RAM }.isr_vector段必須放在Flash的最前面因為Cortex-M3上電后從0x08000000讀取棧頂?shù)刂泛蛷臀幌蛄?。KEEP防止鏈接器把這個段優(yōu)化掉。.data段的AT FLASH表示初始化數(shù)據(jù)存在Flash里運行時拷貝到RAM。.bss段不占Flash空間啟動文件里會把它清零。注意鏈接腳本里的LENGTH必須和芯片實際容量一致。STM32F103C8T6是64KB Flash和20KB RAM如果你寫成了128KB編譯能過但燒進去之后超出部分的數(shù)據(jù)會丟失表現(xiàn)為程序隨機崩潰。4. Renode模擬運行與調(diào)試實戰(zhàn)4.1 Renode腳本編寫要點Renode需要一個.resc腳本來描述硬件平臺。針對STM32F103C8T6核心配置如下mach create stm32f103 machine LoadPlatformDescription platforms/cpus/stm32f103.repl sysbus LoadELF build-arm/stm32_cpp_demo.elf showAnalyzer sysbus.uart1 startLoadPlatformDescription加載的是Renode自帶的STM32F103平臺描述文件里面定義了內(nèi)存映射、外設寄存器、中斷控制器。LoadELF把編譯出來的elf文件加載到模擬的內(nèi)存里。showAnalyzer打開UART1的分析窗口串口輸出會顯示在這里。如果你想讓Renode在特定地址暫??梢约觭ysbus.cpu PC 0x08000100這樣的斷點命令。Renode支持單步執(zhí)行、查看寄存器、查看內(nèi)存基本調(diào)試功能都有。4.2 用UART輸出驗證程序運行光看GPIO翻轉(zhuǎn)在Renode里不太直觀加個串口輸出更實在void uart_init() { // 使能USART1和GPIOA時鐘 auto rcc_apb2enr *reinterpret_castvolatile std::uint32_t*(0x40021018); set_bit(rcc_apb2enr, 2); // GPIOA set_bit(rcc_apb2enr, 14); // USART1 // PA9配置為復用推挽輸出 auto gpioa_crh *reinterpret_castvolatile std::uint32_t*(0x40010804); gpioa_crh ~(0xFu 4); gpioa_crh | (0xBu 4); // 波特率11520072MHz主頻 auto usart1_brr *reinterpret_castvolatile std::uint32_t*(0x40013808); usart1_brr 0x271; // 使能USART1發(fā)送和接收 auto usart1_cr1 *reinterpret_castvolatile std::uint32_t*(0x4001380C); usart1_cr1 (1u 13) | (1u 3) | (1u 2); } void uart_send_char(char c) { auto usart1_sr *reinterpret_castvolatile std::uint32_t*(0x40013800); auto usart1_dr *reinterpret_castvolatile std::uint32_t*(0x40013804); while (!(usart1_sr (1u 7))) {} usart1_dr c; }波特率計算72MHz / (16 * 115200) 39.0625取整39小數(shù)部分0.0625 * 16 1所以BRR (39 4) | 1 0x271。這個計算過程在參考手冊里有公式但實際調(diào)試時建議用示波器測一下實際波特率誤差超過3%就可能通信失敗。在Renode里運行后UART分析窗口會顯示你發(fā)送的字符。如果什么都沒顯示先檢查時鐘使能位有沒有寫對再檢查GPIO配置是不是復用模式。4.3 常見問題排查表現(xiàn)象可能原因排查方法Renode加載elf后立即停止棧頂?shù)刂放渲缅e誤檢查鏈接腳本RAM起始地址UART無輸出時鐘未使能或波特率錯誤用Renode的sysbus.usart1命令查看寄存器程序跑飛中斷向量表未對齊確認.isr_vector段在Flash起始位置編譯報錯undefined reference to _exit缺少newlib的syscall樁添加-specsnosys.specs鏈接選項代碼體積超過64KB開啟了異?;騌TTI檢查-fno-exceptions -fno-rtti-specsnosys.specs這個鏈接選項特別重要。ARM GCC默認鏈接newlibnewlib里有些函數(shù)依賴操作系統(tǒng)提供的系統(tǒng)調(diào)用比如_write、_sbrk。裸機環(huán)境下沒有這些系統(tǒng)調(diào)用鏈接就會報錯。nosys.specs提供了一套空實現(xiàn)讓鏈接能通過。5. 從能跑到好用工程化改進與經(jīng)驗總結(jié)5.1 把寄存器操作封裝成類直接操作寄存器地址雖然直觀但代碼一多就亂。用C的模板和constexpr可以封裝出零開銷的抽象template std::uint32_t Addr struct Register { static volatile std::uint32_t value() { return *reinterpret_castvolatile std::uint32_t*(Addr); } static void set(std::uint32_t bits) { value() | bits; } static void clear(std::uint32_t bits) { value() ~bits; } static bool read(std::uint32_t bits) { return value() bits; } }; using RCC_APB2ENR Register0x40021018; using GPIOC_CRH Register0x40011004; using GPIOC_ODR Register0x4001100C;這樣寫出來的代碼是RCC_APB2ENR::set(1u 4)比裸地址清晰得多。而且因為全是static函數(shù)和constexpr地址編譯出來的匯編和直接寫地址完全一樣沒有任何額外開銷。5.2 用constexpr做編譯期檢查C17的if constexpr可以在編譯期做條件判斷這對嵌入式開發(fā)很有用template typename T constexpr bool is_valid_gpio_pin(T pin) { return pin 0 pin 15; } template int Pin void toggle_led() { static_assert(is_valid_gpio_pin(Pin), GPIO pin must be 0-15); GPIOC_ODR::value() ^ (1u Pin); }static_assert在編譯期檢查引腳號是否合法不合法直接編譯報錯不會等到運行時才發(fā)現(xiàn)問題。這種“把錯誤提前到編譯期”的思路在嵌入式開發(fā)里特別有價值因為運行時調(diào)試的成本遠高于編譯期。5.3 我踩過的幾個坑第一個坑啟動文件里的棧大小。默認的啟動文件棧大小是1KB如果你的程序里有遞歸或者大數(shù)組棧會溢出。溢出的表現(xiàn)是程序隨機崩潰而且很難定位。我的做法是把棧大小改成4KB在鏈接腳本里顯式定義_estack符號。第二個坑Renode的時鐘模擬。Renode默認的時鐘頻率和真實芯片可能不一致導致延時函數(shù)的實際時間和預期不符。如果你在Renode里測出來1秒的延時燒到板子上可能只有0.8秒。解決辦法是在Renode腳本里顯式設置時鐘頻率sysbus.cpu Frequency 72000000。第三個坑CMake的target_include_directories順序。CMake會按照你添加的順序搜索頭文件如果兩個目錄里有同名頭文件先添加的會被優(yōu)先使用。我遇到過CMSIS頭文件和自己的board.h沖突的情況排查了半天才發(fā)現(xiàn)是包含順序問題。建議把自己的頭文件目錄放在CMSIS之前。第四個坑Ninja的并行編譯。Ninja默認用所有CPU核心并行編譯這在大多數(shù)時候是好事但如果你的CMake腳本里有生成代碼的步驟比如從.svd文件生成寄存器定義并行編譯可能導致生成步驟和編譯步驟競爭。解決辦法是用add_dependencies顯式聲明依賴關(guān)系。5.4 后續(xù)可以怎么擴展這套框架跑通之后擴展方向很多。你可以加FreeRTOS把任務創(chuàng)建和調(diào)度用C的類封裝起來可以加單元測試框架在PC上驗證算法邏輯可以用constexpr和模板元編程在編譯期計算查表數(shù)據(jù)減少運行時開銷。我個人比較推薦的一個擴展是用C的強類型枚舉替代宏定義。比如GPIO模式enum class GPIOMode : std::uint32_t { InputAnalog 0x0, InputFloat 0x4, OutputPP 0x2, OutputOD 0x6, AfPushPull 0xA, AfOpenDrain 0xE, };這樣編譯器會幫你檢查類型不會出現(xiàn)把輸入模式傳給輸出配置這種低級錯誤。而且enum class不會隱式轉(zhuǎn)換成整數(shù)必須顯式static_cast進一步減少誤用。最后再分享一個小技巧在CMake里加一個size目標編譯完自動顯示固件占用的Flash和RAM大小add_custom_target(size COMMAND ${CMAKE_SIZE} $TARGET_FILE:stm32_cpp_demo DEPENDS stm32_cpp_demo )每次編譯完敲ninja size就能看到當前固件占了多少空間離64KB上限還有多少余量。這個習慣能幫你避免“功能寫完了發(fā)現(xiàn)放不下”的尷尬。