Un sensore acquisisce una nuova finestra di dati. Il trasferimento in memoria termina e il software avvia l’inferenza. Il risultato, però, sembra riferirsi alla finestra precedente. Prima di sospettare i pesi del modello o la normalizzazione, dobbiamo porci una domanda più elementare: il processore e il dispositivo che trasferisce i dati stanno vedendo davvero la stessa copia? Capiremo come due rappresentazioni dello stesso indirizzo possano divergere e perché il rimedio richieda un accordo su chi possiede la memoria durante il trasferimento.
Abstract. Studiamo un modello didattico deterministico di una linea di cache write-back da 32 byte. La CPU accede alla copia in cache e il DMA accede direttamente alla RAM, senza un meccanismo automatico che le mantenga coerenti. Sette sequenze eseguite distinguono lettura di dati vecchi, sovrascrittura di un campione nuovo e perdita di una modifica a un byte vicino. Il risultato centrale è che occorre ragionare sull’intera linea, non soltanto sui byte del buffer. Il modello spiega un meccanismo: non misura una scheda, non simula tutti i comportamenti di Cortex-M7 e non fornisce un driver pronto all’uso.
Due percorsi verso un indirizzo
La CPU è il processore che esegue il programma. La RAM è la memoria di lavoro. Una cache conserva vicino al processore copie di piccoli blocchi di memoria per evitare di raggiungere continuamente la RAM. Il DMA, accesso diretto alla memoria, è un dispositivo che può trasferire dati senza far eseguire alla CPU una copia per ogni elemento. È utile per campioni di sensori e immagini, ma introduce un secondo attore che può accedere alla memoria.
Nel modello considerato, una lettura della CPU usa la cache se trova già il blocco; altrimenti carica il blocco dalla RAM. Una scrittura della CPU modifica la copia locale e la marca come sporca. Sporco significa diverso dalla memoria sottostante, non corrotto. Con politica write-back, la modifica può essere riportata in RAM in un momento successivo. Il DMA, invece, legge o scrive direttamente la RAM. Non stiamo dicendo che ogni sistema con DMA funzioni così: qui assumiamo precisamente l’assenza di coerenza hardware fra questi percorsi.
Un’analogia utile è quella di un quaderno di appunti accanto a un registro centrale. Aggiornare il registro non corregge automaticamente un appunto già copiato. L’analogia si ferma qui: nell’hardware trasferimenti e manutenzione operano con unità precise, e non possiamo decidere liberamente quale singolo dato cancellare. Perciò passeremo subito a un modello esplicito.
Il campione 7, il campione 99 e un vicino
Immaginiamo una sola linea che contiene 32 posizioni da un byte, numerate da 0 a 31. All’inizio la RAM contiene 7 alla posizione 8 e 1 alla posizione 0; le altre posizioni valgono zero. La posizione 8 rappresenta un campione. Quella 0 rappresenta una variabile indipendente, che il programma vorrebbe aggiornare a 9. I valori sono sintetici: non sono letture di un sensore o risultati di un modello AI.
La cache inizia vuota. Quando il processore legge la posizione 8, il modello copia tutti i 32 byte dalla RAM nella cache. Da quel momento può leggere 7 dalla copia locale anche se un altro attore modifica la RAM. Questa scelta rende visibile il ruolo della granularità: il campione e il vicino appartengono alla medesima unità di manutenzione.
La linea da 32 byte è coerente con il contesto Cortex-M7 descritto nella nota ST AN4839; non è una proprietà universale delle cache. L’esempio usa offset e non indirizzi reali di un microcontrollore. Non assume che una particolare regione sia raggiungibile da un particolare controller DMA: questa raggiungibilità va verificata nel manuale del dispositivo scelto.
Primo errore: consegnare una modifica rimasta in cache
Consideriamo prima la direzione CPU verso DMA. Il processore vuole trasmettere 99 e lo scrive nella posizione 8. Nel nostro modello la cache contiene ora 99, mentre la RAM conserva 7. Se il DMA parte subito, legge 7. Dal punto di vista del programma la scrittura è avvenuta, ma non è stata ancora resa disponibile lungo il percorso usato dal DMA.
L’operazione chiamata clean riporta in RAM il contenuto di una linea sporca. Ripetendo la sequenza con clean prima della lettura DMA, il risultato passa da 7 a 99. Questa coppia di casi isola la causa: stessi valori iniziali e stesso trasferimento, con una sola operazione aggiunta. Non dimostra un guadagno di prestazioni, perché il programma non rappresenta cicli, bus o frequenze.
È importante non confondere clean con invalidate. Nel modello invalidate scarta la copia locale. Se scartassimo l’unica copia che contiene una modifica utile ancora assente dalla RAM, perderemmo quella modifica. La domanda corretta non è quale funzione abbia un nome più rassicurante, ma dove si trovi in quel momento il dato da conservare.
Secondo errore: una ricezione completata ma invisibile
Ora invertiamo la direzione. La CPU legge inizialmente 7, quindi conserva una linea valida. Il DMA scrive 99 in RAM alla posizione 8 e termina. Il processore legge ancora la propria copia e ottiene 7. Il trasferimento è riuscito nella RAM; il problema è la copia scelta per la lettura successiva.
Invalidate, dopo il completamento del trasferimento nel nostro ordine seriale, elimina la copia vecchia. La lettura seguente ricarica la linea e può vedere 99. Abbiamo quindi due esigenze diverse: rendere disponibile al DMA una modifica CPU e impedire alla CPU di riutilizzare una copia antecedente a una modifica DMA. Scambiare queste direzioni produce rimedi sbagliati.
Fin qui abbiamo supposto che non esistano altre modifiche da conservare nella linea. È un’ipotesi forte, che spesso viene nascosta quando si descrive il problema soltanto come un vecchio campione da eliminare. Il byte vicino ci permette di metterla alla prova.
Perché pulire troppo tardi può peggiorare il risultato
La CPU modifica il vicino alla posizione 0, portandolo da 1 a 9. Poiché la scrittura carica e sporca l’intera linea nel modello, la cache conserva anche il vecchio campione 7. Il DMA scrive poi 99 alla posizione 8 in RAM. Se a questo punto eseguiamo clean, l’intera linea locale viene riportata in memoria: il vicino 9 viene salvato, ma il campione torna da 99 a 7.
Non abbiamo soltanto letto una copia vecchia: abbiamo distrutto la nuova copia in RAM. Anche invalidando successivamente, la CPU rileggerà 7, perché il 99 è già stato sovrascritto. Nel codice questa sequenza è rx_late_clean. Il suo risultato mostra perché non si possano trattare clean e invalidate come operazioni intercambiabili da aggiungere alla fine del trasferimento.
Provare il rimedio opposto rivela un altro errore. Nella stessa situazione, eseguiamo soltanto invalidate dopo il DMA. Ora la CPU vede correttamente 99, ma il vicino torna a 1: la modifica 9 esisteva soltanto nella copia appena scartata. Un test che controlla esclusivamente il campione dichiarerebbe successo e perderebbe una corruzione esterna al buffer.
Un accordo che protegge tutta la linea
Nel caso corretto del nostro modello la CPU modifica il vicino, esegue clean prima di consegnare la linea al DMA e non la tocca durante il trasferimento. La RAM contiene quindi già 9 quando il DMA vi scrive il nuovo campione 99. Dopo il completamento, invalidate elimina l’eventuale copia CPU non più valida. Le letture finali restituiscono 99 e 9: entrambe le informazioni sono conservate.
La condizione cruciale è l’assenza di accessi CPU che possano sporcare nuovamente la linea mentre appartiene al DMA. Lo verifichiamo con un settimo caso: dopo la preparazione, la CPU modifica il vicino durante la fase assegnata al DMA; una successiva clean riproduce la sovrascrittura. La preparazione precedente non rende innocua una violazione successiva del possesso.
Questo è un accordo sulla granularità effettiva della cache. Assegnare al DMA otto byte non separa quei byte dagli altri ventiquattro presenti nella stessa linea. Il nostro esperimento non impone una specifica sequenza universale di chiamate firmware: mette in evidenza le informazioni che un protocollo concreto deve preservare. Una piattaforma reale richiede ulteriori regole di ordinamento, completamento e manutenzione documentate.
Quale intervallo viene realmente toccato?
Supponiamo che il buffer inizi all’offset a e abbia lunghezza n, con n maggiore di zero. Se ogni linea contiene L byte, vogliamo individuare tutte le linee che intersecano l’intervallo del buffer. Gli estremi matematici sono:
inizio = L × floor(a/L) fine = L × ceil((a+n)/L) estensione = fine − inizio
Floor arrotonda verso il basso e ceil verso l’alto. Tutte le grandezze sono indirizzi o conteggi in byte; L è la dimensione di linea. Per a=8, n=8 e L=32, il buffer occupa [8,16), mentre la manutenzione copre [0,32). La notazione [inizio,fine) include l’estremo iniziale ed esclude quello finale. La posizione 0 è fuori dal buffer ma dentro la linea.
Arrotondare correttamente non risolve quindi la condivisione: identifica il territorio su cui dobbiamo avere il controllo. Anche un buffer allineato all’inizio può condividere l’ultima linea se la sua lunghezza non riempie un numero intero di linee. In codice C occorre inoltre controllare overflow dell’indirizzo finale, dimensione zero, tipi accettati dall’API e limiti della memoria. La formula è una relazione matematica, non una funzione pronta da incollare in un driver.
Che cosa fa il programma riproducibile
experiment.py contiene una lista RAM di 32 valori e una copia cache inizialmente assente. Una lettura CPU riempie la copia soltanto quando manca. Una scrittura CPU modifica la copia e imposta dirty. Il metodo clean trasferisce la linea sporca in RAM; invalidate elimina la copia e il flag. Il DMA modifica o legge la lista RAM senza consultare la copia CPU.
Ogni scenario riparte dallo stesso stato iniziale, quindi non eredita modifiche del precedente. Ogni operazione salva una fotografia numerica di campione, vicino e flag dirty. Gli assert controllano gli esiti attesi e interrompono il programma se le relazioni illustrate non valgono. Non serve un seed casuale, perché non esistono estrazioni casuali. Il risultato è deterministico, non una stima statistica su molte esecuzioni.
Per ripetere l’analisi: eseguire Python con experiment.py --out results.json nella cartella del materiale. Il JSON contiene versione Python, ipotesi, unità, intervallo coperto e timeline complete. Nessun pacchetto esterno è necessario per il calcolo. Consultare i valori intermedi è più informativo del solo esito finale: permette di distinguere perdita della modifica CPU e perdita della modifica DMA.
Alternative e domande ancora aperte
Una soluzione progettuale è separare fisicamente le linee dei buffer da quelle delle altre variabili, controllando collocazione e dimensioni. Un’altra è usare una regione non cacheable dove il dispositivo lo consente. Un terzo approccio copia i dati in un buffer di lavoro dopo una consegna coerente. Sono strategie da confrontare, non vincitori universali: collocazione, accessibilità del DMA e costo dei trasferimenti dipendono dal sistema.
Il modello non permette di assegnare millisecondi o joule a queste alternative. Non descrive espulsioni spontanee, accessi speculativi, interrupt, concorrenza fra core o barriere. La loro assenza non autorizza a ignorarli nel firmware: impedisce proprio di trasformare questo esperimento in una prova di correttezza del prodotto. Prima di una prova fisica servono dispositivo e revisione, regione di memoria, attributi MPU, controller DMA, runtime e header CMSIS identificati, oltre al protocollo di test.
Risposta alla domanda iniziale
Il sensore può avere consegnato correttamente il nuovo campione e l’AI può comunque leggere quello vecchio perché CPU e DMA non consultano necessariamente la stessa copia. La distinzione fra clean e invalidate chiarisce come rendere visibili le modifiche nelle due direzioni. Il byte vicino aggiunge il punto decisivo: mantenere coerente il solo campione non basta se le operazioni coinvolgono tutta una linea.
L’insegnamento è progettare il trasferimento come una consegna temporanea della memoria, con proprietà e granularità esplicite. Nel nostro modello questo conserva sia 99 sia 9; su una scheda reale la stessa idea deve essere tradotta e verificata secondo il manuale della piattaforma. Non sono state effettuate misure hardware né dimostrate prestazioni o funzionalità EL-AI.
Riferimenti e stato del materiale
STMicroelectronics, AN4839, revisione 2, marzo 2018, sezioni 2, 2.1, 3.2 e 4, lette nella preparazione dell’11 ottobre: 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, documentazione riletta il 15 ottobre 2026; parametri delle operazioni by-address: https://arm-software.github.io/CMSIS_6/latest/Core/group__Dcache__functions__m7.html
Testo con assistenza AI, senza revisione umana attestata. Esempi sintetici propri; nessuna riproduzione di benchmark o ricerca originale peer-reviewed.
Risultati sintetici dei sette casi
| Sequenza | Campione osservato | Vicino finale |
|---|---|---|
| tx_missing_clean | 7 | 1 |
| tx_clean | 99 | 1 |
| rx_stale | 7 | 1 |
| rx_late_clean | 7 | 9 |
| rx_only_post_invalidate | 99 | 1 |
| rx_clean_before_invalidate_after | 99 | 9 |
| rx_neighbor_written_during_transfer | 7 | 9 |

Codice completo del modello eseguito
"""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)
Riproducibilità e complessità
Nel programma, copiare una linea di L elementi richiede O(L) operazioni Python e O(L) memoria. Ogni scenario contiene un numero fissato di accessi. Sono costi del modello didattico, non cicli di cache reale. Eseguire experiment.py --out results.json; per le figure plot.py richiede Matplotlib e il font Windows Microsoft YaHei per il cinese. Il font non è distribuito.
Scarica codice e risultati · Risultati JSON
Copertina illustrativa generata con AI, non foto di un dispositivo o laboratorio EL-AI.

