裸机编程基础:启动文件分析、链接脚本编写、内存映射、堆栈初始化

各位同学,咱们今天聊点实在的。裸机编程,说白了就是让芯片“光着脚”跑起来。没有操作系统,没有文件系统,连个 printf 都没有。你写的第一行代码,就是芯片上电后执行的第一条指令。

我刚开始做 ARM 开发时,总觉得启动文件和链接脚本是“玄学”。明明代码写对了,下载进去就是跑不起来。后来踩的坑多了,才明白——这三板斧(启动文件、链接脚本、内存映射)是裸机编程的根基。根基不稳,上层代码写得再漂亮也是白搭。

1. 启动文件分析:芯片上电后第一件事

ARM Cortex-M 系列芯片上电后,硬件会自动做两件事:

  1. 从 0x00000000 地址加载栈顶指针(MSP)
  2. 从 0x00000004 地址加载复位向量,跳转执行

嗯,这就是中断向量表的头两个条目。启动文件的核心任务,就是把这个向量表摆对位置,然后完成 C 语言运行环境的初始化。

启动文件标准流程(以 STM32F4 为例):

  1. 定义中断向量表,第一个是栈顶地址,第二个是复位函数
  2. 复位函数中:关闭中断 → 拷贝 .data 段 → 清零 .bss 段 → 初始化堆栈 → 调用 SystemInit → 跳转 main
  3. 定义其他中断服务函数的弱别名(weak alias),防止链接报错
; 启动文件核心片段(汇编)
__Vectors       DCD     __initial_sp        ; 栈顶指针
                DCD     Reset_Handler       ; 复位向量

Reset_Handler   PROC
                EXPORT  Reset_Handler
                ; 1. 关闭全局中断
                CPSID   I
                ; 2. 拷贝 .data 段从 Flash 到 RAM
                LDR     R0, =_sidata
                LDR     R1, =_sdata
                LDR     R2, =_edata
                ; ... 循环拷贝
                ; 3. 清零 .bss 段
                LDR     R1, =_sbss
                LDR     R2, =_ebss
                ; ... 循环清零
                ; 4. 调用 C 初始化函数
                BL      SystemInit
                ; 5. 跳转 main
                BL      main
                ENDP

我的习惯:启动文件里不要写太多业务逻辑。它就是个“搬运工”,把数据从 Flash 搬到 RAM,把 BSS 清零,然后赶紧把控制权交给 main。我在项目中见过有人把外设初始化写在启动文件里,结果调试时改一次外设配置就要重新烧写整个启动文件,太折腾了。

2. 链接脚本编写:告诉编译器“东西放哪”

链接脚本(Linker Script)是编译器生成最终可执行文件的“地图”。它告诉链接器:代码段放哪、数据段放哪、堆栈放哪。

ARM 芯片的典型内存布局是这样的:

段名 存储位置 运行位置 内容
.text Flash Flash 代码、只读常量、中断向量表
.rodata Flash Flash 字符串常量、const 变量
.data Flash RAM 已初始化的全局/静态变量
.bss 无(初始值为0) RAM 未初始化的全局/静态变量
堆(Heap) 无 RAM 动态分配(malloc)
栈(Stack) 无 RAM 局部变量、函数调用

这里有个关键点:.data 段在 Flash 里存着初始值,但运行时必须搬到 RAM 里。因为 Flash 不能写,而全局变量是要被修改的。启动文件里的拷贝循环,干的就是这个活。

/* 链接脚本核心片段 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .text : {
        *(.isr_vector)    /* 中断向量表必须放在最前面 */
        *(.text*)
        *(.rodata*)
        _etext = .;       /* 标记 .text 结束地址 */
    } > FLASH

    _sidata = .;          /* .data 在 Flash 中的起始地址 */

    .data : AT(_sidata) {
        _sdata = .;       /* .data 在 RAM 中的起始地址 */
        *(.data*)
        _edata = .;       /* .data 在 RAM 中的结束地址 */
    } > RAM

    .bss : {
        _sbss = .;
        *(.bss*)
        *(COMMON)
        _ebss = .;
    } > RAM
}

我曾经踩过的坑:有一次我把 .data 段的 LMA(加载地址)和 VMA(运行地址)写反了。结果芯片上电后,全局变量全是乱的,LED 死活不亮。查了两天才发现是链接脚本里 AT 关键字用错了。记住:AT 后面跟的是加载地址(Flash),> 后面跟的是运行地址(RAM)。

3. 内存映射:芯片的“地址地图”

ARM Cortex-M 系列有统一的内存映射。说白了,就是芯片把 Flash、RAM、外设寄存器都映射到一个 4GB 的地址空间里。你往某个地址写数据,可能是写 RAM,也可能是配置 GPIO——全看地址落在哪个区域。

以 STM32F4 为例:

  • 0x0000 0000 - 0x1FFF FFFF:代码区(Flash 映射、系统存储器)
  • 0x2000 0000 - 0x3FFF FFFF:SRAM 区(片内 RAM)
  • 0x4000 0000 - 0x5FFF FFFF:外设区(GPIO、USART、TIM 等寄存器)
  • 0x6000 0000 - 0x9FFF FFFF:外部 RAM/ROM 区(FSMC 扩展)
  • 0xE000 0000 - 0xE00F FFFF:系统控制区(NVIC、SysTick、MPU)

为什么理解内存映射很重要?

因为裸机编程里,你经常要直接操作寄存器地址。比如点亮一个 LED,你要知道 GPIOB 的基地址是 0x40020400,ODR 寄存器的偏移是 0x14。如果你把地址算错了,写进去的数据就跑到别的外设里去了——我见过有人把数据写到了 RTC 寄存器里,结果时间乱跳。

ARM Cortex-M4 典型内存映射 Flash (0x08000000 - 0x0807FFFF) 代码段 .text | 只读数据 .rodata | 中断向量表 SRAM (0x20000000 - 0x2001FFFF) .data 运行区 | .bss 区 | 堆 (Heap) | 栈 (Stack) 外设区 (0x40000000 - 0x5FFFFFFF) GPIO | USART | TIM | SPI | I2C | ADC | DMA ... 外部扩展区 (0x60000000 - 0x9FFFFFFF) FSMC 扩展 NOR Flash | NAND Flash | SRAM 系统控制区 (0xE0000000 - 0xE00FFFFF) NVIC | SysTick | MPU | SCB 地址从低到高:Flash → SRAM → 外设 → 外部扩展 → 系统控制

4. 堆栈初始化:C 语言能跑起来的“幕后功臣”

堆和栈,是 C 语言运行时的两大支柱。没有它们,局部变量没法分配,函数调用没法嵌套,malloc 更是想都别想。

栈(Stack):

  • 向下生长(高地址向低地址)
  • 存放局部变量、函数参数、返回地址
  • 由编译器自动管理,你不需要手动分配释放
  • 大小在链接脚本里定死,溢出就会踩到堆或全局变量

堆(Heap):

  • 向上生长(低地址向高地址)
  • 用于动态内存分配(malloc/calloc/realloc)
  • 需要你手动管理,申请了就要释放
  • 裸机中堆不是必须的,我建议能不用就不用

我的建议:在裸机项目中,尽量别用 malloc。嵌入式环境里,内存碎片和分配失败是很难调试的。我一般用静态数组 + 内存池的方式替代动态分配。如果非要用堆,记得实现一个 _sbrk 函数,告诉 C 库堆的边界在哪。

堆栈初始化在启动文件里完成:

; 堆栈初始化(汇编)
; 栈顶地址由链接脚本提供
__initial_sp   EQU     0x20010000    ; 或者从链接脚本导入

; 堆的边界(用于 _sbrk)
Heap_Begin     EQU     _ebss + 0x1000
Heap_Limit     EQU     0x2001F000

; 在复位函数中设置栈指针
Reset_Handler
    LDR     SP, =__initial_sp    ; 设置主栈指针 MSP
    ; ... 其他初始化 ...

我曾经遇到的一个经典问题:栈溢出。在一个项目中,我在中断服务函数里定义了一个 2KB 的局部数组,而栈总共才配了 1KB。结果程序跑着跑着就死机了,查了两天才发现是栈把全局变量区给踩了。从那以后,我养成了一个习惯:每个中断函数里尽量少用局部大数组,实在要用就声明为 static。

5. 实战要点总结

好了,咱们把这一章的核心点捋一捋:

知识点 核心要点 常见坑
启动文件 向量表在前、搬运 .data、清零 .bss、跳转 main 向量表对齐错误、弱别名冲突
链接脚本 LMA vs VMA、段顺序、内存区域定义 AT 关键字用错、Flash/RAM 地址写反
内存映射 4GB 地址空间分区、外设基地址 地址计算错误、访问越界
堆栈初始化 栈向下生长、堆向上生长、大小合理配置 栈溢出踩全局变量、堆碎片

裸机编程的启动流程,说白了就是:芯片上电 → 硬件加载向量表 → 汇编初始化 C 环境 → 跳转 main。这个流程你理解透了,后面写驱动、调 Bootloader 都会顺手很多。