쓰기를 일단 캐시에만 하고 메인 메모리에는 나중에 반영하는 방식.
write-through와의 대비
write-through
CPU → 캐시 + 메모리 (동시에)
├ 항상 일치 → 단순, 안전
└ 매 쓰기가 메모리 속도에 묶인다 → 느리다
write-back
CPU → 캐시만 (dirty 표시)
→ 그 캐시 라인이 쫓겨날 때 메모리에 기록
├ 같은 곳에 여러 번 쓰면 마지막 한 번만 메모리로 → 빠르다
└ 메모리와 일시적으로 다르다 → 관리 필요
dirty 비트가 핵심이다. 캐시 라인마다 "이 라인은 수정됐다"는 표시를 두고, 교체될 때 dirty면 메모리에 쓴다(write-back).
얼마나 이득인가
for (int i = 0; i < 1000; i++) sum += arr[i]; // sum 에 1000번 쓰기
write-through : 메모리 쓰기 1000번
write-back : 캐시에서 1000번, 메모리 쓰기 1번 ← 쓰기 병합
루프 변수나 누적 변수처럼 같은 위치를 반복해 쓰는 경우 차이가 극적이다.
임베디드에서 무엇이 문제인가 — DMA와의 충돌
이것이 실무에서 가장 자주 겪는 캐시 문제다.
① CPU → DMA 방향 (송신)
uint8_t tx[64];
fill(tx); // 캐시에만 기록됨 (dirty)
HAL_UART_Transmit_DMA(&h, tx, 64);
// DMA는 메모리를 직접 읽는다 → 옛 데이터를 전송! ✗
해결 — 전송 전에 캐시를 비운다(clean)
SCB_CleanDCache_by_Addr((uint32_t *)tx, 64);
② DMA → CPU 방향 (수신)
uint8_t rx[64];
HAL_UART_Receive_DMA(&h, rx, 64); // DMA가 메모리에 직접 씀
// 완료 후
process(rx); // 캐시에 옛 값이 남아 있으면 그것을 읽는다 ✗
해결 — 읽기 전에 캐시를 무효화한다(invalidate)
SCB_InvalidateDCache_by_Addr((uint32_t *)rx, 64);
외우는 법
- 보내기 전 → Clean — (캐시의 내용을 메모리로 밀어낸다)
- 받은 후 — → Invalidate (캐시를 버리고 메모리에서 다시 읽게 한다)
캐시 라인 정렬 함정
캐시 연산은 라인 단위(보통 32바이트) 로 동작한다.
uint8_t a[10];
uint8_t dma_buf[10]; // a 와 같은 캐시 라인에 있을 수 있다
SCB_InvalidateDCache_by_Addr(dma_buf, 10);
// → 같은 라인의 a 까지 무효화된다 → a의 수정 내용이 사라진다! ✗
DMA 버퍼는 캐시 라인 크기로 정렬하고 크기도 배수로 맞춘다.
__attribute__((aligned(32))) uint8_t dma_buf[64];
이 정렬을 빠뜨려 간헐적으로만 데이터가 깨지는 버그가 실제로 흔하다. 재현이 어려워 원인 파악에 오래 걸린다.
더 나은 해법 — MPU로 non-cacheable 영역
- MPU 로 DMA 버퍼 영역을 "캐시 안 함"으로 설정
- clean/invalidate 를 신경 쓸 필요가 없어진다
- 그 영역 접근은 느려지지만 버그 위험이 사라진다
실무에서는 이 방식을 선호한다. 사람이 매번 캐시 연산을 정확히 호출하는 것보다 안전하기 때문이다. 링커 스크립트에 전용 섹션을 만들어 DMA 버퍼를 모아 두고, 그 구역만 MPU로 설정한다.
Cortex-M에서 언제 신경 쓰나
- Cortex-M0/M3/M4 — → 데이터 캐시 없음. 이 문제가 아예 없다
- Cortex-M7 — → L1 데이터 캐시 있음. 반드시 관리해야 한다
M4에서 M7으로 포팅했더니 DMA가 이상하게 동작한다는 것이 전형적인 시나리오다. 코드는 그대로인데 캐시가 생겼기 때문이다.