
Как показывает практика, написать максимально эффективный код под микроконтроллеры AVR можно только на ассемблере. Примеры плохой оптимизации кода AVR GCC приводились тут. Писать же код на ассемблере - сложно, а зачастую очень сложно (некоторые мысли о причинах этой сложности приведены там же. И сложность эта усугубляется примитивностью компиляторов ассемблера, дающие слишком мало возможностей программисту по написанию легко читаемого кода. При написании ассемблерного кода возникает желание иметь более удобный синтаксис, похожий на синтаксис Си, где вместо мнемоник ассемблера можно использовать привычные и более читаемые конструкции. Подобно тому, как это делается в языке C--. Это желание и побудило на создания компилятора C-- - подобного языка для AVR, позволяющего писать компактный и шустрый код, и названного Rat.
Компилятор Rat можно рассматривать как обыкновенный компилятор ассемблера с препроцессором C. Весь код можно писать на практически "обыкновенном" ассемблере. При этом, есть несколько особенностей:
proc
)#define CPU
var
Рассмотрим простой пример кода моргания светодиодом на чистом ассемблере
#define CPU "atmega8"
proc main() {
sbi DDRD, 0
main_loop:
sbi PORTD, 0
ldi r21, 100
rcall delay
cbi PORTD, 0
ldi r21, 200
rcall delay
rjmp main_loop
}
proc delay(time: r21) {
loop_1:
ldi r22, 0xff
loop_2:
nop
dec r22
brne loop_2
dec r21
brne loop_1
ret
}
Этот же код может быть написан на Rat таким образом:
#define CPU "atmega8"
pin ledPin = D0
proc main() {
ledPin->ddr = 1
loop {
ledPin->port = 1
rcall delay(100)
ledPin->port = 0
rcall delay(200)
}
}
proc delay(time: r21) {
loop (time) {
loop (r22 = 0xff) {
nop
}
}
ret
}
В коде остались ассемблерные менмоники, ни их стало меньше. Меток тоже стало меньше (точнее, не осталось совсем). В результате даже такой тривиальный код стал более компакетн и читаем. Эффективность же кода при этом не ухудшилась.
Весь код программы должен находиться в процедурах. Объявление процедуры состоит из ключевого слова proc
, опционального списка
аргументов в круглых скобках, и тела процедуры в фигурных скобках. Все метки, объявленные внутри процедуры, являются локальными, на них не получится
сослаться извне. Если нужна глобальная метка, доступная из других процедур, то перед её именем указывается амперсанд:
@global_label:
Для функции можно указать список аргументов. Например:
proc drawCircle(x: r24, y: r22, radius: r20)
тут x
, y
и radius
- имена аргументов, r24
, r22
, r20
- имена регистров,
в которых будет передаваться эти аргументы. Имена аргументов можно использовать в теле функции как синонимы соответствующих регистров.
Также эти имена можно передать при вызове функции (директивами rcall и
rjmp, например
rcall drawCircle (x: 20, y: 30, radius: 15)
Компилятор добавит необходимые инструкции инициализации переменных перед вызовом rcall. Порядок инициализации определяется порядком перечисления аргументов в команде вызова. Для нашего примера будет сгенерирован такой код:
ldi r24, 20
ldi r22, 30
ldi r20, 15
rcall drawCircle
В качестве значения аргумента можно использовать не только константы, но и другие регистры, переменные, порты и выражения.
Если процедура имеет только один аргумент, то его имя при вызове может быть опущено. Например для процедуры
proc delay(time: r21)
возможны оба варианта вызова:
rcall delay(100)
rcall delay(time: 100)
Важно не забыть, что в конце процедуры обычно должна быть команда возврата (ret или reti для обработчика прерываний). Эту команда должна явно указываться программистом.
AVR имеет 32 8-битных регистра (r0
- r31
), и для хранения и передачи больших значений, их далеко не всегда бывает достаточно.
Регистры можно объединять в группы точкой. Например
r24.r23
Тут r24
- старший байт, r23
- младший. Группа (24-битная) из трёх регистров:
r20.r22.r2
Такие группы можно использовать в выражениях и в качестве аргументов процедур.
Микроконтроллеры AVR имеют “особые” регистры, образующие регистровые пары, имеющие специальное назначение (доступ к ОЗУ и флеш-памяти).
X = XH.XL = r27.r26
Y = YH.YL = r29.r28
Z = ZH.ZL = r31.r30
В коде можно использовать все эти имена. Следует заметить, что имена регистров не чувствительные к тому, заглавная буква ("R") или строчная ("r").
Имена регистровых пар и их компонентов (X
, XH
, XL
и т.д.) пишутся только заглавными буквами.
Вообще, все идентификаторы в языке чувствительны к регистру символов (как в Си), за исключением имён r-регистров и ассемблерных инструкций
(как в ассемблере).
Синонимы позволяют присвоить регистру или группе регистров читаемое имя. Синоним объявляется при помощи ключевых слов use as
.
Например
use r23 as flags
use r10.r11.r12 as counter
Синонимы могут быть как глобальными, так и локальными. Глобальные синонимы объявляются вне процедур и доступны во всей программе. Локальные синонимы будут доступны только в процедуре, где они объявлены.
Локальные синонимы (вместе с именованными аргументами) решают одну из проблем ассемблера - необходимость держать в уме назначение всех регистров при работе с кодом.
Если регистр используется только в качестве глобальной переменной, то при объявлении синонима можно "захватить" его, указав в конце восклицательный знак:
use r0 as reg_zero!
Такая запись запрещает ссылаться в коде на регистр r0
по его "машинному" имени, доступ будет возможен только по алиасу.
Возможность захвата регистра позволяет предотвращать его ошибочное использование в случае, если вы где-то забыли, что регистр уже используется
в качестве глобальной переменной и попытаетесь использовать как общий (временный) регистр, забыв при этом сохранить/восстановить его содержимое.
Кроме регистров, переменные не-константные данные программы могут храниться также в ОЗУ (SRAM). Для адресации ОЗУ используются переменные и массивы.
Переменные объявляются с помощью ключевого слова var
и могут иметь следующие типы:
byte
, word
, dword
- ячейка памяти размером,
соответственно, 1, 2 и 4 байта.
ptr
- указатель , адрес в ОЗУ (2 байта)
prgptr
- указатель, адрес во флеш-памяти МК (2 байта, флеш-память адресуется двухбайтовыми словами)
Примеры объявления переменных
var current_command: byte
var a1, a2, a3: byte
var backlight_time: word
Кроме простых переменных, можно объявить массив, указав в квадратных скобках его размер
var video_memory: byte[32]
Для доступа к портам используются их имена, которые загружаются из dev-файла микроконтроллера. Также в dev-файле описаны имена битовых полей портов ввода-вывода. К портам ввода-вывода можно обращаться как к обыкновенным переменным чтобы писать и читать их:
r24 = ADC
ADCSRA = r24
также можно писать и читать их битовые поля (как по номеру бита, так и по его имени):
ADMUX->0 = 0
ADMUX->MUX0 = 1
Аналогично, битовые поля портов можно использовать в условиях и циклах
if (!ADMUX->0)
if (ADMUX->MUX0)
loop (!SPSR->SPIF) {}
Встроенный препроцессор аналогичен препроцессору Си и поддерживает следующие директивы:
#include, #define, #undef, #ifdef, #ifndef, #if, #else, #elif, #endif
Поддерживаются однострочные и блочные комментарии. Первые имеют традиционный ассемблерный синтаксис и начинаются с точки с запятой:
var rx_buf[32] ; буфер чтения UART
Для блочных комментариев используется Си-шный стиль /* */
/*
Любой текст
до закрывающих скобок
*/
Для определения таблицы векторов прерывания служит блок vectors
, состоящий из меток-названий прерывания и кода их обработчика.
Имена прерываний конкретного микроконтроллера можно посмотреть в его dev-файле.
Команда обработчика должна занимать ровно одно слово.
Блок default
содержит команду, которая будет прописана для всех прерываний по умолчанию (обычно это reti).
Блок default
опционален.
Пример:
; объявление таблицы векторов прерываний
vectors {
TIMER2_COMP:
rjmp timer2_comp
default:
reti
}
; объявление обработчика прерывания
proc timer2_comp() {
reti
}
Если надо разместить блок по конкретному адресу, используется директива $org
(address).
Где address - адрес (чётный!) в байтах по которому будет размещена процедура в памяти программ МК.
Директива нужна, в первую очередь, для написания bootloader-ов.
Пример:
#define BOOT_START 0x1f00
$org(BOOT_START)
proc init_bootloader() {
}
Кроме ассемблерных инструкций можно использовать более человеко-читаемые варианты из Си-подобных языков.
Регистру (или группе) можно присвоить некоторое выражение из суммы или разности других регистров (групп), констант, переменных и портов. Например
r1 = r2 ; копирование регистров
r24 = 010 ; запись в регистр восьмеричного значения
r24 += '0' ; сложение регистра с символьной константой (ASCII кодом символа)
r24 = r25 + 0x12 ; сложение регистра с шестнадцатеричным значением
r24 &= 0b10100000 ; логическое И регистра с двоичным значением
r1 = -r2
r1 = r2 + r3 - 8
r1 = -r2
r1 += r2
r1.r2++ ; инкремент регистровой пары
r1.r2 += 1000 ; сложение регистровой пары с константой
r1.r2 = X + 10
r1 = byte_variable + 10 ; инициализация регистра переменной из SRAM с последующим прибавлением 10
r25.r24 = word_variable - 1 ; инициализация регистровой пары переменной из SRAM с последующим декрементом
Тут имеются ограничения и не любая конструкция может быть скомпилирована. Но принцип такой, что если конструкция может быть переведена в ассемблер максимально эффективным способом, то компилятор её “переварит”. Если же это невозможно (например, без использования вспомогательных регистров/стека), то выдаст ошибку. Например, не получится присвоить регистру сумму значений двух переменных, т.к. такая операция потребовала бы использовать вспомогательный регистр.
При этом, компилятор, при возможности, генерирует максимально эффективный код. Например, при возможности, для операций с регистровыми парами будут использоваться инструкции movw, adiw и sbiw.
В выражениях можно использовать множественные присвоения, что удобно при записи в порты. Например, инициализация стека константой RAMEND
с
использованием вспомогательных регистром может выглядеть так:
SP = r16.r17 = RAMEND
или так:
SPH = r16 = high(RAMEND)
SPL = r16 = low(RAMEND)
Множественные присвоения выполняются справа налево. Т.е., для приведённых примеров сначала будет выполнена запись константы в регистр, затем, запись этого регистра в порт.
Язык позволяет обращаться напрямую к битам регистра как к элементам массива:
r21[0] = 1
Аналогичным образом можно обращаться к битам регистровых пар:
use r23.r22 as addr
addr[10] = 1
Система команд AVR позволяет копировать биты регистров во флаг Т и наоборот.
SREG->T = r26[3]
r25[4] = SREG->T
Или одной строкой (можете сравнить, сколько строчек Си-кода заняла бы такая пересылка бит):
r25[4] = SREG->T = r26[3]
Условный оператор if
- else
аналогичен таковому в Си-подобных языках.
В качестве условия могут использоваться выражения, оперирующие регистрами и группами (арифметическое сравнение, операции ==, !=, <, >, <=, >=), а также битами регистров и портов ввода-вывода. Примеры условий:
if (r10[5]) ; проверка бита регистра
if (!ADMUX->0) ; проверка бита порта ввода-вывода
if (ADMUX->MUX0) ; проверка бита порта ввода-вывода
if (r21 == 0xFE) ; проверка равенства регистра константе
if (r21.r22 < ZH.ZL) ; сравнение двух регистровых пар
if (SREG->Z) ; проверка бита Z в SREG
Также поддерживаются сложные выражения в виде комбинаций с использованием операторов &&
||
, !
и
круглых скобок. Телом оператора может быть как одна команда, так и блок в фигурных скобках.
Если надо выполнить сравнение знакового целого, то используется преобразование (signed)
на регистре со знаковым значением.
Например
if ((signed)r12 >= -50)
if (((signed)r12 >= -50) && ((signed)r12 <= -3))
Ключевое слово loop
используется для объявления циклов. Аргументом может служить выражение с
регистром-счётчиком (или регистровой парой)
loop (r24 = 123) {
nop ; тело цикла будет выполнено 123 раза, пока значение r24 не достигнет нуля
}
Правая часть выражения может быть опущена (если необходимая инициализация уже была выполнена ранее):
loop (r25.r24) {
nop
}
Также, аргументом может быть бит порта ввода-вывода:
loop (SPSR->SPIF) {} ; цикл ожидания установки SPSR->SPIF в 0
loop (!SPSR->SPIF) {} ; цикл ожидания установки SPSR->SPIF в 1
Если нужен бесконечный цикл, то условие в скобках опускается:
loop {
; бесконечный цикл
}
В теле цикла можно использовать операторы break
и continue
.
Первый прерывает исполнение цикла, второй - переход к его началу (т.е., к проверке условия и выполнению следующей итерации, если оно выполняется).
Ключевые слова mem
и prg
позволяют обращаться к ОЗУ микроконтроллера (на чтение и запись)
и его флеш-памяти, содержащей программу (на чтение) соответственно.
Чтение из массива mem[]
транслируется в инструкции
ld и ldd.
Запись в массив mem[]
транслируется в инструкции
st и std.
Индексом массива mem[]
может быть любая регистровая пара X
, Y
,
Z
,
с возможным пред- или пост-инкрементом/декрементом. В случае индексации парой Y
возможно указать смещение
Y+q, где 0 ≤ q ≤ 63 (команды ldd и std).
Примеры использования квази-массива массива mem[]
:
; чтение ячейки памяти с косвенной адресацией
r24 = mem[Z]
; заполнением массива нулями
r16 = 0
loop (r17 = 32) {
mem[Z++] = r16
}
; чтение из UART в массив
loop (r23 = PAGE_SIZE) {
rcall uartWaitChar
mem[Y++] = r24
}
Квази-массив prg[]
даёт доступ к данным внутри флеш-памяти МК.
Чтение из массива prg[]
эквивалентно инструкции lpm.
Тут индексом может быть только Z
и Z++
.
Пример:
; чтение массива из области кода в ОЗУ (с использованием промежуточного регистра)
loop (r16 = 15) {
mem[X++] = r0 = prg[Z++]
}
Ключевое слово pin
описывает единичный пин порта ввода-вывода позволяя обращаться к нему по имени.
Объявление пина выглядит так:
pin pinLcdE = B5
pin pinKeyLeft = C1
Записи означают, что пин номер 5 порта B получает имя pinLcdE, а пин 1 порта C - имя pinKeyLeft.
У пинов доступны три атрибута: port
, ddr
и pin
, позволяющие обращаться к соответствующим битам
портов PORTx
, DDRx
и PINx
следующим образом:
pinLcdE->ddr = 1 ; настроить пин pinLcdE на вывод
pinKeyLeft->ddr = 0 ; настроить пин pinKeyLeft на ввод
pinLcdE->port = 1 ; установить высокий уровень на пине
pinKeyLeft->port = 1 ; включить подтяжку к питанию входного пина
if (pinKeyLeft->pin) r24 = 0 ; проверка уровня входного пина
Также пины могут использоваться в качеств индекса для доступа к битам регистров
r16 = PORTB
r16[pinLcdRs] = 1
r16 |= 0b11100000
PORTB = r16
Операторы high()
и low()
принимают один двухбайтовый аргумент и возвращают,
соответственно, его старший и младший байты. Например
UBRRH = r16 = high(UART_UBRR)
UBRRL = r16 = low(UART_UBRR)
Оператор sizeof()
принимает один аргумент-массив ОЗУ и возвращает его размер
var video_memory: byte[32]
; ....
loop (r20 = sizeof(video_memory)) {
; ...
}
Оператор bitmask()
принимает переменное количество аргументов (от 1 до 8) с номерами битов (от 0 до 7) и
формирует битовую маску на их основе. В качестве аргументов могут выступать константы, биты портов ввода-вывода или пины.
Звучит не очень понятно, и лучше смотреть на примерах:
; эти два выражения равнозначны
r24 = (1 << PGWRT) | (1 << SPMEN)
r24 = bitmask(PGWRT, SPMEN)
Помимо компактности преимущество использования bitmask()
в том, что компилятор проверит, что все биты принадлежат
одному регистру ввода-вывода, и выдаст ошибку, если комбинируются разнородные (принадлежащие разным портам) биты.
Приведём ещё несколько примеров использования bitmask()
:
UCSRB = r16 = bitmask(RXEN, TXEN)
UCSRC = r16 = bitmask(URSEL, UCSZ0, UCSZ1)
pin pinLcdE = B5
pin pinLcdRs = B4
DDRB = r16 = 0x0F | bitmask(pinLcdE, pinLcdRs)
Inline-процедуры - аналог ассемблерных макросов, при "вызове" в коде содержимое тела процедуры будет подставлено в месте вызова. Их имеет смысл использовать вместо коротких (1-3 команды) процедур, либо вместо процедур, вызываемых только один раз, для лучшей структуризации кода. Пример:
pin highlight = A7
inline proc highlight_enable() {
highlight->port = 0
}
inline proc highlight_disable() {
highlight->port = 1
}
; .....
highlight_disable()
Подобно битам регистров можно адресовать и биты числовых констант. Для получения N-го бита, его номер записывается в квадратных скобках. Например
3[0] ; вернёт младший бит, т.е. 1
100[7] ; вернёт старший бит, т.е. 0
Это дает возможность преобразовывать битовые маски и формировать различные константы на этапе компиляции. Например, следующий макрос меняет порядок бит в байте на обратный:
#define reverse(v) ((v[0]<<7) | (v[1]<<6) | (v[2]<<5) | (v[3]<<4) | (v[4]<<3) | (v[5]<<2) | (v[6]<<1) | (v[7]<<0))
Ассемблерные команды работы со стеком push
и pop
практически всегда используются парами.
Как правило, сначала мы сохраняем регистры в стек в некотором порядке, затем извлекаем их в порядке строго обратном.
Чтобы улучшить читаемость кода и не дать тут "выстрелить себе в ногу", в язык добавлена команда saveregs
,
которая получает список регистров и блок кода.
Смысл команды очень прост - сначала она сохраняет в стек переданные регистры (в указанном порядке), затем добавляет блок кода, после чего восстанавливает
сохранённые регистры из стека.
saveregs(r1, r2, SREG, r24) {
... ; некоторый блок кода
}
Помимо регистров в списке может быть указан порт SREG (который часто надо сохранять в обработчиках прерываний). При этом порт SREG не может быть первым в списке, ему всегда должен предшествовать регистр, который будет использоваться в качестве временного. Результат компиляции:
push r1
push r2
in r2, SREG
push r2
push r24
... ; некоторый блок кода
pop r24
pop r2
out SREG, r2
pop r2
pop r1
Также следует заметить, что для директив push
и pop
можно сразу указывать несколько регистров
(и предыдущий ассемблерный листинг можно было бы сократить так):
push r1 r2
in r2, SREG
push r2 r24
... ; некоторый блок кода
pop r24 r2
out SREG, r2
pop r2 r1
Компилятор Rat может генерировать как готовый машинный код (в hex или двоичном формате) так и ассемблерный листинг в формате AVR GCC (S-файл). Первый режим позволяет писать весь код полностью на Rat, второй режим позволяет использовать Rat вместе с Си (чтобы писать на Rat только критичные участки кода).
В случае совместного использования с языком Си возникают два вопроса: "как вызывать Rat-процедуры из Си кода?" и "как вызывать Си-процедуры из Rat-кода?".
Компилятор Rat генерирует ассемблерный листинг объявляя все не-приватные процедуры как global
, что делает их доступными в Си-коде
(где они должны быть объявлены как extern
).
Процедура будет считаться приватной и не экспортироваться, если её имя начинается с символа подчеркивания _.
Чтобы получить доступ к Си-процедурам и переменным из Rat, надо также использовать директиву extern
при объявлении функции/переменной:
extern proc readNextByte()
extern proc writeNextByte(b: r24)
extern var buffer: byte
Чтобы использовать Rat-процедуры и переменные из Си-кода, их надо задекларировать стандартным способом
uint8_t readNext();
void writeNextByte(uint8_t b);
extern uint8_t buffer;
Про особенности передачи аргументов и возвращаемых функцией значений в AVR GCC можно почитать тут.
Скачать компилятор можно по ссылке в конце. Он написан на Java (точнее, Kotlin) и является кроссплатформенным. Для запуска под *nix-системами архив
имеет shell-файл ratc
. Синтаксис запуска компилятора следующий:
Usage: ratc [options] <source_file> [<output_file>]
OPTIONS:
-D<macro>=<value> Define <macro> to <value> (or 1 if <value> omitted)
-I<dir> Add directory to include search path
-dev=<path> Set path to dev-files
-gcc Produce GCC Assembler file
-list Produce assembler listing
-help Show this usage screen
Пакет содержит в себе каталог devices
с файлами, содержащими параметры микроконтроллеров (размеры флеш-памяти, SRAM и EEPROM,
векторы прерываний и порты ввода-вывода). Директивой #define CPU
выбирается имя dev-файла. В результате компиляции по умолчанию создаются
hex- и map- файлы. Опция командной строки -gcc
заставить компилятор сгенерировать ассемблерный код для GCC.
В качестве среды разработки лично я использую NetBeans версии 8.2 (это последняя версия IDE, поддерживающая C/C++).
При совместном использовании Rat- и Си-кода проект можно собирать с помощью avr-builder. Исходные
файлы Rat имеют расширение art
(Avr RaT) и avr-builder вызовет для них компилятор и линковщик.
В качестве одного из примеров в каталоге samples
есть прошивка частотомера-тестера кварцев на Atmega8,
переписанная на Rat.
Также в архиве с компиялтором имеется плагин для подсветки синтаксиса языка в NetBeans (файл misc/netbeans/ru-trolsoft-therat-avr.nbm для версии 0.1).
Исходники доступны на гитхабе: https://github.com/trol73/the-rat-avr