Mở đầu: nút nhấn "ma ám" — không đụng vào cũng tự nhảy trạng thái

Một mạch demo đơn giản: nối một nút nhấn vào chân GPIO, đọc giá trị chân đó để biết nút có được bấm hay không. Không ai chạm vào nút cả — nhưng LED báo trạng thái cứ nhấp nháy loạn xạ, đôi khi đứng yên cả phút, đôi khi đổi liên tục trong vài giây. Không có bug logic nào trong code. Vấn đề nằm ở chỗ hoàn toàn khác: chân đó đang "lửng lơ" về điện — không nối rõ ràng với mức cao (VCC) hay mức thấp (GND) nào cả, nên nó bắt lấy bất kỳ nhiễu điện từ nào bay ngang qua (dây dẫn gần đó, sóng Wi-Fi, thậm chí bàn tay người đưa lại gần) và đọc ra một giá trị hoàn toàn ngẫu nhiên.

Bài 2 dạy cách MCU output ra thế giới. Bài này dạy chiều ngược lại — cách MCU nghe thế giới bên ngoài một cách đáng tin cậy: điện trở kéo nội (pull-up/pull-down), vì sao gần như mọi nút nhấn thực tế đều đấu theo kiểu "active-low", và cách polling một chân trong vòng lặp. Cuối bài, VMCU có thêm khả năng đọc input thật — và bạn sẽ tự tay tắt điện trở kéo để chứng kiến chính hiện tượng nhiễu ngẫu nhiên ở đầu bài, chạy trên engine thật chứ không phải minh hoạ giả định.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 2 (thanh ghi, RMW, MODER/ODR). Nên có: Series Điện Tử — khái niệm điện trở và định luật Ohm giúp hiểu vì sao cần "kéo" điện áp; không bắt buộc vì bài này nhắc lại ý tưởng cốt lõi khi cần.

1. Chân floating: khi không ai quyết định mức điện áp

Một chân GPIO cấu hình input (Bài 2, MODER = 00) chỉ "lắng nghe" điện áp tại chân đó — bản thân nó không chủ động đẩy dòng ra. Nếu chân này không nối với bất kỳ thứ gì khác (không nút nhấn, không cảm biến, không điện trở), nó ở trạng thái gọi là floating (lửng lơ, thả nổi). Về mặt vật lý, đường dây kim loại dẫn tới chân đó hoạt động như một cái ăng-ten tí hon — bắt lấy nhiễu điện từ môi trường xung quanh và khiến điện áp dao động ngẫu nhiên quanh ngưỡng phân biệt 0/1 của MCU. Kết quả: mỗi lần CPU đọc chân này, nó có thể ra 0 hoặc 1 hoàn toàn tuỳ hứng.

Giải pháp: gắn một điện trở kéo (pull resistor) — điện trở giá trị lớn (chục đến trăm kΩ) nối chân đó về VCC hoặc GND, đủ yếu để không cản trở tín hiệu thật khi có, nhưng đủ mạnh để "quyết định thay" mức điện áp mặc định khi không có gì khác tác động. MCU hiện đại có sẵn điện trở này ngay bên trong chip (không cần hàn thêm linh kiện!) — bật/tắt qua thanh ghi PUPDR, 2 bit cho mỗi chân:

Giá trị 2-bit Chế độ Hiệu ứng
00 No pull (mặc định reset) Không kéo gì cả — floating nếu không có gì khác nối vào chân
01 Pull-up Kéo lên VCC — đọc mặc định ra 1 khi không có gì khác tác động
10 Pull-down Kéo xuống GND — đọc mặc định ra 0 khi không có gì khác tác động
pupdr_config.c
#define GPIOA_PUPDR (*(volatile uint32_t *)0x40010804)

// Bat pull-up cho chan 1 (RMW dung ky thuat Bai 2: xoa 2 bit cu, dat 2 bit moi)
GPIOA_PUPDR = (GPIOA_PUPDR & ~(0b11 << (1 * 2))) | (0b01 << (1 * 2));

2. Active-low vs active-high: vì sao nút nhấn thường "ngược đời"

Khi đấu một nút nhấn vào chân GPIO, có 2 quy ước hoàn toàn hợp lệ. Nghe tên gọi active-low lần đầu dễ thấy phản trực giác — "nhấn nút mà đọc ra 0?" — nhưng đây lại là quy ước phổ biến nhất trong thực tế, đúng vì lý do điện trở kéo vừa học:

Kiểu đấu Cấu hình PUPDR Nút nhấn nối chân với Đọc khi KHÔNG nhấn Đọc khi ĐANG nhấn
Active-low (phổ biến nhất) Pull-up GND 1 0
Active-high Pull-down VCC 0 1

Lý do active-low thắng thế: MCU nào cũng có sẵn điện trở kéo NỘI (không tốn linh kiện), và quy ước này chỉ cần nút nhấn nối thẳng chân xuống GND — mọi board MCU đều có chân GND ở khắp nơi, dễ đấu dây hơn hẳn so với phải kéo dây riêng tới VCC cho từng nút. Khi đọc thấy tài liệu hoặc code người khác viết if (!gpio_read(PIN_BUTTON)) (đọc dấu phủ định), gần như chắc chắn đó là quy ước active-low.

3. Polling: đọc chân trong vòng lặp

Cách đơn giản nhất để phát hiện nút được nhấn là polling — liên tục đọc thanh ghi IDR trong vòng lặp main(), so sánh với lần đọc trước để phát hiện thời điểm chuyển trạng thái (cạnh xuống với active-low):

button_polling.c
#define GPIOA_IDR (*(volatile uint32_t *)0x40010808)
#define BUTTON_PIN 1

int last_state = 1; // active-low: nha nut = 1

while (1) {
    int current_state = (GPIOA_IDR >> BUTTON_PIN) & 1;
    if (last_state == 1 && current_state == 0) {
        // Vua bat duoc CANH XUONG - nut vua duoc nhan
        handle_button_press();
    }
    last_state = current_state;
}

Polling đơn giản, dễ hiểu, nhưng có 2 giới hạn sẽ quay lại ở các bài sau: nó chiếm dụng CPU liên tục (không làm gì khác được trong lúc chờ — Bài 7 sẽ giới thiệu ngắt như một lựa chọn hiệu quả hơn), và tín hiệu nút nhấn thật có hiện tượng chống dội (bounce) — một lần nhấn vật lý tạo ra hàng chục cạnh lên/xuống giả trong vài mili-giây, khiến code trên phát hiện NHIỀU lần nhấn cho một cú bấm thật — chi tiết đầy đủ ở Bài 5.

⚠️ Cạm bẫy: quên cấu hình điện trở kéo — floating đọc ra rác
Đây là lỗi cấu hình phổ biến nhất khi mới học GPIO input: cấu hình đúng chế độ INPUT (MODER) nhưng quên bật PUPDR (để mặc định 00 — no pull). Nút nhấn hoạt động "có vẻ đúng" khi test nhanh vài lần (vì lúc nhấn, nút chủ động ép mức điện áp, ghi đè hẳn trạng thái floating), nhưng lúc KHÔNG nhấn, chân floating đọc ra giá trị ngẫu nhiên — chương trình tưởng nhầm là "đang nhấn liên tục" hoặc bỏ sót sự kiện tuỳ may rủi. Bug dạng này đặc biệt khó chịu vì nó không tái hiện ổn định — có lúc chạy đúng cả ngày, có lúc lỗi ngay phút đầu, tuỳ nhiễu điện từ môi trường ngay lúc đó. Luôn tự hỏi với MỌI chân input: "nếu không có gì nối vào đây, nó sẽ đọc ra gì?" — nếu câu trả lời là "không biết", đó là floating, phải bật pull-up hoặc pull-down. Thử ngay ở demo Mục 4.

4. VMCU: GPIOA vừa đọc được thế giới bên ngoài

VMCU giờ có thêm 2 thanh ghi: PUPDR (địa chỉ 0x40010804, cấu hình điện trở kéo) và IDR (địa chỉ 0x40010808, chỉ đọc — phản ánh mức điện áp thật tại chân). Khác với MODER/ODR (lưu trạng thái cố định), IDR được tính lại MỖI LẦN đọc — đúng bản chất một chân input thật: giá trị của nó phụ thuộc điều gì đang xảy ra ở thế giới bên ngoài NGAY LÚC đọc, không phải một ô nhớ tĩnh.

vmcu_read_pin.js (trích engine vmcu.js — logic đang chạy ở demo Mục 5)
// Muc logic THAT SU tai 1 chan, dung THU TU UU TIEN cua phan cung that:
gpioaReadPin(pin) {
  if (this.gpioaPinMode(pin) === GPIO_MODE_OUTPUT) {
    return (this.gpioaOdr >>> pin) & 1;             // 1. output: IDR = ODR
  }
  const drive = this.externalDrive[pin];
  if (drive === 0 || drive === 1) return drive;     // 2. mach ngoai (nut nhan) LUON thang
  const pull = this.gpioaPinPull(pin);
  if (pull === GPIO_PULL_UP) return 1;              // 3. dien tro keo quyet dinh
  if (pull === GPIO_PULL_DOWN) return 0;
  return Math.random() < 0.5 ? 0 : 1;               // 4. floating - NHIEU THAT
🔬 Đào sâu: vì sao mạch ngoài LUÔN thắng điện trở kéo nội?
Thứ tự ưu tiên trong gpioaReadPin() không phải quy ước tuỳ tiện — nó phản ánh đúng định luật Ohm. Điện trở kéo nội cố tình chọn giá trị LỚN (chục đến trăm kΩ) để dòng điện nó tạo ra rất yếu; một nút nhấn nối thẳng xuống GND (gần như 0Ω) tạo ra đường dẫn dòng điện mạnh hơn hẳn. Khi cả hai cùng tác động lên một điểm, đường dẫn có trở kháng thấp hơn luôn "thắng" về mặt điện áp — đây chính là lý do điện trở kéo được gọi là "yếu" (weak pull), cố tình thiết kế để dễ dàng bị ghi đè bởi bất kỳ tín hiệu chủ động nào khác. Nếu điện trở kéo có giá trị NHỎ (vài Ω) thay vì lớn, nút nhấn sẽ phải "vật lộn" với nó, gây sụt áp không dứt khoát ở cả hai mức — đây cũng là lý do tài liệu MCU luôn ghi rõ giá trị điện trở kéo nội (thường 30–50 kΩ) thay vì để mặc định tuỳ ý.

5. Thực hành: nút nhấn ảo — tắt pull-up để thấy nhiễu thật

Demo dưới đây mô phỏng một nút nhấn đấu active-low vào chân PA1 của VMCU thật — không phải hoạt ảnh. Đọc IDR được cập nhật sống mỗi 150ms để bạn thấy rõ hiện tượng "nhấp nháy ngẫu nhiên" khi floating không phải một khoảnh khắc đơn lẻ mà là liên tục theo thời gian:

🔘 Nút nhấn ảo — PUPDR/IDR trên VMCU thật
PA1 (IDR bit) = ?
Đang khởi tạo…

Để nguyên "Không pull" và ĐỪNG nhấn nút — quan sát PA1 tự nhấp nháy 0/1 ngẫu nhiên dù không ai chạm vào (đúng cạm bẫy Mục 3). Chuyển sang "Pull-up" — PA1 ổn định về 1, chỉ xuống 0 đúng lúc bạn giữ nút. Bảng dưới tổng kết mọi tổ hợp:

Cấu hình PUPDR Không nhấn nút Đang giữ nút Đánh giá
Không pull (floating) ❌ Ngẫu nhiên, không đoán trước được ✅ Đọc đúng 0 (nút thắng) ❌ Sai — chỉ đúng lúc nhấn, sai lúc nhả
Pull-up (active-low) ✅ Ổn định = 1 ✅ Ổn định = 0 ✅ Đúng — quy ước phổ biến nhất
Pull-down (dùng sai kiểu đấu) ✅ Ổn định = 0 ✅ Ổn định = 0 (nút vẫn kéo GND, trùng pull-down) ❌ Sai — không phân biệt được nhấn/không nhấn
button_active_low.c
#include <stdint.h>

#define GPIOA_PUPDR (*(volatile uint32_t *)0x40010804)
#define GPIOA_IDR   (*(volatile uint32_t *)0x40010808)
#define BUTTON_PIN 1

int main(void) {
    // MODER cho PA1 da la INPUT (00) mac dinh, khong can cau hinh gi them
    // Bat pull-up cho PA1 (active-low: nut noi xuong GND khi nhan)
    GPIOA_PUPDR = (GPIOA_PUPDR & ~(0b11 << (BUTTON_PIN * 2))) | (0b01 << (BUTTON_PIN * 2));

    while (1) {
        int pressed = ((GPIOA_IDR >> BUTTON_PIN) & 1) == 0; // active-low: 0 = dang nhan
        // ...
    }
}
pullup_demo.js (đúng logic đang chạy ở tab Xem trước)
import { VMCU, GPIOA_PUPDR_ADDR, GPIO_PULL_UP } from './vmcu.js';

const cpu = new VMCU();
const pin = 1;

// Bat pull-up cho PA1
cpu.write32(GPIOA_PUPDR_ADDR, GPIO_PULL_UP << (pin * 2));

// Doc song moi 150ms - floating se thay nhay ngau nhien, pull-up se on dinh
setInterval(() => {
  console.log(cpu.gpioaReadPin(pin));
}, 150);

// Gia lap GIU nut (active-low): noi chan xuong GND
cpu.setExternalDrive(pin, 0);
// Gia lap NHA nut: go bo, tra lai cho pull-up/down quyet dinh
cpu.setExternalDrive(pin, null);

Tóm lược

  • ✅ Chân input floating (không nối gì) bắt nhiễu điện từ và đọc ra giá trị NGẪU NHIÊN — không phải bug, mà là hệ quả vật lý tất yếu.
  • PUPDR (pull-up/pull-down) là điện trở kéo NỘI trong chip, ép một mức logic mặc định khi không có gì khác tác động — giải quyết dứt điểm hiện tượng floating.
  • Active-low (pull-up + nút nối GND) là quy ước phổ biến nhất trong thực tế vì tận dụng điện trở kéo có sẵn và dễ đấu dây hơn active-high.
  • Polling đọc IDR trong vòng lặp phát hiện cạnh chuyển trạng thái — đơn giản nhưng chiếm CPU liên tục và chưa xử lý chống dội (hẹn Bài 5).
  • ✅ VMCU vừa đọc được thế giới ngoài: IDR tính "sống" mỗi lần đọc theo đúng thứ tự ưu tiên output → mạch ngoài → pull → floating.

Trắc nghiệm ôn tập

Câu 1

Một chân GPIO cấu hình INPUT, không nối với bất kỳ thứ gì, và PUPDR để mặc định (no pull). Đọc chân này nhiều lần liên tiếp sẽ cho kết quả gì?

Câu 2

Vì sao active-low (pull-up + nút nối GND) là quy ước phổ biến nhất?

Câu 3

Trên VMCU, nếu một chân đang ở chế độ INPUT với pull-up BẬT, nhưng đồng thời có "externalDrive" (mạch ngoài, ví dụ nút nhấn đang giữ) ép mức 0 vào chân đó — gpioaReadPin() trả về giá trị nào?

Câu 4

Giới hạn chính của kỹ thuật polling (đọc IDR trong vòng lặp) 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 3 vừa thêm PUPDR/IDR (pull-up/down, floating, đọc pin), 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 2: Thanh ghi & Memory-Mapped I/O Bài 4: SysTick & thời gian: thoát khỏi delay blocking Quay lại Lộ trình Series Hệ Thống Nhúng

Bình luận