ARTICLE DETAIL

资讯详情

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

从C语言main到STM32的main:芯片上电后的启动流程与启动文件解析

从C语言main到STM32的main:芯片上电后的启动流程与启动文件解析 你大概率写过这样一段代码#include stdio.h int main() { printf(hello world!\n); return 0; }它在你的电脑上安静地跑完输出了一行字你甚至不会去想“这个 main 到底是谁调起来的”。直到你开始玩 STM32新建一个工程把同样的main写进去烧到板子里然后发现灯不亮、串口没输出、程序好像飞了。于是你开始问一个很基础但很多人没搞清楚的问题——从 C 语言的main到 STM32 的main这段路到底是怎么走的这篇文章想讲清楚的就是从你在编辑器和 Keil 里写的那个main函数到芯片上电复位后真正执行的第一条指令中间到底发生了什么。适合刚学完 C 语言准备转向嵌入式的同学也适合已经写过几个 STM32 例程、但一直被启动文件、链接脚本、堆栈这些概念搞懵的开发者。1. 你以为的起点其实不是起点1.1 在 PC 上main 只是系统加载后的一个“入住员工”先回到 PC 上的 C 程序。你写的main当然是一个入口函数但“入口”这两个字是相对的。真实工作流程是这样的操作系统先通过加载器把可执行文件读进内存完成动态链接接着由 C 运行时库CRT里的启动代码接管它要初始化进程环境、准备好标准输入输出、解析命令行参数把这些参数填到argc和argv里最后才跳进你的main。换句话说PC 上main被调用之前至少有三层东西已经动了操作系统内核、动态链接器、C 运行时库。你以为自己写的是“第一个被执行的函数”其实你只是最后一个“被请上台的嘉宾”。这层关系很多 C 语言教材不会细讲因为标准 C 语言只规定main是程序入口但没说谁去调用main。在 PC 上这块脏活累活被编译器、链接器、操作系统联手隐藏了。1.2 main 本质上只是一个“被调用的函数”从 C 语言语法角度看main并没什么特殊魔法。它就是一个函数只不过名字从几十个候选标识符里被挑出来约定为“程序入口”。你甚至可以用函数指针指向它可以在代码其他地方调用它编译器并不拦着你。真正让main特殊的地方在于链接器去找它。链接器在链接阶段会检查入口符号main或者_mainCRTStartup有没有被定义。如果找不到就会报undefined symbol: main之类错误。很多第一次用 Keil 建工程的读者应该被这个报错教育过。但在单片机世界里还有一个更隐蔽的事实STM32 工程里让你产生困惑的main只是一个“约定俗成的名字”硬件压根不认识它。芯片上电后第一条指令根本不是main。1.3 PC 与 STM32 的底层差异加载 vs 上电PC 程序的入口要经历“加载——重定位——初始化环境——调用 main”这几大步而 STM32 这类裸机单片机没有操作系统在背后帮忙代码直接被烧录在 Flash 里上电复位的瞬间CPU 做的事情和我们印象中的“程序启动”完全是两套逻辑。一到了裸机环境你突然发现很多藏在水面下的东西全露出来了启动文件要你自己加堆栈大小要你自己配链接脚本要你自己理解外设时钟要你自己开。这就是为什么很多人在 PC 上写 C 语言写得挺顺一到 STM32 就摔跟头——不是 C 语言没学好而是“没人告诉你背后还有这么多层”。2. STM32 的 main 并不是第一个执行的代码2.1 上电复位后CPU 先看“向量表”STM32 基于 Cortex-M 内核它上电后的行为和个人电脑完全不同。Cortex-M 设计了一种非常简明的启动方式从固定地址读取两个值——初始的栈指针MSP和复位异常向量Reset_Handler。地址通常被映射到 Flash 起始地址0x08000000在 F1 系列里又可以映射到0x00000000这个别名区。向量表里存放的是异常服务函数地址。其中第 0 项是初始栈顶第 1 项是复位后要跳转的地址。CPU 上电后做的事情极其简单读第 0 项放入 SP读第 1 项放入 PC然后开始执行。换句话说硬件层面只知道“复位向量”它根本不知道main是什么。这块逻辑在 STM32 的启动文件.s中体现得最清楚。以 STM32F103 为例startup_stm32f10x_hd.s文件顶部就是Stack_Size EQU 0x400 AREA STACK, NOINIT, READWRITE, ALIGN3 Stack_Mem SPACE Stack_Size __initial_sp AREA RESET, DATA, READONLY EXPORT __Vectors __Vectors DCD __initial_sp DCD Reset_HandlerDCD __initial_sp就是向量表第 0 项DCD Reset_Handler就是向量表第 1 项。这串汇编并不是“造出来的仪式”而是芯片上电后真真实实要执行的第一份数据。2.2 startup 文件程序员自己当一次“操作系统”ST 提供的启动文件实际上充当了“极简操作系统引导程序”的角色。它做的事情包括定义栈空间和堆空间建立中断向量表提供复位函数Reset_Handler在跳转main之前调用SystemInit初始化时钟最后调用 C 库的__main注意前面有两个下划线不是你的main。Reset_Handler的典型代码是Reset_Handler PROC EXPORT Reset_Handler IMPORT __main IMPORT SystemInit LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP这里有个关键点它调的是__main不是main。__main是 C 库函数负责把程序需要的运行环境准备到位把只读的初始值复制到 RAM把未初始化的全局变量清零建立堆栈指针然后才调用你写在源码里的main。所以如果你在 Keil 工程里没有添加启动文件或者启动文件没参与链接编译器就会非常困惑它找不到__main的调用方也不知道__initial_sp该定位到哪里于是一堆“死去活来”的报错就会找上门。2.3 真正的 C 世界是从 __main 开始的当__main被调起来之后它会执行一个把你熟悉的 C 运行环境“缝合”起来的过程。这个过程在嵌入式里有个学名叫“运行时初始化”主要包含两部分把 Flash 里的 RW 数据有初始值的全局变量复制到 RAM把 ZI 数据初始值为 0 的全局变量对应 RAM 区域清零。为什么非得这么做因为 RAM 上电后是随机值Flash 里的数据可以长期保存。你把uint8_t count 10;写在源码里编译后这个 10 被存放在 Flash 某个只读区域程序启动阶段启动代码会把它搬运到 RAM 里对应的地址这样你在代码里读写count才有意义。等这段脏活干完后__main才会跳进你的main。这也是嵌入式里一个经典误区你觉得程序是从main开始的其实在main之前芯片已经被一大段“看不见的代码”反复操练过了。当然main一旦执行就意味着你的业务代码开始跑了。紧接着你会看到很多嵌入式程序写成一个死循环int main(void) { SystemInit(); GPIO_Config(); while (1) { // 业务逻辑 } }为什么是死循环因为裸机程序没有操作系统main一旦返回就失去意义了——没有谁来接收返回值PC 会乱跳紧接着触发 HardFault。这个问题下文还会细聊。3. 从 .c 到 .hex你的代码后来去了哪里3.1 编译四步走预处理、编译、汇编、链接在 PC 上你可能执行过gcc hello.c -o hello但你很少关心这一条命令背后发生了什么。到 STM32 开发里这个流程你必须理解因为 Keil、IAR、STM32CubeIDE 虽然把过程自动化了但报错时还是会狠狠考验你。一条标准 C 语言源文件变成芯片里的机器码大体经历四步预处理处理#include、#define、条件编译。你可以用编译器选项只输出.i文件看看宏展开后的内容往往比源文件大好几倍。编译把预处理后的 C 代码翻译成汇编。Keil 的 ARMCC 或者 GCC 的-S选项能输出.s汇编文件。汇编把汇编文件翻译成机器指令生成可重定位的目标文件.o。这个文件里的地址大多是“未定址”的需要链接阶段才能确定最终地址。链接把所有目标文件、启动文件、库文件组合在一起按照链接脚本指定的内存布局分配最终地址生成可执行文件通常是.axf或.elf。之后调试器和烧录工具再从这个可执行文件里提取出纯二进制数据转成.hex或.bin烧进 Flash。电脑端编译一个程序你不需要思考地址因为操作系统已经把虚拟内存安排得明明白白。但是在 STM32 里链接阶段分配地址是躲不掉的代码放在0x08000000起的 Flash 区域变量放在0x20000000起的 RAM 区域堆和栈再挤压剩下的空间。3.2 链接脚本与内存布局全局变量的“家”在哪里STM32 的链接脚本Keil 里叫分散加载文件.sct是一段“自我介绍”告诉链接器芯片上有哪些内存区域、怎么分配。以 F103 为例典型的散布文件长这样LR_IROM1 0x08000000 0x00080000 { ER_IROM1 0x08000000 0x00080000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }第一段LR_IROM1从0x08000000开始长度0x80000也就是 512KB正好对应 STM32F103ZET6 的 Flash 容量第二段RW_IRAM1从0x20000000开始长度0x10000对应 64KB RAM。链接器最终会把启动文件的RESET段放到最前面这样向量表在 Flash 起始地址CPU 复位后立刻能找到。你的main里定义的全局变量则被放到 RW 或 ZI 段也就是 RAM 区域。所以“变量从哪来”的答案很简单编译时给它分配一个 RAM 地址启动时把初始值复制过去或者清零。这里顺带解答一个常见的 C 语言疑惑为什么未初始化的全局变量默认值是 0因为启动代码会对 ZI 段做清零操作这不是 C 语言规范里的魔法是启动代码干的活。3.3 .hex 与 .bin烧进芯片的两种姿态STM32 烧录文件常见两种格式.hexIntel HEX 格式纯文本带地址、数据类型和校验和烧录器能自行识别起始地址对新手最友好。.bin纯粹的二进制镜像没有任何地址信息烧录时必须手动指定起始地址。如果你用串口 ISP 下载通常需要选.bin并且填对偏移地址。调试时用.axf或.elf因为它同时包含符号表调试器能反查出某个地址属于哪个函数、哪一行源码发布固件时用.hex或.bin体积更小不带调试信息。很多新手在 Keil 里找不到生成的.hex文件多半是没在 Options for Target - Output 里勾选 “Create HEX File”。这算一个不值钱但常见的坑。3.4 “编译器未包含 main 类型”与 Keil 工程的经典报错热词里有个一眼很奇怪的句子“编译器未包含 main 类型”。实际工程里意思往往是编译器没有找到用户定义的main或者链接器找不到入口符号。常见原因有这么几个工程里没有添加main.c或者源文件没被加入编译启动文件缺失。如果 Keil 工程里没有.s启动文件某些旧版本编译器会提示找不到入口main函数名写错比如写成mian源文件里有语法错误导致编译器提前放弃没有生成目标文件。排查思路很简单先编译看有没有语法错误再查工程树里启动文件和main.c是否在列最后看编译输出窗口能不能生成.axf和.hex。4. main 里面和 main 外面嵌入式开发真正的主战场4.1 在 STM32 里main 是个“后场组织者”你在 STM32 里写的main通常不会像 PC 那样写完整套业务逻辑再退出。它更像个“后场组织者”先初始化时钟再配置 GPIO、串口、定时器等外设最后钻进一个while(1)把主要逻辑放在循环里或者交给中断。拿最基础的点灯来说代码路径大致是int main(void) { RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_5); while (1) { } }这里最容易被新手忽略的是第一句RCC_APB2PeriphClockCmd(GPIOA, ENABLE)。STM32 的外设时钟默认是关闭的你没开 GPIOA 的时钟后面配置寄存器全部无效灯自然不亮。C 语言基础再扎实不理解外设时钟树照样卡在这。很多爱钻牛角尖的人会问为什么 RCC 要这么设计答案是为了省电。芯片上所有外设的时钟都常开待机功耗会很难看。所以 STM32 把时钟开关做成“门”你用到谁就打开谁。这里的main不仅仅是个函数入口它还要扮演“调度员”的角色把需要的硬件资源都激活起来。4.2 中断你的 main 跑得“看起来像并行”while(1)看起来像是一个死循环但实际上在 MCU 内部Stm32 的代码执行经常被中断打岔。定时器溢出、串口接收、外部按键任何一个事件都可能触发中断暂停主循环跳进中断服务函数执行完再回来。很多嵌入式程序的真正主逻辑反而不在main的循环里void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { // 处理定时任务 TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }这种机制导致一个事实你的代码会被“零零散散”地执行。main只是给了整个系统一个稳定的主频循环但真正响应实时性要求的任务都靠中断来实现。这也是嵌入式开发和纯 PC 端 C 语言开发在逻辑思维上一个巨大的差异你不再只写一个顺序执行的程序而是同时管理一个主循环和无数个中断上下文。4.3 从 main 到 RTOS执行权交出去了如果你进一步使用 FreeRTOS你会发现main的形态又变了int main(void) { xTaskCreate(Task1, Task1, 128, NULL, 1, NULL); xTaskCreate(Task2, Task2, 128, NULL, 1, NULL); vTaskStartScheduler(); while (1); }vTaskStartScheduler()启动调度器之后正常情况下不会返回。你的main从此“功成身退”真正的业务逻辑都跑在创建的任务里。这时候回头再看“你的代码后来去了哪里”你会意识到代码去了任务控制块、调度器、各种就绪列表里而main不过是把这些串起来的那根线。这其实和 PC 端有点类似了——你已经把“程序的展开方式”委托给了一个更上层的东西。只不过在 PC 上是操作系统来调度线程在 MCU 上是你自己烧进去的实时操作系统内核来调度任务。4.4 bootloader 与 app相当于第二个 main 的大门在支持固件升级的 STM32 工程里Flash 通常被划分为 Bootloader 区和 App 区。Bootloader 里也有一个mainApp 里也有一个main。Bootloader 先跑检查是否有升级请求如果不需要升级就跳转到 App 的复位向量处。跳转代码核心就这几行typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); __set_MSP(app_sp); pFunction jump (pFunction)app_pc; jump(); }这里读的正是 App 区向量表的前两个 Word第一个是 App 的初始栈顶第二个是 App 的 Reset_Handler。跳转后App 自己的启动流程重新来一遍又走一遍SystemInit和__main最终进入 App 的main。看到这里你应该明白main在嵌入式中并不是一个不可替代的东西。从硬件角度来看它只是一个被__main调用的普通函数从软件架构角度来看它只是你工程里若干函数之一。真正决定“执行权去哪”的是驻留在 Flash 里的向量表和启动代码。4.5 从 main 开始理解你写出的每一行 C把视角放到整个系统上你就能理解很多 C 语言的点在嵌入式中为什么变得很重要。比如指针寄存器操作本质就是往特定地址写数据GPIOA-ODR这个概念背后就是一个地址函数指针中断向量表本质上就是一个函数指针数组内存管理堆和栈的分配直接影响程序能否稳定运行。这也是为什么好多面试官爱问“从main出发描述 STM32 启动流程”。因为一个问题就能看出你是只掌握了 C 语法还是真正理解了 C 语言和硬件之间的关系。5. 常见问题与排查技巧实录5.1 启动文件选错或漏选导致的“启动失败”STM32 型号很多启动文件也按容量和内核分成多个版本比如startup_stm32f10x_ld.s、startup_stm32f10x_md.s、startup_stm32f10x_hd.s。如果你把 F103C8T6中容量的工程装到 F103ZET6高容量板子上最常见的问题就是程序上电后完全不跑或者跑飞进 HardFault。建议做法建工程时确定型号后直接打开对应启动文件看一眼顶部注释确认是当前芯片的启动文件。很多人以为启动文件是 Keil 自动生成的其实 ST 标准外设库例程里已经给你分好了复制时千万别拿错。5.2 堆栈太小导致 main 里莫名“翻车”startup文件里默认Stack_Size通常给0x400即 1KBHeap_Size给0x200。如果业务里用了比较大的局部数组或者递归函数比较多栈很容易爆。栈溢出后程序表现千奇百怪变量值莫名被改、HardFault、卡死在某个中断。处理办法把Stack_Size适当调大比如0x1000同时可以打开编译器的栈使用报告。遇到“跑着跑着死了”的故障先检查局部变量和中断嵌套深度比瞎查逻辑要快得多。5.3 main 里没有 while(1)程序到底会怎样有读者在调试时故意让main执行到末尾不写死循环结果程序跑飞。原因是main返回后没有系统接管CPU 会继续执行main后面的地址——那可能是一堆残存的库函数或随机数据结果就是硬件异常。嵌入式开发里main的末尾即便逻辑上没问题也建议保留一个while(1)。这不仅是规范更是保护。5.4 GPIO 不输出、串口没反应时先查 RCC 和复用功能新手调 STM32最常犯的错误是只配 GPIO 不配时钟。还有一类容易坑人的是复用功能你要用 USART1 的 TX/RX 引脚光把引脚配成推挽输出是不够的必须把引脚模式设成GPIO_Mode_AF_PP复用推挽或者通过 AFIO 重映射。排查顺序建议先查时钟有没有使能再查引脚模式是否和复用功能匹配最后用示波器或万用表量引脚电平。大部分外设不工作都栽在这三步上。5.5 printf 重定向与 MicroLIB串口输出的经典劝退坑不少人在 STM32 上想用printf输出调试信息却遇到程序卡死在BKPT 0xAB。这是因为标准 C 库的printf默认走“半主机模式”这个模式通过调试器模拟 I/O在裸机上没有实现程序就会停住。常用两种解法在 Keil 里勾选 “Use MicroLIB”微库裁剪了半主机实现配合重写fputc即可重写fputc函数把它定向到串口发送寄存器。int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }这种方法在实际项目里非常常用。看到printf没输出不要先怀疑硬件十有八九是这个半主机问题。5.6 中断服务函数里不要放耗时操作实际项目里有人把大运算、长延时、甚至printf直接塞进HAL_UART_RxCpltCallback这类中断回调里结果导致主循环卡顿、中断嵌套爆栈。中断服务函数的原则是尽快把数据搬到缓冲或置个标志位真正的处理放回主循环。那种“按下按键程序就死机”的问题很多都是中断里干了太多事CPU 根本没有机会回到主循环去响应其他事件。C 语言本身不会教你系统设计但嵌入式的坑会教你做人。个人经验的一点收尾我到现在还是觉得main这个名字在嵌入式世界里是个“可爱又迷惑人的约定”。它让我们从 PC 编程迁移过来时有了一个熟悉的抓手但也正因为这个名称导致很多人忽略了它背后真正的启动机制。我见过不少工作一两年的同事调 Bootloader 跳转时还在问“为什么跳转后 main 不跑”其实只要沿着向量表往下追答案自己就浮出来了。如果你刚接触 STM32我特别建议你做一件事拿到一个新板子后不要着急写业务代码先打开启动文件照着向量表把SystemInit和__main这两条调用链读一遍。然后再去读链接脚本看 Flash 和 RAM 的分配情况。这个过程不复杂但能让你在面对所有“跑飞、没输出、进 HardFault”问题时都有个清晰的下手方向。C 语言那套语法在编译后终究要落实为一段段“住在芯片地址里的数据”。理解了这一点你就不会被 STM32 这种看起来很复杂的东西吓到了。
返回列表