Arduino. Ассемблер AVR8

Иногда бывает так, что только ассемблер способен помочь создать эффективный код для скетча Arduino. И тогда на помощь приходит встроенный (inline) ассемблер AVR-GCC.

Arduino. Ассемблер AVR8

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

PORTD |= _BV(1);

__asm__ __volatile__(

"nop\n\t"

"nop\n\t"

"nop\n\t"

"nop\n\t"

);

PORTD &= ~_BV(1);

или, что несколько читабельнее:

PORTD |= _BV(1);

__asm__ __volatile__("nop");

__asm__ __volatile__("nop");

__asm__ __volatile__("nop");

__asm__ __volatile__("nop");

PORTD &= ~_BV(1);

Оформив таким образом все команды ассемблера AVR8 как набор inline-функций и макросов, я получил в результате заголовочный файл с говорящим названием avrasm.h (скачать). Файл этот следует поместить в каталог Libraries/avrasm программного обеспечения Arduino. С его использованием вышеприведённый код будет выглядеть следующим образом:

#include <avrasm.h>


PORTD |= _BV(1);

__NOP;

__NOP;

__NOP;

__NOP;

PORTD &= ~_BV(1);

Надо заметить, что в самом по себе программировании Arduino на ассемблере нет ничего сложного, но есть нюансы, которые неизбежно приходится учитывать. Например, результат команды умножения MUL помещается в пару регистров R0:R1, которая компилятором языка программирования C используется следующим образом: регистр R0 используется как временный (temporary) регистр, а регистр R1 как нулевой (zero) регистр (теоретически его значение всегда должно быть равно нулю).

Поэтому на ассемблере удобнее всего писать совсем небольшие программы. Например, когда я делал 8-битный синтезатор, изучая взаимодействие нескольких микроконтроллеров, то один из микроконтроллеров у меня выполнял функцию UART-приёмника MIDI с буфером FIFO. Соответственно лучше всего и быстрее всего он работал с микропрограммой, целиком написанной на ассемблере (на C работал значительно медленнее, и даже иногда подвисал).

С другой стороны программы на ассемблере намного сложнее отлаживать, поскольку код программы имеет свойство довольно быстро разрастаться, и найти в программе ошибку типа опечатки - задача крайне утомительная и трудоёмкая.

Регистры

Микропроцессор AVR8 является RISC-процессором, то есть процессором с уменьшенным набором команд (в сравнении с CISC-процессорами), но зато эти команды выполняются за 1-2 машинных такта, плюс он имеет 32 8-битных регистра, мнемонически обозначаемых R0..R31.

Регистры R0..R15 являются младшими регистрами (не все команды принимают их в качестве операндов), регистры R16..R31 являются старшими регистрами. Говоря проще, если требуется 8-битная переменная, то под неё можно отвести один из младших регистров, но если требуется 16-битная регистровая пара, то под переменную следует отвести один из старших регистров.

Регистровые пары R26:R27, R28:R29 и R30:R31 также дополнительно обозначаются мнемонически как X, Y и Z. Ими можно пользоваться для адресации данных при операциях ввода-вывода (чтения-записи оперативной памяти ОЗУ).

Кроме регистров общего назначения, есть несколько специальных регистров:

  • __SREG__ -- регистр состояния (status register), адрес 0x3F (0x5F)

  • __SP_H__ -- старший байт указателя вершины стека, адрес 0x3E (0x5E)

  • __SP_L__ -- младший байт указателя вершины стека, адрес 0x3D (0x5D)

  • __tmp_reg__ -- регистр R0, временный регистр

  • __zero_reg__ -- регистр R1, нулевой регистр

Использование регистров компилятором avr-gcc:

  • r0 -- временный регистр, используемый кодом, генерируемым компилятором. Если Ваш ассемблерный код использует регистр r0 и вызывает функцию C, то Вам следует сохранить и восстановить значение этого регистра.

  • r1 -- нуль-регистр, подразумевается что его всегда равно нулю. Ассемблерный код, который использует регистр r1, должен обнулить регистр r1 перед возвратом в C-код или при вызове функций C.

  • r2..r17, r28, r29 -- сохраняемые регистры. Если Ваш ассемблерный код использует эти регистры, то их значение следует сохранить и затем восстановить. При этом, если вызывается функция C, то её код также сохранит и восстановит эти регистры.

  • r18..r27, r30, r31 -- несохраняемые регистры. Их можно свободно использовать, не сохраняя и не восстанавливая их значения. Однако если из ассемблерного кода вызывается функция C, то их значения следует сохранить, поскольку в коде функции они так же могут использоваться без сохранения и восстановления.

Переменные C и вызов функций ассемблера и C

Чтобы переменная или функция, объявленная в коде C, была доступна в ассемблерном коде, её следует объявить как внешнюю переменную:

.extern my_C_variable

.extern my_C_function

И наоборот, чтобы ассемблерная была доступна из C-кода, её следует в ассемблерном коде объявить как глобальную:

.global my_asm_function

а в коде C -- как внешнюю:

extern unsigned char my_asm_function(unsigned char, unsigned int);

Для передачи фиксированного списка аргументов функции используются регистровые пары с r24..r25 по r8..r7, аргументы перечисляются слева направо. Таким образом аргументы типа char (uint8_t) фактически передаются как short (uint16_t).

При передаче аргументов переменного списка, аргументы помещаются на стек в порядке справа налево. Для типа char также резервируются два байта.

Возвращаемые значения передаются передаются в регистровых парах r25..r18, в зависимости от размера типа возвращаемого значения. Байтовый порядок таков:

Байтовый порядок регистров

Регистры ввода-вывода (GPIO)

Нижнюю часть адресов адресного пространства ОЗУ занимают регистры ввода-вывода (GPIO - General Purpose Input/Output). Первые 64 регистра ввода-вывода с адресами 0..0x3F доступны для чтения-записи с помощью команд IN и OUT, для доступа к остальным используются команды чтения-записи данных в ОЗУ.

Младшие 32 адреса ОЗУ занимают регистры R0..R31 и соответственно при использовании команд IN и OUT адрес например регистра SREG будет 0x3F, а при использовании команд LD и ST адрес регистра SREG будет 0x5F (0x3F+0x20).

В микроконтроллерах с объёмом Flash-памяти или памяти ОЗУ, большими 64 KБайт, используются регистры конкатенации:

  • RAMPX, RAMPY, RAMPZ -- позволяют в конкатенации с регистрами X, Y и Z неявно адресовать больше 64 КБайт адресного пространства данных и программ

  • RAMPD -- позволяет в конкатенации с регистром Z неявно адресовать больше 64 КБайт адресного пространства данных

  • EIND -- позволяет в конкатенации с регистром Z производить неявный переход либо вызов функций в адресном пространстве программ больше 64 КБайт

Регистр состояния (SREG)

Биты регистра состояния имеют специальное значение, показывающее результат выполнения предыдущей команды, разрешены ли прерывания, а также пользовательский бит общего назначения T, чтение и запись которого можно производить командами BLD и BST. Биты регистра состояния устанавливаются и сбрасываются командами SE* и CL*.

  • С - флаг переноса

  • Z - флаг нуля

  • N - флаг отрицательного результата

  • V - флаг переполнения в дополнении до двойки

  • S - N xor V, флаг знака

  • H - флаг полу-переноса

  • T - бит общего назначения

  • I - флаг глобального разрешения прерываний

Как посмотреть листинг скомпилированного кода

Возникает резонный вопрос, как увидеть листинг скомпилированного машинного кода. В Linux для этого нужно скомпилировать скетч (например Blink), затем в каталоге /tmp найти каталог /tmp/buildXXXXXXXXXXXXXXXXXXX.tmp, где XXXXXXXXXXXXXXXXXXX -- 19-значное число, и в этом каталоге найти файл Blink.cpp.elf:

find /tmp | grep Blink.cpp.elf

Это скомпилированный исполняемый файл формата ELF, в котором раздел .text содержит машинный код скетча, дизассемблировать который можно командой:

avr-objdump -d Blink.cpp.elf

Чтобы код удобнее было просматривать, можно воспользоваться командой less:

avr-objdump -d Blink.cpp.elf | less

либо вывести листинг в файл:

avr-objdump -d Blink.cpp.elf > Blink.cpp.lss

Пример использования AVRASM.H

Приведу пример использования файла avrasm.h.

#include <avrasm.h>


char var1; // Переменная в ОЗУ


void setup()

{

register uint8_t rd, rdx, rr; // 8-битные переменные

register uint16_t rdl asm("Z"), rdm; // 16-битные переменные

rdl = 0;

rdm = 0xFFFF;

rd = 0;

rdx = 0;

rr = 1;

// Метка в коде C (в ассемблерном коде не доступна)

L0:

__ADC(rd, rr);

__ADIW(rdl, rr);

__INC(rd);

__COM(rd);

__DEC(rd);

__TST(rd);

__CLR(rd);

__SER(rd);

__MUL(rd, rd);

__FMUL(rdx, rdx);

// Ассемблерная метка в коде C

__LABEL(L1);

__RJMP(L1);

__CPSE(rd, rdx);

__MOVW(rdl, rdm);

__LDD(rd, rdl, 0x3F);

__STM(rdl, rd);

__STS(var1, rd);

__LDP(rdx, rdl);

__STP(rdl, rd);

__STD(rdl, 0x3F, rdx);

goto L0;

}

Приведённый пример у меня компилируется в следующий код:

00000090 <setup>:

90: cf 93 push r28

92: df 93 push r29

94: 31 e0 ldi r19, 0x01 ; 1

96: 20 e0 ldi r18, 0x00 ; 0

98: 8f ef ldi r24, 0xFF ; 255

9a: 9f ef ldi r25, 0xFF ; 255

9c: e0 e0 ldi r30, 0x00 ; 0

9e: f0 e0 ldi r31, 0x00 ; 0

a0: 43 1f adc r20, r19

a2: 11 96 adiw r26, 0x01 ; 1

a4: 43 95 inc r20

a6: 40 95 com r20

a8: 4a 95 dec r20

aa: 44 23 and r20, r20

ac: 44 27 eor r20, r20

ae: 4f ef ldi r20, 0xFF ; 255

b0: 42 9f mul r20, r18

b2: 4a 03 fmul r20, r18

000000b4 <L1>:

b4: ff cf rjmp .-2 ; 0xb4 <L1>

b6: 22 13 cpse r18, r18

b8: dc 01 movw r26, r24

ba: 47 ad ldd r20, Z+63 ; 0x3f

bc: 2e 93 st -X, r18

be: 20 93 00 01 sts 0x0100, r18

c2: 41 91 ld r20, Z+

c4: 2d 93 st X+, r18

c6: 2f af std Y+63, r18 ; 0x3f

c8: eb cf rjmp .-42 ; 0xa0 <setup+0x10>

Автор: Андрей Шаройко <vanyamboe@gmail.com>