编译报 "region FLASH overflowed" 还能理解,但全局变量加着加着程序就跑飞、某个数组初值明明写了却不生效——这些诡异问题的根子,多半出在内存布局和链接脚本上。
四个区域与两块物理存储
C 程序编译后的内容按属性分成几段,链接器把段映射到物理存储器上。
| 段 |
内容 |
物理位置 |
运行时是否可写 |
.text |
函数代码、中断向量表 |
Flash |
否 |
.rodata |
字符串常量、const 全局量、查表数据 |
Flash |
否 |
.data |
有初值的全局/静态变量 |
Flash 存副本,运行时在 RAM |
是 |
.bss |
无初值或初值为 0 的全局/静态变量 |
仅 RAM,Flash 只存大小信息 |
是 |
| stack |
局部变量、函数调用现场 |
RAM |
是 |
| heap |
malloc 分配区 |
RAM |
是 |
.data 这一行是理解内存布局的关键:它在 Flash 里也占一份空间。上电时变量初值从 Flash 拷贝到 RAM,所以一个 4KB 的全局数组会同时吃掉 4KB Flash 和 4KB RAM。.bss 不占 Flash,因为初始值全是 0,启动时清零即可。
局部变量在栈上,占用的是运行时的 RAM,不体现在 Flash 里。反过来,函数里写的 static 变量落在 .data 或 .bss,与其他全局变量没有区别。
启动代码做了什么
很多人以为全局变量的初值是编译器自动生效的,实际上这是启动代码在 main() 之前完成的。Cortex-M 的复位流程大致是:先设置主栈指针 MSP,接着把 .data 段从 Flash 拷贝到 RAM,再把 .bss 段全部清零,最后调用 SystemInit 和 __libc_init_array,才进入 main()。
对应的汇编启动代码大致是:
Reset_Handler:
ldr r0, =_estack
msr msp, r0 /* 主栈指针 */
/* 拷贝 .data: Flash 的 _sidata -> RAM 的 _sdata */
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_sidata
movs r3, #0
b LoopCopyDataInit
CopyDataInit:
ldr r4, [r2, r3]
str r4, [r0, r3]
adds r3, r3, #4
LoopCopyDataInit:
adds r4, r0, r3
cmp r4, r1
bcc CopyDataInit
/* 清零 .bss */
ldr r2, =_sbss
ldr r4, =_ebss
movs r3, #0
b LoopFillZerobss
FillZerobss:
str r3, [r2]
adds r2, r2, #4
LoopFillZerobss:
cmp r2, r4
bcc FillZerobss
bl SystemInit
bl __libc_init_array
bl main
_sdata、_sidata、_ebss 这些符号不是编译器自动生成的,是链接脚本定义的。改链接脚本时把 .data 的加载地址写错,表现就是全局变量初值全乱——程序能跑,但参数全是随机数。
链接脚本的两个地址
理解 LMA(加载地址)和 VMA(虚拟地址/运行地址) 之后,链接脚本就不再是天书。
- LMA:这段代码或数据“存放”在哪里,即烧录位置。
- VMA:运行时“运行”在哪里。
.text 和 .rodata 的 LMA 与 VMA 都在 Flash,两者相同。.data 的 LMA 在 Flash、VMA 在 RAM,所以要用 AT() 显式指定加载地址。
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
CCMRAM(rw) : ORIGIN = 0x10000000, LENGTH = 64K
}
SECTIONS
{
/* 中断向量表必须放在 Flash 起始位置 */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} >FLASH
.text :
{
. = ALIGN(4);
*(.text)
*(.text*)
*(.rodata)
*(.rodata*)
_etext = .; /* .data 的加载地址从这里开始 */
} >FLASH
/* .data: 运行地址在 RAM,加载地址紧跟在 .text 之后 */
.data : AT (_etext)
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} >RAM
.bss :
{
. = ALIGN(4);
_sbss = .;
__bss_start__ = _sbss;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
__bss_end__ = _ebss;
} >RAM
/* 栈顶与堆的空间检查 */
._user_heap_stack :
{
. = ALIGN(8);
PROVIDE ( end = . );
PROVIDE ( _end = . );
. = . + _Min_Heap_Size;
. = . + _Min_Stack_Size;
. = ALIGN(8);
} >RAM
}
_sidata 在多数 STM32 工程里被定义为 LOADADDR(.data),等价于紧跟 _etext。链接器会保证 AT() 指定的位置不与前面的段重叠。
查看真实占用
size 命令给出三列数字,含义经常被人读错:
arm-none-eabi-size -A build/firmware.elf
section size addr
.isr_vector 392 134217728
.text 42108 134218120
.rodata 316 134260228
.data 512 536870912
.bss 8424 536871424
text 列 = 代码 + 只读数据,占用 Flash
data 列 = 有初值的全局变量,Flash 和 RAM 各占一份
bss 列 = 只占 RAM
Flash 实际占用 = text + data,RAM 实际占用 = data + bss + 栈 + 堆。很多“RAM 够用”的误判都来自漏算了栈和堆。
要找具体哪个符号占得最多,看 map 文件:
arm-none-eabi-nm --print-size --size-sort -r build/firmware.elf | head -20
输出按大小降序列出符号,一眼能看出是某个查表数组、某个任务栈还是某个缓冲区在吃 RAM。map 文件里还有 Cross Reference Table 和每个目标文件的段占用统计,排查“为什么加了这个功能 RAM 就爆了”时很有用。
把东西放到指定位置
把关键数据放到特定内存区,靠的是自定义段加链接脚本配合。
// 定义到 CCM RAM(Cortex-M4 的 64KB 紧耦合内存,CPU 访问零等待)
__attribute__((section(".ccmram"))) uint8_t g_fast_buf[4096];
// 定义到指定对齐的 DMA 缓冲,避免缓存行与对齐问题
__attribute__((section(".dma_buf"), aligned(32))) uint8_t g_dma_rx[1024];
链接脚本里要声明这些段:
.ccmram (NOLOAD) :
{
. = ALIGN(4);
_sccmram = .;
*(.ccmram)
*(.ccmram*)
. = ALIGN(4);
_eccmram = .;
} >CCMRAM
.dma_buf (NOLOAD) :
{
. = ALIGN(32);
*(.dma_buf)
} >RAM
用 CCM RAM 时注意一条限制:它通常不在 DMA 能访问的总线上,DMA 缓冲放进去会收不到数据。外部 SDRAM 也有类似问题——能 DMA 但访问延迟大,且需要先初始化控制器才能用,.data 段不能放进去(启动时还没初始化)。
常用的特殊区域对比:
| 区域 |
特点 |
注意 |
| 主 SRAM |
通用,DMA 可访问 |
带 D-Cache 的核需注意一致性 |
| CCM RAM |
CPU 独占,零等待,不占用总线带宽 |
通常不可 DMA |
| TCM(M7) |
指令/数据紧耦合,延迟确定 |
容量小,需显式放置 |
| Backup SRAM |
低功耗域保持 |
需开启备份域电源与时钟 |
| 外部 SDRAM |
容量大 |
需初始化控制器,不适合作启动段 |
栈与堆的设置
栈大小由链接脚本里的 _Min_Stack_Size 提供(裸机工程),或者由 RTOS 为每个任务单独指定。栈从 RAM 高地址向下生长,堆从 end 符号向上生长,两者之间是“谁先撞上谁”的关系。
裸机工程的栈估算方法是算最坏调用链:每个函数的局部变量加现场保存(Cortex-M 约 64~100 字节每级,带浮点更多),乘上嵌套深度,再加上中断嵌套的余量。查实际水位的方法是把栈区填充成固定图案(如 0xCD),运行一段时间后从栈顶往下数未被改写的字节数。
RTOS 环境下要区分三块内存:
RAM 总占用 = .data + .bss + 系统栈(中断与启动用)
+ 各任务栈之和 + RTOS 内核对象(TCB/队列/信号量)
+ heap_4 管理的堆(若用动态分配)
FreeRTOS 的 configTOTAL_HEAP_SIZE 是从一个静态数组里划出来的,这个数组本身在 .bss 里。任务栈从这个堆分配,所以调大 configTOTAL_HEAP_SIZE 直接体现为 bss 增长,而不是运行时动态增长。
malloc 走的是 newlib 的 _sbrk,从 end 符号之后的堆区分配。裸机工程里 _Min_Heap_Size 设得过小(有些模板默认 0x200),malloc 会在某个时刻静默失败,返回值是 NULL 却没被检查。
Bootloader 场景下的地址偏移
带 Bootloader 时,APP 的链接地址要整体偏移,同时中断向量表要靠 VTOR 重映射。
/* APP 从 0x08020000 开始,给 Bootloader 留出 128KB */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08020000, LENGTH = 384K
RAM (rwx): ORIGIN = 0x20000000, LENGTH = 128K
}
代码里配套设置:
#define APP_VECTOR_OFFSET 0x20000
// 在 SystemInit 之后、main 之前完成重映射
SCB->VTOR = FLASH_BASE | APP_VECTOR_OFFSET;
只改链接地址不改 VTOR,中断会跳到 Bootloader 的向量表;只改 VTOR 不改链接地址,APP 的中断向量本身还在错误位置。两处必须同步。
复位后硬件固定从 0x00000000 取栈指针,所以 Bootloader 必须放在 Flash 起始,APP 的链接脚本偏移不能为 0。
如果你正在从零搭建裸机工程,或者被链接脚本里的 AT()、LMA/VMA 这些概念绕得头疼,不妨结合体系化的 C 语言与底层开发课程把启动流程和内存模型串起来看,很多“玄学问题”会变得有据可查。