大多数嵌入式工程师在早期就会遇到这个问题:同样的代码在相同的处理器上,在某种环境下运行速度很快,而在另一种环境下却会变得异常缓慢。造成这种现象的原因几乎总是与代码和数据在内存中的存储位置有关。
桌面电脑和服务器处理器通过多级缓存来掩盖内存延迟的问题。而许多嵌入式处理器,尤其是基于ARM Cortex-M和Cortex-R架构的芯片,则采取了不同的处理方式——它们允许用户直接控制多个不同性能特性的内存区域。
本手册介绍了ITCM、DTCM以及DDR内存的含义与区别,说明了如何将代码和数据放置到合适的内存区域中,同时还提供了监测固件内存使用情况的方法。
目录
先决条件
要想充分理解本指南的内容,您应该具备基本的C语言编程知识,包括指针、结构体以及静态变量与局部变量的区别。
同时,对嵌入式开发中的编译、链接以及将固件下载到目标设备等概念也有所了解会很有帮助。
最后,了解CPU是如何获取并执行指令的,也会让您更容易理解后续关于性能的内容。
您并不需要在这些领域成为专家——本文会在涉及相关概念时对其进行详细解释。
为什么嵌入式内存架构如此重要
现代嵌入式处理器的时钟频率可能高达400 MHz甚至更高,因此它能够在几纳秒内完成一次指令的执行。
但是,当处理器需要从内存中获取指令或读取变量时,内存的响应速度可能无法满足需求。此时处理器就会陷入等待状态,等待内存子系统提供所需的数据。这种等待时间会迅速累积起来。
在台式计算机上,硬件缓存(L1、L2、L3)位于CPU和主内存之间,能够自动将最近使用过的数据保存在附近。缓存硬件会自行决定哪些数据需要保留,哪些需要被替换,并且这一过程是透明的。程序员通常无需考虑这些细节,而且在没有进行手动干预的情况下,系统的性能也往往足够好。
但在许多嵌入式处理器中,情况有所不同。这些处理器并没有使用硬件缓存,而是拥有三个不同的内存区域,每个区域都与CPU以不同的方式连接。
| 内存类型 | 存储内容 | 访问速度 | 典型容量 |
|---|---|---|---|
| ITCM | 指令(可执行代码) | 单周期访问(访问时间固定不变) | 512 KB至2 MB |
| DTCM | 数据(变量、堆栈、缓冲区) | 单周期访问(访问时间固定不变) | 512 KB至1.5 MB |
| DDR | 其他所有数据 | 多周期访问(访问时间可能变化) | 4 MB至数GB |
上表列出了典型的ARM Cortex-M或Cortex-R嵌入式系统中会出现的三种内存类型。ITCM和DTCM速度很快,但容量较小;而DDR速度较慢,但容量较大。
“访问时间固定不变”这一特点意味着,无论之前是否有其他操作在芯片上进行,每次访问ITCM或DTCM时,其访问时间都是一样的。“多周期访问”这一特点则说明,DDR的访问时间会根据其内部状态及其控制器的状况而发生变化。
作为开发者,你需要自行决定程序中的各个部分应该存储在哪个内存区域中。编译器和链接器并不会自动做出这些选择——你需要通过源代码中的段属性以及链接脚本中的配置规则来指定这些信息。正确设置这些参数,往往能够决定程序是否能够按时完成实时任务。
什么是ITCM(指令紧密耦合内存)?
ITCM的全称是指令紧密耦合内存。
“指令”这一名称表示,这种内存用于存储可执行的机器代码,也就是CPU会读取并运行的编译后的指令。
“紧密耦合”这一术语意味着,这种内存与CPU核心位于同一块芯片上,它们通过专用的总线进行连接,不存在任何竞争或仲裁机制。因此,没有其他组件会干扰这种数据传输路径,也没有需要遍历的缓存层次结构。当CPU需要获取某条指令时,ITCM会通过这条专用通道直接将其传递给CPU,而芯片上的其他部分无法干扰这一过程。
每次在一个时钟周期内,CPU都能够从ITCM中获取一条指令。这种访问速度既快又具有确定性,不会因为访问模式、之前的访问记录,或者总线上正在发生的其他操作而发生变化。这种确定性与机器的实际执行速度同样重要,因为只有有了它,才能进行最坏情况下的执行时间分析。在那些对安全性要求极高的系统中,你必须能够证明某个函数肯定能在指定的周期数内完成执行。而ITCM技术使得这种证明变得容易得多。
为什么单周期数据获取如此重要
每一行C代码在编译后都会转化成一条或多条机器指令,而这些指令在CPU能够解码并执行之前,都必须先从内存中被读取出来。对于每一条指令来说,这个读取操作都是必不可少的;因此,在循环结构或被频繁调用的函数中,即使每次指令读取所花费的时间很短,这些时间累积起来也会导致整体执行速度大幅下降。
假设有一个循环会执行100万次,而每次循环都需要进行10次指令读取操作,那么总共就需要进行1000万次数据读取。
ITCM:1000万次数据读取 × 1个周期 = 1000万周期
DDR:1000万次数据读取 × 8个周期 = 8000万周期
差异:7000万周期
在400 MHz的频率下:7000万周期 ÷ 40000万周期 = 0.175秒 = 175毫秒
从这个计算结果可以看出,当同一个循环使用ITCM和DDR技术来执行时,总的执行周期数是有所差异的。使用ITCM时,每次数据读取操作只需要1个周期,因此1000万次数据读取总共需要1000万周期;而使用DDR时,每次数据读取需要8个周期,所以同样的1000万次数据读取总共需要8000万周期。这种差异相当于7000万周期,而在400 MHz的频率下,这相当于175毫秒的延迟。
在那些以1 kHz的频率运行控制循环的实时系统中,175毫秒的额外延迟绝对不是一个小问题。这种延迟可能会导致系统错过截止时间、丢失传感器传来的数据,或者产生错误的输出结果。在电机控制应用中,错过截止时间可能会对硬件造成损坏;而在音频处理领域,这种延迟则会导致声音中出现异常干扰。因此,指令读取速度慢所带来的影响绝非抽象的概念,而是非常现实且严重的问题。
哪些内容应该被放入ITCM中
由于ITCM的容量有限(通常在512 KB到2 MB之间),因此你不可能将所有的固件代码都存储在其中。所以,在选择哪些代码放入ITCM时,必须有所取舍。
中断服务例程(ISRs)是首选对象。这些例程会在硬件事件发生时被立即执行,比如定时器计时结束、ADC转换完成,或者通信外设接收到数据等等。它们需要以最快的速度执行完毕并返回。
如果ISR从DDR内存中读取指令,那么每次数据读取操作都需要多个周期,从而导致其整体执行时间大大增加,甚至可能超过规定的截止时间。因此,将ISR放入ITCM中可以确保它们能够以最高的速度运行,并且其执行时间也能被完全预测出来。
实时处理函数也是优先考虑的对象。这些函数包括信号处理程序、电机控制循环、音频处理模块,以及任何那些需要以固定频率运行并且必须在规定的时间内完成执行的函数等等。
如果你的音频编解码器回调函数需要每5毫秒处理一批样本数据,那么每一个指令获取周期都十分重要。将这类函数放在ITCM内存区域中,就能让CPU有更多的时间用于实际计算,而不会浪费在等待内存数据的过程中。
你的主处理流程中的内层循环也会从将函数放入ITCM这一措施中受益匪浅。如果你的固件有80%的执行时间都花在了少数几个函数上,那么这些函数就应该被放在ITCM中。性能分析工具以及链接器生成的映射文件(本文后面会有介绍)可以帮助你确定哪些函数是系统中最常被执行的。
那些需要确保执行时间具有确定性的函数,即使它们并非系统中最快的执行路径,也应该被放入ITCM中。因为ITCM区域的访问时间是固定的,所以这种安排能够使时序分析变得更加可靠。对于那些对安全性要求极高的系统(如汽车、医疗、航空航天领域),这一点尤为重要——因为在这些系统中,你需要向认证机构证明程序在最坏情况下的运行时间。
如何将函数放入ITCM内存区域
你可以使用GCC提供的章节属性来告诉编译器,某个函数应该被放置到哪个内存区域中。然后,在链接器脚本中,你需要将这个内存区域映射到ITCM区域。
__attribute__((section(".itcm_text")))))
void my_criticalISR(void) {
volatile uint32_t *sensor_reg = (volatile uint32_t *)0x40001000;
uint32_t reading = *sensor_reg;
process_sample(reading);
}
在这段代码中,__attribute__((section(".itcm_text")))这条指令告诉编译器,将这个函数的机器码生成到名为.itcm_text的内存区域中,而不是默认的.text区域。该函数会读取地址0x40001000处的传感器寄存器值,将结果存储在局部变量中,然后再传递给process_sample()函数进行进一步处理。volatile关键字告诉编译器,这个内存地址的值可能会随时发生变化(因为它是一个硬件寄存器),因此编译器不能优化掉对这个地址的读操作。
单独来看,章节属性并不能决定函数最终会被放置到物理内存的哪个位置。它只是让编译器知道应该用哪个名称来标记这段代码而已。
实际的内存分配工作是由链接器脚本完成的,它会将.itcm_text区域映射到ITCM地址范围内。我们会在后面的章节中详细介绍链接器脚本的相关内容。
通常情况下,ITCM内存区域会占用多少空间呢?
下面是一个来自实际嵌入式项目的示例数据,可以帮助你了解这个问题的具体规模:
内存区域 使用大小 区域总大小 占用比例
ITCM: 570936 B 2 MB 27.22%
DTCM: 727240 B 1572608 B 46.24%
DDR: 622915 B 4 MB 14.85%
这些数据来自链接器生成的映射文件的摘要部分。它显示了系统中三个不同的内存区域,以及编译后的固件分别占用了这些区域的多少空间。
ITCM有2MB的可用空间,而固件目前使用了大约557KB的空间,占用了27.22%。DTCM有约1.5MB的可用空间,目前使用了727KB,占用了46.24%。DDR有4MB的可用空间,目前使用了大约609KB,占用了14.85%。
这个项目仅使用了ITCM中2MB可用空间中的557KB,约占27%,因此还有很大的扩展空间。
在实际使用中,建议将ITCM的利用率控制在80%-85%以下,这样才能为未来的功能添加或库更新留出足够的空间。如果利用率超过90%,那么只要再添加一个功能,就可能会导致构建失败;此时应该及时将那些不太重要的代码移放到DDR上。
什么是DTCM(数据紧密耦合内存)?
DTCM就是数据紧密耦合内存。它的工作原理与ITCM相同(物理位置靠近CPU核心,通过专用总线连接,支持单周期访问),但它用来存储的是数据而不是指令。
如果ITCM是存放代码的地方,那么DTCM就是让代码实际执行的地方。它是CPU在执行那些对性能要求极高的功能时所使用的快速临时存储空间。所有被读取的变量、所有数组访问操作、以及热点代码路径中的所有栈操作,都会通过DTCM来完成。因此,尽可能提高DTCM的速度,就能有效减少程序运行中出现的延迟现象。
哪些类型的数据适合存储在DTCM中?
栈帧是DTCM中最重要的数据类型。每次函数调用都会创建一个栈帧,其中包含局部变量、返回地址以及被保存的寄存器值;而每次函数返回时,都会销毁这个栈帧。
如果栈被存储在DTCM中,那么函数调用和返回相关的内存访问操作就能在单个周期内完成。但如果栈位于DDR中,那么仅进行栈操作这一系列操作就会消耗多个周期的时间,从而影响程序的执行效率。
在大多数Cortex-M和Cortex-R架构中,启动代码会默认将栈指针设置为指向DTCM,因此用户无需额外配置就能享受到这一优势。
频繁被访问的全局变量也非常适合存储在DTCM中。状态机中的变量、控制标志、在每个循环迭代中都会被更新和读取的传感器数据、在中断服务程序中被递增然后在主循环中被读取的计数器等等,这些数据都可以通过单周期访问快速获取。
如果某个变量每秒会被读取或写入数千次,那么DTCM与DDR之间的延迟差异就会累积起来,从而显著影响程序的性能。
在热点代码路径中使用的小型查找表,只要它们的大小足够小,也适合存储在DTCM中。例如用于电机控制的正弦/余弦表格、音频处理中的滤波系数表格,以及通信协议中使用的CRC校验码表格等等,都是常见的例子。
这类表格通常只有几百字节到几千字节的大小,而且会在每个处理循环中被反复访问。因此,“体积小”是它们适合存储在DTCM中的关键因素。一个512字节的正弦表格就很适合存储在DTCM中,而一个64KB的校准表格就不合适了,应该将其存放到DDR中。
DMA缓冲区有时可以被放置在DTCM中,但这取决于你所使用的芯片的总线架构。在某些芯片上,DMA控制器可以通过总线矩阵直接访问DTCM;而在其他芯片上,DMA控制器只能访问DDR内存以及可能的其他SRAM区域。如果你在那些DMA控制器无法访问DTCM的芯片上将缓冲区放置到DTCM中,数据传输将会失败,或者会被写入完全错误的地址。
在将DMA缓冲区放入DTCM之前,务必查阅参考手册中关于该芯片总线架构的说明。
如何将数据放置到DTCM中
将数据放置在DTCM中时,需要使用与ITCM相同的段属性机制,只不过段名称需要由链接器脚本映射到DTCM的地址范围内。
__attribute__((section(".dtcm_data")))
static int16_t audio_buffer[256];
__attribute__((section(".dtcm_data"))
static volatile uint32_t sensor_state = 0;
在这段代码中,audio_buffer是一个由256个16位有符号整数组成的数组(总大小为512字节),它会被放置在DTCM中。这个缓冲区可以用来存储音频样本数据,这些数据会通过DMA传输被读取进来,然后由中断服务程序进行处理。static关键字表示该缓冲区的作用域为文件级,并且会在程序运行期间一直存在(它不会被分配在栈上)。
sensor_state变量是一个32位无符号整数,而且被标记为volatile,这意味着每次访问这个变量时,编译器都必须从内存中读取它的值,而不会将其缓存到寄存器中。
对于那些在中断服务程序中被写入、然后在主循环中被读取的变量来说,这一点非常重要,因为编译器需要确保这些变量的值随时都可能发生变化。将这类变量放置在DTCM中,可以确保中断服务程序中的写操作和主循环中的读操作能够在同一个时钟周期内完成。
DTCM的占用速度比ITCM快
再来看一下内存使用情况:
DTCM: 727240 B 1572608 B 46.24%
链接器映射文件中的这一行数据表明,DTCM共有1,572,608字节(约1.5MB)的可用空间,而固件目前使用了727,240字节(约710KB),占DTCM总容量的46.24%。
DTCM的占用速度之所以比ITCM快,是因为有很多因素会占用DTCM的空间:你的栈空间、堆空间(如果有的话)、全局变量,以及你链接到的所有库中的数据段。每一个使用静态数据的C语言函数、每一个实时操作系统的数据结构、每一个中间件组件,都会占用一定的内存空间。因此,DTCM的容量总是处于动态变化的状态。
对于每一个数据结构来说,都需要仔细考虑:它是否真的需要在单个时钟周期内被访问完成,还是可以从DDR内存中读取/写入呢?
性能影响的实际例子
假设你的处理器运行频率为400 MHz。DTCM允许你在1个时钟周期内完成数据访问操作,而DDR则需要8个时钟周期。如果你有一个每秒被访问100,000次的查找表,那么使用DTCM会显著提高程序的性能。
DTCM:100,000次访问 x 1个周期 = 100,000个周期/秒
DDR:100,000次访问 x 8个周期 = 800,000个周期/秒
差异:700,000个周期/秒
在400 MHz的频率下:700,000 / 400,000,000 = 0.00175秒 = 1.75毫秒
这个计算展示了这两种内存类型在每秒进行100,000次内存访问时的周期消耗情况。在DTCM中,每次访问需要1个周期,因此总共需要100,000个周期;而在DDR中,每次访问需要8个周期,因此总共需要800,000个周期。在400 MHz的时钟频率下,这种700,000个周期/秒的差异意味着CPU会额外花费1.75毫秒的时间来处理内存访问操作。
如果你正在运行一个周期为1千赫兹的实时控制循环,那么每秒1.75毫秒的内存延迟意味着某些循环的执行时间会超过1毫秒的限制。是否会导致实际任务延误,取决于这些内存访问操作在各个循环中的分布情况,以及你的时间预算还有多少剩余。不过,这一现象确实说明了在嵌入式系统中,内存布局的选择会带来实质性的影响。
什么是DDR双倍数据速率内存?
DDR是一种外部内存。它位于处理器芯片之外的电路板上,通过内存控制器与处理器相连。它的容量通常比TCM大得多(一般为4MB到几GB),但访问速度却明显较慢。
“双倍数据速率”这一名称指的是数据在DDR芯片与内存控制器之间的传输方式:数据会在时钟信号的上升沿和下降沿都被传输,因此其传输速度是单数据速率设计的两倍。不过,这种设计并不能消除激活DDR芯片内部行和列所导致的延迟,这才是造成其访问速度较慢的根本原因。
DDR的访问机制是如何工作的?
当CPU从DDR中读取数据时,内存控制器和DDR芯片内部会依次执行多个步骤。
首先,CPU会向内存控制器发送地址请求。内存控制器是处理器内部的硬件模块,它的作用是将CPU发出的地址转换成DDR芯片能够识别的具体行地址和列地址。
其次,内存控制器会激活DDR芯片中相应的那一行。这个步骤被称为RAS阶段。DDR芯片被组织成一块由无数微小电容器组成的网格结构,“激活一行”意味着需要将这一行中的所有电容器的数据读取到DDR芯片内部的缓冲区中,这个过程需要几个时钟周期。
接着,内存控制器会从被激活的那一行中选择正确的列地址。这个步骤被称为CAS阶段。DDR芯片会利用列地址从缓冲区中提取所需的数据,这一过程同样需要几个时钟周期。
最后,数据会被传输回内存控制器,然后再由内存控制器传送给CPU。数据传输是在时钟信号的上升沿和下降沿同时进行的,这就是“双倍数据速率”设计的特点,这种设计可以提高数据传输效率,但并不能减少RAS阶段和CAS阶段的初始延迟。
总延迟取决于请求到达时内存所处的状态。如果之前已经访问过正确的行,那么该行已经处于激活状态,此时可以跳过RAS阶段,从而加快访问速度;但如果当前激活的是另一行,而在新行被激活之前需要先关闭当前这行(对其进行预充电),那么访问速度就会变慢。如果此时DDR芯片正在执行刷新操作,访问延迟还会进一步增加。
实际上,DDR内存的访问延迟通常在5到20多个CPU时钟周期之间,这一数值会受到访问模式和时序的影响。
为什么需要DDR内存
因为固件内容往往无法仅通过TCM来存储。真正的嵌入式系统通常还包括协议栈、连接库、文件系统驱动程序、调试接口等组件。TCM的总容量一般为2到3.5MB(ITCM与DTCM之和),而功能完备的固件文件其大小往往会远远超过这个数值。
以下是一个实例,展示了在添加无线连接功能前后内存使用情况的变化:
未添加无线连接功能时:
ITCM:506,996 B (24.18%)
DTCM:628,408 B (39.96%)
DDR:558,779 B (13.32%)
添加无线连接功能后:
ITCM:570,936 B (27.22%)
DTCM:727,240 B (46.24%)
DDR:622,915 B (14.85%)
变化量:
ITCM:+63,940 B (约增加了62KB代码)
DTCM:+98,832 B (约增加了96KB数据)
DDR:+64,136 B (约增加了62KB数据/代码)
这个对比清楚地显示了同一个项目在是否包含无线连接功能的情况下,内存使用情况的变化。“未添加”部分表示基准状态,“添加后”部分表示加入该功能后的使用情况,“变化量”部分则展示了两者之间的差异。
仅仅添加这一项功能,就使得三个内存区域中的数据总量增加了约220KB。其中,那些对响应时间要求极高的组件(如中断处理程序、缓冲区管理模块)被存储在ITCM和DTCM中;而其余部分(如数据包解析模块、连接管理逻辑、配置逻辑等)则被存储在DDR中,因为这些组件并不需要单周期访问速度。
哪些内容应该存储在DDR内存中
初始化和配置代码属于最适合存储在DDR中的类别。那些仅在系统启动时运行一次的函数,比如解析配置文件、初始化外设或设置数据结构,其实并不需要快速执行。这类代码只会运行一次,由于DDR延迟的存在,可能会多消耗几毫秒的时间,但之后就再也不会被执行了。因此,将这类代码存储在DDR中,可以释放TCM的空间,让那些需要每秒钟执行数百万次的代码有足够的空间来运行。
大型缓冲区也必须存储在DDR中,因为它们显然无法容纳在TCM里。例如,对于一个320x240分辨率、每像素16位色的显示设备来说,其图像缓冲区的大小约为150KB;网络数据包缓冲区可能需要32KB或更多空间;文件系统缓存也可能占用64KB的容量。这些缓冲区会占据DTCM总容量的很大一部分,从而使得那些真正需要单周期访问速度的组件无法有足够的空间来存储。
不常被访问的数据也适合存储在DDR中。那些在系统启动时仅加载一次、之后在运行过程中偶尔会被读取的校准表格,那些仅在开发阶段或出现错误时才会被打印出来的调试信息字符串表,以及错误描述表格,这些数据存储在DDR中都是完全合适的。当访问次数较少时,每次访问所增加的延迟其实并不重要。
非实时关键的代码也属于适合存储在DDR中的数据类型。协议栈(如蓝牙、Wi-Fi、TCP/IP)、文件系统驱动程序、OTA更新处理程序以及shell调试命令解释器等,虽然这些组件都发挥着重要的作用,但它们并没有必要每一条指令都在一个时钟周期内完成执行。因此,它们完全可以承受DDR所带来的较高延迟,而这种延迟并不会影响系统的正常运行。
如何将代码和数据存储在DDR中
__attribute__((section(".ddr_text")))
void parse_config_file(const char *path) {
// 这段代码存储在DDR中,指令获取速度较慢,
// 但由于配置解析仅在系统启动时进行一次,因此这种延迟并不会影响程序的运行性能。
}
__attribute__((section(".ddr_bss"))
static uint8_t network_packet_pool[32768];
__attribute__(section(".ddr_bss"))
static uint8_t framebuffer[320 * 240 * 2]; // 150 KB,对于TCM来说空间太大
在这段代码中,parse_config_file函数被放置在.ddr_text节中,链接器会将其映射到DDR内存中。因此,这个函数中的每一条指令都会从DDR中以多周期延迟的方式被读取出来,但由于配置解析仅在系统启动时进行一次,所以这种额外的延迟可以忽略不计。
network_packet_pool是一个大小为32 KB的缓冲区,它被放置在.ddr_bss节中。.bss这个后缀表示这些数据会被初始化为零(链接器会在系统启动时确保这些内存区域被清零,而不需要在固件中存储32 KB的零值)。这个缓冲区用于存储网络数据包,由于其用途并不属于实时关键任务,因此没有必要使用TCM内存来存储它。
framebuffer是一个大小为150 KB的缓冲区,它的分辨率是320像素宽、240像素高,每像素占用2字节的内存。这个缓冲区也被放置在.ddr_bss节中。由于它的容量为150 KB,因此如果使用它的话,将会占用TCM总容量的大约10%,而当显示更新操作并不属于实时任务时,这种浪费显然是不合理的。
它们之间的比较:侧栏对比概览
| 属性 | ITCM | DTCM | DDR |
|---|---|---|---|
| 用途 | 指令存储 | 数据存储 | 通用存储 |
| 存储位置 | 芯片内部,专用总线 | 芯片内部,专用总线 | 芯片外部,通过内存控制器访问 |
| 访问延迟 | 1个时钟周期(确定性延迟) | 1个时钟周期(确定性延迟) | 5到20多个时钟周期(可变延迟) |
| 典型容量 | 512 KB至2 MB | 512 KB至1.5 MB | 4 MB至数GB |
| 技术原理 | SRAM | SRAM | DRAM(需要定期刷新) |
| 功耗 | 低功耗(无需刷新操作) | 低功耗(无需刷新操作) | 较高功耗(需要持续刷新) |
最适合用于
| 中断服务程序、实时循环、数字信号处理器 |
栈结构数据、临时变量、查找表 |
大型缓冲区、初始化代码、协议栈 |
|
该表格总结了三种内存类型之间的主要区别。其中最重要的两列是“访问延迟”和“典型容量”,因为它们体现了这些内存类型所遵循的基本权衡原则:TCM速度很快,但容量较小;而DDR速度较慢,但容量较大。
“技术原理”这一列解释了其中的原因:TCM使用SRAM(静态随机存取存储器),它通过触发器电路来存储每个比特的数据,只要电源持续供电,这些数据就能保持不变。而DDR则使用DRAM(动态随机存取存储器),它通过微小的电容器来存储比特信息。由于电容器会逐渐丢失电荷,因此必须定期对DRAM进行刷新操作,这一过程会增加功耗,并且当刷新周期与读操作同时发生时,还会导致访问延迟。
内存映射
地址空间:
+------------------------------+ 0x00000000
| |
| ITCM(2 MB) | 单周期数据访问
| 中断服务程序、实时循环、 |
| 数字信号处理器、关键代码 |
| |
+------------------------------+ 0x00200000
| (保留/空闲区域) |
+------------------------------+ 0x20000000
| |
| DTCM(约1.5 MB) | 单周期数据访问
| 堆栈、临时变量、 |
| 查找表、DMA缓冲区 |
| |
+------------------------------+ 0x20180000
| (保留/空闲区域) |
+------------------------------+ 0x80000000
| |
| DDR(4 MB) | 多周期数据访问
| 大容量缓冲区、初始化代码、|
| 协议栈、配置信息 |
| |
+------------------------------+ 0x80400000
该图展示了CPU的地址空间分布情况,从顶部的低地址开始逐渐向下延伸到高地址。ITCM占据了从地址0x00000000开始的最低2MB内存区域;在一段保留/未使用的地址空间之后,DTCM位于0x20000000地址处,其容量约为1.5MB;再往后是一段保留空间,最后DDR从0x80000000地址开始,占用4MB的内存空间。
这些区域之间的空闲区间非常重要,它们实际上是未被分配给任何物理内存的预留地址范围。如果你的代码不小心访问了这些空闲区间内的地址,结果会取决于芯片的总线故障处理机制:有时会触发HardFault异常,有时则会返回无效数据。
以上地址仅供参考,每块芯片的实际内存映射情况都会在其技术参考手册中有所说明。因此,请务必查阅你所使用芯片的技术参考手册,以获取准确的地址信息和容量数值。
如何决定代码和数据的存储位置
是代码还是数据?
|
+-- 代码(指令):
| +-- 是否由中断服务程序调用或在实时循环中执行?
| | +-- 是 -> 选择ITCM(需要确保访问时间具有确定性)
| +-- 在主处理流程中是否被频繁调用?
| | +-- 是 -> 选择ITCM(如果空间允许的话)
| +-- 很少被调用(用于初始化、配置或调试)?
| +-- 选择DDR(为关键代码节省ITCM空间)
|
+-- 数据(变量、缓冲区、表格):
+-- 是否在中断服务程序或实时环境中被访问?
| +-- 是 -> 选择DTCM(单周期访问,访问时间确定)
+-- 数据量小且被频繁访问?
| +-- 是 -> 选择DTCM(如果空间允许的话)
+-- 数据量较大(超过16KB)?
| +-- 通常选择DDR(ITCM无法容纳如此大的数据量)
+-- 只在系统启动时被访问一次或非常少被访问?
+-- 选择DDR(这种情况下不适合使用DTCM)
这棵决策树清晰地展示了将每段固件放置在正确内存区域时应遵循的思考流程。
首先需要判断自己要放置的是代码还是数据——代码指的是指令,而数据则包括变量、缓冲区及表格等。对于代码来说,关键在于确定它被执行的频率以及是否存在时间限制;中断服务程序代码和实时循环代码应被存放在ITCM内存中,其余代码则应存入DDR内存。至于数据,需要考虑的是其被访问的频率及其大小:那些体积小且被频繁访问的数据应该存入DTCM内存,而大型缓冲区或很少被访问的数据则适合存入DDR内存。
一般原则是:将那些使用频率最高、对延迟要求严格或需要精确控制执行时间的数据和代码放在TCM内存中,其余数据则全部存入DDR内存。当遇到不确定的情况时,可以先选择将数据存入DDR内存,只有在对系统性能进行分析后发现确实有必要时,才将其移至TCM内存。因为一旦发现某个功能成为了系统性能的瓶颈,之后再将它从DDR内存转移到ITCM内存会容易得多,而如果一开始就强行将所有数据都存入ITCM内存,最终很可能会导致内存空间不足的问题。
链接器脚本如何控制内存分配
到目前为止我们讨论的所有内容——节属性、内存分配规则以及地址分配方式——最终都会在链接器脚本中得到体现。这种文件(通常具有.ld扩展名)会明确指定哪些代码段应该被存放在哪个内存区域中。因此,链接器脚本才是决定你的固件内存布局的唯一依据。
MEMORY
{
ITCM (rx) : ORIGIN = 0x00000000, LENGTH = 2M
DTCM (rw) : ORIGIN = 0x20000000, LENGTH = 1536K
DDR (rwx) : ORIGIN = 0x80000000, LENGTH = 4M
}
SECTIONS
{
/* === ITCM: 关键代码 === */
.itcm_text :
{
KEEP(*(.isr_vector)) /* 中断向量表 */
*(.itcm_text) /* 使用__attribute__((section(".itcm_text")))声明的函数 */
*audio_processing.o(.text) /* audio_processing.c文件中的所有代码 */
*motor_control.o(.text) /* motor_control.c文件中的所有代码 */
} > ITCM
/* === DDR: 非关键代码 === */
.ddr_text :
{
*(.text) /* 其余代码的通用存储区域 */
*(.rodata) /* 只读数据(字符串字面量、常量等) */
*(.rodata*)
} > DDR
/* === DTCM: 关键数据 === */
.dtcm_data :
{
*(.dtcm_data) /* 使用__attribute__((section(".dtcm_data")))声明的数据 */
*audio_processing.o(.data) /* audio_processing.c文件中所有需要初始化的数据 */
*audio_processing.o(.bss) /* audio_processing.c文件中所有初始值为0的数据 */
} > DTCM
/* === DTCM: 栈内存 === */
.stack (NOLOAD) :
{
. = ALIGN(8);
__stack_start = .;
. = . + 8K; /* 8 KB的栈空间 */
__stack_end = .;
} > DTCM
/* === DDR: 其余数据 === */
.ddr_data :
{
*(.data) /* 其他需要初始化的数据 */
*(.bss) /* 其他初始值为0的数据 */
*(COMMON)
} > DDR
}
这个链接器脚本主要由两个部分组成:MEMORY和SECTIONS。
MEMORY部分用于定义芯片上可用的物理内存区域。每一行代码都会指定一个区域名称、其访问权限(rx表示只读执行,rw表示读写,rwx表示读写执行)、起始地址(ORIGIN)以及大小(LENGTH)。这些数值必须与芯片参考手册中记载的实际内存映射相匹配。
SECTIONS部分则规定了链接器应如何将编译后的代码和数据分配到这些内存区域中。每条配置规则包括一个区域名称(例如.itcm_text)、用于指定哪些目标文件中的内容应被包含在内的模式列表,以及一个> REGION指令,该指令告诉链接器应将生成的代码放置到哪个内存区域中。
.itcm_text区域主要用于存放中断向量表(KEEP(*(.isr_vector)))、那些被明确标记为属于.itcm_text区域的函数,以及来自audio_processing.o和motor_control.o文件中的所有代码。KEEP指令可以确保链接器在进行垃圾回收时不会删除中断向量表,即使没有其他代码直接引用它。所有这些内容最终都会被存入ITCM区域中。
.ddr_text区域使用通配模式*(.text)和*(.text*)来收集那些未被ITCM区域占用的剩余代码,同时还会收录只读数据(.rodata),比如字符串字面量和const变量。所有这些内容最终都会被存入DDR区域中。
.dtcm_data区域用于存放那些被明确指定要放置在此区域的数据,以及来自audio_processing.o文件中的所有数据。.stack区域为堆栈预留了8 KB的内存空间,并确保这些内存以8字节对齐的方式被使用;同时,它还会生成__stack_start和__stack_end这两个符号,供启动代码和堆栈分析代码使用。所有这些内容最终都会被存入DTCM区域中。
.ddr_data区域同样使用通配模式来收集剩余的数据,这些数据最终也会被存入DDR区域中。
节匹配的工作原理
链接器会从上到下依次处理各个代码段。当遇到像*(.text)这样的通配模式时,它会查找脚本中所有未被更具体的规则先占用的.text代码段。
以上述示例来说,在ITCM区域中使用的*audio_processing.o(.text)规则会首先获取audio_processing.c文件中的所有代码。当链接器处理到DDR区域中的*(.text)规则时,由于audio_processing.o的.text代码段早已被分配好了位置,因此这个规则会被跳过。只有那些未被其他目标文件占用掉的.text代码段才会与DDR区域的通配规则匹配起来。
这意味着,链接器脚本中各部分的排列顺序非常重要。请将那些针对特定情况的规则(例如针对单独的对象文件或具有特定名称的代码段的规则)放在那些通用的、适用于所有情况的规则之前。如果将*(.text)这条通用规则放在*audio_processing.o(.text)这条特定规则之前,那么通用规则会优先匹配所有内容,而特定规则则根本无法起到任何作用。
需要避免的常见错误
1. DTCM中的栈溢出问题
你的程序代码存储在DTCM中,而DTCM的内存容量是有限的。如果你在函数内部声明一个较大的局部数组,那么这些数据就会被分配到栈上:
void problematic_function(void) {
uint8_t huge_local_buffer[65536]; // 会占用64 KB的栈空间
// 这会导致DTCM中的栈空间立即被占满64 KB
}
这段代码声明了一个大小为64 KB的局部数组。由于它是一个局部变量(而不是static类型的变量),因此当函数被调用时,这些数据会被分配到栈上。如果你的程序总栈空间大小仅为8 KB(就像上面的链接器配置示例中所显示的那样),那么这个局部变量的声明就会导致栈空间溢出56 KB,从而覆盖DTCM中相邻的内存区域。
在桌面操作系统中,栈溢出会引发段错误,因为操作系统会利用虚拟内存和保护页来检测这种异常情况。
而在没有内存保护机制的嵌入式系统中,栈空间会悄悄地扩展到相邻的内存区域,从而破坏那些存储在这些区域中的数据。这类错误极其难以诊断,因为其表现症状(如变量值被破坏、程序行为异常或偶尔出现崩溃)往往与实际的根本原因没有直接关系。你可能会花费数天的时间来调试那些看似随机出现的数据损坏问题,直到最终发现问题的根源其实是某个深度为3层的函数所导致的栈溢出。
解决方法:对于大型数据缓冲区,应该使用static类型进行声明或者使用堆内存来进行分配,并将这些缓冲区放置在DDR内存中:
void fixed_function(void) {
__attribute__((section(".ddr_bss})}
static uint8_t huge_buffer[65536]; // 存储在DDR内存中,而不是栈上
// 这样就不会导致栈空间溢出,DTCM的内存也不会被浪费
}
将缓冲区声明为static类型后,它就不再会被分配到栈上了。相反,链接器会将其分配到.ddr_bss段中,而这个段是位于DDR内存中的。因此,这个缓冲区会在程序的整个运行过程中一直存在(类似于全局变量的作用),但其作用范围仅限于当前函数。栈上只保存着一个指向该缓冲区的指针,而已不是64 KB大小的整块数据了。
2. ITCM内存空间的溢出问题
如果你的程序使用了超过ITCM所能容纳的数据量,链接器会生成类似“ITCM区域被占用了N字节”的错误提示。但如果你已经非常接近这个限制值,那么只需更新一个库文件或添加一个新的功能,就可能导致构建失败。即使是你发布的RTOS或通信栈的版本号只是略微升级了一下,也可能导致新增的代码使ITCM内存空间再次达到上限。
因此,一定要留出足够的内存余量。前面提到的27%的内存利用率属于正常范围;如果这个数值超过了85%,就应该尽快将那些不太重要的代码移放到DDR内存中;而当利用率超过95%时,你就已经没有多余的空间了,必须立即采取行动进行调整。在你的持续集成流程中设置自动检查内存使用情况的功能(本文后面会介绍具体方法),可以有效避免意外的发生。
3. 忽视对数据对齐的要求
中医内存通常具有对齐要求。在那些严格执行对齐规则的Cortex-M处理器上,如果从未对齐的地址访问32位数据,就会引发HardFault异常。
/* 问题所在:使用“packed”属性会导致字段排列不整齐 */
__attribute__((section(".dtcm_data"), packed))
struct badly-aligned {
uint8_t flag;
uint32_t counter; // 可能位于字节偏移量1处,即未对齐的位置
};
/* 正确做法:使用自然对齐,并添加少量填充字符 */
__attribute__((section(".dtcm_data"))
struct properlyAligned {
uint32_t counter; // 位于偏移量0处,为4字节对齐
uint8_t flag; // 位于偏移量4处
// 在flag之后添加3个字节的填充字符,虽然会增加一些空间,但能确保数据正确性
};
在第一个结构体中,packed属性告诉编译器不要在字段之间添加填充字符。因此counter从字节偏移量1开始存储(紧接在1字节的flag之后),而这个位置并不是4的倍数。当CPU尝试从这种未对齐的地址读取32位数据时,在那些严格执行对齐规则的处理器上就会触发HardFault异常(大多数Cortex-M核心都属于这类处理器)。
在第二个结构体中,counter字段被安排在偏移量0处,因此它是4字节对齐的;flag字段位于偏移量4处。编译器会在flag之后添加3个字节的填充字符,使得整个结构体的大小变为8字节(即4的倍数),这样就能确保数据传输的正确性,避免程序崩溃。
4. 在不兼容的总线架构上通过DMA向中医内存传输数据
有些DMA控制器无法访问中医内存。是否能够通过DMA访问中医内存,完全取决于芯片内部的总线架构。
如果你配置了从外设到DTCM缓冲区的DMA传输,但DMA控制器没有通往DTCM的总线路径,那么这次传输要么会无声无息地失败,要么会将数据写入错误的地址。
这两种情况都不会产生明显的错误提示。DMA控制器会认为传输已经完成,你的代码也会认为缓冲区中存储的是新数据,但实际上得到的却是旧数据或无效数据。这是嵌入式开发中最为令人困惑的错误类型之一,因为从代码上看,一切似乎都是正确的。
在使用DMA与中医内存进行数据传输之前,**务必查阅芯片参考手册中的总线架构图**。该图表会说明哪些主机设备(如CPU、DMA控制器、USB接口等)能够访问哪些从机设备(如ITCM、DTCM、SRAM存储器、DDR内存或外设)。要检查DMA控制器的主机端口是否与中医内存的从机端口相连;如果没有连接,那么DMA传输就无法正常进行。
用实际数值进行的性能对比
下表比较了在不同类型内存上数据访问的延迟时间,测试环境为运行在400 MHz频率下的Cortex-R系列处理器:
+---------------------+----------+----------+----------+
| 操作类型 | ITCM | DDR | 延迟倍数 |
| | DTCM | | |
+---------------------+----------+----------+----------+
| 指令获取 | 1个周期 | 5–20个周期 | 5–20倍 |
| 数据读取(32位) | 1个周期 | 5–20个周期 | 5–20倍 |
| 数据写入(32位) | 1个周期 | 5–20个周期 | 5–20倍 |
| 顺序数据传输 | 1个周期/次 | 2–4个周期/次 | 2–4倍 |
| 随机访问 | 1个周期 | 10–20个周期 | 10–20倍 |
+---------------------+----------+----------+----------+
该表格展示了五种不同类型内存操作的延迟情况。前三行(指令获取、数据读取、数据写入)表明,对TCM的单独访问操作始终需要1个周期;而對DDR的单独访问操作所需的周期数则根据内存的内部状态不同,介于5到20个周期之间。这种延迟差异实际上反映了两者之间的性能差距。
“连续访问”这一行说明了在连续读取或写入相同地址时会发生什么。DDR在连续访问模式下表现更好,因为每次读取同一行数据时都会跳过RAS阶段,因此每个操作只需要2到4个周期;而TCM由于没有DDR那样的行列结构,每个操作仍然需要1个周期。
“随机访问”这一行展示了DDR在最糟糕情况下的表现。当每次访问都针对不同的数据行时,内存控制器必须每次都先对旧的数据行进行预充电,然后再激活新的数据行,因此这个过程需要10到20个周期。这种情况在那些需要在内存中频繁跳转的数据处理场景中很常见,比如遍历链表、查找哈希表中的元素,或者通过函数指针数组调用间接函数等。
实际应用建议是:如果你的代码需要访问DDR内存,尽量采用连续访问的方式。按顺序遍历数组要比随机访问快得多,因为在这种模式下,内存控制器和DDR芯片的内部预取机制能够更高效地工作。
TCM如何影响功耗
内存的布局方式会直接影响功耗,对于那些依靠电池供电的产品来说,这一点尤为重要。
DDR需要持续的刷新操作。DRAM通过微小的电容器来存储数据,而这些电容器中的电荷会随着时间的推移而逐渐流失。因此,为了防止数据丢失,内存控制器必须每隔大约64毫秒就读取并重新写入DDR芯片中的所有数据。即使处理器处于休眠状态且没有代码在运行,这个刷新过程也会消耗电能。在某些系统中,DDR的刷新操作会占据整个休眠模式下功耗的很大一部分。
TCM基于SRAM技术,因此不需要进行刷新操作。SRAM利用触发器电路来存储数据,只要电源持续供电,这些数据就能保持不变。虽然也会有一些电流泄漏现象(因为没有任何电子元件是完美的),但这种泄漏所消耗的电能远远低于DDR的刷新过程所需的电量。
对于那些依靠电池运行的设备来说,如果可能的话,应该尽量将那些需要在休眠模式下仍然保留的数据存储在DTCM中。如果你的硬件支持这种功能,可以在设备进入深度休眠状态时关闭DDR芯片的电源,从而完全消除其带来的功耗。你的固件在运行时使用的DDR资源越少,你就能够更加灵活地控制DDR的功耗状态,进而延长设备的电池续航时间。
如何分析内存使用情况
将代码和数据分别存储到ITCM、DTCM和DDR中之后,你需要确认所有数据都存放在了正确的位置上,同时需要长期监控这些内存模块的使用情况,并在出现性能下降的情况时及时发现并解决问题。为此,有多种方法可供选择,从简单的命令行工具到自动化的持续集成检查机制,应有尽有。
方法1:链接器映射文件
每次你构建固件时,链接器都会生成一个映射文件。这个详细的文本文件会记录每个符号(函数、变量、常量)最终被放置在了内存的哪个位置,以及它们的大小是多少。在嵌入式开发中,这份文件是了解内存使用情况的最有用的工具。
要生成这样的文件,只需在链接器参数中添加-Wl,-Map=output.map即可:
arm-none-eabi-gcc \
-T linker_script.ld \
-Wl,-Map=firmware.map \
-o firmware.elf \
main.o audio.o bluetooth.o
这条命令会使用链接器脚本linker_script.ld来连接三个目标文件main.o、audio.o和bluetooth.o。参数-Wl,-Map=firmware.map告诉GCC将-Map=firmware.map选项传递给链接器,这样链接器就会在生成的ELF二进制文件旁边生成一份详细的映射文件。这份文件可能会包含数千行内容,但其中最有用的是文件末尾的总结部分。
映射文件末尾的总结会显示每个内存区域的使用情况:
内存区域 使用大小 区域总大小 使用比例
ITCM: 570936 B 2 MB 27.22%
DTCM: 727240 B 1572608 B 46.24%
DDR: 622915 B 4 MB 14.85%
这份总结包含了三列信息:使用了多少字节、该内存区域的总大小以及使用比例。通过这些信息,你可以快速了解固件的内存使用状况。一般来说,如果使用比例低于80%,说明固件运行正常,还有进一步优化的空间;如果使用比例在80%到90%之间,说明内存使用情况已经比较紧张,你需要考虑如何为新的功能预留足够的内存;而如果使用比例超过了90%,那就必须采取行动了:要么将某些数据移至成本更低的内存区域,要么优化现有的数据布局。
方法2:解析映射文件以获取各模块的具体内存使用情况
虽然总结部分能告诉你总共使用了多少内存,但却无法说明这些内存是被哪些模块占用的。映射文件中确实包含了每个符号的详细信息,但由于文件内容可能长达数千行,且其格式并不适合人类阅读,因此手动分析这些信息会非常困难。
以下这个Python脚本可以解析映射文件,并生成一份按模块划分的内存使用报告,显示哪些目标文件在哪些内存区域占用了多少资源。
#!/usr/bin/env python3
"""解析链接器映射文件,并输出各目标文件的内存使用情况。"""
import re
import sys
from collections import defaultdict
def parse_map_file(map_path):
"""从GCC链接器生成的映射文件中提取符号位置信息。"""
usage = defaultdict(lambda: defaultdict(int))
regions = {
'ITCM': (0x00000000, 0x00200000),
'DTCM': (0x20000000, 0x20180000),
'DDR': (0x80000000, 0x80400000),
}
def addr_to_region(addr):
for name, (start, end) in regions.items():
if start <= addr < end:
return name
return 'UNKNOWN'
symbol_re = re.compile(
r'^\s+\S+\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)\s+(\S+\.o)'
)
with open(map_path) as f:
for line in f:
m = symbol_re.match(line)
if m:
addr = int(m.group(1), 16)
size = int(m.group(2), 16)
obj = m.group(3).split][-1]
region = addr_to_region(addr)
usage[obj][region] += size
return usage
def print_report(usage):
"""打印一份排序后的内存使用报告。"""
print(f"{'目标文件':<35} {'ITCM':>10} {'DTCM':>10} {'DDR':>10} {'总计':>10}")
print("-" * 80)
totals = defaultdict(int)
rows = []
for obj, regions in usage.items():
total = sum(regions.values())
rows.append((obj, regions, total))
for r, s in regions.items():
totals[r] += s
rows.sort(key=lambda x: x[2], reverse=True)
for obj, regions, total in rows[:20]:
print(f"{obj:<35} "
f"{regions.get('ITCM', 0):>10,} "
f"{regions.get('DTCM', 0):>10,} "
f"{regions.get('DDR', 0):>10,} "
f"{total:>10,}")
print("-" * 80)
grand = sum(totals.values())
print(f"{'总计':<35} "
f"{totals.get('ITCM', 0):>10,} "
f"{totals.get('DTCM', 0):>10,} "
f"{totals.get('DDR', 0):>10,} "
f"{grand:>10,.")
if __name__ == '__main__':
usage = parse_map_file(sys.argv[1])
print_report(usage)
这个脚本主要完成三项任务。首先,parse_map_file函数会逐行读取映射文件,查找符合符号放置格式的记录(包括段名、地址、大小以及目标文件名)。对于每一条匹配到的记录,它会将十六进制地址转换为整数,然后利用addr_to_region辅助函数确定该地址所属的内存区域,并将对应的大小存储在一个以目标文件名和内存区域为键的嵌套字典中。
其次,print_report函数会按照目标文件的总内存占用量对它们进行排序(从占用量最大的文件开始),并打印出前20个文件的名称以及它们在各个内存区域中的占用情况。
最后,if __name__ == '__main__'代码块使得这个脚本能够通过命令行来执行。
你需要根据自己芯片的内存映射结构,调整regions字典中指定的地址范围。
运行方式如下:
python3 parse_map.py firmware.map
示例输出结果如下:
目标文件 ITCM区域 DTCM区域 DDR区域 总占用量
--------------------------------------------------------------------------------
bluetooth_stack.o 42,380 65,200 38,400 146,080
audio_processing.o 89,200 32,000 0 121,200
wifi_driver.o 21,560 33,632 25,736 80,928
sensor_hub.o 45,000 18,400 0 63,400
libc.a(memcpy.o) 12,340 0 0 12,340
...
--------------------------------------------------------------------------------
总占用量 570,936 727,240 622,915 1,921,091
这个输出结果列出了固件中占用内存最多的文件,这些文件是按照总占用量排序的。每一行都显示了一个目标文件名称以及它在各个内存区域中所占用的字节数。
bluetooth_stack.o文件是占用内存最多的文件,其总大小为146 KB,这些内存分布在ITCM区域、DTCM区域和DDR区域中。而audio_processing.o文件仅占用了121 KB的内存,且所有这些内存都位于ITCM区域和DTCM区域中(DDR区域没有占用任何内存),这是合理的,因为音频处理任务对处理速度要求很高,因此相关代码被放置在速度较快的内存区域中。libc.a(memcpy.o)这一条目表示的是一个C语言库函数,它也被放置在了ITCM区域中,这很可能是因为这个函数在那些对性能要求极高的代码路径中被频繁调用。
方法3:使用size命令
如果你想快速查看内存占用情况而不需要解析映射文件,可以使用arm-none-eabi-size命令:
arm-none-eabi-size -A firmware.elf
输出结果如下:
firmware.elf :
section 大小 地址
.itcm_text 570936 0
.dtcm_data 530240 536870912
.dtcm_bss 196000 537401152
.stack 8192 537600000
.ddr_text 422915 2147483648
.ddr_data 120000 2147906563
.ddr_bss 80000 2148026563
总大小 1928283
此输出列出了ELF二进制文件中的每个节段、其大小(以字节为单位)以及起始地址(以十进制形式显示)。
你可以通过查看地址来判断这些节段所属的内存区域:接近0的地址属于ITCM,接近5.36亿(0x20000000)的地址属于DTCM,而接近21亿(0x80000000)的地址则属于DDR。
另外,节段名称本身也暗示了它们所属的区域:.itcm_text位于ITCM中,.dtcm_data和.dtcm_bss位于DTCM中,而.ddr_text、.ddr_data以及.ddr_bss则位于DDR中。
使用-A选项可以获取每个节段的具体大小,而非默认的BSD格式输出。虽然这种方式提供的细节较少,但执行速度很快,能让你快速了解整体情况。
方法4:运行时堆栈分析
静态分析(如使用映射文件或size命令获取输出)能够告诉你代码在编译时的内存分配情况。然而,某些内存使用是动态变化的,尤其是堆栈——它在运行时会根据函数调用深度和局部变量的大小而扩大或缩小。如果一个函数只分配了2KB的局部缓冲区,那么在它执行期间,只有这部分堆栈空间会被使用,因此静态分析无法告诉你该函数的堆栈使用高峰值。
一种常见的技术是堆栈水印法:在系统启动时,用一个已知的模式填充整个堆栈区域,然后定期检查其中有多少部分已经被覆盖。
#define STACK_FILL_PATTERN 0xDEADBEEF
void stack_watermark_init(void) {
extern uint32_t __stack_start;
extern uint32_t __stack_end;
uint32_t *p = &__stack_start;
register uint32_t sp asm("sp");
while (p < (uint32_t *)(sp - 64)) {
*p++ = STACK_FILL_pattern;
}
}
uint32_t stack_usage_bytes(void) {
extern uint32_t __stack_start;
extern uint32_t __stack_end;
uint32_t *p = &__stack_start;
while (p < &__stack_end && *p == STACK_fill_pattern) {
p++;
}
return (uint32_t)(&__stack_end) - (uint32_t)p;
}
void check_stack_health(void) {
uint32_t used = stack_usage_bytes();
uint32_t total = 8192;
uint32_t percent = (used * 100) / total;
if (percent > 80) {
log_warning("堆栈使用情况:%lu / %lu字节(占%lu%%)",
used, total, percent);
}
}
stack_watermark_init函数会将从__stack_start开始到当前堆栈指针所在位置之前的整个堆栈区域填充为0xDEADBEEF这个模式。其中,extern声明引用了链接器脚本中.stack节所定义的符号;register uint32_t sp asm("sp")这条代码用于读取当前的堆栈指针值,这样函数就能知道在何处停止填充操作(因为你不希望覆盖自己的堆栈帧)。设置64字节的安全缓冲区是为了确保填充操作不会过于接近正在使用的堆栈区域。
stack_usage_bytes函数会从栈的底部开始向上扫描,统计其中还有多少个字符串仍然包含该填充模式。第一个不符合该模式的字符串所对应的位置,就标志着栈已经到达的最深处。该函数会返回从这一位置到栈顶所覆盖的字节总数。
check_stack_health函数用于计算栈被使用的百分比,如果这一比例超过80%,则会生成警告信息。在正常运行期间,应定期调用此函数以监控栈的使用情况。
请在启动代码中尽早调用stack_watermark_init()(如果可能的话,在main()之前调用),然后在正常运行期间定期调用check_stack_health()。这样就能了解到“最高上限”值,即你的固件目前所能达到的最大栈深度。
方法5:跨不同构建版本跟踪内存使用情况
每当你添加新功能或合并代码更改时,都应在修改前后分别运行内存分析命令:
arm-none-eabi-size -A firmware_before.elf > mem_before.txt
arm-none-eabi-size -A firmware_after.elf > mem_after.txt
diff mem_before.txt mem_after.txt
这三条命令会将两次构建版本的内存结构信息保存到文本文件中,然后通过差分运算找出变化之处。不过原始的差分结果可能较难阅读。以下脚本通过计算每个内存区域的变化量,提供了更清晰的分析结果:
#!/bin/bash
# memory_diff.sh - 比较两次构建版本之间的内存使用情况
echo "修改对内存的影响:"
echo "========================"
parse_size() {
arm-none-eabi-size -A "$1" | awk '
/\.itcm/ { itcm += $2 }
/\.dtcm/ { dtcm += $2 }
/\.ddr/ { ddr += $2 }
/\.stack/ { dtcm += $2 }
END { printf "%d %d %d", itcm, dtcm, ddr }
'
}
read itcm_before dtcm_before ddr_before <<< << \((parse_size "\)1")
read itcm_after dtcm_after ddr_after <<< << \((parse_size "\)2")
printf "ITCM: %+d字节 (%d -> %d)\n" \
\(((itcm_after - itcm_before)) \)itcm_before $itcm_after
printf "DTCM: %+d字节 (%d -> %d)\n" \
\ (((dtcm_after - dtcm_before)) \)dtcm_before $dtcm_after
printf "DDR: %+d字节 (%d -> %d)\n" \
\(((ddr_after - ddr_before)) \)ddr_before $ddr_after
该脚本接受两个ELF文件作为参数,分别代表修改前的构建版本和修改后的构建版本。parse_size函数会使用arm-none-eabi-size -A命令分析这些文件,并通过awk统计每个内存区域的大小。名称中包含.itcm的章节计入ITCM,包含.dtcm或.stack的章节计入DTCM,而包含.ddr的章节则计入DDR。脚本会读取修改前后的数据,然后以“+”或“-”符号显示每个区域的变化量。
使用方法及输出示例:
$ ./memory_diff.sh firmware_without_bt.elf firmware_with.bt.elf
修改对内存的影响:
========================
ITCM: +63940字节 (506996 -> 570936)
DTCM: +98832字节 (628408 -> 727240)
DDR: +64136字节 (558779 -> 622915)
从这些数据可以看出,添加蓝牙功能后,ITCM的大小增加了约62KB,DTCM增加了约96KB,DDR的大小也增加了约62KB。你可以将这一信息纳入你的CI/CD流程中,这样每次提交拉取请求时,系统都能清楚地显示这项功能会消耗多少内存资源。
方法6:在持续集成中自动检测内存使用情况
你可以将内存使用情况的监控功能集成到持续集成/持续部署流程中,从而在问题出现之前就及时发现它们。
#!/bin/bash
# memory_check.sh – 如果内存使用量超过阈值,则导致持续集成失败
ITCM_LIMIT=85 # 百分比
DTCM_LIMIT=80
DDR_LIMIT=90
check_region() {
local name=\(1 used=\)2 total=\(3 limit=\)4
local percent=$((used * 100 / total))
if [ \(percent -ge \)limit ]; then
echo "失败:\(name\)的使用量为\){percent}%(阈值:${limit}%)"
echo " 使用量:\(used / \)total字节"
return 1
else
echo "成功:\(name\)的使用量为\){percent}%(阈值:${limit}%)"
return 0
fi
}
ITCM_used=\((grep "ITCM:" firmware.map | awk '{print \)2}')
ITCM_TOTAL=$((2 * 1024 * 1024))
DTCM_used=\((grep "DTCM:" firmware.map | awk '{print \)2}')
DTCM TOTAL=1572608
DDR_used=\((grep "DDR:" firmware.map | awk '{print \)2}')
DDR_TOTAL=$((4 * 1024 * 1024))
FAILED=0
check_region "ITCM" \(ITCM_USED \)ITCMTOTAL $ITCM_LIMIT || FAILED=1
check_region "DTCM" \(DTCM_used \)DTCM TOTAL $DTCM_LIMIT || FAILED=1
check_region "DDR" \(DDR_used \)DDR_TOTAL $DDR_LIMIT || FAILED=1
exit $FAILED
该脚本会从链接器映射文件中读取内存使用数据,并将这些数据与可配置的阈值进行比较。`check_region`函数会接收区域名称、已使用的字节数、总可用字节数以及百分比阈值作为参数,计算实际的使用比例,并输出“成功”或“失败”的结果以及相关数值。如果有任何区域的使用量超过了阈值,脚本会以非零状态退出,从而导致持续集成构建失败。
上述阈值(ITCM为85%,DTCM为80%,DDR为90%)可以根据你项目的实际发展情况以及你希望预留的安全裕度来进行调整。由于DTCM内存更容易被占用且更难释放,因此为其设定的阈值较低。
将这个脚本添加到你的构建流程中,这样每次提交拉取请求时,系统都会显示相应的内存使用情况。如果某个变更导致某区域的使用量超过阈值,构建过程就会失败,开发人员也能立即得到通知。
方法7:运行时堆内存跟踪
如果你的嵌入式项目使用了动态内存分配函数(如`malloc`/`free`),你可以对这些函数进行封装,以便实时监控内存使用情况。
static size_t heap_used = 0;
static size_t heap_peak = 0;
void *tracked_malloc(size_t size) {
size_t *block = (size_t *)malloc(size + sizeof(size_t));
if (!block) return NULL;
*block = size;
heap_used += size;
if (heap_used > heap_peak) {
heap_peak = heap_used;
}
return (void *)(block + 1);
}
void tracked_free(void *ptr) {
if (!ptr) return;
size_t *block = ((size_t *)ptr) - 1;
heap_used -= *block;
free(block);
}
void print_heap_stats(void) {
printf("堆内存:当前使用量=%zu字节,最大使用量=%zu字节\n",
heap_used, heap_peak);
}
这段代码为`malloc`和`free`函数添加了跟踪逻辑。`tracked_malloc`函数会分配比请求量多出一些内存(额外多分配`sizeof(size_t)`字节),并将实际请求的大小存储在内存分配区域的第一个字中。随后,它会更新`heap_used`计数器;如果新的内存使用总量超过了之前的最高值,还会更新`heap_peak`变量。该函数返回的指针会跳过内存大小信息所在的区域,因此调用者看到的仍然是一个指向实际数据区的正常指针。
`tracked_free`函数的运作过程与`tracked_malloc`相反:它会从指针中减去`size_t`大小的值来找到隐藏的内存大小信息,然后从`heap_used`计数器中扣除这个数值,最后才对原始内存块执行真正的`free`操作。
`print_heap_stats`函数用于显示当前的内存使用情况以及历史上的最高使用峰值。你可以定期调用这个函数,或者通过调试接口(如UART控制台或调试命令行工具)根据需要随时查看固件所使用的内存量。
虽然这种做法会带来一定的开销(每次内存分配都会多占用一个字节存储空间),但它能让你清楚地了解动态内存的使用情况——而这些信息在正常情况下是无法被直接观察到的。这种方法在追踪内存泄漏问题时尤为有用:如果`heap_used`计数器持续增加而从未减少,那就说明肯定有某个地方在不断分配内存却没有及时释放它。
总结
基于ARM Cortex-M和Cortex-R架构的嵌入式处理器允许你直接控制三种性能特征截然不同的内存区域。
ITCM(指令紧密耦合内存)用于存储那些对性能要求极高的代码。它能够实现单周期、确定性的指令读取操作。由于其容量较小(通常为512 KB到2 MB),因此应将其专门用于中断服务程序、实时处理函数以及循环次数较多的代码段中。
DTCM(数据紧密耦合内存)同样用于存储对性能要求极高的数据。它也提供单周期、确定性的访问机制。默认情况下,堆栈数据就存储在DTCM区域内。由于它的容量比ITCM更小,因此在使用时必须格外谨慎,避免浪费空间。
DDR(双倍数据速率内存)用于存储其他所有类型的数据。它的容量较大,但访问速度较慢(每次访问需要5到20多个周期,且存在延迟),因此适合用来存放初始化代码、大型缓冲区、协议栈以及那些不需要确定性访问时序的数据。
你可以通过在C代码中使用`__attribute__((section(...)))`指令来指定数据应存储在哪个内存区域中,同时也可以通过链接器脚本中的区域映射设置来实现这一目的。此外,还可以利用映射文件、`size`命令以及运行时分析工具(如堆栈水印检测技术)来验证数据是否被正确存放到了预定的位置。掌握这些方法的关键在于明确了解固件中的每个组件应该属于哪个内存区域,并确保拥有能够及时发现错误的相关工具。

![如何在 Flutter 中使用混合组件——[完整手册] 如何在 Flutter 中使用混合组件——[完整手册]](https://cdn.hashnode.com/uploads/covers/63a47b24490dd1c9cd9c32ff/26c1c13b-8a54-4b4c-8b46-c292be780b65.png)
![如何构建属于自己的、针对特定语言的大语言模型——[完整手册] 如何构建属于自己的、针对特定语言的大语言模型——[完整手册]](http://www.cheeli.com.cn/wp-content/plugins/contextual-related-posts/default.png)
