C语言_内存分配

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 1 Likes: 0 Comments: 0

247.1.5C语言_内存分配

#include <rtthread.h>                     // 引入 RT-Thread 内核接口,本例使用其中的毫秒延时函数。
#define DBG_TAG "main"                    // 把本文件日志的标签设为 main。
#define DBG_LVL DBG_LOG                   // 允许输出到 DBG_LOG 这一日志等级。
#include <rtdbg.h>                        // 引入 LOG_D 等 RT-Thread 日志宏;两个配置宏必须写在它前面。
static int wfeng;                         // 文件作用域静态变量;本例未使用,最终链接时被垃圾回收,不占最终映像的 RAM。
int main(void)                            // RT-Thread 应用入口;函数代码链接到 Flash 中的 .text 区域。
{                                         // main 函数体开始。
    int count = 1;                        // main 的自动局部变量;本次构建中可在 main 线程栈上观察到它。

    LOG_D("main adrees %p",main);         // 打印函数指针 main;原程序中的 adrees 拼写按课程源码保留。
    LOG_D("main adrees %p",&main);        // 再打印 &main;两种写法在本工程中得到相同的函数指针值。
    LOG_D("count adrees %p",&count);      // 打印对象指针 &count,用于观察局部变量所在的 RAM 地址。
    while (count++)                       // 每轮先判断旧值、再自增;长期运行到有符号 int 溢出时行为未定义。
    {                                     // 循环体开始。
        rt_thread_mdelay(1000);           // 让当前线程延时约 1000 ms,期间调度器可以运行其他就绪线程。
    }                                     // 本轮循环结束,返回条件处再次对 count 进行后缀自增。

    return RT_EOK;                        // 按函数声明返回 RT-Thread 的成功状态码;正常情况下循环不会主动退出。
}                                         // main 函数体结束。

一、内存分配

1.1 内存的分类

“内存分配”在这一课里包含两层意思:链接器先把程序的代码和静态数据安排到目标芯片的不同地址区域;程序运行后,启动代码、函数调用和动态分配器再使用这些区域。课程所说的“代码段、数据段、BSS 段、堆、栈五大段”是便于入门的概念模型,并不是 ISO C 规定每个程序都必须具有的五个物理分区。

本工程最终生成的是 ELF 映像,实际还含有中断向量表、只读数据、初始化函数表、.ARM.exidx 等内容。各段放在哪里由链接脚本决定,而局部变量究竟在栈上、寄存器中还是被优化掉,则由编译器、ABI 和优化选项共同决定。

  • .text(代码段):主要保存机器指令。本工程还把中断向量表和初始化表等内容并入同一个 Flash 输出段。
  • .rodata(只读数据):通常保存字符串字面量和只读常量。本链接脚本把 .rodata 输入段一起收进 Flash 中的 .text 输出段。
  • .data(已初始化数据):通常放有非零初值的全局变量和静态变量。它们运行时位于 RAM,但初值镜像必须保存在 Flash,复位后由启动代码复制到 RAM。
  • .bss(零初始化数据):通常放没有显式初值或初值为零的全局变量和静态变量。映像只需记录其范围,启动代码进入 C 程序前把该区域清零。
  • 堆:动态分配器管理的一段 RAM,供 malloc、rt_malloc 等接口在运行期申请。
  • 栈:保存函数调用所需状态和部分临时数据。RTOS 中通常每个线程都有独立的线程栈。

“int a; 在 .bss、int a = 0; 在 .data”不能当作硬规则。两者都具有静态存储期并在进入程序时为零,但编译器通常也会把显式初始化为零的对象放入 .bss,以节省 Flash 映像空间。精确归属应查看当前构建的链接映射文件(map)或 ELF 段表。

还有一个容易忽略的环节:只有真正进入最终映像的对象才占用目标内存。本课源码声明了未使用的 static int wfeng;,但当前工程启用了按节回收;map 文件显示它所在的 .bss.wfeng 输入节被丢弃,所以不能仅凭源文件中的声明断定它一定占用 RAM。

1.2 堆和栈

1.2.1 堆

堆用于满足运行期大小才确定的内存需求。C 常用 malloc/free,C++ 常用 new/delete 和 new[]/delete[];接口必须成对匹配。RT-Thread 开启动态内存管理后还可使用 rt_malloc/rt_free。申请失败时分配接口会返回空指针,使用前必须检查。

课程图中“堆向上生长”只是一种常见示意。这个 STM32 工程并没有无限增长的物理内存:板级初始化调用 rt_system_heap_init(HEAP_BEGIN, HEAP_END),把 __bss_end 到 SRAM 末端交给 RT-Thread 的堆分配器管理。因此,堆的可用地址范围在启动时已经受 64 KB SRAM 和其他 RAM 占用的共同限制。

调用 free 或 rt_free 的准确含义,是把对应内存块交还给分配器供以后复用。它不会必然缩小整个堆区域,也不保证把空闲空间连续合并;频繁申请和释放不同大小的块还可能造成碎片。以下错误都需要避免:

  • 忘记释放已经不用的块会造成内存泄漏;
  • 释放后继续访问会形成悬空指针;
  • 重复释放同一块内存可能破坏分配器的数据结构;
  • malloc 与 delete、new 与 free 混用属于错误配对。

桌面操作系统通常会在进程退出后统一回收该进程的资源,但这不是忽略释放的理由。裸机程序和常见 RTOS 固件通常长期运行,没有桌面系统那种进程退出阶段;一般要到系统复位才整体重新初始化内存,所以嵌入式程序更要明确对象的生命周期。

1.2.2 栈

栈遵循后进先出(LIFO,也常写作 FILO)的使用方式。函数 A 调用 B、B 再调用 C 时,C 的调用现场最后建立、最先撤销,然后才返回 B 和 A,这正适合嵌套函数调用。

一个栈帧在概念上可能包含返回地址、被保存的寄存器、局部变量和传递给其他函数的参数。不过,“局部变量和参数都在栈上”只是入门近似:编译器可能把它们放在寄存器中,可能完全消去它们;static 局部变量具有静态存储期,也不属于普通自动变量的栈空间。压栈和出栈由编译器生成的指令按照处理器 ABI 完成,CPU 执行这些指令。

在 RT-Thread 中,各线程通常拥有独立的线程栈,创建线程时传入的 stack_size 决定该线程可用栈空间。链接脚本中的 _system_stack_size = 0x400 只为启动阶段和系统环境预留了 0x400 字节,即 1024 字节,不能把它误认为所有线程共享的总栈大小。线程栈太小会越界并破坏相邻内存,因此递归深度、大型局部数组、中断嵌套和函数调用深度都应纳入估算。

1.3 单片机内存

本课 STM32F103ZE 工程的链接脚本给出了两个主要存储区:

链接区名称起始地址大小主要用途
ROM0x08000000512 KB片内 Flash,保存中断向量、代码、只读数据及 .data 初值镜像
RAM0x2000000064 KB片内 SRAM,保存 .data、.bss、系统栈、线程栈和堆等运行期数据

链接脚本中的 ROM 只是这个存储区的名字,在本板级工程中它实际对应可重复烧写的片内 Flash,不能把它等同于 PC 的硬盘。Cortex-M 可以直接从映射在 0x08000000 的 Flash 取指执行,这种方式常称为 XIP(execute in place)。芯片的取指预取和 Flash 加速机制会影响性能;特殊的高性能代码也可以显式复制到 RAM 执行,因此“单片机代码总是在 Flash 运行”也不是绝对规则。

链接脚本最能说明 .data 的双重地址:.data : AT (_sidata) 表示它的运行地址(VMA)安排在 RAM,而加载初值的地址(LMA)从 Flash 中的 _sidata 开始。复位后的启动流程大致如下:

  1. 设置初始栈指针并完成最早期的芯片初始化;
  2. 把 Flash 中的 .data 初值镜像复制到 RAM 的 _sdata 至 _edata;
  3. 把 _sbss 至 _ebss 的 .bss 区域清零;
  4. 初始化运行库和 RT-Thread,随后进入应用代码。

所以,“.data 在 RAM”只描述运行期位置;掉电后 RAM 不保存内容,初值仍必须存放在 Flash。.bss 则不需要在 Flash 中逐字节保存一大片零,只需在映像里记录清零范围。

1.4 应用案例

1.4.1 例题: RT-Thread内存分布示例

课程打开的 linkscripts/STM32F103ZE/link.lds 给出了本次构建的真实布局:

  • ROM 从 0x08000000 开始,长度为 512 KB;RAM 从 0x20000000 开始,长度为 64 KB。
  • .text、.rodata、中断向量表和 .ARM.exidx 等被链接到 ROM。
  • .data 的运行地址在 RAM,初值加载地址由 AT (_sidata) 指向 ROM。
  • .stack 在 RAM 中预留 0x400 字节系统栈。
  • .bss 位于 RAM 并由启动代码清零;其结束符号 __bss_end 又被板级代码用作 RT-Thread 堆的起点。

链接脚本描述“规则”,map 文件描述“本次链接的结果”。判断某个符号最终放在哪里时,应优先查 map 文件中的符号地址;需要继续核对段属性时,可用 arm-none-eabi-readelf -S rtthread.elf 查看节表,用 arm-none-eabi-nm -n rtthread.elf 按地址查看符号。

1.4.2 例题: RT-Thread变量地址打印

视频中串口打印得到的关键地址是:main 和 &main 均显示为 0x0800B2A5,&count 显示为 0x20001E2C。前者落在从 0x08000000 开始的 Flash 地址空间,后者落在从 0x20000000 开始的 SRAM 地址空间,因而与链接布局一致。

这里的奇数函数地址需要特别解释。map 文件中 main 符号的实际代码地址是偶数 0x0800B2A4;Cortex-M 只执行 Thumb 指令,函数指针最低位用作 Thumb 状态标志,所以程序打印出的函数指针值为 0x0800B2A5。最低位是状态标记,不表示函数真的从奇数字节边界开始存放。

在大多数表达式中,函数名 main 会转换为指向该函数的指针,&main 直接取得函数地址,因此二者具有相同的函数指针类型和值。它与数组规则看起来相似,但不能混为一谈:数组表达式通常转换为指向首元素的 T *,而 &array 是指向整个数组的 T (*)[N];两者数值起点常相同,类型和加 1 后跨越的字节数却不同。

本例的地址打印还有可移植性边界:%p 严格要求对应实参为 void *。&count 是对象指针,可显式转换为 (void *)&count;标准 C 不保证函数指针能无损转换为 void *,因此直接用 %p 打印 main 或 &main 不是可移植写法。课程代码在当前 ARM GCC、RT-Thread 日志实现和目标 ABI 下能够工作,适合观察本板地址,但不能据此推导所有 C 平台都支持这种打印方式。

count 的地址位于 RAM,符合本次未优化构建把它放在 main 线程栈中的结果,但“自动局部变量一定在栈上”仍不成立。改变优化等级、修改代码或换编译器后,变量可能进寄存器或被消除,地址也可能变化。另需注意,while (count++) 最终会发生有符号整数溢出,C 语言规定其行为未定义;它只是课程中让线程持续运行的简化写法。

1.5 博客详解

视频提到了一篇补充博客,但画面和转录没有提供可核对的标题或网址,因此这里不猜测链接。学习本节时,最可靠的验证链是“源码声明 → 编译器生成的输入节 → 链接脚本规则 → map/ELF 最终地址 → 板上打印结果”:

  1. 在 main.c 中确认对象的作用域、存储期和是否真正被引用;
  2. 在 link.lds 中确认 Flash、RAM 及各输出段的映射;
  3. 在 map 文件中确认符号有没有被丢弃以及最终地址;
  4. 用 readelf、nm 或 objdump 复核 ELF;
  5. 最后用串口或调试器观察运行时地址,并结合 Cortex-M Thumb 函数指针规则解释结果。

这样既能理解常见的“五段模型”,也能避免把模型中的概括误当成当前固件的实际布局。