Зробимо GCC C++ для AVR і Arduino краще?



Привіт хабраплюсплюсовцам!

Хочу розібрати проблему компілятора avr-g++, з-за якої в різних дискусіях про AVR і Arduino звучить «С++ — це не для мікроконтролерів, C++ жере пам'ять, C++ генерує роздутий код — пишіть на голому C, а краще на ASM».

Для початку давайте розберемося, в чому ж перевага C++ перед C. Концепцій, які додає C++ багато, але найбільш значуща й найбільш експлуатована — це підтримка ООП. Що таке ООП?
  • Інкапсуляція
  • Спадкування
  • Поліморфізм


Використання перших двох пунктів в C++ «безкоштовно». Ніякої переваги програма на чистому C перед програмою на C++ з инкапсуляцией і спадкуванням не має. Картина міняється, коли ми підключаємо до дійства поліморфізм. Поліморфізм буває різним: compile-time, link-time, run-time. Я кажу про класичному run-time, тобто про віртуальні функції. Як тільки в своїх класах ви починаєте додавати віртуальні методи, чудесним чином зростає споживання як Flash-пам'яті, так і SRAM.

Чому так відбувається і що з цим можна було б зробити, розповім під катом.

Приклад без віртуальних функцій
Давайте подивимося на програму з одним базовим класом і двома спадкоємцями:

volatile unsigned char var;

class Base
{
public:
void foo() { var += 19; }
void bar() { var += 29; }
void baz() { var += 39; }
};

class DerivedOne : public Base
{
public:
void foo() { var += 17; }
void bar() { var += 27; }
void baz() { var += 37; }
};

class DerivedTwo : public Base
{
public:
void foo() { var += 18; }
void bar() { var += 28; }
void baz() { var += 38; }
};

DerivedOne dOne = DerivedOne();
DerivedTwo dTwo = DerivedTwo();

int main()
{
Base* b;
if (var)
b = &dOne;
else
b = &dTwo;

asm("nop");
b->foo();

for (;;)
;

return 0;
}


У функції `main` на основі значення `var`, яке компілятору свідомо не відомо, ми призначаємо покажчик на базовий клас `b` посилання або на об'єкт першого успадкованого класу, або посилання на об'єкт другого. А потім викликаємо метод `foo` за вказівником на базовий клас.

Цей приклад дурнуватий, т. к. незалежно від нашої метушні з дочірніми класами, буде викликана реалізація `foo` від базового класу `Base`. Приклад корисний, як відправна точка.

$ avr-g++ -O0-c novirtual.cpp -o novirtual.o
$ avr-gcc-O0 novirtual.o-o novirtual.elf
$ avr-size-C --format=avr novirtual.elf
AVR Memory Usage
----------------
Device: Unknown

Program: 104 bytes
(.text + .data + .bootloader)

Data: 3 bytes
(.data + .bss + .noinit)


Отже, програма використовує 104 байта Flash-пам'яті і 3 байт SRAM. 104+3 байт при використанні прапорів оптимізації всихають до 34+3, а при використанні прапорів очищення мертвого коду і зовсім — 16+0 байт.

Якщо відкрити генерується компілятором асемблер і знайти місце виклику функції, побачимо картину:

ldd r24,Y+1
ldd r25,Y+2
rcall _ZN4Base3fooEv


В регістри `r24:r25` заганяється значення `this` і робиться безпосередній виклик `Base::foo`. Просто, ефективно. Звичайно, оптимізатор помітить непотрібність this і взагалі побачить можливість inline'а, але ми давайте міркувати на неоптимизированном рівні.

Додаємо virtual
Тепер давайте додамо поліморфізму. Зробимо наші методи віртуальними:

volatile unsigned char var;

class Base
{
public:
virtual void foo() { var += 19; }
virtual void bar() { var += 29; }
virtual void baz() { var += 39; }
};

class DerivedOne : public Base
{
public:
virtual void foo() { var += 17; }
virtual void bar() { var += 27; }
//virtual void baz() { var += 37; }
};

class DerivedTwo : public Base
{
public:
virtual void foo() { var += 18; }
//virtual void bar() { var += 28; }
virtual void baz() { var += 38; }
};

DerivedOne dOne = DerivedOne();
DerivedTwo dTwo = DerivedTwo();

int main()
{
Base* b;
if (var)
b = &dOne;
else
b = &dTwo;

asm("nop");
b->foo();

for (;;)
;

return 0;
}


Перевіряємо:

AVR Memory Usage
----------------
Device: Unknown

Program: 312 bytes
(.text + .data + .bootloader)

Data: 25 bytes
(.data + .bss + .noinit)


Ого-го! 25 байт SRAM як не бувало. Легко перевірити, що створення чергового екземпляра класу з'їсть ще 2 байти. Ці 2 байта — покажчик на таблицю віртуальних функцій, яка і дозволяє при виклику методу за вказівником на базовий клас виконувати конкретну реалізацію номінального дочірнього класу.

Але ж у нас всього 2 глобальних об'єкта і одна нещасна змінна на 1 байт. Хто зжер всю іншу пам'ять? Ось ми і підійшли до суті проблеми. Це самі віртуальні таблиці. По штуці на кожен клас. Розмір кожної лінійно залежить від кількості віртуальних функцій.

Ціна поліморфізму
Давайте схематично зобразимо таблиці віртуальних функцій. У нашому прикладі їх 3, по одній на кожен клас:

vtable for Base:
foo -> Base::foo
bar -> Base::bar
baz -> Base::baz

vtable for DerivedOne:
foo -> DerivedOne::foo
bar -> DerivedOne::bar
baz -> Base::baz

vtable for DerivedTwo:
foo -> DerivedTwo::foo
bar -> Base::bar
baz -> DerivedTwo::baz


Кожен вказівник на 8-розрядний AVR — це 2 байти. Достатньо один раз створити такі таблиці для кожного класу в ієрархії, а потім в конкретних примірниках додавати одне приховане поле `__vtbl*`, яке вказує на конкретну таблицю. Так кожен примірник буде «знати хто він» незалежно від того, за вказівником якого типу викликають його методи. Тобто оверхед поліморфізму для одного об'єкта — це лише +2 байти на `__vtbl*` і витрати на непрямий виклик. Метод викликається не безпосередньо, а спочатку підтягується його адресу з таблиці, а потім йде виклик.

ldd r24,Y+1
ldd r25,Y+2
mov r30,r24
mov r31,r25
ld r24,Z
ldd r25,Z+1
mov r30,r24
mov r31,r25
ld r18,Z
ldd r19,Z+1
ldd r24,Y+1
ldd r25,Y+2
mov r30,r18
mov r31,r19
icall

Додаткові витрати на непрямий виклик важливі, якщо мова йде про численні виклики в коді, який дуже критичний до часу виконання. Але тоді виникає питання: що робить поліморфізм в такому коді? Кожній задачі — свій інструмент. Для вирішення завдань високого рівня ООП — благо.

Де avr-gcc не прав
Я показав, що реальні пенальті за SRAM від активного використання віртуальних функцій — це 2 байти на екземпляр. Дуже адекватно за такі багаті можливості. Але що робить avr-gcc? Він пхає самі віртуальні таблиці в SRAM! З-за цього поява кожного нового класу з віртуальними функціями, його спадкоємця або навіть інтерфейсу (pure abstract class) призводить до збільшення споживаної SRAM.

Це абсолютно не обгрунтовано, т. к. віртуальні таблиці не можуть змінюватися по ходу виконання програми. Їм саме місце в Flash-пам'яті, яка зазвичай закінчується» куди пізніше, ніж SRAM. Це тема 100 разів піднімалася різних спільнотах.

Іронія в тому, що ці таблиці і так вже розміщуються в Flash, а в момент старту контролера копіюються ще і в SRAM. В генерованому ASM для отримання адреси реалізації функції треба просто використовувати не `ldd`, а `lpm`, тобто ходити за адресою копію таблиці в SRAM, а в її оригінал на Flash.

Чому цього оптимізації ще ніхто не зробив? Все як завжди впирається не в техніку, а в людей. GCC — по-справжньому великий open source проект, за яким не стоїть великого папи з грошима. GCC дуже великий, зі своєю культурою, структурою, валізою знань і т. д. На тлі його купка людей, які кричать про те, що хочуть C++ на якихось штуках з якоюсь гарвардською архітектурою, дуже мала. Ще не знайшлося людини, який належав би обом світам і був досить вмотивований на доопрацювання.

Що ж робити?
У GCC давним давно з'явився механізм плагінів, який дозволяє втрутитися в будь-яке місце ланцюжка від AST до асемблера. Оптимізацію віртуальних таблиць можна реалізувати на рівні плагіна. Проблема лише в тому, що для створення плагіна потрібно або бути інсайдером GCC, щоб розуміти всю специфіку, API і точки входу, або бути уберпрограммистом, який дуже швидко курить мануали і вихідний код GCC.

Я дуже сподіваюся, що така людина є. Дуже хочеться, щоб такий плагін з'явився і став доступний спільноти, зробивши наше життя трохи приємніше. Амперка готова підтримати розробку рублем… 150 килорублями за плагін, який привів би до усушиванию програми з прикладу з 25 байт SRAM до 7 байт.

Якщо ви знаєте людину, яка вже збирав граблі в GCC, будь ласка, зверніть його увагу на цей пост. Заздалегідь вам вдячна! Пишіть в коменти, в личку або на victor[собака]amperka.ru.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.