Định lý lấy mẫu Nyquist: tần số lấy mẫu bao nhiêu là đủ, chồng phổ và thí nghiệm trên ESP32
Định lý lấy mẫu Nyquist giải thích bằng hình: tần số Nyquist, hiện tượng chồng phổ khi lấy mẫu thiếu, cách chọn tần số lấy mẫu theo ứng dụng và thí nghiệm ESP32 thấy sóng 200 Hz thành 50 Hz.
Mọi phép đo bằng vi điều khiển đều là lấy mẫu: ADC đọc tín hiệu ở từng thời điểm rời rạc, ví dụ 1000 lần mỗi giây. Đọc thưa quá thì mất thông tin, tệ hơn nữa là thấy một tín hiệu không có thật. Định lý lấy mẫu Nyquist (còn gọi Nyquist–Shannon) cho biết phải lấy mẫu nhanh tới đâu. Bài này giải thích định lý bằng hình, hiện tượng chồng phổ khi lấy mẫu thiếu, cách chọn tần số lấy mẫu cho từng loại tín hiệu, và một thí nghiệm trên ESP32 chỉ cần một sợi dây để thấy tận mắt sóng 200 Hz biến thành 50 Hz.
1. Định lý Nyquist nói gì
Một tín hiệu không có thành phần nào cao hơn tần số fmax có thể được ghi lại và dựng lại đầy đủ từ các mẫu, nếu tần số lấy mẫu fs lớn hơn hai lần fmax:
fs > 2 × fmax
Nửa tần số lấy mẫu, fs/2, gọi là tần số Nyquist: đó là tần số cao nhất mà dãy mẫu còn biểu diễn đúng. Ví dụ ADC đọc 1000 lần mỗi giây thì chỉ ghi đúng các thành phần dưới 500 Hz.
Hai điều kiện hay bị quên:
- Lớn hơn hẳn, không phải bằng: lấy mẫu sóng 50 Hz ở đúng 100 Hz có thể toàn rơi vào điểm qua 0, đọc ra đường thẳng.
- Điều kiện áp dụng cho mọi thành phần có trong tín hiệu đi vào ADC, kể cả nhiễu, hài, nhiễu điện lưới, chứ không chỉ phần mình quan tâm.
2. Lấy mẫu thiếu thì chuyện gì xảy ra: chồng phổ

Khi lấy mẫu chậm hơn 2 lần tần số tín hiệu, các mẫu vẫn có giá trị hợp lệ, nhưng chúng khớp hoàn hảo với một sóng tần số thấp hơn. Chương trình không có cách nào biết dãy mẫu đến từ sóng nào. Hiện tượng này gọi là chồng phổ (aliasing). Ở hình dưới, sóng 200 Hz lấy mẫu 250 Hz cho ra dãy mẫu giống hệt sóng 50 Hz.
Tần số nhìn thấy sau khi lấy mẫu tính bằng: lấy tần số thật trừ đi bội số gần nhất của fs, rồi lấy trị tuyệt đối. Ví dụ 200 Hz, fs = 250 Hz: |200 − 250| = 50 Hz.

Chồng phổ nguy hiểm vì kết quả trông hợp lý. Ví dụ hay gặp: đo rung một máy quay 3000 vòng/phút (50 Hz) bằng gia tốc kế đọc 60 lần mỗi giây, số liệu cho thấy dao động 10 Hz rất đều. Không có gì sai trong code, nhưng 10 Hz là tần số không có thật.
3. Chọn tần số lấy mẫu bao nhiêu là đủ

Cách chọn:
- Xác định thành phần tần số cao nhất cần giữ lại (không phải tần số cơ bản). Đo điện lưới 50 Hz mà cần biết hài bậc 20 thì fmax là 1 kHz.
- Nhân với hệ số: tối thiểu là 2 về lý thuyết. Phân tích phổ bằng FFT thường dùng 2,56 lần. Muốn nhìn được dạng sóng trên đồ thị (đỉnh nhọn, sườn dốc) thì cần 5–10 lần.
- Kiểm tra phần cứng làm được không: thời gian đọc ADC, thời gian xử lý, dung lượng lưu, băng thông gửi lên mạng.
Ngược lại, tín hiệu chậm không cần lấy mẫu nhanh. Nhiệt độ bồn nước đọc mỗi giây đã dư; đọc 1000 lần mỗi giây chỉ tốn điện, tốn bộ nhớ. Có thể đọc nhanh rồi lấy trung bình để giảm nhiễu, nhưng chỉ lưu kết quả mỗi giây.
4. Chống chồng phổ: lọc trước khi lấy mẫu
Vì nhiễu cũng bị gấp xuống, cách đúng là lọc thông thấp trước ADC để cắt mọi thành phần trên fs/2, gọi là bộ lọc chống chồng phổ (anti-aliasing filter). Mạch đơn giản nhất là một điện trở nối tiếp và một tụ xuống GND (lọc RC), tần số cắt fc = 1 / (2π × R × C). Ví dụ R = 10 kΩ, C = 100 nF cho fc ≈ 159 Hz, hợp với lấy mẫu 1–2 kHz.
Lọc số trong phần mềm (trung bình trượt, EMA) chỉ làm mượt dãy mẫu đã có; nó không sửa được chồng phổ, vì thành phần sai đã nằm sẵn trong dãy mẫu. Bộ lọc chống chồng phổ chi tiết và cách lấy mẫu dư rồi lọc số có trong bài về aliasing cùng cụm chủ đề.
5. Thí nghiệm trên ESP32: thấy chồng phổ bằng một sợi dây
ESP32 có bộ tạo sóng cos bên trong, xuất ra chân DAC D25. Chương trình tạo sóng 200 Hz ở D25, rồi dùng ADC ở D34 lấy mẫu với sáu tần số từ 2000 Hz xuống 210 Hz, đếm số lần dãy mẫu cắt qua giá trị trung bình để ước tính tần số nhìn thấy, và in kèm tần số tính theo lý thuyết.

Chuẩn bị ESP32 DevKit V1 và một dây cắm cái–cái. Bộ tạo sóng cos chỉ có trên ESP32 bản gốc (không có trên ESP32-S3, C3).
// PYWORLD.VN - Thí nghiệm định lý lấy mẫu Nyquist trên ESP32: DAC tạo sóng 200 Hz, ADC lấy mẫu ở nhiều tần số khác nhau
// PYWORLD.VN - Nối một dây từ D25 (DAC kênh 0, bộ tạo sóng cos trong chip) sang D34 (ADC1); không cần linh kiện khác
// PYWORLD.VN - Với mỗi tần số lấy mẫu fs, đếm số lần cắt qua giá trị trung bình để ước tính tần số "nhìn thấy" được
#include "driver/dac_cosine.h"
const int CHAN_ADC = 34;
const uint32_t F_TIN_HZ = 200; // tần số sóng tạo ra
const uint32_t DS_FS[] = {2000, 1000, 450, 300, 250, 210};
const int SO_MAU = 256;
const int TRE_MV = 40; // vùng trễ khi đếm cắt, chống nhiễu
int mau[SO_MAU];
float tanSoChongPho(float f, float fs) { // tần số sau lấy mẫu theo lý thuyết, trong khoảng 0..fs/2
float n = roundf(f / fs);
return fabsf(f - n * fs);
}
float uocTinhTanSo(uint32_t fs) {
uint32_t chuKyUs = 1000000UL / fs;
uint32_t moc = micros();
for (int i = 0; i < SO_MAU; i++) {
while ((uint32_t)(micros() - moc) < chuKyUs) {
}
moc += chuKyUs;
mau[i] = analogReadMilliVolts(CHAN_ADC);
}
long tong = 0;
for (int i = 0; i < SO_MAU; i++) {
tong += mau[i];
}
int tb = tong / SO_MAU;
int lanCat = 0;
bool tren = mau[0] > tb;
for (int i = 1; i < SO_MAU; i++) {
if (!tren && mau[i] > tb + TRE_MV) {
tren = true;
lanCat++;
} else if (tren && mau[i] < tb - TRE_MV) {
tren = false;
lanCat++;
}
}
float thoiGianS = (float)SO_MAU / fs;
return lanCat / 2.0f / thoiGianS; // hai lần cắt = một chu kỳ
}
void setup() {
Serial.begin(115200);
analogSetPinAttenuation(CHAN_ADC, ADC_11db);
dac_cosine_handle_t kenh;
dac_cosine_config_t cauHinh = {};
cauHinh.chan_id = DAC_CHAN_0; // GPIO25
cauHinh.freq_hz = F_TIN_HZ;
cauHinh.clk_src = DAC_COSINE_CLK_SRC_DEFAULT;
cauHinh.atten = DAC_COSINE_ATTEN_DB_6; // nửa biên độ, nằm gọn trong dải đo của ADC
cauHinh.phase = DAC_COSINE_PHASE_0;
cauHinh.offset = 0;
ESP_ERROR_CHECK(dac_cosine_new_channel(&cauHinh, &kenh));
ESP_ERROR_CHECK(dac_cosine_start(kenh));
delay(100);
Serial.printf("Song vao: %u Hz tren D25, noi sang D34\n", (unsigned)F_TIN_HZ);
}
void loop() {
for (uint32_t fs : DS_FS) {
float doDuoc = uocTinhTanSo(fs);
float lyThuyet = tanSoChongPho(F_TIN_HZ, fs);
Serial.printf("fs = %4u Hz | fs/2 = %4u Hz | do duoc ~%6.1f Hz | ly thuyet %6.1f Hz %s\n",
(unsigned)fs, (unsigned)(fs / 2), doDuoc, lyThuyet,
fs > 2 * F_TIN_HZ ? "" : "<- chong pho");
}
Serial.println();
delay(5000);
}
Giải thích:
dac_cosine_new_channel()vàdac_cosine_start()là hàm của ESP-IDF có sẵn trong gói esp32; bộ tạo sóng chạy bằng phần cứng, không chiếm CPU. Tần số thấp nhất của nó khoảng 130 Hz, nên thí nghiệm dùng 200 Hz.- Hàm
uocTinhTanSo()chờ đúng chu kỳ bằngmicros()rồi đọc ADC, lấy 256 mẫu. Vùng trễ 40 mV khi đếm cắt giúp nhiễu ADC không làm đếm thừa. tanSoChongPho()tính tần số nhìn thấy theo công thức ở mục 2 để so với số đo.

Ba dòng đầu, tần số lấy mẫu lớn hơn 400 Hz, đo đúng khoảng 200 Hz. Từ 300 Hz trở xuống, cùng một sóng 200 Hz được "nhìn thấy" là 100 Hz, 50 Hz rồi 10 Hz. Trên mạch thật, số đo lệch thêm vài Hz vì bộ tạo sóng cos không cho đúng 200 Hz và vòng chờ micros() có sai số nhỏ.
6. Những nhầm lẫn hay gặp
| Nhầm lẫn | Thực tế |
|---|---|
| Lấy mẫu đúng 2 lần là đủ | Phải lớn hơn 2 lần, thực tế 2,5–10 lần |
| Chỉ cần quan tâm tần số của tín hiệu chính | Nhiễu, hài, nhiễu điện lưới cũng bị gấp xuống |
| Lọc trung bình trong code sẽ sửa được chồng phổ | Không; phải lọc tương tự trước ADC hoặc lấy mẫu nhanh hơn |
delay(1) trong vòng lặp là lấy mẫu 1000 Hz | Cộng thêm thời gian đọc ADC, gửi Serial; dùng timer hoặc mốc micros() |
| Càng lấy mẫu nhanh càng chính xác | Với tín hiệu chậm chỉ tốn điện, bộ nhớ; độ chính xác do ADC và mạch đo quyết định |
Xem thêm