< Часть 3. Косвенная адресация и управление потоком исполнения
Библиотека генератора ассемблерного кода для микроконтроллеров AVR
Часть 4. Программирование периферийных устройств и обработка прерываний
В этой части поста мы, как и обещали, займемся одним из самых востребованных аспектов программирования микроконтроллеров — а именно работой с периферийными устройствами. Существует два наиболее распространенных подхода к программированию периферии. Первый — система программирования ничего не знает о периферийных устройствах и предоставляет только средства доступа к портам управления устройствами. Этот подход практически не отличается от работы c устройствами на уровне ассемблера и требует досконального изучения назначения всех портов, связанных с работой конкретного периферийного устройства. Для облегчения работы программистов существуют специальные программы, но их помощь, как правило, заканчивается генерацией последовательности начальной инициализации устройств. Достоинством этого подхода является полный доступ ко всем возможностям периферии, а недостатком — сложность программирования и большой объем программного кода.
Второй — работа с периферийными устройствами ведется на уровне виртуальных устройств. Основным достоинством такого подхода является простота управления устройствами и возможность работать с ними не вникая в особенности аппаратной реализации. Недостаток такого подхода — ограничение возможностей периферийных устройств назначением и функциями эмулируемого виртуального устройства.
В библиотеке NanoRTOS реализован третий подход. Каждое периферийное устройство описывается специализированным классом, цель которого упростить настройку и работу с устройством с сохранением его полной функциональности. Продемонстрировать особенности данного подхода лучше на примерах, поэтому приступим.
Начнем с самого простого и распространенного периферийного устройства — порта цифрового ввода/вывода. Такой порт объединяет до 8 каналов, каждый из которых может быть настроен независимо на вход или выход. Уточнение до 8 означает, что архитектура контроллера подразумевает возможность назначения альтернативных функций для отдельных разрядов порта, что исключает их использование в качестве портов вода/вывода, тем самым уменьшая количество доступных разрядов. Настройка и дальнейшая работа может осуществляться как на уровне отдельного разряда, так и на уровне порта в целом (запись и чтение всех 8 разрядов одной командой). В контроллере Mega328, который использован в примерах, имеется 3 порта: B, C и D. В начальном состоянии с точки зрения библиотеки разряды всех портов нейтральны. Это означает, что для их активации необходимо обязательно указать режим их использования. В случае попытки обращения к не активированному порту, программа выдаст ошибку компиляции. Это сделано для того, чтобы устранить возможные коллизии при назначении альтернативных функций. Для переключения портов в режим ввода/вывода используются команды Mode, для установки режима одиночного разряда, и Direction, для установки режима всех разрядов порта одной командой. С точки зрения программирования все порты одинаковы и их поведение описывается одним классом.
var m = new Mega328();
m.PortB[0].Mode = ePinMode.OUT;//Установили 0 разряд порта B в режим вывода
m.PortC.Direction(0xFF);// Настроили порт С в режим вывода
m.PortB.Activate(); //Инициализировали порт В
m.PortC.Activate(); //Инициализировали порт C
//начало основной программы
m.PortB[0].Set(); //Установили значение 0 разряда порта B в 1
m.PortB[0].Clear();//Установили значение 0 разряда порта B в 0
m.PortB[0].Toggle();//Изменили значение 0 разряда порта B на противоположенное
m.PortC.Write(0b11000000);//установили 6 и 7 разряд порта С и сбросили остальные разряды
var rr = m.REG(); // Объявили переменную типа регистр
rr.Load(0xC0);
m.PortC.Write(rr);//вывели в порт С значение регистра rr
var t = AVRASM.Text(m);
Пример, приведенный выше демонстрирует, как может быть организован вывод данных через порты. Работа с портом B здесь осуществляется на уровне одного разряда, а с портом С на уровне порта, как единого целого. Обратите внимание на команду активации Activate(). Ее назначение заключается в генерации в выходном коде последовательности команд инициализации устройства в соответствии с установленными ранее свойствами. Таким образом команда Activate() всегда использует актуальный на момент исполнения набор установленных параметров. Рассмотрим пример чтения данных из порта.
m.PortB.Activate(); //Инициализировали порт B
m.PortC.Activate(); //Инициализировали порт C
Bit dd = m.BIT(); // Объявили переменную типа бит
Register rr = m.REG(); // Объявили переменную типа регистр
m.PortB[0].Read(dd); //Прочитали значение 0 разряда порта B
m.PortC.Read(rr);//Прочитали значение порта С в регистр rr
var t = AVRASM.Text(m);
В этом примере появился новый тип данных Bit. Наиболее близким аналогом этого типа в языках высокого уровня является тип bool. Тип данных Bit используется для хранения только одного бита информации и позволяет использовать его значение, как условие в операциях ветвления. В целях экономии памяти, битовые переменные при хранении объединяются в блоки таким образом, что один регистр РОН используется для хранения 8 переменных типа Bit. Кроме описанного типа, библиотека содержит еще два битовых типа данных: Pin, имеющий идентичную с Bit функциональность, но использующий для хранения регистры IO и Mbit для хранения битовых переменных в памяти RAM. Посмотрим, как можно использовать битовые переменные для организации ветвлений
m.IF(m.PortB[0], () => AVRASM.Comment("Выполняем, если пин = 1"));
var b = m.BIT();
b.Set();
m.IF(b, () => AVRASM.Comment("Выполняем, если переменная b установлена"));
В первой строке проверяется состояние порта ввода и если на входе 1 — выполняется код условного блока. Последняя строка содержит пример, где в качестве условия ветвления используется переменная типа Bit.
Следующим распространенным и часто используемым периферийным устройством можно считать аппаратный счетчик/таймер. В микроконтроллерах AVR это устройство обладает большим набором функций и, в зависимости от настройки, может использоваться для формирования задержки, генерации меандра с программируемой частотой, измерения частоты внешнего сигнала, а так же в качестве многорежимного ШИМ — модулятора. В отличие от портов ввода-вывода, каждый из имеющихся в Mega328 таймеров обладает уникальным набором возможностей. Поэтому каждый таймер описывается отдельным классом.
Рассмотрим их поподробнее. В качестве источника сигнала каждого таймера может быть использован как внешний сигнал, так и внутреннее тактирование процессора. Средства аппаратной настройки микроконтроллера позволяют настроить для периферийных устройств использование либо полной частоты, либо включить единый для всех периферийных устройств предделитель на 8. Так как микроконтроллер допускает работу в широким диапазоне частот, правильный расчет значений делителей таймера для требуемой задержки при внутреннем тактировании требует указания частоты процессора и режима предделителя. Таким образом секция настройки таймера приобретает следующий вид
var m = new Mega328();
m.FCLK = 16000000; //определяем частоту тактирования
m.CKDIV8 = false; //определяем режим работы предделителя периферии
// настройка режима таймера Timer1
m.Timer1.Clock = eTimerClockSource.CLK256; //установка источника тактирования
m.Timer1.OCRA = (ushort)((0.5 * m.FCLK) / 256); // установка порога канала A
m.Timer1.Mode = eWaveFormMode.CTC_OCRA; // установка режима таймера
m.Timer1.Activate(); // генерация команд настройки Timer1
Очевидно, что настройка таймера требует изучения документации производителя для выбора правильного режима и понимания назначения различных настроек, но использование библиотеки делает работу с устройством проще и понятнее, сохраняя возможность использования всех режимов устройства.
Теперь я предлагаю немного отвлечься от описания использования конкретных устройств и перед тем, как продолжить, обсудить проблему асинхронности их работы. Основным достоинством периферийных устройств является то, что они способны выполнять определенные функции не используя ресурсы ЦПУ. Сложность может возникнуть в организации взаимодействия между программой и устройством, так как события, возникающие в процессе работы периферийного устройства, асинхронны относительно потока исполнения кода в ЦПУ. Синхронные методы взаимодействия, при которых программа содержит циклы ожидания требуемого статуса устройства, сводят на нет почти все достоинства периферии, как независимых устройств. Более эффективным и предпочтительным является режим прерываний. В этом режиме процессор непрерывно выполняет код основного потока, а в момент возникновения события, переключает поток исполнения на его обработчик. По окончанию обработки, управление возвращается в основной поток. Достоинства такого подхода очевидны, но его использование может быть затруднено сложностью настройки. В ассемблере для использования прерывания необходимо:
- установить правильный адрес в таблице прерываний,
- настроить на работу с прерываниями само устройство,
- описать функцию обработки прерывания,
- предусмотреть в ней сохранение всех используемых регистров и флагов для того, чтобы прерывание не повлияло на ход выполнения основного потока
- разрешить глобальные прерывания.
Для упрощения программирования работы через прерывания, классы описания периферийных устройств библиотеки содержат свойства обработчика событий. При этом для организации работы с периферийным устройством через прерывания нужно только описать код обработки требуемого события, а все остальные настройки библиотека выполнит самостоятельно. Вернемся к настройке таймера и дополним ее определением кода, который должен выполняться при достижении порогов срабатывания каналов сравнения таймера. Допустим мы хотим, чтобы при срабатывании порогов каналов сравнения устанавливались, а при переполнении сбрасывались определенные биты портов ввода-вывода. Иными словами мы хотим реализовать при помощи таймера функцию генерации ШИМ сигнала на выбранных произвольных портах со скважностью, определяемой значениями OCRA для первого и OCRB для второго канала. Посмотрим как в этом случае будет выглядеть код.
var m = new Mega328();
m.FCLK = 16000000;
m.CKDIV8 = false;
var bit1 = m.PortB[0];
bit1.Mode = ePinMode.OUT;
var bit2 = m.PortB[1];
bit2.Mode = ePinMode.OUT;
m.PortB.Activate(); // настроили 0 и 1 разряды порта B на вывод
// настраиваем режим работы таймера
m.Timer0.Clock = eTimerClockSource.CLK;
m.Timer0.OCRA = 50;
m.Timer0.OCRB = 170;
m.Timer0.Mode = eWaveFormMode.PWMPC_TOP8;
//настраиваем обработчики событий
m.Timer0.OnCompareA = () => bit1.Set();
m.Timer0.OnCompareB = () =>bit2.Set();
m.Timer0.OnOverflow = () => m.PortB.Write(0);
m.Timer0.Activate();
m.EnableInterrupt(); //разрешили прерывания
//основной цикл выполнения
m.LOOP(m.TempH, (r, l) => m.GO(l), (r) => { });
Часть, касающаяся настройки режимов таймера, была рассмотрена ранее, поэтому сразу перейдем к обработчикам прерывания. В примере для реализации средствами одного таймера двух каналов ШИМ используются три обработчика. Код обработчиков вполне очевиден, но может возникнуть вопрос, как реализовано ранее упомянутое сохранение состояния для того, чтобы вызов прерывания не влиял на логику основного потока. Вариант решения, при котором сохраняются все регистры и флаги выглядит явно избыточным, поэтому библиотека анализирует использование ресурсов в прерывании и сохраняет только необходимый минимум. Пустой основной цикл подтверждает мысль о том, что задача непрерывного формирования нескольких ШИМ сигналов работает без участия основной программы.
Следует отметить, что в библиотеке реализован единый подход к работе с прерываниями для всех классов описания периферийных устройств. Это позволяет упростить программирование и уменьшить количество ошибок.
Продолжим изучать работу с прерываниями и рассмотрим ситуацию, в которой нажатие на кнопки, присоединенные к портам ввода должно вызывать определенные действия со стороны программы. В рассматриваемом нами процессоре существует два способа генерировать прерывания при изменении состояния портов ввода. Самым продвинутым является использование режима внешнего прерывания. В этом случае мы имеем возможность генерировать отдельные прерывания для каждого из выводов и настраивать реакцию только на конкретное событие (фронт, спад, уровень). К сожалению в нашем кристалле их предусмотрено всего два. Другой способ позволяет контролировать при помощи прерываний любой из разрядов порта ввода, но обработка получается более сложной из-за того, что событие возникает на уровне порта при изменении входного сигнала любого из настроенных разрядов, и дальнейшее уточнение причины прерывания должно выполняться на уровне алгоритма программными средствами.
В качестве иллюстрации, попробуем решить задачу управления состоянием вывода порта при помощи двух кнопок. Одна из них должна устанавливать значение указанного нами порта в 1, а другая сбрасывать. Так как кнопок всего две, воспользуемся возможностью использовать внешние прерывания.
var m = new Mega328();
m.PortD[0].Mode = ePinMode.OUT;
m.PortD.Write(0x0C); //активируем pull-up для кнопок
m.INT0.Mode = eExtIntMode.Falling; //настроим реакцию INT0 на спад.
m.INT0.OnChange = () => m.PortD[0].Set(); //по наступлению события установим пин в 1
m.INT1.Mode = eExtIntMode.Falling; //настроим реакцию INT1 на спад.
m.INT1.OnChange = () => m.PortD[0].Clear(); //по наступлению события сбросим пин
//Активируем устройства
m.INT0.Activate();
m.INT1.Activate();
m.PortD.Activate();
m.EnableInterrupt(); // разрешим прерывания
//основной цикл
m.LOOP(m.TempL, (r, l) => m.GO(l), (r, l) => { });
Использование внешних прерываний позволило решить нашу задачу максимально просто и понятно.
Управление внешними портами программным путем — не единственный возможный способ. В частности таймеры имеют настройку, позволяющую им управлять выводом микроконтроллера непосредственно. Для этого в настройке таймера необходимо указать режим управления выводом
m.Timer0.CompareModeA = eCompareMatchMode.Set;
После активизации таймера, 6 разряд порта D получит альтернативную функцию и будет управляться таймером. Таким образом, мы имеем возможность генерировать ШИМ сигнал на выводе процессора чисто на аппаратном уровне, используя программные средства только для задания параметров сигнала. При этом, если мы попытаемся средствами библиотеки обратится к занятому порту как к порту ввода-вывода, то получим ошибку на уровне компиляции.
Последним устройством, которое мы будем рассматривать в этой части статьи, будет последовательный порт USART. Функциональные возможности этого устройства весьма широки, но пока мы коснемся лишь одного из наиболее часто встречающихся вариантов использования этого устройства.
Самым популярным вариантом использования этого порта можно считать подключение последовательного терминала для ввода-вывода текстовой информации. Часть кода, касающаяся настройки порта в этом случае может выглядеть следующим образом
m.FCLK = 16000000; //определяем частоту тактирования
m.CKDIV8 = false; //определяем режим работы предделителя периферии
m.Usart.Mode = eUartMode.UART; // асинхронный режим работы UART
m.Usart.Baudrate = 9600; // скорость обмена 9600 бод
m.Usart.FrameFormat = eUartFrame.U8N1; // формат фрейма 8N1
Указанные настройки совпадают с дефолтными настройками USART в библиотеке, поэтому могут быть частично или полностью пропущены в тексте программы.
Рассмотрим небольшой пример, в котором мы выведем статический текст в терминал. Для того, чтобы не раздувать код, ограничимся выводом на терминал классического «Hello world!» при старте программы.
var m = new Mega328();
var ptr = m.ROMPTR(); // объявим указатель на область программы
m.CKDIV8 = false;
m.FCLK = 16000000;
// настройка режима работы последовательного порта
m.Usart.Mode = eUartMode.UART;
m.Usart.Baudrate = 9600;
m.Usart.FrameFormat = eUartFrame.U8N1;
// определим обработчик прерывания для вывода данных через порт
m.Usart.OnTransmitComplete =
() =>
{
ptr.MLoadInc(m.TempL);
m.IF(m.TempL!=0,()=>m.Usart.Transmit(m.TempL));
};
m.Usart.Activate();
m.EnableInterrupt();
// начало программы
var str = Const.String("Hello world!"); //объявим константную строку
ptr.Load(str); //назначим указатель на начало строки
ptr.MloadInc(m.TempL); // считаем первый символ
m.Usart.Transmit(m.TempL); // инициируем вывод строки.
m.LOOP(m.TempL, (r, l) => m.GO(l), (r,l) => { });
В этой программе из нового — объявление константной строки str. Все константные переменные библиотека размещает в памяти программ, поэтому для работы с ними необходимо использовать указатель ROMPtr. Вывод данных в терминал начинается с вывода первого символа строковой последовательности, после чего управление сразу уходит на выполнение основного цикла, не ожидая окончания вывода. Завершение процесса пересылки байта вызывает прерывание, в обработчике которого считывается следующий символ строки. В случае, если символ не равен 0 (библиотека использует zero-terminated формат хранения строк), этот символ отправляется в порт последовательного интерфейса. Если мы достигли конца строки, символ в порт не отправляется и цикл отправки завершается.
Недостатком такого подхода является фиксированный алгоритм обработки прерывания. Он не позволит использовать последовательный порт каким-либо иным образом, кроме как для вывода статических строк. Еще одним недостатком данной реализации можно назвать отсутствие механизма контроля занятости порта. При попытке последовательной отправки нескольких строк, возможна ситуация, когда передача предыдущих строк будет оборвана или строки окажутся перемешанными.
Более эффективные методы решения этой и других задач, а так же работу с другими периферийными устройствами, мы увидим в следующей части поста. В нем мы подробно рассмотрим программирование с использованием специального класса управления задачами Parallel.
Комментарии (6)
idv2013 Автор
14.08.2019 16:18Уточните пожалуйста, с какого момента возникли трудности. Если где-то недостаточно объяснений, я посмотрю, как более подробно описать то, что вызывает сложности в восприятии. Библиотека тоже может использовать процедуры, но объем статьи не позволяет затронуть все аспекты сразу. В следующей статье я как раз и планировал описать в том числе использование подпрограмм и процедур
AVI-crak
14.08.2019 22:36Да, такой момент есть — это разбор синтетики исходного текста. По сути GCC — это текстовый редактор, который многократно изменяет и переписывает исходный текст программы. Просто для ускорения сам процесс парсинга остаётся за кадром, где-то глубоко в памяти большого компа. Причём это так сказать непрерывный процесс, на всех уровнях компиляции.
Второе чего я не увидел явно — так это разбор исходного кода на примитивы. У вас получается сразу Си->бинарный код. То-есть места для оптимизации просто нет. Это немного иначе делается, и для этого есть хороший пример habr.com/ru/post/274083idv2013 Автор
14.08.2019 23:37К сожалению, Вы сделали абсолютно неправильный вывод из неверных предпосылок. Библиотека не является компилятором в том представлении, которое Вы в это вкладываете. И она действительно сделана совсем иначе. То, что я представляю, это подключаемая к консольному приложению C# библиотека. И исходная программа для AVR это не текст, который эта библиотека парсит, а программа на C#, со всеми возможностями IDE и языка C#, содержащая за счет использования библиотеки возможность в процессе своего выполнения формировать ассемблерный код. С точки зрения программиста, он пишет на самом обычном C# (ни к языку C, ни даже к C++ этот синтаксис не имеет никакого отношения) консольную программу, результатом работы которой является текст ассемблера. Что касается оптимизации — библиотека как раз и писалась исключительно в целях дать возможность максимальной оптимизации и 100% контроля над кодом. И именно из этих соображений результатом работы является не бинарный код, а аннотированный ассемблер. Более подробную информацию какой код генерит библиотека можно найти в предыдущих частях статьи. В дальнейшем я возможно напишу отдельную статью о принципах, на которых она работает.
evorios
14.08.2019 17:17Планируете расширять список поддерживаемых микроконтроллеров? Например, добавить популярный нынче для умных домов ESP8266.
idv2013 Автор
14.08.2019 23:42Да, возможно. Сам принцип построения библиотеки это позволяет. К сожалению, для ESP пока недостаточно документации. Из этих соображений, более перспективной выглядит серия процессоров STM
AVI-crak
С определённого момента начинается магия, которую очень трудно понять.
В этом плане GCC намного проще — там каждый этап работы с кодом выглядит как полностью автономная процедура. Которую можно полностью переписать заново — без слома общего алгоритма.