一些杂记(三)
全局数据结构/静态局部数据结构/普通局部数据结构分别存储在程序的哪些段中?
在进行区分之前,我们先来看看在嵌入式C语言程序中都有哪些核心的内存段划分:
| 段名 | 存放内容 | 物理属性 | 典型例子 |
|---|---|---|---|
| .text | 代码段:所有可执行指令、函数体。 | Flash(只读) | if、while、函数调用等等。 |
| .rodata | 只读数据段:常量、字符串字面量。 | Flash(只读) | const char *p = "Hello"; 中的字符串 "Hello"。 |
| .data | 已初始化读写数据:全局/静态变量,且有非零初值。 | Flash存初值,启动时拷贝到RAM | int a = 5; 中的 5 存在Flash,变量 a 在RAM。 |
| .bss | 零初始化数据:全局/静态变量,无初值或初值为0。 | RAM(无Flash占用) | int b; 或 static int c = 0;。启动时由 __main 循环清零。 |
| Stack | 栈(由编译器管理):局部变量、函数调用现场(返回地址、寄存器)。 | RAM | uint8_t temp;(定义在函数内部的)。 |
| Heap | 堆(由用户管理):动态分配的内存。 | RAM | malloc(size) 申请的空间。注意:如果工程没用到标准 malloc,这个段通常很小甚至为0。 |
- 对于全局数据结构:这里要考虑是否初始化为0。看下面的例子:
1 | |
三个变量均开辟在全局数据区,g_verA 默认初始化为0,g_verB 和 g_init 都进行了对应的初始化。对于 g_verA,由于没有进行显示初始化,因此被放在了 .bss 段,上电后由 __main 进行清零操作,并且在 Flash 中零占用;
g_verB 和 g_init 进行了显式初始化,因此被放入 .data 段中(这里要注意:被放入.data中的只是变量本身,初始化数据是被放入了 flash 中的)。系统上电后,__main 会将 flash 中的对应初始化数据拷贝到 .data 中对应的位置。这也是为什么我们的全局变量能够一进入程序就已经被赋予一个初始化值的原因。
- 对于普通局部数据结构:是统一放在函数调用栈中的。
1 | |
myTestStructure 这个结构我们是在函数局部空间进行定义的,因此其被压栈存入到函数栈中。只在当前的作用域有效,一旦函数运行周期结束出栈返回,这个变量的内存空间也随即跟着被销毁了。
由于栈大小是固定且通常比较小,因此不要在局部定义大型数据结构(例如大数组或大型结构体),注意堤防栈溢出。
- 静态局部数据结构:前面我们说过,static 修饰局部数据结构时不改变其作用域,只规定其生命周期。
1 | |
此处,myTestStructure 虽然定义语句在局部,但是由于 static 的修饰,其真正的内存空间位置是被放在了 .bss 段(未显式进行初始化,因此默认初始化为0)。当程序流程进入 funcA 时,不会压栈 myTestStructure,而是从 RAM 中直接读出该变量,操作完毕后 funcA 返回,myTestStructure 依然有效。
这里要强调一点:.bss 和 .data 段一大区别就是是否在 flash 中有占用。.data段的数据在上电时需要来自 flash 中的初始化值,而 .bss 不需要,因此 .data 在 Flash 中和 RAM 中是双份空间占用!
堆和栈的主要区别
| 对比维度 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 管理方式 | 自动管理。编译器自动插入入栈/出栈指令,用户不需要也不应该插手。 | 手动管理。用户必须显式调用 malloc/calloc 申请,用完必须 free。 |
| 分配速度 | 极快。仅涉及栈指针的移动。 | 较慢。内部涉及搜索算法,需要遍历空闲链表寻找合适大小的内存块。 |
| 生命周期 | 临时。函数执行完,里面的局部数据结构立即被销毁。 | 长期。只要你不主动 free,内存一直有效(哪怕函数已经退出)。漏 free 就会造成内存泄漏。 |
| 内存碎片 | 无碎片。后进先出(LIFO)机制。 | 有碎片。多次申请释放会留下许多细小的空洞,无法被进一步使用(外部碎片)。 |
| 大小限制 | 通常很小,由编译器或链接脚本固定。 | 较大。 |
这里讲讲标准C和嵌入式C中的关于堆栈相关的一些差异:
- 堆内存的来源不一样
标准C语言中堆由操作系统通过 brk/sbrk 系统调用动态扩充,堆区(.heap)位于链接器预留的虚拟地址空间,且在程序运行过程中可以向 OS 申请扩大。
但在嵌入式环境下,标准C没有所谓的操作系统层调用。其操作的本质是一块预先在编译期就定义好的巨型静态数组,形如 static uint8_t ucHeap[20 * 1024]; 这个数组被放在 .bss 或 .data 段,所以堆的大小在编译时就彻底固定了,无法动态扩充。
现在很多中间件你比如 FreeRTOS / lwIP 等等,内部都会实现自身的动态内存管理算法而不是直接使用标准C的实现。这些管理算法实现的本质其实也是在一块巨型静态数组上进行切割分配和回收。
- 栈溢出后果不同
标准C栈通常很大(几 MB),且如果栈溢出,操作系统可能会分配新的内存页或触发段错误,程序会崩溃但是容易排查。
但是在嵌入式中,栈空间很小(通常1~4Kb),栈溢出后会静默踩到相邻的全局数据结构或另一个任务栈,导致程序运行出现 HardFault 。比较难以排查。
- 使用标准与安全性
嵌入式中, 标准 malloc 实现是不可重入的,不应该在多线程环境中使用。裸机环境下若不得不使用,应保证不在 ISR 中使用。因为 malloc 内部涉及搜索算法,其分配时间是不确定的,而 ISR 要求尽快返回。
同时,不管在任何环境,若必须使用malloc 请一定使用临界区/或互斥量等手段进行保护。因为不可重入性,若在分配过程中被打断并在别处再次调用 malloc 就很可能会造成整个堆管理结构直接损坏。
标准C的堆实现在实际运用中,基本不会被使用到。需要动态内存分配时,应优先考虑适用于嵌入式环境的三方内存分配算法。