Mở đầu: làm mờ một LED mà không có "núm vặn điện áp" nào cả

MCU chỉ biết đúng 2 mức: 0V hoặc VCC (thường 3.3V) — không có "0.5V", không có "70% độ sáng" nào ghi thẳng vào một chân output cả. Vậy làm sao đèn LED trên xe đạp, màn hình laptop, hay động cơ quạt điều chỉnh tốc độ mượt mà chỉ bằng vi điều khiển thuần digital? Và làm sao một con buzzer bé xíu (chỉ có thể "kêu" hoặc "im") lại phát ra được nốt nhạc có cao độ nghe rõ ràng?

Câu trả lời là PWM — bật/tắt cực nhanh với tỷ lệ thời gian bật khác nhau, đánh lừa cả mắt lẫn tai. Bài này mổ xẻ ngoại vi tạo ra PWM — Timer đa năng (TIM) — từ toán học prescaler/auto-reload đến 2 cạm bẫy kinh điển khi tính tần số, rồi áp dụng vào ứng dụng thật: điều khiển góc quay servo RC. Cuối bài, bạn tự tay kéo 3 thanh trượt và NGHE buzzer đổi cao độ thật.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 2 (thanh ghi, RMW) và Bài 4 (khái niệm clock/tick — TIM1 dùng đúng ý tưởng chia nhịp clock, chỉ khác SysTick ở mục đích).

1. Timer đa năng khác gì SysTick?

Bài 4 đã giới thiệu SysTick — một bộ đếm CỐ ĐỊNH trong lõi ARM, chỉ có đúng 1 việc: giữ "giờ hệ thống" (millis()). TIM1 là một ngoại vi HOÀN TOÀN KHÁC — đặc thù riêng của hãng chip (giống GPIOA), với vai trò khác hẳn: tạo ra dạng sóng (PWM, xung vuông tần số bất kỳ) và đo đạc thế giới ngoài (đếm xung ngoài, đo độ rộng xung — gọi là input capture, bài này không đi sâu). Hai vai trò không đổi chỗ được: SysTick không tạo được PWM, TIM1 không thích hợp làm đồng hồ hệ thống toàn cục (mỗi vendor có số lượng TIM khác nhau, không chuẩn hoá như SysTick).

TIM1 có 4 thanh ghi cốt lõi cho bài này:

Thanh ghi Vai trò
CR1 Control Register — bit 0 (CEN) bật/tắt bộ đếm, giống SYST_CSR Bài 4
PSC Prescaler — chia bớt xung nhịp đầu vào TRƯỚC khi bộ đếm nhận
ARR Auto-Reload Register — bộ đếm đếm từ 0 tới đây rồi tự quay về 0 (đặt CHU KỲ)
CCR1 Capture/Compare Register kênh 1 — đặt NGƯỠNG so sánh (đặt ĐỘ RỘNG xung cao trong PWM)

2. Toán prescaler/ARR: 2 cạm bẫy kinh điển

Bộ đếm TIM1 không đếm trực tiếp xung nhịp gốc (thường rất nhanh, 72MHz trên STM32F103) — nó đếm xung nhịp ĐÃ được chia bởi prescaler. Công thức tần số cập nhật (mỗi lần bộ đếm tràn từ ARR về 0):

$$f_{update} = \dfrac{f_{clk}}{(PSC+1)(ARR+1)}$$

Chú ý dấu +1 ở CẢ HAI vế — đây chính là cạm bẫy đầu tiên. Vài ví dụ số thật (với f_clk = 72MHz, xung nhịp APB2 timer thật của STM32F103):

PSC ARR Tần số Độ phân giải duty (ARR+1 bậc)
7199 9999 1 Hz 10.000 bậc
71 999 1000 Hz 1.000 bậc (mịn)
7199 9 1000 Hz (CÙNG tần số!) 10 bậc (rất thô)
0 3599 20.000 Hz 3.600 bậc

Hai dòng giữa bảng là điểm mấu chốt: cùng một tần số 1000Hz có thể đạt được bằng 2 cặp PSC/ARR hoàn toàn khác nhau — nhưng độ phân giải duty (số bậc sáng LED có thể phân biệt được) lệch nhau tới 100 lần. Số đo thật này lấy trực tiếp từ self-test node vmcu.js (Mục 5).

⚠️ Cạm bẫy kép: quên "+1" và chọn PSC quá lớn
(1) Quên "+1": nếu code tính tần số bằng f_clk / (PSC * ARR) (bỏ quên cả 2 dấu +1) thay vì công thức đúng, hậu quả xuất hiện NGAY LẬP TỨC khi PSC = 0 (giá trị prescaler mặc định/tối thiểu, nghĩa thật là "chia cho 1") — công thức sai tính ra f_clk / 0, một phép chia cho 0 (verify bằng self-test: kết quả Infinity). (2) Chọn PSC quá lớn: để đạt tần số thấp (nghe được bằng tai, cỡ trăm Hz), nếu chọn PSC lớn thay vì ARR lớn, ARR còn lại quá nhỏ — như dòng 3 trong bảng trên, PWM chỉ còn 10 bậc sáng phân biệt được thay vì 1000 bậc, LED nhấp nháy giật cục thay vì mờ dần mượt mà. Nguyên tắc: ưu tiên ARR lớn, PSC nhỏ nhất có thể đạt tần số mong muốn.

3. PWM — giả lập analog bằng digital thuần

PWM là kỹ thuật bật/tắt một chân digital cực nhanh, với tỷ lệ thời gian ở mức cao trong 1 chu kỳ gọi là duty cycle:

$$D = \dfrac{CCR}{ARR}$$

LED nối vào chân PWM có vẻ "mờ dần" không phải vì điện áp thay đổi (vẫn chỉ 0V/3.3V) — mà vì mắt người tích phân ánh sáng theo thời gian: nếu chu kỳ đủ nhanh (trên vài trăm Hz, mắt không phân biệt được nhấp nháy riêng lẻ), LED bật 25% thời gian trong 1 chu kỳ trông mờ hơn LED bật 75% thời gian, dù về bản chất điện, LED vẫn chỉ ở 1 trong 2 trạng thái tại bất kỳ thời điểm nào. Buzzer thì khác — tai không "tích phân" theo cách đó, mà nghe được TẦN SỐ dao động trực tiếp là cao độ âm thanh; đó là lý do PWM điều khiển buzzer chủ yếu qua tần số (Mục 2), còn PWM điều khiển LED chủ yếu qua duty cycle.

🔬 Đào sâu: vì sao PWM không thay được DAC cho audio chất lượng cao?
PWM đủ tốt cho LED (mắt tích phân chậm) và động cơ/buzzer đơn giản (chỉ cần 1 tần số cố định) — nhưng KHÔNG thể phát nhạc/giọng nói chất lượng cao trực tiếp qua 1 chân PWM đơn giản như trong bài này, vì âm thanh thật là tổng của NHIỀU tần số cùng lúc thay đổi liên tục theo thời gian, cần một DAC thật xuất ra điện áp LIÊN TỤC (hoặc PWM tần số RẤT cao kết hợp bộ lọc analog phía sau — về bản chất là "giả lập DAC bằng PWM", đúng chủ đề lượng tử hoá & lọc analog của Series 14 DSP). Đây là lý do loa/tai nghe chất lượng dùng DAC chuyên dụng, còn PWM chỉ đủ cho buzzer 1 tông đơn giản như bài này.

4. Ứng dụng kinh điển: điều khiển servo RC

Servo RC (loại phổ biến trong robot/mô hình) đọc góc quay từ ĐỘ RỘNG XUNG (không phải duty cycle phần trăm): khung tín hiệu lặp lại mỗi 20ms (50Hz), độ rộng xung mức cao từ 1ms đến 2ms mã hoá góc quay từ 0° đến 180° (1.5ms thường là góc giữa 90°). Tính PSC/ARR để có tick đúng 1 micro-giây giúp việc tính CCR cực đơn giản — không cần quy đổi gì thêm:

servo_tim1_calc.c (PSC/ARR/CCR cho servo 50Hz)
// f_clk = 72MHz. Chon PSC=71 -> tick = 72MHz/(71+1) = 1MHz -> 1 tick = 1us.
#define TIM1_PSC (*(volatile uint32_t *)0x40012C28)
#define TIM1_ARR (*(volatile uint32_t *)0x40012C2C)
#define TIM1_CCR1 (*(volatile uint32_t *)0x40012C34)

TIM1_PSC = 71;      // tick = 1us
TIM1_ARR = 19999;   // chu ky = 20000 tick = 20000us = 20ms = 50Hz (dung chuan servo)

TIM1_CCR1 = 1000;   // 1000 tick = 1000us = 1ms  -> goc servo MIN  (0 do)
TIM1_CCR1 = 1500;   // 1500 tick = 1500us = 1.5ms -> goc servo GIUA (90 do)
TIM1_CCR1 = 2000;   // 2000 tick = 2000us = 2ms   -> goc servo MAX  (180 do)

Vì tick đã đúng 1 micro-giây, CCR1 chính là số micro-giây của độ rộng xung — không cần quy đổi. Chiều ngược lại (đo độ rộng xung ĐẾN từ bên ngoài, gọi là input capture) cũng tồn tại trên cùng phần cứng TIM, nhưng bài này không đi sâu.

💡 Mẹo: chọn PSC để tick tròn số (1us hoặc 1ms)
Một mẹo thực dụng khi tính PSC/ARR: chọn PSC sao cho tick đúng 1 micro-giây (như servo ở trên) hoặc 1 mili-giây — khi đó mọi phép tính CCR/ARR chỉ còn là số micro-giây/mili-giây thuần tuý, không cần quy đổi qua lại. Với f_clk = 72MHz, chọn PSC = 71 luôn cho tick 1us (vì 72.000.000 / 72 = 1.000.000 tick/giây = 1MHz); đây là lý do PSC = 71 xuất hiện lặp lại trong cả bảng ví dụ Mục 2 lẫn tính toán servo Mục 4.

5. VMCU: TIM1 + PWM đã "sống"

VMCU thêm ngoại vi TIM1 vào vùng Peripheral (địa chỉ 0x40012C00, đúng số thật STM32F103), với 4 thanh ghi ở Mục 1. VMCU KHÔNG mô phỏng bộ đếm chạy sống theo thời gian thực (không cần cho bài này — chỉ cần TÍNH tần số/duty từ 4 thanh ghi, khác SysTick cần đếm tick thật):

vmcu_tim1.js (trích engine vmcu.js)
tim1Frequency() {
  // dung CONG THUC DUNG - co "+1" o ca 2 thanh phan
  return TIM1_CLK_HZ / ((this.tim1Psc + 1) * (this.tim1Arr + 1));
}

tim1DutyCycle() {
  if (this.tim1Arr === 0) return 0;
  return this.tim1Ccr1 / this.tim1Arr; // duty = CCR/ARR
}

Số đo thật từ self-test (node vmcu.js) xác nhận cạm bẫy Mục 2:

Phép tính Kết quả
Công thức ĐÚNG, PSC=0, ARR=3599 20.000 Hz
Công thức SAI (quên +1), cùng PSC=0, ARR=3599 Infinity (chia cho 0)
Servo: PSC=71, ARR=19999 → tần số khung 50 Hz (đúng chuẩn servo)
Servo: CCR1=1000 → độ rộng xung 1000us = 1ms
Servo: CCR1=2000 → độ rộng xung 2000us = 2ms

6. Thực hành: kéo 3 thanh trượt, NGHE tần số thật

Kéo PSC/ARR/CCR bên dưới — công thức cập nhật SỐ THẬT theo tay kéo, LED đổi độ sáng theo duty cycle, và bấm "Phát âm" để NGHE buzzer đổi cao độ thật theo tần số tính được (dùng Web Audio, sóng vuông — đúng dạng sóng PWM thật):

🎛️ TIM1 PWM — kéo thanh trượt, nghe thật

LED (duty)
Đang khởi tạo…

Kéo ARR/CCR khi PSC nhỏ để nghe âm cao (tần số lớn); kéo PSC lớn để nghe âm trầm (tần số nhỏ) — nhưng chú ý độ phân giải duty (số bậc sáng LED) giảm mạnh khi PSC lớn, đúng cạm bẫy Mục 2.

pwm_led_buzzer.c
// Dung o tab "Xem truoc": PSC/ARR/CCR nguoi dung keo tuong ung 3 thanh ghi that
TIM1_PSC = psc_slider_value;
TIM1_ARR = arr_slider_value;
TIM1_CCR1 = ccr_slider_value;
TIM1_CR1 |= (1 << 0); // bat CEN

// Tan so xuat ra buzzer, duty cycle dieu khien do sang LED
float freq = 72000000.0f / ((psc_slider_value + 1.0f) * (arr_slider_value + 1.0f));
float duty = (float)ccr_slider_value / (float)arr_slider_value;
pwm_demo.js (đúng logic đang chạy ở tab Xem trước)
import { VMCU, TIM1_PSC_ADDR, TIM1_ARR_ADDR, TIM1_CCR1_ADDR } from './vmcu.js';

const cpu = new VMCU();

function onSliderChange(psc, arr, ccr) {
  cpu.write32(TIM1_PSC_ADDR, psc);
  cpu.write32(TIM1_ARR_ADDR, arr);
  cpu.write32(TIM1_CCR1_ADDR, ccr);

  const freq = cpu.tim1Frequency();  // f_clk / ((PSC+1)(ARR+1))
  const duty = cpu.tim1DutyCycle();  // CCR / ARR

  led.style.background = duty > 0 ? `rgba(34,197,94,${duty})` : '#334155';
  oscillator.frequency.value = freq; // sóng vuông Web Audio = PWM thật

Tóm lược

  • TIM1 khác SysTick (Bài 4): SysTick giữ giờ hệ thống cố định trong lõi ARM, TIM1 tạo dạng sóng/đo đạc — đặc thù riêng hãng chip, không đổi vai cho nhau được.
  • ✅ Công thức tần số f = f_clk/((PSC+1)(ARR+1)) — quên dấu "+1" gây chia cho 0 khi PSC=0 (verified: Infinity).
  • ✅ Cùng 1 tần số có thể đạt bằng nhiều cặp PSC/ARR khác nhau, nhưng độ phân giải duty (ARR+1 bậc) lệch nhau rất lớn — ưu tiên ARR lớn, PSC nhỏ.
  • PWM: duty = CCR/ARR — LED mờ dần nhờ mắt tích phân ánh sáng, buzzer đổi cao độ nhờ tai nghe trực tiếp tần số. Đủ tốt cho LED/động cơ/buzzer, KHÔNG thay được DAC cho audio chất lượng cao.
  • ✅ Servo RC: khung 50Hz, xung 1-2ms mã hoá góc — verified: PSC=71/ARR=19999 cho tick=1us, CCR=1000/2000 cho đúng 1ms/2ms.

Trắc nghiệm ôn tập

Câu 1

Vai trò của TIM1 khác gì so với SysTick (Bài 4)?

Câu 2

Nếu tính tần số bằng công thức SAI f_clk / (PSC * ARR) (quên cả 2 dấu "+1"), điều gì xảy ra khi PSC = 0?

Câu 3

PSC=71/ARR=999 và PSC=7199/ARR=9 đều cho ra CÙNG tần số 1000Hz. Vì sao vẫn nên chọn cặp đầu tiên?

Câu 4

Với PSC=71 (tick=1us), ARR=19999 (khung 50Hz), cần đặt CCR1 bằng bao nhiêu để tạo xung 1.5ms (góc servo giữa, 90°)?

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 6 vừa thêm TIM1 (PSC, ARR, CCR1, tính tần số/duty cycle PWM), 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 5: Chống dội phím & Máy trạng thái (FSM) Bài 7: Ngắt (Interrupt) & NVIC Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận