Печать

Мысли (Автор: dez)

Недавно настал день, которого я одновременно и боялся, и с интересом ждал. Это день, когда мне вместо чтения ассемблерного кода пришлось заняться его написанием. Самый логичный путь к такой необходимости - наличие критичных ко времени выполнения кусков кода и частые вызовы процедуры обработки прерывания (ISR). В таких случаях прибегают к ассемблерным вставкам. В самом деле, писать даже средненький проект полностью на асме - глаза на лоб полезут. Так что вместо этого поставлю цель поменьше и действовать буду по плану: "Посмотреть, что сделал компилятор ЯВУ, и сделать лучше."

 

Вскрытие

Рассмотрим пример вот такой ISR. Несложно догадаться о её назначении - это генерация аналогового сигнала табличным методом (DDS), а на порте D скорее всего висит параллельный ЦАП.

unsigned char ch1Index=0;
ISR(TIMER2_COMPA_vect){
    PORTD = ch1WaveTable[ch1Index];
    ch1Index += ch1Step;
}

Скомпилируем код в Atmel Studio (в недрах которого пыхтит avr-gcc) с отключенной оптимизацией и найдем процедуру обработки прерывания в сгенерированном ассемблерном листинге (файл lss).

000005da <__vector_7>:
5da: 1f 92 push r1
5dc: 0f 92 push r0
5de: 00 90 5f 00 lds r0, 0x005F
5e2: 0f 92 push r0
5e4: 11 24 eor r1, r1
5e6: 2f 93 push r18
5e8: 3f 93 push r19
5ea: 8f 93 push r24
5ec: 9f 93 push r25
5ee: ef 93 push r30
5f0: ff 93 push r31
5f2: cf 93 push r28
5f4: df 93 push r29
5f6: cd b7 in r28, 0x3d ; 61
5f8: de b7 in r29, 0x3e ; 62
5fa: 8b e2 ldi r24, 0x2B ; 43
5fc: 90 e0 ldi r25, 0x00 ; 0
5fe: 20 91 9a 01 lds r18, 0x019A
602: 22 2f mov r18, r18
604: 30 e0 ldi r19, 0x00 ; 0
606: 2e 55 subi r18, 0x5E ; 94
608: 3e 4f sbci r19, 0xFE ; 254
60a: f9 01 movw r30, r18
60c: 20 81 ld r18, Z
60e: fc 01 movw r30, r24
610: 20 83 st Z, r18
612: 90 91 9a 01 lds r25, 0x019A
616: 80 91 9e 01 lds r24, 0x019E
61a: 89 0f add r24, r25
61c: 80 93 9a 01 sts 0x019A, r24
620: df 91 pop r29
622: cf 91 pop r28
624: ff 91 pop r31
626: ef 91 pop r30
628: 9f 91 pop r25
62a: 8f 91 pop r24
62c: 3f 91 pop r19
62e: 2f 91 pop r18
630: 0f 90 pop r0
632: 00 92 5f 00 sts 0x005F, r0
636: 0f 90 pop r0
638: 1f 90 pop r1
63a: 18 95 reti

Нда... Цензурными словами это тяжело описать. Не будем слишком глубоко заворачиваться в эту простыню, пока лишь обратим внимание на огромное количество операций со стеком (в AVR стек обычно находится где-то в конце оперативной памяти). Стены из инструкций PUSH в начале и POP в конце называются прологом и эпилогом соответственно. Для чего они нужны? Чтобы не потерять "ход мысли" основной программы. Перед окончательным уходом в прерывание в стеке сохраняются значения тех регистров, которые будут перезаписаны в ходе работы основной "начинки" ISR. На выходе из прерывания эти значения считываются из стека обратно в регистры.

В нашем случае компилятор решил, что для решения такой простой задачи, как вывод элемента массива в порт и сложение двух однобайтовых переменных, ему понадобится аж 10 регистров. Ну что за кулацкие замашки? Расточительность компилятора по отношению к регистрам привела к тому, что теперь прерыванию надо тащить на себе две футбольные команды из операций со стеком (т.к. R0 толкался дважды). А теперь последний гвоздь в крышку гроба нашего терпения - инструкции PUSH и POP выполняются по 2 цикла. Офигеть, получается целых 44 цикла накладных расходов!

Впрочем, это был вариант без оптимизации. Как правило, я отключаю оптимизацию в своих проектах (этому меня научили как старшие товарищи, так и горький опыт). Но раз пошла такая пьянка, давайте для сравнения посмотрим, что получится при оптимизации -O2.

000003b4 <__vector_7>:
3b4: 1f 92 push r1
3b6: 0f 92 push r0
3b8: 0f b6 in r0, 0x3f ; 63
3ba: 0f 92 push r0
3bc: 11 24 eor r1, r1
3be: 8f 93 push r24
3c0: 9f 93 push r25
3c2: ef 93 push r30
3c4: ff 93 push r31
3c6: e0 91 98 01 lds r30, 0x0198
3ca: f0 e0 ldi r31, 0x00 ; 0
3cc: ee 55 subi r30, 0x5E ; 94
3ce: fe 4f sbci r31, 0xFE ; 254
3d0: 80 81 ld r24, Z
3d2: 8b b9 out 0x0b, r24 ; 11
3d4: 80 91 98 01 lds r24, 0x0198
3d8: 90 91 9e 01 lds r25, 0x019E
3dc: 89 0f add r24, r25
3de: 80 93 98 01 sts 0x0198, r24
3e2: ff 91 pop r31
3e4: ef 91 pop r30
3e6: 9f 91 pop r25
3e8: 8f 91 pop r24
3ea: 0f 90 pop r0
3ec: 0f be out 0x3f, r0 ; 63
3ee: 0f 90 pop r0
3f0: 1f 90 pop r1
3f2: 18 95 reti

Уже лучше. Листинг в полтора раза короче, можно начать вчитываться более внимательно. Теперь в прологе и эпилоге шевелятся только 6 регистров. Но вопросы всё ещё остаются.

Например, несложно заметить, что основной код процедуры никак не использует регистры R0 и R1. С точки зрения avr-gcc регистр R0 (он же __tmp_reg__) используется для кратковременного хранения промежуточных результатов. R1 используется для хранения нуля (на случай, если ноль понадобится инструкции, умеющей работать только с регистрами). Из-за этого в avr-gcc действуют определенные "соглашения", по которым эти два регистра являются fixed registers и поэтому всегда должны сохраняться при входе в прерывание (и восстанавливаться на выходе). Как я понимаю, понять причину существования таких соглашений можно так - "мало ли что там понапишут на своём Си и во что этот бардак транслируется, мы за всем не уследим". Но если мы собрались писать ISR исключительно на ассемблере, а не на Си, то у нас всё под контролем - имеем право выкинуть. Только будьте внимательны - эти два регистра могут быть затерты некоторыми инструкциями (например, LPM без операндов и MUL). Всегда держите Instruction Set под рукой и сверяйтесь!

Есть ещё один момент. Сравним фрагменты из первого кода

5dc: 0f 92 push r0
5de: 00 90 5f 00 lds r0, 0x005F
5e2: 0f 92 push r0

И второго кода

3b6: 0f 92 push r0
3b8: 0f b6 in r0, 0x3f ; 63
3ba: 0f 92 push r0

По сути они выполняют одну и ту же функцию. Но первый код делает это дольше :) По адресу 0x005F в ОЗУ находится регистр SREG. По адресу 0x3F в пространстве ввода-вывода (I/O Memory) тоже находится регистр SREG! В чём разница? К пространству I/O Memory обращаются командами IN/OUT, которые выполняются один цикл. К оперативной памяти обращаются командами LDS/STS, которые выполняются два цикла ;) Почему у компилятора два взляда на один и тот же вопрос - не совсем понятно. В любом случае, сохранять SREG важно, поскольку он несёт в себе флаги переносов, флаг нуля и прочие вещи, нужные для циклов, ветвлений, арифметических и логических операций. Если этот регистр "исказить", то основная программа рискует конкретно заблудиться. Так что если в нашем прерывании есть операции, перебивающие SREG, то сто пудов надо сохранять.

 

Собираем своего Франкенштейна

Теперь попробуем творчески переписать основное тело. На что ещё мы можем повлиять? Ну например, на выборку элемента массива уходит 5 команд: 2 на загрузку адреса первого элемента (адреса ведь 16-битные), 2 на вычисление адреса нужного элемента, и 1 команда на копирование значения элемента в регистр. Мы могли бы выкинуть этап вычисления адреса, но это возможно только при опеределённых условиях. Во-первых, начало массива должно лежать на 256-байтной границе (т.е. иметь адрес вида XX00). Во-вторых, размер массива не должен превышать 256 байт. Запахло бубном, да? :)

Разместить переменную или массив в памяти по определенному адресу нам поможет секция (named section). В настройках проекта в Atmel Studio 6 задать секции во всех видах памяти: Flash, SRAM и EEPROM. Но это только на первый взгляд. Жестокая правда жизни такова, что при создании секции в SRAM я словил отдельное приключение, мало относящееся к теме данной заметки. Поэтому, для простоты и повторяемости эксперимента, будем использовать массив во Flash-памяти.

Через GUI секция задается в Toolchain > AVR/GNU Linker > Memory Settings. Тут нужно иметь в виду, что gcc изначально не расчитан на гарвардскую архитектуру микроконтроллеров (раздельная память для программы и данных), поэтому перед тем, как ваши настройки попадут в Makefile, IDE делает особое колдовство над ними. К адресам SRAM прибавляется 0x800000, к адресам EEPROM - 0x810000. Ну а самый прикол в том, что адреса Flash умножаются на 2 (если вы посмотрите в даташит, то увидите, что память программ измеряется словами, а не байтами). То есть в ответ на такие наши действия...

Пример настроек памяти в Atmel Studio 6 / AS6 Memory settings example

...IDE создаёт агрумент командной строки

-Wl,-section-start=.lookup=0x3e00

Ладно, пусть так. Повесить на этот адрес массив можно таким образом:

__attribute__((used, section(".lookup"))) unsigned char sineWave[256] = {
    // .... тут элементы
};

И наконец-то мы уже переходим к нашей ISR. К счастью, в ассемблерных вставках в avr-gcc можно пользоваться теми же именами переменных, что и C-коде.

ldi r30, ch1Index ;номер элемента
ldi r31, hi8(sineWave) ;старший байт адреса начала массива
lpm r24, Z ;значение нужного элемента массива
out 0x0B, r24 ;вывод значения в PORTD
in r31, 0x3F ;сохраним SREG (r31 больше не нужен)
lds r24, ch1Step
add r24, r30 ;приращение индекса массива на шаг
sts ch1Index, r24 ;сохранение в ОЗУ
out 0x3F, r31 ;восстановление SREG

Итак, что здесь происходит? От вычисления адреса элемента массива мы избавились, расположив массив sineWave по адресу 3E00 и подсунув номер элемента вместо младшего байта адреса. Одно это изменение упрощает жизнь ещё в паре мест - раз не нужно сложение, значит, не нужны и регистры для сложения. В итоге меньше операций со стеком :) К тому же, после выборки элемента массива нам остаётся только сложить два байта, а заняли мы три регистра. Поскольку использованные операции с памятью не изменяют содержимое SREG, можно сохранить его в "освободившийся" после выборки регистр R31. Остаётся лишь одна печаль в этом примере - инструкция LPM (чтение из Flash) выполняется ТРИ цикла. Если получится разместить массив в ОЗУ по нужному адресу, то LPM просто заменяется на LD - в данном случае она выполнилась бы за 1 цикл. Но это уже за рамками нашей темы - как-нибудь в другой раз :)

Основной алгоритм у нас теперь есть, осталось скормить его через ассемблерную вставку. Если собираетесь писать пролог и эпилог своими руками, нужно будет дописать ISR_NAKED - тогда компилятор не будет генерировать соответствующий код и оставит это дело программисту. Сама вставка начинается со слов asm volatile, и в случае avr-gcc каждая строка ассемблерного кода должна заканчиваться символами \n\t.

unsigned char ch1Index=0;
ISR(TIMER2_COMPA_vect,ISR_NAKED){
    asm volatile(
    "push r24 \n\t"
    "push r30 \n\t" //Пролог
    "push r31 \n\t"
/*********************************
основное тело
*********************************/
    "pop r31 \n\t"
    "pop r30 \n\t" //Эпилог
    "pop r24 \n\t"
    "reti \n\t"
    :
    :
    : "r24", "r30", "r31"
    );
}

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

Статья опубликована 2018-03-04 14:20:53, её прочитали 4630 раз(а).