AVR: Учимся писать компактный код

AVR: Учимся писать компактный код.

В свое время, когда я только осваивал AVR, великое зло по имени ардуйно меня обошло стороной, за что я очень благодарен судьбе. Но время идет, и волею судьбы мне довелось познакомиться не столько с ардуйной, сколько с ее фанатами, которые после прочтения этой статьи меня, должно быть, возненавидят.
Не останусь в долгу и я, резонно заметив, что большинство проектов ардуинщиков где используются atmega1280 или 2560 (кто больше?) реализуемы если не на 555 таймере, то уж на аттайни2313.
Скажу более, это не единственная беда, частенько люди настолько загоняют себя в рамки той же ардуйны, что выходит весьма идиотский подход: не хватает ардуйны для решения задачи? Возьми две ардуйны. Не хватает шести? Возьми десять!
Если сказанное вызывает уже ненависть ко мне, я попал в точку, дальнейшее можно не читать.

Что не так с ардуйно?

Не так крайне многое. Своя нумерация пинов, кривое апи, и многое другое. Будучи средой для чайников ардуйна легко загоняет новичка в свои рамки и вызыввает священный ужас при одной мысли о выходе куда-то за пределы, где нет привычных скетчей и есть страшный СИ.
При том, что реально код они уже пишут на С/С++ многие ардуинщики и не знают и честно говорят, что пишут на ардуйно. Это вызывает иногда улыбку, примерно как когда человек говорит, что пишет на ассеблере masm. Они здорово напоминают мне приверженцев одного такого язычка с решеткой в названии. ( С#) Оный тоже до железок добрался, так что ждем новой волны зеленых «погромистов».
Ну и, наконец, это С++. Его ниша никак не системное программирование. Здесь царство чистого СИ. Впрочем недостатки языка я не обсуждаю, скажу только, что при использовании С++ размер прошивки той же аврки пухнет как на дрожжах.

Но мы сейчас не ругаем ардуйно, а ищем конкретные выходы из положения, так ведь? Хочется и писать компактно, и чтобы удобно было.
Производители микроконтроллеров идут навстречу и в некоторых мк AT91RM9200, Stellaris Есть ROM с готовым набором костылей. В атмелевском варианте полезные штуки типа реализации xmodem’a, а у стеллариса вообще API, которое позволяет забыть что в регистры микроконтроллера надо что-то писать.
Что остается делать вымирающим фанатам красивого и компактного кода?
— Писать на ассемблере (круто, оптимально, позволяет познать дзен и научиться вселенскому терпению), но долго и не особо переносимо.
— Пользовать библиотеки типа avrlib, да таскать с собой по проектам вагон разных костылей, которые допиливать по мере необходимости.
— Или применять изыски для автогенерации кусков кода.

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

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

Сколько раз в маленькой прошивке мк нам надо изменять функцию-обработчик того или иного прерывания? Можете не напрягаться, не часто. А теперь давайте оценим оверхед такой универсальности. Так как таблица векторов прерываний в AVR зашивается в Flash, то изменять ее из кода крайне нежелательно. (Можно, конечно, извратиться и перезаписывать флеш, но это скоро убьет его, так как количество циклов перезаписи ограничено).
А ардуине это реализовано так. Отдельный массив указателей в ОЗУ, куда attachInterrupt кидает указатель на функцию, которая будет вызываться. То есть как бы еще одна таблица векторов прерываний.

Иными словами, происходит нечто вроде этого:

поступило внешнее прерывание -> контроллер скакнул по адресу указанному в зашитой во флеше таблице векторов прерываний -> ардуиновская функция-обработчик загрузила в регистр адрес обработчика выставленного через attachInterrupt -> прыжок к обработчику -> мы обработали прерывание.

Давайте оценим потраченное на это место. Хотя бы для вызова функции по указателю. Для удобства сравнения перед вызовом функции ставим nop’ы.

Для теста набросаем вот такой файлик.

void (*fptr)(void)=0;
int main()
{
asm("nop");
fptr();
asm("nop");
}

Соберем его под архитектуру авр.

avr-gcc test.c -o out.elf

И отдизассемблируем полученную эльфятину.

avr-objdump -S out.elf

Это ассемблерный код, который нам сварганил gcc. Внимание на секцию мейн.

00000054

:
54: df 93 push r29
56: cf 93 push r28
58: cd b7 in r28, 0x3d ; 61
5a: de b7 in r29, 0x3e ; 62
5c: 00 00 nop
5e: 80 91 60 00 lds r24, 0x0060
62: 90 91 61 00 lds r25, 0x0061
66: e8 2f mov r30, r24
68: f9 2f mov r31, r25
6a: 09 95 icall
6c: 00 00 nop
6e: cf 91 pop r28
70: df 91 pop r29
72: 08 95 ret

Аккурат между двумя нопами инструкции, которые вызывают функцию по указателю. Запомним, и сварганим другой пример.

int callme()
{
}


int main()
{
asm("nop");
callme();
asm("nop");
}

Собираем elf, дизассемблируем, получаем:

00000072

:
72: df 93 push r29
74: cf 93 push r28
76: cd b7 in r28, 0x3d ; 61
78: de b7 in r29, 0x3e ; 62
7a: 00 00 nop
7c: eb df rcall .-42 ; 0x54
7e: 00 00 nop
80: cf 91 pop r28
82: df 91 pop r29
84: 08 95 ret

Как видно, разница одна инструкция против пяти. Четыре уходят на загрузку адреса. то есть 8 байт против двух.

Реально, конечно, оверхед не слишком большой, если функцию по указателю дергаем не так часто. Но не забываем, что каждая объявленная функция — это затраты на то, чтобы отправить в стек содержимое регистров и потом их оттуда достать. именно это и делает код в том же самом мейне, не относящийся к nop’ам.

Но не все же мы будем пихать в мейн, так? Тут опять есть выход.
Инлайны. Инлайн фунция, не заставляет компилятор загнать все регистры в стек, прыгнуть к коду, а потом вновь достать регистры из стека и вернуться при помощи ret. Она просто подставляет ее текст в место, где это надо.
В каких случаях это оптимально?
Смотрим дизасм функции callme состоящей из одного единственного nop и офигеваем.

00000054 :
54: df 93 push r29
56: cf 93 push r28
58: cd b7 in r28, 0x3d ; 61
5a: de b7 in r29, 0x3e ; 62
5c: 00 00 nop
5e: cf 91 pop r28
60: df 91 pop r29
62: 08 95 ret



На таком тесте видно, что это уже куча 14 байт оверхеда. Мелочь, если у нас сотни килобайт памяти, и в тоже время это ценное место, если у нас памяти кот наплакал — 1кб или 512 байт, на самых маленьких мк.

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

Инициализация переменных.
Допустим у нас есть массив чисел. Все числа надо инициализировать нулем, допустим в функции сброса, если они там что-то считают.
Любой программист, увидев, текст вроде:

char a[3];
a[0]=0;
a[1]=0;
a[2]=0;

Скажет китайский код, надо делать в цикле. А будет ли это реально меньше занимать памяти во флеше?
Давайте проверим.
Создаем массивчик:

char a[COUNT];

В мейн пихаем примерно такое:

int main()
{
reset();
}

А в виде reset’a делаем два разных варианта.

reset()
{
a[0]=0;
a[1]=0;
a[2]=0;
}

и

reset()
{
int i;
for (i=0; i<3; i++)
{
a[i]=0;
}
}

И что имеем на выходе?

Для начала дизассемблируем эльфятину с «китайским кодом» и видим вот такое:

00000054 :
54: df 93 push r29
56: cf 93 push r28
58: cd b7 in r28, 0x3d ; 61
5a: de b7 in r29, 0x3e ; 62
5c: 10 92 60 00 sts 0x0060, r1
60: 10 92 61 00 sts 0x0061, r1
64: 10 92 62 00 sts 0x0062, r1
68: cf 91 pop r28
6a: df 91 pop r29
6c: 08 95 ret

То есть 3 инициализации нулем превратились в 3 sts инструкции.

А теперь вариант с for

00000054 :
54: df 93 push r29
56: cf 93 push r28
58: 00 d0 rcall .+0 ; 0x5a
5a: cd b7 in r28, 0x3d ; 61
5c: de b7 in r29, 0x3e ; 62
5e: 1a 82 std Y+2, r1 ; 0x02
60: 19 82 std Y+1, r1 ; 0x01
62: 0c c0 rjmp .+24 ; 0x7c
64: 89 81 ldd r24, Y+1 ; 0x01
66: 9a 81 ldd r25, Y+2 ; 0x02
68: 80 5a subi r24, 0xA0 ; 160
6a: 9f 4f sbci r25, 0xFF ; 255
6c: e8 2f mov r30, r24
6e: f9 2f mov r31, r25
70: 10 82 st Z, r1
72: 89 81 ldd r24, Y+1 ; 0x01
74: 9a 81 ldd r25, Y+2 ; 0x02
76: 01 96 adiw r24, 0x01 ; 1
78: 9a 83 std Y+2, r25 ; 0x02
7a: 89 83 std Y+1, r24 ; 0x01
7c: 89 81 ldd r24, Y+1 ; 0x01
7e: 9a 81 ldd r25, Y+2 ; 0x02
80: 83 30 cpi r24, 0x03 ; 3
82: 91 05 cpc r25, r1
84: 7c f3 brlt .-34 ; 0x64
86: 0f 90 pop r0
88: 0f 90 pop r0
8a: cf 91 pop r28
8c: df 91 pop r29
8e: 08 95 ret

На этом месте тихонечко офигеваем: цикл нам обошелся в 40 байт или 20 инструкций. То есть до 20 элементов массива выгоднее инициализировать китайским кодом.
Смотрим сильно ли изменится если элементами будут 16ти битные числа:

00000054 :
54: df 93 push r29
56: cf 93 push r28
58: cd b7 in r28, 0x3d ; 61
5a: de b7 in r29, 0x3e ; 62
5c: 10 92 61 00 sts 0x0061, r1
60: 10 92 60 00 sts 0x0060, r1
64: 10 92 63 00 sts 0x0063, r1
68: 10 92 62 00 sts 0x0062, r1
6c: 10 92 65 00 sts 0x0065, r1
70: 10 92 64 00 sts 0x0064, r1
74: cf 91 pop r28
76: df 91 pop r29
78: 08 95 ret

В два раза больше sts при китайском коде. А теперь если воткнем цикл.

00000054 :
54: df 93 push r29
56: cf 93 push r28
58: 00 d0 rcall .+0 ; 0x5a
5a: cd b7 in r28, 0x3d ; 61
5c: de b7 in r29, 0x3e ; 62
5e: 1a 82 std Y+2, r1 ; 0x02
60: 19 82 std Y+1, r1 ; 0x01
62: 0f c0 rjmp .+30 ; 0x82
64: 89 81 ldd r24, Y+1 ; 0x01
66: 9a 81 ldd r25, Y+2 ; 0x02
68: 88 0f add r24, r24
6a: 99 1f adc r25, r25
6c: 80 5a subi r24, 0xA0 ; 160
6e: 9f 4f sbci r25, 0xFF ; 255
70: e8 2f mov r30, r24
72: f9 2f mov r31, r25
74: 11 82 std Z+1, r1 ; 0x01
76: 10 82 st Z, r1
78: 89 81 ldd r24, Y+1 ; 0x01
7a: 9a 81 ldd r25, Y+2 ; 0x02
7c: 01 96 adiw r24, 0x01 ; 1
7e: 9a 83 std Y+2, r25 ; 0x02
80: 89 83 std Y+1, r24 ; 0x01
82: 89 81 ldd r24, Y+1 ; 0x01
84: 9a 81 ldd r25, Y+2 ; 0x02
86: 83 30 cpi r24, 0x03 ; 3
88: 91 05 cpc r25, r1
8a: 64 f3 brlt .-40 ; 0x64
8c: 0f 90 pop r0
8e: 0f 90 pop r0
90: cf 91 pop r28
92: df 91 pop r29
94: 08 95 ret

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

Пока это все примеры, которые я хотел раписать тут. Дальше мне заниматься этим было лениво.
Уменьшить размер прошивки и занимаемой оперативной памяти можно так же выгружая лишние данные в eeprom и progmem, но расписывать это мне, честно говоря, сейчас лениво.

Заключение.

Как видно из примеров, писать код занимающий мало места, требует знаний конкретной архитектуры, да и вообще штука это нетривиальная, но жутко интересная. Это вам не ПокажиСообщение(«Невосстановимая ошибка базы данных 1С»);