🔌 Lập trình nhúng với Arduino & ESP32
LED, nút nhấn, cảm biến, PWM, servo, động cơ, Serial, ngắt, I2C, WiFi, web server, MQTT — từ Blink tới nhà thông minh IoT.
?Khóa học dành cho ai?
Học sinh THCS–THPT đã biết lập trình cơ bản (Python, C++ hoặc kéo thả) muốn làm sản phẩm thật: robot, nhà thông minh, trạm quan trắc, dự án thi Khoa học kỹ thuật. Khóa dùng Arduino IDE (ngôn ngữ C/C++), áp dụng cho cả Arduino Uno và ESP32.
🧰 Bộ linh kiện gợi ý
ESP32 DevKit, breadboard, dây cắm, LED, điện trở, nút nhấn, biến trở, LDR, DHT22, HC-SR04, servo SG90, LCD I2C, driver động cơ.
▶ Chạy được ngay cả khi chưa có linh kiện
Mọi sketch trong khóa có kết quả từ bộ mô phỏng Arduino viết riêng cho khóa: thấy rõ chân nào bật lúc mấy ms, Serial in gì.
🏠 Dự án IoT
Kết thúc bằng nhà thông minh điều khiển qua app, dữ liệu lên Internet bằng MQTT.
⌚Lộ trình gợi ý — 12 buổi
🔌 Làm quen Arduino, ESP32 và chương trình Blink
Hiểu vi điều khiển là gì, chọn board phù hợp, cài Arduino IDE và nạp chương trình đầu tiên.
- Phân biệt Arduino Uno và ESP32
- Cài Arduino IDE, thêm board ESP32, chọn cổng COM
- Hiểu cấu trúc setup() / loop()
- Nạp và quan sát chương trình Blink
1Vi điều khiển là gì?
Vi điều khiển (microcontroller) là một máy tính tí hon trên một con chip: có CPU, bộ nhớ và các chân vào/ra để đọc cảm biến, điều khiển đèn, động cơ. Arduino là nền tảng giúp lập trình vi điều khiển dễ dàng (board + phần mềm + thư viện). ESP32 mạnh hơn nhiều và có sẵn WiFi, Bluetooth — lựa chọn số 1 cho dự án IoT, lập trình bằng chính Arduino IDE.
2Cấu trúc chương trình và Arduino IDE
- Tải Arduino IDE 2 tại arduino.cc. Với ESP32: File → Preferences → Additional boards URL:
https://espressif.github.io/arduino-esp32/package_esp32_index.json, rồi Boards Manager → cài “esp32 by Espressif”. - Cắm board, chọn Tools → Board và Tools → Port. Không thấy cổng COM: cài driver CH340 hoặc CP2102.
- Bấm Upload. Một số board ESP32 cần giữ nút BOOT khi hiện “Connecting…”.
3Blink – chương trình đầu tiên
// Bài 1 – Blink: "Hello world" của vi điều khiển. Nháy LED có sẵn trên board mỗi 500 ms.
// Arduino Uno: LED_BUILTIN là chân 13. ESP32 DevKit: LED xanh ở GPIO 2 (nếu board có LED).
void setup() { // chạy MỘT lần khi cấp điện hoặc nhấn reset
pinMode(LED_BUILTIN, OUTPUT); // khai báo chân là ngõ RA
Serial.begin(115200); // mở cổng Serial để in thông tin lên máy tính
Serial.println("Board đã khởi động!");
}
void loop() { // lặp lại MÃI MÃI
digitalWrite(LED_BUILTIN, HIGH); // xuất mức cao (5 V / 3.3 V) -> LED sáng
delay(500); // chờ 500 ms
digitalWrite(LED_BUILTIN, LOW); // xuất mức thấp (0 V) -> LED tắt
delay(500);
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)Board đã khởi động! ⚡ [ 0 ms] chân 13 (LED) = HIGH ⚡ [ 500 ms] chân 13 (LED) = LOW ⚡ [ 1000 ms] chân 13 (LED) = HIGH ⚡ [ 1500 ms] chân 13 (LED) = LOW ⚡ [ 2000 ms] chân 13 (LED) = HIGH ── hết mô phỏng tại 2200 ms ──
Giải thích
- Kết quả ở đây chạy trên bộ mô phỏng của khóa: dòng chữ thường là những gì in ra Serial Monitor, dòng
⚡là trạng thái chân thay đổi kèm thời điểm. pinMode(chân, OUTPUT)phải gọi trước khidigitalWrite.delay(500)dừng toàn bộ chương trình 500 ms — đơn giản nhưng sẽ gây vấn đề ở bài 8.
⚠Lỗi thường gặp
- Chọn sai board/cổng COM → lỗi upload.
- Dùng cáp USB chỉ sạc (không có dây dữ liệu).
- Quên tốc độ baud Serial Monitor khớp với Serial.begin().
✎Bài tập về nhà
🚦 Ngõ ra số: LED, điện trở và đèn giao thông
Điều khiển LED an toàn: tính điện trở hạn dòng, nối mạch đúng và viết chương trình đèn giao thông.
- Đọc sơ đồ mạch LED, tính điện trở
- Dùng breadboard
- Viết hàm phụ để code gọn
1Mạch LED
Mỗi chân vi điều khiển chỉ chịu dòng nhỏ (Uno ~20 mA, ESP32 ~12 mA khuyến nghị). LED luôn phải có điện trở hạn dòng nối tiếp. Tải lớn (động cơ, đèn 12 V, quạt) phải qua transistor, MOSFET hoặc relay.
2Đèn giao thông
// Bài 2 – Đèn giao thông: 3 LED đỏ / vàng / xanh, mỗi LED nối nối tiếp một điện trở 220 Ω xuống GND.
const int DEN_DO = 4;
const int DEN_VANG = 5;
const int DEN_XANH = 6;
// Hàm phụ: bật đúng một đèn, tắt hai đèn còn lại
void batDen(int den) {
digitalWrite(DEN_DO, den == DEN_DO ? HIGH : LOW);
digitalWrite(DEN_VANG, den == DEN_VANG ? HIGH : LOW);
digitalWrite(DEN_XANH, den == DEN_XANH ? HIGH : LOW);
}
void setup() {
pinMode(DEN_DO, OUTPUT);
pinMode(DEN_VANG, OUTPUT);
pinMode(DEN_XANH, OUTPUT);
Serial.begin(115200);
}
void loop() {
Serial.println("XANH: được đi");
batDen(DEN_XANH);
delay(3000);
Serial.println("VÀNG: chuẩn bị dừng");
batDen(DEN_VANG);
delay(1000);
Serial.println("ĐỎ: dừng lại");
batDen(DEN_DO);
delay(3000);
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)XANH: được đi ⚡ [ 0 ms] chân 4 (đèn ĐỎ) = LOW ⚡ [ 0 ms] chân 5 (đèn VÀNG) = LOW ⚡ [ 0 ms] chân 6 (đèn XANH) = HIGH VÀNG: chuẩn bị dừng ⚡ [ 3000 ms] chân 5 (đèn VÀNG) = HIGH ⚡ [ 3000 ms] chân 6 (đèn XANH) = LOW ĐỎ: dừng lại ⚡ [ 4000 ms] chân 4 (đèn ĐỎ) = HIGH ⚡ [ 4000 ms] chân 5 (đèn VÀNG) = LOW XANH: được đi ⚡ [ 7000 ms] chân 4 (đèn ĐỎ) = LOW ⚡ [ 7000 ms] chân 6 (đèn XANH) = HIGH ── hết mô phỏng tại 7500 ms ──
Giải thích
- Hàm
batDen()đảm bảo luôn chỉ một đèn sáng — tránh lặp code 3 lần ở mỗi pha. - Bộ mô phỏng chỉ ghi lại khi trạng thái chân THAY ĐỔI.
✎Bài tập về nhà
🔘 Ngõ vào số: nút nhấn, điện trở kéo lên, chống dội
Đọc nút nhấn ổn định, hiểu vì sao cần điện trở kéo lên/xuống và chống dội phím.
- digitalRead và INPUT_PULLUP
- Hiện tượng dội phím và cách chống
- Phát hiện cạnh (vừa nhấn) thay vì mức
1Nút nhấn và điện trở kéo lên
2Chống dội phím
// Bài 3 – Nút nhấn: đọc ngõ vào số, điện trở kéo lên trong (INPUT_PULLUP), chống dội phím, bật/tắt LED.
// Nối: một chân nút vào chân 2, chân kia xuống GND. Không nhấn = HIGH, nhấn = LOW.
const int NUT = 2;
const int LED = 13;
const unsigned long CHONG_DOI_MS = 30;
bool trangThaiLed = false;
int trangThaiOnDinh = HIGH; // trạng thái nút đã được xác nhận
int lanDocTruoc = HIGH;
unsigned long lanDoiCuoi = 0;
int soLanNhan = 0;
void setup() {
pinMode(NUT, INPUT_PULLUP);
pinMode(LED, OUTPUT);
Serial.begin(115200);
}
void loop() {
int doc = digitalRead(NUT);
if (doc != lanDocTruoc) { // tín hiệu vừa thay đổi (có thể do nảy tiếp điểm)
lanDoiCuoi = millis();
lanDocTruoc = doc;
}
// Chỉ tin trạng thái mới nếu nó giữ nguyên đủ 30 ms
if (millis() - lanDoiCuoi > CHONG_DOI_MS && doc != trangThaiOnDinh) {
trangThaiOnDinh = doc;
if (trangThaiOnDinh == LOW) { // cạnh xuống = vừa nhấn
soLanNhan++;
trangThaiLed = !trangThaiLed;
digitalWrite(LED, trangThaiLed);
Serial.print("Nhấn lần ");
Serial.println(soLanNhan);
}
}
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)⚡ [ 339 ms] chân 13 (LED) = HIGH Nhấn lần 1 ⚡ [ 939 ms] chân 13 (LED) = LOW Nhấn lần 2 ⚡ [ 1639 ms] chân 13 (LED) = HIGH Nhấn lần 3 ── hết mô phỏng tại 2300 ms ──
Giải thích
- Kịch bản mô phỏng: nhấn 3 lần lúc 300, 900, 1600 ms; mỗi lần tiếp điểm nảy loạn 8 ms đầu.
- Không chống dội, mỗi lần nhấn có thể bị đếm thành 3–5 lần. Ở đây LED đổi trạng thái đúng 3 lần.
- Chỉ xử lý ở cạnh xuống (HIGH → LOW) nên giữ nút lâu cũng chỉ tính một lần.
⚠Lỗi thường gặp
- Dùng INPUT không có điện trở kéo → đọc lung tung.
- Nối nút giữa chân và 5 V khi dùng INPUT_PULLUP → không bao giờ thấy LOW.
✎Bài tập về nhà
🎚️ Ngõ vào analog: ADC, biến trở, quang trở
Đọc tín hiệu liên tục (ánh sáng, vị trí biến trở, điện áp) bằng ADC và quy đổi sang đại lượng thật.
- Hiểu ADC 10 bit / 12 bit
- Cầu phân áp với cảm biến điện trở
- Đổi giá trị ADC ra volt, phần trăm bằng map()
1Cầu phân áp và ADC
analogRead() trả về số tỉ lệ với điện áp ở chân: ESP32 0–4095 cho 0–3.3 V (12 bit), Uno 0–1023 cho 0–5 V (10 bit). Cảm biến dạng điện trở (LDR, NTC, cảm biến uốn) cần thêm một điện trở để tạo cầu phân áp.
2Đèn ngủ tự động
// Bài 4 – Ngõ vào analog: quang trở (LDR) làm đèn ngủ tự động, biến trở chỉnh ngưỡng.
// ESP32: ADC 12 bit (0..4095) ở 3.3 V. Arduino Uno: ADC 10 bit (0..1023) ở 5 V.
const int CHAN_LDR = 34; // cầu phân áp: 3V3 – LDR – chân 34 – điện trở 10k – GND (tối -> giá trị nhỏ)
const int CHAN_BIEN_TRO = 35; // chân giữa biến trở
const int DEN = 2;
void setup() {
pinMode(DEN, OUTPUT);
Serial.begin(115200);
}
void loop() {
int anhSang = analogRead(CHAN_LDR);
int nguong = analogRead(CHAN_BIEN_TRO);
float dienAp = anhSang * 3.3 / 4095; // đổi giá trị ADC ra volt
int phanTram = map(anhSang, 0, 4095, 0, 100);
Serial.printf("Ánh sáng: %4d (%.2f V, %3d%%) ngưỡng: %4d -> ", anhSang, dienAp, phanTram, nguong);
if (anhSang < nguong) {
digitalWrite(DEN, HIGH);
Serial.println("TỐI, bật đèn");
} else {
digitalWrite(DEN, LOW);
Serial.println("SÁNG, tắt đèn");
}
delay(500);
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)Ánh sáng: 3200 (2.58 V, 78%) ngưỡng: 1500 -> SÁNG, tắt đèn ⚡ [ 0 ms] chân 2 (đèn ngủ) = LOW Ánh sáng: 2750 (2.22 V, 67%) ngưỡng: 1500 -> SÁNG, tắt đèn Ánh sáng: 2300 (1.85 V, 56%) ngưỡng: 1500 -> SÁNG, tắt đèn Ánh sáng: 1850 (1.49 V, 45%) ngưỡng: 1500 -> SÁNG, tắt đèn Ánh sáng: 1400 (1.13 V, 34%) ngưỡng: 1500 -> TỐI, bật đèn ⚡ [ 2000 ms] chân 2 (đèn ngủ) = HIGH Ánh sáng: 950 (0.77 V, 23%) ngưỡng: 1500 -> TỐI, bật đèn Ánh sáng: 500 (0.40 V, 12%) ngưỡng: 1500 -> TỐI, bật đèn ── hết mô phỏng tại 3100 ms ──
Giải thích
- Kịch bản: trời tối dần, ánh sáng giảm từ 3200 xuống 500; biến trở đặt ngưỡng 1500.
- Đèn bật lúc 2000 ms khi ánh sáng xuống dưới ngưỡng.
- Thực tế nên thêm vùng trễ (hysteresis): bật dưới 1400, tắt trên 1600 để đèn không chập chờn.
✎Bài tập về nhà
〰️ PWM: độ sáng LED, servo và tốc độ động cơ
Tạo “điện áp trung bình” bằng PWM để chỉnh độ sáng, tốc độ động cơ và điều khiển servo.
- Hiểu duty cycle
- analogWrite (Uno) và ledcAttach/ledcWrite (ESP32)
- Điều khiển servo bằng thư viện
- Nối động cơ qua driver
1PWM là gì?
// Bài 5 – PWM: LED "thở" (sáng dần – tối dần).
// Arduino Uno: analogWrite(chân ~PWM, 0..255). ESP32 (core 3.x): ledcAttach + ledcWrite.
const int LED = 9;
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
for (int doSang = 0; doSang <= 255; doSang += 51) { // sáng dần
analogWrite(LED, doSang);
delay(100);
}
for (int doSang = 255; doSang >= 0; doSang -= 51) { // tối dần
analogWrite(LED, doSang);
delay(100);
}
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)⚡ [ 0 ms] chân 9 (LED) PWM = 0 ( 0%) ⚡ [ 100 ms] chân 9 (LED) PWM = 51 ( 20%) ⚡ [ 200 ms] chân 9 (LED) PWM = 102 ( 40%) ⚡ [ 300 ms] chân 9 (LED) PWM = 153 ( 60%) ⚡ [ 400 ms] chân 9 (LED) PWM = 204 ( 80%) ⚡ [ 500 ms] chân 9 (LED) PWM = 255 (100%) ⚡ [ 700 ms] chân 9 (LED) PWM = 204 ( 80%) ⚡ [ 800 ms] chân 9 (LED) PWM = 153 ( 60%) ⚡ [ 900 ms] chân 9 (LED) PWM = 102 ( 40%) ⚡ [ 1000 ms] chân 9 (LED) PWM = 51 ( 20%) ⚡ [ 1100 ms] chân 9 (LED) PWM = 0 ( 0%) ── hết mô phỏng tại 1250 ms ──
Giải thích
- Mỗi bước tăng 51 ≈ 20% độ sáng.
- Arduino Uno chỉ có PWM ở chân có dấu ~ (3, 5, 6, 9, 10, 11).
2Servo và động cơ DC
Servo nhận xung 50 Hz, độ rộng 0.5–2.5 ms tương ứng 0–180° — thư viện lo hết, ta chỉ gọi servo.write(goc). Động cơ DC kéo dòng lớn và sinh nhiễu: luôn qua driver (L298N, TB6612, DRV8833), PWM vào chân ENA/PWMA, hai chân IN chọn chiều quay. Nguồn động cơ tách riêng, nối chung GND với vi điều khiển.
// Bài 5 – Servo theo biến trở và điều tốc động cơ DC bằng PWM trên ESP32 (bộ LEDC)
#include <ESP32Servo.h> // Arduino Uno dùng <Servo.h> – cách dùng giống hệt
const int CHAN_SERVO = 18;
const int CHAN_BIEN_TRO = 34;
const int CHAN_PWM_DONG_CO = 25; // nối tới chân ENA của driver L298N / TB6612
Servo servo;
void setup() {
Serial.begin(115200);
servo.attach(CHAN_SERVO);
ledcAttach(CHAN_PWM_DONG_CO, 20000, 8); // tần số 20 kHz (không kêu rít), độ phân giải 8 bit: 0..255
}
void loop() {
int bienTro = analogRead(CHAN_BIEN_TRO); // 0..4095
int goc = map(bienTro, 0, 4095, 0, 180);
int tocDo = map(bienTro, 0, 4095, 0, 255);
servo.write(goc);
ledcWrite(CHAN_PWM_DONG_CO, tocDo);
Serial.printf("biến trở %4d -> servo %3d°, động cơ %3d%%\n", bienTro, goc, tocDo * 100 / 255);
delay(400);
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)⚡ [ 0 ms] Servo chân 18 (servo) quay tới 0° ⚡ [ 0 ms] chân 25 (động cơ) LEDC duty = 0/255 (0%) biến trở 0 -> servo 0°, động cơ 0% ⚡ [ 400 ms] Servo chân 18 (servo) quay tới 44° ⚡ [ 400 ms] chân 25 (động cơ) LEDC duty = 63/255 (24%) biến trở 1019 -> servo 44°, động cơ 24% ⚡ [ 800 ms] Servo chân 18 (servo) quay tới 89° ⚡ [ 800 ms] chân 25 (động cơ) LEDC duty = 126/255 (49%) biến trở 2039 -> servo 89°, động cơ 49% ⚡ [ 1200 ms] Servo chân 18 (servo) quay tới 134° ⚡ [ 1200 ms] chân 25 (động cơ) LEDC duty = 190/255 (74%) biến trở 3060 -> servo 134°, động cơ 74% ⚡ [ 1600 ms] Servo chân 18 (servo) quay tới 90° ⚡ [ 1600 ms] chân 25 (động cơ) LEDC duty = 127/255 (49%) biến trở 2048 -> servo 90°, động cơ 49% biến trở 2048 -> servo 90°, động cơ 49% ── hết mô phỏng tại 2100 ms ──
Giải thích
- Kịch bản: người dùng vặn biến trở từ 0 lên hết rồi về giữa.
- Tần số PWM 20 kHz cho động cơ không còn tiếng rít nghe được.
⚠Lỗi thường gặp
- Cấp nguồn servo/động cơ trực tiếp từ chân 5V của board → board reset liên tục.
- Quên nối chung GND giữa nguồn động cơ và board.
✎Bài tập về nhà
💬 Giao tiếp Serial: in dữ liệu và nhận lệnh
Dùng Serial để gỡ lỗi, gửi dữ liệu lên máy tính và nhận lệnh điều khiển (từ máy tính, Bluetooth, module khác).
- Serial.print / println / printf
- Đọc lệnh theo dòng với readStringUntil
- Phân tích lệnh và trả lời
1Bộ xử lý lệnh
UART (Serial) truyền từng byte trên 2 dây TX/RX. Cùng mã nhận lệnh này dùng được cho module Bluetooth HC-05 (nối vào Serial2) hoặc Bluetooth có sẵn của ESP32 (BluetoothSerial).
// Bài 6 – Giao tiếp Serial: nhận lệnh văn bản từ máy tính / app Bluetooth để điều khiển thiết bị.
// Mở Serial Monitor (115200, kết thúc dòng: Newline) rồi gõ: LED ON, LED OFF, PWM 128, STATUS
const int LED = 2;
const int QUAT = 25;
int tocDoQuat = 0;
void xuLyLenh(String lenh) {
lenh.trim();
lenh.toUpperCase();
if (lenh == "LED ON") {
digitalWrite(LED, HIGH);
Serial.println("OK: đèn bật");
} else if (lenh == "LED OFF") {
digitalWrite(LED, LOW);
Serial.println("OK: đèn tắt");
} else if (lenh.startsWith("PWM ")) {
tocDoQuat = constrain(lenh.substring(4).toInt(), 0, 255);
analogWrite(QUAT, tocDoQuat);
Serial.printf("OK: quạt = %d\n", tocDoQuat);
} else if (lenh == "STATUS") {
Serial.printf("Đèn: %s, quạt: %d, chạy được %lu ms\n", digitalRead(LED) ? "bật" : "tắt", tocDoQuat, millis());
} else {
Serial.println("Lỗi: không hiểu lệnh \"" + lenh + "\"");
}
}
void setup() {
pinMode(LED, OUTPUT);
Serial.begin(115200);
Serial.println("Sẵn sàng nhận lệnh: LED ON | LED OFF | PWM <0-255> | STATUS");
}
void loop() {
if (Serial.available()) { // có dữ liệu đang chờ trong bộ đệm
String lenh = Serial.readStringUntil('\n'); // đọc tới hết dòng
Serial.print("> ");
Serial.println(lenh);
xuLyLenh(lenh);
}
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)Sẵn sàng nhận lệnh: LED ON | LED OFF | PWM <0-255> | STATUS > LED ON ⚡ [ 300 ms] chân 2 (đèn) = HIGH OK: đèn bật > pwm 180 ⚡ [ 800 ms] chân 25 (quạt) PWM = 180 ( 70%) OK: quạt = 180 > STATUS Đèn: bật, quạt: 180, chạy được 1300 ms > MO CUA Lỗi: không hiểu lệnh "MO CUA" > led off ⚡ [ 2100 ms] chân 2 (đèn) = LOW OK: đèn tắt ── hết mô phỏng tại 2400 ms ──
Giải thích
- Kịch bản: 5 lệnh được “gõ” ở các thời điểm khác nhau, có cả lệnh viết thường và lệnh sai.
trim()bỏ ký tự\rthừa khi Serial Monitor gửi “Both NL & CR”.- Mẫu “lệnh + tham số” (PWM 180) là cách đơn giản nhất để điều khiển robot từ máy tính hoặc app.
✎Bài tập về nhà
🌡️ Cảm biến: nhiệt độ – độ ẩm DHT22 và siêu âm HC-SR04
Dùng thư viện cảm biến số và cảm biến đo thời gian; xử lý giá trị lỗi.
- Cài và dùng thư viện DHT
- Kiểm tra giá trị lỗi (NaN)
- Đo khoảng cách bằng pulseIn
- Ra quyết định theo ngưỡng
1DHT22
Cài thư viện: Library Manager → “DHT sensor library” (Adafruit). DHT22 chính xác hơn DHT11 (±0.5°C), đọc tối đa mỗi 2 giây; chân DATA cần điện trở kéo lên 10 kΩ (module thường có sẵn).
// Bài 7 – Cảm biến nhiệt độ, độ ẩm DHT22: bật quạt khi nóng, cảnh báo khi độ ẩm cao.
// Thư viện: "DHT sensor library" (Adafruit). DHT22 đọc tối đa 1 lần / 2 giây.
#include <DHT.h>
const int CHAN_DHT = 4;
const int QUAT = 26;
DHT dht(CHAN_DHT, DHT22);
void setup() {
Serial.begin(115200);
dht.begin();
pinMode(QUAT, OUTPUT);
}
void loop() {
float t = dht.readTemperature();
float h = dht.readHumidity();
if (isnan(t) || isnan(h)) { // luôn kiểm tra: cảm biến lỏng dây sẽ trả về NaN
Serial.println("Lỗi đọc DHT22!");
} else {
Serial.printf("Nhiệt độ: %.1f°C Độ ẩm: %.1f%%\n", t, h);
digitalWrite(QUAT, t >= 30 ? HIGH : LOW);
if (h >= 80) Serial.println(" ! Độ ẩm cao – nguy cơ nấm mốc");
}
delay(2000);
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)Nhiệt độ: 27.0°C Độ ẩm: 72.0% ⚡ [ 0 ms] chân 26 (quạt) = LOW Nhiệt độ: 29.2°C Độ ẩm: 76.0% Nhiệt độ: 31.4°C Độ ẩm: 80.0% ⚡ [ 4000 ms] chân 26 (quạt) = HIGH ! Độ ẩm cao – nguy cơ nấm mốc Nhiệt độ: 33.6°C Độ ẩm: 84.0% ! Độ ẩm cao – nguy cơ nấm mốc Nhiệt độ: 35.8°C Độ ẩm: 88.0% ! Độ ẩm cao – nguy cơ nấm mốc ── hết mô phỏng tại 8100 ms ──
Giải thích
- Kịch bản: phòng nóng và ẩm dần lên.
- Quạt bật khi nhiệt độ ≥ 30°C, cảnh báo khi độ ẩm ≥ 80%.
2Siêu âm HC-SR04
// Bài 7 – Cảm biến siêu âm HC-SR04: đo khoảng cách, còi kêu nhanh dần khi vật cản tới gần (cảm biến lùi xe).
const int TRIG = 5;
const int ECHO = 18; // ESP32 3.3 V: dùng HC-SR04P (3.3 V) hoặc cầu phân áp cho chân ECHO
const int COI = 19;
float docKhoangCachCm() {
digitalWrite(TRIG, LOW);
delayMicroseconds(2);
digitalWrite(TRIG, HIGH); // xung kích 10 µs
delayMicroseconds(10);
digitalWrite(TRIG, LOW);
unsigned long thoiGian = pulseIn(ECHO, HIGH, 30000); // thời gian sóng đi và về (µs)
if (thoiGian == 0) return -1; // không nhận được tiếng vọng
return thoiGian * 0.0343 / 2; // vận tốc âm thanh 343 m/s = 0.0343 cm/µs, chia 2 vì đi - về
}
void setup() {
pinMode(TRIG, OUTPUT);
pinMode(ECHO, INPUT);
Serial.begin(115200);
}
void loop() {
float d = docKhoangCachCm();
if (d < 0) {
Serial.println("Ngoài tầm đo");
} else if (d < 10) {
Serial.printf("%.1f cm – DỪNG LẠI!\n", d);
tone(COI, 2000);
} else if (d < 40) {
Serial.printf("%.1f cm – gần\n", d);
tone(COI, 1000, 100);
} else {
Serial.printf("%.1f cm – an toàn\n", d);
noTone(COI);
}
delay(300);
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)80.0 cm – an toàn 68.6 cm – an toàn 57.2 cm – an toàn 45.8 cm – an toàn 34.5 cm – gần ⚡ [ 1216 ms] chân 19 (còi) còi kêu 1000 Hz 23.1 cm – gần 11.8 cm – gần 5.0 cm – DỪNG LẠI! ⚡ [ 2119 ms] chân 19 (còi) còi kêu 2000 Hz ── hết mô phỏng tại 2400 ms ──
Giải thích
- Kịch bản: xe lùi dần từ 80 cm tới 5 cm.
pulseIncó timeout 30 ms (~5 m) để không bị treo khi không có vật cản.
⚠Lỗi thường gặp
- Đọc DHT liên tục không có delay → toàn NaN.
- Cấp 5 V cho chân ECHO vào ESP32 không qua phân áp.
✎Bài tập về nhà
⏱ Làm nhiều việc cùng lúc với millis() và máy trạng thái
Bỏ delay(), viết chương trình phản hồi nhanh làm nhiều việc song song; tổ chức logic bằng máy trạng thái.
- Kỹ thuật “đã đủ thời gian chưa?” với millis()
- Máy trạng thái bằng enum + switch
- Chống dội không chặn
1Ba việc song song
Mỗi việc có biến “lần cuối làm” riêng; mỗi vòng loop() chỉ kiểm tra millis() - lanCuoi >= chuKy. Không việc nào chặn việc nào, nút nhấn được phản hồi ngay.
// Bài 8 – Làm nhiều việc "cùng lúc" bằng millis() (không dùng delay):
// • LED trạng thái nháy mỗi 250 ms • đọc cảm biến mỗi 1000 ms • nút nhấn đổi chế độ phản hồi NGAY
// • Máy trạng thái (state machine) cho 3 chế độ: TẮT -> TỰ ĐỘNG -> BẬT -> TẮT ...
const int LED_TT = 2, NUT = 0, QUAT = 26, CHAN_NHIET = 34;
enum CheDo { TAT, TU_DONG, BAT };
CheDo cheDo = TU_DONG;
const char* TEN_CHE_DO[] = {"TẮT", "TỰ ĐỘNG", "BẬT"};
unsigned long lanNhay = 0, lanDoc = 0, lanNhan = 0;
bool led = false;
int nutTruoc = HIGH;
void capNhatQuat(float nhietDo) {
bool bat = (cheDo == BAT) || (cheDo == TU_DONG && nhietDo > 30);
digitalWrite(QUAT, bat);
}
void setup() {
pinMode(LED_TT, OUTPUT);
pinMode(QUAT, OUTPUT);
pinMode(NUT, INPUT_PULLUP);
Serial.begin(115200);
}
void loop() {
unsigned long now = millis();
// Việc 1: nháy LED trạng thái
if (now - lanNhay >= 250) {
lanNhay = now;
led = !led;
digitalWrite(LED_TT, led);
}
// Việc 2: đọc cảm biến mỗi giây
if (now - lanDoc >= 1000) {
lanDoc = now;
float nhietDo = analogRead(CHAN_NHIET) / 4095.0 * 50; // giả sử cảm biến 0..50°C
Serial.printf("[%lu] %.1f°C, chế độ %s\n", now, nhietDo, TEN_CHE_DO[cheDo]);
capNhatQuat(nhietDo);
}
// Việc 3: nút nhấn (có chống dội 50 ms) đổi chế độ
int nut = digitalRead(NUT);
if (nut == LOW && nutTruoc == HIGH && now - lanNhan > 50) {
lanNhan = now;
cheDo = (CheDo)((cheDo + 1) % 3);
Serial.printf("[%lu] Nút -> chế độ %s\n", now, TEN_CHE_DO[cheDo]);
}
nutTruoc = nut;
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)⚡ [ 250 ms] chân 2 (LED) = HIGH ⚡ [ 500 ms] chân 2 (LED) = LOW ⚡ [ 750 ms] chân 2 (LED) = HIGH ⚡ [ 1000 ms] chân 2 (LED) = LOW [1000] 32.0°C, chế độ TỰ ĐỘNG ⚡ [ 1000 ms] chân 26 (quạt) = HIGH ⚡ [ 1250 ms] chân 2 (LED) = HIGH ⚡ [ 1500 ms] chân 2 (LED) = LOW [1720] Nút -> chế độ BẬT ⚡ [ 1750 ms] chân 2 (LED) = HIGH ⚡ [ 2000 ms] chân 2 (LED) = LOW [2000] 28.0°C, chế độ BẬT ⚡ [ 2250 ms] chân 2 (LED) = HIGH ⚡ [ 2500 ms] chân 2 (LED) = LOW [2650] Nút -> chế độ TẮT ⚡ [ 2750 ms] chân 2 (LED) = HIGH ⚡ [ 3000 ms] chân 2 (LED) = LOW [3000] 28.0°C, chế độ TẮT ⚡ [ 3000 ms] chân 26 (quạt) = LOW ── hết mô phỏng tại 3200 ms ──
Giải thích
- Kịch bản: nhiệt độ ~32°C rồi giảm ~28°C; nút nhấn lúc 1720 ms và 2650 ms.
- LED nháy đều đặn 250 ms trong khi vẫn đọc cảm biến và nút.
- Khi chương trình lớn hơn nữa, bước tiếp theo là RTOS — xem khóa FreeRTOS.
✎Bài tập về nhà
⚡ Ngắt ngoài: đếm xung encoder, không bỏ sót sự kiện
Dùng ngắt để phản ứng tức thì với sự kiện phần cứng và đếm xung tốc độ cao.
- attachInterrupt với RISING/FALLING/CHANGE
- Viết ISR ngắn, dùng volatile
- Đọc biến chung an toàn
1Đếm xung encoder
Ngắt tạm dừng chương trình chính để chạy hàm phục vụ ngắt (ISR) ngay khi chân thay đổi. ISR phải thật ngắn: không delay, không Serial.print. Trên ESP32 thêm IRAM_ATTR.
// Bài 9 – Ngắt ngoài (interrupt): đếm xung encoder bánh xe để tính tốc độ, không bỏ sót xung dù loop() bận.
const int CHAN_ENCODER = 27;
const int XUNG_MOI_VONG = 20; // đĩa encoder 20 lỗ
const float CHU_VI_BANH_CM = 20.4; // bánh xe đường kính 6.5 cm
volatile unsigned long demXung = 0; // volatile: biến dùng chung giữa ISR và loop()
void IRAM_ATTR khiCoXung() { // ISR: thật ngắn – chỉ đếm
demXung++;
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_ENCODER, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(CHAN_ENCODER), khiCoXung, RISING);
}
void loop() {
static unsigned long xungTruoc = 0;
delay(500); // loop() làm việc khác 500 ms – ngắt vẫn đếm đủ
noInterrupts(); // đọc biến chung an toàn
unsigned long xung = demXung;
interrupts();
unsigned long moi = xung - xungTruoc;
xungTruoc = xung;
float vongPhut = moi / (float)XUNG_MOI_VONG / 0.5 * 60;
float cmGiay = moi / (float)XUNG_MOI_VONG * CHU_VI_BANH_CM / 0.5;
Serial.printf("%3lu xung/0.5s -> %5.1f vòng/phút, %5.1f cm/s (tổng %lu xung)\n", moi, vongPhut, cmGiay, xung);
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)27 xung/0.5s -> 162.0 vòng/phút, 55.1 cm/s (tổng 27 xung) 34 xung/0.5s -> 204.0 vòng/phút, 69.4 cm/s (tổng 61 xung) 46 xung/0.5s -> 276.0 vòng/phút, 93.8 cm/s (tổng 107 xung) 70 xung/0.5s -> 420.0 vòng/phút, 142.8 cm/s (tổng 177 xung) 100 xung/0.5s -> 600.0 vòng/phút, 204.0 cm/s (tổng 277 xung) ── hết mô phỏng tại 2600 ms ──
Giải thích
- Kịch bản: bánh xe tăng tốc, chu kỳ xung giảm từ 20 ms xuống 5 ms.
- Dù loop() bận
delay(500), không xung nào bị mất. noInterrupts()/interrupts()bảo vệ lúc đọc biến nhiều byte dùng chung với ISR.
✎Bài tập về nhà
🖥️ Giao tiếp I2C và màn hình LCD 16x2
Nối nhiều thiết bị chỉ với 2 dây I2C, quét địa chỉ và hiển thị dữ liệu lên LCD.
- Hiểu bus I2C, địa chỉ thiết bị
- Quét I2C để tìm địa chỉ
- Dùng LiquidCrystal_I2C
1Bus I2C
2Trạm thời tiết có màn hình
// Bài 10 – Giao tiếp I2C: quét thiết bị trên bus, hiển thị lên màn hình LCD 16x2 (module I2C PCF8574).
// ESP32: SDA = GPIO 21, SCL = GPIO 22. Arduino Uno: SDA = A4, SCL = A5.
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <DHT.h>
LiquidCrystal_I2C lcd(0x27, 16, 2); // địa chỉ 0x27 (có module là 0x3F)
DHT dht(4, DHT22);
void quetI2C() {
Serial.println("Quét bus I2C...");
for (uint8_t dc = 1; dc < 127; dc++) {
Wire.beginTransmission(dc);
if (Wire.endTransmission() == 0) Serial.printf(" tìm thấy thiết bị tại 0x%02X\n", dc);
}
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
quetI2C();
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print("Tram thoi tiet");
lcd.setCursor(0, 1);
lcd.print("BKSTAR v1.0");
dht.begin();
delay(1500);
}
void loop() {
float t = dht.readTemperature();
float h = dht.readHumidity();
lcd.clear();
lcd.setCursor(0, 0);
lcd.print("Nhiet do: ");
lcd.print(t, 1);
lcd.print("C");
lcd.setCursor(0, 1);
lcd.print("Do am : ");
lcd.print(h, 0);
lcd.print("%");
delay(2000);
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)Quét bus I2C...
tìm thấy thiết bị tại 0x27
tìm thấy thiết bị tại 0x76
⚡ [ 1 ms] LCD |Tram thoi tiet |
|BKSTAR v1.0 |
⚡ [ 1501 ms] LCD |Nhiet do: 28.7C |
|Do am : 62% |
⚡ [ 3501 ms] LCD |Nhiet do: 29.2C |
|Do am : 60% |
⚡ [ 5501 ms] LCD |Nhiet do: 29.7C |
|Do am : 58% |
── hết mô phỏng tại 5600 ms ──Giải thích
- Quét bus tìm thấy LCD (0x27) và cảm biến BME280 (0x76).
- LCD hiển thị lại mỗi 2 giây; khung trong kết quả là nội dung 16x2 thật trên màn hình.
- Màn hình không hiện chữ: vặn biến trở tương phản phía sau module.
✎Bài tập về nhà
📶 ESP32: WiFi và web server điều khiển thiết bị
Kết nối ESP32 vào WiFi và tạo trang web điều khiển đèn, đọc nhiệt độ từ điện thoại.
- Kết nối WiFi chế độ STA
- Tạo WebServer, gắn đường dẫn với hàm
- Đọc tham số URL, trả HTML và JSON
1Web server trên ESP32
// Bài 11 – ESP32 kết nối WiFi và làm WEB SERVER: mở trình duyệt trên điện thoại (cùng WiFi) để bật/tắt đèn, xem nhiệt độ.
#include <WiFi.h>
#include <WebServer.h>
const char* TEN_WIFI = "BKSTAR-Lab";
const char* MAT_KHAU = "12345678";
const int DEN = 2;
const int CHAN_NHIET = 34;
WebServer server(80);
void trangChu() {
String html = "<h1>Nha thong minh</h1>";
html += "<p>Den: " + String(digitalRead(DEN) ? "BAT" : "TAT") + "</p>";
html += "<a href='/den?bat=1'>Bat</a> | <a href='/den?bat=0'>Tat</a>";
server.send(200, "text/html", html);
}
void dieuKhienDen() {
if (server.hasArg("bat")) digitalWrite(DEN, server.arg("bat") == "1" ? HIGH : LOW);
server.send(200, "text/plain", digitalRead(DEN) ? "Den da BAT" : "Den da TAT");
}
void docNhietDo() { // trả về JSON cho app / trang web đọc
float t = analogRead(CHAN_NHIET) / 4095.0 * 50;
server.send(200, "application/json", "{\"nhiet_do\":" + String(t, 1) + "}");
}
void setup() {
Serial.begin(115200);
pinMode(DEN, OUTPUT);
WiFi.mode(WIFI_STA);
WiFi.begin(TEN_WIFI, MAT_KHAU);
Serial.print("Đang kết nối WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Đã kết nối! Mở trình duyệt: http://");
Serial.println(WiFi.localIP());
server.on("/", trangChu);
server.on("/den", dieuKhienDen);
server.on("/nhiet-do", docNhietDo);
server.onNotFound([] { server.send(404, "text/plain", "Khong co trang nay"); });
server.begin();
}
void loop() {
server.handleClient(); // phải gọi liên tục để phục vụ trình duyệt
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)Đang kết nối WiFi...
Đã kết nối! Mở trình duyệt: http://192.168.1.25
⚡ [ 2200 ms] Trình duyệt gửi: GET /
⚡ [ 2200 ms] Phản hồi 200 (text/html): <h1>Nha thong minh</h1><p>Den: TAT</p><a href='/den?bat=1'>Bat</a> ...
⚡ [ 2600 ms] Trình duyệt gửi: GET /den?bat=1
⚡ [ 2600 ms] chân 2 (đèn) = HIGH
⚡ [ 2600 ms] Phản hồi 200 (text/plain): Den da BAT
⚡ [ 3000 ms] Trình duyệt gửi: GET /nhiet-do
⚡ [ 3000 ms] Phản hồi 200 (application/json): {"nhiet_do":28.7}
⚡ [ 3400 ms] Trình duyệt gửi: GET /den?bat=0
⚡ [ 3400 ms] chân 2 (đèn) = LOW
⚡ [ 3400 ms] Phản hồi 200 (text/plain): Den da TAT
⚡ [ 3800 ms] Trình duyệt gửi: GET /abc
⚡ [ 3800 ms] Phản hồi 404 (text/plain): Khong co trang nay
── hết mô phỏng tại 4000 ms ──Giải thích
- Kịch bản: kết nối WiFi mất 1.5 giây, sau đó trình duyệt lần lượt mở 5 đường dẫn.
- Đường dẫn trả JSON (
/nhiet-do) để app hoặc trang web khác đọc dữ liệu. - Muốn truy cập từ xa (ngoài nhà) dùng MQTT hoặc dịch vụ như Blynk, Firebase — bài 12.
✎Bài tập về nhà
🏠 IoT với MQTT và dự án nhà thông minh
Đưa dữ liệu lên Internet bằng MQTT, nhận lệnh từ app, kết hợp mọi kỹ năng trong một dự án hoàn chỉnh.
- Hiểu mô hình publish/subscribe, topic, broker
- Dùng PubSubClient gửi JSON và nhận lệnh
- Thiết kế chế độ tự động / thủ công
1MQTT
Broker miễn phí để thử: broker.hivemq.com, test.mosquitto.org. App điện thoại: IoT MQTT Panel, MQTT Dash; dashboard: Node-RED, Home Assistant. Thư viện: PubSubClient (Nick O'Leary).
2Dự án nhà thông minh
// Bài 12 – DỰ ÁN: Nhà thông minh IoT với ESP32 + MQTT
// • Gửi nhiệt độ, độ ẩm, ánh sáng lên broker MQTT mỗi 2 giây (app điện thoại / Node-RED / Home Assistant hiển thị)
// • Nhận lệnh bật/tắt đèn, quạt từ app qua topic nha/dieu-khien
// • Tự động: tối thì bật đèn hiên, nóng quá thì bật quạt (khi đang ở chế độ tự động)
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
const int DEN_HIEN = 2, QUAT = 26, CHAN_LDR = 34;
DHT dht(4, DHT22);
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
bool tuDong = true;
unsigned long lanGui = 0;
void khiNhanTin(char* topic, byte* duLieu, unsigned int len) {
String lenh = "";
for (unsigned int i = 0; i < len; i++) lenh += (char)duLieu[i];
if (lenh == "den:bat") digitalWrite(DEN_HIEN, HIGH);
else if (lenh == "den:tat") digitalWrite(DEN_HIEN, LOW);
else if (lenh == "quat:bat") digitalWrite(QUAT, HIGH);
else if (lenh == "quat:tat") digitalWrite(QUAT, LOW);
else if (lenh == "che-do:tu-dong") tuDong = true;
else if (lenh == "che-do:thu-cong") tuDong = false;
Serial.printf("Lệnh \"%s\" -> chế độ %s\n", lenh.c_str(), tuDong ? "tự động" : "thủ công");
(void)topic;
}
void ketNoi() {
WiFi.begin("BKSTAR-Lab", "12345678");
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.println("WiFi OK, IP " + WiFi.localIP());
mqtt.setServer("broker.hivemq.com", 1883);
mqtt.setCallback(khiNhanTin);
mqtt.connect("bkstar-nha-01");
mqtt.subscribe("nha/dieu-khien");
}
void setup() {
Serial.begin(115200);
pinMode(DEN_HIEN, OUTPUT);
pinMode(QUAT, OUTPUT);
dht.begin();
ketNoi();
}
void loop() {
mqtt.loop(); // nhận tin MQTT
if (millis() - lanGui >= 2000) {
lanGui = millis();
float t = dht.readTemperature(), h = dht.readHumidity();
int anhSang = analogRead(CHAN_LDR);
char json[96];
snprintf(json, sizeof json, "{\"t\":%.1f,\"h\":%.0f,\"anh_sang\":%d}", t, h, anhSang);
mqtt.publish("nha/cam-bien", json);
if (tuDong) {
digitalWrite(DEN_HIEN, anhSang < 1200);
digitalWrite(QUAT, t > 31);
}
}
}
Arduino IDE: chọn board → Upload (kết quả bên dưới chạy trên bộ mô phỏng của khóa)WiFi OK, IP 192.168.1.25
⚡ [ 1500 ms] MQTT kết nối broker broker.hivemq.com với id "bkstar-nha-01"
⚡ [ 1500 ms] MQTT đăng ký nhận topic nha/dieu-khien
⚡ [ 2000 ms] MQTT gửi nha/cam-bien = {"t":31.0,"h":65,"anh_sang":2500}
⚡ [ 2000 ms] chân 2 (đèn hiên) = LOW
⚡ [ 2000 ms] chân 26 (quạt) = LOW
⚡ [ 4000 ms] MQTT gửi nha/cam-bien = {"t":33.0,"h":65,"anh_sang":2500}
⚡ [ 4000 ms] chân 26 (quạt) = HIGH
⚡ [ 4500 ms] MQTT nhận nha/dieu-khien = che-do:thu-cong
Lệnh "che-do:thu-cong" -> chế độ thủ công
⚡ [ 5200 ms] MQTT nhận nha/dieu-khien = den:bat
⚡ [ 5200 ms] chân 2 (đèn hiên) = HIGH
Lệnh "den:bat" -> chế độ thủ công
⚡ [ 6000 ms] MQTT gửi nha/cam-bien = {"t":35.0,"h":65,"anh_sang":900}
⚡ [ 7800 ms] MQTT nhận nha/dieu-khien = che-do:tu-dong
Lệnh "che-do:tu-dong" -> chế độ tự động
⚡ [ 8000 ms] MQTT gửi nha/cam-bien = {"t":37.0,"h":65,"anh_sang":900}
── hết mô phỏng tại 9700 ms ──Giải thích
- Kịch bản: nhiệt độ tăng dần, lúc 4.5 s app chuyển chế độ thủ công và bật đèn, trời tối lúc 5 s, lúc 7.8 s trả về tự động.
- Ở chế độ thủ công, logic tự động không ghi đè lệnh người dùng.
- Dữ liệu gửi dạng JSON để app/dashboard dễ đọc.
✓Tóm tắt
- Ngõ ra/vào số, analog, PWM là 4 kỹ năng nền của mọi dự án.
- Bỏ delay(): dùng millis(), máy trạng thái, ngắt.
- Giao tiếp: Serial, I2C, WiFi/HTTP, MQTT.
- Học tiếp: FreeRTOS, thiết kế mạch PCB để làm sản phẩm hoàn chỉnh.