[其它产品/技术] 单片机从上电到 main()函数之间到底发生了什么?

[复制链接]
 楼主| 星辰大海不退缩 发表于 2025-5-26 15:49 | 显示全部楼层 |阅读模式
今天我们来聊一个容易被忽略的话题——从 MCU 芯片上电到 main() 函数运行,到底发生了什么?这篇文章,我们从零开始,手把手带你搞懂 MCU 的裸机 C 启动过程,顺便点亮一块开发板上的 LED 小灯,体验一下从“裸奔”到“优雅运行”的快感。


嵌入式开发的“潜规则”

如果你玩过单片机开发,尤其是基于 Cortex-M 系列的芯片,估计对下面这些“铁律”再熟悉不过:

程序入口必须叫 main。
全局静态变量得老老实实初始化,不然芯片会“擅自”给你清零。
中断处理函数一个都不能少,尤其是 HardFault_Handler 和 SysTick_Handler,得写得妥妥的。
每次提到这些规则,很多人都会一脸懵:“这到底咋来的?谁定的?”答案通常藏在那些让人头晕的启动文件中——一堆汇编代码,从一个项目复制粘贴到另一个项目,基本没人认真读,更别提改了。

今天,我们就来把这层神秘面纱掀开!从MCU 上电到 main() 函数运行的每一步,咱们都要搞得明明白白。不仅要弄懂,还要自己动手写一个最简洁的启动流程,彻底把裸机 C启动 的“前世今生”整清楚!
 楼主| 星辰大海不退缩 发表于 2025-5-26 15:52 | 显示全部楼层

实验环境


为了让大家能跟得上,我们选用课程32位单片机开发板来做实验,Cortex-M4 内核,性能强劲,性价比超高,特别适合用来学习。

实验环境如下:

硬件:Cortex-M4 内核的开发板
调试工具:Jlink V11
实验目标很简单:写个程序,让开发板上的 LED 小灯闪烁。代码不复杂,但麻雀虽小,五脏俱全,足够我们用来研究启动过程。

以下是我们的“点灯”代码,先贴出来给大家瞅瞅:
  1. #include "MCUxx.h"

  2. #define LED_PIN GPIO_PIN_13
  3. #define LED_PORT GPIOC

  4. void set_output(GPIO_TypeDef* port, uint16_t pin) {
  5.     // 使能 GPIOC 时钟
  6.     RCC->AHB1ENR |= RCC_AHB1ENR_GPIOCEN;
  7.    
  8.     // 配置引脚为推挽输出
  9.     port->MODER &= ~(GPIO_MODER_MODER13); // 清零
  10.     port->MODER |= GPIO_MODER_MODER13_0;  // 设置为输出模式
  11.     port->OTYPER &= ~(GPIO_OTYPER_OT13);  // 推挽输出
  12.     port->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR13; // 高速
  13.     port->PUPDR &= ~(GPIO_PUPDR_PUPDR13); // 无上拉/下拉
  14.    
  15.     // 默认输出低电平
  16.     port->BSRR = GPIO_BSRR_BR13;
  17. }

  18. int main(void) {
  19.     set_output(LED_PORT, LED_PIN);
  20.     while (1) {
  21.         // 翻转 LED 状态
  22.         LED_PORT->ODR ^= LED_PIN;
  23.         // 简单延时
  24.         for (volatileuint32_t i = 0; i < 100000; i++) {}
  25.     }
  26. }


运行这段代码,板子上的 LED(接在 PC13 引脚)就会开始闪。这代码看着简单,但问题来了:我们是怎么从“上电”跑到 main() 的?芯片到底干了啥?别急,下面一步步拆解。
 楼主| 星辰大海不退缩 发表于 2025-5-26 15:54 | 显示全部楼层
上电之后,MCU在忙啥?

给开发板通电后,代码就开始跑了。这过程看似“天经地义”,但背后有一套固定的流程。想搞清楚细节,我们得翻翻 MCU的参考手册,顺便参考一下 Cortex-M4 的技术参考手册(ARMv7-M 架构)。

ARM 手册里有一段伪代码,描述了芯片复位后的行为(太长就不贴了,怕你们睡着),简单总结一下,MCU上电后会干这些事儿:

把向量表地址(VTOR)清零,设置为 0x00000000(默认指向 Flash)。
禁用所有中断,确保啥也不来捣乱。
从地址 0x00000000 加载主栈指针(MSP)。
从地址 0x00000004 加载程序计数器(PC)。
然后……直接跳到 PC 指向的地址开始执行!
看到这儿,你可能想:“哦!那 main() 函数肯定就在 0x00000004 咯!”别急,我们来验证一下。



验证:从二进制文件看真相

为了确认芯片到底跳转到哪儿,我们把编译好的二进制文件(.bin)拿出来看看,检查地址 0x00000000 和 0x00000004 里存了啥。用 xxd 命令 dump 一下:

$ xxd build/minimal/minimal.bin | head
00000000: 0080 2000 c100 0000 b500 0000 bb00 0000  .. ............
解析一下:

地址 0x00000000 存的是主栈指针(MSP),值是 0x20008000(RAM 顶部)。
地址 0x00000004 存的是程序计数器(PC)初始值,值是 0x000000c1。
咦?0x000000c1 是啥地址?我们再用 objdump 看看符号表,找找这个地址对应啥函数:

$ arm-none-eabi-objdump -t build/minimal.elf | sort
...
000000b4 g     F .text  00000006 NMI_Handler
000000ba g     F .text  00000006 HardFault_Handler
000000c0 g     F .text  00000088 Reset_Handler
000002ac g     F .text  0000002c main
...
有点意外!main() 函数在 0x000002ac,而 0x000000c1 附近是个叫 Reset_Handler 的函数(准确说是 0x000000c0,因为 Cortex-M 用 Thumb 指令集,地址最低位设为 1 表示 Thumb 模式,具体细节可以参考 ARMv7-M 手册 A2.3.1 节)。

真相大白!MCU上电后并不是直接跳到 main(),而是先跳到 Reset_Handler。这个 Reset_Handler 是个啥?它为啥这么重要?接着往下挖!
 楼主| 星辰大海不退缩 发表于 2025-5-26 15:55 | 显示全部楼层
Reset_Handler:从零开始的“管家”

Reset_Handler 可以看作是 MCU 的“启动管家”。它负责在上电后把环境收拾得干干净净,确保 main() 能顺利运行。想搞懂它的作用,我们得看看 Cortex-M4 的规范(参考 Cortex-M4 TRM,DDIo338 文档,5.9.2 节)。

手册里说,Reset_Handler 主要干这几件事:

初始化变量:把全局/静态变量设置好。没初始值的变量清零(放 BSS 段),有初始值的变量从 Flash 拷贝到 RAM(放数据段)。
设置栈:如果程序需要多个栈(比如主栈 MSP 和进程栈 PSP),得把它们初始化好。
初始化运行时环境:如果用到了 C/C++ 的高级功能(比如堆、浮点运算),得调用运行时初始化代码。
这跟 C 语言标准也有呼应。C 标准(5.1.2 节)规定:所有静态存储周期的变量(全局变量、静态变量)在程序启动前必须初始化。没赋初值的设为 0,有初值的设为指定值。

举个例子,假设有这么段代码:

  1. static uint32_t foo;        // 默认初始化为 0
  2. static uint32_t bar = 2;    // 初始化为 2

Reset_Handler 的任务就是确保 foo 的内存是 0x00000000,bar 的内存是 0x00000002。但它不可能一个变量一个变量地去设,太麻烦了。实际操作中,编译器和链接器会帮忙把变量“归类”:

BSS 段:存放没初始值的静态变量(要清零),链接器提供 _sbss(起始地址)和 _ebss(结束地址)。
数据段:存放有初始值的静态变量,链接器提供:
_etext:初始值存储在 Flash 的地址。
_sdata:变量运行时的 RAM 地址。
_edata:数据段的结束地址。
有了这些信息,Reset_Handler 就能批量处理了。

 楼主| 星辰大海不退缩 发表于 2025-5-26 15:56 | 显示全部楼层
本帖最后由 星辰大海不退缩 于 2025-5-26 15:58 编辑

手写一个最简 Reset_Handler

与其对着 MCU官方启动文件(一堆汇编,头大)研究,不如咱们自己动手写一个简单清晰的 Reset_Handler!目标是完成变量初始化,然后跳到 main()。代码如下:

  1. #include "MCUxx.h"

  2. externuint32_t _etext;   // 数据段初始值在 Flash 的地址
  3. externuint32_t _sdata;   // 数据段起始地址(RAM)
  4. externuint32_t _edata;   // 数据段结束地址(RAM)
  5. externuint32_t _sbss;    // BSS 段起始地址
  6. externuint32_t _ebss;    // BSS 段结束地址

  7. void Reset_Handler(void) {
  8.     // 1. 拷贝数据段初始值(从 Flash 到 RAM)
  9.     uint32_t *init_values_ptr = &_etext;
  10.     uint32_t *data_ptr = &_sdata;

  11.     if (init_values_ptr != data_ptr) {
  12.         while (data_ptr < &_edata) {
  13.             *data_ptr++ = *init_values_ptr++;
  14.         }
  15.     }

  16.     // 2. 清零 BSS 段
  17.     for (uint32_t *bss_ptr = &_sbss; bss_ptr < &_ebss;) {
  18.         *bss_ptr++ = 0;
  19.     }

  20.     // 3. 跳转到 main()
  21.     main();

  22.     // 4. 如果 main() 返回,进入死循环(防止跑飞)
  23.     while (1);
  24. }

解释一下:

数据段拷贝:把初始值从 _etext(Flash)拷贝到 _sdata 到 _edata(RAM),确保有初始值的变量值正确。
BSS 段清零:把 _sbss 到 _ebss 的内存清零,确保没初始值的变量是 0。
调用 main() :环境准备好后,直接跳到 main()。
死循环:如果 main() 意外返回,死循环防止程序跑飞。
为了让代码更健壮,我们还可以加点 STM32F411 专属的初始化。比如,ST的需要确保系统时钟配置正确(否则默认用内部 HSI 16 MHz 运行,可能不满足某些场景需求)。我们可以在 Reset_Handler 中调用 SystemInit(ST 官方提供的函数)来搞定这些。改后的代码如下:

  1. #include "MCUxx.h"

  2. externuint32_t _etext;   // 数据段初始值在 Flash 的地址
  3. externuint32_t _sdata;   // 数据段起始地址(RAM)
  4. externuint32_t _edata;   // 数据段结束地址(RAM)
  5. externuint32_t _sbss;    // BSS 段起始地址
  6. externuint32_t _ebss;    // BSS 段结束地址

  7. extern void SystemInit(void); // STM32 官方提供的系统初始化函数

  8. void Reset_Handler(void) {
  9.     // 1. 拷贝数据段初始值
  10.     uint32_t *init_values_ptr = &_etext;
  11.     uint32_t *data_ptr = &_sdata;

  12.     if (init_values_ptr != data_ptr) {
  13.         while (data_ptr < &_edata) {
  14.             *data_ptr++ = *init_values_ptr++;
  15.         }
  16.     }

  17.     // 2. 清零 BSS 段
  18.     for (uint32_t *bss_ptr = &_sbss; bss_ptr < &_ebss;) {
  19.         *bss_ptr++ = 0;
  20.     }

  21.     // 3. 调用系统初始化(设置时钟等)
  22.     SystemInit();

  23.     // 4. 跳转到 main()
  24.     main();

  25.     // 5. 死循环
  26.     while (1);
  27. }

注意:这里的 SystemInit 是MCU 官方固件库(或 HAL 库)提供的,负责初始化时钟、FPU(浮点单元,Cortex-M4 特有)等。如果不用官方库,也可以自己写时钟配置,比如手动设置 HSE 或 PLL。

 楼主| 星辰大海不退缩 发表于 2025-5-26 15:58 | 显示全部楼层
总结:从“魔法”到“掌控”

通过这篇文章,我们从上电开始,一步步揭开了裸机 C 代码启动的秘密。原来,main() 并不是故事的起点,Reset_Handler 才是默默干活的幕后英雄!它初始化变量、设置环境、配置时钟,最后才把舞台交给 main()。

整个过程的核心逻辑其实不复杂:

芯片复位后从向量表加载 MSP 和 PC。
跳转到 Reset_Handler,完成变量初始化和系统设置。
最后调用 main(),开始执行用户代码。
希望这篇文章能让你对mcu裸机开发多一份掌控感。

小夏天的大西瓜 发表于 2025-5-27 10:21 | 显示全部楼层
非常不错的资料
您需要登录后才可以回帖 登录 | 注册

本版积分规则

287

主题

2525

帖子

6

粉丝
快速回复 在线客服 返回列表 返回顶部

287

主题

2525

帖子

6

粉丝
快速回复 在线客服 返回列表 返回顶部