При программировании вещей, критичных к быстродействию и размеру кода хорошо использовать ассемблер. При этом обычно не обязательно писать на нем весь код, достаточно реализовать наиболее “чувствительные” подпрограммы. Компилятор GCC и среда Atmel Studio позволяют использовать в проекте ассемблер и С одновременно. При этом возникает вопрос организации взаимодействия между подпрограммами на разных языках: вызова методов с передачей им параметров и доступа к переменным.
Ассемблерные файлы сохраняются с расширением .S. Один из файлов проекта должен содержать функцию main(), которая будет являться точкой входа. Обычно, она объявляется в c-файлах, но ничего не запрещает объявить main() в ассемблерном файле (используя директиву .global).
При совмещении языков Си и ассемблера в одном проекте могут возникнуть следующие вопросы:
Чтобы Си-функции были видны из ассемблерного кода нужно использовать директиву .extern
.extern my_C_function
Для того, чтобы видеть ассемблерные функции из Си, служит директива .global:
.global my_assembly_function
При этом, в Си-коде надо объявить эту ассемблерную функцию с ключевым словом extern:
extern uint8_t my_assembly_function (uint8_t, unsigned int);
Для того, чтобы использовать общие глобальные переменные коде, их следует объявить в Си коде:
unsigned char my_value;
В ассемблерном коде доступ к переменным организуется аналогично доступу к функциям, директивой .extern:
.extern my_value
Написание ассемблерного кода для совместного использования с Си-кодом требует знаний о том, каким образом компилятор Си использует регистры.
Регистры r18-r27, и r30-r31 (регистровая пара Z) могут свободно использоваться в ассемблерном коде. Компилятор Си не беспокоится об их сохранении. Поэтому, если ассемблерный код использует эти регистры и вызывает Си-функции, то он должен сам заботится о сохранении этих регистров перед вызовом и восстановлении после вызова.
Регистры r2-r17, а также r28-r29 (регистровая пара Y) не изменяются в Си-функциях. Если ассемблерная функция, вызываемая из Си-кода использует регистры из этой группы, то она сама должна заботится о сохранении и восстановлении их содержимого. Регистровая пара Y используется для сохранения начального значения указателя стека SP (см. далее).
Регистр r1 всегда содержит ноль. Если ассемблерный метод модифицирует этот регистр, то он должен всегда занулять его перед возвращением в
Си-код (например, командой clr r1
).
Во встроенном ассемблере вы можете использовать __zero_reg__
для этого регистра.
Регистр r0 используется компилятором в качестве временного хранилища.
Во встроенном ассемблере GCC вы можете использовать алиас __tmp_reg__
для доступа к нему.
Ассемблерный код может не заботится о сохранении этого регистра (если только это не код обработчика прерываний!).
Аналогично временному регистру r0 компилятор может использовать и бит T статусного регистра SREG. Заботится о сохранении этого флага в "обычном" ассемблерном коде так же не надо.
Если число параметров у метода фиксировано, то для передачи аргументов используются регистры r25-r8. Аргументы передаются слева-направо, каждый использует четное количество регистров, т.е., для передачи аргумента типа char/uint8_t будет зарезервировано два регистра (это сделано для того, чтобы компилятор мог использовать команду movw). Если регистров не хватит, часть аргументов будет переданы через стек. Последнее не лучшим образом сказывается на производительности, поэтому передачи больших объемов данных в функцию следует избегать.
Если функция имеет переменное количество аргументов, то они передаются через стек, справа-налево. Однобайтовые аргументы так же передаются как двубайтовые (при этом старший байт для них зануляется командой eor r25, r25).
Лучше всего понять, как передаются параметры и результат для функций можно рассмотрев примеры генерируемого компилятором кода. Например, для функции
uint8_t max_8(uint8_t a1, uint8_t a2, uint8_t a3, uint8_t a4)
Параметры a1, a2, a3, a4 будет передаваться в регистрах r24, r22, r20 и r18 соответственно.
Для функции
uint16_t max_16(uint16_t a1, uint16_t a2, uint16_t a3, uint16_t a4)
Параметры a1, a2, a3, a4 будет передаваться в регистрах r25:r24, r23:r22, r21:r20 и r19:r18 соответственно. Причем, r25, r23, r21, r19 - старшие байты, а r24, r22, r20, r18 - младшие.
Для функции
uint32_t max_32(uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4);
Параметры передадуться через регистры r25:r24:r23:r22, r21:r20:r19:r18, r17:r16:r15:r14, r13:r12:r11:r10 соответственно.
Возвращаемое функцией значение передается через регистры r25 - r18, в зависимости от его длины следующим образом:
Создаваемый компилятором файл прошивки имеет следующую структуру:
Первым блоком следуте таблица векторов прерываний. Если в коде определены какие-то векторы, то тут будут короткие переходы на них.
Для остальных же векторов в таблице будут переходы к подпрограмме __bad_interrupt
, представляющую собой команду jmp 0
.
После таблицы прерываний следует блок константных данных, представляющий собой строки и массивы, размещенными в области кода (PRG).
Далее следует процедура __ctors_end
:
__ctors_end:
clr r1
out SREG, r1
ldi r28, 0x5F
ldi r29, 0x04
out SPH, r29
out SPL, r28
Тут мы видим зануление регистра r1 и SREG и инициализацию 16-битного указателя фрейма, которым служит регистровая пара Y (r29:r28). Указатель фрейма используется для получения местоположения стека.
Блок __do_clear_bss
инициализирует секцию статических данных bss (block starting symbol) и очищает память
Среди библиотечных функций есть функции сохранения пролога и загрузки эпилога. Как было сказано выше, Си-функции не меняют регистры r2-r17, r28-r29. При этом, если функции недостаточно остальных регистров для размещения своих данных (локальных переменных), то GCC использует подпрограммы __prologue_saves__ и __epilogue_restores__.
Первая сохраняет нужные регистры в стеке и настраивает указатель стека SP и указатель фрейма Y следующим образом:
Y = SP - X
SP = Y
После чего продолжает выполнение функции переходя к адресу, переданному в Z.
Вторая, наоборот, восстанавливает сохраненные регистры и значения SP и Y к исходным:
SP = Y + ZL
Y += X
__prologue_saves__:
push r2
push r3
push r4
push r5
push r6
push r7
push r8
push r9
push r10
push r11
push r12
push r13
push r14
push r15
push r16
push r17
push r28
push r29
in r28, SPH
in r29, SPL
sub r28, r26
sbc r29, r27
in r0, SREG
cli
out SPH, r29
out SREG, r0
out SPL, r28
ijmp
__epilogue_restores__:
ldd r2, Y+18
ldd r3, Y+17
ldd r4, Y+16
ldd r5, Y+15
ldd r6, Y+14
ldd r7, Y+13
ldd r8, Y+12
ldd r9, Y+11
ldd r10, Y+10
ldd r11, Y+9
ldd r12, Y+8
ldd r13, Y+7
ldd r14, Y+6
ldd r15, Y+5
ldd r16, Y+4
ldd r17, Y+3
ldd r26, Y+2
ldd r27, Y+1
add r28, r30
adc r29, r1
in r0, SREG
cli
out SPH, r29
out SREG, r0
out SPL, r28
movw r28, r26
ret
Стек растет сверху вниз (меньшие адреса находятся внизу). После пролога функции, указатель фрейма будет указывать на один байт ниже фрейма стека, т.е. Y+1 будет указывать на низ стека фрейма. Как показано в таблице:
Аргументы функции | (могут отсутствовать) | верх стека |
Адрес возврата из функции | (2 или 3 байта) | |
Сохраненные регистры | (могут отсутствовать) | |
Слоты стека | (могут отсутствовать) | низ стека, Y+1 указывает вниз этого блока |