当前位置: 首页 > news >正文

为什么复位后不能直接运行 main 函数? 硬件初始化、栈、向量表、全局变量这些谁来准备?

难度:★
本文首发于我的嵌入式技术公众号「OneChan」,未经授权禁止转载。


先做一个小实验。

打开 Keil,新建一个 Cortex-M3 工程,写一个最简单的main函数:

intmain(void){inta=1;intb=2;intc=a+b;returnc;}

编译,下载,运行。一切正常。

现在,把启动文件startup_xxx.s从工程里删掉,再编译。

编译器报错了:

Error: L6218E: Undefined symbol __initial_sp (referred from entry2.o). Error: L6218E: Undefined symbol Reset_Handler (referred from entry2.o).

连编译都过不去。

这说明一件事:哪怕你写的是纯 C 代码,哪怕你一个寄存器都不想碰,芯片上电之后,在main函数的第一行代码执行之前,已经有大量工作必须由别人替你完成

这个人,就是启动文件。

那它到底干了什么?为什么这些事情不能让main自己来?


一、芯片复位那一瞬间,发生了什么?

Cortex-M3 上电或复位时,硬件只自动做三件事,不多不少:

  1. 0x00000000地址处的 32 位值,读出来,塞进 MSP(主堆栈指针)。
  2. 0x00000004地址处的 32 位值,读出来,塞进 PC(程序计数器)。
  3. 从 PC 指向的地址开始取指令执行。

就这三件。没了。

不初始化外设时钟,不设置 Flash 等待周期,不给全局变量赋初值,不清零未初始化变量。这些事,硬件统统不管。

你可能会问:0x000000000x00000004这两个地址里存的是啥?

答案是——向量表的前两项。

地址内容含义
0x00000000栈顶地址(高地址)复位后自动装入 MSP
0x00000004Reset_Handler函数地址复位后自动装入 PC

所以复位流程本质上是:

硬件取 0x00000000 → MSP 硬件取 0x00000004 → PC CPU 开始执行 Reset_Handler

Reset_Handler是你写的吗?

不是。它在启动文件里,由汇编写成,是芯片复位后执行的第一段用户代码。

Reset_Handler又干了什么,才轮到main


二、从Reset_Handlermain,中间隔着四座大山

打开启动文件,找到Reset_Handler,你会看到类似这样的代码(以 Keil 为例):

Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDP

翻译成人话:

  1. 调用SystemInit,初始化系统时钟、Flash 延迟、外设总线。
  2. 跳转到__main,这是 ARM C 库的入口。
  3. __main做完剩下的脏活累活后,才调用你的main

SystemInit比较好理解,就是配置时钟。不配的话,芯片可能跑在 8MHz 内部 RC 振荡器上,而不是 72MHz 外部晶振。

真正让人困惑的是__main

它不是你的main。它是 ARM 标准 C 库里的一个函数,藏在编译器的背后。它干了三件至关重要的事:

  1. .data段从 Flash 复制到 RAM
  2. .bss段在 RAM 里全部清零
  3. 如果用了 C++,调用全局对象的构造函数

不干这三件事,你的main连门都出不了。


三、.data段:为什么全局变量初始值要“搬一次家”?

看这个例子:

intg_counter=100;// 带初始值的全局变量intmain(void){g_counter++;while(1);}

g_counter的初始值100存在哪?

答案:Flash 里。

因为 RAM 断电就丢数据,所以变量的初始值必须在掉电不丢的 Flash 里存一份。但问题是——程序运行时要修改g_counter,而 Flash 不能直接写。

所以启动代码必须做一件事:把初始值从 Flash 搬运到 RAM

这个过程就叫“.data段拷贝”。

在分散加载文件(.sct)里,这对应两个地址:

概念地址说明
加载地址Flash(如0x08001000初始值存放处,只读
执行地址RAM(如0x20000000运行时变量所在地,可读写

启动代码里,有一段汇编循环,大致长这样:

CopyData: LDR R1, =__data_load ; Flash 中的起始地址 LDR R2, =__data_start ; RAM 中的起始地址 LDR R3, =__data_end ; RAM 中的结束地址 CopyLoop: CMP R2, R3 BEQ CopyDone LDR R0, [R1], #4 ; 从 Flash 读 4 字节,地址递增 STR R0, [R2], #4 ; 写到 RAM,地址递增 B CopyLoop CopyDone:

如果你直接运行main,而没有这段拷贝代码,会发生什么?

g_counter的初始值会是一个随机数(RAM 上电后的残留值)。你的程序从第一行开始就在用垃圾数据计算。

这不是 bug,这是灾难。


四、.bss段:为什么未初始化变量必须清零?

再看这个:

intg_uninit;// 没有初始值的全局变量intmain(void){if(g_uninit==0){// 你以为它一定是 0?}}

C 标准规定:未初始化的全局变量,在进入main之前必须为 0

但这个 0 是谁写的?硬件不会帮你清零 RAM。启动代码必须手动把.bss段所在的 RAM 区域全部填 0。

代码类似这样:

ZeroBss: LDR R1, =__bss_start LDR R2, =__bss_end MOV R0, #0 ZeroLoop: CMP R1, R2 BEQ ZeroDone STR R0, [R1], #4 B ZeroLoop ZeroDone:

如果不清零呢?

g_uninit的值是随机的。它可能在你的板子上恰好是 0,测试通过。到了客户手里,上电后它是 0xDEADBEEF,程序崩溃。你远程升级固件,客户骂娘。

这种 bug,你仿真一百次都复现不出来,因为它依赖 RAM 的上电随机状态。


五、栈:main的函数调用靠谁撑腰?

C 语言的函数调用,靠的是栈。

调用函数时,参数、返回地址、局部变量,全往栈里压。返回时再弹出来。

但栈指针是谁设的?

还是启动文件。

回头看复位流程的第一步:硬件从0x00000000读 32 位值,塞进 MSP

这个值就是启动文件里定义的__initial_sp,通常等于 RAM 的最高地址(比如0x20010000)。

栈是向下增长的(高地址往低地址走),所以初始 SP 必须指向 RAM 顶部。

如果没有这一步呢?

MSP 的值是随机的。main里第一个函数调用,PUSH指令把数据往一个随机地址写——可能是 Flash 区,可能是外设寄存器区,立刻 HardFault。

你连main的第一行都跑不到。


六、所以,启动文件到底是干什么的?

总结一下,芯片从复位到进入main,经历了这样一个过程:

复位 ↓ 硬件自动加载 SP(来自 0x00000000) ↓ 硬件自动加载 PC(来自 0x00000004) ↓ 执行 Reset_Handler ↓ 调用 SystemInit(配置时钟) ↓ 跳转到 __main(库函数入口) ↓ ├── 拷贝 .data 段(Flash → RAM) ├── 清零 .bss 段(RAM 填 0) └── 调用 C++ 全局构造(如果有) ↓ 调用 main()

启动文件做的事情,一句话概括:

在 C 语言运行环境建立之前,用汇编把硬件初始化、栈、全局变量、C 库这四件事全部安排妥当。

main函数之所以能写得像白纸一样干净,是因为有人替它把所有脏活干完了。


七、举一反三

问题 1:如果我写的是纯汇编程序,不用 C,还需要这些吗?

不需要全部。

你可以不拷贝.data(因为没有带初值的全局变量),可以不调__main。但栈指针和向量表仍然必须正确设置,因为硬件强制要求。

问题 2:为什么有些 RTOS 的启动文件和裸机的不一样?

因为 RTOS 需要额外的栈——每个任务有自己的任务栈,而 MSP 只用于中断。启动代码里需要额外初始化 PSP(进程栈指针),并在第一次任务切换时切过去。这对应系列第 85 篇《为什么 RTOS 的启动文件需要额外处理 PendSV 和 SVC?》。

问题 3:RISC-V 也需要这样吗?

机制不同,但本质相同。

RISC-V 没有向量表的概念,复位后直接跳转到固定地址(如0x00001000)执行。但.data拷贝、.bss清零、栈初始化这些事,一样都少不了。只是实现方式不同。这对应系列第 83 篇《为什么 RISC-V 的启动流程与 Cortex-M3 不同?》。


八、写在最后

回到最初的问题:为什么复位后不能直接运行main函数?

不是不能,是硬件只给了你一个空壳 CPU,C 语言需要的运行环境一样都没准备

栈没有,变量初始值是垃圾,时钟跑在默认频率,向量表没指向正确的中断函数。

启动文件是芯片和 C 语言之间的翻译官。你写的每一行 C 代码能优雅地运行,都是因为它在你没看见的地方,把最脏最累的活扛了下来。

下次复制粘贴startup_xxx.s的时候,对它客气一点。


下一篇预告:《为什么 Cortex-M3 需要向量表?向量表为什么必须放在地址 0 附近?》

关注「OneChan」,不错过后续 85 篇“为什么”。

http://www.jsqmd.com/news/686047/

相关文章:

  • 大厂VS小厂AI岗位要求深度解析!求职必看
  • 基于Java开发的物联网云平台:开源可二次开发,工业设备远程控制,数据采集与视频接入,支持多种...
  • 2026年武汉云熵讯灵AI搜索平台费用多少钱 - 工业设备
  • 边缘计算网络架构
  • Qwen3.5-9B-GGUF快速部署:5分钟完成start.sh执行+WebUI响应验证
  • 告别联网焦虑!用HLK-V20-SUIT离线语音模块给STM32设备加个‘嘴’(附完整烧录避坑指南)
  • WeDLM-7B-Base实际作品:技术博客续写、古诗新创、科幻短篇生成效果集
  • Qwen3.5-4B-AWQ部署案例:地方政府12345热线智能应答系统落地实践
  • 从ONNX到NCNN:Android端模型部署的完整环境搭建与转换实战
  • UE5.1/5.2 Android打包:除了SDK路径,别忘了检查这三个隐藏设置
  • Oumuamua-7b-RP详细步骤:基于start.sh脚本的零基础Web UI启动教程
  • FLUX.1-Krea-Extracted-LoRA入门指南:如何用‘golden hour lighting‘增强质感
  • 2026年武汉、宜昌等地实力强的武汉云熵讯灵AI搜索方案公司Top10 - 工业品网
  • 面向对象的测试层理分类
  • 2026年安庆汽车贴膜费用大揭秘,安庆哪里贴车衣是专车专用裁膜 - 工业品网
  • RAG赋能Agent:告别业务盲区,让AI真正理解你的世界!
  • 说说常州好用的改善水质的净水活性炭,江苏竹溪活性炭靠谱吗 - 工业品牌热点
  • PyTorch炼丹时遇到OMP报错?别慌,三步搞定libiomp5md.dll冲突(附环境变量与文件删除两种方案)
  • Intv_ai_mk11处理复杂网络请求:应对Traefik网关代理的配置实践
  • STM32F103C8T6连接ZH03B传感器:一个串口采集PM2.5数据的完整流程(附代码)
  • 2026年聊聊华聊能不能执行下去,深圳靠谱的社交电商公司排名 - 工业品牌热点
  • 【实测指南】英文文章AI率86%怎么救?好用的降AI软件推荐与重构技巧
  • picclp32.ocx文件丢失找不到怎么办?免费下载方法分享
  • 2026年口碑好的网带式抛丸机/抛丸机精选厂家推荐 - 行业平台推荐
  • 【大模型微调实战】第4期:从失败到迭代终局——SFT三轮修复与DPO复盘全记录前言
  • 为什么 Cortex-M3 需要向量表?向量表为什么必须放在地址 0 附近?
  • 聊聊2026年华聊可不可以运作,深圳哪些社交软件性价比高? - 工业推荐榜
  • 前端资源加载管理
  • 用户故事管理化技术中的用户故事计划用户故事实施用户故事验证
  • 别再用暴力枚举了!PTA L1-006连续因子题,用数学优化把复杂度降下来