尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

stm32 heap stack “两面包夹芝士”的堆栈模型 --转

发布时间:2026/9/11 11:18:46

资讯中心
01
ARTICLE

stm32 heap stack “两面包夹芝士”的堆栈模型 --转

stm32 heap stack “两面包夹芝士”的堆栈模型 --转
【说在前面的话】在中文嵌入式环境中时不时的总能看到不少朋友”堆”“栈“傻傻分不清楚我很早之前在文章《漫谈C变量——夏虫不可语冰》介绍过二者的区别这里就不再深入展开总之栈Stack“是我们用来分配局部变量、实现函数调用和在异常响应时保存被打断代码上下文的地方——具体细节不重要在本文的讨论中我们只需要记住以下信息Cortex-M系统栈的生长方向是自上而下的也就是随着更多内容被压入PUSH栈中栈顶指针的地址值是越来越小的——也就是从地址值较大的位置向地址值较小的位置移动。Cortex-M的栈顶指针指向的是“栈顶部的空位”。从最大兼容性角度考虑Cortex-M架构下栈存储空间必须对齐到8字节。“堆Heap”是我们使用 malloc 申请动态存储空间时所必须用到的一种数据结构——通常由C语言的系统库提供。堆本身只是一个内存管理的算法它所要管理的RAM空间需要用户通过某种手段将指定大小的RAM空间交到Heap算法手里。与栈不同堆的生长方向其实完全由具体的管理算法决定而堆的算法数量虽然不能说是灿若星辰至少一双手肯定数不过来——但一般来说我们可以大体认为堆的生长方向是“自下而上的”——也就是从地址值较小的位置延伸到地址值较大的位置。堆的对齐要求一般是4字节起步8字节更好情况不明的直接就32个字节吧。【常见的堆栈模型】从单纯从我不负责任的经验来看由很多GCC领衔使用的“对向生长”模型可能是嵌入式领域最常见的”大聪明模型“没有之一。如下图所示先说优点吧该模型栈和堆共用同一块连续的地址区间配置时不需要操心具体栈有多大、堆有多大配置方法简单只需要指定这一整块”堆栈“区域的起始地址以及这一整块堆栈区域的大小堆和栈的最大可用大小是此消彼长的理论上可以在某种最优的情况下达到动态的”此消彼长“可以获得理想状下最大的空间复用效率。缺点也很明显堆和栈的最大可用大小是此消彼长的在真实场景中由于”你长我也长谁怕谁”的情况居多发生随机性的“双向奔赴”从而进行“负距离”的互动可能性从理论上就不可避免因而是系统稳定性的“一生之敌”。实验室里7x24小时完美通过一去客户那里就随机性宕机的“挖坑之王”。为了提高系统稳定性人们简单地将“堆”和“栈”拆开来单独配置就获得了常见的“两段式堆栈模型”可以看到相较之前的模型虽然仍然是“对向生长”但由于栈和堆有了自己固定空间因此可以方便地根据实际用量调整它们的大小比如留下足够的余量从而降低彼此入侵带来的稳定性风险。更有甚者在二者的边界上引入一个特殊值比如0xDEADBEEF所充当的溢出检测”金丝雀Canary”——一旦发现这个值与预设的不同基本就可以断定发生了溢出。【最安全的“两面包夹芝士”模型】将“栈Stack”和堆(Heap)独立配置的“两段式”模型配合边界金丝雀为预防和检测堆栈溢出提供了可能。但对金丝雀的检测总归有种“事后诸葛亮”的感觉而且很多时候我们是想不起来去检查金丝雀的比如栈曾经一度跨越雷池入侵到了堆空间但由于此时堆恰巧分配出去的RAM不多没有与栈发生实质性的重叠因而整个系统“安然无恙”——这只能说是运气好而风险肯定是存在的——正由于系统“安然无恙”因此我们在系统开发阶段可能不会想起来去检查一下金丝雀有自动检查机制的RTOS除外那么这类溢出就有可能被隐藏。基于上述原因有没有一种方法可以彻底避免栈/堆入侵对系统的破坏在栈/堆入侵的瞬间就立即表现出来——方便我们在调试阶段立即发现答案是肯定的这就是“两面包夹芝士”模型此前又叫“三明治”模型从上图很容易看出该模型属于“两段式模型”的变种与过去堆和栈的“相向生长”不同该模型采用了“背向生长”的方式——避免了栈与堆的相互伤害栈被放在了SRAM的起始位置Cortex-M从架构上鼓励将SRAM放置在从0x2000-0000开始的地址上这样一旦发生栈溢出指针就会指向SRAM存储器以外的无效位置——这在大部分芯片上会触发“Bus Fault”从而产生故障异常——这就实现了对栈溢出的当场捕获并且不依赖MPU或者“栈底地址限制检测Stack Limit Checking”之类的架构特性。当然有些芯片设计者可能会选择“隐藏这类错误”不仅不会触发异常而且会当做无事发生具体表现为对无效地址的写入操作将被无视对无效地址的读取操作将会返回0值。具体可以参考芯片手册或者干脆做个实验。堆被放置在了RAM的最后中间夹着存放静态/全局变量的“RW/ZI区域”这也是“两面包夹芝士”模型或者“三明治”模型名称的由来。这样的安排也彻底杜绝了栈和堆对“RW/ZI区域”发生入侵的可能。当堆溢出时与栈类似对大部分芯片来说都会触发故障异常从而在开发调试阶段第一时间被我们所捕获。通过链接脚本比如Arm Compiler的Scatter Script或者gcc、clang的ld的一些运算功能我们甚至可以做到“将剩下的空间全留给HEAP”从而简化系统的配置。【Arm官方低调推荐的”新“方法】其实Arm Compiler 在很久之前就逐步淘汰了“大聪明的单段对向生长模型”而“两段模型”早已成为主流。比如我们在汇编启动文件中经常可以见到这样的代码片段这就是“两段式”模型的证据。实际上在启动代码的尾部汇编程序通过代码语言javascript复制IMPORT __use_two_region_memory选择了对两段式模型提供支持的libc库看过我前面一期文章《【嵌入式秘术】Cortex-M静态链接库——从入坑到入土》的小伙伴一定会眼前一亮——“原来是这样啊我们其实是手动选择了对应两段式堆栈模型的库版本呢”。问题是我们要如何在Arm Compiler环境下实现“两面包夹芝士”模型呢我们需要写汇编代码么不用担心即便你的启动文件是汇编的具体操作方法也非常简单。步骤如下步骤一准备阶段注意此步骤只针对使用汇编启动文件的情况。如果你的启动文件是C则可跳过该步骤。在工程管理器中找到你的汇编启动文件它通常以代码语言javascript复制startup_芯片型号.s的形式命名找到配置栈和堆大小的部分红框标注的部分将其整体删除或者注释掉。注意请保留这里的 PRESERVE8和THUMB部分。继续移动到汇编文件的尾部找到如下的代码同理将其删除或者注释掉。注意这里要保留 END 。移动到中断向量表的定义处将红框中所标注的代码选中代码语言javascript复制__Vectors DCD __initial_sp替换为如下内容代码语言javascript复制IMPORT |Image$$ARM_LIB_STACK$$ZI$$Limit| __Vectors DCD |Image$$ARM_LIB_STACK$$ZI$$Limit|即保存启动文件。此时如果你着急编译当你当你开启了microLib时很可能会看到如下的链接错误即代码语言javascript复制Error: L6218E: Undefined symbol __initial_sp (referred from entry2.o).或者你没有开启 microLib则会看到一个不同的错误即代码语言javascript复制Error: L6915E: Library reports error: The semihosting __user_initial_stackheap cannot reliably set up a usable heap region if scatter loading is in use这都是正常的不必惊慌。这类错误会在完成后面的步骤后自然消失。步骤二获取链接脚本Scatter Script打开工程配置窗口“Options for Target”切换到“Linker”选项卡首先一定要确保你勾选了图中的“Use Memory Layout from Target Dialog”选项。在这一前提下再次取消对它的勾选我们会看到MDK基于当前的Memory Layout为我们在Out目录下生成了一个与工程同名的链接脚本比如图中的工程名叫example因此生成的链接脚本为example.sct。单击Edit按钮可以看到脚本的内容先别着急半路开香槟——该文件是系统自动生成的如果我们不移动它的位置那么只要哪次手抖勾选了“Use Memory Layout from Target Dialog”它的内容就会立即被覆盖掉——意味着我们在后续步骤中所做的修改就会付诸东流。为了避免该问题应该将它从 Out 目录中移动到工程目录下。具体步骤为右键单击脚本文件名选择“Open Container Folder”来打开文件所在目录找到Scatter Script脚本文件后将其拷贝到上一级目录下也就是工程目录重新打开工程配置窗口确保我们“没有”选中“Use Memory Layout from Target Dialog”选项并在Scatter File文本框中直接填写我们刚刚拷贝出来的脚本文件名由于我们直接放在工程目录下因此这里直接用相对路径./example.scat或者example.scat就行。单击OK保存配置。步骤三在链接脚本中部署堆和栈在编辑器中打开我们的脚本文件图中选中的部分实际上包含了RAM中的所有内容包括静态变量、全局变量、栈和堆是的你的猜测没错当我们没有特别说明时Stack和Heap都以ZI的形式存在于上述空间内其位置任由Linker摆布——这当然也带来了很多不确定性。接下来我们要做的就是按照我们的设计——“两面包夹芝士”来明确的指定栈和队列的大小和位置我们要做的是首先将一个名为ARM_STACK_LIB的execution region放置到RAM的起始位置代码语言javascript复制ARM_LIB_STACK 0x20000000 ALIGN 8 EMPTY 0x800 {}这里起始地址是0x20000000STACK的大小是0x800ALIGN 8指定对齐是8个字节EMPTY是必须要保留的它用来说明ARM_LIB_STACK是一个大数组里面默认填充了0。如果你想修改填充的内容还可以通过关键字FILL 填充值来指定填充的32bit数值比如代码语言javascript复制ARM_LIB_STACK 0x20000000 ALIGN 8 FILL 0xDEADBEEF EMPTY 0x800 {}它实现了往0x20000000开始的0x8002KB大小的栈空间中填充0xDEADBEEF的功能熟悉“水印法”测量栈用量的小伙伴一定大喜。为了让ZI/RW紧随其后——放在STACK的后面我们需要对 RW_IRAM1 的描述进行修改即从代码语言javascript复制RW_IRAM1 0x20000000 0x00020000 {修改为代码语言javascript复制RW_IRAM1 0 {即这里我们在原本放置地址0x20000000的位置用0表示“紧随其后”并删除了原本的大小0x00020000——这样做就是告诉编译器“RW_IRAM1”不限制大小。接下来我们要用类似的方法紧随 RW_IRAM1 之后放置名为 ARM_LIB_HEAP 的execution region——用来指定堆的位置和大小代码语言javascript复制ARM_LIB_HEAP 0 ALIGN 8 EMPTY 0x200 {}可以看到这里与栈的设置方式几乎一样而“0”则同样告诉linker请将HEAP紧邻前面的 RW_IRAM1 放置。最终的效果如下代码语言javascript复制LR_IROM1 0x00000000 0x00040000 { ER_IROM1 0x00000000 0x00040000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) .ANY (XO) } ARM_LIB_STACK 0x20000000 ALIGN 8 EMPTY 0x800 {} ;RW_IRAM1 0x20000000 0x00020000 { ; RW data RW_IRAM1 0 { ; RW data .ANY (RW ZI) } ARM_LIB_HEAP 0 ALIGN 8 EMPTY 0x800 {} }还记得我们前面删除了原本对RW_IRAM1的尺寸限制也就是0x0002000么这意味着现阶段的脚本文件对我们实际使用的RAM空间是没有任何限制的——换句话说如果超出了芯片实际的SRAM大小编译器也是不会报告错误的。为了重新加入这一限制我们可以在 ARM_LIB_HEAP的后面加入下面的语句代码语言javascript复制ScatterAssert(ImageLimit(ARM_LIB_HEAP) 0x20000000 0x20000)这里ScatterAssert()是让linker对括号中的内容进行检查ImageLimit()是在编译时刻获得括号内指定 execution region 的终止地址0x200000000x20000是例子中整个RAM的终止地址这里假设RAM从0x20000000开始大小是0x20000综合来说上述代码的作用是在linker的链接阶段计算HEAP的终止地址确认它是否落在了RAM的有效范围内。如果超出了范围我们就会看到如下的编译错误代码语言javascript复制Error: L6388E: ScatterAssert expression (ImageLimit(ARM_LIB_HEAP) 0x20000000 0x20000) failed on line 22 : (0x20001220 0x20020000)最终效果如下对应的“两面包夹芝士”图示如下编译工程【“虽迟但到”的宏和头文件】是的你猜得没错我们可以在链接脚本中使用编译预处理这意味着我们可以使用宏我们可以include头文件我们可以进行条件编译具体方法并不难只需要在链接脚本的“第一行”注意一定要是第一行Number One——前面不能有任何内容空行或者注释都不行——放置如下的内容代码语言javascript复制#! armclang --targetarm-arm-none-eabi -mcpucortex-m0 -E -xc然后我们就可以在脚本文件中愉快地使用宏和include了。看到脚本中这么多的常数了么地址啊、大小啊这下都可以用宏替代了。比如代码语言javascript复制#define RAM1_SIZE 0x00020000 #define RAM1_BASE 0x20000000 #define RAM1_LIMIT (RAM1_BASE RAM1_SIZE) #define STACK_SIZE 0x800 #define HEAP_SIZE 0x200其实我们还可以把宏的定义部分放置到专门的配置头文件中——通过#include来包含——从而真正做到一个配置头文件定天下。至于宏可以有哪些骚操作感兴趣的小伙伴可以关注【裸机思维】公众号后发送关键字“宏”来获取相关文章这里就不再赘述。【如何把剩余的空间都留给堆】很多时候把剩余空间都留给堆是一个不错的想法这样“两面包夹芝士”模型就获得了和“单段相向生长”模型一样的优势——配置简单。由于我们已经有了宏的帮助借助ImageLimit()我们可以将 HEAP_SIZE 的宏定义修改为代码语言javascript复制#define HEAP_SIZE (RAM1_LIMIT - ImageLimit(RW_IRAM1))它的意思是用RAM1的终止地址减去RW_IRAM1的终止地址获得中间的差额其图示如下【后记】在这篇文章中我们介绍了栈和堆在存储器中的常见排布模型比较了它们的优劣并提出了一种被称为“两面包夹芝士”的两段式模型。该模型可以有效避免堆栈溢出破坏常规变量溢出发生时可以在大部分芯片中第一时间触发异常——被我们捕捉到后面我们以MDK为例介绍了如何在Arm Compiler环境下应用这一模型并引入了使用宏对其进行进一步拓展的方法。值得说明的是这一方法对Arm Compiler 5armcc和Arm Compiler 6armclang同样适用。支持MicroLib和非MicroLib的情况。无论启动文件是否为汇编都可以正常工作。实际上使用链接脚本而非汇编启动文件来对两段式堆栈模型进行配置是Arm公司一直以来所提倡的。随着Arm Compiler 6的逐步普及更多的芯片公司正在追随Arm的脚步将原本的汇编启动文件替换为 CMSIS 目录下所提倡的纯C语言启动文件。作为【反复横跳】系列的一部分我希望通过这篇文章能帮助大家扫清从Arm Compiler 5向Arm Compiler 6过渡图中与栈相关的障碍。希望对你有所帮助。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。