Как стать автором
Обновить

Разработка firmware на С++ словно игра в бисер. Как перестать динамически выделять память и начать жить

Время на прочтение 18 мин
Количество просмотров 12K

C++ is a horrible language. It's made more horrible by the fact that a lot of substandard programmers use it, to the point where it's much much easier to generate total and utter crap with it.

Linus Benedict Torvalds

Собеседование шло уже второй час. Мы наконец-то закончили тягучее и вязкое обсуждение моей скромной персоны, и фокус внимания плавно переполз на предлагаемый мне проект. Самый бойкий из трех моих собеседников со знанием дела и без лишних деталей принялся за его описание. Говорил он быстро и уверенно – явно повторяет весь этот рассказ уже не первый раз. По его словам, работа велась над неким чрезвычайно малым, но очень важным устройством на базе STM32L4. Потребление энергии должно быть сведено к минимуму... USART... SPI... ничего необычного, уже неоднократно слышал подобное. После нескольких убаюкивающих фраз собеседник внезапно подался чуть вперед и, перехватив мой сонный взгляд, не без гордости произнес:

— А firmware мы пишем на C++! – мой будущий коллега заулыбался и откинулся в кресле, ожидая моей реакции на свою провокативную эскападу.

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

— У вас есть какие-то опасения? – поспешил спросить он с искренней озабоченностью в голосе.

Опасения у меня всегда имеются в избытке. Я с ностальгическим умилением вспомнил, как писал первую прошивку на MbedOS для одной из демонстрационных плат. Память тогда закончилась быстрее, чем я успел моргнуть светодиодом второй раз. Эх, опыт, сын ошибок... Надо все же признать, что страшные темные времена, когда о прошивке на «плюсах» никто и не заикался, давно прошли. Все разумные возражения в настоящее время рассыпались в труху и превратились в мифы. Вроде бы...

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

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

— И у нас есть код ревью! – встрепенувшись, поспешили добавить хором двое других невероятно квалифицированных членов команды.

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

IAR

Так уж получилось, что мы впервые встретились на этом проекте. "Ну, это же специальный компилятор для железок", – наивно думал я, – "сработаемся". Не скажу, что я жестоко ошибся и проклял тот день, но использование именно этого компилятора доставляет определенный дискомфорт. Дело в том, что в проекте уже начали внедрение относительно нового стандарта С++17. Я уже потирал потные ладошки, представляя, как перепишу вон то и вот это, как станет невероятно красиво, но IAR может охладить пыл не хуже, чем вид нововоронежской Аленушки.

Новый стандарт реализован для нашего любимого коммерческого компилятора лишь частично, несмотря на все заверения о поддержке всех возможностей новейших стандартов. Например, structured binding declaration совсем не работает, сколько ни уговаривай упрямца. Еще IAR весьма нежен и хрупок, какая-нибудь относительно сложная конструкция может довести его до истерики: компиляция рухнет из-за некой внутренней ошибки. Это самое неприятное, поскольку нет никаких подсказок, по какой причине все так неприятно обернулось. Такие провалы огорчают даже сильнее финала «Игры престолов».

Можно справедливо заметить, что всему виной сложные шаблонные конструкции. Да, но у GCC с пониманием аналогичных шаблонов никогда не было проблем.

SIL

Для некоторых классов устройств существует такое понятие, как стандарты SIL. Safety integrity level – уровень полноты безопасности, способность системы обеспечивать функциональную безопасность.

Проще говоря, если от вашего устройства зависят жизни людей, то при его разработке нужно придерживаться определенных правил. Одно из них – это отсутствие динамического распределения памяти, по крайней мере, после вызова функции main. Думаю, все знают, что в no-OS устройствах динамические аллокации чреваты проблемами, вроде фрагментации памяти и т.п.

Одно только это правило привело ко всем последующим значительным изменениям в коде, о которых речь пойдет ниже.

std::exception

Несмотря на утверждение Герба Саттера, что без исключений C++ уже перестает быть тем языком, который мы знаем и любим, они были беспощадно выпилены. Немудрено, ведь обычный механизм исключений использует динамическое выделение памяти, что недопустимо в нашем проекте.

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

__cxa_allocate_exception

Название у нее уже какое-то нехорошее, и действительно, выделяет память для объекта исключения и делает это весьма неприятным образом прямо в куче. Вполне возможно эту функцию подменить на собственную реализацию и работать со статическим буфером. Если не ошибаюсь, то в руководстве для разработчиков autosar для с++14 так и предлагают делать. Но есть нюансы. Для разных компиляторов реализация может отличаться, нужно точно знать, что делает оригинальная функция, прежде чем грубо вмешиваться в механизм обработки. Проще и безопаснее от исключений отказаться вовсе. Что и было сделано, и соответствующий флаг гордо реет теперь над компилятором! Только вот стандартную библиотеку нужно будет использовать осторожней вдвойне, поскольку пересобрать ее с нужными опциями под IAR возможности нет.

std::vector

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

Все вектора подлежали замене на std::array, но были особо сложные случаи, которые использовали возможности векторов по полной и были очень хитро вплетены в код. Для таких случаев можно написать простой аллокатор, который позволит изобразить вектор из стандартного массива.

template <class T, std::size_t Size>
class StaticArray {
 using ssize_t = int;

public:
 using value_type = T;
 template <class U>
 struct rebind {
   using other = StaticArray<U, Size>;
 };
 StaticArray() = default;
 ~StaticArray() = default;
 template <class U, std::size_t S>
 StaticArray(const StaticArray<U, S>&);

 auto allocate(std::size_t n) -> value_type*;
 auto deallocate(value_type* p, std::size_t n) -> void;
 auto max_size() const -> std::size_t;
};

Ключевые функции, конечно, allocate и deallocate. Передаваемый им параметр n это не размер в байтах, а размер в попугаях, которые хранятся в векторе. Функция max_size используется при проверке вместимости аллокатора и возвращает максимально возможное теоретически число, которое можно передать в функцию allocate.

Тут очевиднейший пример использования аллокатора
std::vector<int, StaticArray<int, 100>> v;
    
v.push_back(1000);
std::cout<<"check size "<<v.size()<<std::endl;
    
v.push_back(2000);
std::cout<<"check size "<<v.size()<<std::endl;

Результат выполнения такой программы (скомпилировано GCC) будет следующий:

max_size() -> 100

max_size() -> 100

allocate(1)

check size 1

max_size() -> 100

max_size() -> 100

allocate(2)

deallocate(1)

check size 2

deallocate(2)

std::shared_ptr

Умные указатели, безусловно, хорошая вещь, но нужная ли в bare metal? Требование безопасности, запрещающее динамическую аллокацию памяти, делает использование умных указателей в этой области крайне сомнительным мероприятием.

Конечно, контролировать управление памятью путем использования кастомных аллокаторов вполне возможно. В стандартной библиотеке есть замечательная функция std::allocate_shared, которая создаст разделяемый объект именно там, где мы укажем. Указать же можно самолепным аллокатором примерно такого вида:

template <class Element, 
          std::size_t Size, 
          class SharedWrapper = Element>
class StaticSharedAllocator { 
 public:
  static constexpr std::size_t kSize = Size;
  using value_type = SharedWrapper;
  using pool_type = StaticPool<Element, kSize>;
  pool_type &pool_;
  using ElementPlaceHolder = pool_type::value_type;

  template <class U>
  struct rebind {
    using other = StaticSharedAllocator<Element, kSize, U>;
  };

  StaticSharedAllocator(pool_type &pool) : pool_{pool} {}
  ~StaticSharedAllocator() = default;
  template <class Other, std::size_t OtherSize>
  StaticSharedAllocator(const StaticSharedAllocator<Other, OtherSize> &other) 
    : pool_{other.pool_} {}

  auto allocate(std::size_t n) -> value_type * {
    static_assert(sizeof(value_type) <= sizeof(ElementPlaceHolder));
    static_assert(alignof(value_type) <= alignof(ElementPlaceHolder));
    static_assert((alignof(ElementPlaceHolder) % alignof(value_type)) == 0u);
  
    return reinterpret_cast<value_type *>(pool_.allocate(n));
  }

  auto deallocate(value_type *p, std::size_t n) -> void {
    pool_.deallocate(reinterpret_cast<value_type *>(p), n);
  }
};

Очевидно, Element – тип целевого объекта, который и должен храниться как разделяемый объект. Size – максимальное число объектов данного типа, которое можно создать через аллокатор. SharedWrapper – это тип объектов, которые будут храниться в контейнере на самом деле!

Конечно, вы знаете, что для работы shared_ptr необходима некоторая дополнительная информация, которую нужно где-то хранить, лучше прямо с целевым объектом вместе. Поэтому для этого аллокатора очень важна структура rebuild. Она используется в недрах стандартной библиотеки, где-то в районе alloc_traits.h, чтобы привести аллокатор к виду, который необходим для работы разделяемого указателя:

using type = typename _Tp::template rebind<_Up>::other;

где _Tp это StaticSharedAllocator<Element, Size>,

_Up это std::_Sp_counted_ptr_inplace<Object, StaticSharedAllocator<Element, Size>, __gnu_cxx::_S_atomic>

К сожалению, это верно только для GCC, в IAR тип будет немного другой, но общий принцип неизменен: нам нужно сохранить немного больше информации, чем содержится в Element. Для простоты тип целевого объекта и расширенный тип должны быть сохранены в шаблонных параметрах. Как вы уже догадались, SharedWrapper и будет расширенным типом, с которым непосредственно работает shared_ptr.

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

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

Еще немного кода для иллюстрации

Сам пул объектов основан на StaticArray аллокаторе. А чего добру пропадать?

template <class Type, size_t Size>
struct StaticPool {
  static constexpr size_t kSize = Size;
  static constexpr size_t kSizeOverhead = 48;
  using value_type = std::aligned_storage_t<sizeof(Type)+kSizeOverhead, 
                                            alignof(std::max_align_t)>;
  StaticArray<value_type, Size> pool_;
  
  auto allocate(std::size_t n) -> value_type * {
    return pool_.allocate(n);
  }
  auto deallocate(value_type *p, std::size_t n) -> void {
    pool_.deallocate(p, n);
  }
};

А теперь небольшой пример, как это все работает вместе:

struct Object {
  int index;
};
constexpr size_t kMaxObjectNumber = 10u;

StaticPool<Object, kMaxObjectNumber> object_pool {};

StaticSharedAllocator<Object, kMaxObjectNumber> object_alloc_ {object_pool};

std::shared_ptr<Object> MakeObject() {
  return std::allocate_shared<Object>(object_alloc_);
}

Все эти изуверства позволили избежать динамического выделения памяти и здесь. Хотя, мне кажется, лучше реорганизовать код так, чтоб не было необходимости использовать shared_ptr вообще. К сожалению, не всегда рефакторинг можно сделать быстро и безболезненно.

std::function

Универсальная полиморфная обертка над функциями или функциональными объектами. Очень удобная штука. Точно была бы полезна в embedded проекте, хотя бы для каких-нибудь функций обратного вызова (callbacks).

Чем мы платим за универсальность?

Во-первых, std::function может использовать динамическую аллокацию памяти.

Небольшой и несколько искусственный пример:

int x[] = {1, 2, 3, 4, 5};
    auto sum = [=] () -> int {
      int sum = x[0];
      for (size_t i = 1u; i < sizeof(x) / sizeof(int); i++) {
        sum += x[i];
      }
      return sum;
    };
    
    std::function<int()> callback = sum; 

Когда элементов массива 5, то размер функции – 20 байт. В этом случае, когда мы присваиваем переменной callback экземпляр нашей лямбда-функции, будет использована динамическая аллокация.

Дело в том, что в классе нашей универсальной обертки содержится небольшой участок памяти (place holder), где может быть определена содержащаяся функция.

Любая функция в С++ может быть определена с помощью двух указателей максимум. Для свободных функций или функторов достаточно одного указателя, если нужно вызвать метод класса, то нужен указатель на объект и смещение внутри класса. Собственно, у нас есть небольшое укромное местечко для пары указателей. Конечно, небольшие функциональные объекты можно хранить прямо на месте этих указателей! Если размер лямбды, например, не позволяет целиком запихать ее туда, то на помощь снова придет динамическая аллокация.

Для GCC

Опции -specs=nano.specs уже не будет хватать для std::function.

Сразу появится сообщения подобного вида:

abort.c:(.text.abort+0xa): undefined reference to _exit

signalr.c:(.text.killr+0xe): undefined reference to _kill

signalr.c:(.text.getpidr+0x0): undefined reference to _getpid

Правильно, ведь пустая функция должна бросать исключение.

Нужна другая опция -specs=nosys.specs, где включены все необходимые заглушки для всяких системных функций.

Соберем небольшую прошивку, чтоб проверить как повлияет включение std::function на потребление памяти различных видов. Прошивка – стандартный пример от ST для подмигивающего светодиода. Изменения в размере секций файла-прошивки в таблице:

Δtext

Δdata

Δbss

67 880

2 496

144

Невооруженным взглядом видно, что секция .text выросла просто фантастически (на 67Кб!). Как одна функция могла сделать такое?

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

Если заглянуть в получившийся elf-файл, то можно увидеть много новых символов. Отсортируем их по размеру и посмотрим на самые жирные.

00000440	cplus_demangle_operators
0000049e	__gxx_personality_v0
000004c4 	d_encoding
000004fe	d_exprlist
00000574	_malloc_r
0000060c	d_print_mod
000007f0	d_type
00000eec	_dtoa_r
00001b36	_svfprintf_r
0000306c	d_print_comp

Много функций с префиксом d_* – функции из файла cp-demangle.c библиотеки libiberty, которая, как я понимаю, встроена в gcc, и не так просто выставить ее за дверь.

Также имеются функции для обработки исключений (bad_function_call, std::unexpected, std::terminate)

_sbrk, malloc, free – функции для работы с динамическим выделением памяти.

Результат ожидаемый – флаги -fno-exceptions и -fno-rtti не спасают.

Внедрим второй подобный функциональный объект в другой единице трансляции:

Δtext

Δdata

Δbss

67992

2504

144

Вторая std::function обошлась не так уж и дорого.

Показательно также то, сколько объектных файлов и из каких библиотек мы используем для этих случаев.

Для случая без std::function список короткий
libc_nano.a
libg_nano.a
libg_nano.a(lib_a-exit.o)
libg_nano.a(lib_a-exit.o) (_global_impure_ptr)
libg_nano.a(lib_a-impure.o)
libg_nano.a(lib_a-init.o)
libg_nano.a(lib_a-memcpy-stub.o)
libg_nano.a(lib_a-memset.o)
libgcc.a
libm.a
libstdc++_nano.a
Для случая с std::function список гораздо длиннее
libc.a
libg.a
libg.a(lib_a-__atexit.o)
libg.a(lib_a-__call_atexit.o)
libg.a(lib_a-__call_atexit.o) (__libc_fini_array)
libg.a(lib_a-__call_atexit.o) (atexit)
libg.a(lib_a-abort.o)
libg.a(lib_a-abort.o) (_exit)
libg.a(lib_a-abort.o) (raise)
libg.a(lib_a-atexit.o)
libg.a(lib_a-callocr.o)
libg.a(lib_a-closer.o)
libg.a(lib_a-closer.o) (_close)
libg.a(lib_a-ctype_.o)
libg.a(lib_a-cxa_atexit.o)
libg.a(lib_a-cxa_atexit.o) (__register_exitproc)
libg.a(lib_a-dtoa.o)
libg.a(lib_a-dtoa.o) (_Balloc)
libg.a(lib_a-dtoa.o) (__aeabi_ddiv)
libg.a(lib_a-exit.o)
libg.a(lib_a-exit.o) (__call_exitprocs)
libg.a(lib_a-exit.o) (_global_impure_ptr)
libg.a(lib_a-fclose.o)
libg.a(lib_a-fflush.o)
libg.a(lib_a-findfp.o)
libg.a(lib_a-findfp.o) (__sread)
libg.a(lib_a-findfp.o) (_fclose_r)
libg.a(lib_a-findfp.o) (_fwalk)
libg.a(lib_a-fini.o)
libg.a(lib_a-fputc.o)
libg.a(lib_a-fputc.o) (__retarget_lock_acquire_recursive)
libg.a(lib_a-fputc.o) (__sinit)
libg.a(lib_a-fputc.o) (_putc_r)
libg.a(lib_a-fputs.o)
libg.a(lib_a-fputs.o) (__sfvwrite_r)
libg.a(lib_a-freer.o)
libg.a(lib_a-fstatr.o)
libg.a(lib_a-fstatr.o) (_fstat)
libg.a(lib_a-fvwrite.o)
libg.a(lib_a-fvwrite.o) (__swsetup_r)
libg.a(lib_a-fvwrite.o) (_fflush_r)
libg.a(lib_a-fvwrite.o) (_free_r)
libg.a(lib_a-fvwrite.o) (_malloc_r)
libg.a(lib_a-fvwrite.o) (_realloc_r)
libg.a(lib_a-fvwrite.o) (memchr)
libg.a(lib_a-fvwrite.o) (memmove)
libg.a(lib_a-fwalk.o)
libg.a(lib_a-fwrite.o)
libg.a(lib_a-impure.o)
libg.a(lib_a-init.o)
libg.a(lib_a-isattyr.o)
libg.a(lib_a-isattyr.o) (_isatty)
libg.a(lib_a-locale.o)
libg.a(lib_a-locale.o) (__ascii_mbtowc)
libg.a(lib_a-locale.o) (__ascii_wctomb)
libg.a(lib_a-locale.o) (_ctype_)
libg.a(lib_a-localeconv.o)
libg.a(lib_a-localeconv.o) (__global_locale)
libg.a(lib_a-lock.o)
libg.a(lib_a-lseekr.o)
libg.a(lib_a-lseekr.o) (_lseek)
libg.a(lib_a-makebuf.o)
libg.a(lib_a-makebuf.o) (_fstat_r)
libg.a(lib_a-makebuf.o) (_isatty_r)
libg.a(lib_a-malloc.o)
libg.a(lib_a-mallocr.o)
libg.a(lib_a-mallocr.o) (__malloc_lock)
libg.a(lib_a-mallocr.o) (_sbrk_r)
libg.a(lib_a-mbtowc_r.o)
libg.a(lib_a-memchr.o)
libg.a(lib_a-memcmp.o)
libg.a(lib_a-memcpy.o)
libg.a(lib_a-memmove.o)
libg.a(lib_a-memset.o)
libg.a(lib_a-mlock.o)
libg.a(lib_a-mprec.o)
libg.a(lib_a-mprec.o) (_calloc_r)
libg.a(lib_a-putc.o)
libg.a(lib_a-putc.o) (__swbuf_r)
libg.a(lib_a-readr.o)
libg.a(lib_a-readr.o) (_read)
libg.a(lib_a-realloc.o)
libg.a(lib_a-reallocr.o)
libg.a(lib_a-reent.o)
libg.a(lib_a-s_frexp.o)
libg.a(lib_a-sbrkr.o)
libg.a(lib_a-sbrkr.o) (_sbrk)
libg.a(lib_a-sbrkr.o) (errno)
libg.a(lib_a-signal.o)
libg.a(lib_a-signal.o) (_kill_r)
libg.a(lib_a-signalr.o)
libg.a(lib_a-signalr.o) (_getpid)
libg.a(lib_a-signalr.o) (_kill)
libg.a(lib_a-sprintf.o)
libg.a(lib_a-sprintf.o) (_svfprintf_r)
libg.a(lib_a-stdio.o)
libg.a(lib_a-stdio.o) (_close_r)
libg.a(lib_a-stdio.o) (_lseek_r)
libg.a(lib_a-stdio.o) (_read_r)
libg.a(lib_a-strcmp.o)
libg.a(lib_a-strlen.o)
libg.a(lib_a-strncmp.o)
libg.a(lib_a-strncpy.o)
libg.a(lib_a-svfiprintf.o)
libg.a(lib_a-svfprintf.o)
libg.a(lib_a-svfprintf.o) (__aeabi_d2iz)
libg.a(lib_a-svfprintf.o) (__aeabi_dcmpeq)
libg.a(lib_a-svfprintf.o) (__aeabi_dcmpun)
libg.a(lib_a-svfprintf.o) (__aeabi_dmul)
libg.a(lib_a-svfprintf.o) (__aeabi_dsub)
libg.a(lib_a-svfprintf.o) (__aeabi_uldivmod)
libg.a(lib_a-svfprintf.o) (__ssprint_r)
libg.a(lib_a-svfprintf.o) (_dtoa_r)
libg.a(lib_a-svfprintf.o) (_localeconv_r)
libg.a(lib_a-svfprintf.o) (frexp)
libg.a(lib_a-svfprintf.o) (strncpy)
libg.a(lib_a-syswrite.o)
libg.a(lib_a-syswrite.o) (_write_r)
libg.a(lib_a-wbuf.o)
libg.a(lib_a-wctomb_r.o)
libg.a(lib_a-writer.o)
libg.a(lib_a-writer.o) (_write)
libg.a(lib_a-wsetup.o)
libg.a(lib_a-wsetup.o) (__smakebuf_r)
libgcc.a
libgcc.a(_aeabi_uldivmod.o)
libgcc.a(_aeabi_uldivmod.o) (__aeabi_ldiv0)
libgcc.a(_aeabi_uldivmod.o) (__udivmoddi4)
libgcc.a(_arm_addsubdf3.o)
libgcc.a(_arm_cmpdf2.o)
libgcc.a(_arm_fixdfsi.o)
libgcc.a(_arm_muldf3.o)
libgcc.a(_arm_muldivdf3.o)
libgcc.a(_arm_unorddf2.o)
libgcc.a(_dvmd_tls.o)
libgcc.a(_udivmoddi4.o)
libgcc.a(libunwind.o)
libgcc.a(pr-support.o)
libgcc.a(unwind-arm.o)
libgcc.a(unwind-arm.o) (__gnu_unwind_execute)
libgcc.a(unwind-arm.o) (restore_core_regs)
libm.a
libnosys.a
libnosys.a(_exit.o)
libnosys.a(close.o)
libnosys.a(fstat.o)
libnosys.a(getpid.o)
libnosys.a(isatty.o)
libnosys.a(kill.o)
libnosys.a(lseek.o)
libnosys.a(read.o)
libnosys.a(sbrk.o)
libnosys.a(write.o)
libstdc++.a
libstdc++.a(atexit_arm.o)
libstdc++.a(atexit_arm.o) (__cxa_atexit)
libstdc++.a(class_type_info.o)
libstdc++.a(cp-demangle.o)
libstdc++.a(cp-demangle.o) (memcmp)
libstdc++.a(cp-demangle.o) (realloc)
libstdc++.a(cp-demangle.o) (sprintf)
libstdc++.a(cp-demangle.o) (strlen)
libstdc++.a(cp-demangle.o) (strncmp)
libstdc++.a(del_op.o)
libstdc++.a(del_ops.o)
libstdc++.a(eh_alloc.o)
libstdc++.a(eh_alloc.o) (std::terminate())
libstdc++.a(eh_alloc.o) (malloc)
libstdc++.a(eh_arm.o)
libstdc++.a(eh_call.o)
libstdc++.a(eh_call.o) (__cxa_get_globals_fast)
libstdc++.a(eh_catch.o)
libstdc++.a(eh_exception.o)
libstdc++.a(eh_exception.o) (operator delete(void*, unsigned int))
libstdc++.a(eh_exception.o) (__cxa_pure_virtual)
libstdc++.a(eh_globals.o)
libstdc++.a(eh_personality.o)
libstdc++.a(eh_term_handler.o)
libstdc++.a(eh_terminate.o)
libstdc++.a(eh_terminate.o) (__cxxabiv1::__terminate_handler)
libstdc++.a(eh_terminate.o) (__cxxabiv1::__unexpected_handler)
libstdc++.a(eh_terminate.o) (__gnu_cxx::__verbose_terminate_handler())
libstdc++.a(eh_terminate.o) (__cxa_begin_catch)
libstdc++.a(eh_terminate.o) (__cxa_call_unexpected)
libstdc++.a(eh_terminate.o) (__cxa_end_cleanup)
libstdc++.a(eh_terminate.o) (__gxx_personality_v0)
libstdc++.a(eh_terminate.o) (abort)
libstdc++.a(eh_throw.o)
libstdc++.a(eh_type.o)
libstdc++.a(eh_unex_handler.o)
libstdc++.a(functional.o)
libstdc++.a(functional.o) (std::exception::~exception())
libstdc++.a(functional.o) (vtable for __cxxabiv1::__si_class_type_info)
libstdc++.a(functional.o) (operator delete(void*))
libstdc++.a(functional.o) (__cxa_allocate_exception)
libstdc++.a(functional.o) (__cxa_throw)
libstdc++.a(pure.o)
libstdc++.a(pure.o) (write)
libstdc++.a(si_class_type_info.o)
libstdc++.a(si_class_type_info.o) (__cxxabiv1::__class_type_info::__do_upcast(__cxxabiv1::__class_type_info const*, void**) const)
libstdc++.a(si_class_type_info.o) (std::type_info::__is_pointer_p() const)
libstdc++.a(tinfo.o)
libstdc++.a(tinfo.o) (strcmp)
libstdc++.a(vterminate.o)
libstdc++.a(vterminate.o) (__cxa_current_exception_type)
libstdc++.a(vterminate.o) (__cxa_demangle)
libstdc++.a(vterminate.o) (fputc)
libstdc++.a(vterminate.o) (fputs)
libstdc++.a(vterminate.o) (fwrite)

А что IAR?

Все устроено немного иначе. Он не требует явного указания спецификации nano или nosys, ему не нужны никакие заглушки. Этот компилятор все знает и сделает все в лучшем виде, не нужно ему мешать.

Δtext

Δro data

Δrw data

2 958

38

548

О, добавилось всего-то каких-то жалких 3Кб кода! Это успех. Фанат GCC во мне заволновался, почему так мало? Смотрим, что же добавил нам IAR.

Добавились символы из двух новых объектных файлов:

dlmalloc.o                                                     1'404                        496

heaptramp0.o                                                     4

Конечно же, появились функции и данные для работы с динамической памятью, как в случае GCC.

Естественно, никаких выделений в куче нет, но IAR приготовился: видно, что он создал структуру gm (global malloc: a malloc_state holds all of the bookkeeping for a space) и некоторые функции для работы с этой структурой.

Объектный файл того юнита, в котором была добавлена функция, тоже ощутимо располнел:

до

main.cpp.obj                                           3'218               412   36'924

после

main.cpp.obj                                           4'746               451   36'964

Файл прибавил более 1Кб. Появилась std::function, ее сопряжение с лямбдой, аллокаторы.

Добавление второго такого функционального объекта в другую единицу трансляции дает нам очередной прирост:

Δtext

Δro data

Δrw data

3 998

82

600

Прибавили более 1Кб. Т.е. каждая новая функция добавляет нам по килобайту кода в каждой единице трансляции. Это не слишком помогает экономить: в проекте не один и не два колбэка, больше десятка наберется. Хорошо, что большинство таких функций имеют сигнатуру void(*)(void) или void(*)(uint8_t *, int), мы можем быстро накидать свою реализацию std::function без особых проблем. Что я и сделал.

Убедившись, что моя примитивная реализация function работает и не требует много памяти, перенес ее в проект, заменив все std::function, до которых смог дотянуться. С чувством выполненного долга я отправился на набережную любоваться закатом.

Дома меня поджидало письмо от коллеги, преисполненное благодарности. Он писал, что благодаря отказу от богомерзких std::function проект сильно схуднул, мы все молодцы! Сочившееся из меня самодовольство брызнуло во все стороны. Прилагался также классический рекламно-наглядный график до-после, вопивший об уменьшении размера отладочной версии прошивки аж на 30 процентов. В абсолютных величинах цифра была еще страшнее, это, на минуточку, целых 150 килобайт! Что-о-о-о? Улыбка довольного кота медленно отделилась от лица и стремительным домкратом полетела вниз, пробивая перекрытия. В коде просто нет столько колбэков, чтоб хоть как-то можно было оправдать этот странный феномен. В чем дело?

Смотря на сонное спокойствие темной улицы, раскинувшейся внизу, я твердо решил, что не сомкну глаз, пока не отыщу ответ. Проснувшись утром, в первую очередь сравнил два разных elf-файла: до и после замены std::function. Тут все стало очевидно!

В одном забытом богом и кем-то из разработчиков заголовочном файле были такие строчки:

using Handler = std::function<void()>;
static auto global_handlers = std::pair<Handler, Handler> {};

И это в заголовочном файле. Насколько я смог понять, эти функции использовались для создания бледного подобия shared_ptr, увеличивая и уменьшая счетчик ссылок на ресурс в конструкторе и деструкторе соответственно. В некотором роде такой подход даже работал, только для каждой единицы трансляции отдельно. Впрочем, это почти нигде уже не использовалось, но за каждое включение заголовочного файла приходилось платить примерно одним килобайтом памяти. А включений было предостаточно. Да, в релизной версии почти все вырезалось, но в отладочной оптимизация выключена и компилятор честно создавал все эти бесполезные объекты.

Понятно, чего хотел добиться неизвестный мне автор, и это вполне могло получиться. Начиная с 17-го стандарта, в заголовочном файле можно разместить некие глобальные объекты, которые будут видны и в других единицах трансляции. Достаточно вместо static написать inline. Это работает даже для IAR. Впрочем, я не стал изменять себе и просто все убрал.

Вот тут я все же не удержатся от объяснения очевидных вещей

Если у вас несколько единиц трансляции и создание глобального объекта вынесено в заголовочный файл, то при сборке проекта вы неизбежно получите ошибку multiple definition. Ежели добавить static, как сделал неизвестный мне разработчик, то все пройдет гладко, но в итоге будет занято несколько участков памяти и от глобальности ничего не останется.

Давайте же наглядно продемонстрируем как можно получить несколько одинаковых символов в финальном файле. Ну, если кто-то еще сомневается, конечно.

// a.h

#pragma once

int a();

// a.cpp

#include "a.h"

#include "c.hpp"

int a() { return cglob * 2; }

// b.h

#pragma once

int b();

// b.cpp

#include "b.h"

#include "c.hpp"

int b() { return cglob * 4; }

// main.cpp

#include "a.h"

#include "b.h"

int main() { return a() + b(); }

// c.hpp

#pragma once

int c_glob = 0;

Пробуем собрать наш небольшой и бесполезный проект.

$ g++ a.cpp b.cpp main.cpp -o test

/usr/lib/gcc/x8664-pc-cygwin/10/../../../../x8664-pc-cygwin/bin/ld: /tmp/cccXOcPm.o:b.cpp:(.bss+0x0): повторное определение «cglob»; /tmp/ccjo1M9W.o:a.cpp:(.bss+0x0): здесь первое определение

collect2: ошибка: выполнение ld завершилось с кодом возврата 1

Неожиданно получаем ошибку. Так, теперь меняем содержимое файла c.hpp:

static int c_glob = 0;

Вот теперь все собирается! Полюбуемся на символы:

$ objdump.exe -t test.exe | grep glob | c++filt.exe

[ 48](sec  7)(fl 0x00)(ty   0)(scl   3) (nx 0) 0x0000000000000000 c_glob

[ 65](sec  7)(fl 0x00)(ty   0)(scl   3) (nx 0) 0x0000000000000010 c_glob

Вот и второй лишний символ, что и требовалось доказать.

А ежели изменить c.hpp таким образом:

inline int c_glob = 0;

Объект c_glob будет единственным, все единицы трансляции будут ссылаться на один и тот же объект.

Вывод будет весьма банален: нужно понимать, что делаешь... и соответствовать стандартам SIL!

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

Всем спасибо, всем удачи!

Теги:
Хабы:
+48
Комментарии 87
Комментарии Комментарии 87

Публикации

Информация

Сайт
hr.auriga.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия