Некоторое время назад мне попалась статья о том, как сделать стробы для авиамодели. Вообще-то мне не очень хотелось ввязываться в электронику, но идея поставить разные моргалки на модель меня, почему то, зацепила. Некоторые не видят в этом смысла — лучше облепить модель светодиодными лентами сверху донизу, и красиво и видно издалека. Но мне больше нравятся копийные модели, а значит все лампочки, стробы, фары и другие огни должны включаться и выключаться так же как и у оригинала.
По ряду причин предложеный вариант мне не подошел. В этой статье я описал свой вариант контроллера моргающих и не моргающих светодиодов для авиамодели.
Контроллер сделан на базе ATTiny13A, т.о. статья будет так же полезна тем, кто разбирается с микроконтроллерами AVR. Я старался все разжевать и разложить по полочкам, так что статья будет интересна в первую очередь новичкам.
Способности железяки можно оценить по этому видео:
Вступление
Когда я начал ковырять этот вопрос и даже спаял плату предложеную в той статье, оказалось что это совсем не то, что мне нужно. Во-первых всего два канала, которые могут моргать только по очереди. Можно подрегулировать тайминги, но алгоритм зашит в прошивке. Во-вторых на плате присутствуют аж 3 кнопки, которые добавляют лишние граммы. Я не собираюсь переставлять моргалку с модели на модель и перепрограммировать светодиоды после каждого полета, а значит эти кнопки мне без надобности. Я согласен один раз напаять провода прямо на контроллер и запрограммировать тот алгоритм, который нужен. И наконец в-третьих прошивка есть только в бинарном виде, без исходников, а значит доработать что либо нельзя.
Задумывая очередную модель я сразу прикинул сколько светодиодов мне нужно и как они будут моргать. В результате «переписи» выяснилось, что мне нужно 4 канала (в каждом канале по 2-3 светодиода):
- БАНО (Бортовые АэроНавигационные Огни — зеленый и красный фонари на концах крыльев) – эти штуки включены всегда
- Посадочные фары – будут включаться и выключаться с пульта, т.е. схема должна реагировать на PWM сигнал с приемника
- Стробы — белые огни которые время от времени моргают короткими и яркими вспышками
- Проблесковые маячки – красные огни, которые включаются и выключаются плавно, напоминая крутящийся ламповый олдскульный проблесковый маяк.
Модель большая, летает далеко. А значит, что бы светодиоды было видно, они должны быть мощными. На предыдущей модели я делал БАНО на одноваттных светодиодах – их прекрасно видно с расстояния в 50м даже в яркий солнечный день вечер. Значит это мой размерчик.
Вот только оказалось что запитать мощные светодиоды не так то просто. На борту имеется только питание от линейного стабилизатора (на плате регулятора двигателя). Это значит подключив туда даже один мощный светодиод (через резистор, разумеется) получим очень большие потери в тепло. Большие настолько, что термоусадка регулятора плавится до дыр. Подробнее с рассчетами тут
Импульсный стабилизатор напряжения лучше, но как оказалось светодиодам нужно стабилизировать не напряжение, а ток. Благо нашлась микруха, которая это делает весьма неплохо. Это была вторая часть подготовки, которую я описал тут.
Электроника
С требованиями разобрались. Пора браться за паяльник.
Я в электронике, в общем то, новичок. А потому я творчески переработал схему от Acinonyx (который в свою очередь позаимствовал ее у САМОКАТ ВЕТЕРАНА). Мне потребовалось изменить следующее:
- Выкинуть кнопки
- Завести PWM вход от приемника
- 3 ноги ATTiny определить как выходы и подключить к ним светодиодные драйверы
- Добавить четвертый драйвер, который будет всегда включен (для БАНО)
В общем от оригинала мало что осталось.
В качестве драйвера удачно подошла микросхема ZXLD1350 которая как раз и рассчитана на питание одноваттных светодиодов (ток до 350мА). При чем в каждый канал можно ставить последовательно любое светодиодов, лишь бы все вместе суммарно вкладывались в напряжение питания. Т.е. если я буду запитывать схему от батареи 3S (11.1В) то смогу в каждый канал поставить до 3 светодиодов на кажом из которых падает 3.2В.
Микроконтроллер я запитал отдельно от приемника, тем же проводом что и PWM вход.
Схема. Каждый канал построен по схеме из даташита. Таких каналов на плате 4 штуки (я нарисовал только один). Светодиодов я нарисовал 3, но, как я уже сказал, можно ставить любое количество светодиодов в каждый канал. Можно даже ставить светодиоды разного цвета (на них падает разное напряжение), главное что бы они были рассчитаны на одинаковый ток. Драйвер сам подберет такое напряжение, что бы ток через диоды не превышал 350мА.
Вход ADJ трех из каналов подключен к выходу контроллера через транзистор. У драйвера ZXLD1350 есть специальный механизм с помощью которого можно включать и выключать светодиод с контроллера. Более того, можно плавно регулировать яркость меняя напряжение на входе или с помощью ШИМ. Вот только рабочее напряжение входа от 0.3В до 2.5В, а с контроллера выдает 5В. Благо даташит рекомендует решение в виде транзистора. Нужно только учесть, что этот транзистор инвертирует логическое состояние – ноль на ноге контроллера будет включать светодиод, а единица выключать. Впрочем, это не проблема пофиксить программно.
В целях экономии веса я решил попробовать сделать двухстороннюю плату. Я так и не подружился с ЛУТом, а вот с фоторезистом все вышло с первого раза. Пробовал еще поиграться с паяльной маской, но нарушил технологию и маска легла плохо (а кое где вообще отвалилась). Ошибки учтены на будущее, а эту попытку оставлю как есть. Для первого раза все равно покатит.
Разводка платы. Крестики по краям это стыковочные метки. Я вырезал текстолит немного с запасом, потом в местах крестиков сверлил отверстия по которым потом совмещал маски. Метализацию отверстий не делал, справился перемычками. Ну и ножки конденсатора тоже в качестве перемычки между сторонами работают.
Готовое изделие. Лишний текстолит обрезал по рамочке. Получилась платка 27х22мм и весом 4г. Ну еще 2г на провода и раземы получилось. К приемнику устройство подключается через стандартный трехпиновый JR разъем. Светодиодные драйверы берут питание с балансировочного разъема батарейки.
Кому 1Вт мало может посмотреть на микросхему ZXLD1360. Она рассчитана на питание 3Вт светодиодов (ток 750мА). Схема включения и цоколевка такие же, так что разводка платы подойдет. Только номиналы некоторых деталей поменять нужно, курите даташит.
Для тех, кто не прокачался еще в травлении двухсторонних плат, я так же выкладываю несколько вариантов односторонних – для 2, 3 и 4 каналов.
Светодиоды купил у китайцев на ебее. Покупал специально без радиатора, который не везде влезет. В качестве радиатора использовал кусочки алюминиевой полосы. Светодиод можно крепить с помощью специального термопроводящего скотча или клея. Вот так это выглядит в тестовом варианте (тут радиатор маленький, греется)
А вот так на предыдущей модели.
Прошивка
Теперь нужно вдохнуть жизнь в эту железяку. Поскольку исходников прошивки от САМОКАТ ВЕТЕРАНА в инете не нашлось, то пришлось делать все самому. Нет, я, конечно, дизассемблировал его прошивку, что бы посмотреть что внутри, но гораздо полезнее было просто прочитать спецификацию на ATTiny13.
Несмотря на то, что в микроконтроллере всего 1Кб флеша, я решил писать на С. Так удобнее и нагляднее. Скетчи ардуино, конечно, в чем то попроще будут, но все что я задумал не влезет в память контроллера. Поэтому пришлось спуститься на более низкий уровень и программировать регистры напрямую. К моему удивлению компилятор (gcc 3.4.2 из Atmel Studio 6) сгенерил довольно хороший код. Было, правда, пару мест, где компилятор действовал неоптимально, но эти места удалось скорректировать.
Архитуктурная проблема прошивки в том, что мне требовалось делать несколько концептуально разных действий одновременно – тут моргать, тут не моргать, тут рыбу заворачивать, тут слушать ШИМ вход, тут генерить ШИМ на выходе.
Приведу классический пример. Что если нам нужно моргать одним светодиодом? Ну тогда наша программа будет выглядеть как то так:
while(1)
{
led1(on);
delay(500);
led1(off);
delay(500);
}
А что если нам нужно мигать двумя светодиодами, да еще и с разной частотой? Ну можно, конечно, извратиться и написать чтото типа такого:
while(1)
{
led1(on);
delay(300);
led2(on);
delay(200);
led1(off);
delay(500);
led2(off);
delay(200);
}
Но скорее всего будет очнь сложно подобрать тайминги и последовтельность включений-выключений. Если получится, конечно (в чем я сомневаюсь). А если нужно тремя диодами моргать? А четырьмя?
Правильное решение — использовать таймеры. Но есть проблема: таймер в микроконтроллере всего 1, да и тот восьмибитный.
На самом деле там где есть один таймер можно сделать скольугодно много софтварных таймеров. Выглядит это так: хардварный таймер тикает с большой частотой. На каждое срабатывание таймера обработчик проверяет а не вышло ли заданное время для програмного таймера. Если вышло, то нужно вызвать обработчик.
Давайте посмотрим, как это будет выглядеть в коде.
// Pointer to a timer handler
typedef void (*eventHandler)();
// Software timers list
typedef struct timer_t
{
uint16_t timeout;
eventHandler handler;
} timer_t;
#define TIMERS_LIST_SIZE 5
timer_t timersList[TIMERS_LIST_SIZE];
Програмный таймер — это счетчик сколько раз должен прокрутиться основной (железный) таймер прежде чем вызвать обработчик. Указатель на обработчик прилагается. Для моих задач вполне достаточно трех таких записей, но на всякий случай я сделал список програмных таймеров размером в 5 элементов.
Вопрос настройки микроконтроллерного таймера я опишу чуть позже. А сейчас про реализацию програмных таймеров. Функция инициализации выглядит просто – обнуляем список таймеров
void setupEventQueue()
{
// Clear timers list
memset(timersList, 0, sizeof(timersList));
}
Для того что бы добавить программный таймер просто ищем пустой слот и вписываем в него значения таймаута плюс указатель на обработчик. Проверка на ошибки отсутствует в целях экономии места в контроллере.
void addTimer(eventHandler handler, uint16_t timeout)
{
// Search through the timers list to find empty slot
for(timer_t * timer = timersList; timer < timersList + TIMERS_LIST_SIZE; timer++)
{
if(timer->handler != NULL)
continue;
// Add the timer to the list
timer->handler = handler;
timer->timeout = timeout;
break;
}
}
Основной цикл выглядит следующим образом.
void runEventLoop()
{
runTimer();
// Set up sleep mode
set_sleep_mode(SLEEP_MODE_IDLE);
while(1) // Main event loop
{
wdt_reset();
// Sleep until the timer event occurs
sleep_enable();
sleep_cpu();
sleep_disable();
//Iterate over timers
for(timer_t * timer = timersList; timer < timersList + TIMERS_LIST_SIZE; timer++)
{
// Skip inactive timers
if(timer->handler == NULL)
continue;
if(timer->timeout) // Decrement timeout value
{
timer->timeout--;
}
else // If it is already zero - execute handler
{
timer->handler();
timer->handler = NULL;
}
}
}
}
Вначале запускаем таймер (об этом чуть ниже, там не все так тривиально). Вместо активного ожидания я использую sleep. В начале каждого цикла процессор переходит в режим Idle. Это означает что сам CPU заснет, но все таймеры (ок, весь, он один!) продолжат работать. Когда таймер досчитает до конца и ресетнется в ноль возникнет прерывание, которое разбудит процессор и программа пойдет дальше. Как раз то, что нам нужно.
Да, в светодиодной моргалке много электричества не сэкономишь, но в будущем тот же каркас можно использовать и в других приложениях где засыпание процессора может быть очень кстати.
Если мы проснулись это означает что пора пройтись по списку програмных таймеров. В каждой записи уменьшаем значение счетчика. Если уже дошли до нуля, то вызываем обработчик, после чего удаляем таймер из списка (записав NULL в указатель на обработчик).
Поскольку все происходит в одном потоке, то никаких мьютексов и локов не требуется.
Что бы поморгать светодиодом с фиксированой частотой, обработчик будет выглядеть так: инвертируем состояние светодиода просим систему вызвать этот же обработчик опять через некоторое время.
#define LED_A_PIN PORTB0
void toggleLedATask()
{
PORTB ^= (1 << LED_A_PIN);
addTimer(toggleLedATask, TIMEOUT_MS(300));
}
Что бы это все заработало, нужно чтобы обработчик как-то вызвался в первый раз. Для этого перед запуском основного цикла — просто положим в очередь сообщение, что пора вызвать обработчик с задеркой 0 мс (т.е. сразу при первой же возможности).
int main(void)
{
// Set up ports
PORTB = 1 << LED_A_PIN; // LEDs switched off
DDRB = 1 << LED_A_PIN; // output mode for LED pins
setupEventQueue();
addTimer(toggleLedATask, TIMEOUT_MS(0));
sei();
runEventLoop();
}
Вначале тут настраивается пин на вывод. Напоминаю что светодиоды у нас подключены через инвертор. Значит для того, что бы выключить светодиод по умолчанию нужно записать единицу в порт.
Ну моргать туда-сюда это не интересно. Нельзя ли что нибудь покруче? Например блымнуть раз, потом после паузы моргнуть дважды, потом трижды, повторить, взболтать, не размешивать. Ну это тоже не сложно.
#define LED_B_PIN PORTB1
uint8_t delayIndex = 0;
const uint16_t delays[] =
{
TIMEOUT_MS(100), //on
TIMEOUT_MS(700), //off
TIMEOUT_MS(100), //on
TIMEOUT_MS(200), //off
TIMEOUT_MS(100), //on
TIMEOUT_MS(700), //off
TIMEOUT_MS(100), //on
TIMEOUT_MS(200), //off
TIMEOUT_MS(100), //on
TIMEOUT_MS(200), //off
TIMEOUT_MS(100), //on
TIMEOUT_MS(1200), //off
};
void complexLedTask()
{
PORTB ^= (1 << LED_B_PIN);
uint16_t delay = delays[delayIndex];
delayIndex ++;
if(delayIndex >= sizeof(delays)/sizeof(uint16_t)) //dim(delays)
delayIndex = 0;
addTimer(complexLedTask, delay);
}
Просто делаем таблицу с таймингами. Обработчик каждый раз меняет состояние светодиода и ждет уремя указаное в таблице.
Для того, что бы моргать несколькими светодиодами одновременно и независимо, просто добавляем несколько аналогичных обработчиков, не забыв настроить порт и добавить в список обработчиков.
int main(void)
{
// Set up ports
PORTB = 1 << LED_A_PIN | 1 << LED_B_PIN | 1 << LED_C_PIN; // LEDs switched off
DDRB = 1 << LED_A_PIN | 1 << LED_B_PIN | 1 << LED_C_PIN; // output mode for LED pins
setupEventQueue();
addTimer(toggleLedATask, TIMEOUT_MS(0));
addTimer(complexLedTask, TIMEOUT_MS(0));
addTimer(blinkLedCTask, TIMEOUT_MS(0));
sei();
runEventLoop();
}
Разумеется, к такому стилю программирования еще нужно привыкнуть, но в целом подход работает неплохо. Вспомните, если мы пишем многопоточное приложение для большого компа, обычно каждый поток имеет вечный цикл и, возможно, какой нибудь sleep или wait. Считайте, что представленые выше обработчики — это тело того самого вечного цикла, а вызов addTimer() это тот же самый sleep.
Как часто должен тикать основной таймер? Если он будет тикать редко, то это снизит точность отмеряемых временных отрезков. С другой стороны на каждый цикл таймера нужно будет сделать определенное число полезных действий. И эти действия нужно успеть закончить до следующего цикла таймера. Значит таймер тикать должен и не очень часто так же.
Т.е. не часто и не редко. Но как именно? Ок, для предыдущей задачи диапазон возможных значений достаточно большой. Но нужно так же не забывать про задачу «слушать PWM вход». Конкретнее, там ходят импульсы длительностью 800-2200мкс и эту длину нам прийдется измерять. Для нашей задачи включать/выключать светодиод по команде с пульта будем считать так: если импульс короче чем 1500мкс – светодиод выключен, если длинее – включен.
В переводе на язык микроконтроллеров и таймеров мы будем считать сколько тиков таймера уместится в измеряемом отрезке времени. Проблема возникает когда длительность импульса примерно равна пороговой. Тогда возможны ложные срабатывания и светодиод будет промаргивать при изменении длины импульса. Что бы уменьшить вероятность промаргивания нам нужно точнее измерять длину импульса. Я думаю разрешение таймера должно быть в районе 1-2 мкс – такое разрешение обеспечит достаточную точность измерений.
Раз уж речь зашла про конкретные цифры нужно разобраться с частотой микроконтроллера. Контроллер может тактироваться от внутреннего и от внешнего генератора. Внешний генератор точнее, но это дополнительные детали и вес. Да и не нужна нам точность особо. Из внутренних генераторов доступны 128кГц, 4.8МГц и 9.6МГц. 128кГц маловато будет, будем выбирать между двумя другими вариантами.
Таймер в свою очередь может иметь ту же частоту, что и микроконтроллер, а может задействовать делитель частоты на 8, 64, 256 или 1024. Сам же таймер считает от 0 до 255 и потом сбрасывается в 0. В случае если делитель не используется один тик таймера соответствует одному тику процессора, что в большинстве случаев соответствует одной команде. Мы собирались делать полезную работу каждый полный цикл таймера. Но если нам эту работу нужно будет делать каждые 256 команд, то мы попросту не будем успевать делать эту работу (либо ее должно быть очень очень мало).
Итак, нужно выбирать между частотой 4.8МГц и 9.6Мгц, и делителями 8, 64 и 256. Как по мне, вариант 4.8МГц с делителем 8 довольно удачный. Таймер будет тикать с частотой 4.8МГц / 8 = 600кГц. А это означает, что один тик будет занимать 1.666мкс. Как раз укладывается в искомые 1-2мкс. Полный цикл таймера будет занимать 1,666 * 256 = 426.66 мкс. В качестве програмного таймера мы используем 16-битную переменную, а значит мы способны отмерять отрезки времени 65536 * 426,66мкс = 27,96с (с точностью тех же самых 426,66мкс)
Код запуска таймера:
void runTimer()
{
// Reset timer counter
TCNT0 = 0;
// Run timer at 4.8MHz/8 = 600 kHz
// This gives 1.667 uSec timer tick, 426.667 uSec timer interval
// Almost 28 seconds with additional 16bit SW timer value
TCCR0A = 0; // Normal mode
TCCR0B = 0 << CS02 | 1 << CS01 | 0 << CS00; // run timer with prescailer f/8
}
В коде выше я использовал загадочный макрос TIMEOUT_MS. Пришло время его расшифровать.
#define TIMEOUT_MS(t) ((uint32_t)t * 600 / 256) //4.8MHz / (8 prescailer * 256 full timer cycle * 1000 since we are counting in ms)
Этот макрос определяет количество циклов по 426,6мкс необходимых для отмерения заданого количества милисекунд. К сожалению когда я бахнул туда полную формулу (ту, что в комментарии) то компилятор начал генерить страшные ворнинги с которыми я не справился. Пришлось формулу пересчитать до непонятных теперь 600/256.
Но вернемся к слушанию ШИМ входа. Что бы было чуть понятнее, расскажу еще раз как все работает, но другими словами. Основной 8-битный таймер тикает от 0 до 255. Каждый полный цикл таймера мы процессим список программных таймеров и запускаем обработчики, если нужно. Помимо этого значение самого 8-битного таймера используется в измерение длины импульса на входе. Делается это очень просто: если начался импульс – запоминаем значение таймера. Пока импульс идет таймер продолжает тикать. К моменту когда закончится импульс таймер дотикает до какого то нового значения. Соответственно по разнице значений мы можем вычислить длину импульса просто умножив на время одного тика (1,666мкс)
Стоп! Таймер то у нас 8битный а это означает, что таким способом можно измерить только импульсы длиной до 256 * 1,66 = 426,66мкс, в то время как входящие импульсы длиной до 2200мкс. Не беда! Можно искусственно расширить счетчик таймера добавив столько старших байт сколько нужно. Действует обычная бинарная математика – когда младший байт переполнился инкрементируем старшие байты.
// Additional high byte for 8bit timer value
volatile uint8_t tcnth;
void runTimer()
{
// Reset timer counters
tcnth = 0;
TCNT0 = 0;
// Run timer at 4.8MHz/8 = 600 kHz
// This gives 1.667 uSec timer tick, 426.667 uSec timer interval
// Almost 28 seconds with additional 16bit SW timer value
TCCR0A = 0; // Normal mode
TCCR0B = 0 << CS02 | 1 << CS01 | 0 << CS00; // run timer with prescailer f/8
TIMSK0 = 1 << TOIE0;
}
Почти все тоже самое. Добавилась только переменная tcnth – «старший» байт в дополнение к младшему байту внутри таймера. Еще важна последняя строка – она включает прерывание по переполнению таймера. Это прерывание и будет инкрементировать старший байт:
ISR(TIM0_OVF_vect)
{
// Increment high byte of the HW counter
tcnth++;
}
Обратите внимание, что переменная tcnth объявлена как volatile. Без этого ключевого слова компилятор в другой части программы может подумать, что переменная не меняется и оптимизировать лишнего. Он же не в курсе, что переменная изменяется в прерывании (по сути в другом потоке).
Для того, что бы словить начало и конец импульса можно задействовать специально предназначеный для этого pin change interrupt – прерывание, которое вызовется как раз когда значение на входе поменяется. Т.о. не нужно постоянно опрашивать вход – всю работу сделает микроконтроллер. Нам остается только написать обработчик этого прерывания
uint16_t pwmPulseStartTime;
#define PWM_THRESHOLD 900 // number of pulses in 1500 uS at 4.8MHz with /8 prescailer = 1500 * 4.8 / 8 = 900
// Pin Change interrupt
ISR(PCINT0_vect)
{
/*
// Get the current time stamp
uint16_t curTime = (tcnth << 8) + TCNT0;
Unfortunately gcc generates plenty of code when constructing 16 bit value from 2 bytes. Let's do it ourselves
*/
union
{
struct
{
uint8_t l;
uint8_t h;
};
uint16_t val;
} curTime;
// Get the current time stamp
curTime.h = tcnth;
curTime.l = TCNT0;
// It may happen that Pin Change Interrupt occurs at the same time as timer overflow
// Since timer overflow interrupt has lower priority let's do its work here (increment tcnth)
if(TIFR0 & (1 << TOV0))
{
curTime.h = tcnth+1;
curTime.l = TCNT0;
}
if(PINB & (1 << PWM_INPUT_PIN)) // On raising edge just capture current timer value
{
pwmPulseStartTime = curTime.val;
}
else // On failing edge calculate pulse length and turn on/off LED depending on time
{
uint16_t pulseLen = curTime.val - pwmPulseStartTime;
if(pulseLen >= PWM_THRESHOLD)
PORTB |= (1 << LED_C_PIN);
else
PORTB &= ~(1 << LED_C_PIN);
}
}
Первая часть обработчика посвящена вытягиванию значения таймерного счетчика (расширенного дополнительным внешним байтом). К сожалению, вычитывание значения в лоб не работало – время от времени светодиод самопроизвольно помаргивал. Это происходило потому, что 2 прерывания возникали примерно одновременно. А поскольку у прерывания таймера приоритет ниже, то обработчик иногда не вызывался тогда, когда следовало. В результате старший байт оказывался не увеличеным, а значит общее значение оказывалось на 256 единиц меньше. Это существенно.
Решение довольно простое – проверить, а не возникло ли переполнение таймера и если возникло, сделать ту же самую работу, что и обработчик этого переполнения – сделать +1 к старшему байту.
В этом месте я наткнулся на неоптимальность кода, сгенеренного гнусью. Код (tcnth << 8) + TCNT0 компилировал вот так, как написано — со сдвигами и сложениями. И это не смотря на включенную оптимизацию (-O1). Мне же в этом месте нужно всего лишь 2 байта трактовать как 16-битное число. Пришлось городить огород с юнионами.
Вторая часть обработчика делает собственно полезную работу. Если мы словили начало импульса – просто запомним временнУю метку в переменной pwmPulseStartTime. Если словили окончание импульса — считаем разницу временнЫх меток и включаем/выключаем светодиод в зависимости от значения. Порог срабатывания – 1500мс, или 900 тиков таймера по 1,66мкс каждый.
Чего тут не хватает, так это инициализации этого самого pin change interrupt:
#define PWM_INPUT_PIN PCINT3
void setupPWMInput()
{
// Initialize the timestamp value
pwmPulseStartTime = 0;
// Set up pin configuration
PORTB |= 1 << PWM_INPUT_PIN; // pull-up for PCINT3
DDRB &= ~(1 << PWM_INPUT_PIN); // output mode for LED pins, input mode for PCINT3 pin
// Use PCINT3 pin as input
PCMSK = 1 << PWM_INPUT_PIN;
// Enable Pin Change interrupt
GIMSK |= 1 << PCIE;
}
Почти все готово. Из требований не реализовано только плавное моргание. На самом деле контроллер может только включать и выключать светодиод. Промежуточных значений нет. Но если быстро быстро включать и выключать с заданой скважностью (отношением времени когда светодиод горит к времени когда он не горит), то человеку кажется, что светодиод по прежнему светит ровно, только с меньшей яркостью. Ну а если понемногу менять скважность, то будет казаться, что плавно меняется яркость светодиода.
Можно написать код, который будет включать и выключать светодиод. Благо таймеров теперь можно наделать сколько хочешь. Но зачем, если PWM генерация уже встроена в контроллер? При чем можно независимо управлять аж двумя каналами генерации – на ножках OC0A и OC0B (они же PB0 и PB1).
Работает это так. Единственный таймер крутится как обычно с заданой скоростью. В начале цикла на ножке выставляется единица, по достижению некоторого определенного значения (задается регистрами OCR0A и OCR0B) на ножке выставляется ноль. Далее цикл повторяется. Чем больше значение регистра, тем больше скважность и тем ярче светится диод. Это называется Non-Inverting режим. Поскольку светодиоды подключены через инвертор то нам больше подойдет Inverting mode – включаемся по значению в регистре, выключаемся когда таймер добегает до конца и ресетится.
// Current PWM value
volatile uint8_t pwmAValue = 1;
volatile uint8_t pwmBValue = 1;
void runTimer()
{
// Reset counter counters
tcnth = 0;
TCNT0 = 0;
OCR0A = pwmAValue;
OCR0B = pwmBValue;
// Run timer at 4.8MHz/8 = 600 kHz
// This gives 1.667 uSec timer tick, 426.667 uSec timer interval
// Almost 28 seconds with additional 16bit SW timer value
//TCCR0A = 1 << COM0A1 | 1 << COM0A0 | 1 << COM0B1 | 1 << COM0B0 | 1 << WGM01 | 1 << WGM00; // Fast PWM on OC0A and OC0B pins, inverting mode
TCCR0A = 1 << COM0A1 | 1 << COM0A0 | 1 << WGM01 | 1 << WGM00; // Fast PWM on OC0A pin, inverting mode
TCCR0B = 0 << CS02 | 1 << CS01 | 0 << CS00; // run timer with prescailer f/8
TIMSK0 = 1 << TOIE0;
}
Пришлось слегка подкорректировать инициализацию таймера. Биты WGM00 и WGM01 включают режим генерации Fast-PWM. Биты COM0A0, COM0A1, COM0B0 и COM0B1 включают Inverting mode в каналах A и B. Точнее закоментареная строка включает в обоих, раскоментареная только для OC0A.
Поскольку значения в переменных pwmAValue время от времени меняются, то нужно как то давать знать об этом таймеру. Делать это лучше всего в обработчике переполнения.
ISR(TIM0_OVF_vect)
{
// Update the PWM values
OCR0A = pwmAValue;
OCR0B = pwmBValue;
// Increment high byte of the HW counter
tcnth++;
}
Можно, конечно, и напрямую сразу пихать значения в регистры OCR0A и OCR0B, но даташит это не рекомендует. Это может привести к «проскакиванию», когда значение пина меняется раньше или позже чем должно было бы. Визуально это проявлялось бы в нежелательном резком и кратковременном изменении яркости.
Саму яркость можно менять в уже знакомых програмных таймерах. Например, так:
uint8_t directionA = 0;
void pwmLedATask()
{
if(directionA) // Incrementing
{
pwmAValue += 2;
if(pwmAValue == 255)
directionA = 0;
}
else //decrementing
{
pwmAValue -= 2;
if(pwmAValue == 1)
directionA = 1;
}
addTimer(pwmLedATask, TIMEOUT_MS(2));
}
Просто понемногу увеличиваем или уменьшаем значение переменной pwmAValue, которое потом будет занесено в соответствующий регистр. Хотя для эмуляции настоящего проблескового маячка придется придумать что нибудь покрасивее. Например, так:
typedef struct complexPWM
{
uint8_t step;
uint8_t maxValue;
uint16_t delay;
} complexPWM;
complexPWM pwmItems[] =
{
{0, 1, TIMEOUT_MS(1000)},
{2, 127, TIMEOUT_MS(2)},
{-2, 33, TIMEOUT_MS(2)},
{2, 255, TIMEOUT_MS(2)},
{-2, 1, TIMEOUT_MS(2)}
};
uint8_t pwmTableIndex = 0;
void complexPWMTask()
{
complexPWM * curItem = pwmItems + pwmTableIndex;
pwmAValue += curItem->step;
if(curItem->maxValue == pwmAValue)
pwmTableIndex++;
if(pwmTableIndex == sizeof(pwmItems)/sizeof(complexPWM)) //dim(pwmItems)
pwmTableIndex = 0;
addTimer(complexPWMTask, curItem->delay);
}
Не уверен, что это похоже на проблесковый маяк, но в этом куске кода делается предвспышка до яркости 127, потом уменьшаем яркость до 33 и делаем полную вспышку (до 255).
На этом, наверное, по прошивке и все. Со всеми потрохами и моргульками все влазит в 500-600 байт – даже еще запас остается. Остается осветить один важный момент – Fuse биты. Они равны hfuse=0xff, lfuse=0x79. За расшифровкой попрошу в даташит. В двух словах, пару бит в этих байтах заставляют контроллер работать на частоте 4.8МГц. Остальные биты оставлены в дефолтном состоянии.
Как это выглядит в реальности можно оценить по видео в шапке статьи.
Заключение
В этой статье я описал свой вариант контроллера для разных авиамодельных лампочек – БАНО, посадочных фар и стробов. Останется только чуток подковырять прошивку и Ваши модели копии станут выглядеть как настоящие.
Более того, подобную моргалку можно поставить и на автомодель. А можно, например, подсвечивать рекламную вывеску. Все в Ваших руках.
Но не самолетами едиными. Помимо моргающих лампочек я осветил в статье несколько других моментов, связаных с программированием слабых контроллеров:
- Как на одном таймере сэмулировать несколько таймеров
- Как выбирать параметры таймера
- Как настраивать таймер в режим генерации PWM
- Как слушать PWM вход и делать полезную работу на основе считаных значений
Если абстрагироваться от задачи моргания светодиодами, то получился неплохой такой каркас для «многозадачных» («многопоточных»?) приложений на микроконроллере. Конечно, это еще не RTOS, но уже избавляет от целой кучи рутинных операций. В прошивке этот каркас я вынес в отдельный модуль EventQueue.c/.h. Используйте наздоровье.
Приятно то, что получилось 3 совершенно независимых задачи (отсчет длинных отрезков времени, измерение длительности импульсов на входе и генерацию ШИМ на выходе) удалось повесить на один единственный 8-битный таймер. Ну а на добавленых програмных таймерах можно еще кучу всего полезного сделать.
Код, правда, получился не очень структурированый. Разные задачи решаются в одних и тех же функциях, при этом каждая задача размазана в нескольких местах. Но это плата за компактный размер. Так же как и отсутствие кнопок и других средств ввода информации. Но, повторюсь, мне не западло один раз припаяться проводами программатора прямо на контроллер, сконфигурить в коде нужный вариант моргания и влить все это в виде прошивки.
Статья не является справочником — вам все равно за разъяснением тех или иных битов и регистров прийдется лезть в даташит. Это всго лишь некий пример, как можно скудными средствами младшего контроллера в линейке AVR сделать некоторую полезную штуку.
Изначально я не планировал прикладывать к статье финальный скомпилированые hex файл. Дело в том, что все модели разные. Где-то нужно другое число каналов, где-то нужно по другому моргать, возможно нужно добавить пару входов, или сделать что нибудь еще. Вместо этого я бы предложил вам самим попробовать изменить прошивку так, что бы полностью соответствовать вашей задумке. Там все просто!
Тем не менее, не все авиамоделисты дружат с компилятором. Так что я все же скомпилировал некий средний вариант: один канал моргает раз в 2 секунды (строб), ШИМ канал мигает чуть-чуть чаще двойными вспышками, третий канал включается по команде с пульта, четвертый, как и раньше, светит всегда. Эта прошивка будет некой отправной точкой для последующего допиливания. Примеры представленые в статье я так же оставил в коде, только вызовы закоментарил.
Успехов!
Исходники прошивки и разводка плат.
Автор: grafalex