Поддержка C++ на avr в gcc

в 7:25, , рубрики: arduino, avr, c++, Программинг микроконтроллеров, стандарты, метки: , ,

Компилятор avrgcc поддерживает C++, однако в его поставку не входит ни стандартная библиотека, ни реализация ABI: служебных функций, вызовы которых вставляет сам компилятор. В результате люди пытаются реализовать те части, которые им нужны, самостоятельно и зачастую делают это не очень хорошо. Например, часто предлагается отстрелить себе ногу определив пустую функцию __cxa_pure_virtual(void) {} или подложить себе грабли, написав заглушки для __cxa_guard_acquire, __cxa_guard_release и __cxa_guard_abort. В данной статье я предлагаю разобраться, чего не хватает для счастья, где это взять или как написать.
Я знаю, что немло людей считает, что C++ на микроконтроллере не нужен. Их я прошу прочитать последний раздел статьи перед тем, как писать комментарии.

Особенности для владельцев arduino

Arduino предоставляет ограниченную поддержку C++. Но, насколько я понимаю, разработчики arduino не любят C++, поэтому в соответсвующий модуль, по просьбам трудящихся, был вставлен первый попавшийся костыль. Им оказался костыль, описанный на avrfreaks, причем без исправлений, указанных в комментариях к топику. Поэтому сперва вам придется от него избавиться. Удалите файлы

  • hardware/arduino/cores/arduino/new.h
  • hardware/arduino/cores/arduino/new.cpp

Или используйте версию, где это уже сделано.

Чисто виртуальные и удаленные методы

Механизм виртуальных методов, как правило, реализуется через vtable. Это не регламентировано стандартом, однако используется во всех компиляторах. Даже если вы объявляете метод чисто виртуальным, то есть не имеющим реализации, в vtable все равно будет отведено место под указатель на этот метод. Это необходимо для того, чтобы у дочерних классов по этому же смещению положить указатель на соответсвующую реализацию. Вместо указателя на отсутствющий метод компилятор записывает в vtable указатель на функцию-заглушку __cxa_pure_virtual. Если кто-то сумеет вызвать чисто виртуальную функцию, то управление перейдет на заглушку и она остановит программу, вместо того, чтобы пытаться исполнить случайный кусок памяти. Обратите внимание, что эта защита обходится почти бесплатно: реализация __cxa_pure_virtual, содержащая единственный вызов, занимает всего 6 байт флеша.
Возникает резонный вопрос, а как вообще можно вызвать чисто виртуальную функцию, если нельзя создать объект абстрактного класса? Создать нельзя, а вызвать можно, если писать странный код:

class B {
public:
    B() {
        // Объект класса C пока не создан, поэтому используется vtable класса B
        // Однако в конструкторе не работает механизм виртуальных функций, поэтому
        // мы не можем вызвать не сущесутвющую функцию virt(), но можем вызвать
        non_virtual();
    }

    void non_virtual() {
        // Это обычная функция и она не знает, что вызвана из конструктора
        // поэтому она выполнит вызов виртуальной функции, причем вызовет
        // реализацию для класса B
        virt();
        // pure virtual method called
        // terminate called without an active exception
        // Аварийный останов (core dumped)
    }

    virtual void virt() = 0;
};

class C : public B{
public:
    virtual void virt() {}
};

int main(int argc, char** argv) {
    C c;
    return 0;
}

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

void __cxa_pure_virtual(void) {
    // We might want to write some diagnostics to uart in this case
    std::terminate();
}

void __cxa_deleted_virtual(void) {
    // We might want to write some diagnostics to uart in this case
    std::terminate();
}

Также вам понадобится реализация std::terminate из стандартной библиотеки. Если очень нужно экономить 2 байта оперативной памяти и 14 байт флеша, можно вызывать и abort() напрямую, однако позже я расскажу, почему std::terminate предпочтительнее.

Статические переменные

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

int counter(int start) {
    static int cnt = start;
    return ++cnt;
}

В C этот код не соберется, так как статическая переменная должна инициализироваться константой, чтобы сразу поместить начальное значение в секцию .data. В C++ это было разрешено, однако не специфицировалось, что произойдет в случае, если два потока попытаются инициализировать переменную одновременно. Многие компиляторы предпочли добавить блокировки и обеспечить потокобезопасность в этом случае, а в C++11 такое поведение стало частью стандарта. Поэтому инициализацию не константным значением статической переменной gcc развернет в следующий код: gcc/cp/decl.c

static <type> guard;
if (!guard.first_byte) {
    if (__cxa_guard_acquire (&guard)) {
        bool flag = false;
        try {
            // Do initialization.
            flag = true; __cxa_guard_release (&guard);
            // Register variable for destruction at end of program.
        } catch {
            if (!flag) __cxa_guard_abort (&guard);
        }
    }
}

где guard — целочисленный тип, достаточного размера чтобы хранить флаг и мьютекс. Просмотр исходников gcc показал, что его оптимизацией заморачивались только на ARM архитектуре:
gcc/config/arm/arm.c

/* The generic C++ ABI says 64-bit (long long).  The EABI says 32-bit.  */
static tree
arm_cxx_guard_type (void)
{
  return TARGET_AAPCS_BASED ? integer_type_node : long_long_integer_type_node;
}

Во всех остальных случаях используется тип по-умолчанию: long_long_integer_type_node. На avr, в зависимости от опции -mint8 он будет либо 64, либо 32 бита. Нам хватит и 16. guard.first_byte, в котором размещается флаг, понимается компилятором как байт с наименьшим адресом: *(reinterpret_cast<char*>(g)). Исключением является платформа ARM, где используется только один бит первого байта.

Как правильно?

Если вам не нужны потокобезопасные статические переменные, то отключите их опцией -fno-threadsafe-statics и компилятор вместо сложных блокировок поставит простую проверку флага. Реализовывать __cxa_guard_* в этом случае не нужно. Но если вы их предоставляете (как это сделано в arduino), то реализация должна обеспечивать корректную работу в случае одновременной инициализации переменной из обычного кода и из прерывания. Другими словами, __cxa_guard_acquire должна блокировать прерывания, а __cxa_guard_release и __cxa_guard_abort должны их возвращать к предыдущему состоянию. В случае использования RTOS я, возможно, готов пожертвовать корректностью в прерываниях, оставив корректность для двух потоков.Корректная реализация должна работать вот так:

namespace {
// guard is an integer type big enough to hold flag and a mutex. 
// By default gcc uses long long int and avr ABI does not change it
// So we have 32 or 64 bits available. Actually, we need 16.

inline char& flag_part(__guard *g) {
    return *(reinterpret_cast<char*>(g));
}

inline uint8_t& sreg_part(__guard *g) {
    return *(reinterpret_cast<uint8_t*>(g) + sizeof(char));
}
}

int __cxa_guard_acquire(__guard *g) {
    uint8_t oldSREG = SREG;
    cli();
    // Initialization of static variable has to be done with blocked interrupts
    // because if this function is called from interrupt and sees that somebody
    // else is already doing initialization it MUST wait until initializations
    // is complete. That's impossible.
    // If you don't want this overhead compile with -fno-threadsafe-statics
    if (flag_part(g)) {
        SREG = oldSREG;
        return false;
    } else {
        sreg_part(g) = oldSREG;
        return true;
    }
}

void __cxa_guard_release (__guard *g) {
    flag_part(g) = 1;
    SREG = sreg_part(g);
}

void __cxa_guard_abort (__guard *g) {
    SREG = sreg_part(g);
}

Сколько стоит

Если вы не используете статические переменные или присваиваете им константные значения — то бесплатно. Если вы указываете флаг -fno-threadsafe-statics, то платите 8 байт оперативной памяти за флаг, и 12 байт флеша на каждую переменную. Если же вы используете потокобезопасную инициализацию, потратите еще 38 байт флеша на каждую переменную и еще 44 на всю программу. Кроме того, на время инициализации статических переменных будут заблокированы прерывания. Но вы же не делаете сложную работу в конструкторах?
Выбор за вами, но в любом случае, если библиотека предоставляет функции __cxa_guard_*, они должны быть реализованы корректно, а не являться той затычкой, которую везде предлагают. А вообще, я рекомендовал бы стараться не использовать статические переменные.

Где взять

abi.h и abi.cpp

operator new и operator delete

Когда речь заходит об операторах new и delete обязательно кто-нибудь скажет, что в микроконтроллерах крайне мало памяти, поэтому динамическая память — непозволительная роскошь. Эти люди не знают, что new и delete — это не только управление динамической памятью. Есть еще placement new, располагающий объект в выделенном программистом буфере. Без него не напишешь любимый разработчиками встроенного ПО кольцевой буфер, через который реализуются очереди сообщений. Ну и если вы так уверены в том, что динамическая память не нужна, то зачем написали реализации для malloc и free? Значит есть задачи, где без них обойтись не получилось.

Типы операторов new и deleteВо-первых, есть operator new выделяющий память под одиночные объекты и есть operator new[], выделяющий память под массивы. Технически они отличаются тем, что new[] запоминает размер массива, чтобы при удалении вызвать деструктор у каждого элемента. Поэтому важно при освобождении памяти использовать парный operator delete или operator delete[].
Во-вторых, каждый из этих операторов, как и любая функция в C++, может быть перегружен. И стандартом определены три варианта:

  1. void* operator new(std::size_t numBytes) throw(std::bad_alloc);

    выделит блок памяти размером numBytes. В случае ошибки кидает исключение std::bad_alloc

  2. void* operator new(std::size_t numBytes, const std::nothrow_t& ) throw();

    выделит блок памяти размером numBytes. В случае ошибки возвращает nullptr

  3. inline void* operator new(std::size_t, void* ptr) throw() {return ptr; }

    placement new, располагает объект там, где сказали. Используется при реализации контейнеров

В arduino, по непонятным причинам, реализован только void* operator new(std::size_t numBytes) throw(std::bad_alloc), причем в случае ошибки он возвращает 0, что приводт к неопределенному поведению программы, так как возвращаемое значение никто не проверяет.
С operator delete все немного хитрее. Есть void* operator delete(std::size_t numBytes) и void* operator delete[](std::size_t numBytes). Вы можете его перегрузить для других параметров, но не можете вызвать эти перегрузки, так как в языке нет соответсвующего синтаксиса. Есть только один случай, когда компилятор вызовет перегруженные версии оператора delete. Представьте, что вы создаете объект в динамической памяти, оператор new успешно выделил память, ее начал заполнять конструктор и кинул исключение. Ваш код еще не получил в свое распоряжение указатель на объект, так что он не может вернуть системе память объекта-неудачника. Поэтому компилятор вынужден сам это сделать вызвав delete. Но что произойдет, если память «была выделена» с помощью placement new? В этом случае нельзя вызывать обычный delete, поэтому, если конструктор бросил исключение, компилятор вызовет перегруженную версию delete с теми же параметрами, с которыми был вызван new. Так что в стандартной библиотеке определено три версии operator delete и три версии operator delete[].

Обработка bad_alloc

Как было сказано выше, наиболее часто используемая версия new обязана кидать исключение, в случае ошибки. Но gcc не поддерживает исключения на avr: их нельзя ни кинуть, ни поймать. Но если их нельзя ловить, то в программе нет ни одной try секции, а значит, если бы исключение было кинуто, то вызвался бы std::terminate. Более того, стандарт C++ позволяет в таком случае (см. 15.5.1) не разматывать стек. Поэтому new может вызвать std::terminate напрямую и это будет соответствовать стандарту.
Не надо ужасаться, что стандартная библиотека возьмет и завершит прошивку! Часто ли вы можете что-то исправить, если произошел bad_alloc? Как правило, ничего. Ваша прошивка не может продолжать корректную работу и слава богу, что она завершится в момент возникновения ошибки. Но если вы знаете, как поправить ситуацию, вы можете воспользоваться nothrow версией оператора new. Посмотрите на это как на безопасный malloc, корректно себя ведущий в случае, если вы не проверяете возвращаемое им значение.

Где взять

В uClibc++ есть полная и корректная реализация new и delete. Правда вместо std::terminate там вызывается abort(). Поэтому я сделал исправленную версию. Заодно туда добавлены списки инициализации, std::move и std::forvard.

std::terminate vs abort()

Согласно документации на avr-libc, функция abort() блокирует все прерывания, после чего впадает в бесконечный цикл. Это не то, чего бы мне хотелось. По двум причинам. Во-первых, она оставляет устройство в опасном состоянии. Представьте, что система управляет нагревательным элементом и программа зациклится в момент, когда он включен. В случае ошибки я хочу перейти в безопасное состояние, установив все выходы платы в 0. Во-вторых, я уже знаю, что все плохо и мне не нужно дожидаться, пока сработает watchdog и перезагрузит систему. Это можно сделать немедленно.
Если прошивка завершается по std::terminate, я могу установить собственный обработчик и выполнить там все необходимые действия. Переопределить abort я не могу: механизм, предусмотренный для этого в unix не работает на avr. Поэтому я лучше потрачу те 2 байта оперативной памяти и 14 байт флеша, которые занимает реализация std::terminate.

Исключения

Исключения — та часть C++ за которую приходится платить много, причем не только в коде, который их использует напрямую, но и в коде, через который может пролететь исключение: компилятор вынужден регистрировать деструктор каждой переменной, создаваемой на стеке. Кроме того, для исключений нужен RTTI и небольшой резервный буфер памяти, чтобы было, где создать std::bad_alloc, когда память кончится. Кроме того, это единственная часть C++, для которой проблематично, хотя и не невозможно, рассчитать время выполнения. Насколько я понимаю, у любого, кто достаточно разбирался, чтобы написать недостающие на AVR функции поддержки работы исключений, пропадало желание это делать. Находится много более важных вещей, которые тоже надо сделать. Поэтому поддержки исключений на AVR в gcc нет и, вполне вероятно, что и не будет.

STL

Я видел много сообщений, что STL на микроконтроллере — это плохо: она раздувает код и, делая сложные вещи простыми, подстрекает ими пользоваться. При этом умалчивается, что в STL есть и такие примитивы, как быстрая сортировка, которая значительно быстрее и компактнее qsort, или бинарный поиск, безопасные версии min и max. Вы правда знаете сакральный способ написать классические алгоритмы эффективнее других программистов? А тогда почему бы не использовать готовый оттестированный алгоритм, который займет столько же места, сколько то, что вам придется написать. За те части STL, которые вы не используете, вы не платите.

Где взять

Используйте uClibc++. У этой библиотеки есть одна особенность: std::map и std::set реализованы поверх вектора, поэтому при вставках и удалениях итераторы инвалидируются. Кроме того, у них иная сложность. В документации автор подробно описывает, почему он так сделал.

Для чего нужен C++

Это тема для отдельной статьи, которую я готов написать, если вам это интересно. Если кратко, то при грамотном использовании C++ позволяет писать столь же эффективные решения, что и решения на C, но при этом получать более читаемый и более безопасный за счет проверок компилятора код. А механизм шаблонов позволяет писать эффективные реализации обобщенных алгоритмов, что проблематично на C. А еще я к нему привык. В любом случае, я крайне прошу воздержаться от дискуссии на эту тему сейчас.

Автор: kibergus

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js