Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Библиотека генератора ассеблерного кода для микроконтроллеров AVR
Часть 1. Первое знакомство
Добрый день, уважаемые хабаровчане. Хочу предложить Вашему вниманию очередной (из имеющегося великого множества) проект, для программирования популярных микроконтроллеров серии AVR.
Можно было бы потратить много текста для объяснения зачем это понадобилось, но вместо этого просто посмотрим на примерах чем он отличается от других решений. А все пояснения и сравнения с имеющимися системами программирования будут по мере необходимости в процессе разбора примеров. Библиотека сейчас находится в процессе доработки, поэтому реализация некоторых функций может показаться не вполне оптимальной. Так же часть задач, которые в этой версии возложены на программиста, предполагается в дальнейшем оптимизировать или автоматизировать.
Итак, приступим. Сразу хочу уточнить, что представленный материал ни в коем случае не стоит рассматривать как полноценное описание, а только как демонстрацию некоторых возможностей разработанной библиотеки для того, чтобы помочь понять насколько интересным для читателей может оказаться данный подход.
Не будем отступать от сложившийся практики и начнем с классического примера, своеобразного «Hello world» для микроконтроллеров. А именно помигаем светодиодом, подключенным к одной из ножек процессора. Откроем VisualStudio от Microsoft (подойдет любой выпуск) и создадим консольное приложение для C#. Тем, кто не в курсе — достаточный для работы Community Edition абсолютно бесплатен.
Собственно сам текст выглядит следующим образом:
using NanoRTOSLib;
using System;
namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{
var m = new Mega328();
m.PortB[0].Mode = ePinMode.OUT;
m.PortB.Activate();
m.LOOP(m.TempL, (r, l) => m.GO(l), (r) => { m.PortB[0].Toggle();});
Console.WriteLine(AVRASM.Text(m));
}
}
}
Разумеется, чтобы все заработало и нужна та самая библиотека, которую я и представляю.
После компиляции и запуска программы, в выводе консоли увидим следующий результат работы данной программы.
#include “common.inc”
RESET: ldi r16, high(RAMEND)
out SPH,r16
ldi r16, low(RAMEND)
out SPL,r16
outi DDRB,0x1
L0000:
in TempL,PORTB
ldi TempH,1
eor TempL,TempH
out PORTB,TempL
xjmp L0000
.DSEG
Если скопировать результат в любую среду, умеющую работать с AVR ассемблером и подключить библиотеку макросов Common.inc (макробиблиотека так же является одной из составных элементов представляемой системы программирования и работает совместно с NanoRTOSLib), то эту программу можно скомпилировать и проверить на эмуляторе или реальном кристалле и убедиться что все работает.
Рассмотрим подробнее исходный код программы. Первым делом назначаем переменной m тип используемого кристалла. Далее устанавливаем для нулевого бита порта B кристалла режим цифрового выхода и активируем порт. Следующая строка выглядит немного странно, но ее смысл совсем простой. В ней мы говорим, что хотим организовать бесконечный цикл в теле которого мы меняем значение нулевого бита порта B на противоположное. Последняя строка программы собственно и визуализирует результат всего написанного ранее в виде ассемблерного кода. Все предельно просто и компактно. А результат практически не отличается от того, что можно было бы написать на ассемблере. Вопросов к выходному коду может возникнуть только два: первый — зачем инициализировать стек, если его все равно не используем и что за xjmp? Ответом на первый вопрос и одновременно объяснением почему выводится ассемблер, а не готовый HEX будет следующий: результат в виде ассемблера позволяет дополнительно анализировать и оптимизировать программу, позволяя программисту выделять и изменять фрагменты кода, которые ему не нравятся. А инициализация стека оставлена хотя бы из тех соображений, что без использования стека можно придумать не так много программ. Впрочем, если не нравится — смело убирайте. Вывод в ассемблер для этого и предназначен. Что касается xjmp — это пример использования макросов для увеличения читабельности выходного ассемблера. Конкретно xjmp — замена для jmp и rjmp с правильной подстановкой в зависимости от длины перехода.
Если залить программу на кристалл, то мигания диодом мы конечно не увидим, несмотря на то, что состояние пина меняется. Просто это происходит слишком быстро для того для того, чтобы увидеть это глазами. Поэтому рассмотрим следующую программу в которой продолжим мигать диодом, но уже так, чтобы это можно было увидеть. Для примера вполне подойдет задержка в 0.5 секунды: не слишком быстро и не слишком медленно. Можно было бы сделать множество вложенных циклов с NOP для формирования задержки, но мы пропустим этот этап, как не добавляющий ничего к описанию возможностей библиотеки, и сразу воспользуемся возможностью использовать имеющиеся аппаратные средства. Изменим наше приложение следующим образом.
using System;
namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{
var m = new Mega328();
m.PortB[0].Mode = ePinMode.OUT;
m.PortB.Activate();
m.WDT.Clock = eWDTClock.WDT500ms;
m.WDT.OnTimeout = () => m.PortB[0].Toggle();
m.WDT.Activate();
m.EnableInterrupt();
var loop = AVRASM.newLabel();
m.GO(loop);
Console.WriteLine(AVRASM.Text(m));
}
}
}
Очевидно, что программа похожа на предыдущую, поэтому рассмотрим только то, что изменилось. Во-первых в этом примере мы задействовали WDT(watchdog timer). Для работы с большими и не требующими особой точности задержками это наилучший вариант. Все, что нужно для его использования — это установить требуемую периодичность, путем установки делителя через свойство WDT.Clock и определить действия, которые необходимо выполнить в момент срабатывания события, путем определения кода через свойство WDT.OnTimeout. Так как для работы нам нужны прерывания, их необходимо разрешить командой EnableInterrupt. А вот основной цикл можно заменить пустышкой. В нем мы все равно ничего делать не планируем. Поэтому объявим и установим метку и сделаем на нее безусловный переход для организации пустого цикла. Если больше нравится LOOP — пожалуйста. Результат от этого не изменится.
Ну и в финале посмотрим на получившийся код.
#include “common.inc”
jmp RESET
reti ; IRQ0 Handler
nop
reti ;IRQ1 Handler
nop
reti ;PC_INT0 Handler
nop
reti ;PC_INT1 Handler
nop
reti ;PC_INT2 Handler
nop
jmp WDT ;Watchdog Timer Handler
RESET: ldi r16, high(RAMEND)
out SPH,r16
ldi r16, low(RAMEND)
out SPL,r16
outi DDRB,0x1
ldi TempL, (1<<WDCE) | (1<<WDE)
sts WDTCSR,TempL
ldi TempL, 0x42
sts WDTCSR,TempL
sei
L0000:
xjmp L0000
WDT:
push r17
push r16
in r16,SREG
push r16
in TempL,PORTB
ldi TempH,1
eor TempL,TempH
out PORTB,TempL
pop r16
out SREG,r16
pop r16
pop r17
reti
.DSEG
У тех, кто знаком с этим процессором безусловно возникнет вопрос куда делись еще несколько векторов прерываний. Здесь мы использовали следующую логику — если код не используется — код не нужен. Поэтому таблица прерываний и заканчивается на последнем используемом векторе.
Несмотря на то, что программа прекрасно справляется с поставленной задачей, самым придирчивым может не понравится то, что набор возможных задержек ограничен, а шаг слишком грубый. Поэтому рассмотрим еще один способ, а заодно и посмотрим, как в библиотеке организована работа с таймерами. В кристалле Mega328, который взят за образец, их целых 3 штуки. 2 8-битных и один 16-битный. Архитекторы очень постарались вложить в эти таймеры как можно больше возможностей, поэтому и их настройка достаточно объемна.
Сначала посчитаем какой счетчик следует применить для нашей задержки в 0,5 сек. Если взять тактовую частоту кристалла 16 Мгц, то даже с максимальным предделителем периферии не получается уложится в 8-разрядный счетчик. Поэтому не будем усложнять и воспользуемся единственным доступным для нас 16 -разрядным счетчиком Timer1.
В результате программа приобретает следующий вид:
using NanoRTOSLib;
using System;
namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{var m = new Mega328();
m.FCLK = 16000000;
m.CKDIV8 = false;
var bit1 = m.PortB[0];
bit1.Mode = ePinMode.OUT;
m.PortB.Activate();
m.Timer1.Mode = eWaveFormMode.CTC_OCRA;
m.Timer1.Clock = eTimerClockSource.CLK256;
m.Timer1.OCRA = (ushort)((0.5 * m.FCLK) / 256);
m.Timer1.OnCompareA = () => bit1.Toggle();
m.Timer1.Activate();
m.EnableInterrupt();
m.LOOP(m.TempH, (r, l) => m.GO(l), (r) => { });
Console.WriteLine(AVRASM.Text(m));
}
}
}
Так как мы используем в качестве источника тактирования для нашего таймера основной генератор, для правильного расчета задержки необходимо указать частоту тактирования процессора, настройку делителя и фьюза тактирования периферии. Основной текст программы занимает установка таймера в нужный режим. Здесь сознательно для тактирования выбран предделитель на 256 а не максимальный, так как при выборе предделителя 1024 для требуемой тактовой частоты в 500ms, которую мы хотим получить, получается дробное число.
Результирующий ассемблерный код нашей программы будет выглядеть следующим образом:
#include “common.inc”
jmp RESET
reti ; IRQ0 Handler
nop
reti ;IRQ1 Handler
nop
reti ;PC_INT0 Handler
nop
reti ;PC_INT1 Handler
nop
reti ;PC_INT2 Handler
nop
reti ;Watchdog Timer Handler
nop
reti ;Timer2 Compare A Handler
nop
reti ;Timer2 Compare B Handler
nop
reti ;Timer2 Overflow Handler
nop
reti ;Timer1 Capture Handler
nop
jmp TIM1_COMPA ;Timer1 Compare A Handler
RESET: ldi r16, high(RAMEND)
out SPH,r16
ldi r16, low(RAMEND)
out SPL,r16
outi DDRB,0x1
outiw OCR1A,0x7A12
outi TCCR1A,0
outi TCCR1B,0xC
outi TCCR1C,0x0
outi TIMSK1,0x2
outi DDRB,0x1
sei
L0000:
xjmp L0000
TIM1_COMPA:
push r17
push r16
in r16,SREG
push r16
in TempL,PORTB
ldi TempH,1
eor TempL,TempH
out PORTB,TempL
pop r16
out SREG,r16
pop r16
pop r17
reti
.DSEG
Здесь уже вроде больше нечего комментировать. Инициализируем устройства, настраиваем прерывания и наслаждаемся работой программы.
Работа через прерывания — наиболее простой вариант создания программ для работы в режиме реального времени. К сожалению переключение между выполняемыми параллельно задачами с использованием только обработчиков прерываний для выполнения этих задач не всегда возможно. Ограничением является запрет на вложенную обработку прерываний, что приводит к тому, что до тех пор, пока не произойдет выход из обработчика, процессор не реагирует на все остальные прерывания, что может привести к потере событий, если обработчик будет выполняться слишком долго.
Вариантом решения является разделение кода регистрации событий и их обработки. Ядро многопоточной обработки Parallel из библиотеки организовано таким образом, что при возникновении события обработчик прерывания только регистрирует данное событие и при необходимости выполняет минимально необходимые операции по захвату данных, а вся обработка производится в основном потоке. Ядро последовательно проверяет наличие необработанных флагов и в случае их нахождения переходит к выполнению соответствующей задачи.
Использование этого подхода упрощает проектирование систем с несколькими асинхронными задачами, позволяя рассматривать каждую из них изолированно, не концентрируясь на проблемах переключения ресурсов между задачами. В качестве примера рассмотрим реализацию двух независимых задач, каждая из которых переключает свой вывод с определенной задержкой.
using NanoRTOSLib;
using System;
namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{
var m = new Mega328();
m.FCLK = 16000000;
m.CKDIV8 = false;
m.PortB.Direction(0x07);
var bit1 = m.PortB[1];
var bit2 = m.PortB[2];
m.PortB.Activate();
var tasks = new Parallel(m, 4);
tasks.Heap = new StaticHeap(tasks, 64);
var t1 = tasks.CreateTask((tsk) =>
{
var loop = AVRASM.NewLabel();
bit1.Toggle();
tsk.Delay(32);
tsk.TaskContinue(loop);
},"Task1");
var t2 = tasks.CreateTask((tsk) =>
{
var loop = AVRASM.NewLabel();
bit2.Toggle();
tsk.Delay(48);
tsk.TaskContinue(loop);
}, "Task2");
var ca = tasks.ContinuousActivate(tasks.AlwaysOn, t1);
tasks.ActivateNext(ca, tasks.AlwaysOn, t2);
ca.Dispose();
m.EnableInterrupt();
tasks.Loop();
Console.WriteLine(AVRASM.Text(m));
}
}
}
В этой задаче мы настраиваем нулевой и первый выводы порта В на вывод и меняем значение с 0 на 1 и обратно с периодом 32ms для нулевого и 48ms для первого вывода. За управление каждым из портов отвечает отдельная задача. Первое, на что следует обратить внимание, это определение экземпляра Parallel. Этот класс представляет из себя ядро управления задачами. В его конструкторе мы определяем максимально допустимое количество одновременно работающих потоков. Далее следует выделение памяти для хранения данных потоков. Использованный в примере класс StaticHeap выделяет под каждый поток фиксированное количество байт. Для решения нашей задачи это допустимо, а использование фиксированного распределения памяти по сравнению с динамическим упрощает алгоритмы и делает код более компактным и быстрым. Далее в коде мы описываем набор задач, которые предназначены для запуска под управлением ядра. Следует обратить внимание на асинхронную функцию Delay, которую мы используем для формирования задержки. Ее особенность состоит в том, что при вызове этой функции в настройках потока устанавливается требуемая задержка, а управление передается в ядро. По истечении установленного интервала ядро возвращает управление задаче с команды, следующей за командой Delay. Другой особенностью задачи является программирование поведения потока задачи по ее завершению в последней команде задачи. В нашем случае обе задачи настроены на выполнение в бесконечном цикле с возвратом управления ядру в конце каждого цикла. В случае необходимости, завершение задачи может освобождать поток или передавать его для выполнения другой задачи.
Основанием для вызова задачи является активация сигнала, назначенного потоку задачи. Сигнал может активироваться как программным путем, так и аппаратно по прерываниям от периферийных устройств. Вызов задачи сбрасывает сигнал. Исключением является предопределенный сигнал AlwaysOn, который всегда находится в активном состоянии. Это дает возможность создавать задачи, которые будут получать управление в каждом цикле опроса. Функция LOOP необходима для вызова основного цикла исполнения. К сожалению размер выходного кода при использовании Parallel уже становится существенно больше чем в предыдущих примерах (примерно 600 команд) и не может быть целиком приведен в статье.
И на сладкое — нечто более похожее на живой проект, а именно цифровой термометр. Все как всегда просто. Цифровой датчик с интерфейсом SPI, 7-сегментный 4-х разрядный индикатор и несколько потоков обработки для того, чтобы все крутилось. В одном гоняем цикл для динамической индикации, в другом события по которым запускается цикл считывания температуры, в третьем читаем принятые c датчика значения и преобразуем из бинарного кода в BCD а затем в сегментный код для буфера динамической индикации.
Сама программа выглядит следующим образом.
using NanoRTOSLib;
using System;
namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{
var m = new Mega328();
m.FCLK = 16000000;
m.CKDIV8 = false;
var led7s = new Led_7();
led7s.SegPort = m.PortC;
led7s.Activate();
m.PortD.Direction(0xFF);
m.PortD.Activate();
m.PortB[0].Mode = ePinMode.OUT;
var tc77 = new TC77();
tc77.CS = m.PortB[0];
tc77.Port = m.SPI;
m.Timer0.Clock = eTimerClockSource.CLK64;
m.Timer0.Mode = eWaveFormMode.Normal;
var reader = m.DREG("Temperature");
var bcdRes = m.DREG("digits");
var tmp = m.BYTE();
var bcd = new BCD(reader, bcdRes);
m.subroutines.Add(bcd);
var os = new Parallel(m, 4);
os.Heap = new StaticHeap(os, 64);
var tmrSig = os.AddSignal(m.Timer0.OVF_Handler);
var spiSig = os.AddSignal(m.SPI.Handler, () =>
{
m.SPI.Read(m.TempL);
m.TempL.MStore(tmp);
});
var actuator = os.CreateTask((tsk) =>
{
var loop = AVRASM.NewLabel();
tc77.ReadTemperatureAsync();
tsk.Delay(16);
tsk.TaskContinue(loop);
}, "actuator");
var treader = os.CreateTask((tsk) =>
{
var loop = AVRASM.NewLabel();
tc77.ReadTemperatureCallback(os, reader, tmp);
reader >>= 7;
m.CALL(bcd);
tsk.TaskContinue(loop);
}, "reader");
var display = os.CreateTask((tsk) =>
{
var loop = AVRASM.NewLabel();
m.PortD.Write(0xFE);
m.TempQL.Load(bcdRes.Low);
m.TempQL &= 0x0F;
led7s.Show(m.TempQL);
os.AWAIT();
m.PortD.Write(0xFD);
m.TempQL.Load(bcdRes.Low);
m.TempQL >>= 4;
led7s.Show(m.TempQL);
os.AWAIT();
m.PortD.Write(0xFB);
m.TempQL.Load(bcdRes.High);
m.TempQL &= 0x0F;
led7s.Show(m.TempQL);
os.AWAIT();
m.PortD.Write(0xF7);
m.TempQL.Load(bcdRes.High);
m.TempQL >>= 4;
led7s.Show(m.TempQL);
os.AWAIT();
tsk.TaskContinue(loop);
}, "display");
var ct = os.ContinuousActivate(os.AlwaysOn, actuator);
os.ActivateNext(ct, spiSig, treader);
os.ActivateNext(ct, tmrSig, display);
tc77.Activate();
m.Timer0.Activate();
m.EnableInterrupt();
os.Loop();
Console.WriteLine(AVRASM.Text(m));
}
}
}
Понятно, что это не рабочий проект, а только технологическое демо, призванное продемонстрировать возможности библиотеки NanoRTOS. Но во всяком случае меньше 100 строк исходного и меньше 1кб выходного кода вполне неплохой результат для работоспособного приложения.
В следующих статьях я планирую в случае интереса к данному проекту более подробно остановится уже на принципах и особенностях программирования с использованием данной библиотеки.