Автор С++, Б'ярн Страуструп Фото взято тут. |
(*) З традиційною обмовкою, що всезагальні твердження про об'єкти реального світу, завжди помилкові -- включаючи це твердження. :-)
Давайте подивимося, яка runtime-підтримка потрібна C++, а потім -- що потрібно "допилювати" вручну, щоб добитися її, працюючи із наступним комплектом інструментів: ARM GCC 4.8 та 4.9 + STM32 CMSIS + CoIDE. Для інших компіляторів -- будуть суттєві відмінності, навіть для інших версій GCC певні відмінності можуть бути. Для інших контролерів -- будуть відмінності. Із середовищем -- як повезе. [Можливо, про Keil поговоримо окремо.]
Розгляд нижче -- достатньо мінімалістичний. І так пост непомірно великий. Про підтримку локалей мова не йде, для контролерів вони просто не вартують затрат. Взагалі, про ціну за використання окремих можливостей С++ буде окремий пост -- сюди воно просто не влазить. Хіба скажу -- зазвичай ця ціна нульова або незначна. Основні винятки описані нижче -- виключення і RTTI.
Вважатимемо, що вже обговорена підтримка стандартної бібліотеки у нас є. Працюватимемо все із тією ж STM32VLDiscovery, обладнаною мікроконтролером STM32F100RB. Сама по собі модель мікроконтролера не є аж такою важливою, але для ясності, орієнтуватимемося на його ресурси -- 128 Кб пам'яті програм, 8Кб оперативної пам'яті.
Так як пост вийшов достатньо великим і трохи плутаним, почну із короткого огляду:
- C++ runtime. Огляд задач, які стоять перед кодом часу підтримки, зокрема -- перед його реалізаціями, libstdc++ і libsupc++.
- Ініціалізація. З чого починає роботу контролер, і як добитися, щоб стандартна для С++ процедура підготовки програми перед входом в main(), зокрема -- виклик конструкторів глобальних об'єктів відбувалася, як очікується. Розглядається процедура ініціалізації контролера, підхід GCC до ініціалізації програми (зокрема, всілякі там crt0.o) та обробник переривання Reset. Також, для повноти, зачіпається тема "деініціалізації" -- виклику деструкторів по завершенню main(), хоча для контролерів можливість не є особливо цінною.
- Оператори new, new[], delete, delete[]. Особливості реалізації, вимоги до них, опис цілого зоопарку цих операторів.
- Виключення. Дуже корисна можливість сучасних мов програмування, яка, на жаль, вимагає складної підтримки часу виконання і помітних ресурсів для своєї роботи (помітних -- в масштабах доступних контролеру ресурсів). Самопальною тут не обійдешся. Тому рекомендується їх просто не використовувати. Якщо ж таки є потреба у них, (чи є просто бажання спробувати), розповідається, як підключити їх підтримку засобами libsupc++, і чого це коштуватиме. Звертається увага на відмінності обробки виключень в GCC для ARM та його ж для інших архітектур. Є посилання для подальшого заглиблення. Згадується альтернативна реалізація C++ runtime -- libcxxrt, про яку буде окремий пост.
- RTTI -- RunTime Type Information. Так само, як із виключенням, краще без цієї можливості обійтися, якщо ж таки дуже треба (не вдається прожити без dynamic_cast<> :-), розповідається, як її підключити -- крім компонування із libsupc++ слід додати декілька функцій: __cxa_bad_cast(), __cxa_bad_typeid(), що задають реакцію на відповідні події.
- Реакція на виклик чисто віртуальних чи видалених функцій. Власне -- воно, пара функцій, що викликатимуться у таких нещасливих випадках. Компілятор вставляє їх виклик в патологічних місцях коду, без надання їх реалізації будуть помилки компонування.
- Локальні статичні змінні. Для захисту від одночасного доступу, race condition, під час ініціалізації статичних змінних в багатопоточному середовищі, компілятор вставляє спеціальні захисні виклики (таке собі блокування-розблокування мютекса). Написати ці функції коректно відносно складно, тому тут, для початку, описано мінімальні заглушки, які, насправді, не захищають, але дозволяють коду скомпілюватися. (За більш повною реалізацією див. ту ж libcxxrt).
- C++11. Більшість мовних засобів мали б працювати зразу, без додаткової підтримки.
- Стандартна бібліотека С++. Включаючи STL. Огляд того, що вдасться використовувати зразу, що потребує певної підтримки часу виконання, а для чого доведеться тягнути цілу libstdc++. Якщо коротко -- послідовні контейнери (vector<>, deque<>, list<>, array<>, і т.д.) та більшість алгоритмів можна використовувати майже зразу. Асоціативні вимагають компонуватися із libstdc++. Паралельно розглянуті деякі брудні трюки, що дозволяють справитися із дещо непомірними її апетитами щодо RAM. Ілюстрація затрат ресурсів на це все.
- Керування та відстеження динамічного виділення пам'яті.
- Опис демонстраційного проекту.
- Додаток містить прокоментований скрипт лінкера.
Для лінивих, демонстраційний проект тут.
На жаль, якорі HTML в цьому блозі чомусь дуже капризно поводяться, тому, щоб не витрачати додаткового часу (даний пост його спожив неміряно!), не використовую їх для посилання на розділи нижче (може потім додам, під настрій).
Перш ніж перейти до тексту, див. заголовок блогу --- виклад не всюди робиться із повним розумінням процесів, іноді -- емпіричний, тому ніяких гарантій. Звичайно, буду вдячний за уточнення та виправлення!
C++ runtime
Почнемо із загальних питань -- кілька слів про Runtime взагалі. Код часу виконання (runtime-код) С++ можна поділити на дві частини: код, який реалізовує стандартну бібліотеку і код підтримки засобів мови, таких як виключення. Runtime GCC включає libstdc++ в ролі і першого і другого та, за потреби, libsupc++ в ролі лише другого -- ця бібліотека включає підмножину засобів, потрібних для підтримки мовних засобів, без стандартної бібліотеки. Про libstdc++ мова буде пізніше.
Найбільш складними задачами, що вирішує libsupc++, є підтримка RTTI та виключень, хоч цим її роль і не вичерпується. Відповідно, загальне вирішення проблеми підтримки мовних засобів -- перенести її (port) на наш контролер, надавши відповідні системні виклики. Іноді це складно, іноді лише вимагає непомірних ресурсів -- бібліотека відносно громіздка. Тому, крім способів використання її для контролеру, що розглядається, подивимося як можна обійтися мінімалістичною реалізацією необхідної підтримки.
Так як нижче чисто С++-ні речі тісно переплетені із особливостями ARM GCC (новіших версій) та CoIDE, наведу список необхідних елементів підтримки:
- Викликати, перед стрибком до main(), конструктори статичних об'єктів (вони ж -- глобальні змінні та змінні області видимості просторів імен). Формально, варто викликати їх деструктори після виходу з цієї функції.
- Реалізація сімейства операторів new та delete.
- Реалізація реакції на виклик чисто віртуальної функції.
- Підтримка RTTI.
- Підтримка виключень.
- Реалізація засобів безпечної щодо багатопоточності ініціалізації.
Детальніше див.:
- Статті "C++", "C++ Bare Bones", "C++ Exception Support", "Full C++ Runtime Support Using libgcc And libsupc++" з OSDev -- сайту про написання власних операційних систем. Сайт орієнтований на x86-сумісні машини, тому уважно слідкуйте за згадками про ARM/EABI -- часто не уточнюється, що щось стосується тільки x86. Загальні принципи та сама ситуація ("гола машина", без сервісів операційної системи) схожі -- програма для мікроконтролера є сама собі операційною системою, однак деталі можуть дуже суттєво відрізнятися.
- "Поддержка C++ на avr в gcc" та відповідна гілка на форумі avrfreaks: "avr-c++ micro how-to". ARM-и не AVR, звичайно, але і на них потрібно реалізувати ті ж мінімальні засоби підтримки C++, часто -- майже тим же чином, що і для AVR. Однак, The Devil is in the detail.
- Також, корисним буде поколупатися в реалізаціях: uClibc++, Miosix, libcxxrt.
Ініціалізація
Запуск програми відбувається дуже просто --- після перевантаження/ввімкнення мікроконтролер:
- Бере значення із адреси 0x0 флеш-пам'яті і кладе його в SP -- регістр стеку
- Передає керування за адресою, яка міститься в комірках флешу, починаючи із 0x4 -- до обробника переривання із номером 1, Reset.
Детальніше див. "The Definitive Guide to the ARM® Cortex-M3", 2nd ed., глава 3.7 Reset sequence, стор. 40-42.
Таким чином, програма починає виконуватися із коректним стеком (в припущенні, що компілятор чи програміст все не зіпсували), але за межами того -- повністю "on it's own".
Таким чином, програма починає виконуватися із коректним стеком (в припущенні, що компілятор чи програміст все не зіпсували), але за межами того -- повністю "on it's own".
Ініціалізація та написання обробників переривань
Відволічемося від С++ на хвилинку. Програма почне виконання із обробника переривання Reset. В майбутньому програмі доведеться реагувати й на інші переривання. Тому треба знати, як їх писати. Взагалі, обробка переривань -- тема складна і велика. (Див. "The Definitive Guide to the ARM® Cortex-M3", 2nd ed., главу 7 -- про те, як процесор забезпечує їх обробку та главу 19, про те, як це робити із допомогою GCC.) Однак, компілятор про більшість технічних подробиць турбується за нас. Решту, зазвичай, робить середовище, (тут --- CoIDE) або CMSIS чи інша "стандартна" бібліотека від розробника -- надає мінімальний комплект обробників. Для контролера плати STM32VLDsicovery, код ініціалізації знаходиться в файлі: startup_stm32f10x_md*.*. Розширення не вказано свідомо -- із старішими версіями CoIDE йшла реалізація цього файлу на С, із асемблерними вставками, актуальна версія 2.0 використовує асемблер.
Детальний розбір реалізації версії цього файлу на С, CoIDE 1.5, знаходиться в пості: "CooCox CoIDE", хоча в цьому пості є трохи помилок та неточностей (особливо стосовно С++). Була спокуса, для самодостатності цього поста, скопіювати текст сюди, однак втримався -- за подробицями йдіть туди. Тому лише коротко перекажу. У цьому файлі:
- Виділяється пам'ять для стеку. (Іноді цей розмір доводиться збільшувати)
- Оголошує таблицю переривань, із обробниками по замовчуванню. Зверніть увагу на цей пункт.
- Створюється обробник переривання Reset.
static void Default_Handler(void) { /* Go into an infinite loop. */ while (1) { } }
Зауважте, що написано обробник із використанням звичайного С -- про всі апаратні нюанси, типу збереження-відновлення регістрів турбується компілятор разом із мікроконтролером.
За потреби його можна замінити на свій. Питання --- як? Звичайно, можна змінювати сам startup-файл. Це, часто, незручно. Використано інший підхід --- символи-обробник по замовчуванню оголошено як слабкі:
Даний атрибут означає, що якщо лінкер зустріне інше визначення цього символу, використає його. Надане по замовчуванню буде використовуватися лише, якщо нічого кращого не трапиться.
Сучасніша версія CoIDE використовує для ініціалізації файл з CMSIS, написаний на асемблері, startup_stm32f10x_md_vl.s. У ньому обробник по замовчуванню та таблиця переривань оголошуються так (коментар "Skipped" показує де, для економії розміру цього тексту, викинуто однотипні фрагменти коду):
Як видно, по замовчуванню -- все той же нескінчений цикл, (мнемоніка b в команді "b Infinite_Loop" означає безумовний перехід, b -- "branch", "розгалуження"), ті ж слабкі символи, які посилаються на обробник по замовчуванню.
С-ний варіант цього обробника, запропонований CoIDE 1.5, виглядає так:
Він робить наступне:
Їх оголошення в коді на С:
На асемблері це ж звучатиме так:
Асемблерний варіант, який надасть CoIDE-2.0 (копірайт відсилається до CMSIS), виглядає так:
Взагалі, асемблер потрібно знати. Але навіть без цього, користуючись коментарями та підглянувши мнемоніки команд, видно, що робиться рівно те ж саме.
І ось тут починаються проблеми. Статичні (глобальні) змінні C++ ініціалізуються по іншому.
Отож, переходимо до підтримки С++.
Глобальні змінні С++, як відомо, ініціалізуються викликом їх конструкторів, у непередбачувані моменти, до виклику main() (хоча, стандарт дозволяє і пізніше, лише б до першого використання, але це тема для іншої розмови).
Більше того, це може стосуватися навіть змінних фундаментальних (вбудованих) типів! Вони цілком можуть ініціалізуватися не з секції даних, .data, (див. також розмову про скрипти лінкера і файл link.ld в: "CooCox CoIDE") а викликом спеціальної функції, фактично -- конструктора! Наприклад, наступний код:
використовуючи проект CoIDE із попереднього поста, дає такий результат [YMMV!]:
ca=5, cb=5
a=7, b=0
c=12, d=0
Сюрприз. Проблема ось в чому. Код ініціалізації, який розглядався вище не викликає ніяких конструкторів! Він про них навіть знати не хоче... Доведеться самостійно.
Ініціалізація програми на С відбувається доволі хитро. У ній беруть участь аж 5 об'єктних файлів, які називаються якось так: crt0.o, crtbegin.o, crtend.o, crti.o, and crtn.o.
crt0.o типово містить функцію _start, яка ініціалізує процес та викликає щось типу exit(main(argc, argv)), щоб значення повернене main() передалося ОС. У нас його роль виконує розглянутий вище Default_Reset_Handler.
В чистому С перед main() код не виконується, а в С++ може -- якраз конструктори статичних об'єктів. Те ж стосується коду, який виконується після main() --- деструкторів. GCC надає таку можливість і для чистого С, як нестандартне розширення --- використовуючи атрибути виду __attribute__((constructor)) .
crtbegin.o і crtend.o містять код, який виконує необхідні дії, в секції .init для конструкторів та .fini для деструкторів. Хитрою магією компонування (див. посилання нижче) ці секції "приліплюються" до відповідних секцій з crti.o (.init) та crtn.o (.fini), при чому, фактично, пролог і епілог відповідних функцій _init та _fini знаходяться у різних файлах, тому на багатьох платформах написати їх засобами С неможливо --- доводиться використовувати асемблер.
Аналогічна пара для ELF: .ctors/.dtors.
Компонування може виглядати якось так:
1. Створюємо файл startup.c, який міститиме високорівневий код ініціалізації. Щоб заплутування імен (name mangling) не заважало, або файл має бути саме С-ний, або слід додати щось типу extern "C".
У цьому файлі оголошуємо необхідні символи:
Однак, зі стелі вони не візьмуться -- їх має надати лінкер. Раніше в скрипті, що йшов з CoIDE, доводилося всіх їх прописувати вручну. Тепер скрипт по замовчуванню (див. додаток) частину із них оголошує, залишається додати до нього _ctor_start і _ctor_end. Зображу це таким собі псевдо-diff, нові рядки показано плюсиками:
Тепер у файлі startup.c можна написати функцію виклику конструкторів. Так як атрибути у визначенні вказувати не можна, спочатку оголошуємо функцію із необхідними атрибутами, потім даємо її визначення:
Як видно з її коду, оці секції ініціалізації -- всього лиш масиви вказівників на функції.
Тепер можна створити функцію, яка, за допомогою всього описаного вище, здійснить необхідну ініціалізацію:
Нагадую, що всілякі _ctor_start -- це символи, щоб отримати вказівник на відповідну сутність, слід взяти їхню адресу.
Нарешті, останній штрих --- викликати цю функцію в Default_Reset_Handler із startup_stm32f10x_md_vl.s:
Тематичні розмірковування та експерименти показали наступне:
Користуватися слід Newlib, а не її nano версією, (добре подумавши, чи воно того варте, з точки зору витрат пам'яті програм).
До файлу startup.c додати наступне (правда, він вже тепер не тільки startup, але плодити файли не хочеться):
та:
Скрипт лінкера модифікуємо, створюючи символи і для деструкторів:
Нарешті, модифікуємо Default_Reset_Handler із startup_stm32f10x_md_vl.s:
Тепер створення та знищення об'єктів поводитиметься більш-менш звично. За одним винятком, про який далі.
Спробуємо, що із того вийшло. Ось така програма (main.cpp):
виведе засобами SemiHosting наступне:
Маленьке (платформо, себто -- компіляторо-середовище-конфіграційно-і-т.-д. залежне) дослідження показало, що за виклик конструкторів статичних об'єктів, як і очікувалося згідно EABI, відповідає виклик call_constructors(&__init_array_start, &__init_array_end), аналогічно, за виклик деструкторів GCC (тобто, функцій з відповідним атрибутом), відповідає call_constructors(&__fini_array_start, &__fini_array_end).
Так як malloc() i free() у нас вже є, природно для виділення та звільнення пам'яті скористатися саме ними. Тут все просто. Складність в іншому. Стандарт вимагає від цих операторів доволі складної поведінки(*), крім того, їх є багато різновидів (**):
void* operator new (std::size_t size)
void* operator new[] (std::size_t size)
"Звичайний" оператор, у двох варіантах -- для однієї змінної і для масиву. У випадку неможливості виділити пам'ять, повинен кинути виключення std::bad_alloc.
void* operator new (std::size_t size, const std::nothrow_t& nothrow_value)
void* operator new[] (std::size_t size, const std::nothrow_t& nothrow_value)
Оператор, що повертає нульовий вказівник, коли не може виділити пам'ять.
void* operator new (std::size_t size, void* ptr)
void* operator new[] (std::size_t size, void* ptr)
Placement new --- просто повертає вказівник. Використовується для конструювання об'єкта у потрібному місці пам'яті. Не може бути перевизначеним.
void operator delete (void* ptr)
void operator delete[] (void* ptr)
Звільняє пам'ять, виділену відповідним new. Повинен нормально приймати нульовий вказівник.
void operator delete (void* ptr, const std::nothrow_t& nothrow_constant)
void operator delete[] (void* ptr, const std::nothrow_t& nothrow_constant)
По суті, те ж. Ніколи не викликається при знищенні об'єктів, що вже існують. Потрібен відповідним виразам new(***), якщо пам'ять виділити вдалося, але конструктор завершився аварійно -- виключенням.
void operator delete (void* ptr, void* voidptr2)
void operator delete[] (void* ptr, void* voidptr2)
Не робить нічого -- такий собі placement delete, напарник placement new. Як і попередній, nothrow delete, ніколи не викликається delete виразом. Не може бути перевизначеним.
С++14 визначає ще два варіанти як для однієї змінної, так і для масивів:
void operator delete (void* ptr, std::size_t size)
void operator delete[] (void* ptr, std::size_t size)
void operator delete (void* ptr, std::size_t size, const std::nothrow_t& nothrow_constant)
void operator delete[] (void* ptr, std::size_t size, const std::nothrow_t& nothrow_constant)
Вони можуть використовуватися в деяких хитрих оптимізаціях. Отримують розмір, використаний при виділенні, по замовчуванню просто викликають відповідний "звичайний" delete. На них надалі не відволікатимемося.
На додачу до перерахованих операторів, слід також визначити константу nothrow.
Під час реалізації їх для мікроконтролерів, традиційно постає кілька виборів, які стосуються того, наскільки детально та точно відтворювати поведінку, що вимагається стандартом.
Більш відповідну стандарту реалізацію можна отримати, підключивши libsupc++ або libcxxrt (повчально глянути код реалізації їх у цих бібліотеках).
Отож, код може виглядати якось так (де макрос USE_RTTI_AND_EXCEPTIONS визначає, чи є підтримка виключень):
Реалізація на OSDev, як на мене, все ж, непристойно мінімалістична.
___________
(*) Наприклад, якщо пам'яті немає, new спершу повинен викликати функцію, встановлену викликом set_new_handler(), яка може спробувати надати більше пам'яті, і лише якщо це не вдалося і обробник повернув нуль -- кидати виключення. Уявляєте, які перспективи відкриваються? Виявити, що пам'ять кінчилася, вивести повідомлення для користувача -- доставити ще пару мікросхем пам'яті, ініціалізувати її і продовжити. :-)
(**) Взагалі кажучи, специфікація виключень для С++98/03 і С++11 цих операторів відрізняється, і її треба вказувати (див., наприклад, тут), але для наших цілей робити це буде надмірним буквоїдством.
(***) MyClass *p = new MyClass; -- це new-вираз, в процесі його роботи викликається оператор new, який виділяє пам'ять, потім викликається конструктор (для масиву -- конструктор викликається для кожного елемента). Якщо відбувся збій -- викликається "симетричний" delete ("звичайний" delete, nothrow delete, placement delete). Аналогічно (тільки в зворотному порядку) працює вираз delete, викликаючи спершу деструктор, потім "звичайний" оператор delete -- nothrow delete чи placement delete ніколи так не викликаються, див. у тексті.
#define WEAK __attribute__ ((weak))
/* System exception vector handler */ void WEAK Reset_Handler(void); void WEAK NMI_Handler(void); void WEAK HardFault_Handler(void);
Даний атрибут означає, що якщо лінкер зустріне інше визначення цього символу, використає його. Надане по замовчуванню буде використовуватися лише, якщо нічого кращого не трапиться.
Сучасніша версія CoIDE використовує для ініціалізації файл з CMSIS, написаний на асемблері, startup_stm32f10x_md_vl.s. У ньому обробник по замовчуванню та таблиця переривань оголошуються так (коментар "Skipped" показує де, для економії розміру цього тексту, викинуто однотипні фрагменти коду):
.global g_pfnVectors .global Default_Handler .equ BootRAM, 0xF108F85F /** * @brief This is the code that gets called when the processor receives an * unexpected interrupt. This simply enters an infinite loop, preserving * the system state for examination by a debugger. * @param None * @retval None */ .section .text.Default_Handler,"ax",%progbits Default_Handler: Infinite_Loop: b Infinite_Loop .size Default_Handler, .-Default_Handler /****************************************************************************** * * The minimal vector table for a Cortex M3. Note that the proper constructs * must be placed on this to ensure that it ends up at physical address * 0x0000.0000. * ******************************************************************************/ .section .isr_vector,"a",%progbits .type g_pfnVectors, %object .size g_pfnVectors, .-g_pfnVectors g_pfnVectors: .word _eram .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler /*---------- Skipped ---------------------*/
/*---------- Skipped ---------------------*/
.word BootRAM /* @0x01CC. This is for boot in RAM mode for STM32F10x Medium Value Line Density devices. */ /******************************************************************************* * Provide weak aliases for each Exception handler to the Default_Handler. * As they are weak aliases, any function with the same name will override * this definition. *******************************************************************************/ .weak NMI_Handler .thumb_set NMI_Handler,Default_Handler .weak HardFault_Handler .thumb_set HardFault_Handler,Default_Handler .weak MemManage_Handler .thumb_set MemManage_Handler,Default_Handler .weak BusFault_Handler .thumb_set BusFault_Handler,Default_Handler .weak UsageFault_Handler .thumb_set UsageFault_Handler,Default_Handler .weak SVC_Handler .thumb_set SVC_Handler,Default_Handler /*---------- Skipped ---------------------*/
/*---------- Skipped ---------------------*/
Як видно, по замовчуванню -- все той же нескінчений цикл, (мнемоніка b в команді "b Infinite_Loop" означає безумовний перехід, b -- "branch", "розгалуження"), ті ж слабкі символи, які посилаються на обробник по замовчуванню.
Обробник Reset
С-ний варіант цього обробника, запропонований CoIDE 1.5, виглядає так:
void Default_Reset_Handler(void) { /* Initialize data and bss */ unsigned long *pulSrc, *pulDest; /* Copy the data segment initializers from flash to SRAM */ pulSrc = &_sidata; for(pulDest = &_sdata; pulDest < &_edata; ) { *(pulDest++) = *(pulSrc++); } /* Zero fill the bss segment. This is done with inline assembly since this will clear the value of pulDest if it is not kept in a register. */ __asm(" ldr r0, =_sbss\n" " ldr r1, =_ebss\n" " mov r2, #0\n" " .thumb_func\n" " zero_loop:\n" " cmp r0, r1\n" " it lt\n" " strlt r2, [r0], #4\n" " blt zero_loop"); /* Setup the microcontroller system. */ SystemInit(); /* Call the application's entry point.*/ main(); }
Він робить наступне:
- Копіює ініціалізовані глобальні змінні із флеш-пам'яті в оперативну.
- Заповнює неініціалізовані глобальні змінні нулями, як і повинно бути в С.
- Викликає код ініціалізації периферії контролера, функцію SystemInit() із CMSIS. Вона визначена в system_stm32f10x.c, ініціалізує системний годинник (джерело тактування), детальніше див. коментарі у цьому файлі.
- Викликає main().
Їх оголошення в коді на С:
/*----------Symbols defined in linker script--------------------------*/ extern unsigned long _sidata; /*!< Початкова адреса для значень, якими ініціалізуватиметься секція .data */ extern unsigned long _sdata; /*!< Початкова адреса секції .data */ extern unsigned long _edata; /*!< Кінцева адреса секції .data */ extern unsigned long _sbss; /*!< Початкова адреса секції .bss */ extern unsigned long _ebss; /*!< Кінцева адреса секції .bss */ extern void _eram; /*!< Кінець оперативної (RAM) пам'яті */
На асемблері це ж звучатиме так:
.word _sidata .word _sdata .word _edata .word _sbss .word _ebss
Асемблерний варіант, який надасть CoIDE-2.0 (копірайт відсилається до CMSIS), виглядає так:
.section .text.Reset_Handler .weak Reset_Handler .type Reset_Handler, %function Reset_Handler: /* Copy the data segment initializers from flash to SRAM */ movs r1, #0 b LoopCopyDataInit CopyDataInit: ldr r3, =_sidata ldr r3, [r3, r1] str r3, [r0, r1] adds r1, r1, #4 LoopCopyDataInit: ldr r0, =_sdata ldr r3, =_edata adds r2, r0, r1 cmp r2, r3 bcc CopyDataInit ldr r2, =_sbss b LoopFillZerobss /* Zero fill the bss segment. */ FillZerobss: movs r3, #0 str r3, [r2], #4 LoopFillZerobss: ldr r3, = _ebss cmp r2, r3 bcc FillZerobss /* Call the clock system intitialization function.*/ bl SystemInit /* Call the application's entry point.*/ bl main bx lr .size Reset_Handler, .-Reset_Handler
Взагалі, асемблер потрібно знати. Але навіть без цього, користуючись коментарями та підглянувши мнемоніки команд, видно, що робиться рівно те ж саме.
І ось тут починаються проблеми. Статичні (глобальні) змінні C++ ініціалізуються по іншому.
Отож, переходимо до підтримки С++.
Ініціалізація глобальних змінних
Глобальні змінні С++, як відомо, ініціалізуються викликом їх конструкторів, у непередбачувані моменти, до виклику main() (хоча, стандарт дозволяє і пізніше, лише б до першого використання, але це тема для іншої розмови).
Більше того, це може стосуватися навіть змінних фундаментальних (вбудованих) типів! Вони цілком можуть ініціалізуватися не з секції даних, .data, (див. також розмову про скрипти лінкера і файл link.ld в: "CooCox CoIDE") а викликом спеціальної функції, фактично -- конструктора! Наприклад, наступний код:
#include <stdio.h> #include <stm32f10x.h> const int my_ca = 5; const int my_cb = my_ca; int my_a = 7; int my_b = my_a; int my_c = 12; const int my_d = my_c; int main() { printf("ca=%i, cb=%i\n", my_ca, my_cb); printf("a=%i, b=%i\n", my_a, my_b); printf("c=%i, d=%i\n", my_c, my_d); while(1){} }
використовуючи проект CoIDE із попереднього поста, дає такий результат [YMMV!]:
ca=5, cb=5
a=7, b=0
c=12, d=0
Сюрприз. Проблема ось в чому. Код ініціалізації, який розглядався вище не викликає ніяких конструкторів! Він про них навіть знати не хоче... Доведеться самостійно.
Ініціалізація програми на С відбувається доволі хитро. У ній беруть участь аж 5 об'єктних файлів, які називаються якось так: crt0.o, crtbegin.o, crtend.o, crti.o, and crtn.o.
crt0.o типово містить функцію _start, яка ініціалізує процес та викликає щось типу exit(main(argc, argv)), щоб значення повернене main() передалося ОС. У нас його роль виконує розглянутий вище Default_Reset_Handler.
В чистому С перед main() код не виконується, а в С++ може -- якраз конструктори статичних об'єктів. Те ж стосується коду, який виконується після main() --- деструкторів. GCC надає таку можливість і для чистого С, як нестандартне розширення --- використовуючи атрибути виду __attribute__((constructor)) .
crtbegin.o і crtend.o містять код, який виконує необхідні дії, в секції .init для конструкторів та .fini для деструкторів. Хитрою магією компонування (див. посилання нижче) ці секції "приліплюються" до відповідних секцій з crti.o (.init) та crtn.o (.fini), при чому, фактично, пролог і епілог відповідних функцій _init та _fini знаходяться у різних файлах, тому на багатьох платформах написати їх засобами С неможливо --- доводиться використовувати асемблер.
Аналогічна пара для ELF: .ctors/.dtors.
Компонування може виглядати якось так:
<platform>-elf-ld crt0.o crti.o crtbegin.o prog_file_1.o prog_file_2.o crtend.o crtn.oДля ARM EABI існує один нюанс --- для ініціалізації/деініціалізації використовуються (також?) секції .init_array і .fini_array.
Інструкції
Отож, "щоб все було добре", робимо наступне:1. Створюємо файл startup.c, який міститиме високорівневий код ініціалізації. Щоб заплутування імен (name mangling) не заважало, або файл має бути саме С-ний, або слід додати щось типу extern "C".
У цьому файлі оголошуємо необхідні символи:
extern unsigned long __preinit_array_start; extern unsigned long __preinit_array_end; extern unsigned long __init_array_start; extern unsigned long __init_array_end; extern unsigned long _ctor_start; extern unsigned long _ctor_end;
Однак, зі стелі вони не візьмуться -- їх має надати лінкер. Раніше в скрипті, що йшов з CoIDE, доводилося всіх їх прописувати вручну. Тепер скрипт по замовчуванню (див. додаток) частину із них оголошує, залишається додати до нього _ctor_start і _ctor_end. Зображу це таким собі псевдо-diff, нові рядки показано плюсиками:
KEEP(*(.fini)) + _ctor_start = .; /* Додано для підтримки С++ */ /* .ctors -- згадана в тексті альтернатива до .init */ *crtbegin.o(.ctors) *crtbegin?.o(.ctors) *(EXCLUDE_FILE(*crtend?.o *crtend.o) .ctors) *(SORT(.ctors.*)) *(.ctors) + _ctor_end = .; /* Додано для підтримки С++ */
Тепер у файлі startup.c можна написати функцію виклику конструкторів. Так як атрибути у визначенні вказувати не можна, спочатку оголошуємо функцію із необхідними атрибутами, потім даємо її визначення:
void call_constructors(unsigned long *start, unsigned long *end)
__attribute__((noinline)); void call_constructors(unsigned long *start, unsigned long *end) { unsigned long *i; void (*funcptr)(); for ( i = start; i < end; i++) { funcptr=(void (*)())(*i); funcptr(); } }
Як видно з її коду, оці секції ініціалізації -- всього лиш масиви вказівників на функції.
Тепер можна створити функцію, яка, за допомогою всього описаного вище, здійснить необхідну ініціалізацію:
void CallAll(void) { call_constructors(&__preinit_array_start, &__preinit_array_end); call_constructors(&__init_array_start, &__init_array_end); call_constructors(&_ctor_start, &_ctor_end); }
Нагадую, що всілякі _ctor_start -- це символи, щоб отримати вказівник на відповідну сутність, слід взяти їхню адресу.
Нарешті, останній штрих --- викликати цю функцію в Default_Reset_Handler із startup_stm32f10x_md_vl.s:
/* Call the clock system intitialization function.*/ bl SystemInit + /* Init C++ static objects */ + bl CallAll /* Call the application's entry point.*/ bl main
Якщо коротко, bl -- виклик підпрограми.Тепер конструктори викликатимуться. Однак, є ще один етап, який, зазвичай, для контролерів, нехтують. Деструктори --- фіналізація по завершенню програми. Вона не дуже актуальна --- дітися контролеру із main() не особливо є куди. Але, для порядку, розглянемо.
Destruction
Тематичні розмірковування та експерименти показали наступне:
- Виклик функцій із масивів __fini_array_start та _dtor_start викликає "деструктори" GCC, тобто функції, оголошені з атрибутом __attribute__ ((destructor)), але не викликає жодних інших.
- exit() із Newlib, перш ніж здійснити системний виклик _exit() (нами ж наданий в syscalls.c), викликає деструктори глобальних об'єктів.
- exit() із Newlib-nano цього НЕ робить.
- Однак, деструктори локальних об'єктів із області видимості main() (але не вкладених в неї областей видимості!) викликаються тільки після виходу із неї. А виклик exit() ніколи не повертається.
Користуватися слід Newlib, а не її nano версією, (добре подумавши, чи воно того варте, з точки зору витрат пам'яті програм).
До файлу startup.c додати наступне (правда, він вже тепер не тільки startup, але плодити файли не хочеться):
extern unsigned long __fini_array_start; extern unsigned long __fini_array_end; extern unsigned long _dtor_start; extern unsigned long _dtor_end;
та:
void ClearAll(void) { call_constructors(&__fini_array_start, &__fini_array_end); call_constructors(&_dtor_start, &_dtor_end); }
Скрипт лінкера модифікуємо, створюючи символи і для деструкторів:
+ _dtor_start = .; /* Додано для підтримки С++ */ /* .dtors -- згадана в тексті альтернатива до .fini */ *crtbegin.o(.dtors) *crtbegin?.o(.dtors) *(EXCLUDE_FILE(*crtend?.o *crtend.o) .dtors) *(SORT(.dtors.*)) *(.dtors) + _dtor_end = .; /* Додано для підтримки С++ */ /* Секція констант */
Нарешті, модифікуємо Default_Reset_Handler із startup_stm32f10x_md_vl.s:
/* Call the clock system intitialization function.*/ bl SystemInit /* Init C++ static objects */ bl CallAll /* Call the application's entry point.*/ bl main /* Call GCC destructors and clear C++ static objects */ bl ClearAll bl exit
Тепер створення та знищення об'єктів поводитиметься більш-менш звично. За одним винятком, про який далі.
Випробування
Спробуємо, що із того вийшло. Ось така програма (main.cpp):
#include <cstdio> #include <cstdlib> #include <cmath> #include <stm32f10x.h> const int my_ca = 5; const int my_cb = my_ca; int my_a = 7; int my_b = my_a; int my_c = 12; const int my_d = my_c; class TestCtorsDtors { private: int idx; public: TestCtorsDtors(int idx_in): idx(idx_in) { printf("Constructing object No: %i\n", idx); } ~TestCtorsDtors(){ printf("Destructing object No: %i\n", idx); } }; TestCtorsDtors global(1); namespace testnamespace { TestCtorsDtors namespace_level(2); } __attribute__ ((constructor)) void gcc_ctor(void) { printf("GCC constructor called\n"); } __attribute__ ((destructor)) void gcc_dtor(void) { printf("GCC destructor called\n"); } int main() { puts("In main"); TestCtorsDtors local(3); printf("ca=%i, cb=%i\n", my_ca, my_cb); printf("a=%i, b=%i\n", my_a, my_b); printf("c=%i, d=%i\n", my_c, my_d); puts("Exiting main"); //exit(0); // while(1){} }
виведе засобами SemiHosting наступне:
GCC constructor calledЯкщо викликати exit() явно, не буде "Destructing object No: 3", якщо його взагалі не викликати, не буде "Destructing object No: 2" і "Destructing object No: 1", якщо не передбачити виклик ClearAll -- не буде "GCC destructor called". Якщо не викликати CallAll -- взагалі буде чорт зна що, тому його завжди викликаємо!
Constructing object No: 1
Constructing object No: 2
In main
Constructing object No: 3
ca=5, cb=5
a=7, b=7
c=12, d=12
Exiting main
Destructing object No: 3
GCC destructor called
Destructing object No: 2
Destructing object No: 1
Маленьке (платформо, себто -- компіляторо-середовище-конфіграційно-і-т.-д. залежне) дослідження показало, що за виклик конструкторів статичних об'єктів, як і очікувалося згідно EABI, відповідає виклик call_constructors(&__init_array_start, &__init_array_end), аналогічно, за виклик деструкторів GCC (тобто, функцій з відповідним атрибутом), відповідає call_constructors(&__fini_array_start, &__fini_array_end).
Детальніше див:
- "Creating a C Library", зокрема -- подробиці про crt0.o, crtbegin.o, crtend.o, crti.o, and crtn.o (стаття орієнтована на x86!).
- "Calling Global Constructors" --- містить окрему главу про ARM ABI (Application Binary Interface).
- "The C Runtime Initialization, crt0.o", глава з книги "Howto: Porting newlib, A Simple Guide".
- "How exactly does __attribute__((constructor)) work?" -- детальне обговорення на stackoverflow.
- "understanding the __libc_init_array" -- теж із stackoverflow, таке собі, але хай буде.
- "Linkers & loaders." -- фундаментальна книга.
new, new[], delete, delete[]
Так як malloc() i free() у нас вже є, природно для виділення та звільнення пам'яті скористатися саме ними. Тут все просто. Складність в іншому. Стандарт вимагає від цих операторів доволі складної поведінки(*), крім того, їх є багато різновидів (**):
void* operator new (std::size_t size)
void* operator new[] (std::size_t size)
"Звичайний" оператор, у двох варіантах -- для однієї змінної і для масиву. У випадку неможливості виділити пам'ять, повинен кинути виключення std::bad_alloc.
void* operator new (std::size_t size, const std::nothrow_t& nothrow_value)
void* operator new[] (std::size_t size, const std::nothrow_t& nothrow_value)
Оператор, що повертає нульовий вказівник, коли не може виділити пам'ять.
void* operator new (std::size_t size, void* ptr)
void* operator new[] (std::size_t size, void* ptr)
Placement new --- просто повертає вказівник. Використовується для конструювання об'єкта у потрібному місці пам'яті. Не може бути перевизначеним.
void operator delete (void* ptr)
void operator delete[] (void* ptr)
Звільняє пам'ять, виділену відповідним new. Повинен нормально приймати нульовий вказівник.
void operator delete (void* ptr, const std::nothrow_t& nothrow_constant)
void operator delete[] (void* ptr, const std::nothrow_t& nothrow_constant)
По суті, те ж. Ніколи не викликається при знищенні об'єктів, що вже існують. Потрібен відповідним виразам new(***), якщо пам'ять виділити вдалося, але конструктор завершився аварійно -- виключенням.
void operator delete (void* ptr, void* voidptr2)
void operator delete[] (void* ptr, void* voidptr2)
Не робить нічого -- такий собі placement delete, напарник placement new. Як і попередній, nothrow delete, ніколи не викликається delete виразом. Не може бути перевизначеним.
С++14 визначає ще два варіанти як для однієї змінної, так і для масивів:
void operator delete (void* ptr, std::size_t size)
void operator delete[] (void* ptr, std::size_t size)
void operator delete (void* ptr, std::size_t size, const std::nothrow_t& nothrow_constant)
void operator delete[] (void* ptr, std::size_t size, const std::nothrow_t& nothrow_constant)
Вони можуть використовуватися в деяких хитрих оптимізаціях. Отримують розмір, використаний при виділенні, по замовчуванню просто викликають відповідний "звичайний" delete. На них надалі не відволікатимемося.
На додачу до перерахованих операторів, слід також визначити константу nothrow.
Під час реалізації їх для мікроконтролерів, традиційно постає кілька виборів, які стосуються того, наскільки детально та точно відтворювати поведінку, що вимагається стандартом.
- Як має поводитися "звичайний" new, якщо виключення не підтримуються -- як nothrow new, просто повернувши нуль за недостачі пам'яті, чи якось по іншому?
- Чи відтворювати роботу із new_handler?
Більш відповідну стандарту реалізацію можна отримати, підключивши libsupc++ або libcxxrt (повчально глянути код реалізації їх у цих бібліотеках).
Отож, код може виглядати якось так (де макрос USE_RTTI_AND_EXCEPTIONS визначає, чи є підтримка виключень):
void* operator new (std::size_t size, const std::nothrow_t& nothrow_value) { return malloc(size); } void* operator new[] (std::size_t size, const std::nothrow_t& nothrow_value) { return ::operator new(size, nothrow_value); } void* operator new (std::size_t size) { void *ptr = ::operator new(size, std::nothrow); if(!ptr) { #ifdef USE_RTTI_AND_EXCEPTIONS throw std::bad_alloc(); #else std::terminate(); #endif } return ptr; } void* operator new[] (std::size_t size) { return ::operator new(size); } void operator delete(void * ptr) { free(ptr); } void operator delete[](void * ptr) { ::operator delete(ptr); } void operator delete (void* ptr, const std::nothrow_t& nothrow_constant) { ::operator delete(ptr); } void operator delete[] (void* ptr, const std::nothrow_t& nothrow_constant) { ::operator delete(ptr); } namespace std { const nothrow_t nothrow; }
Реалізація на OSDev, як на мене, все ж, непристойно мінімалістична.
___________
(*) Наприклад, якщо пам'яті немає, new спершу повинен викликати функцію, встановлену викликом set_new_handler(), яка може спробувати надати більше пам'яті, і лише якщо це не вдалося і обробник повернув нуль -- кидати виключення. Уявляєте, які перспективи відкриваються? Виявити, що пам'ять кінчилася, вивести повідомлення для користувача -- доставити ще пару мікросхем пам'яті, ініціалізувати її і продовжити. :-)
(**) Взагалі кажучи, специфікація виключень для С++98/03 і С++11 цих операторів відрізняється, і її треба вказувати (див., наприклад, тут), але для наших цілей робити це буде надмірним буквоїдством.
(***) MyClass *p = new MyClass; -- це new-вираз, в процесі його роботи викликається оператор new, який виділяє пам'ять, потім викликається конструктор (для масиву -- конструктор викликається для кожного елемента). Якщо відбувся збій -- викликається "симетричний" delete ("звичайний" delete, nothrow delete, placement delete). Аналогічно (тільки в зворотному порядку) працює вираз delete, викликаючи спершу деструктор, потім "звичайний" оператор delete -- nothrow delete чи placement delete ніколи так не викликаються, див. у тексті.
Виключення
Виключення -- штука дуже корисна. С++-ники, особливо старшої школи, люблять побурчати про їх затратність, однак а) ця затратність не така вже й обов'язкова та неуникна -- їх можна реалізувати без сповільнення коду, хоч і не буду вдаватися тут в порівняння різних способів реалізації виключень, б) молодші (не казатимемо -- сучасніші ;-) мови --- Java, Python, C#, просто не залишають вибору, завжди користуючись виключеннями -- щось їхні розробники знали, правда?
Однак, вони, все ж, достатньо громіздкі, як для скромних мікроконтролерів. В більшості випадків краще обходитися без них.
Це досягається просто --- компілятору передається опція "-fno-exceptions" (див. попередній пост на тему, як її додати в CoIDE). При цьому код, який користується throw, try, catch, перестане компілюватися.
На жаль, не залежно від цієї опції, навіть найпростіша С++ програма не лінкуватиметься, якщо в скрипті лінкера не буде секції .ARM.exidx, із символами __exidx_start та __exidx_end. (От без .ARM.extab можна тоді обійтися, але хай буде). Так як скрипт лінкера, який надає CoIDE 2.0, вже їх містить, залишається вписати "-fno-exceptions" (або безпосередньо, або натиснувши на вкладці Configuration->Compile->Advanced setting->"Disable exceptions") і все.
Однак, виключення іноді таки потрібні. Зацитую один діалог в коментах із stackoverflow. Користувач питається, що він робить не так, що у нього не працюють виключення, під запитанням наступний діалог:
Very few embedded toolchains support exceptions properly, if at all. In my opinion, using exceptions in embedded software is a bad idea. – Igor Skochinsky Aug 28 '12 at 8:21
@IgorSkochinsky I knew someone was going to say that. Codesourcery's documentation clearly states support for C++ exceptions. I know my requirements and I know that using exceptions is the right decision for my project, but that's irrelevant to the question at hand. – Verax Aug 28 '12 at 9:49Іншими словами, як С++ FAQ часто наголошує, "One size does not fit all".
Нотатка. Під час першої спроби зробити описане нижче, працездатності добитися не вдалося -- десь в глибині коду обробки виключень, функція, що шукає щось-там у таблицях, створених компілятором (get_eit_entry()) просто зависала, очевидно, наштовхуючись на пошкоджені дані. Витративши прірву часу, толку не дійшов. Потім створив порожній проект, і все запрацювало...
Підтримка виключень вимагає співпраці трьох різних компонент --- компілятора, який генерує необхідний код із синтаксичних конструкцій try, catch, throw, тощо; код часу виконання (runtime) C++, який, в даному випадку, служить посередником із третім рівнем, бібліотекою розкрутки стеку (unwind library).
Самостійно, на коліні, написати підтримку виключень не вдасться. Для GCC підтримкою виключень та RTTI (про яку ще буде мова) займається libsupc++, при тому, libgcc відповідає за розкрутку стеку. Існує альтернатива -- libcxxrt, незалежна реалізація ABI/runtime-підтримки для GCC. Так як libsupc++ є, фактично, частиною GCC, почнемо з неї. Про libcxxrt поговоримо окремо.
Задачу підтримки виключень можна розбити на дві (сильно не рівні!) частини. Перша -- щоб код, який користується try-catch хоча б компілювався і нормально працював, поки виключень немає. Друга --- власне, повна підтримка генерації та перехоплення виключень.
Якщо забрати "-fno-exceptions", або й, для гарантії, додати "-fexceptions", компілюватися конструкція try{}catch(){} все рівно відмовиться, цього разу лінкер скаржитиметься на відсутність, тобто "undefined reference to" __cxa_allocate_exception, __cxa_throw і т.д. --- десяток подібних функцій.
Як зовсім-зовсім ерзац, надавши мінімальні заглушки в ролі цих функцій, можна добитися, щоб код із try{}catch(){} компілювався, але, все рівно, виключення не працюватимуть, програма падатиме за спроби тієї чи іншої маніпуляції ними.Визначені ці функції, виклик яких вставляє компілятор, в libsupc++ (звичайно, libcxxrt їх теж реалізовує). Тому, вказавши лінкеру підключати цю бібліотеку (Configuration->Linker->Linked Libraries->Add), код скомпілювати можна.
Тепер виконання програми, як за наявності виключень, так і коли їх не було, працює успішно. Правда, розмір прошивки зріз на багато десятків кілобайт -- самі вирішуйте, чи воно того вартує. LTO та -Os трішки покращують ситуацію, звичайно.
Для прикладу, розглянемо просту програму:
#include <cstdio> #include <stm32f10x.h> class myException {}; int main() { try{ throw myException(); } catch(myException &e){ puts("We have myException"); } catch(...){ puts("Unknown exception"); } }
Працює вона, як і очікується. Подивимося, як підтримка виключень впливає на розмір коду.
Для порівняння візьмемо наступну програму:
#include <cstdio> #include <stm32f10x.h> class myException {}; int main() { puts("We have myException"); puts("Unknown exception"); }
Особливого сенсу у ній немає, вивід тих же текстових стрічок призначений частково компенсувати суттєво різну логіку програми -- якщо puts() не викликати, її тіло не буде присутнім в другій програмі, що спотворить результати -- зменшивши програму не тільки на розмір коду підтримки виключень, але й на розмір puts(), те ж стосується текстових стрічок, хоч вони і маленькі.
Порівняємо розміри (GCC 4.8):
Код із виключеннями | Код без виключень | |||||||
---|---|---|---|---|---|---|---|---|
text | data | bss | Разом | text | data | bss | Разом | |
O0 | 61876 | 2212 | 2184 | 66272 | 13308 | 2148 | 116 | 15572 |
Os | 61108 | 2212 | 2184 | 65504 | 12596 | 2148 | 116 | 14860 |
Os, LTO | 61080 | 2188 | 2184 | 65452 | 8332 | 2124 | 108 | 10564 |
Отож, сам факт використання виключень (із libsupc++) збільшив розмір прошивки на 50 Кб, а потребу в оперативній пам'яті -- на 2 Кб, це із всього наявних 128Кб і 8Кб, відповідно, на контролері, з яким експериментуємо! Думайте добре, чи воно справді потрібно.
В теорії, із використанням libcxxrt, витрати мають бути суттєво меншими. Однак, його використання вимагає певних зусиль (одне тільки бажання цієї бібліотеки мати 16Кб буферів для аварійного виділення пам'яті чого вартує!), йому буде присвячено окремий пост. Крім того, що найгірше, поки коректної роботи виключень в цій реалізації, для STM32, мені не вдалося...
Література по реалізації виключень
Розбираючись із проблемами, що загадково виникли і загадково пропали, а потім -- ще раз, борючись із libcxxrt, при спробі підключити виключення, довелося вникати в те, як вони реалізовані.
Виявилося, що інформація в Інтернеті доволі суперечлива. Справа в тому, що виключення x86/x86-64 та виключення ARM (до яких належать і ARM Cortex M) обробляються компілятором по різному. Наприклад, для x86 GCC використовує секцію .eh_frame (див. System V ABI, для i686-elf-gcc, x86_64-elf-gcc, та інших ELF платформ), а для ARM використовуються .ARM.extab та .ARM.exidx (див. "Exception Handling ABI for the ARM® Architecture" -- EHABI). Крім того, трапляється просто застаріла інформація -- версії компілятора до 4.5 використовували секцію .gcc_except_table, та потребували явний виклик __register_frame() для реєстрації секції eh_frame.
Наприклад, описане ось тут "How to enable C++ exception support for an embedded newlib PowerPC target" з одного боку застаріле, з іншого, хоча для x86 поведінка схожа, для ARM --- зовсім інша. Ось тут ближче: "GCC arm-none-eabi (Codesourcery) and C++ Exceptions", але .eh_frame -- з іншої опери!
Отож, де читати?
Найбільш авторитетним є, звичайно, офіційний документ від розробника ARM-ів, "Exception Handling ABI for the ARM® Architecture":
This document describes the exception handling component of the Application Binary Interface (ABI) for the ARM architecture. It covers the exception handling model, encoding in relocatable ELF files, language-independent unwinding, and C++-specific aspects.
Документ корисний, якщо потрібно написати свою реалізацію (або розібратися, чому не працює чужа), але складний та вимагає глибокого знання нюансів роботи компілятора.
Короткий огляд механізму саме для ARM можна знайти тут: "Stack frame unwinding on ARM" та "unwinding on ARM". Короткий опис того, як Visual Studio та GCC реалізовують підтримку часу виконання --- таблиці віртуальних функцій, RTTI, виключення, можна почитати тут: "Compiler Internals: Exceptions and RTTI" (шукайте ARM EABI!).
Загальний механізм обробки виключень GCC описано в наступній серії постів: ".eh_frame", ".eh_frame_hdr" (зокрема, розказується про старий і новий підходи), ".gcc_except_table", однак вони стосуються механізмів, що використовують .eh_frame, тому для нашого випадку не дуже підходять -- можуть бути корисні хіба для загального розвитку.
Високорівнева обробка виключень для платформ такого типу описана тут: "Itanium C++ ABI: Exception Handling", згадка про Itanium трохи збиває з пантелику, воно стосується далеко не тільки Ітаніумів, зокрема, ARM - теж! Табличка функцій, що беруть участь у цьому, є тут: "libc++abi Specification", а детальний опис тих, що стосуються розкручування стеку, тут: "Interface Definitions for libgcc_s" із "Linux Standard Base Core Specification" чи тут: "Unwind Library Interface" (платформи, про які мова, трохи відрізняються -- про це треба пам'ятати, але ради немає).
Корисно також буде подивитися реалізацію, наприклад тут (чи будь-які сорци GCC) та в libcxxrt, яка реалізовує те ж ABI.
Список літератури
- "ELF for the ARM® Architecture", ARM IHI 0044E, current through ABI release 2.09, 30th November 2012. Зокрема, містить опис секцій. Так як такі файли мають дурну звичку пропадати і переміщатися, ось дзеркало.
- "Exception Handling ABI for the ARM® Architecture", ARM IHI 0038A (current through ABI r2.09), 25th January 2007, reissued 30th November 2012. Дзеркало.
- Також існує "референтна" реалізація обробки виключень, EHEGI. Знайти її на офіційному сайті мені не вдалося... Ось посилання на варіант 2.07, із стороннього сайту.
- Деякі загальні принципи: "Exception Handling in LLVM"
- "Stack frame unwinding on ARM" та "unwinding on ARM".
- "Compiler Internals: Exceptions and RTTI"
- "Itanium C++ ABI: Exception Handling"
- Решта посилань в попередніх абзацах --- щоб не копіювати намарно.
RTTI
Так само, як і з виключеннями, через накладні витрати, цю можливість, в більшості випадків, варто просто відключити --- опцією "-fno-rtti" (в CoIDE навіть є спеціальна кнопка Configuration->Compile->Advanced setting->"Disable generation of information about every class with virtual functions for use by the C++ run-time type identification features").
Але тоді не буде і dynamic_cast. Іноді це погано. (На контролерах -- рідко, але теж буває).
Для підтримки RTTI нам потрібна все та ж libsupc++ (або libcxxrt) -- без неї лінкер скаржитиметься на відсутність __dynamic_cast, typeinfo for <...>, vtable for __cxxabiv1::__si_class_type_info і т.д.. Після того як її додано в список бібліотек, все успішно працює, тільки див. вище про зростання розміру коду!
Приклад. Код:
#include <cstdio> #include <typeinfo> #include <stm32f10x.h> class Base { public: virtual void fn() = 0; }; class Derived1 : public Base { public: virtual void fn(){ puts("I'm derived-one");}; }; class Derived2 : public Base { public: virtual void fn(){ puts("I'm derived-two");}; }; int main() { Derived1 d1; Base *pb = &d1; Derived1 *pd1 = dynamic_cast<Derived1 *>(pb); if(pd1) { puts("Successfully casted - as expected"); } else { puts("Cast failed - unexpected!"); } Derived2 *pd2 = dynamic_cast<Derived2 *>(pb); if(pd2) { puts("Successfully casted - unexpected!"); } else { puts("Cast failed - as expected"); } puts(typeid(int).name()); puts(typeid(int*).name()); puts(typeid(d1).name()); puts(typeid(pb).name()); if(pb) puts(typeid(*pb).name()); puts(typeid(pd1).name()); if(pd1) puts(typeid(pd1).name()); puts(typeid(pd2).name()); if(pd2) puts(typeid(*pd2).name()); //exit(0); // while(1){} }
Виведе:
Successfully casted - as expected
Cast failed - as expected
i
Pi
8Derived1
P4Base
8Derived1
P8Derived1
P8Derived1
P8Derived2
Successfully casted - as expected
Cast failed - as expected
i
Pi
8Derived1
P4Base
8Derived1
P8Derived1
P8Derived1
P8Derived2
Крім того, потрібно надати компілятору функції для реакції на помилки.
__cxa_bad_cast()
Викликається ця функція, коли компілятор хоче кинути виключення bad_cast, для того, щоб не вставляти кожного разу код генерації цього виключення. Якщо написати:
Derived1 d1; Derived2& d2 = dynamic_cast<Derived2&>(d1);
Де d1 i d2 не є нащадками один одного, лінкер скаже: "undefined reference to `__cxa_bad_cast'". Реалізація __cxa_bad_cast може бути тривіальною:
extern "C" void* __cxa_bad_cast() { throw std::bad_cast(); }
за умови, що виключення підтримуються, або якоюсь такою, якщо ні:
extern "C" void* __cxa_bad_cast() { std::terminate(); }
__cxa_bad_typeid()
Аналогічно до __cxa_bad_cast(), коли компілятору потрібно кинути виключення std::bad_typeid, компілятор викличе __cxa_bad_typeid().
Наприклад, вона може стати потрібною в ситуації:
class Base { public: virtual void f(); }; int main() { Base* p = 0; try { puts( typeid(*p).name() ); } catch(const std::bad_typeid& e) { puts( e.what() ); } }
Реалізація теж може бути тривіальною -- кидати виключення, якщо вони є:
extern "C" const std::type_info& __cxa_bad_typeid() { throw std::bad_cast(); }
або викликати terminate(), якщо ні:
extern "C" const std::type_info& __cxa_bad_typeid() { std::terminate(); }
Увага! Зазвичай її створювати не потрібно -- вона не має сенсу без підтримки RTTI, а код підтримки, зазвичай, її вже теж містить.
Реакція на виклик чисто віртуальних чи видалених функцій
Компілятор хоче знати, що робити у випадку виклику чисто віртуальної чи видаленої функції (зробити такий виклик нетривіально, але, як то кажуть, захист від дурня не рятує від винахідливого дурня). Йому слід надати "void __cxa_pure_virtual(void)" для чисто віртуальних та "void __cxa_deleted_virtual(void)" для видалених.
Реалізація може бути тривіальною:
#include <exception> // містить оголошення std::terminate extern "C" void __cxa_pure_virtual(void) { // Можна повідомити про помилку, через USART, чи засобами semihosting // Наприклад: // puts("Pure virtual function called"); std::terminate(); } extern "C" void __cxa_deleted_virtual(void) { // Можна повідомити про помилку, через USART, чи засобами semihosting // Наприклад: // puts("Deleted virtual function called"); std::terminate(); }
Іноді -- коли не використовується libstdc++, також може бути потрібним надати реалізацію terminate():
void std::terminate() { abort(); }
Хоча, в даному випадку, напевне, простіше зразу abort() викликати, але terminate() може і ще десь знадобитися. (Зауважте, правило не додавати нічого до простору імен std ми не порушуємо --- надаємо реалізацію!)
Gcc-4.8/4.9, які використовуються для написання цих постів, не дають скомпілюватися типовому коду, що викликає чисто віртуальну функцію:
class Base { public: virtual void fn() = 0; Base(){ fn(); }; }; class Derived1 : public Base { public: virtual void fn(){ puts("I'm derived-one");}; }; Derived1 d1;
скаржачись: "undefined reference to `Base::fn()'", хоча змусити їх можна:
class Base { public: virtual void fn() = 0; Base(); void foo(){ fn(); } }; Base::Base() { foo(); } class Derived1 : public Base { public: virtual void fn(){ puts("I'm derived-one");}; }; Derived1 d1; // <=========
В момент конструювання d1 відбудеться виклик чисто віртуальної функції.
libsupc++ надає їх реалізацію у вигляді "слабких" символів -- дозволяючи замінити на власні, тому якщо ви її і так використовуєте, самому реалізовувати ці функції не обов'язково, але можна.
- ""Pure Virtual Function Called": An Explanation"
- "Where do “pure virtual function call” crashes come from?"
Локальні статичні змінні
Локальні статичні змінні ініціалізуються в момент першого виклику функції, де вони визначені. Іноді для цього здійснюється виклик конструктора. Якщо ту ж саму функцію викличуть із різних потоків одночасно --- буде біда. Тому GCC захищає таку ініціалізацію викликами спеціальних функцій, реалізуючи такий собі інтерфейс мютекса. Якщо такі змінні в програмі існують -- потрібно надати реалізацію цих функцій, __cxa_guard_acquire(), __cxa_guard_release (), __cxa_guard_abort (). Або заборонити їх виклик ключем "-fno-threadsafe-statics", або відповідною кнопкою в середовищі, Configuration->Compile->Advanced setting->"Do not emit the extra code to use the routines specified in the C++ ABI for thread-safe initialization of local statics".
Наприклад, навіть такий код може не скопілюватися (може і скомпілюватися, якщо компілятор зможе відоптимізувати так, що виклик конструктора не знадобиться):
class Constr { double field; public: Constr():field(5.1){} }; void stat_var() { static Constr c; }
скаржачись, що: "undefined reference to `__cxa_guard_acquire'" та "undefined reference to `__cxa_guard_release'".
Коректно реалізувати їх не так просто -- слід використовувати атомарні операції, які дозволяють прочитати і встановити змінну за раз (test-and-set примітиви), або за допомогою асемблерних вставок, або так званих Compiler Intrinsics, які дозволяють вставляти їх, використовуючи високорівневий код. Однак, якщо вас проблема багатопоточності (поки) не дуже хвилює, можна надати просто заглушки, які справжнього захисту не надають, однак дозволяють коду компілюватися:
#include <cxxabi.h> using __cxxabiv1::__guard; int __cxa_guard_acquire(__guard *g) { return !*(char *)(g); }; void __cxa_guard_release (__guard *g) { *(char *)g = 1; }; void __cxa_guard_abort (__guard *) {};
Якщо потрібна більш коректна реалізація, можна скористатися засобами ексклюзивного доступу, наприклад команди LDREX/STREX чи відповідні інтринсики (див. главу "5.7 EXCLUSIVE ACCESSES" та додаток "G.5 CMSIS INTRINSIC FUNCTIONS", із Joseph Yiu, "The Definitive Guide to the ARM® Cortex-M3", Second Edition.)
Також корисним може бути ознайомитися із вмістом guard.cc із libcxxrt. Там вони, правда, реалізовані із використанням атомарних операцій GCC, крім того, реалізація оптимізована -- а значить складніша, але, в принципі, цілком доступна розумінню.
Альтернативна коротка розповідь про цю ініціалізацію є, наприклад, тут: "Static locals and threadsafety in g++".
Також корисним може бути ознайомитися із вмістом guard.cc із libcxxrt. Там вони, правда, реалізовані із використанням атомарних операцій GCC, крім того, реалізація оптимізована -- а значить складніша, але, в принципі, цілком доступна розумінню.
Альтернативна коротка розповідь про цю ініціалізацію є, наприклад, тут: "Static locals and threadsafety in g++".
__cxa_atexit та __cxa_finalize
Щодо __cxa_atexit і __aeabi_atexit -- мені не вдалося створити ситуацію, коли їх потрібно створювати самостійно (всілякі atexit (fnExit1) -- не діють). Тому обмежуся хіба посиланнями:
- "C++ ABI for the ARM® Architecture" із описом
- Виклад загальних принципів та приклад реалізації на OSDev.
- Реалізація від libcxxrt: cxa_atexit.c та cxa_finalize.c.
C++11
Засоби С++11 можна використовувати цілком вільно. Гарантії не дам -- не проводив систематичної перевірки, але за винятком __cxa_deleted_virtual(), згаданої вище, жоден із використаних засобів не вимагав спеціальної підтримки часу виконання (підтримки мовних засобів, того типу, що є в libcxxrt, а не повної libstdc++).
Щоб дозволити підтримку цього стандарту, слід передати компілятору "-std=c++11", в CoIDE це можна зробити, вибравши відповідний пункт в налаштуваннях компілятора: "Configuration->Compile->Advanced setting->Specify the C++ standard".
Стандартна бібліотека С++
Обходитися без стандартної бібліотеки, як мінімум -- незручно. Однак, її використання може привести до сильного роздування коду, тому вимагає певної акуратності.
Почну із підсумку:
- Більшість засобів, зокрема -- STL, можна успішно використовувати.
- Створення і знищення багатьох об'єктів іноді приводить до вичерпання пам'яті --- навіть тоді, коли всі створені раніше об'єкти вже знищено, спроба створити ще один, скажімо, vector<> все рівно приводить до збою через неможливість виділити пам'ять. Поки не розбирався в причинах до найглибших подробиць, але так виглядає, це пов'язано із певною жадністю бібліотеки до пам'яті -- потягом оптимізувати виділення, зберігаючи їх в пулах. Можу помилятися...
Значна частина стандартної бібліотеки (зокрема, її сучасніша частина), скажімо --- переважна більшість коду STL, знаходиться в шаблонах, тому майже не вимагає runtime-підтримки. Однак, невпорядковані контейнери С++11 (unordered_XXX), стрічки, потоки вводу-виводу, містять багато коду, тому вимагають підключення libstdc++. Бібліотека ця відносно громіздка -- варто добре зважувати плюси та мінуси її використання.
Також, існує більш компактна реалізація, uClibc++, про неї поговоримо окремо. (Не плутати із libcxxrt, яка реалізовує лише підтримку мовних засобів, для libstdc++ чи когось іншого -- окрема тема).
Отож, якщо підключити libstdc++ --- передати лінкеру прапорець "-lstdc++", (Configuration->Linker->Linked Libraries->Add), то буде повна підтримка всього-всього, але прошивка може вийти надміру роздутою і просто не вміститися у флеш-пам'ять. Якщо не підключати, скористатися асоціативними контейнерами чи std::string не вдасться -- простіше, певне, буде написати свій, ніж надати все потрібне. Проте, написавши пару функцій замість тих, що надаються бібліотекою, алгоритмами, вектором, та іншими послідовними стандартними контейнерами можна буде користуватися вільно.
Подивимося детальніше.
std::terminate()
Важлива для стандартної бібліотеки функція, яка вже згадувалася вище. Якщо бібліотека libstdc++ не використовується, потрібно її надати. Зауважте, якщо цю функцію визначатиме і ваша програма і бібліотека, буде конфлікт -- тому, або одне, або друге. Це ж стосується й самопальних реалізації інших функцій з простору імен std, згаданих нижче.
void std::terminate() { abort(); }
vector<>
Без libstdc++ лінкер скаржитиметься на відсутність std::__throw_length_error (потрібна власне вектору) та std:: __throw_bad_alloc (потрібна алокатору по замовчуванню), функцій, які компілятор використовує для кидання відповідних виключень, як і згадані вище ф-ції типу __cxa_bad_cast(). Також буде потрібна константа nothrow.
Надаємо їх (макрос MRT_USE_EXCEPTIONS керує використанням виключень --- його роль очевидна з коду):
namespace std { const nothrow_t nothrow; void __throw_length_error(char const* str) { #ifdef MRT_USE_EXCEPTIONS throw std::length_error(str); #else puts(str); std::terminate(); #endif } void __throw_bad_alloc() { #ifdef MRT_USE_EXCEPTIONS throw std::bad_alloc(); #else std::terminate(); #endif } }
Все, вектором можна повноцінно користуватися. Звичайно, я не випробовував всіх методів -- котрийсь із них може потребувати коду із libstdc++, але типовий комплект конструкторів, деструкторів, звертання за індексом, методів size(), front(), back(), push_back(), clear(), swap(), resize(), begin(), end() та shrink_to_fit(), data(), emplace_back() із С++11, тощо, працюватиме зразу. Звичайно, такі речі залежать від реалізації і для інших компіляторів можуть не працювати!
Увага! Пам'ятайте, що вектор, вичерпавши резерв пам'яті -- capacity, виділяє вдвічі більшу величину. На контролері, із його ліченими кілобайтами, це може стати фатальним. Reserve() вам у поміч.
Ніякої додаткової підтримки, крім тієї, що для вектора, не потребують.
Більшість алгоритмів не вимагають додаткової підтримки чи наявності libstdc++. Всі я (поки) не випробовував, але типові операції проблем не створюють.
Як мінімум, pair<> та numeric_limits<> -- працюють успішно.
На жаль, такі важливі речі, як асоціативні контейнери (set, map, multiset, multimap, unordered_set, unordered_map, unordered_multiset, unordered_multimap), стрічки, потоки вводу-виводу вимагають занадто громіздкої підтримки, щоб можна було надати її самостійно. Тобто, доведеться або компонуватися із libstdc++, або скористатися альтернативною реалізацією стандартної бібліотеки С++, наприклад uClibc++.
Експериментуйте, що тут ще скажеш.
Систематично я це питання зараз не розглядатиму, лише тезово перерахую.
1. Всі стандартні контейнери допускають заміну алокаторів.
2. GCC надає ряд готових алокаторів, окрім алокатора по замовчуванню. Їх список можна побачити, наприклад, тут: "libstdc++ manual, Memory, Chapter 6: Utilities".
Використання може виглядати якось так:
3. Поведінкою malloc із glibc можна керувати за допомогою політик, котрі встановлюються викликом функції mallopt() (з <malloc.h>). Функція ця, звичайно, нестандартна -- нічого подібного не описує ні стандарт С, ні POSIX, чисто Лінуксівське розширення. Однак, іноді вона корисна. Використовується якось так:
де M_TOP_PAD -- величина запасу при запиті на пам'ять (викликом sbrk(), наприклад) встановлюється рівною нулю.
Подробиці див., наприклад, тут: "mallopt - set memory allocation parameters".
Ця тема пасувала б до іншого поста, але тут -- під час боротьби із libstdc++, вона найбільш актуальна, тому пишу тут.
Вільна пам'ять знаходиться між вершиною стеку та кінцем купи. Строго визначити, скільки там ще, складно (можна, звичайно, скористатися трюком типу створення змінної на стеку та визначення її адреси, але і з тим є свої складності). Однак, якщо нагадати собі код нашої реалізації sbrk(), вона покладається на символ __StackLimit, створений лінкером. Його адреса -- границя вершини стеку. Тому, різниця між цією величиною та вершиною купи і буде оцінкою доступної пам'яті.
Значить, можна попросити і sbrk(), і наші оператори new(), виводити відладочну інформацію про те, які блоки алокуються, скільки ще залишилося, тощо:
Так як компілятори до 4.9 не підтримують нестандартну itoa, а тягнути цілий printf сюди не хочеться, скористався реалізацією itoa, наведеною тут. Звичайно, розмір буфера в 33 символи взято зі стелі, (щоб вистачило для 32-бітного числа, записаного в двійковій системі), але їх вистачає.
Аналогічно, new та delete (лише змінені, із повного списку у відповідному розділі):
Тоді, в процесі роботи програми, спостерігатиметься щось типу:
5832 before, 1048 request, 4784 left
4784 before, 688 request, 4096 left
Allocating: 32, addr: 20000d48
Allocating: 512, addr: 20000d70
Allocating: 32, addr: 20000f78
4096 before, 4096 request, 0 left
Allocating: 512, addr: 20000fa0
Deleting at: 20000fa0
Deleting at: 20000f78
Allocating: 32, addr: 20000f78
Allocating: 512, addr: 20000fa0
Allocating: 32, addr: 200011a8
Allocating: 512, addr: 200011d0
Deleting at: 200011d0
Deleting at: 200011a8
Deleting at: 20000fa0
Deleting at: 20000f78
Deleting at: 20000d70
Deleting at: 20000d48
Зауважте, ще до першого виклику new відпрацьовує sbrk().
Систематичного дослідження не проводитиму, але, на загал, можу сказати, використання тієї частини бібліотеки, що не покладається на libstdc++ не приводило до якогось помітного роздування розміру. Наприклад, додавання до порожньої програми наступного коду:
Збільшує розмір прошивки на 208 байт, без збільшення розміру статичних змінних чи BSS. (Використовувалося -Os i LTO). Зауважте, тих 208 включають одноразові витрати --- код __throw_length_error, __throw_bad_alloc, terminate (тому наголошено, що додається до порожньої програми), код конструктора по замовчуванню, код push_back та код звертання за індексом (хоча, значну частину того коду оптимізатор і викинув). Скажімо, якщо додати рядки:
Ще один приклад, в демонстраційному проекті є файл app/main_test_stl.cpp, який мінімально тестує різні компоненти STL. Подивимося на розмір (пам'ятаючи, що без описаної вище ризикованої модифікації sbrk(), прошивка, що використовує libstdc++ -- неробоча, їй бракує RAM):
Виключення та RTTI заборонені, крім додаткового рядка.
Скачати проект можна тут. Він є розширенням проекту із цього поста. Зокрема, файл конфігурації, runtime_config.h, містить крім макросів конфігурації C runtime, описаних там, також конфігурацію C++ runtime. Правда, організація її трішки інша і, в більшості випадків, не вимагає ручного втручання:
Як бачимо, з одного боку, повноцінна підтримка С++ на голому залізі вимагає великого труда. З іншого боку, відмовившись від виключень та RTTI, за мінімальних зусиль можна отримати всю (ну, всю решту :-) потужність С++ -- класи, шаблони, тощо, та значну частину стандартної бібліотеки. При чому -- без особливих накладних витрат чи проблем із продуктивністю.
Враховуючи, що вникати аж так детально, як описано в цьому пості, не є необхідним, щоб ефективно використовувати С++ на мікроконтролерах, думаю, переваги які надає його використання часто переважають недоліки.
Увага! Пам'ятайте, що вектор, вичерпавши резерв пам'яті -- capacity, виділяє вдвічі більшу величину. На контролері, із його ліченими кілобайтами, це може стати фатальним. Reserve() вам у поміч.
deque<>, list<>, forward_list<>, array<>, адаптери <stack>, queue<>, priority_queue<>
Ніякої додаткової підтримки, крім тієї, що для вектора, не потребують.
Ітератори та Алгоритми
Більшість алгоритмів не вимагають додаткової підтримки чи наявності libstdc++. Всі я (поки) не випробовував, але типові операції проблем не створюють.
Допоміжні інструменти
Як мінімум, pair<> та numeric_limits<> -- працюють успішно.
libstdc++
На жаль, такі важливі речі, як асоціативні контейнери (set, map, multiset, multimap, unordered_set, unordered_map, unordered_multiset, unordered_multimap), стрічки, потоки вводу-виводу вимагають занадто громіздкої підтримки, щоб можна було надати її самостійно. Тобто, доведеться або компонуватися із libstdc++, або скористатися альтернативною реалізацією стандартної бібліотеки С++, наприклад uClibc++.
Зрозуміло, що коли підключена libstdc++, надані нами __throw_length_error і т.д., слід прибратим (можна ifdef-ом, звичайно). Крім того, за використання LTO (Link-Time Optimization), слід буде прибрати і реалізацію abort() із нашого syscalls.c.Після додавання цієї бібліотеки до списку бібліотек, використання всіх засобів із стандартної бібліотеки успішно компілюватиметься. Однак, при тому виникає декілька проблем. (Не став проводити систематичних замірів, тому наведені числа -- по порядку величини).
- Розмір прошивки (без оптимізації) зростає на півсотні кілобайт. З оптимізацією та ввімкненим LTO, щоправда -- лише на два десятки, що може бути цілком прийнятним. (Може не бути).
- Потреба в оперативній пам'яті зростає на 2.5Кб! Ось це вже, фактично, часто неприйнятно...
- Найгірше, різко змінюється потреба в оперативній пам'яті -- одним викликом до sbrk починає виділятися більше пам'яті, ніж є в системі. (При чому, якось так цікаво виглядало під час моїх експериментів -- якщо ж, 3684 байт, воно захоче 3688, якщо звільнити, щоб було 3704, воно захоче 3708 -- намагається виділити рівно на 4 байти більше ніж є. На чому зупиниться -- точно не знаю, ймовірно, хоче цілу сторінку, 4Кб = 4096 байт, але стільки вільної оперативної пам'яті при підключеному libstdc++ (із 8Кб, наявних на контролері) мені виділити не вдалося, тому точно не знаю. Тобто, той самий код створення стеку (stack<double> s2;) без компонування із цією бібліотекою зразу запитує в системи (за допомогою sbrk()) порядку 1600 байт, а якщо нічого не змінювати, лише додати -lstdc++ до командного рядка лінкера -- різко починає виділяти "стільки, скільки є, + ще 4" -- поза 3600 байт. На жаль, поки не розібрався, чого це воно. Заміна алокаторів при створенні контейнерів, заміна політики malloc, ігри з опціями оптимізації нічого не змінили. Ймовірно, причина в реалізації отих __throw_length_error, __throw_bad_alloc, terminate, можливо, вони якісь буфери аварійні підтримують, (статичні?), абощо --- експерименти показують, що як тільки вони приліновуються, зразу і все -- ще до першої алокації. Хоча, реалізація ніби тривіальна...
- Знайшов нестандартне, потенційно небезпечне, але (поки) -- робоче рішення. Якщо дозволити sbrk(), при запиті більшої кількості пам'яті, ніж є, виділяти всю наявну і не більше, виділення пам'яті (яке у нас крутиться навколо malloc) працюватиме коректно. Зокрема, якщо спробувати виділити більше, ніж було реально виділено, але менше, ніж було запитано -- коректно поверне нуль. Зрозуміло, що такий підхід дуже небезпечний -- sbrk() так поводитися не дозволяється, тому все залежить від ніяк не зафіксованих нюансів роботи malloc. Однак, він дозволяє скористатися засобами стандартної бібліотеки С++ тоді, коли без нього використати її не вдасться. (Область корисності все рівно лімітована -- складні програми так не напишеш, пам'яті забракне.). Використовуйте на свій страх і ризик -- це дуже тонкий лід.
- Використання потоків, як iostream, так і stringstream, збільшує розмір прошивки мінімум на сотню кілобайт, потребу в оперативній пам'яті - теж на немало кілобайт, тому є повністю неможливим -- стільки пам'яті просто немає. Та й не вартує воно того...
Експериментуйте, що тут ще скажеш.
Керування виділенням пам'яті
Систематично я це питання зараз не розглядатиму, лише тезово перерахую.
1. Всі стандартні контейнери допускають заміну алокаторів.
2. GCC надає ряд готових алокаторів, окрім алокатора по замовчуванню. Їх список можна побачити, наприклад, тут: "libstdc++ manual, Memory, Chapter 6: Utilities".
Використання може виглядати якось так:
#include <ext/mt_allocator.h> ...................... stack<double, __gnu_cxx::__mt_alloc<double> > s2
3. Поведінкою malloc із glibc можна керувати за допомогою політик, котрі встановлюються викликом функції mallopt() (з <malloc.h>). Функція ця, звичайно, нестандартна -- нічого подібного не описує ні стандарт С, ні POSIX, чисто Лінуксівське розширення. Однак, іноді вона корисна. Використовується якось так:
if(! mallopt(M_TOP_PAD, 0) ) puts("Error setting malloc option");
де M_TOP_PAD -- величина запасу при запиті на пам'ять (викликом sbrk(), наприклад) встановлюється рівною нулю.
Подробиці див., наприклад, тут: "mallopt - set memory allocation parameters".
Дослідження доступної пам'яті
Ця тема пасувала б до іншого поста, але тут -- під час боротьби із libstdc++, вона найбільш актуальна, тому пишу тут.
Вільна пам'ять знаходиться між вершиною стеку та кінцем купи. Строго визначити, скільки там ще, складно (можна, звичайно, скористатися трюком типу створення змінної на стеку та визначення її адреси, але і з тим є свої складності). Однак, якщо нагадати собі код нашої реалізації sbrk(), вона покладається на символ __StackLimit, створений лінкером. Його адреса -- границя вершини стеку. Тому, різниця між цією величиною та вершиною купи і буде оцінкою доступної пам'яті.
Значить, можна попросити і sbrk(), і наші оператори new(), виводити відладочну інформацію про те, які блоки алокуються, скільки ще залишилося, тощо:
__attribute__ ((used)) caddr_t _sbrk ( int incr ) { static unsigned char *heap = NULL; unsigned char *prev_heap; extern unsigned char __StackLimit; /* Value set by linker */ if (heap == NULL) { heap = (unsigned char *)&_end; } prev_heap = heap; #ifdef PRINT_MEMORY_LEFT_AFTER_SBRK int left = &__StackLimit - heap; char buffer [33]; //! itoa is not in any C/C++ standard! //! But printf here would be overkill, and its only for debug itoa (left,buffer,10); strcat(buffer," before, "); _write(1, buffer, strlen(buffer)); itoa (incr,buffer,10); strcat(buffer," request, "); _write(1, buffer, strlen(buffer)); itoa (left-incr,buffer,10); strcat(buffer," left\n"); _write(1, buffer, strlen(buffer)); #endif if (heap + incr > &__StackLimit) { #ifdef PRINT_MESSAGE_ABOUT_HEAP_AND_STACK_COLLISION char err_msg[] = "Heap and stack collision\n"; _write (1, err_msg, strlen(err_msg)); // Потребує <string.h> #endif errno=ENOMEM; // Потребує <errno.h> return (void *) -1; } heap += incr; return (caddr_t) prev_heap; }
Так як компілятори до 4.9 не підтримують нестандартну itoa, а тягнути цілий printf сюди не хочеться, скористався реалізацією itoa, наведеною тут. Звичайно, розмір буфера в 33 символи взято зі стелі, (щоб вистачило для 32-бітного числа, записаного в двійковій системі), але їх вистачає.
Аналогічно, new та delete (лише змінені, із повного списку у відповідному розділі):
void* operator new(std::size_t size, const std::nothrow_t& nothrow_value) { #ifdef PRINT_NEW_ALLOCATIONS printf("Allocating: %i, ", size); void* ptr = malloc(size); printf(", addr: %08x\n", ptr); return ptr; #else return malloc(size); #endif } void* operator new(std::size_t size) { void *ptr = ::operator new(size, std::nothrow); if (!ptr) { #ifdef MRT_USE_EXCEPTIONS throw std::bad_alloc(); #else #ifdef PRINT_NEW_ALLOCATIONS printf("bad_alloc(): %i\n", size); #endif std::terminate(); #endif } return ptr; } void operator delete(void * ptr) { #ifdef PRINT_NEW_ALLOCATIONS printf("Deleting at: %08x\n", ptr); #endif free(ptr); }
Тоді, в процесі роботи програми, спостерігатиметься щось типу:
5832 before, 1048 request, 4784 left
4784 before, 688 request, 4096 left
Allocating: 32, addr: 20000d48
Allocating: 512, addr: 20000d70
Allocating: 32, addr: 20000f78
4096 before, 4096 request, 0 left
Allocating: 512, addr: 20000fa0
Deleting at: 20000fa0
Deleting at: 20000f78
Allocating: 32, addr: 20000f78
Allocating: 512, addr: 20000fa0
Allocating: 32, addr: 200011a8
Allocating: 512, addr: 200011d0
Deleting at: 200011d0
Deleting at: 200011a8
Deleting at: 20000fa0
Deleting at: 20000f78
Deleting at: 20000d70
Deleting at: 20000d48
Зауважте, ще до першого виклику new відпрацьовує sbrk().
Розміри коду із використанням STL
Систематичного дослідження не проводитиму, але, на загал, можу сказати, використання тієї частини бібліотеки, що не покладається на libstdc++ не приводило до якогось помітного роздування розміру. Наприклад, додавання до порожньої програми наступного коду:
vector<int> testv; testv.push_back(1); testv.push_back(2); testv.push_back(3); testv.push_back(3); for(size_t i=0; i< testv.size(); ++i) { printf("%i, ", testv[i]); } puts("");
Збільшує розмір прошивки на 208 байт, без збільшення розміру статичних змінних чи BSS. (Використовувалося -Os i LTO). Зауважте, тих 208 включають одноразові витрати --- код __throw_length_error, __throw_bad_alloc, terminate (тому наголошено, що додається до порожньої програми), код конструктора по замовчуванню, код push_back та код звертання за індексом (хоча, значну частину того коду оптимізатор і викинув). Скажімо, якщо додати рядки:
testv.pop_back(); printf("%i, ", testv.back());Розмір зросте аж на 16 байт, з яких 8 -- виклик printf(), який зроблено, щоб оптимізатор код не викинув.
Ще один приклад, в демонстраційному проекті є файл app/main_test_stl.cpp, який мінімально тестує різні компоненти STL. Подивимося на розмір (пам'ятаючи, що без описаної вище ризикованої модифікації sbrk(), прошивка, що використовує libstdc++ -- неробоча, їй бракує RAM):
Без libstdc++ | З libstdc++ | |||||||
---|---|---|---|---|---|---|---|---|
text | data | bss | Разом | text | data | bss | Разом | |
O0 | 62740 | 2244 | 116 | 65100 | 109836 | 2256 | 2208 | 114300 |
Os | 31948 | 2244 | 116 | 34308 | 79164 | 2256 | 2208 | 83628 |
Os, LTO | 31644 | 2220 | 116 | 33980 | 78944 | 2236 | 2208 | 83388 |
Дозволивши виключення, | 79676 | 2236 | 2200 | 84112 | ||||
(Os, LTO) |
Виключення та RTTI заборонені, крім додаткового рядка.
Нотатки щодо демонстраційного проекту
Скачати проект можна тут. Він є розширенням проекту із цього поста. Зокрема, файл конфігурації, runtime_config.h, містить крім макросів конфігурації C runtime, описаних там, також конфігурацію C++ runtime. Правда, організація її трішки інша і, в більшості випадків, не вимагає ручного втручання:
- Якщо оголошено службовий макрос компілятора, __GXX_RTTI, значить RTTI ввімкнуто, оголошується свій макрос, MRT_USE_RTTI.
- Аналогічно, якщо є __EXCEPTIONS -- виключення дозволені, оголошується MRT_USE_EXCEPTIONS.
- Якщо оголошено один із MRT_USE_RTTI та MRT_USE_EXCEPTIONS, значить має бути libsupc++ або libstdc++, тоді оголошується MRT_USE_LIBCXX, щоб викинути надані нами функції із цієї бібліотеки.
- Єдиним, в нормі -- керованим вручну, є PRINT_NEW_ALLOCATIONS -- чи виводити інформацію про кожне виділення чи звільнення пам'яті.
- main_test_cpp_rt_support.cpp -- головний файл програми, містить main(). Містить різноманітні тести підтримки базових засобів мови.
- main_test_stl.cpp -- альтернативний головний файл програми, містить main(). Демонструє використання STL. Одночасно в програмі може бути лише один main()! Тому обидва ці файли взяті в директиву умовної компіляції, один в "#if 0 .... #endif", інший в "#if 1 .... #endif". Тобто, один компілюється, інший ні. Для заміни -- змініть в одному 0 на 1, в іншому -- 1 на 0. Для практичного використання -- замініть на свою програму. ;-)
- Так як вивід, незалежний від типу, нам недоступний (мова про iostream), а printf() з шаблонами зовсім не дружить, sample_tmpl_print.h містить примітивну реалізацію такого агностичного щодо типів виводу. Його напарник, sample_tmpl_print.cpp -- порожній.
- minimal_runtime_code.cpp -- містить мінімалістичну реалізацію засобів підтримки С++ часу виконання, яка обговорюється вище.
- startup.c -- код ініціалізації (зокрема, виклику конструкторів глобальних змінних) та деініціалізації, про який мова вище.
- runtime_config.h -- як вже згадувалося, конфігурація компіляції.
- syscalls.c -- реалізація системних викликів для стандартної бібліотеки С. (Див. попередній пост, "Стандартна бібліотека C та SemiHosting (на прикладі STM32 і CoIDE)").
Резюме
Як бачимо, з одного боку, повноцінна підтримка С++ на голому залізі вимагає великого труда. З іншого боку, відмовившись від виключень та RTTI, за мінімальних зусиль можна отримати всю (ну, всю решту :-) потужність С++ -- класи, шаблони, тощо, та значну частину стандартної бібліотеки. При чому -- без особливих накладних витрат чи проблем із продуктивністю.
Враховуючи, що вникати аж так детально, як описано в цьому пості, не є необхідним, щоб ефективно використовувати С++ на мікроконтролерах, думаю, переваги які надає його використання часто переважають недоліки.
Додаток --- оригінальний скрипт лінкера CoIDE
З коментарями.
/* Оголошення форматів, що підтримуються */ OUTPUT_FORMAT ("elf32-littlearm", "elf32-bigarm", "elf32-littlearm") /* Карта пам'яті rom -- Read Only Memory (rx -- read/execute), ram -- Random Access Memory (rwx -- read/write/execute) Див. також "Карта пам'яті" в
http://indrekis2.blogspot.com/2012/10/stm32-arm-cortex-m-stmicroelectronics.html Цікаво цей скрипт виглядає для котролерів, наприклад, STM32F4, для яких
RAM не є неперервною. */ MEMORY { rom (rx) : ORIGIN = 0x08000000, LENGTH = 0x00020000 ram (rwx) : ORIGIN = 0x20000000, LENGTH = 0x00002000 } /* Кінець оперативної пам'яті */ _eram = 0x20000000 + 0x00002000; /* Оголошення секцій (сегментів) у пам'яті */ SECTIONS { /* Код програми */ .text : { /* Вказує лінкеру зібрати всі секції .isr_vector.* за цією адресою * Ця інструкція перша, тому вони попадуть на початок Flash (ROM), * де, завдяки SystemInit, процесор і шукатиме таблицю переривань*/ KEEP(*(.isr_vector)) /* Програмний код -- секція .text */ *(.text*) /* Тут часто розташовують секції *(.glue_7) та *(.glue_7t), * потрібні для зв'язку ARM i Thumb інструкцій. * Так як котролер підтримує лише Thumb, не потрібна, * вона закоментована. Детальніше див., наприклад, * http://gcc.gnu.org/ml/gcc-help/2009-03/msg00306.html */ /* Не було в оригінальному скрипті, додано, хоч і закоментоване, мною */ /* *(.glue_7) *(.glue_7t) */ /* Секція конструкторів та деструкторів. * KEEP вказує не видаляти секцію, навіть якщо код на неї не посилається. * Скажімо, у нашому прикладі з ними працює асемблерний код ініціалізації, * лінкер може некоректно їх викинути. */ KEEP(*(.init)) KEEP(*(.fini)) /* .ctors -- згадана в тексті альтернатива до .init */ *crtbegin.o(.ctors) *crtbegin?.o(.ctors) *(EXCLUDE_FILE(*crtend?.o *crtend.o) .ctors) *(SORT(.ctors.*)) *(.ctors) /* .dtors -- згадана в тексті альтернатива до .fini */ *crtbegin.o(.dtors) *crtbegin?.o(.dtors) *(EXCLUDE_FILE(*crtend?.o *crtend.o) .dtors) *(SORT(.dtors.*)) *(.dtors) /* Секція констант */ *(.rodata*) /* Тут в згенерованому скрипті була опечатка --- "eh_fram e" */ /* Взагалі кажучи, в EHABI ARM не використовується --- слід викинути. */ KEEP(*(.eh_frame*)) } > rom /* Інструкція лінкеру всі ці секції помістити в Flash (ROM), * розташування якого задано картою пам'яті. */ .ARM.extab : { /* Інформація для розгортання виключень (unwinding exceptions) */ *(.ARM.extab* .gnu.linkonce.armextab.*) } > rom /* Експортує символ початку секції .ARM.exidx */ __exidx_start = .; .ARM.exidx : { /* Інформація для розгортання виключень (unwinding exceptions) */ *(.ARM.exidx* .gnu.linkonce.armexidx.*) } > rom /* Експортує символ кінця секції .ARM.exidx */ __exidx_end = .; /* Зберігається кінець пам'яті коду */ __etext = .; /* Зберігається початок даних для ініціалізації змінних */ _sidata = __etext; /* Секція .data -- ініціалізованих змінних. * Початкові значення збережено в ROM за адресою _etext, * але самі змінні будуть в RAM, із відповідними адресами! */ .data : AT (__etext) { __data_start__ = .; /* Адреса початку секції -- буде адресою символу _sdata */ _sdata = __data_start__; *(vtable) *(.data*) /* Вирівняти адресу після попередніх секцій на 4 байти */ . = ALIGN(4); /* preinit data */ PROVIDE_HIDDEN (__preinit_array_start = .); KEEP(*(.preinit_array)) PROVIDE_HIDDEN (__preinit_array_end = .); . = ALIGN(4); /* init data */ PROVIDE_HIDDEN (__init_array_start = .); KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array)) PROVIDE_HIDDEN (__init_array_end = .); . = ALIGN(4); /* finit data */ PROVIDE_HIDDEN (__fini_array_start = .); KEEP(*(SORT(.fini_array.*))) KEEP(*(.fini_array)) PROVIDE_HIDDEN (__fini_array_end = .); /* Wtf?! --- секція стосується Java * Див., наприклад, http://www.spinics.net/lists/newbies/msg31396.html */ KEEP(*(.jcr*)) . = ALIGN(4); /* Кінець даних */ __data_end__ = .; /* Адреса кінця секції, з врахуванням вирівнювання, в _edata */ _edata = __data_end__; } > ram /* .bss -- неініціалізовані дані. _sbss і _ebss * аналогічно до _sdata і _edata */ .bss : { . = ALIGN(4); __bss_start__ = .; _sbss = __bss_start__; *(.bss*) *(COMMON) . = ALIGN(4); __bss_end__ = .; _ebss = __bss_end__; } > ram /* Спроба явної підтримки купи --- області, звідки виділяється динамічна * пам'ять. */ .heap (COPY): { __end__ = .; _end = __end__; end = __end__; *(.heap*) __HeapLimit = .; } > ram /* Секція стеку. Використовується для розрахунків розташування веншини стеку та * його розміру. Див. також оригінальний коментар нижче. */ /* .stack_dummy section doesn't contains any symbols. It is only * used for linker to calculate size of stack sections, and assign * values to stack symbols later */ .co_stack (NOLOAD): { /* Стек виівнюємо на границі 8 байт */ . = ALIGN(8); /* Зібрати все, оголошене в коді як .co_stack */ *(.co_stack .co_stack.*) } > ram /* Вершина стеку --- в кінці оперативної пам'яті, * ліміт --- обмежується розміром stack_dummy */ __StackTop = ORIGIN(ram ) + LENGTH(ram ); __StackLimit = __StackTop - SIZEOF(.co_stack); PROVIDE(__stack = __StackTop); /* Перевірка, чи дані + купа + стек не вилізли за межі фізичної * пам'яті. */ ASSERT(__StackLimit >= __HeapLimit, "region ram overflowed with stack") }
Інформацію про скрипти лінкера можна знайти, наприклад, тут:
- Опис на OsDev: "Linker Scripts" та розповідь про GNU LD там же.
- Офіційна документація GNU ld.
- "Linker Scripts" -- приклад, із аналізом.
- Ще приклад, із книги "Embedded Programming with the GNU Toolchain", від Vijay Kumar B.
Література
- Див. посилання вище, у кінці відповідних розділів --- немає сенсу дублювати.
- "C++ ABI for the ARM® Architecture", ARM IHI 0041D, current through ABI release 2.09, 30th November 2012. Дзеркало. Власне, один із найважливіших для цієї теми документів (ABI обробки виключень описується окремим документом -- див. посилання у відповідному розділі ).
- Joseph Yiu, "The Definitive Guide to the ARM® Cortex-M3", Second Edition.
- "EABI Guidelines" --- зокрема, угоди про виклики (calling conventions) для ARM-ів.
- "Embedded Programming with the GNU Toolchain", від Vijay Kumar B.
- "Howto: Porting newlib, A Simple Guide".
- "Compiler Internals: Exceptions and RTTI"
- "Building Bare-Metal ARM Systems with GNU"
- Загальна робота, присвячена С++ на вбудованих системах: "Effective C++ in an Embedded Environment", Scott Meyers (курс лекцій). Курс платний, хоча, з легкістю знаходиться в Інеті.
Про спробу додати обов'язкове обчислення констант до С: https://thephd.dev/constant-integers-are-actually-constant-wow-finally-someones-writing-the-goddamn-paper-%F0%9F%99%84
ВідповістиВидалити