Mở đầu: CPU đang làm việc của một cái băng chuyền

Bài 10 dạy hai cách đọc ADC: polling (CPU tự hỏi) và ngắt EOCIE (phần cứng gõ vai). Cả hai đều có một điểm chung dễ bị bỏ qua: CPU phải đích thân tham gia mọi lần — dù chỉ là chạy vào ISR, đọc DR, cất vào một biến rồi thoát ra. Ở tốc độ lấy mẫu thấp, chi phí đó không đáng kể. Nhưng khi dòng dữ liệu dồn dập — audio 44,1kHz, một cảm biến đọc hàng chục nghìn lần/giây — CPU biến thành một cái băng chuyền: cứ mỗi mẫu lại dừng việc đang làm, chạy vào ISR, sao chép 1 giá trị, rồi quay lại. Đó chính xác là công việc của một người khuân vác, không phải một bộ xử lý.

DMA (Direct Memory Access) là câu trả lời: một bộ điều khiển phần cứng RIÊNG, chuyên trách việc di chuyển dữ liệu giữa ngoại vi và RAM mà KHÔNG cần CPU nhúng tay vào từng byte. Bài này verify bằng số thật chênh lệch chi phí đó lớn tới mức nào, và mổ xẻ kỹ thuật ping-pong (double buffering) — kiến trúc chuẩn của mọi hệ thống audio/DSP thời gian thực (nối thẳng Series 14 Bài 14).


📚 Điều kiện tiên quyết
Bắt buộc: Bài 10 (ADC làm nguồn dữ liệu cho DMA ở bài này), Bài 8 (torn read — Mục 4 tái xuất ở quy mô lớn hơn). Có nhắc lại: Bài 9 (UART cũng có thể dùng DMA cho TX, chỉ nhắc qua).

1. Bài toán: CPU làm "cửu vạn"

Mỗi lần một ISR chạy, CPU phải trả một khoản phí cố định trước cả khi chạm vào logic thật của ISR: lưu lại ngữ cảnh đang chạy dở (các thanh ghi R0–R3, R12, LR, PC, xPSR) lên stack, rồi khi ISR xong lại khôi phục đúng ngần ấy thanh ghi để quay về chỗ cũ. Con số thường được trích dẫn cho lõi Cortex-M: khoảng 12 chu kỳ đẩy vào + 12 chu kỳ khôi phục ≈ 24 chu kỳ — đây là cái giá phải trả DÙ ISR có làm gì hay không.

Verify bằng một kịch bản cụ thể: lấy mẫu ADC ở 10kHz trên clock 72MHz — mỗi mẫu có đúng 72.000.000 / 10.000 = 7200 chu kỳ để "sống". Nếu dùng ngắt-từng-mẫu, mỗi mẫu tốn khoảng 24 chu kỳ vào/ra ISR cộng thêm một chút cho thân ISR (minh hoạ ~10 chu kỳ để cất giá trị vào ring buffer) — tổng ≈34 chu kỳ. Tỷ lệ CPU bị chiếm dụng:

$$\text{CPU load} = \frac{34}{7200} \times 100\% \approx 0{,}472\%$$

Nghe có vẻ nhỏ — và ĐÚNG LÀ nhỏ cho một luồng dữ liệu. Nhưng một hệ thống thật hiếm khi chỉ có một luồng: cộng dồn UART, vài kênh cảm biến khác, mỗi luồng đều "cắn" vào ngân sách CPU giống hệt vậy. Polling thì tệ hơn nhiều — verify: CPU coi như bận 100% suốt chu kỳ mẫu vì phải busy-wait chờ cờ EOC, không làm được việc gì khác trong lúc đó.

2. DMA controller — người khuân vác chuyên nghiệp

DMA controller là một mạch phần cứng độc lập, được cấu hình MỘT LẦN với: địa chỉ nguồn (ngoại vi, ví dụ thanh ghi DR của ADC), địa chỉ đích (một vùng RAM), chế độ tăng địa chỉ (đích tăng dần sau mỗi lần truyền, nguồn thì không — vì DR luôn ở đúng một chỗ), và số phần tử cần truyền. Sau đó, mỗi khi ngoại vi "gõ cửa" (một request — ví dụ ADC báo "tôi vừa có kết quả mới"), DMA tự thực hiện việc sao chép mà KHÔNG đánh thức CPU.

Điểm mấu chốt: CPU chỉ nhận đúng MỘT ngắt khi cả khối hoàn tất, chứ không phải một ngắt cho từng phần tử. Verify: với khối 64 mẫu, chi phí ISR (~34 chu kỳ) giờ chia đều cho cả 64 mẫu:

$$\text{CPU load}_{DMA} = \frac{34}{64 \times 7200} \times 100\% \approx 0{,}0074\%$$

So với 0,472% của ngắt-từng-mẫu, đây là rẻ hơn đúng 64 lần — chính xác bằng kích thước khối, vì bản chất phép tính chỉ là chia chi phí cố định cho số mẫu mỗi lần "trả góp". Đây KHÔNG đáng để làm cho vài byte lẻ tẻ (cấu hình DMA cũng có phí, dù nhỏ) — nhưng với dòng dữ liệu đều đặn tốc độ cao, khoản tiết kiệm này là lý do DMA tồn tại trên mọi MCU nghiêm túc.

3. Ping-pong (double buffering)

Vấn đề tiếp theo: nếu DMA đổ đầy MỘT buffer rồi mới báo ngắt, CPU xử lý xong thì buffer đó lại đang bị DMA ghi đè cho lượt kế tiếp — xử lý dữ liệu VÀ ghi dữ liệu mới tranh nhau cùng một vùng nhớ. Ping-pong (double buffering) giải quyết bằng cách chia đôi: DMA ghi vào nửa A trong khi CPU xử lý nửa B (đã ghi xong từ trước), rồi khi DMA ghi hết nửa A, chuyển sang ghi nửa B trong khi CPU quay lại xử lý nửa A vừa xong — luân phiên vô tận, không rớt mẫu nào.

Cơ chế thật của STM32 cho việc này không cần 2 buffer vật lý tách biệt: dùng MỘT buffer vòng tròn (chế độ CIRC) với 2 loại ngắt: Half-Transfer (HTIF, báo khi đã đổ đầy đúng NỬA buffer — an toàn để xử lý nửa đầu trong khi DMA tiếp tục ghi nửa sau) và Transfer-Complete (TCIF, báo khi đầy TOÀN BỘ buffer — DMA tự vòng lại đầu buffer, không cần phần mềm can thiệp). Verify trên VMCU thật với buffer 8 phần tử: đúng 1 lần ngắt HT tại mẫu thứ 4 (nửa buffer) và 1 lần ngắt TC tại mẫu thứ 8 (đầy buffer) — tổng cộng đúng 2 ngắt cho 1 vòng đầy, sau đó CNDTR và địa chỉ đích tự động vòng lại về giá trị gốc để tiếp tục vòng kế tiếp.

💡 Vì sao quan trọng: kiến trúc chuẩn của audio/DSP thời gian thực
Ping-pong qua CIRC + HT/TC không phải một mẹo riêng của ADC — đây CHÍNH LÀ cách mọi driver audio thật (kể cả trên máy tính lớn) truyền dòng mẫu liên tục không ngắt quãng. Series 14 (DSP Âm thanh) Bài 14 sẽ dùng lại đúng mô hình này khi xử lý audio thời gian thực theo block.

4. Pitfall: torn data quy mô lớn & cache coherency

Nếu main đọc buffer đúng lúc DMA đang ghi dở vào chính buffer đó, kết quả là một phiên bản "xé đôi" của cả khối dữ liệu — nửa cũ nửa mới lẫn lộn. Đây chính xác là torn read của Bài 8 Mục 4, chỉ khác quy mô: thay vì 2 trường của một struct nhỏ, giờ là hàng chục/hàng trăm phần tử của cả một buffer. Cách phòng tránh giống hệt về nguyên tắc: KHÔNG đọc nửa đang được DMA ghi — đây chính là lý do ping-pong tồn tại (nửa CPU đang đọc luôn là nửa DMA KHÔNG động vào).

⚠️ Outlook: cache coherency trên MCU lớn hơn
Trên các MCU cao cấp hơn có bộ nhớ cache (ngoài phạm vi dòng STM32F103 đơn giản của series này), CPU đọc dữ liệu qua cache — nhưng DMA ghi trực tiếp vào RAM, KHÔNG đi qua cache. Nếu không cẩn thận, CPU có thể đọc một bản sao CŨ trong cache trong khi RAM thật đã có dữ liệu mới từ DMA — cần lệnh invalidate cache trước khi đọc vùng nhớ do DMA vừa ghi. Chi tiết này nằm ngoài phạm vi series (VMCU không mô phỏng cache), nhưng đáng biết tên khi gặp trên phần cứng thật.

5. Thực hành: đồng hồ CPU-load ảo & ping-pong visualizer

Demo dưới đây chạy trên VMCU thật với DMA1 Channel 1 vừa "sống": so sánh trực quan chi phí CPU của 3 chiến lược, và xem ping-pong CIRC+HT/TC hoạt động qua từng mẫu:

🚚 CPU-Load Clock & Ping-Pong Visualizer — VMCU thật

1. Đồng hồ CPU-load — 3 chiến lược

2. Ping-pong visualizer (buffer vòng 8 mẫu)

Đang tải…

Ô xanh lá = nửa buffer AN TOÀN để CPU xử lý (DMA vừa ghi xong); ô hổ phách = nửa buffer DMA ĐANG ghi (CPU không nên đụng vào). Sau mẫu thứ 4 sẽ thấy sự kiện Half-Transfer, sau mẫu thứ 8 là Transfer-Complete kèm tự vòng lại.

dma_adc_pingpong.c
#define BUF_LEN 8   // phai la so CHAN de nua buffer chia het
uint16_t adc_buf[BUF_LEN];

void dma_adc_init(void) {
    DMA1_CMAR1 = (uint32_t)adc_buf;   // dia chi dich: RAM
    DMA1_CNDTR1 = BUF_LEN;             // so phan tu can truyen
    DMA1_CCR1 = (1 << 1)  // TCIE: bao ngat khi day buffer
              | (1 << 2)  // HTIE: bao ngat khi day NUA buffer
              | (1 << 5)  // CIRC: tu vong lai, khong can phan mem nap lai
              | (1 << 7); // MINC: tu tang dia chi dich sau moi lan
    ADC1_CR2 |= (1 << 8);  // bat yeu cau DMA moi khi EOC
    DMA1_CCR1 |= (1 << 0); // EN: bat kenh (chot CNDTR/CMAR goc cho CIRC)
}

// CPU KHONG can lam gi ca de nhan tung mau - chi can xu ly khi co ngat:
void DMA1_Channel1_IRQHandler(void) {
    if (DMA1_ISR & (1 << 2)) {              // HTIF: nua dau buffer da day
        process_half(&adc_buf[0], BUF_LEN / 2); // an toan xu ly nua dau
        DMA1_IFCR = (1 << 2);                // xoa co (ghi 1 de xoa)
    }
    if (DMA1_ISR & (1 << 1)) {              // TCIF: nua sau buffer da day
        process_half(&adc_buf[BUF_LEN / 2], BUF_LEN / 2); // an toan xu ly nua sau
        DMA1_IFCR = (1 << 1);
    }
}
dma_demo.js (đúng logic đang chạy ở tab Xem trước)
import {
  VMCU, ADC1_CR2_ADDR, ADC1_CR2_ADON_BIT, ADC1_CR2_SWSTART_BIT, ADC1_CR2_DMA_BIT,
  DMA1_CCR1_ADDR, DMA1_CNDTR1_ADDR, DMA1_CMAR1_ADDR, DMA1_ISR_ADDR,
  DMA1_CCR_EN_BIT, DMA1_CCR_TCIE_BIT, DMA1_CCR_HTIE_BIT, DMA1_CCR_CIRC_BIT, DMA1_CCR_MINC_BIT,
  DMA1_ISR_TCIF1_BIT, DMA1_ISR_HTIF1_BIT, cpuLoadPercent,
} from './vmcu.js';

const cpu = new VMCU();
const RAM_BASE = 0x20000000;
cpu.write32(DMA1_CMAR1_ADDR, RAM_BASE);
cpu.write32(DMA1_CNDTR1_ADDR, 8);
cpu.write32(DMA1_CCR1_ADDR,
  (1 << DMA1_CCR_TCIE_BIT) | (1 << DMA1_CCR_HTIE_BIT) | (1 << DMA1_CCR_CIRC_BIT) | (1 << DMA1_CCR_MINC_BIT));
cpu.write32(ADC1_CR2_ADDR, (1 << ADC1_CR2_ADON_BIT) | (1 << ADC1_CR2_DMA_BIT));
cpu.write32(DMA1_CCR1_ADDR, cpu.read32(DMA1_CCR1_ADDR) | (1 << DMA1_CCR_EN_BIT));

function feedOneSample(mv) {
  cpu.adcSetAnalogInputMv(mv);
  cpu.write32(ADC1_CR2_ADDR, cpu.read32(ADC1_CR2_ADDR) | (1 << ADC1_CR2_SWSTART_BIT));
  // CPU KHONG doc DR - DMA da tu chuyen thang vao RAM tai CMAR
}

// Chi phi CPU minh hoa - xem cong thuc Muc 11.1/11.2
console.log(cpuLoadPercent('polling', 7200), cpuLoadPercent('interrupt', 7200), cpuLoadPercent('dma', 7200, 64));

Tóm lược

  • ✅ Mỗi ISR tốn chi phí vào/ra cố định (~24 cycle, con số thường trích dẫn cho Cortex-M) — verify: ở 10kHz/72MHz, ngắt-từng-mẫu chiếm ≈0,472% CPU, polling chiếm 100%.
  • DMA controller: cấu hình MỘT LẦN (nguồn, đích, số phần tử), tự chuyển dữ liệu mỗi khi ngoại vi "gõ cửa" — CPU chỉ nhận MỘT ngắt khi cả khối xong. Verify: khối 64 mẫu chỉ ≈0,0074% CPU — rẻ hơn ngắt-từng-mẫu đúng 64 lần.
  • Ping-pong qua CIRC + Half-Transfer/Transfer-Complete: verify 1 vòng 8 mẫu cho đúng 2 ngắt (HT tại mẫu 4, TC tại mẫu 8), tự vòng lại CNDTR/CMAR không cần phần mềm can thiệp — kiến trúc chuẩn của audio/DSP thời gian thực.
  • ✅ Pitfall: đọc buffer đang bị DMA ghi dở = torn data quy mô khối (Bài 8.4 phóng to); cache coherency là vấn đề riêng trên MCU có cache (ngoài phạm vi VMCU).

Trắc nghiệm ôn tập

Câu 1

Verified: ngắt-từng-mẫu chiếm ≈0,472% CPU ở 10kHz/72MHz, còn DMA khối 64 mẫu chỉ chiếm ≈0,0074% — rẻ hơn đúng 64 lần. Vì sao chênh lệch lại đúng bằng kích thước khối?

Câu 2

Ping-pong (double buffering) qua chế độ CIRC dùng đúng 2 loại ngắt: Half-Transfer (HT) và Transfer-Complete (TC). Vì sao cần CẢ HAI thay vì chỉ dùng Transfer-Complete khi buffer đầy?

Câu 3

Đọc một buffer đúng lúc DMA đang ghi dở vào chính buffer đó gây ra hiện tượng gì, và nó liên hệ thế nào với kiến thức đã học?

Câu 4

DMA có luôn luôn là lựa chọn tốt hơn ngắt-từng-mẫu hay polling không?

Tải file code thực hành minh họa bài học

File JavaScript VMCU — engine MCU ảo dùng xuyên suốt cả 16 bài, Bài 11 vừa thêm DMA1 Channel 1 (ISR/IFCR/CCR/CNDTR/CPAR/CMAR), mô hình chi phí CPU (cpuLoadPercent), và ping-pong qua CIRC+HT/TC, kèm self-test đối chiếu đúng mọi hành vi trong bài (chạy node vmcu.js, không cần cài thêm gì):

Tải về vmcu.js

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 10: ADC & thế giới tương tự Bài 12: Boot & bản đồ bộ nhớ chương trình Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận