Mở đầu: thế giới là analog, MCU chỉ nói được bằng số

Nhiệt độ, ánh sáng, âm thanh, vị trí một biến trở xoay tay — tất cả đều là điện áp biến thiên liên tục, có thể nhận vô số giá trị giữa 0V và điện áp nguồn. Nhưng bộ nhớ và thanh ghi của MCU chỉ lưu được số nguyên rời rạc. ADC (Analog-to-Digital Converter) là cây cầu bắt buộc phải bắc qua hố sâu đó — và giống mọi phép đo rời rạc hoá, nó luôn đánh đổi: biến điện áp thật thành một con số gần đúng, không bao giờ chính xác tuyệt đối.

Bài này mổ xẻ đúng 1 con chip ADC 10-bit kiểu STM32F103: nguyên lý SAR bên trong, công thức lượng tử hoá verify bằng số thật, cách đọc đúng qua cờ EOC, và kỹ thuật kinh điển nhất để "cứu" một tín hiệu nhiễu — trung bình trượt. Đây cũng là bài mở khoá 2 hướng: Series 14 (DSP Âm thanh) Bài 3 sẽ mổ xẻ lượng tử hoá đến tận gốc rễ toán học, còn Bài 9 Series 14 dạy đàng hoàng vì sao trung bình trượt chính là một bộ lọc FIR.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 7 (ngắt — cờ EOC hoạt động giống hệt cơ chế ngắt đã học), Bài 2 (thanh ghi, memory-mapped I/O). Nên biết thêm: Series 10 Điện Tử (điện áp, biến trở) giúp trực giác phần "thế giới analog" dễ hình dung hơn.

1. Cầu nối analog → digital: nguyên lý SAR

Kiểu ADC phổ biến nhất trong MCU giá rẻ là SAR (Successive Approximation Register — thanh ghi xấp xỉ liên tiếp). Trực giác dễ nhớ nhất: một cái cân nhị phân. Thay vì dò từng nấc điện áp một (chậm không chịu nổi), SAR đoán từng bit một, từ bit quan trọng nhất (MSB) xuống bit thấp nhất (LSB):

  1. Đoán bit cao nhất = 1 (thử "nửa thang đo trở lên"). So sánh với điện áp thật: đúng hướng thì giữ, sai thì đổi lại 0.
  2. Chuyển sang bit kế tiếp, lặp lại phép so sánh nhị phân — mỗi lần thu hẹp khoảng nghi ngờ còn một nửa.
  3. Lặp đúng N lần cho ADC N-bit — N phép so sánh cho ra N bit kết quả, nhanh hơn dò tuần tự tới $2^N$ lần.

Vấn đề: việc "đoán dần" này cần một khoảng thời gian ngắn nhưng khác 0 — nếu điện áp đầu vào cứ đổi liên tục trong lúc SAR đang so sánh, kết quả sẽ bị lẫn lộn. Giải pháp là mạch sample & hold (lấy mẫu & giữ): một tụ điện nhỏ "chụp" đúng điện áp tại một thời điểm và giữ nguyên trong suốt quá trình SAR làm việc — giống chụp ảnh một vật đang chuyển động thay vì cố nhìn trực tiếp trong lúc nó đang di chuyển.

2. Độ phân giải & LSB

ADC 10-bit của STM32F103 chia toàn bộ dải điện áp $[0, V_{ref}]$ thành $2^{10} = 1024$ mức rời rạc, đánh số từ 0 đến 1023. Kích thước mỗi bậc — gọi là LSB (Least Significant Bit, ở đây dùng làm đơn vị điện áp) — tính bằng:

$$V_{LSB} = \frac{V_{ref}}{2^N}$$

Verify: với $V_{ref} = 3300mV$ (3,3V, phổ biến trên board STM32), 10-bit cho V_LSB = 3300 / 1024 ≈ 3,2226mV — mỗi bậc chưa tới 3,3mV, khá mịn. Công thức đổi mã ADC (số nguyên đọc từ DR) sang điện áp thật:

$$V = \frac{code}{2^N - 1} \times V_{ref}$$

Verify vài điểm mốc: code(0mV) = 0 (đáy thang), code(3300mV) = 1023 (đỉnh thang), và điểm giữa thang code(1650mV) = 512 — đổi ngược lại 512 → 1651,6mV, chỉ lệch 1,6mV so với 1650mV gốc. Đây chính là sai số lượng tử: không thể tránh, nhưng bị chặn trong khoảng $\pm\tfrac{1}{2}LSB$ — với 10-bit, biên độ sai số này nhỏ tới mức hầu như vô hại.

⚠️ Verify: số bit càng ít, bậc thang càng thô — kịch tính hơn tưởng tượng
Hạ độ phân giải xuống 4-bit (chỉ 16 mức): V_LSB = 3300/16 = 206,25mV — thô gấp 64 lần so với 10-bit. Verify cụ thể: cùng điện áp 1650mV, 4-bit chỉ đọc được mã 8, đổi ngược lại ra 1760mV — lệch tới 110mV so với giá trị thật, so với chỉ 1,6mV của 10-bit. Đây chính là "bậc thang lượng tử" bạn sẽ THẤY trực tiếp ở demo Mục 5 khi kéo thanh trượt số bit xuống thấp.

3. Đọc ADC đúng cách

Sau khi kích hoạt một lần chuyển đổi (ghi bit SWSTART vào thanh ghi CR2), MCU cần biết khi nào kết quả đã sẵn sàng trong DR. Cờ EOC (End Of Conversion, bit trong thanh ghi SR) đảm nhiệm việc đó — và cách dùng nó gợi nhớ lại chính xác bài học Polling vs Interrupt của Bài 7:

  • Polling: main tự hỏi liên tục "EOC = 1 chưa?" trong một vòng lặp bận rộn — đơn giản, nhưng CPU bị khoá chờ, đúng cạm bẫy quen thuộc.
  • Ngắt (EOCIE): bật bit EOCIE trong CR1, CPU được rảnh tay làm việc khác, phần cứng tự báo ngắt IRQ_ADC1 đúng lúc EOC lên 1 — verify: 1 lần SWSTART kích hoạt đúng 1 lần ngắt.
  • DMA (xem trước): với dòng dữ liệu ADC liên tục tốc độ cao, ngay cả ngắt-từng-mẫu cũng tốn CPU đáng kể — Bài 11 sẽ giới thiệu cách để phần cứng tự chuyển dữ liệu mà CPU không cần đụng tay vào từng mẫu.

Đọc DR sẽ tự động xoá EOC về 0 — đúng cơ chế "đọc là báo đã nhận" đã gặp ở RXNE của UART (Bài 9).

⚠️ Pitfall: nguồn trở kháng cao + thời gian lấy mẫu ngắn = giá trị "mềm"
Tụ sample & hold cần một khoảng thời gian đủ để nạp điện tới đúng mức điện áp đầu vào — nếu nguồn tín hiệu có trở kháng cao (ví dụ một cảm biến yếu, dây dẫn dài) và thời gian lấy mẫu cấu hình quá ngắn, tụ chưa kịp nạp đầy đã bị "chốt" lại — kết quả đọc được trôi nổi, không ổn định giữa các lần đo dù điện áp thật không đổi. Đây là lỗi phần cứng quen thuộc dễ bị nhầm là "ADC bị lỗi" hoặc "nhiễu điện" khi thực ra là thời gian lấy mẫu cấu hình sai.

4. Làm mượt tín hiệu: trung bình trượt & oversampling

Tín hiệu analog thật hiếm khi "sạch" — nhiễu điện, rung tay khi xoay biến trở, nhiễu từ nguồn cấp đều làm giá trị ADC nhảy lung tung quanh giá trị thật. Kỹ thuật đơn giản và hiệu quả nhất: trung bình trượt (moving average) — lấy trung bình cộng của N mẫu gần nhất thay vì dùng riêng mẫu mới nhất.

Verify bằng số thật: với nhiễu ngẫu nhiên biên độ ±50mV quanh 1650mV (20 mẫu), phương sai của tín hiệu thô đo được là ≈901; sau khi áp trung bình trượt cửa sổ 8 mẫu, phương sai giảm còn ≈250 — mượt hơn khoảng 3,6 lần. Nhưng cái giá phải trả luôn tồn tại: độ trễ — cửa sổ càng lớn càng mượt nhưng phản ứng càng chậm với thay đổi đột ngột (biến trở xoay nhanh sẽ "trễ nhịp" theo tay). Đây chính xác là trade-off oversampling: lấy mẫu nhanh hơn cần thiết rồi trung bình lại, đổi tốc độ lấy mẫu thô lấy độ chính xác cao hơn.

💡 Gọi đúng tên: trung bình trượt CHÍNH LÀ một bộ lọc FIR
Trung bình trượt không phải một "mẹo vặt" riêng của lập trình nhúng — nó là bộ lọc số FIR (Finite Impulse Response) đơn giản nhất tồn tại, với mọi hệ số bằng nhau. Series 14 (DSP Âm thanh) Bài 9 sẽ mổ xẻ đàng hoàng: vì sao đáp ứng tần số của nó có hình sinc lởm chởm, và những bộ lọc FIR thiết kế tốt hơn trông như thế nào.

5. Thực hành: xoay biến trở trên VMCU thật

Demo dưới đây chạy trên VMCU thật với ADC1 vừa "sống": kéo biến trở, xem đồ thị raw vs mượt chạy song song, bật/tắt nhiễu môi trường, và kéo số bit ADC xuống thấp để THẤY bậc thang lượng tử rõ ràng bằng mắt:

🎛️ Biến trở ảo & ADC Explorer — VMCU thật

1. Biến trở & nhiễu môi trường

Raw (thô, từng lần đọc DR trực tiếp)   Mượt (trung bình trượt cửa sổ 8 mẫu)

2. Độ phân giải ADC — kéo số bit xem bậc thang

Đang tải…

Kéo xuống 4-bit ở vị trí 1650mV: mã đọc được chỉ còn 8, đổi ngược ra 1760mV — lệch 110mV so với giá trị thật (verified). Ở 10-bit, sai số chỉ còn ~1,6mV.

adc_read.c
// Polling: cho don gian, cho ngat xem Muc 3.
void adc_init(void) {
    ADC1_CR2 |= (1 << 0);   // ADON = 1: bat khoi ADC
}

uint16_t adc_read_polling(void) {
    ADC1_CR2 |= (1 << 22);          // SWSTART: bat dau 1 lan chuyen doi
    while (!(ADC1_SR & (1 << 1))) ; // cho EOC = 1 (BAN RON - dung cach nay khi don gian)
    return ADC1_DR;                  // doc DR TU DONG xoa EOC
}

// Ngat: xem adc_isr.c trong tab JavaScript de thay tuong duong tren VMCU.
// Trung binh truot don gian:
#define WINDOW 8
uint16_t buf[WINDOW];
uint8_t idx = 0;

uint16_t moving_average_push(uint16_t sample) {
    buf[idx] = sample;
    idx = (idx + 1) % WINDOW;
    uint32_t sum = 0;
    for (uint8_t i = 0; i < WINDOW; i++) sum += buf[i];
    return sum / WINDOW;
}
adc_demo.js (đúng logic đang chạy ở tab Xem trước)
import {
  VMCU, ADC1_CR2_ADDR, ADC1_DR_ADDR, ADC1_CR2_ADON_BIT, ADC1_CR2_SWSTART_BIT,
  ADC_VREF_MV, adcVoltageToCode, adcCodeToVoltage, adcLsbMv, movingAverage,
  deterministicNoiseMv,
} from './vmcu.js';

const cpu = new VMCU();
cpu.write32(ADC1_CR2_ADDR, 1 << ADC1_CR2_ADON_BIT); // bat ADON mot lan

function readOneSample(potMv, noiseOn, sampleIndex) {
  const noisy = potMv + (noiseOn ? deterministicNoiseMv(sampleIndex, 50) : 0);
  cpu.adcSetAnalogInputMv(noisy);
  cpu.write32(ADC1_CR2_ADDR, (1 << ADC1_CR2_ADON_BIT) | (1 << ADC1_CR2_SWSTART_BIT));
  const code = cpu.read32(ADC1_DR_ADDR); // doc DR - tu dong xoa EOC
  return adcCodeToVoltage(code, ADC_VREF_MV, 10);
}

const raw = [];
for (let i = 0; i < 30; i++) raw.push(readOneSample(1650, true, i));
const smoothed = movingAverage(raw, 8);

// Do phan giai: minh hoa cong thuc chung, doc lap voi thanh ghi 10-bit that
console.log(adcVoltageToCode(1650, 3300, 4), adcCodeToVoltage(8, 3300, 4)); // 8, 1760

Tóm lược

  • SAR ADC: đoán từng bit theo kiểu "cân nhị phân" — N phép so sánh cho N bit kết quả; mạch sample & hold giữ điện áp đứng yên trong lúc cân.
  • Độ phân giải & LSB: $V_{LSB} = V_{ref}/2^N$ — verify: 10-bit @3,3V cho V_LSB≈3,22mV (1024 mức); 4-bit chỉ 206,25mV/mức (thô gấp 64 lần) — sai số verify cụ thể 1,6mV vs 110mV cùng một điện áp 1650mV.
  • ✅ Đọc ADC qua cờ EOC: polling (đơn giản, tốn CPU) vs ngắt EOCIE (rảnh CPU) — DMA (Bài 11) cho dòng dữ liệu tốc độ cao. Đọc DR tự động xoá EOC.
  • ✅ Pitfall: nguồn trở kháng cao + thời gian lấy mẫu ngắn → giá trị đọc "mềm", trôi nổi dù điện áp thật ổn định.
  • Trung bình trượt verified: nhiễu ±50mV giảm phương sai từ ≈901 xuống ≈250 (~3,6 lần) — cái giá là độ trễ; về bản chất đây là bộ lọc FIR đơn giản nhất.

Trắc nghiệm ôn tập

Câu 1

Vì sao ADC kiểu SAR cần một mạch sample & hold đi kèm?

Câu 2

Verified: cùng một điện áp 1650mV, ADC 10-bit đọc lại chỉ lệch ~1,6mV nhưng ADC 4-bit lệch tới 110mV. Vì sao giảm số bit lại làm sai số tăng mạnh đến vậy?

Câu 3

Đọc ADC bằng polling ("chờ EOC=1 trong vòng lặp bận rộn") và đọc bằng ngắt EOCIE khác nhau ở điểm mấu chốt nào?

Câu 4

Verified: nhiễu ngẫu nhiên ±50mV làm phương sai tín hiệu thô ≈901; sau trung bình trượt cửa sổ 8 mẫu, phương sai giảm còn ≈250. Cái giá phải trả cho việc "mượt hơn" này là gì?

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 10 vừa thêm ADC1 (SR/CR1/CR2/DR), công thức lượng tử hoá (adcVoltageToCode, adcCodeToVoltage, adcLsbMv), và trung bình trượt (movingAverage), 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 9: UART & giao tiếp nối tiếp Bài 11: DMA — chuyển dữ liệu không cần CPU Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận