Arduino - кровь, кишки, ассемблер
24 декабря 2015, 13:08 | Мы Автор: Night_Ghost
Довелось мне тут поковырять одну поделку на AVR (коий известен по большей части тем что стоит в Ардуинах) дизассемблером. И мне настолько не понравилось обнаруженное, что я решил об этом высказаться, в основном дабы пар стравить. Итак, самое ужасное:
0. Лучший дизассемблер "всех времен и народов" IDA Pro не умеет работать с кодом AVR. Нет, поддержка процессоров этого семейства в нем конечно же есть и команды он показывает, вопрос в том КАК.
во-первых, у АВР команда - 2 байта, поэтому адресация памяти программы для команд ICALL/IJMP (которые меняют указатель команд) не совпадает с адресацией команды LPM (которая выбирает байт из памяти программ), а дизассемблер этого
не учитывает. Поэтому обращения к константам в памяти программ (а их большинство!) приходится отслеживать исключительно вручную. Решением было бы вести виртуальный счетчик команд в байтах, а в командах перехода учитывать разницу адресации - но увы
Во-вторых, процессор 8-битный, а для адресации даже его куцей памяти приходится использовать 16-битные адреса - а значит загрузка любого адреса в регистры распадается на 2 команды - загрузку старшей и младшей частей. Поэтому дизассемблер ВООБЩЕ не знает ничего про адреса памяти, загружаемые как адреса!
И даже в ручном режиме он никак не помогает в этом вопросе - ну нет у него возможности указать что операнд команды - это старший/младший байт адреса памяти! Решением тут было бы объединение двух команд в макрос загрузки адреса в регистровую пару, но опять же увы...
Боль от двух предыдущих пунктов можно было бы слегка унять, если бы была возможность в комментарии сделать обращение к адресу в нужном сегменте, дабы оно вело себя как адресный операнд - но такой возможности тоже не предусмотрено!
Не лишним будет упомянуть что контроллеры AVR имеют гарвардскую архитектуру с раздельными областями программы и данных, а значит для симуляции инициализированных переменных при загрузке выполняется массовое "присваивание" начальных значений. Но дизассемблер про это ничего не знает, и поэтому не только не показывает начальное значение инициализированных переменных (в том
числе все изменяемые строки!), но и не позволяет их задать кроме как комментарием.
Но это было лишь вступление о нелегком труде реверс-инженера, а дальше будет самая мякотка
1. Массивы.
Процессор, как уже было упомянуто, 8-битный, и хотя в нем есть команды 16-битной арифметики, но они ограничены значением +-31 байт, а остальные вычисления делаются побайтно. Пример - обращение к массиву (в памяти программы) по индексу:
mov r23, r22; тут у нас байтовый индекс
ldi r25, 0 ; беззнаковое расширение до слова
movw r30, r24; LPM работает с регистровой парой R30 R31
subi r30, -0xD4; ',' это так хитро прибавляется 0x5d4 - байтовый адрес массива в памяти программ
sbci r31, -6 ; '·'
lpm r22, Z; загрузили из памяти первый байт
movw r30, r24; снова индекс
subi r30, 0x18 ; снова массив - можно было бы оптимизировать, просто добавив разницу
sbci r31, -6 ; '·'
lpm r30, Z; наконец-то загрузили второй байт
Из этого куска кода видно, как же тяжело дается процессору адресная
арифметика.
Мораль такова: максимально избегать массивов в пользу адресных указателей, тогда при движении указателя будет просто добавляться небольшое число к регистровой паре.
2. Классы.
В машинном представлении класс - это структура из переменных класса плюс таблица виртуальных функций, то есть все обращение к членам класса идет через ту самую косвенную адресацию, которая так тяжело дается процессору. Еще хуже обстоят дела с хранением в данных класса адресов регистров - как это например сделано в классе HardwareSerial:
Маленький кусочек
cbi(*_ucsrb, UDRIE0);
который вроде как просто должен превратиться в команду CBI, на самом деле превращается в такую портянку
ldd r0, Z+18 ; 0x12
ldd r31, Z+19 ; 0x13
mov r30, r0
ld r24, Z
andi r24, 0xDF ; 223
st Z, r24
Вместо одной команды - шесть! Не, я конечно понимаю что универсальность это хорошо - только вот подавляющее большинство применений приходится на мелкие контроллеры, содержащий всего лишь один последовательный порт! Им эта универсальность не нужна, а расплачиваться приходится напрасным расходом и так крохотной памяти. (Что и послужило причиной написания собственной библиотеки SingleSerial для подобных применений)
Но если существование класса для последовательного порта еще можно понять - их все-таки бывает несколько, то с EEPROM все еще хуже. Зачем, кроме как ради красивости? Ну не может быть в архитектуре несколько разных EEPROM'ов К тому же для основных методов этого класса eeprom.read и eeprom.write вообще не нужны никакие данные класса - они просто вызывают eeprom_read_byte и eeprom_write_byte соответственно, и вполне могли бы быть статическими. А сейчас каждое обращение к EEPROM сопровождается двумя лишними командами - загрузкой адреса данных класса... Конечно, имеющийся класс позволяет мимикрировать работу с EEPROM как будто с обычными переменными - но в этой абстракции есть огромная дыра в виде времени перезаписи ячейки EEPROM.
Напрашивающийся вывод: максимально использовать обычные функции и переменные, а если очень хочется классов - то статические методы. После замены HardwareSerial на SingleSerial, отказа от использования класса EEPROM и объявлении методов класса SPI статическими объем программы OSD уменьшился на 2 килобайта (из 31к доступных!)
3. GCC, используемый в среде Ардуино, дает совершенно отвратительный код. Просто потому что он рассчитан на мощные 32-бит процессоры, и оптимизацию делает "в понятиях" их возможностей - а затем уже просто собирает шаблоны исполнения этих операций. И вот для AVR в подавляющем большинстве случаев такой шаблон состоит из нескольких (и до нескольких десятков!) машинных команд, для которых уже не будет никакой оптимизации. Родной компилятор от AVR Studio с процессором знакОм лучше и оптимизирует код с учетом содержания регистров - в результате получая выигрыш до 40%.
Несколько примеров.
osdbuf[bufpos++] = c;
превращается в
lds r24, 0x0274
lds r25, 0x0275
movw r18, r24
subi r18, 0xFF // команду addiw еще не изобрели?
sbci r19, 0xFF
sts 0x0275, r19
sts 0x0274, r18
movw r30, r24 // почему бы сразу не загрузить в R30 и избавиться от лишних перемещений?
subi r30, 0x8A
sbci r31, 0xFD
st Z, r22
Вот так как с куста 2 совершенно лишние команды. А вот еще лучше
// while (sleep_periods >= 512) // всего-то 2-байтовое целое сравнить с константой
ld r16, 0x00
ldi r17, 0x00
movw r24, r16
add r24, r14
adc r25, r15
cp r24, r1
sbci r25, 0x02
brcs .+26
Выделенное цветом - это что вообще было? Наф(зачеркнуто) какого дьявола перед сравнением надо было прибавлять 0, да еще погоняв по регистрам???
Или вот такой вот шедевЕр "оптимизации"
// uint8_t TCCR1Bcopy = TCCR1B; // Сохраним копию регистра
ldi r26, 0x81 // адрес регистра
mov r2, r26 // в паре r2r3 получаем 16-бит адрес
mov r3, r1
movw r30, r2 // перегоняем в регистровую пару Z
ld r18, Z // и вот наконец-то после долгих мучений загрузили регистр.
А достаточно было сказать "lds r18, 0x81".
С форматированным выводом вообще ад и израиль, строчка "osd.printf_P(PSTR("No mavlink data!"));" превращается в кучу шевелений стека.
ldi r24, 0xCB ; 203
ldi r25, 0x01 ; 1
push r25
push r24
ldi r24, 0xB7 ; 183
ldi r25, 0x05 ; 5
push r25
push r24
call 0x4d28 ; 0x4d28 <_ZN12BetterStream9_printf_PEPKcz>
pop r0
pop r0
pop r0
pop r0
ret
Ранее вроде бы все параметры вызовов передавались через регистры - а тут вдруг через стек. А аргументов-то и нету, так что использование print_P вместо printf_P экономит аж 16 байт.
Мораль - если проект перестал лезть во флеш, то пора брать в руки avr-objdump и смотреть, что там наг%8№кодил компилятор... Ну и по максимуму выносить все похожие вычисления с long и float в отдельные функции - тогда может и удастся "впихнуть невпихнуемое"
Замена компилятора GCC с версии 4.8.1, идущего в комплекте с Ардуино, на собранный по инструкции 5.3 с включенным LTO, устранила практически все претензии к качеству кода, сократив расход флеша в неком проекте с 31902 байт до 29064 байт, и что самое удивительное - увеличив размер свободной памяти с 414 до 490 байт (как???).
То есть - такая замена насущно рекомендуется.