◆ Де ми зупинились
У першій частині ми розібрались, що таке ADC, порівняли 10-біт Uno з 12-біт ESP32 і навіть навчились вичавлювати з Mega точність до 1.07 мВ через внутрішній референс 1.1V. Але реальні проєкти одразу підкидають двох нових ворогів точних вимірювань: зависання коду через delay() і хаотичний цифровий шум на графіках.
Сьогодні прибираємо delay() назавжди, розганяємо внутрішнє залізо ADC через прямий запис у регістри, і пишемо перший математичний DSP-фільтр — вже на Raspberry Pi Pico.
◆ Чому delay() — ворог точних вимірів
Ефект “сліпоти” мікроконтролера
Коли ви пишете delay(20), мікроконтролер повністю зупиняє виконання програми на 20 мілісекунд. Процесор буквально засинає. Якщо саме в цей момент на аналоговому вході станеться швидкий сплеск напруги чи прийде важлива команда по Serial — плата цього просто не побачить. Для якісного оцифрування сигналу заміри мають відбуватись через строго однакові, передбачувані проміжки часу — а delay() гарантує лиш одне: що ваш код на цей час випаде з реальності.
Це, до речі, той самий ефект, який ми мимохідь зачепили в першій статті — приклад із блогу про аліасинг, де Serial.println() посеред циклу семплування зводив нанівець реальну частоту дискретизації. delay() — просто найбільш очевидний і найгрубіший варіант тієї самої хвороби.
Звідки береться цифровий шум
Підключіть довгий дріт до аналогового піна — і графік у Serial Plotter почне тремтіти, навіть якщо датчика ніхто не чіпає. Це не несправність плати. Наші будинки буквально пронизані електромережею на частоті 50 Гц, а довгий провід датчика працює як антена, яка ловить цей фон. Чим довший дріт і чим вищий вихідний опір джерела сигналу — тим сильніше наведення.
⚠ Якщо шум заважає навіть після програмної фільтрації (яку зберемо нижче) — це сигнал звернути увагу на апаратну частину: конденсатор 10 нФ між сигнальним піном і землею прямо на платі, коротший і екранований провід, чи розв’язка живлення ADC (невеликий резистор + конденсатор між AVCC і живленням) прибирають значну частку “сміття” ще до того, як сигнал долетить до коду.
Щоб очистити корисний сигнал від залишку шуму — використовують математичну фільтрацію. Саме цим і займемось у другій вправі.
◆ Вправа 2.1 — осцилограф без гальм і розгін заліза (Arduino Uno)
Тут дві незалежні речі одночасно: переводимо опитування ADC на асинхронний таймер через millis(), щоб плата ніколи не зависала, і прискорюємо сам ADC напряму через регістри.
За замовчуванням analogRead() на Uno займає близько 100-110 мкс. Причина — внутрішній тактовий генератор ADC працює на частоті процесора, поділеній на 128 (це prescaler за замовчуванням, обраний саме так, щоб втрапити в рекомендований даташитом діапазон 50-200 кГц для повної 10-бітної точності). Ми змінимо коефіцієнт ділення на 16 через регістр ADCSRA, скоротивши час одного заміру приблизно до 13-16 мкс.
Схема підключення: будь-який аналоговий датчик чи звичайний потенціометр на пін A0 Arduino Uno. Живлення датчика — 5V чи 3.3V, крайня ніжка — на GND.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
#define SENSOR_PIN A0 unsigned long lastSampleTime = 0; const unsigned long sampleInterval = 2; // опитуємо кожні 2 мс = 500 Гц void setup() { Serial.begin(115200); // --- РОЗГІН ADC ЧЕРЕЗ РЕГІСТРИ --- // ADCSRA — ADC Control and Status Register A. // Останні три біти (ADPS2:0) керують prescaler-ом. // За замовчуванням вони 111 → дільник 128. // Примусово ставимо 100 → дільник 16. ADCSRA = (ADCSRA & 0xF8) | 0x04; } void loop() { // Асинхронний таймер на millis() — процесор ніколи не блокується unsigned long currentMillis = millis(); if (currentMillis - lastSampleTime >= sampleInterval) { lastSampleTime = currentMillis; int rawValue = analogRead(SENSOR_PIN); // тепер ~13-16 мкс замість ~110 мкс Serial.print("Raw_Value:"); Serial.println(rawValue); } // Процесор вільний — тут спокійно живе будь-який інший код: // блимання світлодіодами, опитування кнопок, обробка UART. } |
⚠ Чесне застереження, якого немає в більшості туторіалів: прискорення ADC — це не безкоштовний обід. Даташит вимагає 50-200 кГц тактової частоти ADC саме для збереження повної 10-бітної точності. При prescaler=16 (тактова частота ADC ~1 МГц) ви виходите далеко за рекомендований діапазон, і незалежні тести спільноти (зокрема відомий ресурс Ніка Геммона по AVR-регістрах) показують, що ефективна точність на такій швидкості падає приблизно до 8.5 біт замість повних 10. Для швидкого прототипування чи де важлива саме швидкість реакції (наприклад, майбутній осцилограф з цієї серії) — це прийнятний компроміс. Якщо ж потрібна максимальна точність виміру — лишайте prescaler за замовчуванням чи обирайте проміжне значення (наприклад, 32).
◆ Вправа 2.2 — математичний DSP-фільтр Moving Average (Raspberry Pi Pico)
Переходимо на 32-бітну Raspberry Pi Pico — тактова частота 133 МГц робить її куди зручнішою платформою для цифрової обробки сигналів (DSP), ніж 16-мегагерцевий AVR.
Принцип Moving Average (рухоме середнє): код тримає в пам’яті кільцевий буфер на N останніх вимірювань. З приходом кожного нового значення найстаріше в буфері викидається, а по решті рахується середнє арифметичне. Ефект — гострі піки шуму “розмазуються” й зникають, а повільна, справжня зміна сигналу (наприклад, рух руки над датчиком) залишається помітною.
Схема підключення для Pi Pico:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
Потенціометр / датчик: Ліва ніжка ➡️ GND (пін 38, чи будь-який інший GND) Права ніжка ➡️ 3.3V (пін 36 — 3V3 OUT) Середня ніжка (сигнал) ➡️ GP26 / ADC0 (пін 31) #define ADC_PIN 26 #define FILTER_WINDOW 10 // розмір вікна — чим більше, тим гладший графік, але повільніша реакція int readings[FILTER_WINDOW]; int readIndex = 0; long total = 0; int average = 0; unsigned long lastSampleTime = 0; const unsigned long sampleInterval = 5; // замір кожні 5 мс void setup() { Serial.begin(115200); analogReadResolution(12); // 12 біт для Pi Pico, 0-4095 for (int i = 0; i < FILTER_WINDOW; i++) { readings[i] = 0; } } void loop() { unsigned long currentMillis = millis(); if (currentMillis - lastSampleTime >= sampleInterval) { lastSampleTime = currentMillis; int rawValue = analogRead(ADC_PIN); // --- РУХОМЕ СЕРЕДНЄ --- total = total - readings[readIndex]; // прибираємо старе значення з суми readings[readIndex] = rawValue; // записуємо нове в кільцевий буфер total = total + readings[readIndex]; // додаємо нове до суми readIndex = readIndex + 1; if (readIndex >= FILTER_WINDOW) { readIndex = 0; // повертаємось на початок буфера } average = total / FILTER_WINDOW; // Виводимо обидва графіки одночасно для порівняння Serial.print("Raw_Signal:"); Serial.print(rawValue); Serial.print(","); Serial.print("Filtered_Signal:"); Serial.println(average); } } |
Результат у Serial Plotter: дві лінії. Raw_Signal тремтить і скаче — це і є той самий шум. Filtered_Signal — плавна, повторює реальний рух без “бруду”. Ви щойно написали свій перший цифровий фільтр.
◆ Бонус: зворотний бік прискорення — oversampling заради додаткової точності
У вправі 2.1 ми пришвидшили ADC ціною точності. А що, якщо потрібно навпаки — вичавити з дешевого 10-бітного ADC точність вище номінальної? Тут в гру вступає протилежна техніка — oversampling з децимацією.
Ідея (детальний опис — в офіційній аплікаційній нотатці Atmel AVR121): якщо в сигналі є трохи природного шуму (буквально той самий “тремтливий” Raw_Signal, який ми щойно фільтрували), можна підсумувати кілька вимірів і зсунути результат вправо — і це математично додає біти точності. Наприклад, підсумувавши 4 виміри й зсунувши суму на 1 біт, ви отримуєте один “виграний” додатковий біт роздільної здатності.
⚠ Це працює тільки за наявності певного рівня шуму (dither) — приблизно на рівні 1 молодшого розряду. На абсолютно “чистому” сигналі без жодного шуму oversampling нічого не додасть, бо немає що усереднювати.
Тобто в одному й тому самому чипі ховаються дві протилежні стратегії: prescaler=16 — коли важливіша швидкість, oversampling — коли важливіша точність. Обирати доведеться під конкретну задачу, і саме в цьому — різниця між “просто підключити датчик” і “спроєктувати вимірювальну систему”.
◆ Типові помилки
- Лишити
delay()десь у коді “на всякий випадок” — навіть одна забута затримка ламає рівномірність дискретизації для всієї програми. - Занадто маленьке вікно фільтра (
FILTER_WINDOW) — фільтр майже не згладжує шум. - Занадто велике вікно — фільтр згладжує й реальні швидкі зміни сигналу, система починає “запізнюватись”.
- Прискорити ADC (prescaler) і одразу ж намагатись отримати від нього максимальну точність — ці дві мети суперечать одна одній, треба свідомо обирати.
- Забути ініціалізувати масив
readings[]нулями — перші N вимірів після старту будуть заниженими, поки буфер не заповниться реальними даними.
◆ Домашнє завдання
- Зберіть вправу 2.1, переконайтесь через
micros()навколоanalogRead(), що вимір справді пришвidшився. - Зберіть вправу 2.2 на Pi Pico, поекспериментуйте з
FILTER_WINDOW— спробуйте 3, 10, 50, порівняйте плавність і затримку реакції. - Спробуйте об’єднати обидва прийоми: розігнаний ADC на Uno + Moving Average фільтр — чи компенсує фільтр втрату точності від prescaler?
- Якщо є під рукою — спробуйте oversampling (підсумувати 4 виміри, зсунути на 1 біт) і порівняйте роздільну здатність з фільтрованим сигналом.
◆ Запитання для самоперевірки
- Чому
delay()шкідливий саме для точних вимірів, а не просто “неефективний”? - Звідки фізично береться 50-герцовий шум на довгому аналоговому дроті?
- Що саме змінює запис
ADCSRA = (ADCSRA & 0xF8) | 0x04;, і чому саме ці біти? - Яка ціна прискорення ADC через prescaler, і коли вона виправдана?
- Чому Moving Average згладжує шум, але не відновлює втрачену prescaler-ом точність — це два різні механізми чи один?
◆ Далі в серії
У Модулі 3 повністю відмовляємось від зручних функцій Arduino на користь низькорівневого програмування регістрів STM32 Blue Pill на чистому C — і розберемось, як зробити безпечний дільник напруги, щоб не спалити плату.
Коли будете готові зазирнути під капот ще потужнішого заліза — рухаємось далі.