在中文嵌入式环境中,时不时的总能看到不少朋友”堆”“栈“傻傻分不清楚,我很早之前在文章《漫谈C变量——夏虫不可语冰》介绍过二者的区别,这里就不再深入展开,总之:
“堆(Heap)”是我们使用 malloc 申请动态存储空间时所必须用到的一种数据结构——通常由C语言的系统库提供。
【常见的堆栈模型】
先说优点吧:
为了提高系统稳定性,人们简单地将“堆”和“栈”拆开来单独配置,就获得了常见的“两段式堆栈模型”:
可以看到,相较之前的模型,虽然仍然是“对向生长”,但由于栈和堆有了自己固定空间,因此可以方便地根据实际用量调整它们的大小(比如留下足够的余量),从而降低彼此入侵带来的稳定性风险。 更有甚者,在二者的边界上引入一个特殊值(比如0xDEADBEEF)所充当的溢出检测”金丝雀(Canary)”——一旦发现这个值与预设的不同,基本就可以断定发生了溢出。
【最安全的“两面包夹芝士”模型】
基于上述原因,有没有一种方法可以:
从上图很容易看出:
【Arm官方低调推荐的”新“方法】
这就是“两段式”模型的证据。实际上,在启动代码的尾部,汇编程序通过:
IMPORT __use_two_region_memory
选择了对两段式模型提供支持的libc库:
看过我前面一期文章《【嵌入式秘术】Cortex-M静态链接库——从入坑到入土》的小伙伴一定会眼前一亮——“原来是这样啊,我们其实是手动选择了对应两段式堆栈模型的库版本呢”。 问题是,我们要如何在Arm Compiler环境下实现“两面包夹芝士”模型呢?我们需要写汇编代码么? 不用担心,即便你的启动文件是汇编的,具体操作方法也非常简单。步骤如下: 步骤一:准备阶段
注意:此步骤只针对使用汇编启动文件的情况。如果你的启动文件是C,则可跳过该步骤。
在工程管理器中找到你的汇编启动文件,它通常以
startup_<芯片型号>.s
的形式命名:
找到配置栈和堆大小的部分(红框标注的部分):
将其整体删除(或者注释掉)。注意:请保留这里的 PRESERVE8和THUMB部分。
继续移动到汇编文件的尾部,找到如下的代码:
同理,将其删除(或者注释掉)。注意:这里要保留 END 。
移动到中断向量表的定义处:
将红框中所标注的代码选中:
__Vectors DCD __initial_sp
替换为如下内容:
IMPORT |Image$$ARM_LIB_STACK$$ZI$$Limit|
__Vectors DCD |Image$$ARM_LIB_STACK$$ZI$$Limit|
即:保存启动文件。
此时,如果你着急编译,当你当你开启了microLib时,很可能会看到如下的链接错误:
即:
Error: L6218E: Undefined symbol __initial_sp (referred from entry2.o).
或者你没有开启 microLib,则会看到一个不同的错误:即:
Error: L6915E: Library reports error: The semihosting __user_initial_stackheap cannot reliably set up a usable heap region if scatter loading is in use
这都是正常的,不必惊慌。这类错误会在完成后面的步骤后自然消失。打开工程配置窗口“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_LIB_STACK 的execution region放置到RAM的起始位置:
ARM_LIB_STACK 0x20000000 ALIGN 8 EMPTY 0x800 {}
这里:
ARM_LIB_STACK 0x20000000 ALIGN 8 FILL 0xDEADBEEF EMPTY 0x800 {}
它实现了往0x20000000开始的0x800(2KB)大小的栈空间中填充0xDEADBEEF的功能:
熟悉“水印法”测量栈用量的小伙伴一定大喜。
为了让ZI/RW紧随其后——放在STACK的后面,我们需要对 RW_IRAM1 的描述进行修改,即从:
RW_IRAM1 0x20000000 0x00020000 {
修改为:
RW_IRAM1 +0 {
即:这里,我们在原本放置地址0x20000000的位置用"+0"表示“紧随其后”,并删除了原本的大小0x00020000——这样做就是告诉编译器“RW_IRAM1”不限制大小。
接下来,我们要用类似的方法紧随 RW_IRAM1 之后放置名为 ARM_LIB_HEAP 的execution region——用来指定堆的位置和大小:
ARM_LIB_HEAP +0 ALIGN 8 EMPTY 0x200 {}
可以看到,这里与栈的设置方式几乎一样,而“+0”则同样告诉linker:请将ARM_LIB_HEAP紧邻前面的 RW_IRAM1 放置。最终的效果如下:
LR_IROM1 0x00000000 0x00040000 {
ER_IROM1 0x00000000 0x00040000 {
(RESET, +First)
*(InRoot$$Sections)
(+RO)
(+XO)
}
ARM_LIB_STACK 0x20000000 ALIGN 8 EMPTY 0x800 {}
0x20000000 0x00020000 { ; RW data
RW_IRAM1 +0 { ; RW data
(+RW +ZI)
}
ARM_LIB_HEAP +0 ALIGN 8 EMPTY 0x200 {}
}
还记得我们前面删除了原本对RW_IRAM1的尺寸限制(也就是0x0002000)么?这意味着,现阶段的脚本文件对我们实际使用的RAM空间是没有任何限制的——换句话说,如果超出了芯片实际的SRAM大小,编译器也是不会报告错误的。为了重新加入这一限制,我们可以在 ARM_LIB_HEAP的后面加入下面的语句:
ScatterAssert(ImageLimit(ARM_LIB_HEAP) <= 0x20000000 + 0x20000)
这里:
Error: L6388E: ScatterAssert expression (ImageLimit(ARM_LIB_HEAP) <= 0x20000000 + 0x20000) failed on line 22 : (0x20001220 <= 0x20020000)
最终效果如下:
对应的“两面包夹芝士”图示如下:
编译工程:
【“虽迟但到”的宏和头文件】
具体方法并不难,只需要在链接脚本的“第一行”,注意一定要是第一行(Number One)——前面不能有任何内容,空行或者注释都不行——放置如下的内容:
然后我们就可以在脚本文件中愉快地使用宏和include了。看到脚本中这么多的常数了么?地址啊、大小啊,这下都可以用宏替代了。比如:
#define RAM1_SIZE 0x00020000
#define RAM1_BASE 0x20000000
#define RAM1_LIMIT (RAM1_BASE + RAM1_SIZE)
#define STACK_SIZE 0x800
#define HEAP_SIZE 0x200
其实我们还可以把宏的定义部分放置到专门的配置头文件中——通过#include来包含——从而真正做到一个配置头文件定天下。 至于宏可以有哪些骚操作,感兴趣的小伙伴可以关注【裸机思维】公众号后,发送关键字“宏”来获取相关文章,这里就不再赘述。
...
以解决可能出现的编译错误。
或者
则是告诉编译器从相对路径 "../../cfg" 下去搜索头文件。
当你通过修改头文件的方式来更新scatter script的内容后,第一次编译,请务必一定要以“Rebuild All”的形式进行,否则你的修改不会生效。
别说我没提醒过你哦!
【如何把剩余的空间都留给堆】
它的意思是:用RAM1的终止地址减去 RW_IRAM1的终止地址,获得中间的差额,其图示如下:
看似完美,有的小伙伴一编译就会报告如下的错误:
即:
Error: L6388E: ScatterAssert expression (ImageLimit(ARM_LIB_HEAP) <= (0x20000000 + 0x20000)) failed on line 29 : (0x20020004 <= 0x20020000)
奇怪,我们的计算公式应该没错啊——Heap的尺寸应该就是使用整个 RAM的终止地址减去 RW_IRAM1 的终止地址啊,为什么提示差4个字节呢?
聪明的小伙伴一定已经注意到了,我们在 ARM_LIB_HEAP 的定义中,指定了其首地址的对齐为8字节:
ARM_LIB_HEAP +0 ALIGN 8 EMPTY HEAP_SIZE {}
而 RW_IRAM1 的尺寸不一定是8的整倍数,当它只是“4的整倍数”而不满足“8的整倍数”这一条件时,ImageLimit(RW_IRAM1) 的后面与 ARM_LIB_HEAP的起始地址之间就会产生一个4字节的气泡:
要解决这一问题也很简单,我们可以使用 scatter script 脚本为我们提供的一个专门来进行地址对齐的函数:
AlignExpr(<地址数值>,<对齐要求>)
比如:
AlignExpr(ImageLimit(RW_IRAM1), 8)
就表示对 RW_IRAM1 的终止地址进行 8 字节对齐。借助它的帮助,我们可以修改脚本如下:
(RAM1_LIMIT - AlignExpr(ImageLimit(RW_IRAM1), 8))
即:
再编译时,已然没有问题。
【如何随时随地的了解栈的最大使用情况】
水印法是实现“最大栈用量统计”的最有效方式。其原理也不复杂:
对于步骤1来说,可以通过前面介绍的 FILL 关键字来完成对栈空间的填充:
ARM_LIB_STACK 0x20000000 ALIGN 8 FILL 0xDEADBEEF EMPTY STACK_SIZE
{
}
然后借助下面的代码完成统计工作:
uint32_t calculate_stack_usage_topdown(void)
{
extern uint32_t Image$$ARM_LIB_STACK$$Limit[];
extern uint32_t Image$$ARM_LIB_STACK$$Length;
uint32_t *pwStack = Image$$ARM_LIB_STACK$$Limit;
uint32_t wStackSize = (uintptr_t)&Image$$ARM_LIB_STACK$$Length / 4;
uint32_t wStackUsed = 0;
do {
if (*--pwStack == 0xDEADBEEF) {
break;
}
wStackUsed++;
} while(--wStackSize);
printf("
Stack Usage: [%d/%d] %2.2f%%
",
wStackUsed * 4,
(uintptr_t)&Image$$ARM_LIB_STACK$$Length,
( (float)wStackUsed * 400.0f
/ (float)(uintptr_t)&Image$$ARM_LIB_STACK$$Length));
return wStackUsed * 4;
}
这里有几点需要说明一下:
armlink 为我们提供了通用的语法来获取 execution region 的起始地址、大小和终止地址:
extern uint32_t Image$$$$Base[];
extern uint32_t Image$$$$Length;
extern uint32_t Image$$$$Limit[];
这里,Base和Limit被定义成了不定长数组的形式,因此我们可以直接把它们当做常量指针来使用——获取所需的地址。Length被定义成了一个普通的uint32_t型的变量,按照官方文档的要求,虽然很反直觉,但如果要获取它的值——也就是对应execution region的大小,必须要对其进行&操作,并随后强制转化为整形数值。这么说也许有点抽象,不妨对照前面的代码来看:
#include
...
extern uint32_t Image$$ARM_LIB_STACK$$Limit[];
extern uint32_t Image$$ARM_LIB_STACK$$Length;
uint32_t *pwStack = Image$$ARM_LIB_STACK$$Limit;
uint32_t wStackSize = (uintptr_t)&Image$$ARM_LIB_STACK$$Length / 4;
这里,我们通过 Image$ARM_LIB_STACK$$Limit[] 将栈的终止地址赋值给了(uint32_t *)型的指针 pwStack。以表达式 (uintptr_t)&Image$$ARM_LIB_STACK$$Length 获取了 ARM_LIB_STACK 的实际大小。
普通情况下,在变量名中使用 “$” 会在Arm Compiler 6引发警告:
warning: '$' in identifier [-Wdollar-in-identifier-extension]
为了让编译器闭嘴,我们临时对函数 calculate_stack_usage_topdown() 在编译时刻做了屏蔽warning的操作:
uint32_t calculate_stack_usage_topdown(void)
{
...
}
而 -Wdouble-promotion 则是由printf中的百分比运算引起的,一并屏蔽即可。
在任意时刻,当我们想要知道当前系统的最大栈用量时,可以直接调用函数 calculate_stack_usage_topdown(),比如:
int main(void)
{
...
calculate_stack_usage_topdown();
...
}
一个可能的执行结果如下:
自上而下统计栈用量的方法优点是:当栈空间很大而实际栈用量较小时,可以较快的完成统计;缺点是:如果恰好栈里因为任何原因(比如用户定义了一个局部变量,然后恰好给他赋予了我们的水印常数),就会造成统计错误——没能实际获得最大深度。
针对这一问题,我们可以修改搜索策略,从占空间的起始地址(也就是基地址)处向上搜索“非水印常数”——一旦碰到,就可以用已知的栈空间尺寸减去已经经历过的RAM总量作为栈的最大深度(最大用量)。
该方法的优点是:不容易发生误判;缺点是:当栈空间很大而实际栈用量较小时往往较为耗时。对应的代码如下:
uint32_t calculate_stack_usage_bottomup(void)
{
extern uint32_t Image$$ARM_LIB_STACK$$Base[];
extern uint32_t Image$$ARM_LIB_STACK$$Length;
uint32_t *pwStack = Image$$ARM_LIB_STACK$$Base;
uint32_t wStackSize = (uintptr_t)&Image$$ARM_LIB_STACK$$Length;
uint32_t wStackUsed = wStackSize / 4;
do {
if (*pwStack++ != 0xDEADBEEF) {
break;
}
} while(--wStackUsed);
printf("
Stack Usage: [%d/%d] %2.2f%%
",
wStackUsed * 4,
wStackSize,
((float)wStackUsed * 400.0f / (float)wStackSize));
return wStackUsed * 4;
}
【后记】
后面,我们以MDK为例介绍了如何在Arm Compiler环境下应用这一模型,并引入了使用宏对其进行进一步拓展的方法。
值得说明的是,这一方法对Arm Compiler 5(armcc)和Arm Compiler 6(armclang)同样适用。支持MicroLib和非MicroLib的情况。无论启动文件是否为汇编,都可以正常工作。
实际上,使用链接脚本而非汇编启动文件来对两段式堆栈模型进行配置是Arm公司一直以来所提倡的。随着Arm Compiler 6的逐步普及,更多的芯片公司正在追随Arm的脚步将原本的汇编启动文件替换为 CMSIS 目录下所提倡的纯C语言启动文件。
作为【反复横跳】系列的一部分,我希望通过这篇文章能帮助大家扫清从Arm Compiler 5向Arm Compiler 6过渡图中与栈相关的障碍。希望对你有所帮助。
审核编辑 :李倩
全部0条评论
快来发表一下你的评论吧 !