АЦП в ATmega328P: что под капотом
10-битный последовательно-приближающийся АЦП с 8-канальным мультиплексором (ADC0..ADC7 — ножки PC0..PC5 на DIP-корпусе). Опорное напряжение выбирается: AREF (внешнее), AVCC (по питанию) или внутреннее прецизионное 1,1 В.
Ключевые регистры:
- ADMUX — выбор опорного (REFS1:0) и канала (MUX3:0), бит ADLAR для выравнивания результата влево.
- ADCSRA — включение (ADEN), старт преобразования (ADSC), автотриггер (ADATE), предделитель тактирования (ADPS2:0).
- ADC — 16-битный регистр результата (ADCL + ADCH).
Тактирование АЦП должно быть 50–200 кГц: при F_CPU 16 МГц предделитель 128 даёт 125 кГц — в самый раз.
Однократное преобразование (polling)
#define F_CPU 16000000UL
#include <avr/io.h>
static void adc_init(void)
{
// опорное = AVCC (REFS0=1), канал ADC0
ADMUX = (1 << REFS0);
// ADEN=1, предделитель 128
ADCSRA = (1 << ADEN) | (1 << ADPS2) | (1 << ADPS1) | (1 << ADPS0);
}
static uint16_t adc_read(uint8_t channel)
{
ADMUX = (ADMUX & 0xF0) | (channel & 0x0F); // выбрать канал
ADCSRA |= (1 << ADSC); // старт
while (ADCSRA & (1 << ADSC))
; // ждём ~104 мкс
return ADC; // 0..1023
}
Первое преобразование после включения длится 25 тактов АЦП (инициализация), последующие — 13. Перевод в милливольты:
uint16_t mv = (uint32_t)adc_read(0) * 5000 / 1023; // при Vref=5В
Умножение uint32_t обязательно до деления — 723 * 5000 переполняет uint16.
Усреднение против шума
Одиночное чтение шумит на ±2–3 LSB. Классика — 16 выборок и сдвиг на 4:
static uint16_t adc_read_avg(uint8_t ch, uint8_t samples)
{
uint32_t acc = 0;
for (uint8_t i = 0; i < samples; i++)
acc += adc_read(ch);
return (uint16_t)(acc / samples);
}
Ещё лучше — экспоненциальный фильтр: y = y + (x - y) / K. Дёшево, не хранит массив:
static uint16_t filtered = 0;
filtered += ((int16_t)adc_read(0) - (int16_t)filtered) / 8;
АЦП по прерыванию и в фоне
Автозапуск (free-running): ADATE=1, источник — continuous. Преобразования идут сами, в ISR забираем результат:
volatile uint16_t g_adc_result = 0;
ISR(ADC_vect)
{
g_adc_result = ADC;
}
static void adc_freerun_init(void)
{
ADMUX = (1 << REFS0); // AVCC, канал 0
ADCSRA = (1 << ADEN) | (1 << ADATE) | (1 << ADIE)
| (1 << ADPS2) | (1 << ADPS1) | (1 << ADPS0);
ADCSRB = 0; // free running
ADCSRA |= (1 << ADSC); // погнали
}
Main просто читает g_adc_result — всегда свежие данные без ожидания.
Измерение напряжения батареи без делителя
Фишка AVR: можно переключить вход АЦП на внутренний bandgap 1,1 В, а опорным сделать VCC — тогда по показанию вычисляется само напряжение питания:
static uint16_t read_vcc_mv(void)
{
ADMUX = (1 << REFS0) // опорное = AVCC
| (1 << MUX3) | (1 << MUX2) | (1 << MUX1); // канал = 1.1V bandgap
_delay_ms(2); // bandgap стабилизируется
ADCSRA |= (1 << ADSC);
while (ADCSRA & (1 << ADSC));
return (uint16_t)(1100UL * 1023 / ADC); // реальное Vcc в мВ
}
Идеально для устройств с питанием напрямую от Li-Ion или 3×AA без стабилизатора — следите за разрядом и засыпайте при критическом пороге.
EEPROM: данные после выключения
В ATmega328P — 1024 байта EEPROM, ~100 000 циклов записи на ячейку. avr-libc даёт готовые функции <avr/eeprom.h>:
#include <avr/eeprom.h>
// переменная, привязанная к EEPROM при прошивке
uint16_t EEMEM ee_counter;
int main(void)
{
uint16_t cnt = eeprom_read_word(&ee_counter);
cnt++;
eeprom_update_word(&ee_counter, cnt); // пишет только при изменении!
}
Важные функции:
eeprom_read_byte/word/dword/block— чтение;eeprom_write_*— запись всегда;eeprom_update_*— запись только если байт изменился — главный инструмент против износа, используйте всегда вместо write.
Сохранение структуры настроек:
typedef struct {
uint8_t brightness;
uint8_t mode;
uint16_t threshold;
uint8_t crc;
} settings_t;
settings_t EEMEM ee_settings;
static void settings_save(const settings_t *s)
{
eeprom_update_block(s, &ee_settings, sizeof(settings_t));
}
static uint8_t settings_load(settings_t *s)
{
eeprom_read_block(s, &ee_settings, sizeof(settings_t));
uint8_t crc = crc8((uint8_t *)s, sizeof(settings_t) - 1);
return crc == s->crc; // 0 = данные битые, взять дефолт
}
CRC (или хотя бы магическое байт-слово вроде 0xA5) обязательна: первый запуск на чистом чипе вернёт 0xFF во всех байтах.
Спящие режимы: жизнь от батарейки
ATmega328P в активе кушает ~12 мА на 16 МГц, в power-down — 0,1 мкА. Разница в 100 000 раз — именно она определяет, проработает датчик от CR2032 неделю или 5 лет.
Режимы по убыванию потребления:
- idle — спит CPU, вся периферия работает (экономия ~40%);
- adc-noise-reduction — для точных измерений;
- power-save — как power-down, но Timer2 (асинхронный) тикает — часы реального времени;
- standby — power-down + запущенный кварц (быстрый старт);
- power-down — всё выключено; будят только внешние прерывания по уровню, PCINT, TWI-адрес и WDT.
Минимальный сон с пробуждением по WDT:
#include <avr/sleep.h>
#include <avr/wdt.h>
#include <avr/interrupt.h>
ISR(WDT_vect) { /* просто пробуждение */ }
static void wdt_init_8s(void)
{
cli();
wdt_reset();
WDTCSR |= (1 << WDCE) | (1 << WDE); // вход в конфиг-режим
WDTCSR = (1 << WDIE) // прерывание (не сброс!)
| (1 << WDP3) | (1 << WDP0); // 8 секунд
sei();
}
int main(void)
{
wdt_init_8s();
ADCSRA = 0; // АЦП выкл — экономия
// выключить неиспользуемую периферию:
PRR = (1 << PRTWI) | (1 << PRUSART0) | (1 << PRSPI) | (1 << PRTIM1);
while (1) {
set_sleep_mode(SLEEP_MODE_PWR_DOWN);
sleep_enable();
sleep_bod_disable(); // отключить BOD на время сна
sei();
sleep_cpu(); // ← сон до WDT-прерывания
sleep_disable();
// проснулись: измерить, передать, снова спать
do_measurement();
}
}
Будильник из 75 циклов по 8 с = 10 минут сна между замерами. Счётчик в volatile инкрементирует ISR(WDT_vect).
Периферия жрёт даже в активе
Регистр PRR выключает тактирование неиспользуемых блоков — до 30% экономии в активном режиме. Также не забывайте:
- ненужные входы — в pull-up или выходы (плавающий вход потребляет);
- светодиоды питания на платах Nano/Pro Mini съедают ~2 мА — отпаять;
- LDO-стабилизатор на плате — тоже потребляет; для батарейки питайте напрямую.
Типовые ошибки
- AREF замкнут на AVCC внутренним выбором — при REFS=01 не подавайте внешнее напряжение на AREF.
- Запись EEPROM в цикле — 100 000 циклов изнашиваются за минуты: пишите по событию и через
update. - WDT как сброс вместо будильника — забыт WDE при настройке даст перезагрузку через 8 с.
- Забыли sei() перед sleep_cpu() — уснёт навсегда (прерывание не разрешено).
- Чтение ADC сразу после смены канала — дайте мультиплексору settle-время или сделайте холостое преобразование.
Итог
АЦП + EEPROM + сон = классическая связка автономного датчика: проснулся по WDT → измерил → сравнил с порогом → при изменении сохранил в EEPROM или отправил → снова спать. Такой цикл от CR2032 живёт годами.