Осевые технологии. Пишем свою микрооперационную системуАртемий (Di Halt) Исламов (di_halt@mail.ru) Если всерьез занялся конструированием железа на микроконтроллерах, – неизбежно придется писать разного рода прошивки. Ведь контроллер это, прежде всего, заложенная в нем управляющая программа. Пришло время уделить внимание софтверной части современной электроники. Возьмем и напишем, ни много, ни мало, собственную операционную систему. ОС это не только жирный злой мухПри словах «операционная система» воображение тут же рисует что-то монстровидное, вроде Windows – занимающее сотни мегабайт места, требующие кучи оперативки лишь для того, чтобы запуститься. Кто более продвинут, вспомнит *nix-семейство, всякие QNX и ucLinux. Но и это жирная вещь, которой нужно не меньше мегабайта памяти и, как минимум, 32-разрядный процессор. А что у нас? У нас хардкор! Для пущей жести возьмем не какую-нибудь ATMega128, но ATTiny2313 с 2 Кб памяти. Если учесть, что надо выделить место под прикладную часть, – тратить на операционную систему более 700 байт мне, честно говоря, совершенно западло. Слабо накатать операционку на 600 байт? Сейчас посмотрим. А зачем?Прежде чем кидаться на штурм AVR Studio, надо все же подумать, а зачем нам операционная система? Какие задачи мы хотим на нее повесить? Первое, что требуется от операционной системы, это облегчить труд программиста. Сделать так, чтобы можно было создать единую шаблонную прошивку, в которую без проблем дописывается что угодно. То есть, операционная система должна выполнять задачи. Под задачами я понимаю какие-либо действия с контроллером и периферией. Например, мигнуть светодиодом, считать байт из внешней памяти, обновить изображение на экране, послать байт в последовательный порт. Да практически любое действие, выполняемое микроконтроллером. Задачи должны подключаться легко и непринужденно, чтобы нам не приходилось думать как бы одновременно и дисплей обновить, и диодом мигать, и принимать данные по порту, не забывая слать диагностические сообщения. Также операционная система должна брать на себя некоторые сервисные функции, например, организовать службу таймеров, чтобы прикладные задачи не парились по поводу разделения временных интервалов. РеализацияКлассическая операционная система реального времени работает примерно следующим образом. На каждую задачу отводится свой кусочек оперативной памяти, свой стек, свое адресное пространство. А диспетчер задач ОС постоянно тасует их между собой, по очереди запуская на некоторое время то одну, то другую задачу. Период работы задачи называется квантом времени. Такое можно реализовать на AVR, но на это уйдет очень много оперативки, которой и так кот наплакал. Да и, в отличие от х86-архитектуры, тут нет никаких аппаратных средств, вроде защищенного режима. Поэтому мы пойдем другим путем. В очередь, сукины дети, в очередь! (с) ШариковСобственно, полноценная ось реального времени нам особо не нужна. Достаточно, чтобы события выполнялись в срок. Поэтому мы организуем диспетчер и очередь событий. Каждое задание будет выполняться по этому списку, ставя туда либо вновь себя, либо уже другую задачу. Все внешние запросы, требующие незамедлительной реакции, например, прием байтов по UART или какой-нибудь INT0, будут по-прежнему осуществляться прерываниями. С той разницей, что теперь обработчик может или сам выполнить задачу, если это очень срочно, или добавить ее в очередь, и она будет исполнена, когда до нее дойдет время. Время ЧОдной очереди мало. Надо бы еще службу таймеров добавить. Таймер – это же чертовски ценный ресурс! Отмерять время нужно постоянно и под разные задачи. Таймеров в контроллере мало, обычно всего три, поэтому надо придумать средство, чтобы один таймер мог отмерять множество разных интервалов. Сделать это проще всего той же очередью. Хотим мы, чтобы через секунду случилось событие какое-нибудь – например, зажегся светодиод. Не вопрос – кидаем в таймерную очередь идентификатор события, время которого нам надо отсчитать, и вперед! Таймер будет выбрасывать на, скажем, каждый десятитысячный импульс тактового генератора свое прерывание. В нем мы, по быстрому, инкрементируем счетчик конкретного задания, а если он стал равен нулю, то это задание мы отправляем в очередь на исполнение. Потом переходим ко второй записи в цепочке таймеров и проверяем ее. И так – до тех пор, пока не обслужим все временные интервалы. Просто и эффективно! Контроллер прерыванийКаждое аппаратное действие обычно сопровождается генерацией прерываний. Пришел байт в порт – прерывание. АЦП обработал аналоговый сигнал – прерывание. Ушел байт – прерывание. Буквально на каждый чих. Очень удобно! Можно всю программу развесить на обработчики, оставив в главном цикле пустой код. Но есть одна проблема – приоритеты прерываний. На AVR нет аппаратного контроллера, так что когда приходит прерывание, то аппаратно запрещаются другие. И правильно, иначе может произойти срыв стека. Однако, запретив вообще все, мы рискуем проморгать важное событие. Приведу пример. Есть у нас АЦП и еще мы ждем байты по UART. Запустили обработку АЦП, сигнал оцифровался, и преобразователь сгенерировал прерывание: мол, готово, забирай. Обработчик АЦП кинулся пережевывать данные, да вот засада – в этот момент пришла посылка в UART, а прерывания-то запрещены. В итоге, посылку из UART мы прозеваем и вовремя не успеем среагировать. Как быть? Выход один – делать обработку прерываний как можно короче. В идеале: две-три команды. Тогда проблема решается следующим образом. АЦП сработал и дал приказ закинуть в очередь на исполнение задачу по обработке байта. UART принял байт и сразу закинул в очередь свое задание на исполнение. ПриступимИзобретать велосипед мы не будем, благо, все изобретено до нас. Осталось идею подсмотреть и воплотить. Лучший способ научиться программировать – взять дебаггер и посмотреть, как это делают другие. Так и тут. Идея микро ОС была нагло слизана с реализации прошивок в сотовых телефонах Motorola, и вначале камрад Serg2x2 портировал ее на микроконтроллер архитектуры С51, а потом и на AVR перетащили. С этой операционкой ты уже мог сталкиваться, если пробовал собирать логгер клавы по рецепту dlinyj (сделано на такой же оси, благодаря чему удалось все запихать в крошечный ATTiny2313). Я взял тот же самый проект, очистил его от прикладной шелухи… и сейчас подробно распишу тебе анатомию работы ядра ОС. Во главу угла тут ставится манипуляция программным счетчиком на базе таблицы переходов. Счетчик есть абсолютно в любом процессоре. Программа находится в памяти, и каждая ассемблерная инструкция расположена по какому-либо адресу. Значение программного счетчика (PC) указывает на адрес, куда на следующем такте перейдет процессор. Есть команды вроде RJMP или BRxx, которые закидывают в PC адрес перехода. Так осуществляется ветвление. Наши подпрограммы-задачи представляют собой просто участки кода. Нам нужно всего лишь передать на них управление. Сделать это проще всего с помощью таблицы переходов. Где-нибудь в памяти мы создаем массив, в котором у нас хранятся адреса задач, по порядку. Каждой подпрограмме присваивается номер в таблице. И теперь, зная номер нужной задачи, очень легко вычислить адрес перехода. Делается это обычным смещением от начала таблицы. Так как длина адреса всегда равна двум байтам, то достаточно просто умножить номер на два и прибавить к начальному адресу. Для формирования этой таблицы мы в файле defconst.inc создаем таблицу имен. Она нужна только для нашего удобства, чтобы оперировать именами, а не цифрами. Выглядит она так:
Названия совершенно произвольные и взяты из программы кейлоггера, как есть. Ты можешь назвать их как тебе угодно, главное, сам не забудь, где что. Тут мы задаем всего лишь имена вызываемых функций и номер, на который эта функция будет откликаться. А затем в файле с основной программой LoggerAttiny2313.asm, в самом низу, в памяти программ, создадим таблицу переходов. Выглядеть она будет так: Метка EventProcs – это начало таблицы, и от нее будет вестись весь отсчет. А вот в секциях dw уже расположены реальные метки на процедуры и функции. Если мы вызываем функцию с номером 00 aka EV_Idle, то по таблице переходим на реальную функцию Idle, которую ты можешь найти в файле LoggerAttiny2313.asm. Вызов функции [03] EV_DisplRegen тоже ведет на Idle, но это моя прихоть. Дело в том, что когда я очищал программу от прикладной части, то безжалостно выкинул все, что касается индикатора. А значит и файлы с описанием функций дисплея, в том числе, с процедурой регенерации. Вот и пришлось ее заглушить на Idle. Остальные метки, такие как Proc_ShowReceivedData или ProgTest3, присутствуют в тексте программы. Чуть выше таблицы переходов. Я, правда, их тоже подчистил, выкинув всю требуху и оставив только вызов. Получился вот такой скелет:
По поводу выкинутого - не переживай, я положу на диск еще и оригинальную программу, со всем содержимым, чтобы ты мог посмотреть всю систему на реальном примере. Создали мы таблицу, теперь надо создать очередь. Для этого в ОЗУ задаем ряд меток. Описываются они в defconst.inc, в самом низу:
EventsQueueSize – нужен, чтобы знать, сколько всего значений у нас может быть в очереди. Максимум – 11, но можно сделать сколько угодно, лишь бы оперативки хватило. EventsQueue – это непосредственно адрес в ОЗУ контроллера. Не забывай, что ОЗУ и память программ – это две разные памяти. И величина ОЗУ всего несколько сотен байт, большая очередь может и не влезть. Адрес выбирается произвольно, но надо помнить, что в верхних адресах располагается стек и он возрастает вниз. Если у тебя стек наедет на очередь, то случится переполнение стека и программа пойдет выполнять уже не то, что ты запланировал, а то, что туда закинуло стеком – вот тебе и атака на переполнение буфера в чистом виде. Для постановки задачи в очередь есть функция SendEvent, расположенная в файле kernel.inc. Достаточно записать в регистр Tmp1 номер задачи и вызвать эту функцию, как она тут же встанет в список последней по счету. Вот так:
Главный цикл содержит в себе лишь процедуру обслуживания очереди задач и время простоя, тот самый idle и сброс защитного таймера watchdog.
Процедура ProcessTaskQueue из kernel.inc проверяет наличие в очереди какого-либо задания (номер задачи, отличный от 0xFF) и, если находит, то осуществляет туда переход. При этом удаляется номер из списка, и вся цепочка сдвигается на один шаг вперед. Служба таймеровПоговорим о важнейшей части нашей микрооси. Она заведует всем, что работает в фоновом режиме. Висит все на аппаратном таймере T1 на прерывании по совпадению (том самом, который используется для создания ШИМ). Сделано это, чтобы каждый раз не задавать предварительное значение счетчика. Как только состояние счетного регистра достигнет порога, – у нас свершится таймерный тик и процессор кинется проверять, что там с нашей очередью задержек. Находится по метке: OutComp1AInt, которую ты увидишь почти в самом начале файла LoggerAttiny2313.asm. Здесь я приводить все не буду, так как журнал не резиновый, а в исходнике комментариев будет предостаточно. Опишу лишь алгоритм. При входе в обработчик вначале загружается адрес начала очереди таймеров. Затем берется первый байт и проверяется, не равен ли он 0хFF – это значит, что данный таймер выключен. Если равен, то прибавляем к начальному адресу очереди число три, чтобы выбрать следующий таймер и также его проверить. Если же таймер содержит какое-то значение (номер процедуры), значит, он активен и надо уменьшить на единицу число, которое стоит в следующих двух байтах. Результат проверяется на ноль. И если там образовался ноль (то есть, время вышло), то число из первого байта таймерной записи отправляется прямиком в очередь событий, а мы переходим к следующему таймеру. Если ноль не получился, – то просто переходим к следующему таймеру. Так до тех пор, пока не проверим каждую запись в очереди! Сама очередь находится в ОЗУ и описывается в том же defconst.inc. TimersPoolSize – это очередь таймеров. Точнее, даже не очередь, а просто количество софтверных таймеров, которые мы можем организовать. Тут их сделано пять штук. На каждый таймер отводится по 3 байта – вначале идет номер процедуры, которую должен вызвать таймер, а потом два байта на временной интервал. Например, тебе надо вызвать ProgTest1 через 0х6543 тиков. Тогда ты помещаешь в таймеры такую запись – 05:65:43. Байт номера и два байта времени. Длительность одного тика настраивается отдельно и зависит от частоты процессора и коэффициента деления таймер-счетчика. Об этом я уже писал в прошлых статьях про архитектуру AVR. TimersPool – непосредственное размещение этих переменных в памяти. Нужно выставить так, чтобы не перекрывалось с очередью задач и не залезло на стек. RETIКак видишь, создать операционную систему не так уж и сложно. Главное, четко представлять, что мы хотим получить. А дальше – уже наворачивать по вкусу, добавлять фишки и удобства. Удачи! |