ELAI S.r.l.

传感器已经完成:为什么AI仍在读取上一次的样本?

通过实际执行的模型区分旧数据读取、新样本覆盖与相邻字节丢失:为什么CPU和DMA必须协调整条缓存行。

传感器已经完成:为什么AI仍在读取上一次的样本?

传感器采集了一个新的数据窗口。数据传入内存的过程结束,软件开始执行推理,可是结果似乎对应上一次的窗口。在怀疑模型权重或归一化之前,我们需要先问一个更基本的问题:处理器与负责传输数据的设备,看到的真的是同一份副本吗?本文将解释同一地址的两个表示为什么会不一致,以及为什么解决问题需要明确约定传输期间由谁拥有这块内存。

摘要。我们研究一个确定性的教学模型:一条32字节的写回式缓存行。CPU访问缓存副本,DMA直接访问RAM,两条路径之间没有自动保持一致的机制。实际执行的七组操作区分了读取旧数据、覆盖新样本以及丢失相邻字节修改三类问题。核心结论是必须考虑整条缓存行,而不只是缓冲区中的几个字节。这个模型解释一种机制:它没有测量开发板,没有模拟Cortex-M7的全部行为,也不提供可直接使用的驱动程序。

到达同一地址的两条路径

CPU是执行程序的处理器,RAM是工作内存。缓存把小块内存的副本保存在处理器附近,减少反复访问RAM的需要。DMA即直接内存访问,可以在无需CPU逐个复制元素的情况下传输数据。这对传感器样本和图像很有用,但也引入了另一个可以访问内存的参与者。

在本模型中,如果相应块已经存在,CPU读取就使用缓存;否则从RAM加载。CPU写入会修改本地副本,并将其标记为脏。脏的意思是与下层内存不一致,而不是数据已经损坏。采用写回策略时,修改可以在稍后才写入RAM。DMA则直接读写RAM。这里并不是说所有DMA系统都这样工作;我们明确假设这两条路径之间没有硬件一致性机制。

一个有用的类比是中央登记簿旁边的笔记本。更新登记簿,并不会自动改正已经抄到笔记本上的记录。但类比到此为止:硬件传输和维护有明确的操作单位,不能随意选择丢弃其中某一个数据。因此,下面直接建立一个显式模型。

样本7、样本99以及一个邻居

设想只有一条缓存行,包含32个单字节位置,编号为0到31。初始时,RAM的位置8保存7,位置0保存1,其余位置为0。位置8代表一个样本,位置0代表一个独立变量,程序希望将它改为9。这些都是合成数值,不是传感器读数,也不是AI模型输出。

缓存初始为空。当处理器读取位置8时,模型将整条32字节数据从RAM复制到缓存。从此,即使另一个参与者修改了RAM,处理器仍可能从本地副本读出7。这样就能清楚看到粒度的作用:样本和相邻变量属于同一个维护单位。

32字节缓存行与ST应用说明AN4839所描述的Cortex-M7背景相符,但并不是所有缓存的通用属性。示例使用偏移量,而不是真实微控制器地址。它也不假定某个DMA控制器能够访问某个特定区域;这种可达性必须在所选器件手册中确认。

第一个错误:交出去的修改还留在缓存中

先考虑从CPU到DMA的方向。处理器想发送99,于是将99写入位置8。在模型中,缓存现在保存99,RAM却仍为7。如果DMA立刻开始,它读取的就是7。对程序而言,写入已经发生,但修改还没有沿DMA使用的路径变得可见。

称为clean的操作会把脏缓存行写回RAM。保持初始数据和传输相同,仅在DMA读取之前加入clean,结果就从7变成99。这两个案例隔离了原因:唯一新增的是一次操作。它们没有证明性能提升,因为程序并不表示时钟周期、总线或频率。

必须区分clean和invalidate。模型中的invalidate会丢弃本地副本。如果这个副本是某项有用修改唯一的存放位置,而RAM还没有那项修改,丢弃就会造成数据丢失。正确的问题不是哪个函数的名字听起来更令人放心,而是此刻需要保留的信息究竟在哪里。

第二个错误:接收已完成,但CPU看不见

现在反转方向。CPU先读取7,因此保留了一条有效缓存行。DMA将99写入RAM的位置8,然后结束。处理器仍从自己的副本读取,得到7。传输在RAM中已经成功;问题在于下一次读取采用哪份副本。

在本模型的串行顺序中,传输完成后执行invalidate会移除旧副本。随后读取会重新加载该行,从而看到99。所以存在两种不同需求:把CPU的修改交给DMA,以及防止CPU复用早于DMA修改的副本。混淆方向,就会选择错误的处理办法。

到这里,我们一直假设缓存行内没有其他值得保留的修改。这是一个很强的假设;只把问题描述成丢掉旧样本时,往往会将它隐藏起来。相邻字节可以帮助我们检验这个假设。

为什么过晚清理可能使结果更糟

CPU把位置0的相邻变量从1改为9。因为在模型中这次写入会加载并弄脏整条缓存行,缓存同时还保留着旧样本7。接着,DMA将99写入RAM的位置8。如果此时执行clean,整条本地缓存行都会写回内存:相邻变量9保存下来了,但样本却从99重新变为7。

这不只是读取了旧副本,而是破坏了RAM中的新副本。即使随后执行invalidate,CPU重新加载后仍会读到7,因为99已经被覆盖。代码中这组操作名为rx_late_clean。结果说明不能把clean和invalidate看成可以互换、只需加在传输末尾的操作。

试试相反的补救方式,又会暴露另一个错误。在同样情况下,DMA结束后仅执行invalidate。CPU这次正确看到了99,可相邻变量却退回1:修改值9只存在于刚被丢弃的副本中。如果测试仅检查样本,就会报告成功,同时漏掉缓冲区之外的数据损坏。

保护整条缓存行的约定

在本模型的正确案例中,CPU先修改相邻变量,在把缓存行交给DMA之前执行clean,并且在传输期间不再访问它。这样,当DMA写入新样本99时,RAM中已经有相邻变量9。传输完成后,invalidate移除任何已经不再有效的CPU副本。最终读取分别得到99和9,两项信息都保存下来。

关键条件是:缓存行属于DMA期间,CPU不能进行可能再次把它弄脏的访问。我们用第七个案例进行检验:准备完成后,CPU在分配给DMA的阶段修改相邻变量;随后的clean再次造成覆盖。先前的准备不能让后来的所有权违规变得无害。

这种约定必须匹配缓存的真实粒度。给DMA分配八个字节,并不会将它们与同一行中的其余二十四个字节隔离。实验没有规定一种普适的固件调用顺序,而是揭示具体协议必须保存哪些信息。真实平台还需要按照文档满足额外的顺序、完成条件和维护规则。

实际影响的是哪个区间?

假定缓冲区从偏移a开始,长度为n,且n大于0。若每条缓存行有L字节,我们需要找出与缓冲区区间相交的所有缓存行。其数学边界是:

起点 = L × floor(a/L)
终点 = L × ceil((a+n)/L)
范围长度 = 终点 − 起点

Floor表示向下取整,ceil表示向上取整。各量都是地址或字节数,L是缓存行大小。取a=8、n=8、L=32,缓冲区占据[8,16),维护操作却覆盖[0,32)。记号[起点,终点)包含起点,不包含终点。位置0不在缓冲区中,却在同一条缓存行内。

因此,正确取整并没有解决共享问题,而是明确我们必须控制的区域。即使缓冲区起点已对齐,只要长度不是整条行数,最后一行仍可能被共享。在C代码中还要检查末尾地址溢出、零长度、API接受的类型以及内存边界。这个公式是数学关系,不是可以直接粘贴进驱动的函数。

可复现程序做了什么

experiment.py包含一个由32个值构成的RAM列表,以及最初不存在的缓存副本。CPU读取仅在副本不存在时填充它,CPU写入修改副本并设置dirty标记。Clean把脏行写回RAM;invalidate移除副本并清除标记。DMA直接修改或读取RAM列表,不查询CPU副本。

每个案例都从同一初始状态重新开始,不继承前一案例的修改。每次操作记录样本、相邻变量和dirty标记的数值快照。断言检查预期结果;如果文中展示的关系不成立,程序就停止。因为没有随机抽样,不需要随机种子。结果是确定性的,而不是多次运行产生的统计估计。

要重复分析,请在材料目录中用Python运行experiment.py --out results.json。JSON包含Python版本、假设、单位、覆盖区间以及完整操作记录。计算不需要外部软件包。检查中间值比只看最终结果更有意义:它能够区分CPU修改丢失与DMA修改丢失。

替代方法与尚待解决的问题

一种设计方案是从物理布局上分开缓冲区与其他变量所占的缓存行,控制位置和大小。另一种是在器件允许的情况下使用不可缓存区域。第三种是在完成一致性交接之后,将数据复制到工作缓冲区。这些是需要比较的策略,而不是普适赢家:内存布局、DMA可达性和传输成本都取决于系统。

本模型无法为这些方案给出毫秒或焦耳数。它没有描述自动替换、推测访问、中断、多核并发或屏障。没有建模这些行为,并不意味着固件可以忽略它们;恰恰因此,不能将实验视作产品正确性的证明。进行物理测试之前,需要明确器件及修订版、内存区域、MPU属性、DMA控制器、运行环境与CMSIS头文件,以及测试协议。

回答开头的问题

传感器可能已经正确交付新样本,而AI仍读取旧样本,因为CPU与DMA不一定访问同一份副本。区分clean和invalidate,就能理解两个方向上如何让修改可见。相邻字节进一步揭示关键点:当操作作用于整条缓存行时,仅保持样本本身一致还不够。

真正的启示是把传输设计为内存的临时交接,明确所有权和粒度。在本模型中,这样能够同时保存99和9;在真实开发板上,必须按照平台文档落实并验证这一思路。我们没有进行硬件测量,也没有证明EL-AI拥有相关性能或功能。

参考资料与材料状态

STMicroelectronics,AN4839,修订版2,2018年3月,第2、2.1、3.2和4节;在10月11日准备工作中阅读:https://www.st.com/resource/en/application_note/an4839-level-1-cache-on-stm32f7-series-and-stm32h7-series-stmicroelectronics.pdf
Arm CMSIS-Core 6,D-Cache Functions,2026年10月15日重新阅读;按地址维护操作的参数:https://arm-software.github.io/CMSIS_6/latest/Core/group__Dcache__functions__m7.html
文本由AI辅助撰写,不声称经过人工审核。使用自行构造的合成示例;不属于基准复现或经过同行评审的原创研究。

七个案例的合成结果

操作序列观察到的样本最终相邻变量
tx_missing_clean71
tx_clean991
rx_stale71
rx_late_clean79
rx_only_post_invalidate991
rx_clean_before_invalidate_after999
rx_neighbor_written_during_transfer79
图:左侧过晚的clean将RAM从99变回7;右侧在交接期间CPU不访问,因此保留99。横轴是操作序号,不是时间。左侧依次为修改邻居、DMA、clean、invalidate、两次读取;右侧为修改邻居、clean、DMA、invalidate、两次读取。CPU副本曲线的空缺表示缓存不存在,而不是数值0。没有硬件测量。
图:左侧过晚的clean将RAM从99变回7;右侧在交接期间CPU不访问,因此保留99。横轴是操作序号,不是时间。左侧依次为修改邻居、DMA、clean、invalidate、两次读取;右侧为修改邻居、clean、DMA、invalidate、两次读取。CPU副本曲线的空缺表示缓存不存在,而不是数值0。没有硬件测量。

已执行模型的完整代码

"""Deterministic teaching model, not a Cortex-M7 emulator or hardware benchmark.
One write-back cache line with 32 byte entries; CPU/DMA operations are serialized.
No timing, barriers, speculative fills, cache sets, bus topology or instruction model.
"""
import json,platform,argparse
from pathlib import Path
class Model:
 def __init__(self):
  self.ram=[0]*32;self.ram[8]=7;self.ram[0]=1
  self.cache=None;self.dirty=False;self.trace=[]
 def snap(self,event):
  self.trace.append(dict(event=event,ram_buffer=self.ram[8],ram_neighbor=self.ram[0],cache_buffer=None if self.cache is None else self.cache[8],cache_neighbor=None if self.cache is None else self.cache[0],dirty=self.dirty))
 def read(self,i):
  if self.cache is None:self.cache=self.ram.copy()
  value=self.cache[i];self.snap('cpu_read_'+str(i));return value
 def write(self,i,v):
  if self.cache is None:self.cache=self.ram.copy()
  self.cache[i]=v;self.dirty=True;self.snap('cpu_write_'+str(i))
 def dma_write(self,i,v):self.ram[i]=v;self.snap('dma_write_'+str(i))
 def clean(self):
  if self.dirty:self.ram=self.cache.copy();self.dirty=False
  self.snap('clean')
 def invalidate(self):self.cache=None;self.dirty=False;self.snap('invalidate')

def scenario(name):
 m=Model()
 if name.startswith('tx'):
  m.write(8,99)
  if name=='tx_clean':m.clean()
  dma=m.ram[8];m.snap('dma_read_8');out={'dma_value':dma,'expected':99}
 elif name=='rx_stale':
  m.read(8);m.dma_write(8,99);out={'cpu_value':m.read(8),'expected':99}
 elif name=='rx_late_clean':
  m.write(0,9);m.dma_write(8,99);m.clean();m.invalidate();out={'cpu_value':m.read(8),'neighbor':m.read(0),'expected':99}
 elif name=='rx_only_post_invalidate':
  m.write(0,9);m.dma_write(8,99);m.invalidate();out={'cpu_value':m.read(8),'neighbor':m.read(0),'expected_neighbor':9}
 elif name=='rx_clean_before_invalidate_after':
  m.write(0,9);m.clean();m.dma_write(8,99);m.invalidate();out={'cpu_value':m.read(8),'neighbor':m.read(0),'expected':99,'expected_neighbor':9}
 elif name=='rx_neighbor_written_during_transfer':
  m.read(8);m.clean();m.write(0,9);m.dma_write(8,99);m.clean();m.invalidate();out={'cpu_value':m.read(8),'neighbor':m.read(0),'expected':99}
 else:raise ValueError(name)
 return dict(name=name,outcome=out,trace=m.trace)

def main(out):
 names=['tx_missing_clean','tx_clean','rx_stale','rx_late_clean','rx_only_post_invalidate','rx_clean_before_invalidate_after','rx_neighbor_written_during_transfer']
 rows={n:scenario(n) for n in names}
 assert rows['tx_missing_clean']['outcome']['dma_value']==7
 assert rows['tx_clean']['outcome']['dma_value']==99
 assert rows['rx_stale']['outcome']['cpu_value']==7
 assert rows['rx_late_clean']['outcome']['cpu_value']==7
 assert rows['rx_only_post_invalidate']['outcome']=={'cpu_value':99,'neighbor':1,'expected_neighbor':9}
 assert rows['rx_clean_before_invalidate_after']['outcome']=={'cpu_value':99,'neighbor':9,'expected':99,'expected_neighbor':9}
 assert rows['rx_neighbor_written_during_transfer']['outcome']['cpu_value']==7
 # A range [8,16) touches cache line [0,32), including unrelated byte0.
 a=8;n=8;line=32;lo=(a//line)*line;hi=((a+n+line-1)//line)*line
 assert (lo,hi)==(0,32)
 r={'python':platform.python_version(),'seed':None,'units':{'address':'byte offsets','values':'synthetic byte values'},'hardware_executed':False,'line_size_bytes':32,'assumptions':'Serialized operations on one initially empty write-back cache line; DMA accesses RAM directly; full-line clean/invalidate; no hardware timing or cache-policy reproduction','range':{'buffer_start':a,'buffer_size':n,'maintenance_start':lo,'maintenance_size':hi-lo},'scenarios':rows}
 Path(out).write_text(json.dumps(r,indent=2),encoding='utf8')
 print(json.dumps({n:v['outcome'] for n,v in rows.items()},indent=2))
if __name__=='__main__':
 p=argparse.ArgumentParser();p.add_argument('--out',default='results.json');main(p.parse_args().out)

可复现性与复杂度

在程序中复制包含L个元素的缓存行需要O(L)次Python操作和O(L)内存。每个案例包含固定数量的访问。这些是教学模型的成本,不是真实缓存周期。运行experiment.py --out results.json;plot.py需要Matplotlib,中文绘图还需要Windows微软雅黑字体。压缩包不分发字体。

下载代码与结果 · JSON结果

AI生成的示意封面,不是真实EL-AI设备或实验室照片。