같은 메모리 위치에 대해 캐시와 메모리, 또는 여러 캐시가 서로 다른 값을 갖게 되는 문제와 그것을 맞추는 일(cache coherency).
두 가지 층위 — 구분해야 한다
① 단일 코어 + DMA (임베디드에서 흔한 경우)
CPU ──► [D-Cache] ──► 메모리 ◄── DMA
↑ ↑
여기에 최신값이 여기만 보는
있을 수 있다 DMA는 옛값을 본다
하드웨어가 자동으로 맞춰 주지 않는다. 소프트웨어가 직접 관리해야 한다.
② 멀티코어 (Cortex-A, SMP)
코어0 [캐시] ─┐
코어1 [캐시] ─┼── 버스 ── 메모리
코어2 [캐시] ─┘
같은 주소를 여러 캐시가 갖고 있으면 하나가 수정했을 때 나머지는?
여기서는 하드웨어 프로토콜(MESI 등)이 자동으로 처리한다.
MESI 프로토콜 — 멀티코어의 자동 해결
각 캐시 라인이 네 상태 중 하나를 갖는다.
| 상태 | 뜻 |
|---|---|
| Modified | 나만 갖고 있고 수정했다. 메모리와 다르다 |
| Exclusive | 나만 갖고 있고 수정 안 했다 |
| Shared | 여러 캐시가 같은 값을 갖고 있다 |
| Invalid | 무효. 쓸 수 없다 |
코어0이 Shared 라인에 쓰려고 한다
→ 버스에 "무효화" 신호를 보낸다
→ 다른 코어들의 그 라인이 Invalid 가 된다
→ 코어0의 라인이 Modified 가 된다
캐시들이 버스를 엿들으며(snooping) 서로 조율하는 구조다.
False Sharing — MESI의 부작용
struct { int a; int b; } data; // a와 b가 같은 캐시 라인(64바이트)에 있다
코어0: data.a++ 반복
코어1: data.b++ 반복
서로 다른 변수인데도 같은 라인이라 매번 무효화가 오간다. 논리적으로는 공유가 없는데 하드웨어적으로 경쟁하는 것이다.
→ 캐시 라인 크기로 패딩해 분리한다.
struct { int a; char pad[60]; int b; } data;
성능이 이유 없이 나쁠 때 의심해 볼 항목이다.
임베디드(Cortex-M7)에서의 실전 관리
// 송신: CPU가 쓴 것을 DMA가 읽어야 한다
memcpy(tx_buf, data, len);
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); // 캐시 → 메모리
HAL_SPI_Transmit_DMA(&hspi, tx_buf, len);
// 수신: DMA가 쓴 것을 CPU가 읽어야 한다
HAL_SPI_Receive_DMA(&hspi, rx_buf, len);
// … 완료 콜백에서
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len); // 캐시 버리기
process(rx_buf);
| 함수 | 동작 | 언제 |
|---|---|---|
| Clean | 캐시의 dirty 데이터를 메모리로 밀어냄 | DMA 송신 전 |
| Invalidate | 캐시 내용을 버림 (다음 접근 시 메모리에서 다시) | DMA 수신 후 |
| Clean&Invalidate | 둘 다 | 양방향 버퍼 |
정렬이 반드시 필요한 이유
캐시 연산은 라인 단위로 동작하므로, 버퍼가 라인 경계에 맞지 않으면 이웃 데이터까지 영향을 받는다.
__attribute__((aligned(32))) uint8_t dma_buf[64]; // 32바이트 라인 기준
정렬을 안 하면 간헐적으로만 재현되는 데이터 손상이 생겨 디버깅이 매우 어렵다.
더 안전한 접근 — 처음부터 캐시를 끄기
- MPU로 DMA 버퍼 영역을 non-cacheable, shareable 로 설정
- 캐시 연산이 아예 필요 없다
- 그 영역 접근은 느려지지만 실수의 여지가 사라진다
실무에서는 이쪽을 권장한다. 링커 스크립트에 .dma_buffer 섹션을 만들어
모든 DMA 버퍼를 모으고, 그 구역만 MPU로 설정하는 것이 정석이다.
메모리 배리어와의 관계
캐시 일관성과 메모리 순서는 다른 문제다.
캐시 일관성 : 값이 서로 다른 문제 메모리 순서 : 쓰기가 보이는 순서가 뒤바뀌는 문제
- __DSB() — — 이전 메모리 접근이 완료될 때까지 대기
- __DMB() — — 이전과 이후 메모리 접근의 순서를 보장
- __ISB() — — 명령 파이프라인을 비움 (설정 변경 후 필수)
SCB->VTOR = new_vector_table;
__DSB(); // 쓰기 완료 보장
__ISB(); // 파이프라인 갱신
시스템 레지스터를 바꾼 뒤에는 배리어가 필요하다 — 안 넣으면 파이프라인에 남은 옛 설정으로 실행되는 일이 생긴다.