十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从C语言main到STM32启动流程:谁把控制权交给main

从C语言main到STM32启动流程:谁把控制权交给main 大一那年用 Dev-C 敲下第一个 Hello World我盯着main函数问了自己一个特别蠢的问题是谁在调用它那时候没人给答案只知道编译能过、程序能跑。后来第一次接触 STM32代码里同样只有一个main但既没有操作系统也没有命令行烧进去一上电main就自己跑起来了中间还顺手把时钟、串口、GPIO 全配好了。这两件事看起来一样实际上从谁把控制权交给 main开始就已经是两个完全不同的世界。这篇内容聊的就是这条链路从 C 语言的main到 STM32 的main你的代码在中途到底经过了哪些手。它适合三类人看——刚学完 C 语言语法、对程序入口只有模糊概念的初学者从 PC 端开发转过来做 MCU、搞不清启动文件和链接脚本的同行以及手上有项目、程序偶尔卡在main之前、想搞清楚怎么查下去的人。涉及的C语言、main、STM32这几块我不会只讲结论会把向量表、启动文件、链接脚本、时钟初始化这几段实际发生的事掰开说。1. 两个 main 的入口差异从谁发入场券说起1.1 主机上的 main 是被人叫进来的在 Linux 或 Windows 上写int main(int argc, char* argv[])很多人以为这是程序的第一条指令其实不是。可执行文件被加载后真正的入口是 C 运行时的启动代码通常叫_start或者crt0。它做的事很杂把栈指针安顿好、把argc/argv/envp这几个参数从内核给的原始栈里整理成 C 函数能读的形式然后调用__libc_start_main由后者再去调用你的main。这个过程决定了主机上main的三个特点。第一参数是别人递过来的你不用管它从哪来。第二main里的return 0是有意义的它会回到__libc_start_main最终走到exit()把返回值交给父进程。第三main里的局部变量住在操作系统分配的栈上栈不够了系统会告诉你段错误而不是默默把别的数据踩掉。1.2 单片机上的 main 是被搬运工送进去的STM32 这边没有操作系统兜底。上电复位那一瞬间处理器内部只有极少数寄存器的值是被硬性规定好的其余全是未知。从这一刻到你的main拿到 CPU中间靠的是一段汇编写的启动代码加上链接器排布的一张内存地图两者配合完成。最直观的区别是主机的main是被叫起来的STM32 的main是被搬到正确位置之后直接跳过去的。没有调用者给它准备参数没有人接它的返回值它必须自己保证永远不返回。1.3 一张表看清两者的分岔对比项主机 C 程序STM32 裸机程序谁把控制权交给 main_start→__libc_start_main启动文件的Reset_Handlermain 的参数argc/argv/envp由内核与运行时准备通常无参void main(void)全局变量的初值加载器从可执行文件读入启动代码从 Flash 搬到 RAMmain 返回后回到运行时正常退出返回地址无意义会跑飞栈由谁定内核给一段虚拟地址空间链接脚本里一个符号地址时钟/外设内核和驱动已初始化完全空白要自己配这张表里最容易被忽略的是全局变量初值和栈由谁定两行。很多人第一次写 STM32 代码看到int flag 1;这种全局变量直接就能用以为理所当然其实背后有一段从 Flash 到 RAM 的拷贝动作漏掉它变量初值就是随机的。2. 上电第一拍MSP 和复位向量是怎么被硬件捞出来的2.1 Cortex-M 上电时的两个硬性约定Cortex-M 系列上电复位后硬件强制做两件事从地址0x00000000取第一个 32 位字写进主栈指针 MSP再取下一个字写进程序计数器 PC。第一个字叫初始栈顶值第二个字就是复位处理函数的地址。这就是为什么 STM32 的启动文件里向量表最开头的两个条目长得不像函数指针__Vectors: DCD __initial_sp ; 第 0 项栈顶地址 DCD Reset_Handler ; 第 1 项复位向量 DCD NMI_Handler DCD HardFault_Handler第 0 项__initial_sp是链接脚本里算出来的一个地址值不是代码。第 1 项才是真正跳过去执行的入口。这个设计挺巧妙硬件不需要知道有多少 RAM只需要把链接器算好的栈顶塞给 MSP栈立刻就能用了。2.2 向量表在文件里的真实样子STM32 的向量表默认落在 Flash 起始处。以 STM32F4 为例代码运行地址是0x08000000而0x00000000这个地址被映射到闪存区所以硬件从0x00000000读实际读到的是 Flash 里的内容。启动文件会把这个表放进一个叫.isr_vectorGCC或RESETKeil的段里链接脚本再把它固定在 Flash 最前面。向量表不只是复位向量它后面跟着几十个中断服务函数的地址SysTick_Handler、USART1_IRQHandler、TIM2_IRQHandler……你写了哪个哪个位置就有你的函数地址没写的启动文件里都填的Default_Handler通常是一个死循环。这个细节很重要后面排查问题时用得上。2.3 用调试器亲眼看一遍这段路径如果你想确认这段流程最直接的办法是在调试器里单步。步骤大概是这样在main函数第一行打个断点全速运行到停下。打开寄存器窗口看MSP的值再打开内存窗口跳到这个地址周围你应该能看到栈顶刚好在 RAM 末端附近比如 F407 是0x20020000附近具体看芯片 RAM 大小。打开反汇编窗口跳转到Reset_Handler符号处你会看到几段循环一段搬数据、一段填零、一段调用函数。在Reset_Handler开头下断点复位单步走一遍把R0/R1/R2寄存器看清楚它们分别指向源地址、目的地址和计数器。单步走一遍比看十遍文档管用。我第一次这么干的时候最大的收获是发现main前面真的跑了上百条指令而且这些指令跟业务逻辑一点关系都没有。注意不同厂商、不同工具链的启动文件差异很大。STM32F1 和 F4 的启动文件不同GCC 和 ARM Compiler 生成的代码逻辑也不同。看别人教程时一定要先确认自己用的芯片型号和编译器别直接抄。3. 启动文件里的三段搬家活.data、.bss 与构造函数表3.1 .data 段有初值的全局变量必须搬一次int counter 100;这种有初值的全局变量编译后它的初值存在 Flash 里但变量本身要住在 RAM 里因为可以被改写。启动代码要做的就是把这段初值从 Flash 拷贝到 RAM。GCC 的启动文件里通常是这样的循环LoopCopyDataInit: LDR r0, _sidata ; .data 在 Flash 里的起始地址 LDR r1, _sdata ; .data 在 RAM 里的起始地址 LDR r3, _edata ; .data 在 RAM 里的结束地址 MOVS r2, #0 B CopyDataInit CopyDataInit: LDR r4, [r0, r2] STR r4, [r1, r2] ADDS r2, r2, #4 CMP r2, r3 BCC CopyDataInit三个符号_sidata、_sdata、_edata全部来自链接脚本不是随便起的名字。_sidata是.data的加载地址Load Address在 Flash_sdata和_edata是运行地址Virtual Address在 RAM的起止。链接脚本里那句AT FLASH就是在告诉链接器这段数据运行在 RAM但存盘时放在 Flash。3.2 .bss 段没初值的变量只清零不占空间int buffer[1024];这种没写初值的全局变量默认值为 0。如果每个变量都在 Flash 里存一份 0几 KB 的缓冲区就要白白占几 KB 的 Flash太浪费。链接器就把这类变量归到.bss段只在 RAM 里分配空间Flash 里不存内容启动代码负责把它们全填 0。填零循环比搬运简单一般就是一个长度计数的写零操作。这里有个实践中的细节.bss的填零必须在.data搬运之后做因为两段在 RAM 里是相邻的如果顺序反了刚写好的 0 可能被.data的搬运覆盖。3.3 构造函数表C 全局对象的初始化时机如果你的工程里混了 C比如用了第三方库那还有一段叫__libc_init_array的代码要在main之前跑。它会遍历.init_array段把里面存的函数指针挨个调用一遍——这些指针正是各个全局对象的构造函数。顺序上__libc_init_array必须在SystemInit之后、main之前执行。有些自己手写的启动文件漏掉这一步症状就是C 全局对象的构造函数完全没被调用对象内部全是 0程序看着能跑逻辑就是不对劲。这个坑不好查因为编译能过、也没有报错。3.4 Keil 和 GCC 在这一步的分工完全不同用 KeilARM Compiler的时候启动文件里不会看到上面的搬运循环Reset_Handler最后调用的是__main而不是mainReset_Handler PROC LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP这个__main是 C 库的入口它内部会根据链接器生成的分散加载信息scatter file完成.data搬运和.bss清零然后再调用用户写的main。所以 Keil 下.data搬运的活儿是库干的不是启动文件干的而 GCC 下是启动文件里手写的汇编在干。理解了这一点再看到别人写的启动代码分析就不会混淆了。提示如果你把 GCC 工程的启动文件换成了 Keil 风格、没做搬运全部有初值的全局变量都会失效。这种问题不会在编译期暴露只在运行期表现为变量值莫名其妙非常难查。换个工具链或者参考别人的工程时启动文件和链接脚本要成对替换。4. SystemInit、HAL_Init 与时钟树main 之前被执行的配置4.1 SystemInit 做了什么又没做什么Reset_Handler里通常会调用SystemInit()这个函数在system_stm32fxxx.c里。它主要做三件事把中断向量表偏移寄存器SCB-VTOR设成 Flash 起始地址0x08000000使能 FPU如果你的芯片带浮点单元且编译器会生成 FPU 指令以及把 RCC 时钟寄存器恢复到复位默认状态。这里有个跨芯片系列的差异必须说清楚。STM32F1 的SystemInit在头文件里定义了SYSCLK_FREQ_72MHz之后会真的把 PLL 配上去让主频跑到 72MHz而 STM32F4 的SystemInit默认只把时钟恢复成 HSI 16MHz真正的 168MHz 配置放在main里的SystemClock_Config()。同一句SystemInit不同系列干的事不一样照抄代码很容易踩空。4.2 HAL_Init 和 SystemClock_Config 的分工CubeMX 生成的main开头固定是这几句HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init();HAL_Init()干的事相当基础设置 Flash 预取、配置 SysTick 为 1ms 节拍这是HAL_Delay能工作的前提、初始化底层硬件抽象层。注意它是用 HSI 跑的因为此时时钟还没配。SystemClock_Config()才是真正决定主频的地方。以 F407 用 8MHz 外部晶振跑 168MHz 为例HSE 8MHz 先除以 M8 得到 1MHz 的 VCO 输入再乘以 N336 得到 336MHz 的 VCO 输出最后除以 P2 得到 168MHz 的系统时钟。这三个参数任何一个写错主频就不对而主频不对最直接的后果就是串口波特率全错。4.3 串口乱码这个经典坑根因几乎都在这里几乎每个做过串口调试的人都遇到过程序能跑、LED 能闪就是串口打出来一堆乱码。绝大多数情况不是线接错了而是主频和波特率的分频算错了。举个数串口配置成 115200代码按 168MHz 去算分频系数但实际时钟还停在默认的 16MHz。115200 的分频比在两种主频下差了十倍以上接收端自然解不出正确的位。反过来如果你的工程是从别人那里拿来的、时钟配置被改成了 84MHz 而串口初始化还是按 168MHz 写的一样出乱码。排查顺序建议是先用示波器或者逻辑分析仪量一下实际波特率再用调试器读RCC-CFGR和RCC-PLLCFGR确认 PLL 生效最后才怀疑代码。另外还有一个隐蔽情况外部晶振起振失败时SystemClock_Config里的等待循环while(__HAL_RCC_GET_FLAG(RCC_FLAG_HSERDY) RESET)会一直卡住程序连main都进不去。这就是卡在 main 之前的一种典型原因下一节还会细说。5. main 不能返回的真相栈、堆与返回地址5.1 返回地址指向哪里在主机上main是被__libc_start_main调用的栈上压着一个合法的返回地址。而在 STM32 的启动文件里调用main之前那一段通常是BL main BX lrBL会把返回地址也就是BX lr那条指令的地址压进 LR 寄存器。如果main真的返回了程序就会跳回去执行BX lr然后……跳到 LR 里剩下的值——此时 LR 已经在函数返回过程中被用掉了指向一个不确定的地址。结果就是跑飞可能落在 Flash 的空白区可能落在Default_Handler的死循环里。所以裸机程序里main末尾必须是while(1)这不是风格问题是硬性要求。CubeMX 生成的模板在USER CODE END 3之后就是while (1) { }那个空循环就是给你填业务逻辑的地方。5.2 栈从哪长堆在哪谁说了算STM32 的栈一般是满递减的__initial_sp定在 RAM 的最高地址往里压数据时地址减小。这个值来自链接脚本里的_estack_estack ORIGIN(RAM) LENGTH(RAM);堆则从.bss结束的地方往上长和栈相向而行。CubeMX 生成的链接脚本里通常能看到这样的片段_Min_Heap_Size 0x200; /* 512 字节 */ _Min_Stack_Size 0x400; /* 1KB */这两个值只保证至少留这么多链接器会在链接时检查.bss加堆栈是否超出 RAM。如果你的.bss里放了几个大数组可能会看到region RAM overflowed的链接错误那就是链接器在提醒你 RAM 不够了。5.3 栈溢出是静默发生的栈溢出的可怕之处在于它不会报错。栈往下长一旦超出预留空间就会踩到.bss里的全局变量或者堆区表现为某个不相关的全局变量莫名其妙被改写。这种 bug 用断点很难抓因为发生位置和出错位置隔得很远。我常用的两个办法特征值填充法在链接脚本里把栈区预留得比实际需要大一些初始化后手动用0xA5A5A5A5填满整个栈区程序跑一段时间后从高地址往低地址扫找到第一个不是0xA5的位置就能估出栈的峰值用量。Keil 的调试器里也有类似的栈填充检查选项。禁用大局部数组局部变量的空间来自栈一个uint8_t buf[2048]的局部数组会瞬间吃掉半条栈。改写成static或者全局缓冲区空间就挪到.bss里去了。代价是失去了函数重入性在单线程裸机里通常可以接受。如果工程里跑了 RTOS还有更省事的办法uxTaskGetStackHighWaterMark()会告诉你某个任务栈的剩余最小值直接读它就行不用自己填特征值。提示栈大小定多少合理我的经验是先用填充法跑完所有功能场景包括中断最密集、递归最深的那条路径得到峰值再加 30% 余量。上来就写个大数字确实能跑通但会把 RAM 浪费掉尤其是 RAM 只有 20KB 的型号。6. 链接脚本才是真正的调度员内存地图与向量表重定位6.1 MEMORY 和 SECTIONS 两段话决定了所有事链接脚本由两块组成。MEMORY声明物理内存告诉链接器 Flash 和 RAM 在哪、多大MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K }SECTIONS决定各段怎么排。前面说的.isr_vector固定在 Flash 最前、.text紧随其后、.data运行在 RAM 但存于 Flash、.bss只在 RAM 分配全在这里定义。下面这个片段很关键.data : { _sdata .; *(.data) *(.data*) _edata .; } RAM AT FLASH _sidata LOADADDR(.data);RAM AT FLASH就是运行在 RAM、存放于 Flash的写法LOADADDR取出的是 Flash 中的存放地址。启动文件里搬运循环用到的三个符号全部由这几行产生所以启动文件和链接脚本是强绑定的缺一不可。6.2 有 bootloader 的时候向量表必须搬家做 OTA 升级的项目里Flash 会被切成两半前面一段放 bootloader后面一段放应用程序。应用程序的起始地址不再是0x08000000可能是0x08008000。这时候有三处必须一起改修改点位置作用链接脚本 ORIGINFLASH ORIGIN 0x08008000让程序从新地址开始排布向量表偏移SCB-VTOR 0x08008000让中断跳到 app 自己的处理函数跳转代码bootloader 里设置 MSP 再跳把控制权交给 app第二项最容易漏。VTOR 默认指向0x08000000如果不改app 里发生中断时处理器会去 bootloader 的向量表里找处理函数结果就是app 主循环跑得好好的一进中断就跑到别人的代码里去了。这种毛病的表现非常迷惑串口能发不能收、定时器中断没反应但单步调试一切正常。6.3 几个反复出现的链接问题RAM overflowed.bss太大或者栈堆预留太多。先看 map 文件里谁占得最多通常是一个不小心定义成全局的大数组。.data初值丢失启动文件没做搬运或者链接脚本漏了AT FLASH。症状是所有有初值的全局变量初始都是 0 或者随机值。中断函数名拼错USART1_IRQHandler写成USART1_Handler编译不报错因为链接器认为你定义了一个普通函数而向量表里那个位置仍然指向Default_Handler。打开 map 文件搜一下中断函数名看它有没有出现在向量表段里一眼就能确认。符号被优化掉某些中断处理函数只在向量表里被引用如果用了-ffunction-sections加--gc-sections静态库里的中断函数可能被当成垃圾回收掉。解决办法是在链接脚本里用KEEP()包住向量表段。7. 卡在 main 之前怎么查从现象到根因的排查链路7.1 先把现象分类程序有问题这个描述太粗得先缩小范围。我一般先问三个问题调试器能不能连上能连上之后 PC 停在哪LED 或者串口有没有任何输出现象大概率原因优先验证手段调试器连不上芯片供电异常、SWD 引脚被复用、芯片被读保护用万用表量电压拉高 BOOT0 后复位再连连上但 PC 停在Reset_Handler里某条跳转外部晶振起振失败、等待循环死等读RCC-CR的 HSERDY 位PC 停在Default_Handler触发了未实现的中断或中断函数名不匹配读SCB-ICSR看当前异常号停在HardFault_Handler空指针、非对齐访问、访问了未使能时钟的外设读SCB-CFSR和SCB-HFSR能进 main但外设没反应忘了开外设时钟、引脚复用没配读RCC-AHB1ENR等使能寄存器7.2 一次真实的排查复盘有个项目换了批次的板子同样的固件一半能跑一半跑不起来。现象是调试器能连上单步能进Reset_Handler但一直卡在SystemClock_Config里面出不来。第一步我在等待 HSE 就绪的那个循环前后各读了一次RCC-CR发现 HSERDY 位始终是 0。这说明外部晶振没起振。第二步怀疑是负载电容不匹配。查原理图发现新批次的板子把晶振的匹配电容从 20pF 换成了 12pF而晶振本身要求 20pF 左右。换回 20pF 之后一部分板子恢复了。第三步剩下还有几块不行。这时候把SystemClock_Config里的等待循环临时改成带超时的版本等待超过 100ms 就自动切回 HSI 并记录一个错误标志。这么改之后程序至少能跑起来通过串口把错误标志打出来确认确实是晶振问题而不是代码问题。这个案例给我的教训有两个。一是启动阶段的死等循环非常危险最好都加上超时和降级路径尤其是产品要批量出货的时候。二是排查这类问题不能只靠代码硬件参数晶振、电容、供电、复位电路都要纳入视野。7.3 三个必备的排查工具map 文件每次编译都会生成里面列出了每个段的大小、每个符号的地址。RAM 够不够、中断函数有没有进向量表、.data段多大全在里面。养成看 map 文件的习惯比在代码里瞎猜高效得多。反汇编调试器里打开反汇编窗口能看到 C 代码对应的实际指令。怀疑编译器把某段代码优化掉、或者想确认某个函数的真实地址时反汇编是唯一的真相来源。故障状态寄存器Cortex-M 的SCB-CFSR地址0xE000ED28、SCB-HFSR0xE000ED2C在进入 HardFault 时会被填上原因位。加上SCB-BFAR和MMFAR能直接告诉你是总线错误、存储器管理错误还是用法错误。比盯着反汇编猜快得多。8. 当 main 遇上 RTOS入口函数角色的第二次改写8.1 main 从干活的人变成安排活的人裸机程序里main通常是个while(1)里面轮询各种标志。上了 FreeRTOS 或者别的实时内核之后main的形态完全变了int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); xTaskCreate(task_sensor, sensor, 256, NULL, 3, NULL); xTaskCreate(task_comm, comm, 512, NULL, 2, NULL); vTaskStartScheduler(); while (1) { } }调完vTaskStartScheduler()之后调度器接管 CPUmain本身再也不会被执行。函数末尾那个while(1)其实是个保险正常情况永远走不到——但按前面第 5 节讲的道理它必须存在。这里有个容易忽略的细节在 Cortex-M 上任务运行在 PSP进程栈指针上中断仍然使用 MSP。这样设计是为了让任务栈和中断栈彻底隔离任务栈溢出不会波及中断处理也方便上下文切换时只保存 PSP 相关的寄存器。8.2 栈的空间账要重新算一遍上 RTOS 之前整个程序共用一条栈上 RTOS 之后每个任务各有一条栈空间从同一个堆区切出去。以 STM32F103 只有 20KB RAM 的型号为例如果开三个任务、每个任务 512 字节栈再算上内核自己的开销和.bss里的缓冲区很快就不够用了。所以 RTOS 项目里定栈大小要更谨慎先用uxTaskGetStackHighWaterMark()测峰值再逐个调小。还有一个常被忽略的问题main返回后不用了它的栈去哪儿了在 FreeRTOS 的 Cortex-M 移植中启动调度器之后main使用的那块栈会被空闲任务接管复用所以你在main里定义的局部变量在调度器启动后就不再有效了。如果往main局部数组里存了任务要用的数据会出问题。8.3 一点经验从裸机迁到 RTOS最大的坑不是 API 用错而是同步机制。HAL_Delay()在任务里会阻塞整个任务但不会让出 CPU 给别的任务——它只是空转等待虽然 FreeRTOS 提供了HAL_Delay的替代实现可以做到让出但不同版本的 CubeMX 生成代码行为不同用之前一定要确认。更稳妥的做法是任务里统一用vTaskDelay()中断里用xQueueSendFromISR()而不是普通版本。还有一点RTOS 项目里的中断优先级配置有硬性约束调用 FreeRTOS API 的中断优先级数值必须高于configMAX_SYSCALL_INTERRUPT_PRIORITY对应的门限数值上更小。配错了不会立刻报错而是偶尔在中断里调用 API 时撞上断言或者直接跑飞属于那种跑一天没问题、跑一个月出一次的毛病。把整条链路走完回头再看那个最初的问题——你的代码后来去了哪里——答案其实很清楚从芯片上电取出的第一个地址开始经过向量表的两次取值、启动文件的三段搬运、SystemInit与时钟配置、构造函数表的遍历你的main始终是这条流水线的最后一站而不是起点。搞清楚前面发生了什么遇到卡在main之前的故障时才不会只能靠重启和换板子碰运气。
返回列表