Расширенный ассемблер (C--) для AVR

Никакой язык программирования не сравнится а ассемблером по возможности писать самый компактный и быстрый код. На сегодняшний день, каким бы продвинутым не был компилятор, и какие бы хитрые оптимизации он не творил с кодом, результат всё равно не будет идеален. А иногда он будет совсем сильно не идеален. По крайней мере, это точно свойственно компилятору AVR GCC.

Почему ассемблер для AVR - это хорошо

Не смотря на все утверждения о том, что современные компиляторы научились отлично оптимизировать код лучше человека, это не так. По кр.мере, применительно к AVR GCC. Практика показывает, что создаваемый им код может быть ощутимо улучшен по размеру (и, соответственно, скорости выполнения). Взять, например, обработчики прерываний, которые должны сохранять все используемые регистры на старте и восстанавливать их при завершении. Так вот тут AVR GCC часто использует в обработчике регистров больше, чем надо (т.е., использует несколько разных регистров там, где можно было бы использовать один и тот же регистр повторно). И, соответственно, имеем лишнюю работу по сохранению/восстановлению из стека (это при том, что операции со стеком занимают по два машинных цикла). Оптимизация обработчиков прерываний особенно важна если они вызываются десятки тысяч раз в секунду, тогда даже устранение одной лишней пары PUSH/POP даст ощутимую экономию ресурсов CPU.

Другой пример - функции, работающие с десятком (примерно) переменных и аргументов. GCC может свободно использовать большую часть верхних регистров (r18 - r31, подробнее см. тут) в коде Си-функции не заботясь о их сохранении. Если же этих регистров ему немного не хватило, компилятор вызывает довольно “жирные” подпрограммы __prologue_saves__ и __epilogue_restores__ при входе в функцию и выходе из неё. Функции эти сохраняют на входе стеке и восстанавливают на выходе все нижние регистры (все, без разбора). Это также даёт ощутимый оверхед. При том, что часто этой же функции, переписанной руками, будет достаточно доступных регистров, и сохранять вообще ничего не придётся.

Третий пример - не все команды ассемблера имеют свои аналоги в Си. Например, проход по битам байтовой переменно на Си выльется в монструозный цикл, внутри которого вычисляется битовая маска и делается логическое И по этой маске. Тогда как на ассемблере такой код может быть гораздо проще сделан с использованием команды ROR. Аналогичные моменты возникают при работе с массивами и строками, когда ассемблерный код может быть более лаконичен.

Итого, на ассемблере имеет смысл почти всегда писать обработчики прерываний и наиболее требовательные к производительности функции. Также на нём можно писать совсем простые прошивки. И, да, если писать код целиком на ассемблере, то можно свободно использовать в нём дополнительные регистры r0-r17, которые GCC использует по своему усмотрению (и при написании ассемблерных процедур, вызываемых из Си-кода, программист должен заботиться о сохранении и восстановлении этих регистров).

Один наглядный пример

В качестве примера рассмотрим простейший обработчик прерывания, который инкрементирует uint16-переменную при переполнении таймера. С-код тривиален:


ISR(TIMER1_OVF_vect) {
	counter16++;
}

Сгерерированный компилятором ассемблерный код будет сложнее:


  ISR(TIMER1_OVF_vect) {
    3a0c:	1f 92       	push	r1
    3a0e:	0f 92       	push	r0
    3a10:	0f b6       	in	r0, 0x3f	; 63
    3a12:	0f 92       	push	r0
    3a14:	11 24       	eor	r1, r1
    3a16:	8f 93       	push	r24
    3a18:	9f 93       	push	r25
	counter16++;
    3a1a:	80 91 3e 04 	lds	r24, 0x043E	; 0x80043e 
    3a1e:	90 91 3f 04 	lds	r25, 0x043F	; 0x80043f 
    3a22:	01 96       	adiw	r24, 0x01	; 1
    3a24:	90 93 3f 04 	sts	0x043F, r25	; 0x80043f 
    3a28:	80 93 3e 04 	sts	0x043E, r24	; 0x80043e 
}
    3a2c:	9f 91       	pop	r25
    3a2e:	8f 91       	pop	r24
    3a30:	0f 90       	pop	r0
    3a32:	0f be       	out	0x3f, r0	; 63
    3a34:	0f 90       	pop	r0
    3a36:	1f 90       	pop	r1
    3a38:	18 95       	reti

Что тут происходит:

  • Зачем-то сохраняется регистр r1, который в обработчике не используется. Далее r1 зануляется
  • Зачем-то сохраняется регистр r0, а затем SREG
  • Выполняется инкремент счётчика
  • Сохранённые регистры восстанавливаются из стека, делается возврат из прерывания

Итого 19 команд и 46 байт. При том, что сохранять r0 и r1 тут нет никакой необходимости, обработчик можно было бы переписать так:


  push	r24
  push	r25
  in    r24, SREG
  push  r24
  lds   r24, counter16
  lds   r25, counter16+1
  adiw	r24, 1
  sts   counter16+1, r25
  sts   counter16, r24
  pop   r24
  out   SREG, r24
  pop   r25
  pop   r24
  reti

Итого получается 14 команд (при том, что мы избавились от 4х лишних push/pop команд, выполнение которых занимает по 2 такта). Если бы в обработчике не было команды adiw, и SREG бы не модифицировался, то можно было бы убрать ещё 4 инструкции. GCC же тут использует стандартный шаблон для обработчика прерываний, в котором сохраняет всё, что может быть изменено в Си-коде.

Немного фактов и цифр

В качестве примера того, почему ассемблер - это хорошо, могу привести Дисплейный модуль 128х128, на микроконтроллере ATMega328P, работающий на частоте 20 МГц. Модуль, вообщем, работает и не тормозит, но захотелось мне его переделать под ATMega8A на 16 МГц. Преимущества последней очевидны - она штатно умеет работать на максимальной частоте при питании от 3.3В (ATMega328P по даташиту при таком напряжении гарантированно будет работать только на 10 МГц), стоит в раза в 3-4 дешевле, да и достать её легче. Вообщем, захотелось мне сделать для ZX-магнитофона дисплейный модуль с питанием от 3.3В на ATMega8A, возможно, с немного урезанным функционалом, но с максимально компактной и быстрой прошивкой. Прошивка модуля на ATMega328P на тот момент имела размер в почти 20Кб (из которых ровно 7Кб занимали шрифты, 5х7 и 13х15). Шрифты были ужаты до 3.5 КБ путём оптимизации их формата. В результате под код осталось чуть более 4кб флеша (с учётом того, что надо ещё оставить место для bootloader-а). В итоге, мне удалось впихнуть всю прошивку в примерно 3Кб, сохранив основной функционал, а именно рисование точек и линий, рисование и заливка прямоугольников, окружностей и текста (шрифтами 5х7 и 13х15, латиница + кириллица + основные символы). При этом был удалён код работы с клавиатурой, пищалкой и подсветкой (только потому, что в случае ZX-магнитофона он не нужен), но оставшихся свободных полутора килобайт без проблем хватит на эти вещи. При этом производительность новой прошивки должна быть ощутимо выше, т.к., чем меньше кода, тем быстрее он будет выполняться.

Почему ассемблер для AVR ужасен

Цена эффективности ассемблера - его высокая сложность. Хотя, сама по себе система машинных команд МК достаточно проста. Но ассемблерные компиляторы до безобразия примитивные (по кр.мере, AVRA и GCC). Программа состоит из функций, а функции используют регистры для обработки данных. Всего доступно 32 8-битных регистра, которых обычно бывает более, чем достаточно, чтобы держать основные данные внутри функции. Но, при этом, нет никакой возможности присвоить этим регистрам какие-то осмысленные имена внутри функции. А человеческий мозг, как известно, может более-менее эффективно работать одновременно с 7±2 объектами. И, когда этих объектов (т.е., регистров) раза в два больше, то мозг взрывается, и читать код, пытаясь держать в уме таблицу соответствия между регистрами и их назначением, становится категорически невозможно. Приходится писать комментарии. А ещё сложнее такой код изменять.

Второе невыносимое неудобство - это передача аргументов функциям. Тут, аналогично, приходится держать в уме (или копипастить комментарии), что в каком регистре передаётся. Что так же убивает всякую читаемость кода.

Одно только устранение этих двух неудобств делает код в разы более читаемым. Вообще, тут мне вспоминается язык С--, который позволяет писать Си-подобный код и лёгкими ассемблерными вставками и доступом к регистрам как к переменным. Достаточно забавная была штука, но, к сожалению, для AVR ничего такого не создано.

Препроцессор для ассемблера

Осознав, что на чистом ассемблере писать что-то более-менее большое решительно невозможно, я решил сделать свой препроцессор, который будет расширять синтаксис языка и сохранять файл для компиляции. Препроцессор написан на яве и дружит с Atmel assembler/AVRA и AVR GCC.

Сначала я добавил возможность понятие процедуры, имеющей аргументы, локальные псевдонимы для регистров и локальные метки. Затем, сделал циклы и условные операции. Далее - возможность работать с группами регистров как единым целым (для организации переменных длиной более, чем 8 бит). По мере развития проекта, а исходном, когда-то ассемблерном коде, становилось все меньше и меньше ассемблера..

Препроцессор позволяет как писать код на чистом ассемблере, деля минимальные вставки, так и почти полностью перейти на Си-подобный синтаксис.

Синтаксис

Процедуры

Чтобы быстро продемонстрировать возможности языка, рассмотрим пример функции. Это функция рисования круга, которая использует всего 10 регистров - три - в качестве аргументов и семь - в качестве локальных переменных. Именно эта функция, будучи написанной на ассемблере, заставила меня начать писать препроцессор - её код был абсолютно нечитаем.


.proc drawCircle
   .args circle_x(r24), circle_y(r22), radius(r20)
   .use r19 as x0, r16 as y0
   .use r26 as ddF_x, r27 as ddF_y
   .use r30 as x, r31 as y, r23 as f

   x0 = circle_x
   y0 = circle_y
   f = 1 - radius
   ddF_x = 1
   ddF_y = radius
   x = 0
   y = radius
   ddF_y = -ddF_y
   ddF_y += ddF_y

   rcall drawPixel (x: circle_x,   y: circle_y+radius)
   rcall drawPixel (x: x0,   y: y0-y)
   rcall drawPixel (x: x0+y, y: y0)
   rcall drawPixel (x: x0-y, y: y0)

   loop {
      if s(x >= y) {
        ret
      }
      if (!f[7]) {
        y -= 1
        ddF_y += 2
        f += ddF_y
      }
      x++
      ddF_x += 2
      f += ddF_x

      rcall drawPixel(x: x0 + x, y: y0 + y)
      rcall drawPixel(x: x0 + x, y: y0 - y)
      rcall drawPixel(x: x0 - x, y: y0 - y)
      rcall drawPixel(x: x0 - x, y: y0 + y)

      rcall drawPixel(x: x0 + y, y: y0 + x)
      rcall drawPixel(x: x0 + y, y: y0 - x)
      rcall drawPixel(x: x0 - y, y: y0 + x)
      rcall drawPixel(x: x0 - y, y: y0 - x)
   }
.endproc

Сначала мы объявляем процедуру директивой .proc. Имя продедуры станет меткой в ассемблерном коде. Затем идёт объявление трёх аргументов процедуры директивой .args- координат и радиуса окружности. Далее, директивы .use определяют локальные для процедуры псевдонимы для регистров, весь дальнейший код бедет оперировать ими. Вместо ассемблерных команд mov, ldi и разной арифметики можно использовать простые математические выражения. Так же процедура может иметь свои локальные метки, начинающиеся с символа @, эти метки видны только внутри процедуры.

Процедура drawCircle вызывает другую процедуру drawPixel, принимающую два аргумента - координаты точки. Аргументы передаются через регистры. При вызове процедуры (например, командой rcall) или переходе на неё (например, командой rjmp) можно указать в скобках значения переменных в формате имя : значение. Переменные будут проинициализированы в том порядке, в котором они объявлены. При этом можно указать не все аргументы, а только нужные (если мы знаем, что другие регистры аргументов уже инициализированы правильно). Если аргумент у процедуры только один, его имя (вместе с двоеточием) можно опустить.

Если надо сослаться на функцию, которая не объявлена в текущем файле (актуально в AVR GCC), это можно сделать директивной .extern так:


.extern drawPixel (x: r24, y: r22)

Аналогичным образом можно объявить фцнкцию одной строкой (без директивы .args):


.proc drawCircle (circle_x: r24, circle_y: r22, radius: r20)
...

Циклы

Часто нужно организовать цикл со счётчиком от некоторой величины до нуля. Для этого служат директива loop. Выражение, выполняющиеся в цикле, заключается в фигурные скобки. Пример цикла:


delay_10ms:
  loop (r18 = 100) {
    rcall delay_100us
  }

В круглых скобках можно указать регистр, который бедет служить счётчиком цикла и (опционально) его стартовое значение (в данном примере это 100, именно столько раз будет выполнено содержимое блока). Если регистр не указан, то цикл будет выполняться бесконечно. Также в циклах могут использоваться команды break (передаёт управление на первую инструкцию, следующую за циклом) и continue (передаёт управление на начало цикла не уменьшая счётчика).

Условные операторы

Чтобы не держать в уме более 20 ассемблерных команд условных переходов, добавлена команды if и else. Её условием может быть

  • арифметическое сравнение (==, !=, <, >, <=, >=)
  • проверка одиночного бита регистра
  • проверка флага регистра SREG
  • проверка бита порта ввода-вывода

Условие должно быть простым (&& и || не поддерживаются). Операции могут быть одиночными и блочными. Следующие примеры демонстрируют возможности if:


if (!io[UCSRA].UDRE) goto UartSendChar		; wait for empty buffer

if (!r11[0]) ret

if (!F_ZERO) goto @return

if (r21 == 0xFE) goto send_command

if (r21.r22 < ZH.ZL) break

if (ZH.ZL.XH.XL >= r4.r3.r2.r1) {
  ZH.ZL.XH.XL -= r4.r3.r2.r1
  F_CARRY = 1
} else {
  F_CARRY = 0
}

Группы регистров

Кроме 32 8-битных регистров доступны регистровые пары X, Y и Z (их имена всегда пишутся заглавными буквами). Т.к. 8-битных регистров AVR не всегда достаточно, их можно объединять в группы точкой. Например так:


loop {
    if (r11.r12 < ZH.ZL) break
    r11.r12 -= ZH.ZL
  }

Группы могут использваться как в математических выражениях, так и в качестве аргументов процедур.

Переменные и константы

Регистры - это хорошо, но их на всё не хватит. Кроме регистров нужны переменные в памяти. Переменная может иметь один из следующих типов:

Имя Размер (байт) Описание
byte 1 Одно-байтовая переменная в ОЗУ
word 2 Двух-байтовая переменная в ОЗУ
dword 4 Четырёх-байтовая переменная в ОЗУ
pointer 2 Указатель на область ОЗУ, массив
ptr 2 Указатель на константный массив (строку) во флеш-памяти

Примеры объявления переменных:


.extern s_video_mem : ptr
.extern var_b : byte
.extern var_w : word
.extern var_dw : dword

txtErr:
	.DB "Error in parameter!"
	
Z = txtUartErr    ; поместить в регистровую пару Z смещение строки-константы

Массивы PRG, RAM и IO

Для доступа к ОЗУ, ПЗУ и портам ввода-вывода можно использовать квази-массивы с именами ram[], prg[] и io[] соответственно. Например команда


   r0 = io[PINA]

прочтёт и сохранит содержимое порта PINA в регистре r0

Доступ к портам возможен только по их именам, доступ к памяти возможен только по адресам-регистровым парам. Т.е., тут всё как в ассемблере, декрементировать и инкрементировать адрес до или после выполнения операции:


  r0 = ram[Z++] ; прочитать байт из ОЗУ с пост-декрементом указателя Z
  r1 = prg[--X] ; прочитать байт из флеш-памяти с пред-декрементом указателя X

Чтение/запсись из портов и памяти возможна только через регистр. Следующая конструкция прочтёт байт из памяти, адресованной регистровой парой Z во временный регистр r0, а затем передаст его в порт UART-а UDR:


  io[UDR] = r0 = ram[Z]

А вот пример кода, передающего 16 байт из буфера в памяти в порт UART:


  loop (r1 = 16) {
    if (!io[UCSRA].UDRE) continue	; ждём, пока буфер пуст
    io[UDR] = r0 = ram[Z++]
  }

Тут ещё раз стоит заметить, что continue передаст управление на начало блока цикла и счётчик r1 при этом не будет декрементирован.

А этот код прочтёт строку из памяти программ и передаст её побайтово функции рисования на дисплее:


loop (len) {
  rcall	writeToLCD (prg[Z++])
  rcall	delay40us
}

Флаги

Для доступа к флагам регистра SREG введены следующие квази-переменные: F_GLOBAL_INT, F_BIT_COPY, F_HALF_CARRY, F_SIGN, F_TCO, F_NEG, F_ZERO, F_CARRY Их можно использовать в качестве условий операций if - goto и присвоения.

Как этим пользоваться

Это описание очень поверхностное и навряд ли может дать полное понимание. Лучше ознакомиться с языком можно посмотрев исходники реальных проектов:

Первый проект: Частотомер-тестер кварцев
Исходник, переписанный на расширенный синтаксис
Оригинальный ассемблерный исходник

Тут я старался особо не трогать процедуры обработки прерываний, и оставил их на ассемблере, чтобы было проще считать такты. Остальной код переписан.

Второй пример: Контроллер клавиатуры 86РК Камиля Каримова
Исходники проекта закрыты, и прошивку пришлось (да простит меня автор, но по-другому никак не заточить прошивку под себя) декомпилировать.
Исходник, переписанный на расширенный синтаксис
Декомпилированный ассемблерный исходник

Для сборки этих примеров удобно использовать avr-builder, препроцессор включён в него, а скрипты для сборки проектов есть в их репозиториях. Чтобы включить расширятор синтаксиса, надо добавить в файл make.builder одну строку:


asm_ext = True

Тогда при сборке все ассемблерный файлы будут прогоняться через препроцессор avr-asm-ext. Для работы препроцессора в системе должна быть установлена Java.

Исходный код препроцессора можно найти на гитхабе. В данный момент проект полностью переписывается с нуля с целью получить вместо препроцессора полноценный компилятор со своей средой разработки (в данный момент это плагины для IDE NetBeans). Кроме AVR планируется добавить поддержку других архитектур, в частности, Z80.

Исходники препроцессора на гитхабе
Рейтинг: 
0
Голосов еще нет

Комментарии

Тоже возникали мысли что для микроконтроллеров нужен компилятор похожий на Си и генерирующее такой же оптималный код как ассемблер.

Кстати на С-- есть компилятор для AVR и  PIC но он для старых процов и использует декодирование команд i386 в команды AVR.