STM32H723带D-Cache配置DMA缓存一致性解决方案与避坑指南
带D-Cache的STM32H723上配置DMA说实话这个坑我替大家踩得差不多了。自己第一次在H723上把D-Cache打开然后高高兴兴去调UART DMA结果收到的数据一会儿对一会儿错ADC采出来的值还经常是整个缓冲区的旧数据排查了整整一个晚上才意识到问题出在缓存一致性上——不是代码逻辑写错了也不是DMA配置错了而是CPU的高速缓存和DMA搬运的数据之间“对不上账”。这篇文章想解决的就是这个问题。我会从D-Cache的行为机制讲起然后给出三种可落地的配置方案再针对STM32H723上最常见的UART空闲中断DMA、ADC多通道扫描DMA、SPI收发DMA这三个场景给出完整的配置思路和关键代码片段最后把我在实际调试中遇到过的坑和排查顺序整理出来。适合正在用STM32H7系列、刚把D-Cache打开就发现DMA数据错乱、或者准备把DMA相关外设从F1/F4平台移植到H7平台的开发者。1. 为什么STM32H723开了D-Cache后DMA会“串数据”1.1 D-Cache写回机制与数据滞留Cortex-M7内置的D-Cache默认工作在write-back模式含义是CPU执行写操作时数据并不立即写入物理RAM而是先写入Cache Line等到缓存行被替换或者收到Clean指令时才真正回写到RAM。这套机制在纯CPU场景下效率极高因为同一个地址短时间内被反复读写时CPU根本不用去访问慢速RAM。但一旦引入DMA问题就出现了。DMA控制器和CPU看到的“内存”不是同一个视角CPU通过D-Cache读写数据看到的是Cache里的镜像。DMA直接访问物理RAM看到的是主存里真实的数据。举个例子你把一块ADC缓冲区放在RAM中CPU先往里面存过一些旧值这部分数据还留在Cache里没有回写。然后你启动DMA让外设往这块RAM里搬运新数据DMA确实把新数据写进了物理RAM但CPU再去读的时候Cache命中读到的仍然是之前那个旧值。反过来也一样CPU往发送缓冲区里写了数据数据还在Cache里没回写你就启动DMA去搬运这块缓冲区DMA从物理RAM里读到的全是过期数据串口发出去的自然就是乱码。这就是典型的缓存一致性问题英文社区叫cache coherency。在STM32H723这种Cortex-M7平台上它不是一个“可能发生的边缘情况”而是只要你启用D-Cache走DMA就必然会遇到的常规场景。1.2 STM32H7内存架构与哪些RAM需要小心STM32H7的内存架构和F1/F4有本质区别。F4的SRAM都挂在同一个总线矩阵上CPU访问SRAM基本不会被缓存所以DMA和CPU看到的数据基本一致。H7不一样Cortex-M7核心挂了两套总线AHB和AXI同时还有TCM。和DMA配置最相关的几块RAM区域DTCM和ITCM挂在CPU私有总线上CPU访问速度极快但DMA根本摸不到这块区域。AXI SRAM地址0x24000000附近CPU经AXI总线访问D-Cache启用后会被缓存DMA可以访问。D2域的SRAM1/SRAM2/SRAM3地址0x30000000附近同样会被D-Cache缓存DMA也能访问。所以结论很直接在H723上凡是DMA要读写的RAM缓冲区只要这些区域被CPU访问过并且D-Cache处于启用状态就都必须考虑缓存一致性问题。很多人一开始把缓冲区放在DTCM里抱怨“DMA根本不工作”那是另外一个坑——不是配置错了是DMA根本访问不到TCM区域。1.3 典型故障现象速查我把自己和身边同事遇到过的现象汇总了一下方便你快速对照定位故障现象传输方向根本原因串口发送出去的是乱码但调试器看发送缓冲区内容是对的CPU → RAM → DMA → UARTCPU写的数据还在Cache里DMA从RAM读到旧值串口接收到的数据偶尔少字节或错位但DMA计数正确UART → DMA → RAM → CPUDMA写完RAM后CPU读Cache命中旧值尤其容易出现在循环接收场景ADC采样值全是0或者上一个周期的旧数据ADC → DMA → RAM → CPUDMA已更新RAMCPU还在读Cache里的旧缓冲SPI从机收到的数据错位主机侧看TX缓冲内容正确CPU → RAM → DMA → SPITX缓冲区未回写DMA搬错数据第一次DMA传输正常连续传输第二次、第三次开始错乱任意方向第一次运行时Cache未命中之后Cache被污染缓存一致性问题逐渐显现这类问题最坑的地方在于它不是每次都复现而是“时好时坏”和Cache命中的时机强相关。如果开了优化等级编译器把某些变量优先放在寄存器或Cache里复现频率还会变化。2. 正确路径缓存维护与非缓存内存的三种方案2.1 方案A用MPU把DMA缓冲区标记为Non-Cacheable这是最省心的方案思路是既然DMA和CPU的缓存会对不上账那我干脆不让CPU缓存这块区域。通过MPU配置把DMA缓冲区所在的RAM区域设置为normal memory、non-cacheable属性之后CPU读写这块区域每次都直接访问物理RAM和DMA看到的自然就是同一份数据了。STM32H723上配置代码如下void MPU_Config_DMA_Region(void) { MPU_Region_InitTypeDef MPU_InitStruct {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress 0x24000000; MPU_InitStruct.Size MPU_REGION_SIZE_128KB; MPU_InitStruct.AccessPermission MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable MPU_ACCESS_BUFFERABLE; MPU_InitStruct.IsCacheable MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsShareable MPU_ACCESS_NOT_SHAREABLE; MPU_InitStruct.Number MPU_REGION_NUMBER0; MPU_InitStruct.TypeExtField MPU_TEX_LEVEL1; MPU_InitStruct.SubRegionDisable 0; MPU_InitStruct.DisableExec MPU_INSTRUCTION_ACCESS_DISABLE; HAL_MPU_ConfigRegion(MPU_InitStruct); HAL_MPU_Enable(MPU_CONTROL_MMU_FOR_PRIVILEGED); }这段代码的关键点有两个一是MPU_ACCESS_NOT_CACHEABLE确保DMA缓冲区不会被缓存二是MPU_TEX_LEVEL1配合non-cacheable属性这是Cortex-M7 memory attribute的正确组合。配置完成后如果缓冲区定义在0x24000000区域CPU对这些地址的访问就不会再经过D-Cache了。注意这块区域要和你实际定义缓冲区的位置保持一致。如果你把缓冲区放在0x30000000的D2 SRAM里就需要再配置另一个MPU region对应那个地址。不要试图用一个MPU region覆盖整个4GB空间不现实也不安全。这个方案的优点是调试简单不用在每个DMA传输周期里手动维护缓存状态特别适合DMA缓冲区数量多、数据持续刷新的场景。缺点是CPU访问non-cacheable区域时每次都要走总线性能比cacheable区域低一些。但对DMA缓冲区来说这个代价通常可以接受因为缓冲区的主要写入方是DMA而不是CPU。2.2 方案B在DMA启动前做Clean在DMA完成中断里做Invalidate如果你不想给DMA缓冲区单独划分MPU区域那就要手动维护缓存一致性。这里有两个核心操作Clean就是将Cache中的数据回写到物理RAM。CPU往发送缓冲区写完数据后启动DMA搬运之前必须做一次Clean操作确保DMA能读到最新数据。Invalidate则是把Cache中对应的行标记为无效下次CPU访问时强制从物理RAM重新加载。DMA把接收缓冲区写好后CPU读取之前必须做Invalidate防止CPU继续命中Cache里的旧数据。相关代码位于CMSIS提供的core_cm7.h中/* CPU写完发送缓冲区后启动DMA前执行 */ SCB_CleanDCache_by_Addr((uint32_t *)txBuf, TX_BUF_SIZE); __DSB(); /* DMA接收完成中断里处理数据之前执行 */ SCB_InvalidateDCache_by_Addr((uint32_t *)rxBuf, RX_BUF_SIZE); __DSB();这里必须强调一个注意事项SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr都要求起始地址按32字节对齐长度也必须是32字节的整数倍。Cortex-M7的D-Cache缓存行长度是32字节如果地址和长度不对齐函数内部实现会跳过无法覆盖的行可能有一两个缓存行没被处理程序就变成“偶尔出错、偶尔正常”的诡异状态。缓冲区定义建议写成__attribute__((aligned(32))) uint8_t txBuf[256]; __attribute__((aligned(32))) uint8_t rxBuf[256];__attribute__((aligned(32)))确保数组首地址32字节对齐。长度方面如果你实际传输长度不是32的整数倍要么把缓冲区长度补到32倍数要么宁可多clean/invalidate一点也不能少。比如每次接收150字节那就按192字节对齐来做invalidate多出来的区域无所谓。2.3 方案C同时使用Clean和Invalidate覆盖双向DMA很多实际场景是同一块缓冲区既有DMA写入又有CPU读取或者同一块缓冲区被CPU写过之后还要被DMA读取然后DMA搬完新数据CPU又要读。典型的就是SPI半双工、以及某些双缓冲以太网结构。这种情况下正确顺序是CPU写完缓冲区后执行Clean再启动DMA写/读操作。DMA传输完成后CPU读缓冲区前执行Invalidate。在整个过程中要保证清操作和失效操作都发生在正确的时间窗口。我习惯封装成两个小工具函数static inline void dma_buf_prepare_for_tx(uint32_t addr, uint32_t len) { SCB_CleanDCache_by_Addr((uint32_t *)addr, len); __DSB(); } static inline void dma_buf_prepare_for_rx(uint32_t addr, uint32_t len) { SCB_InvalidateDCache_by_Addr((uint32_t *)addr, len); __DSB(); }这里在Clean和Invalidate之后都加了__DSB()数据同步屏障确保缓存维护指令真正执行完毕后再继续后续操作。有些开发者在调试时发现明明执行了CleanDMA搬运过来的数据还是不对仔细查过之后发现是缓存操作还在流水线上没执行完DMA就开始搬运了。加上__DSB()能规避这个风险。还有一个经常被忽视的细节如果是DMA往缓冲区写数据而CPU在DMA传输前对这块缓冲区执行过写操作那在启动DMA前也建议做一次Clean。原因很简单如果缓冲区对应的Cache行还是脏的dirtyDMA往物理RAM写入新数据后稍后CPU如果再次写这个缓冲区Cache行可能被回写把你期望保留的DMA数据覆盖掉。这种场景在环形缓冲区中尤其容易发生。2.4 怎么选三种方案的适用场景对照方案改动量性能影响适用场景MPU Non-Cacheable一次配置后续零维护CPU读写缓冲区稍慢DMA性能不受影响高频率DMA传输、缓冲区多、接收数据要实时查询手动Clean/Invalidate每次传输前/后各加几行代码缓存操作有开销但按地址操作比全局操作小很多发送/接收频率不高逻辑清晰单缓冲区固定方向CleanInvalidate组合需要严格管理时序开销最高但是覆盖所有双向场景双缓冲、环形缓冲、SPI半双工、以太网DMA共享缓冲以我自己的习惯来说如果板子上RAM够用我会把需要DMA操作的缓冲区单独放到一个MPU non-cacheable区域省心。如果缓冲区是零散分配的没有规律那就老老实实做手动Clean和Invalidate。3. STM32H723上三个高频场景的完整实操3.1 前提D-Cache和MPU的初始化顺序在进入具体外设配置前先确认系统初始化顺序。CubeMX生成的main函数里如果启用了I-Cache和D-Cache会调用SCB_EnableICache(); SCB_EnableDCache();这两行代码必须在MPU初始化之后执行并且MPU要在这之前已经启用了non-cacheable区域配置。否则可能出现MPU还没来得及生效D-Cache已经先跑起来了把缓冲区缓存了一遍。我通常的做法是先调用MPU_Config()函数再调用SCB_EnableICache()和SCB_EnableDCache()。如果使用CubeMX在System Core → MPU里可以先配置好region然后在main函数最开始调用HAL_MPU_ConfigRegion相关代码。另外提醒一句调试的时候不要为了省事直接注释掉SCB_EnableDCache()来验证问题。这样确实能很快验证“是不是D-Cache导致的”但正式发布的代码里必须保留D-Cache否则Cortex-M7跑大循环和浮点运算的性能会明显打折扣。定位问题的时候可以临时禁用来做对比但最终方案必须是“用正确的方式处理缓存一致性”。3.2 UART接收空闲中断DMA只需要改三个地方UART接收用空闲中断加DMA是H7上最常见的组合。CubeMX配置时UART的DMA接收请求选择UART_DMA_RXDMA模式选Circular还是Normal要看你的接收策略。先说Normal模式配合空闲中断的逻辑是调用HAL_UARTEx_ReceiveToIdle_DMA(huart1, rxBuf, RX_BUF_SIZE)启动接收。每收到一帧数据出现空闲线时HAL库回调HAL_UARTEx_RxEventCallback。在回调里处理数据后需要重新调用一次HAL_UARTEx_ReceiveToIdle_DMA启动下一轮接收。需要改的第一处就是缓冲区定义必须32字节对齐__attribute__((aligned(32))) uint8_t rxBuf[256]; __attribute__((aligned(32))) uint8_t txBuf[256];第二处是在启动DMA接收之前先做一个Invalidate把缓冲区对应的Cache行作废。因为如果不作废缓冲区里残留的旧数据可能在Cache里还留着一份“快照”DMA写入新数据后CPU去读时命中的反而是旧内容SCB_InvalidateDCache_by_Addr((uint32_t *)rxBuf, sizeof(rxBuf)); __DSB(); HAL_UARTEx_ReceiveToIdle_DMA(huart1, rxBuf, RX_BUF_SIZE);第三处是在接收完成回调里处理数据之前再做一次Invalidatevoid HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { SCB_InvalidateDCache_by_Addr((uint32_t *)rxBuf, Size); __DSB(); process_rx_data(rxBuf, Size); HAL_UARTEx_ReceiveToIdle_DMA(huart1, rxBuf, RX_BUF_SIZE); } }这里有个很容易踩的坑Size是实际接收到的字节数一般不是32的整数倍。我在前面强调过SCB_InvalidateDCache_by_Addr对长度有对齐要求所以这里不能直接把Size传进去。稳妥做法是传入整个缓冲区的长度或者计算出不小于Size的最小32字节倍数uint32_t aligned_len ((Size 31U) ~(uint32_t)31U); if (aligned_len RX_BUF_SIZE) { aligned_len RX_BUF_SIZE; } SCB_InvalidateDCache_by_Addr((uint32_t *)rxBuf, aligned_len);你也可以偷懒直接invalidate整个rxBuf反正缓冲区不大性能损失可忽略。但如果缓冲区长1KB甚至更大还是用对齐后的实际长度比较好。3.3 ADC多通道扫描DMAD-Cache下的经典翻车现场ADC多通道扫描配合DMA在F1/F4上是最常规的写法F4直接启用DMA循环模式就行。但到了H723开了D-Cache后如果不做缓存维护你会看到一个非常迷惑的现象第一次采集的数据正常之后每次采集的数据都是上一次的旧值或者某个通道的数值被其他通道“污染”。原因还是缓存一致性问题。DMA持续把ADC转换结果写入缓冲区但CPU的Cache里保留着之前的数据副本CPU读缓冲区时命中Cache读出来的全是旧的。关键配置方面CubeMX里ADC连续转换模式和DMA请求要配合好。很多人在CubeMX里看到ADC配置里的“Continuous Requests”选项不知道它到底是什么意思。这个选项对应的DMA请求标志是DMA_CONTINUOUS_REQUESTS含义是每个ADC转换周期结束后DMA自动发起一次新的数据传输请求而不需要等待软件再次触发。和它容易混淆的是ADC的连续转换模式ContinuousConvMode。简单说ADC连续转换模式ADC持续采样不断产生转换结果。DMA连续请求DMA跟着ADC的转换节奏自动搬运数据。对多通道扫描场景正确组合通常是使能ADC连续转换模式同时使能DMA连续请求这样DMA会一直把扫描结果按固定顺序搬运到缓冲区形成连续采样的数据流。中断回调里的处理如下#define ADC_BUF_LEN (32 * 8) /* 32字节对齐的缓冲区长度 */ __attribute__((aligned(32))) uint16_t adcBuf[ADC_BUF_LEN]; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { SCB_InvalidateDCache_by_Addr((uint32_t *)adcBuf, sizeof(adcBuf)); __DSB(); process_adc_data(adcBuf); } } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { uint32_t halfLen sizeof(adcBuf) / 2U; SCB_InvalidateDCache_by_Addr((uint32_t *)adcBuf, halfLen); __DSB(); process_adc_half_data(adcBuf); } }需要注意HAL_ADC_ConvCpltCallback和HAL_ADC_ConvHalfCpltCallback在DMA循环模式下都会持续触发。如果你的应用只关心完整缓冲区那处理ConvCpltCallback即可如果数据处理量大需要流水线作业那就半缓冲和全缓冲一起处理。不管哪种方式访问数据前必须invalidate对应区域。还有个小细节ADC DMA缓冲区类型建议声明为uint16_t但缓存行对齐按32字节。如果一次扫描10个通道缓冲区长度至少是20字节按32字节对齐后是32字节那就定义uint16_t adcBuf[16]够放一轮扫描加上对齐空间。不要傻乎乎地定义一个刚好20字节的数组后面invalidate长度凑不满32字节又是折腾半天。3.4 SPI收发DMA发送与接收的对称操作SPI DMA和UART DMA本质一样但因为是同步协议发送和接收往往同时进行所以更容易踩缓存一致性的坑。先明确方向SPI从RAM发送数据CPU把要发的内容写入TX缓冲区后启动DMA之前必须Clean TX缓冲区。SPI接收数据到RAMDMA搬完数据后CPU读取RX缓冲区之前必须Invalidate RX缓冲区。如果是全双工使用HAL_SPI_TransmitReceive_DMA同时收发那么TX方向先做CleanRX方向在完成中断里做Invalidate__attribute__((aligned(32))) uint8_t spiTxBuf[128]; __attribute__((aligned(32))) uint8_t spiRxBuf[128]; /* 启动SPI DMA传输 */ SCB_CleanDCache_by_Addr((uint32_t *)spiTxBuf, sizeof(spiTxBuf)); __DSB(); SCB_InvalidateDCache_by_Addr((uint32_t *)spiRxBuf, sizeof(spiRxBuf)); __DSB(); HAL_SPI_TransmitReceive_DMA(hspi1, spiTxBuf, spiRxBuf, 128);完成回调里void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { SCB_InvalidateDCache_by_Addr((uint32_t *)spiRxBuf, sizeof(spiRxBuf)); __DSB(); process_spi_data(spiRxBuf); } }这里要注意的是启动传输前对RX缓冲区做的Invalidate很关键。如果不做RX缓冲区里如果正好有从上次传输残留下来的Cache行DMA写入物理RAM后CPU访问时可能还是命中旧的Cache行。尤其循环传输时第二次收到的数据大概率是错的。如果你用MPU把SPI的RX/TX缓冲区都划成了non-cacheable区域那这一切手动操作都可以省略。这也是为什么我建议缓冲区数量多、DMA频率高的项目直接用MPU方案代码会干净很多。4. 实操中的深坑与排查技巧4.1 先定位再修改三步排查法遇到DMA数据错乱不要急着把Cache维护指令到处乱插先按下面的顺序排查第一步把D-Cache临时关闭跑一遍同样的功能。如果关闭D-Cache后一切正常说明问题确实来自缓存一致性如果关闭后仍然错乱那问题大概率在DMA配置本身比如DMA请求源配错、缓冲区地址越界、外设DMA请求使能没开。第二步如果确实是缓存一致性问题检查缓冲区地址和长度是否32字节对齐。这是最容易被忽略的点。很多人写了SCB_InvalidateDCache_by_Addr但地址不是32字节对齐导致缓存行处理不完整问题“偶尔重现”非常难排查。第三步检查缓存维护操作是否在正确的时间点执行。Clean必须在DMA启动前Invalidate必须在DMA完成后、CPU读数据前。时序错了维护得再勤快也没用。如果用调试器在回调里打断点尤其要注意断点处Cache状态已经和正常运行不同容易误导判断。建议只打日志不要打断点验证Cache一致性。4.2 为什么第一次正常后续全部错乱这个现象我见过太多次了。第一次DMA传输时缓冲区对应的Cache行尚未被加载CPU读缓冲区时必然从物理RAM读所以数据是对的。但读完之后Cache行被填充了。第二次DMA写入物理RAM时Cache里还留着第一次读入的副本CPU再访问就命中Cache读出来的是第一轮的数据不是第二轮DMA写入的。这就是为什么很多人误以为“程序启动没问题跑一会就坏了”。实际上程序逻辑一直是坏的只是第一次恰好绕过了缓存命中这一环。解决思路也很清晰每次DMA传输完成后在CPU访问缓冲区前做Invalidate打破Cache行“命中旧值”的循环。Circular模式下尤其要注意半缓冲回调漏掉任何一个处理分支都会出现半个缓冲区是旧数据的现象。4.3 不要在中断里频繁执行全缓存Clean或InvalidateSTM32H723的D-Cache有32KB全缓存操作SCB_CleanDCache和SCB_InvalidateDCache会遍历所有Cache行耗时和当前Cache命中情况有关在高速中断里每帧数据都执行一次性能损失非常大。我实测过在高频SPI DMA中断里执行全缓存Invalidate会明显拉高中断占用时间严重时甚至影响DMA传输实时性。正确做法是只操作涉及到的地址范围使用SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr。这两个函数虽然也要遍历这个范围内的所有Cache行但32字节一行256字节的缓冲区也就遍历8行比全缓存扫描快得多。还有一点SCB_InvalidateDCache_by_Addr虽然函数名带着Invalidate但CMSIS内部实现里如果发现某一行是脏的它会先把这一行写回内存再失效防止数据丢失。换句话说如果你对一块CPU刚写过但要丢弃的缓冲区做Invalidate多余的回写开销是免不了的。这也是为什么一定在DMA前把该Clean的区域Clean掉降低中断里的意外开销。4.4 数据屏障指令DSB/ISB的使用时机__DSB()在前面代码里出现了很多次我简单解释一下为什么需要它。SCB缓存操作指令提交给总线后如果紧接着执行“启动DMA”的命令理论上总线流水线可能还没把缓存维护指令执行完DMA就开始搬运数据了这时clean或invalidate可能没有生效。我在调试一个SPI DMA发数据的问题时就是靠__DSB()救回来的。当时在Clean TX缓冲区之后立即执行HAL_SPI_Transmit_DMA实测偶尔会发错前几个字节。加了一个__DSB()之后问题彻底消失。__ISB()一般用于指令流同步缓存维护场景很少用到但如果改动的是代码段或异常向量表就必须加。需要说明的是CubeMX生成的HAL函数内部在关键时序点已经内嵌了内存屏障所以如果你严格使用HAL库问题不大。一旦你直接用寄存器操作或者绕过HAL直接改写DMA的寄存器就必须自己在代码里显式维护这些屏障指令。5. 个人经验与最后的小建议这篇文章里涉及的所有代码和步骤我都已经在STM32H723板子上实际跑过。最开始我也是习惯用F4的思维觉得DMA配置好就能直接跑结果被D-Cache的数据滞留问题折腾了很久。后来养成了一个习惯所有DMA缓冲区统一加上__attribute__((aligned(32)))定义缓冲区时预留足够的对齐余量。对于项目选型我的个人建议是如果DMA缓冲区数量不多就是两块收发缓冲区那就用手动Clean/Invalidate逻辑直观不占用MPU资源。如果缓冲区很多比如以太网描述符、USB FIFO、多个UART的收发缓冲混在一起直接用MPU划分non-cacheable区域更省心否则你会在每个外设回调里写一堆维护代码还容易漏。最后再送一个小技巧调试缓存一致性问题时可以用GPIO翻转来测量DMA完成回调和数据实际生效之间的延迟。先用一张GPIO在中断入口拉高、数据处理好之后拉低配合逻辑分析仪看波形能直观判断缓存维护操作到底消耗了多少时间以及中断里是否出现了过多的回写开销。这个方法帮你省掉很多瞎猜的时间。