The Rat - компилятор C-- для AVR

The Rat AVR

Как показывает практика, написать максимально эффективный код под микроконтроллеры 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

Если надо разместить блок по конкретному адресу, используется директива $org(address). Где address - адрес (чётный!) в байтах по которому будет размещена процедура в памяти программ МК. Директива нужна, в первую очередь, для написания bootloader-ов. Пример:


  #define BOOT_START			0x1f00

  $org(BOOT_START)
  proc init_bootloader() {
  }

Расширенный синтаксис (aka C--)

Кроме ассемблерных инструкций можно использовать более человеко-читаемые варианты из Си-подобных языков.

Выражения

Регистру (или группе) можно присвоить некоторое выражение из суммы или разности других регистров (групп), констант, переменных и портов. Например


  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 и 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++]
}

Работа с IO-пинами

Ключевое слово 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(), bitmask(), sizeof()

Операторы 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-процедуры

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 вместе с AVR GCC

Компилятор 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

Файлы

Downloadrat-v0.1.tar.bz2
Downloadrat-v0.2.tar.bz2
Downloadthe-rat.jar (jar-файл самой последней версии)
Рейтинг: 
0
Голосов еще нет