Содержание статьи
warning
Редакция и автор не несут ответственности за возможный причиненный вред здоровью и имуществу при несоблюдении техники безопасности работы с электроприборами.
Постановка задачи
Месяц назад мы сделали маленький main, который заставлял весело моргать светодиод на плате. В этот раз мы пойдем дальше, наш проект будет соответствовать следующим требованиям:
- устройство должно иметь два режима индикации: режим ожидания и «аварийный»;
- устройство должно переходить в аварийный режим, если замкнулись определенные контакты на плате, и возвращаться обратно при их размыкании;
- пользователь должен иметь возможность настроить индикацию по своему вкусу.
Так как сухая постановка задачи скучна, мы придумаем жизненную ситуацию. Например, пока ты сидишь и смотришь кино в наушниках, в холодильник на кухне проникает неизвестный враг и похищает оттуда колбасу. Никогда не сталкивался с таким? А ведь это очень возможно, и нужно быть готовым ко всему заранее! Нужна сигнализация. Для ее реализации дверь холодильника оборудуй контактным или магнитным датчиком (например, ИО-102-16/2, но на кустарном уровне сгодятся и два провода витой пары, приклеенные скотчем так, чтобы при закрытой дверце холодильника они замыкались), Arduino положи в комнате на видном месте, заведи провода от датчика к Arduino по следующей схеме (какой конкретно провод куда подключать — значения не имеет):
- один провод на колодке
DIGITAL
на любой из контактовGND
; - второй провод на колодке
DIGITAL
на контакт43
.
План решения задачи
Чтобы отслеживать, замкнуты ли провода, необходимо периодически опрашивать состояние входного сигнала на микроконтроллере.
В задаче указано, что пользователь должен иметь возможность настраивать индикацию. Воспользуемся памятью EEPROM микроконтроллера и при запуске будем читать оттуда настройки. А для записи настроек напишем небольшой интерактивный терминал, к которому можно подключиться любой терминальной программой по порту RS-232. Встает вопрос: где взять RS-232 на Arduino и на компьютере? Все очень просто, если ты не потерял схему платы Arduino. USB-разъем платы Arduino заведен на UART0 микроконтроллера через микросхему‑преобразователь USB — COM (на самом деле, как ты помнишь, там стоит ATmega16U, он‑то и играет роль этого преобразователя).
Индикацию из основного цикла программы придется убирать, потому что время обработки команд у нас непредсказуемо, а значит, мы будем иметь проблемы с выдерживанием времени моргания светодиодом.
info
В реальной жизни для анализа состояния входных сигналов используют аппаратные и программные фильтры, так как мир неидеален и при размыкании/замыкании контактов возникает так называемый дребезг контакта.
Решение
Периферия
Напомню, что ATmega 2560 является представителем SoC. Это означает, что на одном кристалле микроконтроллер содержит различную периферию. Перечислю, что пригодится для решения нашей задачи (полный перечень, как всегда, в документации):
- таймеры — основное их предназначение, как несложно догадаться, — отсчитывать время и совершать какие‑то действия по результатам этих измерений. Очень часто таймеры просят генерировать прерывание при отмеривании какого‑то кратного интервала времени для реализации часов или определения задержек;
- UART — основной для небольших и маленьких микроконтроллеров канал связи с внешним миром;
- GPIO — самый базовый класс периферии, позволяющий напрямую работать с ногами микроконтроллера.
Ввод/вывод
Настройка таймера
Начнем, как принято, с конца :). Как ты понял, раз из основного цикла мы убираем управление светодиодом, то куда‑то его надо вставить. Самое логичное — это повесить обработчик прерывания по таймеру и в нем отсчитывать время зажигания или гашения светодиода, а также собственно зажигать/гасить светодиод. Приведу небольшой кусочек кода, который инициализирует таймер на генерацию прерывания один раз в 1 мс:
TCCR1A = 0x00;TCCR1B = ( 1 << WGM12 ) | ( 1 << CS11 ) | (1 << CS10);TCCR1C = 0x00;TCNT1 = 0x0000;OCR1A = 250;OCR1B = 0x0000;OCR1C = 0x0000;TIMSK1 |= (1 << OCIE1A);
Что тут происходит? Чтобы не заниматься неудобными пересчетами в программе, проще настроить таймер на срабатывание раз в 1 мс. Таймеры в ATmega — штука крутая и имеют кучу возможностей, о которых ты можешь почитать в мануале, я выбрал режим работы таймера по сравнению со сбросом (
. В этом режиме таймер начинает считать с частотой clk/64 ((
), при достижении значения в регистре OCR1A (OCR1A
– 250 тактов) срабатывает прерывание и счетчик начинает заново.
После настройки таймера выставляй флаг разрешения прерывания по сравнению Timer1 TIMSK1
.
Теперь объявляй обработчик прерывания, выглядит это следующим образом:
ISR(TIMER1_COMPA_vect){ // Вызываем пользовательский обработчик прерывания по таймеру
user_timer_ISR();}
Таблица векторов прерываний
Вектор прерывания — это не что иное, как адрес, на который микроконтроллер передает управление при возникновении прерывания. По этому адресу располагается инструкция перехода в функцию — обработчик прерывания или (не обязательно) инструкция возврата из прерывания. Зачастую все среды разработки предлагают заполнять по умолчанию таблицу векторов прерываний инструкцией возврата из прерывания, чтобы при ошибке во время разработки (ты включил прерывание или забыл / не планировал писать обработчик) выполнение программы вернулось в нормальное русло. Таблица векторов прерываний располагается по младшим адресам Flash микроконтроллера. По нулевому адресу располагается так называемый Reset-вектор, на этот вектор микроконтроллер передает управление при старте или при перезагрузке.
Для универсальности я сделал вызов внешней функции, в нее ты потом можешь вынести различные вкусности, которые хочешь обрабатывать раз в миллисекунду. Я же поставил моргание светодиодом и анализ состояния дискретного входа (то есть проверка на замыкание/размыкание контактного датчика).
// Моргаем светодиодом
blink_led();// Анализируем состояние дискретных входов
process_input();
Обработка состояния дискретного входа
Для начала надо инициализировать GPIO микроконтроллера, сказав ему, что ты хочешь использовать определенные ноги как получатель входных сигналов. Делается это следующим образом:
// Включаем внутреннюю подтяжку к «1»
PORTL |= _BV(PL6);// Настраиваем пин PL6 на вход
DDRL &= ~_BV(PL6);
Что такое внутренняя подтяжка? Переключив ногу на вход, ты сказал микроконтроллеру, что он должен снимать сигнал с этой ноги. Но что делать, если нога «болтается в воздухе», то есть контактный датчик разомкнут?
info
Существуют микроконтроллеры, позволяющие делать подтяжку к обоим значениям: или к 1, или к 0.
Давай определимся для начала, какое состояние будет считаться замкнутым. Я выбрал замыкание ноги на GND
. Сейчас объясню почему.
Микроконтроллер понимает только два состояния дискретного входа: к ноге приложено напряжение (логическая единица) или нога замкнута на землю (логический ноль), все остальное есть неопределенность. То есть каждый раз, считывая состояние, ты не можешь точно сказать, какое же оно на самом деле, и микроконтроллер может посчитать его как за 0, так и за 1.
Для разрешения этой дилеммы используют подтяжки к 1 (через ограничительный резистор замыкают на линию питания) или к 0 (замыкают ногу на землю), то есть задается значение по умолчанию. Таким образом, если нога «висит в воздухе», то, зная, к какому значению у тебя подтяжка, ты можешь точно подтвердить факт «нога в воздухе» и получить вполне определенное значение.
В ATmega 2560 (как и во всем семействе микроконтроллеров mega) существует функция внутренней подтяжки к 1, то есть микроконтроллер сам разрешает ситуацию «нога в воздухе». Теперь ты знаешь ответ на вопрос, зачем использовать состояние «замкнуто» контактного датчика как замыкание на GND. Если нога «в воздухе», то ты прочитаешь 1, если нога замкнута на землю, то ты прочитаешь 0.
Чтение состояния дискретного входа осуществляется чтением регистра PINx и маскированием соответствующего бита:
uint8_t cur_state = (PINL & _BV(PL6));
Дальше анализируем состояние и в зависимости от него устанавливаем нужный режим:
// Изменилось, начинаем моргать в соответствии с новым режимом
if (cur_state == 0) ch_blink_mode(wm_alarm);else ch_blink_mode(wm_normal);
warning
При работе с микроконтроллером постарайся убрать все металлические предметы, чтобы предотвратить случайное короткое замыкание и выход платы из строя.
А поговорить?
Теперь давай займемся реализацией терминала, то есть организуем общение с микроконтроллером. Как я уже упоминал, использовать мы будем UART0
. UART0
является периферийным устройством, и для указания микроконтроллеру, что ты хочешь именно его, необходимо произвести некоторые манипуляции, то есть настроить скорость, проверку четности, длину слова.
Для облегчения жизни я сделал небольшой макрос (он стандартный, ничего инновационного) для настройки регистра скорости:
#define UART_CALC_BR( br ) ( ( uint16_t )( ( F_CPU / ( 16UL * (br) ) ) - 1 ) )
Настроим сам USART0
.
uint16_t br = UART_CALC_BR(9600);// Настройка скорости обмена
UBRR0H = br >> 8;UBRR0L = br & 0xFF;// 8 бит данных, 1 стоп-бит, без контроля четности
UCSR0C = ( 1 << USBS0 ) | ( 1 << UCSZ01 ) | ( 1 << UCSZ00 );// Разрешить прием и передачу данныхUCSR0B = ( 1 << TXEN0 ) | ( 1 << RXEN0 ) | (1 << RXCIE0 );
Отдельно прокомментирую последнюю строку. В ней мы включаем передатчик 1 <<
, приемник 1 <<
и включаем прерывание по приему байта 1 <<
. Обращаю твое внимание, что всю работу я перекидываю на прерывания. Таким образом я разгружаю основной цикл программы от необходимости контролировать наличие очередного байта в приемнике UART, так как это чревато либо пропуском байта (пока ты занимался другими делами, пришло 2 байта), либо ожиданием очередного байта, что остановит работу основного кода. Для приема/передачи UART воспользуемся буферами FIFO по одному на каждое направление.
Не подскажете, как пройти в библиотеку?
Разработчики стандартных библиотек для встраиваемых решений позаботились о приближении этих библиотек к стандарту С/С++. Ключевое слово тут «приближение». Реализация многих функций достаточно урезана ввиду ограниченности ресурсов микроконтроллеров, а некоторые закрыты заглушками для совместимости. Так, функции по обработке строк (sscanf
, sprintf
и подобные) достаточно требовательны к использованию стека. При наличии на борту 4 Кб оперативной памяти это достаточно критично. Поэтому, если ты решишь использовать ту или иную функцию, читай описание к ней не в стандартных мануалах, а в документации на конкретную библиотеку.
Теперь позаботимся об удобном обмене данными. Так как у нас будет реализация терминала, я решил, что удобнее всего использовать стандартные библиотечные функции printf
и fgets
. Для того чтобы эти функции заработали в том виде, как они задуманы, необходимо создать свой поток и реализовать функции отправки и приема байта:
// Объявляем поток ввода/вывода, который будем использовать для перенаправления stdio
static FILE uart_stream = FDEV_SETUP_STREAM(uart_putc, uart_getc, _FDEV_SETUP_RW);
А также перенаправить stdio
в наш поток
stdout = stdin = &uart_stream;
Рассмотрим чуть ближе прием байта из порта:
int uart_getc( FILE* file ){ int ret; // Ждем, пока появятся данные в FIFO, если там ничего нет
while(FIFO_IS_EMPTY( uart_rx_fifo ) ); __builtin_avr_cli(); // Запрещаем прерывания
ret = FIFO_FRONT( uart_rx_fifo ); FIFO_POP( uart_rx_fifo ); __builtin_avr_sei(); // Разрешаем прерывания
return ret;}
Для того чтобы функция gets
работала, приходится ждать, пока очередной байт поступит в FIFO. При извлечении байт из FIFO рекомендуется отключать прерывания, чтобы во время извлечения байта не произошло прерывание и один ресурс (FIFO) не начали использовать с двух сторон.
Займемся отправкой байта.
int uart_putc( char c, FILE *file ){ int ret; __builtin_avr_cli(); // Запрещаем прерывания
if( !FIFO_IS_FULL( uart_tx_fifo ) ) { // Если в буфере есть место, то добавляем туда байт
FIFO_PUSH( uart_tx_fifo, c ); // и разрешаем прерывание по освобождению передатчика
UCSR0B |= ( 1 << UDRIE0 ); ret = 0; } else { ret = -1; // Буфер переполнен } __builtin_avr_sei(); // Разрешаем прерывания return ret;}
Вот тут встречается интересный момент: включение прерывания по освобождению передатчика. Так как не очень‑то хочется вручную складывать байты в передатчик, то воспользуемся прерыванием по очистке передатчика. Повесим на это прерывание обработчик, который кладет в передатчик очередной байт из FIFO, если в FIFO что‑то есть:
ISR( USART0_UDRE_vect ){ if( FIFO_IS_EMPTY( uart_tx_fifo ) ) { // Если данных в FIFO больше нет, то запрещаем это прерывание
UCSR0B &= ~( 1 << UDRIE0 ); } else { // Иначе передаем следующий байт char txbyte = FIFO_FRONT( uart_tx_fifo ); FIFO_POP( uart_tx_fifo ); UDR0 = txbyte; }}
Для завершения картины приведу код обработчика по приему байта:
ISR( USART0_RX_vect ){ unsigned char rxbyte = UDR0; if( !FIFO_IS_FULL( uart_rx_fifo ) ) { FIFO_PUSH( uart_rx_fifo, rxbyte ); }}
Отладка
Так как мы ограничиваемся только самими Arduino и не покупаем железный отладчик, то реализованный класс работы с UART удобно использовать для отладки твоей программы. Просто в нужных местах вставляешь трейс‑вывод с помощью printf
. Только не увлекайся и помни про пожирание стека и памяти библиотечными функциями. Для вывода значения регистров и значения переменных в нужных местах этого вполне достаточно.
Хранение настроек
Вот мы и подошли к самому, с моей точки зрения, любопытному. Настройки хотелось бы хранить даже после перезагрузки или отключения питания устройства. Для этой цели в микроконтроллере есть EEPROM. Для работы с этой памятью присутствует библиотека eeprom.
. Можно пойти альтернативным путем и реализовать запись/чтение самостоятельно, это несложно. Но если есть уже готовое решение, то предлагаю им и воспользоваться.
Итак, в арсенале имеются функции eeprom_read_byte
, eeprom_write_byte
, eeprom_read_block
, eeprom_write_block
. У EEPROM есть одна особенность — она бывает занята, поэтому разработчики библиотеки (и в этом я к ним присоединяюсь) рекомендуют вызывать eeprom_busy_wait
или проверять готовность функцией eeprom_is_ready
.
Так как софт для микроконтроллера — это вещь автономная, то настоятельно рекомендуется всячески защищать настройки контрольными блоками от случайных или несанкционированных изменений. В нашем примере я использую один контрольный байт на блок настроек, который сигнализирует о том, что данные мною были записаны. Соответственно, если этот байт равен определенному значению (в нашем случае это 0), то это означает, что данные в блоке верны. Для безопасности перед записью этот байт я перевожу в состояние 1, после окончания записи возвращаю это значение в 0". Данные манипуляции нужны для того, чтобы во время записи при внезапной перезагрузке микроконтроллер не загрузил мусор из памяти, а взял настройки по умолчанию.
danger
Статическое электричество смертельно для микросхем, избегай работы с микроконтроллерами в синтетической и шерстяной одежде, по возможности используй заземляющие браслеты.
3, 2, 1, поехали!
Итак, теперь прошиваем контроллер, запускаем терминал
#minicom -D `ls /dev/serial/by-id/*arduino*` -c on -b 9600
Arduino выводит сообщение о старте и дает приглашение:
Started...
Arduino>
Для терминала я реализовал следующий набор команд (help не реализовывал):
get <mode>
вывод настроенных временных интервалов для режима mode
<mode> := [norm|alarm]
norm — режим нормы
alarm — режим аварии
set <mode> <time_on1> <time_off1> ... <time_onN> <time_offN> [0]
установка (только запись в память) временных интервалов для режима mode
<mode> см. команду get
<time_on1> — задержка для зажженного светодиода первого периода
<time_off1> — задержка для погашенного светодиода первого периода
<time_onN> — задержка для зажженного светодиода N-го периода
<time_offN> — задержка для погашенного светодиода N-го периода
0 — завершает последовательность, необязательный параметр
Примечание: чтобы настройки вступили в силу, необходимо дать команду reload
reload
перечитывание и применение сохраненных настроек
eeprom
вывод EEPROM на экран
После самого первого запуска можешь посмотреть, что в EEPROM микроконтроллера пусто и взяты настройки по умолчанию. Отправь команду eeprom
и ищи адрес 0x100 — это стартовый адрес. Начиная с этого адреса, идет 20 слов (по 2 байта) значений задержек для состояния нормы, за ними контрольный байт первого блока, после этого 20 слов значений задержек для состояния аварии и контрольный байт второго блока.
Давай теперь изменим значения для состояния нормы:
Arduino> set norm 300 200 300 200 500 500 500 500
Writing new parameters
OK
Arduino>
Теперь скажи Arduino, чтобы он перечитал настройки:
Arduino> reload
Reloading settings
OK
Arduino>
Замечу, что здесь ты не перезагрузил микроконтроллер и прочитал настройки при старте, а настройки перечитались и применились на лету. Теперь смотри на светодиод Arduino, он стал мигать в соответствии с вновь заданными настройками.
Давай теперь заглянем в EEPROM и посмотрим, что там изменилось. Снова давай команду eeprom
. Ты должен увидеть что‑то подобное рисунке.
Ну а теперь самое волнующее. Возьми скрепочку (у меня это кусочек зачищенной витой пары) и замкни контакты. Теперь Arduino стал моргать аварийно. И сразу же возникает вопрос, ведь авария должна быть на размыкание? Да, для отладки состояния перемешаны. Чтобы сделать боевую версию, найди анализ состояния дискретного входа и поменяй местами режимы:
// Изменилось, начинаем моргать в соответствии с новым режимом
if (cur_state == 0) ch_blink_mode(wm_normal);else ch_blink_mode(wm_alarm);
Заключение
Использование ATmega 2560 в реализации Arduino открывает большой простор для обучения программированию микроконтроллеров, так как это достаточно мощный с широким набором периферии микроконтроллер. Сегодня я бегло познакомил тебя с прерываниями, таймерами, UART, GPIO и EEPROM, но это всего лишь самая вершина айсберга под названием embedded development. Будь аккуратен и внимателен, так как си + микроконтроллер — это возможность не только прострелить себе ногу, но и укусить себя за локоть :).