Cortex-M7 对于 指令乱序执行特性, Cache, 以及写代码时如何应对这些特性
概述
Cortex-M7
相对于之前的M4,M3有很大区别,尤其是指令流执行方面。M7拥有6级超标量流水线,对于它到底有多少级,我们不需要太关心。我们需要真正注意的是它具有指令乱序执行的特性,这个玩意不处理好的话会导致一些奇奇怪怪的问题。
除此之外,M7还具有L1 Cache
,这个会引入数据一致性问题,也是个麻烦的东西。
(好家伙,CPU乱序执行,Cache搞假数据)
Cortex-M7的乱序执行
关于乱序执行,直接以关键字搜索“CPU乱序执行”可以找到很多相关内容,但我们不需要对其中的实现细节深入了解。只需要知道它是为了效率而设计,并且它会在一些时候提前执行一些代码。
实际上由于硬件限制,CPU不能非常乱的执行代码,但我们可以认为它是完全无序的,它只保证某些内存访问顺序是正确的,而我们如何控制哪些内存是顺序访问的就是我们的目的。(例如外设寄存器访问,DMA要操作的内存等,这些都需要保证顺序,至少一定程度上需要保证顺序)
相反的,编译器编译出的代码更像是乱序执行,keil中在魔术棒里配置的O0优化和O3在调试时完全是两个感觉(谁试谁知道)
像STM32H750,它可以通过MPU将内存配置为
Device
或者Strongly-ordered
类型,这样Cortex-M7访问这些内存时,会严格保证顺序,不会乱来,而对于一些内存,例如堆栈或者一些与硬件无关(一般只有CPU访问的内存都是)的内存,则可以配置为Normal
,Cortex-M7将毫无顾忌的访问执行内存,内存访问的顺序将与代码的逻辑执行顺序不同,以提高效率。
逻辑依赖
不禁想到,如果CPU乱序执行代码,不会乱套吗?我们先看看程序的定义:
实际上,对程序的定义有一点是程序必须要有输出,如果没有输出,那它就不能算程序。为什么,如果一个所谓的“程序”没有输出,那它完全可以什么都不做(表现就是编译器把代码全部优化没了),因为我们(用户)不需要从程序中获取任何信息(即输出),CPU完全可以偷懒什么都不干。(编译器: 我刚刚把一段程序执行时间优化到了0,厉害吧)
即使编译器没有开启优化,如果CPU的流水线是理想的(级数无限),CPU完全可以判断这些指令不需要执行,直接跳过了。
看了前面的内容,下面的就好理解了。
无论是乱序执行(CPU执行代码时发生),还是编译器优化(编译代码时发生),必须保证输出不能有任何改变(不然我代码写了个寂寞)
我管这叫逻辑依赖
。
可以看出,无论是乱序执行还是编译器优化,都是"乱中有序"的。能乱,但不能乱来。
这样,就出现了一个新问题,如何定义哪些是输出/输入
呢?
什么?多了个
输入
,没错,输入
是程序的可选项,并且,它也享受和输出一样的优待,即不优化
,保证顺序
等。
编译器乱序
其实我们早在Cortex-M3,Cortex-M4的代码编写时,就已经碰到这个问题了。最突出的就是volatile
关键字,很多寄存器定义都需要它,但它的作用很简单,将C代码对这些变量的操作(每次的操作)都落实到指令级别的内存访问(对于Cortex 就是:LDR,STR等指令),防止使用这些变量时引用寄存器中的副本,并且,多个volatile
之间的执行顺序(编译后机器码中的执行顺序)也可以得到编译器的保证,不会被优化乱套,这就有点输入/输出
的意思了。
除了volatile
,在MDK中,还有一个伪函数: __schedule_barrier()
如果说volatile
是用来保证内存的访问操作和顺序,那__schedule_barrier()
就是用来保证代码的执行顺序(代码中上在__schedule_barrier()
之前的指令在编译后仍然在之前)。对于一些普通的代码无需用它,因为输入/输出
一般都以内存访问的形式出现在编程中,使用volatile
就足够了;但是,对于Cortex,它的关闭中断操作不是内存操作,而是一个指令,这是,如果想要保证某些代码在关闭中断指令后执行,需要使用__schedule_barrier()
在关闭中断指令之后,其他后续指令之前。
在Cortex-M3,Cortex-M4中,由于没有指令乱序执行的特性,所以我们只需要使用保证volatile
和__schedule_barrier()
保证指令顺序就不会出问题了。
但在Cortex-M7中,事情变得有些不同,CPU会乱序执行代码,光保证代码顺序已经不够了。
Cortex-M7 对内存的分类和访问操作
对于这些内容,在ST官网有个文档PM0253
描述了这些,包括MPU,Cache操作等,这里截取一些并进行说明。
内存类型
这个表对Cortex-M7
地址空间的内存类型进行了描述,这些内存类型可以通过MPU设置进行覆盖。
可以看出,内存总共有3种类型:
Normal
普通内存,乱序执行可以为所欲为
Device
一般用于外设的寄存器,保证访问的顺序,但可能推迟或提前(和C中的
volatile
很像),写操作可缓冲。
Strongly-ordered
与
Device
类似,但它更为严格,不可缓冲。(与指令同步?)
可以发现,外设寄存器和其他一些非内存的玩意都不为Normal
,这相当于CPU中的"volatile"
了,并且Strongly-ordered
比"volatile"
更严格。
访问可见性
上图说明了一些访问顺序的依赖(在没有代码依赖的情况下),标记为-
的,CPU就可能会乱序进行访问。
规避负面影响
为了在某些特殊的场合下,避开这些机制产生的影响(不确定性),例如我想让某个外设寄存器(Device
)操作必须在某一个内存(Normal
)操作之后。
Cortex-M7(其实M4, M3 就已经有了)提供了3个用于数据同步的指令。提供CMSIS可以这么调用:
__DMB()
数据内存屏障,使用该指令,可以保证前面的内存访问操作完毕后才进行后续的内存访问。(对于有Cache的情况,该指令并不会导致Cache与内存之间的内存同步,下面的指令同)
__DSB()
数据同步屏障,相比DMB更严格,可以保证前面的内存访问操作完毕后才进行后续的指令执行。 不仅后续的内存访问不会提前,就连指令也不会。
__ISB()
这个似乎比较特殊,它会清除指令流水线中的所有指令,使得流水线重新从内存或Cache中读取数据。
利用这些指令,可以防止某些场合下乱序执行的影响。
下面是一个STM32H750VB写入内部Flash的操作例子,由于Flash写入是写入的内存类型是Normal
,而FLASH的相关寄存器是Device
,M7默认认为它们之间是无逻辑依赖的,所以某些位置需要调用同步指令,由于所有操作都是基于内存访问的,只用DMB即可满足要求:
/// <summary>
/// 写入flash 无需32字节对齐 自动补齐
/// </summary>
/// <param name="programAddr">编程地址</param>
/// <param name="data">源数据</param>
/// <param name="SizeInByte">数据大小</param>
/// <returns>0:成功</returns>
int Flash_Bank1_Program(unsigned int programAddr,void *data,unsigned int SizeInByte){
int i;
unsigned int topNum;
unsigned int heard[8];
unsigned char *heard_b;
unsigned char *src_b;
if(FLASH->CR1 & FLASH_CR_LOCK){
if(Flash_Bank1_UnLock()){
return -1;
}
}
//等待所有操作完成
while(FLASH->SR1 & FLASH_SR_QW){
}
topNum=programAddr%32;
topNum=32-topNum;
if(topNum && (topNum!=32)){
programAddr=programAddr-programAddr%32;
heard[0]=((volatile unsigned int *)programAddr)[0];
heard[1]=((volatile unsigned int *)programAddr)[1];
heard[2]=((volatile unsigned int *)programAddr)[2];
heard[3]=((volatile unsigned int *)programAddr)[3];
heard[4]=((volatile unsigned int *)programAddr)[4];
heard[5]=((volatile unsigned int *)programAddr)[5];
heard[6]=((volatile unsigned int *)programAddr)[6];
heard[7]=((volatile unsigned int *)programAddr)[7];
__DMB();
}
else{
topNum=0;
}
FLASH->CR1|=FLASH_CR_PG;
__DMB();
src_b=(unsigned char *)data;
heard_b=(unsigned char *)heard;
if(topNum){
for(i=0; i<topNum; i++)
{
heard_b[32-topNum+i]=src_b[i];
SizeInByte--;
if(SizeInByte==0){
break;
}
}
((volatile unsigned int *)programAddr)[0]=heard[0];
__DMB();
((volatile unsigned int *)programAddr)[1]=heard[1];
__DMB();
((volatile unsigned int *)programAddr)[2]=heard[2];
__DMB();
((volatile unsigned int *)programAddr)[3]=heard[3];
__DMB();
((volatile unsigned int *)programAddr)[4]=heard[4];
__DMB();
((volatile unsigned int *)programAddr)[5]=heard[5];
__DMB();
((volatile unsigned int *)programAddr)[6]=heard[6];
__DMB();
((volatile unsigned int *)programAddr)[7]=heard[7];
__DMB();
src_b=&src_b[topNum];
programAddr+=32;
}
while(SizeInByte>=32){
for(i=0; i<32; i++)
{
heard_b[i]=src_b[i];
}
((volatile unsigned int *)programAddr)[0]=heard[0];
__DMB();
((volatile unsigned int *)programAddr)[1]=heard[1];
__DMB();
((volatile unsigned int *)programAddr)[2]=heard[2];
__DMB();
((volatile unsigned int *)programAddr)[3]=heard[3];
__DMB();
((volatile unsigned int *)programAddr)[4]=heard[4];
__DMB();
((volatile unsigned int *)programAddr)[5]=heard[5];
__DMB();
((volatile unsigned int *)programAddr)[6]=heard[6];
__DMB();
((volatile unsigned int *)programAddr)[7]=heard[7];
__DMB();
src_b=&src_b[32];
programAddr+=32;
SizeInByte-=32;
}
if(SizeInByte){
heard[0]=((volatile unsigned int *)programAddr)[0];
heard[1]=((volatile unsigned int *)programAddr)[1];
heard[2]=((volatile unsigned int *)programAddr)[2];
heard[3]=((volatile unsigned int *)programAddr)[3];
heard[4]=((volatile unsigned int *)programAddr)[4];
heard[5]=((volatile unsigned int *)programAddr)[5];
heard[6]=((volatile unsigned int *)programAddr)[6];
heard[7]=((volatile unsigned int *)programAddr)[7];
__DMB();
for(i=0; i<SizeInByte; i++)
{
heard_b[i]=src_b[i];
}
((volatile unsigned int *)programAddr)[0]=heard[0];
__DMB();
((volatile unsigned int *)programAddr)[1]=heard[1];
__DMB();
((volatile unsigned int *)programAddr)[2]=heard[2];
__DMB();
((volatile unsigned int *)programAddr)[3]=heard[3];
__DMB();
((volatile unsigned int *)programAddr)[4]=heard[4];
__DMB();
((volatile unsigned int *)programAddr)[5]=heard[5];
__DMB();
((volatile unsigned int *)programAddr)[6]=heard[6];
__DMB();
((volatile unsigned int *)programAddr)[7]=heard[7];
__DMB();
src_b=&src_b[SizeInByte];
programAddr+=SizeInByte;
//SizeInByte-=SizeInByte;
SizeInByte=0;
}
if(FLASH->SR1 & FLASH_SR_WBNE){
FLASH->CR1 &= FLASH_CR_FW;
}
//等待所有操作完成
while(FLASH->SR1 & FLASH_SR_QW){
}
FLASH->CR1 &= ~FLASH_CR_PG;
return 0;
}
实际上,大部分操作(有关与外设的)都是基于内存访问的(例如外设寄存器,内存等),这些情况下的同步只需要DMB指令就能同步了。我发现需要DSB的例子就只有关中断指令,如果操作会导致代码区更新,并且可能调用其中的代码,则需要ISB指令
Cortex-M7 中的Cache
在Cortex-M7与AXIM之间,多了个Cache,在它带来性能提升的同时,也有一些麻烦的事情。
数据一致性
Cache Cache Cache,它就是个缓存,缓存对内存的操作,它速度很快,当对一小部分内存进行密集(操作频率高)的访问时,很多中间的读写都不会落实到内存中,而是暂存在cache中。虽然速度快了,当缺点很明显,就是读写的内容很可能并不会落实到内存中,如果整个单片机只有CPU操作内存就非常好,但外设(例如stm32中的DMA, ETH USBHS的DMA等)访问内存得到的数据可能就是旧的数据,新的数据还在Cache里呢,相反的,外设向内存写入的数据可能不会被CPU读取到,因为CPU很可能读取的是Cache里的数据了。
如何Cache同步
所幸,这些不是不可避免的。
在头文件core_cm7.h
中,有一些方法可用于数据同步。
对于指令Cache(ICache)同步,只有一个方法提供
void SCB_InvalidateICache (void)
无效化ICache,将ICache内缓存的指令无效化。
例子: 这个可以用在将Flash内容修改后使用,使得将要执行的指令是新的。
对于数据Cache(DCache)同步,相关的同步方法有很多
void SCB_InvalidateDCache (void)
无效化整个DCache
void SCB_CleanDCache (void)
将缓存的并修改过的数据同步写入到内存
void SCB_CleanInvalidateDCache (void)
将缓存的并修改过的数据同步写入到内存后无效化整个DCache
void SCB_InvalidateDCache_by_Addr (uint32_t *addr, int32_t dsize)
SCB_InvalidateDCache的内存区域版本,将操作涉及到的CacheLine
如果要同步的内存区域很小,这个方法很快
void SCB_CleanDCache_by_Addr (uint32_t *addr, int32_t dsize)
SCB_CleanDCache 的内存区域版本,将操作涉及到的CacheLine
如果要同步的内存区域很小,这个方法很快
void SCB_CleanInvalidateDCache_by_Addr (uint32_t *addr, int32_t dsize)
SCB_CleanInvalidateDCache 的内存区域版本,将操作涉及到的CacheLine
如果要同步的内存区域很小,这个方法很快
对于内存区域版本,它的效率低于整个操作Cache的分界线应该在要操作的内存区域大小接近于Cache大小附近(对于STM32H750,如果要同步的内存大小远大于16KB,使用内存区域版本的同步方法就效率更低了,因为这会花更多的时间写入同步地址寄存器(SCB->DCIMVAC
,SCB->DCCMVAC
,SCB->DCCIMVAC
))
对于这些方法的使用,用户无需操心调度屏蔽,乱序执行的影响,这些工作在这些方法内部完成了。
以void SCB_InvalidateICache (void)
举例:
#define __ISB() do {\
__schedule_barrier();\
__isb(0xF);\
__schedule_barrier();\
} while (0U)
#define __DSB() do {\
__schedule_barrier();\
__dsb(0xF);\
__schedule_barrier();\
} while (0U)
#define __DMB() do {\
__schedule_barrier();\
__dmb(0xF);\
__schedule_barrier();\
} while (0U)
__STATIC_INLINE void SCB_InvalidateICache (void)
{
#if defined (__ICACHE_PRESENT) && (__ICACHE_PRESENT == 1U)
__DSB();
__ISB();
SCB->ICIALLU = 0UL;
__DSB();
__ISB();
#endif
}
调度屏蔽,指令同步安排得明明白白。
可以发现__schedule_barrier()
已经被安排进去了,DSB,ISB这种指令可不能被编译器乱排了。(因为编译器可能不知道这种指令是需要注意的)
数据同步时,要注意对齐同步问题,Cache最小单元为32字节,并且同步内存块的首地址都为32的倍数(32字节对齐),如果要同步的数据首地址和尾部有不对其32字节边界的情况的话,这种情况就要小心了,例如: 如果用户的普通数据和DMA要操作的数据的在同一个32字节块里的话,仅
Invalidate
会使修改过(如果修改过)的用户数据丢失,但CleanInvalidate
会破坏DMA传输好的数据,所以,涉及外设的内存有两种处理方式:
1.通过MPU配置内存为不缓存,不缓冲。
2.如果对于内存区域开启了可缓存或可缓冲,则保证内存区域占用的32Byte块(一般为开头或结尾的)中无其他的用户数据,防止相互妨碍。
MPU
前面提到,内存分为好几种类型,但MPU除了能覆盖掉默认的内存类型外,还能进行Cache策略配置,读写权限配置等。
缓存策略通过TEX C B 来配置。一般使用TEX=0 =1这两种TEX。
从表中可以得知各种策略下的访问Cache策略。