ESP-32快速内存复制

这篇文章将讨论如何在Xtensa(ESP-32)快速复制内存。文章分析了Xtensa CPU架构、内存模型、指令集支持。最后通过跑分测试验证通过汇编编写的适用于对齐数据的快速内存复制函数比ROM中提供的stdlib快10%。

--by Captdam @ Oct 13, 2026 Oct 13, 2026

Index

我最近在搞一个ESP-32项目,即使用裸TCP搭建HTTP静态文件服务器。这里面,服务器主要做一件事:将数据从闪存或内存复制到TCP缓冲。

我在之前的文章ESP-32 TCP裸金属HTTP服务器和X86、ARM与Xtensa上微优化字符串比较中已经讨论了如何使用ESP-32搭建简单的基于TCP的HTTP服务器和不同CPU架构上对字符串操作的微优化。你或许可以先参考这两篇文章,因为这两篇文章包括了很多本文将使用的知识点。

本文中,我将探讨如何在Extensa(ESP-32内部使用的CPU)上高效地移动数据。具体来说:

这篇文章中,我们将参考两份文档:

内存模型

哈弗架构

地址映射结构
地址映射结构 - ESP32 TRM (版本 5.8)截屏
地址映射
地址映射 - ESP32 TRM (版本 5.8)截屏

Xtensa是一款双核32位地址空间哈弗架构处理器。也就是说,有两条独立的内存总线分别负责数据与指令访问。ESP-32文档指出:

违反指令/数据的访问模式会造成CPU异常,终止执行程序。

虽然内存地址总线是32位宽,支持4GB空间,一些区域是保留的(未连接),一些区域映射到同一块实际物理内存。

例如,如图所示,内部SRAM被同时映射到地址0x3FFAE000到0x3FFFFFFF的数据内存空间和地址0x40070000到0x400BFFFF的指令内存空间。

这有助于我们将数据保存在指令空间内。在程序初始化时,程序文本(包括指令和初始化数据)可以被一股脑载入SRAM中一块被同时映射到指令空间(以便执行)和数据空间(以便读写)的区块。否则,我们将必须把指令和数据分两次载入指令空间和数据空间。

内存种类

ESP-32内含多种不同类型的物理内存。

RAM

片上寄存器地址映射
片上寄存器地址映射 - ESP32 TRM (版本 5.8)截屏

RAM,更确切地来讲,内部SRAM。这是我们的主内存,直接和CPU的内存总线相连。任何运行时需要的数据,包括栈、堆、全局(静态)变量都存放于此。同时,程序也需要被提前从闪存(或是其它外部设备)载入RAM后,其指令才能被CPU获取并执行,其数据才能被CPU读写。

Xtensa使用哈弗架构。虽然整个RAM都被映射在数据空间,但是只有其中部分RAM也被映射在指令空间。程序的指令和数据必须被放在相应的内存空间并使用正确的访问模式,否则,CPU将会异常。

RAM可以被拆分为3个部分:

除非阻塞,访问(读、写、执行)RAM只需要一个CPU周期。

RAM使用静态RAM,即SRAM。注意这是一个电子工程词汇,表示由静态电路D型寄存器制成。和DRAM相反,不需要刷新电容。不要和编程语言的静态变量搞混了。

指令内存访问
指令内存访问 - Xtensa指令集架构截屏
l32指令性能
l32指令性能 - Xtensa指令集架构截屏

参考Xtensa文档:Only the loads L32I, L32I.N, and L32R can access InstRAM and InstROM locations、Only the stores S32I and S32I.N can access InstRAM。

我们只能使用词访问指令空间,即l32指令。并且,访问指令空间比访问数据空间更慢。我未能在文档中找出访问指令空间需要的时钟周期与降低速度具体原因,我的猜测读取指令空间与执行指令空间会冲突,因此需要多个周期。

ROM

ROM是内部只读内存。正如其名,其内容在出厂时就烧录好,我们没办法修改(除非有几个小目标的实力可以订购一批自定义的芯片)。ROM中包含一级启动引导程序和一些常用函数,例如C标准库,包含memcpy()。

ROM可以被拆分为2个部分:

除非阻塞,访问ROM只需要一个CPU周期。

闪存

片外存储器地址映射
片外存储器地址映射 - ESP32 TRM (版本 5.8)截屏

闪存用于保存我们的程序(包括程序指令和初始化数据)。闪存掉电后仍能保存内容。

ESP32系列芯片对比
ESP32系列芯片对比 - ESP32 系列芯片技术规格书 v5.3截屏
ESP32-PICO系列型号对比
ESP32-PICO系列型号对比 - ESP32-PICO 系列技术规格书 v1.3截屏

闪存可以在片外,通过PCB连接芯片(如ESP32-D0),也可以在片内但是CPU晶圆外,通过金属层/互连层连接CPU晶圆(如ESP32-PICO和ESP32-U40)无论如何,最重要的一点是,闪存不直接与CPU的内存总线相连。CPU既不能执行闪存上的指令,也不能读写闪存上的数据。

ESP-32支持最大16MB外部内存(这个说法好怪,谁最先把memory翻译成内存的?),如闪存和外部RAM,这能够保存对于嵌入式系统来说相当多的指令与数据了,除非拿来存图片或者视(谁家好人这么玩呀,接张SD卡吧)。

我们可以映射:

 SPI系统框图
SPI系统框图 - ESP32 TRM (版本 5.8)截屏

ESP-32支持SPI(DSPI、QSPI)外部内存。ESP-32共有4套SPI外设,其中SPI0用于读取外部内存。

缓存

因为闪存和外部内存不直接与CPU的内存总线相连,CPU自然无法访问它们。一个办法是手动通过SPI将内容读入内部RAM,无疑这很不优雅且低效。

缓存系统则可以用来在后台自动读取。缓存系统将外部内容暂时读入自己的zh内存中,以此将外部SPI闪存或外部SPI内存映射到其内部池中。这样,CPU就可以像是访问内部内存一样访问外部内容了。

Cache系统框图
Cache系统框图 - ESP32 TRM (版本 5.8)截屏

ESP-32有两个缓存池,各32kB,内部为32字节长的块。

下面是缓存的工作原理。当CPU需要特定内容(读数据或是执行指令),就会把该内容的地址至于内存总线上。如果这个地址属于外部设备(如闪存上的一条指令),该请求就会被路由到闪存系统。

缓存系统先检测需要的内容是否已存在与缓存系统内部的池中:

数据分配

缓存

因为有缓存系统加持,我们可以在外部闪存上运行程序,亦或从外部闪存和外部RAM读取数据,就像是它们在内部一样。

但是,缓存池是有限的,只有两个32kB的缓存池用于外部程序和数据。也就是说,我们不可能缓存整个外部闪存或外部RAM。当CPU尝试读取没有被缓存的外部内容时,就会发生缓存缺失的问题,并且这个问题代价不小。

比如说,ESP-32的CPU与RAM运行频率为160MHz,读取一个32比特的(一个词)的RAM中或是已被缓存的内容只需要1各CPU周期,即6.25纳秒。外部Quad-SPI(四数据线SPI)设备一般运行在40MHz(如果距离远或是环境噪音严重,频率还会更低),则需要8个SPI周期(4比特每SPI周期)读取一个32比特的内容,即200纳秒。光是这样就慢了32倍,我们甚至还没考虑其它开销,比如SPI通讯地址段。

再者,因为缓存有限。我们在之前缓存的内容可能会被之后的内容覆盖,当我们再次访问之前爱你的内容时,我们将需要重新缓存之前的内容。

对于偶尔使用的程序和数据,或是我们对运行速度要求不高时,使用缓存并无大碍,不过是缓存缺失时速度慢点而已。除非你对SPI的噪音或是功耗有一些偏执的想法。

RAM

我们也可以把一些程序何数据强制放在RAM里面,这样,访问肯定就只要一个周期了。

在ESP-IDF的C语言中,可以通对程序和常量过添加IRAM_ATTR参数和对数据添加DRAM_ATTR参数来实现。注意,Xtensa使用哈弗架构,IRAM_ATTR将内容放在指令内存中,DRAM_ATTR放在数据内存中:


DRAM_ATTR int globalStaticData_inDataRAM;

IRAM_ATTR int function_in() {

	IRAM_ATTR const static int localStaticData_inInstRAM = 1;
	DRAM_ATTR const static int localStaticData_inDataRAM = 2;

	int localData_inStack = 3; // Stack is always in RAM
	const static int localConstData_inTextFlash = 4;
}
	

当程序初始化时,RAM程序与数据会被从闪存复制到RAM中,然后才执行主程序。

不过,RAM也不是无限的。我们没办法把整个程序文本都放在RAM中,只能顾及常用的或是对速度要求高的内容。至于没有被放进RAM的,只能在运行时依靠缓存系统来动态加载了。当然,代价就是缓存缺失时会阻塞。

RAM由32kB大小的块构成,多个上游设备同时访问同一个块会造成阻塞。

基准测试设置

本文中,我们将编写一个简单程序来测试从一个地方(来源)复制数据到另一个地方(目标):


#define MAXSIZE 2048

size_t len = 1024;
char dest[MAXSIZE];
char src[MAXSIZE];
memcpy(dest, src, len);
	

在32位系统上,编译器一般会将变量都放在对齐的地址上。内存对齐允许使用多字节操作,以提高性能。我们也可以通过添加__attribute__((aligned(size)))参数来确保对齐。


char __attribute__((aligned(4))) dest[MAXSIZE];
char __attribute__((aligned(4))) src[MAXSIZE];
	

我们将测量内存复制前后的CPU周期计数器以计算操作消耗的周期数。我们可以通过ESP-IDF提供的uint32_t cpu_hal_get_cycle_count()宏或是直接在汇编中读取CPU特殊寄存器rsr.ccount ax获取CPU周期。


#include "hal/cpu_hal.h"

uint32_t start = cpu_hal_get_cycle_count();
memcpy(dest, src, len);
uint32_t end = cpu_hal_get_cycle_count();
	

终端中打印周期数:


#include "esp_log.h"

ESP_LOGI("[Copy]", "%x <-- %x", dest, src);
ESP_LOGI("[Copy]", "Cycles %d: %d", i, end - start);
	

为了防止程序缓存缺失,消除数据缓存缺失以外,由于程序指令缓存缺失造成的阻塞周期,我们需要把整个程序指令都载入RAM。


#include "esp_attr.h"

IRAM_ATTR app_main() {

}
	

复制的目标只能是在RAM中:

来源则可以存在多种选择:

结合下来:


#include "esp_log.h"
#include "esp_attr.h"
#include "hal/cpu_hal.h"

#include <string.h>

#define MAXSIZE 2048
#define LEN 1

DRAM_ATTR char __attribute__((aligned(4))) dest[MAXSIZE]; // 数据RAM,静态

IRAM_ATTR void app_main(void) {
	char __attribute__((aligned(4))) src[MAXSIZE]; // 数据RAM,栈
//	const static char __attribute__((aligned(4))) src[MAXSIZE]; // 缓存闪存
//	DRAM_ATTR const static char __attribute__((aligned(4))) src[MAXSIZE]; // 数据RAM,静态
//	IRAM_ATTR const static char __attribute__((aligned(4))) src[MAXSIZE]; // 指令RAM
	
	ESP_LOGI("[Copy]", "%x <-- %x", dest, src);
	
	for (int i = 0; i < 5; i++) {
		uint32_t start = cpu_hal_get_cycle_count();

		memcpy(dest, src, LEN);
		
		uint32_t end = cpu_hal_get_cycle_count();
		ESP_LOGI("[Copy]", "Cycles %d: %d", i, end - start);
	}
}
	

对于每种情况我们都执行5次。第一次得到的周期数为缓存缺失时的结果,包括了从外部设备读取内容所需周期。之后四次的周期数为缓存命中的结果。

标准memcpy()

片内的ROM中提供了一个C标准库,包含memcpy()函数。不管我们是否使用,该函数都在这,出厂时就有了。

该函数位于地址0x4000c2c8。你可以反汇编ROM的ELF文件tool/esp-rom-elfs/20240305/esp32_rev300_rom.elf来查看其内容。

这里我就不再详述反汇编后该函数的机器码了。它确实是进行过(某种)优化的,例如,它一次复制4个4字节词以降低循环的额外开销。

你可以选择使用这个函数,或是不使用。如果使用,就不需要自己再写了,能节约一定闪存和指令RAM的空间。

测试

现在,让我们编译这个程序并载入ESP-32以进行测试。

在所有情况下,因为dest以DRAM_ATTR的方式作为全局变量声明,其地址为0x3FFB1E0C,即内部数据空间,指向内部SRAM2。

如果来源以const static声明,则位于闪存中程序文本内:


const static char __attribute__((aligned(4))) src[MAXSIZE];
	

在这种情况下,src的地址位于0x3F403830,即外部数据空间。

周期数 - 从闪存复制 - 标准库
长度 测试1测试2测试3测试4测试5
1 67340404040
4 67542424242
70 198486868686
128 3274113113113113
1024 21530673673673673
2047 424071325132513251325
2048 423941313131313131313

如果来源以默认方式声明,则位于函数栈内,且栈只可能是在数据RAM中:


char __attribute__((aligned(4))) src[MAXSIZE];
	

在这种情况下,src的地址位于0x3FFB43C0,即内部数据空间,指向内部SRAM2。

周期数 - 从数据RAM栈复制 - 标准库
长度 测试1测试2测试3测试4测试5
1 3939393939
4 4141414141
70 8585858585
128 112112112112112
1024 672672672672672
2047 13241324132413241324
2048 13121312131213121312

如果来源以DRAM_ATTR const static声明,则位于数据RAM中:


DRAM_ATTR const static char __attribute__((aligned(4))) src[MAXSIZE];
	

在这种情况下,src的地址位于0x3FFB160C,即内部数据空间,指向内部SRAM2。

周期数 - 从数据RAM静态变量复制 - 标准库
长度 测试1测试2测试3测试4测试5
1 3939393939
4 4141414141
70 8585858585
128 112112112112112
1024 672672672672672
2047 13241324132413241324
2048 13131313131313131313

如果来源以IRAM_ATTR const static声明,则位于指令RAM中:


IRAM_ATTR const static char __attribute__((aligned(4))) src[MAXSIZE];
	

在这种情况下,src的地址位于0x400835C8,即内部指令空间,指向内部SRAM0。

周期数 - 从指令RAM复制 - 标准库
长度 测试1测试2测试3测试4测试5
1 LoadStoreError
4 4848484848
70 LoadStoreError
128 337337337337337
1024 24652465246524652465
2047 LoadStoreError
2048 48974897489748974897

总结

周期数 - 总结 - 标准库
长度 1470128102420472048 速度(周期每字节,2048字节时)
闪存(缺失) 67367519843274215304240742394 20.700
闪存(命中) 40428611367313251313 0.641
数据RAM(栈) 39418511267213241312 0.641
数据RAM(静态) 39418511267213241313 0.641
指令RAM Error48Error3372465Error4897 2.391

该结果表示:

我们的快速复制对齐数据

正如我在我的另一篇文章中提到,多字节访问词对齐的数据比单字节访问更高效。

也就是说,我们可以一次性复制4字节(一个词,32位机的一个词就是4字节),就比一次只复制一个字节快4倍:


for (int i = 0; i < len / 4; i++) {
	*((uint32_t*)dest) = *((uint32_t*)src);
	dest += 4;
	src += 4;
}
	

虽然两个缓冲的地址都是对齐的,但是长度却不一定。因此,我们仍然需要对最后的1到3字节使用单字节复制:


for (int i = 0; i < len % 4; i++) {
	*((char*)dest) = *((char*)src);
	dest++;
	src++;
}
	

举例,要复制“Hello memories!”,包括15个字符(不计算空结束符),我们需要复制:

  1. Hell - 词访问

  2. o me - 词访问

  3. mori - 词访问

  4. e - 字节访问

  5. s - 字节访问

  6. ! - 字节访问

我们编译这段代码:


400835c4 <mycopy>:
400835c4:	004136        	entry	a1, 32
400835c7:	41b240        	srli	a11, a4, 2
400835ca:	fb9c      	beqz.n	a11, 400835ed <mycopy+0x29>
400835cc:	a0cb20        	addx4	a12, a11, a2
400835cf:	c08c20        	sub	a8, a12, a2
400835d2:	fcc882        	addi	a8, a8, -4
400835d5:	418280        	srli	a8, a8, 2
400835d8:	039d      	mov.n	a9, a3
400835da:	01c882        	addi	a8, a8, 1
400835dd:	078876        	loop	a8, 400835e8 <mycopy+0x24>
400835e0:	09a8      	l32i.n	a10, a9, 0
400835e2:	994b      	addi.n	a9, a9, 4
400835e4:	02a9      	s32i.n	a10, a2, 0
400835e6:	224b      	addi.n	a2, a2, 4
400835e8:	a03b30        	addx4	a3, a11, a3
400835eb:	0c2d      	mov.n	a2, a12
400835ed:	144040        	extui	a4, a4, 0, 2
400835f0:	d48c      	beqz.n	a4, 40083601 <mycopy+0x3d>
400835f2:	424a      	add.n	a4, a2, a4
400835f4:	000382        	l8ui	a8, a3, 0
400835f7:	331b      	addi.n	a3, a3, 1
400835f9:	004282        	s8i	a8, a2, 0
400835fc:	221b      	addi.n	a2, a2, 1
400835fe:	f29247        	bne	a2, a4, 400835f4 <mycopy+0x30>
40083601:	f01d      	retw.n0x33
	

老实说,编译结果并不理想。我决定自己用汇编手搓。

Xtensa指令集架构

在编写汇编代码前,我想要重点指出Xtensa的两条指令。

循环

Xtensa提供了一套零开销的循环指令:loop、loopnez和loopgtz:


my_loop:
	loopxxx, as, label_end
	inst1
	inst2
label_end:
	inst3
	

其中:

CPU内含3个特殊寄存器:

loopxxx可用于在进入循环前就确定循环数的条件下降低缓缓地开销。如果没有循环指令,我们将需要使用额外的指令以追踪循环数并跳转:


my_loop:
	inst1
	inst2
	addi	as, as, -1
	bnez	as, my_loop
label_end:
	inst3
	

注意,CPU内只有一套循环寄存器。因此,我们不能套嵌内外都使用loop指令的循环,在循环中调用子函数也有风险因为子函数可能使用loop指令。

哈弗架构

Xtensa使用哈弗架构,访问指令空间和数据空间模式不同。

要从指令空间读取,我们必须修改复制最后几字节的代码:


uint32_t last = *((uint32_t*)src);
	For (int i = 0; i < len % 4; i++) {
		*((char*)dest) = last;
		dest++;
		last >> 8;
}
	

这样,我们就使用词读取从来源读取一整个词(会包括几个额外字节),以满足指令空间要求的访问模式。

当长度不是4的倍数时,我们会额外读取几个字节。因为读没有副作用,这不会造成什么问题。

另外,内存是以页的方式分配给程序的,并且页的大小永远是4的倍数。也就是说,额外读取的几个字节仍然在当前页中,这也就不会出发out-of-bounds内存错误。

我们使用字节写来写入目标以防止误写。如果要写指令空间,则长度必须为4的倍数。

我们的代码

首先处理最后几个字节:


#define faststr_memcpy(dest, src, len) _faststr_memcpy((uint8_t*)dest, (uint8_t*)src, (size_t)len)
IRAM_ATTR void _faststr_memcpy(uint8_t* dest, uint8_t* src, size_t len) {
	asm volatile (
		"l32i	%[src], %[src], 0\n\t"
		"loopnez	%[len], 1f\n\t"
		"s8i	%[src], %[dest], 0\n\t"
		"srli	%[src], %[src], 8\n\t"
		"addi	%[dest], %[dest], 1\n\t"
		"1:	\n\t"
	:
		[src] "+a" (src),
		[dest] "+a" (dest)
	:
		[len] "a" (len)
	:
		"memory"
	);
}
		

在最开始:

  • dest - 目标缓冲的最后一个词的地址。

  • src - 来源缓冲的最后一个词的地址。

  • len - 最后一个词中需要复制的字节数,可以为0到3。

首先,将最后一个词读入src。这可能包含额外的数据。

如果len不为0,将最后一个词中需要复制的字节数载入循环计数器。在循环中:

  1. 将src(内容)的低8位写入dest(指针)指向的地址。

  2. 右移src(内容)8位,以将下一个字节移入低8位。

  3. dest(指针)进一,以指向目标的下一个字节。

在最后:

  • dest - 目标最后一个字节的下一个字节的地址。

  • src - 覆盖。

  • len - 不变。

所有三个参数都不再需要。

现在,编写复制词复制的主体:


#define faststr_memcpy4(dest, src, len) _faststr_memcpy4((uint32_t*)dest, (uint32_t*)src, (size_t)len)
IRAM_ATTR void _faststr_memcpy4(uint32_t* dest, uint32_t* src, size_t len) {
	uint32_t next = len & 0b11;
	len /= 4;
	asm volatile (
		"loopnez	%[len], 1f\n\t"
		"l32i	%[len], %[src], 0\n\t"
		"s32i	%[len], %[dest], 0\n\t"
		"addi	%[src], %[src], 4\n\t"
		"addi	%[dest], %[dest], 4\n\t"
		"1:	\n\t"
	:
		[len] "+a" (len),
		[src] "+a" (src),
		[dest] "+a" (dest)
	:
	:
		"memory"
	);
	faststr_memcpy(dest, src, next);
}
		

在最开始:

  • dest - 目标缓冲的地址。

  • src - 来源缓冲的地址。

  • len - 需要复制的字节数。

首先,计算:

  • next - 最后一个词中需要复制的字节数,可以为0到3。

  • len - 需要复制的词数。

如果len不为0,将复制的词数载入循环计数器。在循环中:

  1. len不再需要,可以用作临时数据。

  2. 从src(指针)指向的地址读一个词,写入len(临时内容)。

  3. 从len(临时内容)写一个词到dest(指针)指向的地址。

  4. src(指针)和dest(指针)进4(词的大小)。

对于每4字节,我们进行1次读、1次写、2次进位。也就是说,4字节共4周期,即1周期每字节。这里,额外开销为为了进位2个指针消耗的2周期。

在最后:

  • dest - 目标缓冲的最后一个词的地址。

  • src - 来源缓冲的最后一个词的地址。

  • len - 覆盖。

dest和src可以用于调用faststr_memcpy(dest, src, len)。

为了降低指针进位带来的额外消耗,我们可以进行多次带偏移量的读写,循环最后再给指针进位总量:


#define faststr_memcpy48(dest, src, len) _faststr_memcpy48((uint32_t*)dest, (uint32_t*)src, (size_t)len)
IRAM_ATTR void _faststr_memcpy48(uint32_t* dest, uint32_t* src, size_t len) {
	uint32_t next = len & 0b11111;
	len /= 32;
	asm volatile (
		"loopnez	%[len], 1f\n\t"
		"l32i	%[len], %[src], 0\n\t"
		"s32i	%[len], %[dest], 0\n\t"
		"l32i	%[len], %[src], 4\n\t"
		"s32i	%[len], %[dest], 4\n\t"
		"l32i	%[len], %[src], 8\n\t"
		"s32i	%[len], %[dest], 8\n\t"
		"l32i	%[len], %[src], 12\n\t"
		"s32i	%[len], %[dest], 12\n\t"
		"l32i	%[len], %[src], 16\n\t"
		"s32i	%[len], %[dest], 16\n\t"
		"l32i	%[len], %[src], 20\n\t"
		"s32i	%[len], %[dest], 20\n\t"
		"l32i	%[len], %[src], 24\n\t"
		"s32i	%[len], %[dest], 24\n\t"
		"l32i	%[len], %[src], 28\n\t"
		"s32i	%[len], %[dest], 28\n\t"
		"addi	%[src], %[src], 32\n\t"
		"addi	%[dest], %[dest], 32\n\t"
		"1:	\n\t"
	:
		[len] "+a" (len),
		[src] "+a" (src),
		[dest] "+a" (dest)
	:
	:
		"memory"
	);
	faststr_memcpy4(dest, src, next);
}
		

我决定以32字节为一个块,因为缓存的块长度也是32字节。

循环内部的程序文本不能超过256字节。

在最开始:

  • dest - 目标缓冲的地址。

  • src - 来源缓冲的地址。

  • len - 需要复制的字节数。

首先,计算:

  • next - 最后一个块中需要复制的字节数,可以为0到31。

  • len - 需要复制的块数。

如果len不为0,将复制的块数载入循环计数器。在循环中:

  1. len不再需要,可以用作临时数据。

  2. 从src(指针)指向的地址读一个词,写入len(临时内容)。

  3. 从len(临时内容)写一个词到dest(指针)指向的地址。

  4. 从src+4(指针)指向的地址读一个词,写入len(临时内容)。

  5. 从len(临时内容)写一个词到dest+4(指针)指向的地址。

  6. 继续,直到总计读写32字节,即一个块。

  7. src(指针)和dest(指针)进32。

对于每32字节,我们进行8次读、8次写、2次进位。也就是说,32字节共18周期,约半个周期每字节。

在最后:

  • dest - 目标缓冲的最后一个块的地址。

  • src - 来源缓冲的最后一个块的地址。

  • len - 覆盖。

dest和src可以用于调用faststr_memcpy4(dest, src, len)。

测试

现在,让我们编译这个程序并载入ESP-32以进行测试。


400835b8 <_faststr_memcpy48>:
400835b8:	004136        	entry	a1, 32
400835bb:	418540        	srli	a8, a4, 5
400835be:	259876        	loopnez	a8, 400835e7 <_faststr_memcpy48+0x2f>
400835c1:	0388      	l32i.n	a8, a3, 0
400835c3:	0289      	s32i.n	a8, a2, 0
400835c5:	1388      	l32i.n	a8, a3, 4
400835c7:	1289      	s32i.n	a8, a2, 4
400835c9:	2388      	l32i.n	a8, a3, 8
400835cb:	2289      	s32i.n	a8, a2, 8
400835cd:	3388      	l32i.n	a8, a3, 12
400835cf:	3289      	s32i.n	a8, a2, 12
400835d1:	4388      	l32i.n	a8, a3, 16
400835d3:	4289      	s32i.n	a8, a2, 16
400835d5:	5388      	l32i.n	a8, a3, 20
400835d7:	5289      	s32i.n	a8, a2, 20
400835d9:	6388      	l32i.n	a8, a3, 24
400835db:	6289      	s32i.n	a8, a2, 24
400835dd:	7388      	l32i.n	a8, a3, 28
400835df:	7289      	s32i.n	a8, a2, 28
400835e1:	20c332        	addi	a3, a3, 32
400835e4:	20c222        	addi	a2, a2, 32
400835e7:	248240        	extui	a8, a4, 2, 3
400835ea:	079876        	loopnez	a8, 400835f5 <_faststr_memcpy48+0x3d>
400835ed:	0388      	l32i.n	a8, a3, 0
400835ef:	0289      	s32i.n	a8, a2, 0
400835f1:	334b      	addi.n	a3, a3, 4
400835f3:	224b      	addi.n	a2, a2, 4
400835f5:	144040        	extui	a4, a4, 0, 2
400835f8:	0338      	l32i.n	a3, a3, 0
400835fa:	079476        	loopnez	a4, 40083605 <_faststr_memcpy48+0x4d>
400835fd:	004232        	s8i	a3, a2, 0
40083600:	413830        	srli	a3, a3, 8
40083603:	221b      	addi.n	a2, a2, 1
40083605:	f01d      	retw.n
		

这里展示了编译器生成的机器码,可以看到我们的代码位于地址0x400835B8,即内部指令内存空间,目标为内部SRAM0。

另外,编译器将词复制_faststr_memcpy4合并在地址0x400835EA,将字节复制_faststr_memcpy合并在地址0x400835F8。

我们将测试:

在所有情况下,因为dest以DRAM_ATTR的方式作为全局变量声明,其地址为0x3FFB1E0C,即内部数据空间。(和使用标准库时相同)

如果来源以const static声明,地址位于0x3F403830,即外部数据空间。(和使用标准库时相同)

周期数 - 从闪存复制 - 我们的代码
长度 测试1测试2测试3测试4测试5
1 (块) 67037373737
1 (词) 65321212121
4 (块) 67239393939
4 (词) 65522222222
70 (块) 197069696969
70 (词) 198184848484
128 (块) 3266100100100100
128 (词) 3301140140140140
1024 (块) 21466604604604604
1024 (词) 218941036103610361036
2047 (块) 422821195119511951195
2047 (词) 431452063206320632063
2048 (块) 422661180118011801180
2048 (词) 431422060206020602060

如果来源以默认方式声明,地址位于0x3FFB43C0,即内部数据空间,指向内部SRAM2。(和使用标准库时相同)

周期数 - 从数据RAM栈复制 - 我们的代码
长度 测试1测试2测试3测试4测试5
1 (块) 3737373737
1 (词) 2121212121
4 (块) 3838383838
4 (词) 2222222222
70 (块) 6868686868
70 (词) 8484848484
128 (块) 100100100100100
128 (词) 140140140140140
1024 (块) 604604604604604
1024 (词) 10361036103610361036
2047 (块) 11951195119511951195
2047 (词) 20632063206320632063
2048 (块) 11801180118011801180
2048 (词) 20602060206020602060

如果来源以DRAM_ATTR const static声明,地址位于0x3FFB160C,即内部数据空间,指向内部SRAM2。(和使用标准库时相同)

周期数 - 从数据RAM静态变量复制 - 我们的代码
长度 测试1测试2测试3测试4测试5
1 (块) 3737373737
1 (词) 2121212121
4 (块) 3838383838
4 (词) 2222222222
70 (块) 6868686868
70 (词) 8484848484
128 (块) 100100100100100
128 (词) 140140140140140
1024 (块) 604604604604604
1024 (词) 10361036103610361036
2047 (块) 11951195119511951195
2047 (词) 20632063206320632063
2048 (块) 11811181118111811181
2048 (词) 20602060206020602060

如果来源以IRAM_ATTR const static声明,址位于0x40083608,即内部指令空间,指向内部SRAM0。(和使用标准库时相同,因为指令RAM中添加了我们的复制函数)另外,其地址紧接在我们的块复制函数后。

周期数 - Copy from 指令RAM - 我们的代码
长度 测试1测试2测试3测试4测试5
1 (块) 4545454545
1 (词) 2929292929
4 (块) 5353535353
4 (词) 3737373737
70 (块) 195195195195195
70 (词) 211211211211211
128 (块) 331331331331331
128 (词) 372372372372372
1024 (块) 24032403240324032403
1024 (词) 28362836283628362836
2047 (块) 47794779477947794779
2047 (词) 56475647564756475647
2048 (块) 47714771477147714771
2048 (词) 56525652565256525652

总结

周期数 - 总结 - 我们的代码
长度 1470128102420472048 速度(周期每字节,2048字节时)
闪存(缺失) - 块 67067219703266214664228242266 20.638
闪存(缺失) - 词 65365519813301218944314543142 21.065
闪存(命中) - 块 37396910060411951180 0.576
闪存(命中) - 词 212284140103620632060 1.006
数据RAM(栈) - 块 37386810060411951180 0.576
数据RAM(栈) - 词 212284140103620632060 1.006
数据RAM(静态) - 块 37386810060411951181 0.577
数据RAM(静态) - 词 212284140103620632060 1.006
指令RAM - 块 4553195331240347794771 2.330
指令RAM - 词 2937211372283656475652 2.760

该结果表示:

我们的代码对比标准库

周期数 - 我们的代码对比标准库
长度 1470128102420472048 速度(周期每字节,2048字节时)
闪存(缺失) - 标准库 67367519843274215304240742394 20.700 (基准)
闪存(缺失) - 块 67067219703266214664228242266 20.638 (+0.3%)
闪存(缺失) - 词 65365519813301218944314543142 21.065 (-1.8%)
闪存(命中) - 标准库 40428611367313251313 0.641 (基准)
闪存(命中) - 块 37396910060411951180 0.576 (+10.1%)
闪存(命中) - 词 212284140103620632060 1.006 (-56.9%)
数据RAM(栈) - 标准库 39418511267213241312 0.641 (基准)
数据RAM(栈) - 块 37386810060411951180 0.576 (+10.1%)
数据RAM(栈) - 词 212284140103620632060 1.006 (-57.0%)
数据RAM(静态) - 标准库 39418511267213241313 0.641 (基准)
数据RAM(静态) - 块 37386810060411951181 0.577 (+10.1%)
数据RAM(静态) - 词 212284140103620632060 1.006 (-56.9%)
指令RAM - 标准库 Error48Error3372465Error4897 2.391 (基准)
指令RAM - 块 4553195331240347794771 2.330 (+2.6%)
指令RAM - 词 2937211372283656475652 2.760 (-15.4%)

我们的代码和ROM中标准库相比:

,但是:

对于内存种类:

内存阻塞

ESP-32内含两个CPU核心与多个DMA。换言之,将有多个主机(核心与DMA)同时访问内存。

RAM由多个32kB(0x8000)的区块组成,每个区块同时只能响应一个主机。当多个主机同时访问同一区块时,速度就会受限。

我将通过启用第二个核心持续写目标来模拟内存阻塞。


DRAM_ATTR char __attribute__((aligned(4))) dest[MAXSIZE];
IRAM_ATTR void spin(void* param) {
	uint32_t tmp;
	asm volatile (
		"1:	\n\t"
		"movi	%[tmp], -1\n\t"
		"loopnez	%[tmp], 2f\n\t"
		"s32i	%[tmp], %[dest], 0\n\t"
		"2:	\n\t"
		"j	1b\n\t"
	:
		[tmp] "=&a" (tmp)
	:
		[dest] "a" (dest)
	:
		"memory"
	);
}

IRAM_ATTR void app_main(void) {
	xTaskCreatePinnedToCore(spin, "Spin", 2048, NULL, 15, NULL, APP_CPU_NUM);
	vTaskDelay(1000 / portTICK_PERIOD_MS);

	/* Benchmarking */
}
	

第二个核心将执行spin。这里面,使用一个零开销的循环来以1访问每周期的频率访问dest。

另外,我们还将添加另一个目标dest2,大小为32kB并且以32kB对齐。这样dest2就可以完全占用一个区块。


DRAM_ATTR char __attribute__((aligned(32*1024))) dest2[32*1024];
	

我们将使用我们创建的32字节块复制。在这个函数中,对于每32字节,我们进行8次读、8次写、2次指针进位。也就是说,88%的时间下,每周期进行0.5次读和每周期进行0.5次写。

开测:

第一个核心执行_faststr_memcpy48,其地址位于0x40083740,即区块0x40080000。换言之,第一个核心每周期执行区块0x400800001次。

第二个核心执行spin,其地址位于0x40082570,即区块0x40080000。换言之,第二个核心每周期执行区块0x400800001次。

第二个核心写入dest,其地址位于0x3FFC0000,即区块0x3FFC0000。频率为每周期1次。

周期数 - 内存阻塞 - 我们的块复制2048字节
情况 目标来源 测试1测试2测试3测试4测试5
1 dest0x3FFC0000+0000闪存0x3F400000+30D8 422691180118011801180
2 dest0x3FFC0000+0000数据RAM0x3FFC0000+2CE0 20692069206920692069
3 dest20x3FFB8000+0000数据RAM0x3FFC0000+2CE0 11811181118111811181
4 dest0x3FFC0000+0000指令RAM0x40080000+3790 47714771477147714771

结果很有趣:

  1. 吞吐量不变:每个词消耗2周期。具体情况:

    • 数据RAM区块0x3FFC0000 - 1.5次访问每周期:核心1写0.5次、核心2写1次。

    • Flash 0x3F400000 - 0.5次访问每周期:核心1读0.5次。

    • 指令RAM区块0x40080000 - 2次访问每周期:核心1执行1次、核心2执行1次。

  2. 吞吐量减半:每个词消耗4周期。具体情况:

    • 数据RAM区块0x3FFC0000 - 2次访问每周期:核心1读0.5次、核心1写0.5次、核心2写1次。

    • 指令RAM区块0x40080000 - 2次访问每周期:核心1执行1次、核心2执行1次。

  3. 吞吐量不变:每个词消耗2周期。具体情况:

    • 数据RAM区块0x3FFC0000 - 1.5次访问每周期:核心1读0.5次、核心2写1次。

    • 数据RAM区块0x3FFB8000 - 0.5次访问每周期:核心1写0.5次。

    • 指令RAM区块0x40080000 - 2次访问每周期:核心1执行1次、核心2执行1次。

  4. 吞吐量不变:每个词消耗8周期。具体情况:

    • 数据RAM区块0x3FFC0000 - 1.5次访问每周期: 0.5 writes from core 1, 1 write from core 2.

    • 指令RAM区块0x40080000 - 2.5次访问每周期:核心1读0.5次、核心1执行1次、核心2执行1次。

对于数据RAM,当访问频率达到2时,吞吐量减半。对于指令RAM,或许是因为其速度本来就慢,或因为有CPU指令缓存,吞吐量并无改变。