关于ARM微控制器中的缓存

图片你好!



在上一篇文章中,我们使用了处理器缓存来加速Embox中微控制器上的图形在这种情况下,我们使用“直写”模式。然后,我们写了与“直写”模式相关的一些优点和缺点,但这只是一个粗略的概述。如前所述,在本文​​中,我想仔细研究一下ARM微控制器中的缓存类型并进行比较。当然,所有这些都将从程序员的角度考虑,并且我们不打算在本文中讨论内存控制器的细节。



我将从上一篇文章中停止的地方开始,即“回写”和“直写”模式之间的区别,因为这两种模式最常用。简而言之:



  • “回写”。写入数据仅进入高速缓存。实际的内存写入操作将推迟到缓存已满并且需要新数据空间时再进行。
  • “直写”。写操作“同时”发生在缓存和内存中。


直写



直写的优点被认为是易于使用,可以减少错误。实际上,在这种模式下,内存始终处于正确的状态,并且不需要其他更新过程。



当然,这似乎应该对性能产生很大影响,但是本文档中的STM本身却没有:

Write-through: triggers a write to the memory as soon as the contents on the cache line are written to. This is safer for the data coherency, but it requires more bus accesses. In practice, the write to the memory is done in the background and has a little effect unless the same cache set is being accessed repeatedly and very quickly. It is always a tradeoff.
也就是说,最初我们假设由于写入操作是针对内存的,因此写入操作的性能将与完全没有缓存的情况大致相同,并且主要的收益是由于重复读取而产生的。但是,STM对此表示反对,它说内存中的数据“在后台”,因此写入性能几乎与“回写”模式相同。特别是,这可能取决于存储控制器(FMC)的内部缓冲区。



“直写”模式的缺点:



  • 对同一内存的顺序和快速访问会降低性能。相反,在“写回”模式下,对同一内存的连续频繁访问将是一个加号。
  • 与“回写”的情况一样,在DMA操作结束后,仍然需要使缓存无效。
  • 在某些版本的Cortex-M7中,存在“在一系列直写式存储和加载中数据损坏”的错误。一位LVGL开发人员向我们指出了这一点。


回写



如上所述,在这种模式下(与“直写”相反),数据通常不通过写入进入内存,而仅通过高速缓存进入。像直写一样,此策略有两个子选项-1)写分配,2)不写分配。我们将进一步讨论这些选项。



写分配



通常,“读取分配”始终在高速缓存中使用-也就是说,在高速缓存未命中以进行读取时,将从内存中获取数据并将其放置在高速缓存中。同样,在发生写未命中的情况下,可以将数据加载到缓存中(“写分配”),也可以不加载数据(“不写分配”)。



通常,实际上,使用“写回写分配”或“直写不写分配”的组合。进一步在测试中,我们将尝试更详细地检查在哪些情况下使用“写分配”以及在哪些情况下“不写分配”。



MPU



在继续实际部分之前,我们需要弄清楚如何设置存储区的参数。要为ARMv7-M架构中的特定内存区域选择(或禁用)缓存模式,请使用MPU(内存保护单元)。



MPU控制器支持设置存储区域。特别是在ARMV7-M体系结构中,最多可以有16个区域。对于这些区域,您可以独立设置:起始地址,大小,访问权限(读/写/执行等),属性-TEX,可缓存,可缓冲,可共享以及其他参数。特别是,通过这种机制,您可以为特定区域实现任何类型的缓存。例如,我们只需为所有DMA操作分配一个内存区域并将该内存标记为不可缓存,就可以摆脱调用cache_clean / cache_invalidate的需要。



使用MPU时要注意的重要一点:

区域的基址,大小和属性都是可配置的,一般规则是所有区域都自然对齐。可以这样表示:

RegionBaseAddress [(N-1):0] = 0,其中N是log2(SizeofRegion_in_bytes)
换句话说,存储区域的起始地址必须与其自身的大小对齐。例如,如果您具有16 KB的区域,则需要将其对齐16 KB。如果内存区域为64 KB,则对齐到64 KB。等等。如果不这样做,则MPU可以自动将该区域“裁剪”为与其起始地址相对应的大小(已在实践中进行了测试)。



顺便说一下,STM32Cube中有几个错误。例如:



  MPU_InitStruct.BaseAddress = 0x20010000;
  MPU_InitStruct.Size = MPU_REGION_SIZE_256KB;


您可以看到起始地址是64 KB对齐的。我们希望该区域的大小为256 KB。在这种情况下,您将必须创建3个区域:第一个64 Kb,第二个128 Kb和第三个64 Kb。



您只需要指定与标准属性不同的区域即可。事实是,启用处理器缓存时,所有内存的属性都在ARM体系结构中进行了描述。有一组标准的属性(例如,这就是STM32F7 SRAM默认具有“写回写分配”模式的原因),因此,如果某些存储器需要非标准模式,则需要通过MPU设置其属性。在这种情况下,您可以在区域内设置一个具有自己属性的子区域,在该区域内选择另一个具有所需属性的高优先级区域。



中医



文档中所述(2.3嵌入式SRAM部分),STM32F7中的前64 KB SRAM是不可缓存的。在ARMv7-M体系结构本身中,SRAM位于0x20000000。TCM也指SRAM,但相对于其余存储器(SRAM1和SRAM2)位于不同的总线上,并且“更靠近”处理器。因此,该内存非常快,实际上具有与高速缓存相同的速度。因此,不需要缓存,并且无法使该区域可缓存。实际上,TCM是另一个这样的缓存。



指令缓存



应当注意,以上讨论的所有内容均指数据缓存(D-Cache)。但是,除了数据缓存外,ARMv7-M还提供了指令缓存-指令缓存(I-Cache)。I-Cache允许您将一些可执行(和后续)指令传输到缓存,这可以显着提高程序速度。特别是在代码比FLASH慢的情况下,例如QSPI。



为了减少使用以下缓存的测试的不可预测性,我们将有意禁用I-Cache并专门考虑数据。



同时,我想指出,与D-Cache不同,打开I-Cache非常简单,不需要MPU采取任何其他操作。



综合测试



在讨论了理论部分之后,让我们继续进行测试,以更好地理解特定模型的区别和适用范围。正如我上面所说,禁用I-Cache,仅与D-Cache一起使用。我还故意使用-O0进行编译,以使测试中的循环没有得到优化。我们将通过外部SDRAM存储器进行测试。使用MPU,我标记了64 KB区域,我们将向该区域公开所需的属性。



由于使用缓存的测试非常反复无常,并且受系统中所有事物的影响,因此让我们使代码线性和连续。为此,请禁用中断。同样,我们将不使用计时器来测量时间,而是使用DWT(数据观察点和跟踪单元)来测量时间,DWT具有32位处理器周期计数器。在此基础上(在Internet上),人们会延迟微秒的驱动程序。计数器很快以216 MHz的系统频率溢出,但是您最多可以测量20秒。让我们记住这一点,并在此时间间隔内进行测试,然后在启动前将时钟计数器清零。



您可以在此处查看完整的测试代码所有测试均在32F769IDISCOVERY板上执行



不可缓存的内存VS。回写



因此,让我们从一些非常简单的测试开始。



我们只是始终如一地写入内存。



    dst = (uint8_t *) DATA_ADDR;

    for (i = 0; i < ITERS * 8; i++) {
        for (j = 0; j < DATA_LEN; j++) {
            *dst = VALUE;
            dst++;
        }
        dst -= DATA_LEN;
    }


我们还按顺序向内存写入数据,但一次不写入一个字节,而是稍微扩展循环。



    for (i = 0; i < ITERS * BLOCKS * 8; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            *dst = VALUE;
            *dst = VALUE;
            *dst = VALUE;
            *dst = VALUE;
            dst++;
        }
        dst -= BLOCK_LEN;
    }


我们还按顺序向内存写入数据,但是现在我们还将添加读取操作。



    for (i = 0; i < ITERS * BLOCKS * 8; i++) {
        dst = (uint8_t *) DATA_ADDR;

        for (j = 0; j < BLOCK_LEN; j++) {
            val = VALUE;
            *dst = val;
            val = *dst;
            dst++;
        }
    }


如果运行所有这三个测试,则无论选择哪种模式,它们都将给出完全相同的结果:



mode: nc, iters=100, data len=65536, addr=0x60100000
Test1 (Sequential write):
  0s 728ms
Test2 (Sequential write with 4 writes per one iteration):
  7s 43ms
Test3 (Sequential read/write):
  1s 216ms


这是合理的,SDRAM不会那么慢,尤其是当您考虑通过其连接的FMC的内部缓冲区时。不过,我预计数字会略有变化,但事实证明,这些测试并未进行。好吧,让我们进一步考虑。



让我们尝试通过混合读写来“破坏” SDRAM的寿命。为此,让我们扩展循环并在实践中添加诸如数组元素增量之类的常识:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            // 16 lines
            arr[i]++;
            arr[i]++;
	***
            arr[i]++;
        }
    }


结果:



  :   4s 743ms
Write-back:                     :   4s 187ms


已经更好了-有了缓存,结果证明快了半秒。让我们尝试通过添加“稀疏”索引的访问来使测试更加复杂。例如,使用一个索引:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[i + 0 ]++;
            ***
            arr[i + 3 ]++;
            arr[i + 4 ]++;
            arr[i + 100]++;
            arr[i + 6 ]++;
            arr[i + 7 ]++;
            ***
            arr[i + 15]++;
        }
    }


结果:



  :   11s 371ms
Write-back:                     :   4s 551ms


现在,与缓存的区别变得更加明显!最重要的是,我们引入了第二个这样的索引:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[i + 0 ]++;
            ***
            arr[i + 4 ]++;
            arr[i + 100]++;
            arr[i + 6 ]++;
            ***
            arr[i + 9 ]++;
            arr[i + 200]++;
            arr[i + 11]++;
            arr[i + 12]++;
            ***
            arr[i + 15]++;
        }
    }


结果:



  :   12s 62ms
Write-back:                     :   4s 551ms


我们看到非缓存内存的时间增加了近一秒,而缓存时间却保持不变。



写分配VS。没有写分配



现在让我们处理“写分配”模式。在这里看到差异更加困难,因为如果在非缓存内存和“回写”之间的情况已经从第4次测试开始变得清晰可见,则测试尚未揭示“写分配”和“无写分配”之间的区别。让我们考虑一下-什么时候“写分配”会更快?例如,当您对顺序存储位置进行多次写入,但是从这些存储位置进行的读取却很少。在这种情况下,在“无写分配”模式下,我们将收到不断的未命中,并且错误的元素将通过读取被加载到缓存中。让我们模拟这种情况:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[j + 0 ]  = VALUE;
            ***
            arr[j + 7 ]  = VALUE;
            arr[j + 8 ]  = arr[i % 1024 + (j % 256) * 128];
            arr[j + 9 ]  = VALUE;
            ***
            arr[j + 15 ]  = VALUE;
        }
    }


此处,将16个记录中的15个设置为VALUE常数,同时从不同(且与写入无关)的元素arr [i%1024 +(j%256)* 128]中执行读取。事实证明,采用无写分配策略,只有这些元素会被加载到缓存中。使用此索引(i%1024 +(j%256)* 128)的原因是FMC / SDRAM的“速度下降”。由于在显着不同(非顺序)地址处进行的内存访问会显着影响工作速度。



结果:



Write-back                                           :   4s 720ms
Write-back no write allocate:               :   4s 888ms


最终,我们有所不同,虽然不是很明显,但已经可见。也就是说,我们的假设得到了证实。



最后,我认为最困难的案例。我们想了解何时“不写分配”比“写分配”更好。如果我们“经常”指的是在不久的将来不会使用的地址,则第一个更好。此类数据无需缓存。



在下一个测试中,在“写分配”的情况下,数据将在读写时填充。我制作了一个64 KB的数组“ arr2”,因此将刷新缓存以交换新数据。在“不写分配”的情况下,我创建了一个4096字节的“ arr”数组,只有它会进入缓存,这意味着缓存数据不会刷新到内存中。因此,我们将至少争取一个小小的胜利。



    arr = (uint8_t *) DATA_ADDR;
    arr2 = arr;

    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr2[i * BLOCK_LEN            ] = arr[j + 0 ];
            arr2[i * BLOCK_LEN + j*32 + 1 ] = arr[j + 1 ];
            arr2[i * BLOCK_LEN + j*64 + 2 ] = arr[j + 2 ];
            arr2[i * BLOCK_LEN + j*128 + 3] = arr[j + 3 ];
            arr2[i * BLOCK_LEN + j*32 + 4 ] = arr[j + 4 ];
            ***
            arr2[i * BLOCK_LEN + j*32 + 15] = arr[j + 15 ];
        }
    }


结果:



Write-back                                           :   7s 601ms
Write-back no write allocate:               :   7s 599ms


可以看出,“写回”“写分配”模式稍快一些。但是最主要的是它更快。



我没有得到更好的演示,但是我确信在实际情况下,差异更加明显。读者可以提出自己的选择!



实际例子



让我们从综合示例过渡到实际示例。



ping



最简单的方法之一是ping。它很容易启动,并且时间可以直接在主机上查看。Embox是使用-O2优化构建的。让我立即为您提供结果:



    :  ~0.246 c
Write-back                        :  ~0.140 c


Opencv的



我们要尝试缓存子系统的另一个实际问题示例是STM32F7上的OpenCV在那篇文章中,表明了很有可能启动,但是性能却很低。为了演示,我们将使用一个基于Canny过滤器提取边框的标准示例。让我们测量使用和不使用缓存(D缓存和I缓存)的运行时间。



   gettimeofday(&tv_start, NULL);

    cedge.create(image.size(), image.type());
    cvtColor(image, gray, COLOR_BGR2GRAY);

    blur(gray, edge, Size(3,3));
    Canny(edge, edge, edgeThresh, edgeThresh*3, 3);
    cedge = Scalar::all(0);

    image.copyTo(cedge, edge);

    gettimeofday(&tv_cur, NULL);
    timersub(&tv_cur, &tv_start, &tv_cur);


没有缓存:



> edges fruits.png 20 
Processing time 0s 926ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20


带缓存:



> edges fruits.png 20 
Processing time 0s 134ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20


也就是说,926ms和134ms的加速度几乎是7倍。



实际上,经常有人问我们有关STM32上的OpenCV的问题,特别是性能如何。事实证明,FPS当然不高,但是每秒获得5帧是相当现实的。



没有缓存或缓存的内存,但是缓存失效了吗?



在实际设备中,DMA被广泛使用,自然,与此相关的还有很多困难,因为即使对于“直写”模式,也需要同步内存。人们自然希望简单地分配一块不会被缓存的内存,并在使用DMA时使用它。有点分心。在Linux上,这是通过dma_coherent_alloc()函数实现的。是的,这是一种非常有效的方法,例如,在OS中处理网络数据包时,用户数据在到达驱动程序之前经历了很大的处理阶段,并且在驱动程序中,将带有所有标头的准备好的数据复制到使用非缓存内存的缓冲区中。



在某些情况下,使用DMA驱动程序时最好使用clean / invalidate吗?就在这里。例如,视频存储提示了我们仔细研究cache()的工作原理。在双缓冲模式下,系统具有两个缓冲区,依次将其放入该缓冲区,然后将其提供给视频控制器。如果使此类内存不可缓存,则性能将下降。因此,最好在将缓冲区发送到视频控制器之前先进行清理。



结论



我们对ARMv7m中的不同类型的缓存有了一些了解:回写,直写以及“写分配”和“无写分配”设置。我们建立了综合测试,试图找出一种模式何时优于另一种模式,并考虑了ping和OpenCV的实际示例。在Embox,我们仍在研究此主题,因此仍在制定相应的子系统。但是,使用缓存的优势绝对显而易见。



通过从打开的存储库构建Embox可以查看和复制所有示例



PS:



如果您对系统编程和OSDev感兴趣,那么明天将举行OS Day会议今年它在线上,所以不要错过那些希望的人!Embox将于明天12:00执行



All Articles