Интригующие возможности С++ 20 для разработчиков встраиваемых систем

Автор оригинала: William G. Wong
  • Перевод

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

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

С++20 – это седьмая итерация С++, которой предшествовали, например, С++17, С++14 и С++11. Каждая итерация добавляла новые функциональные возможности и при этом влияла на пару функций, добавленных ранее. Например, ключевое слово auto С++14.

Прим. перев.:

В С++14 были введены новые правила для ключевого слова auto. Ранее выражения auto a{1, 2, 3}, b{1};были разрешены и обе переменные имели тип initializer_list<int>. В С++14 auto a{1, 2, 3}; приводит к ошибке компиляции, а auto b{1};успешно компилируется, но тип переменной будет int а не initializer_list<int>. Эти правила не распространяются на выражения auto a = {1, 2, 3}, b = {1};, в которых переменные по-прежнему имеют тип initializer_list<int>.

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

Так уж получилось, что в С++20 было добавлено довольно много новых функциональных возможностей. Новые итераторы и поддержка форматирования строк будут полезны с новой библиотекой синхронизации. У всех на слуху оператор трехстороннего сравнения, он же оператор «космический корабль». Как и большинство функциональных возможностей, описание этого оператора выходит за рамки данной статьи, но если кратко, то сравнение типа x < 20, сначала будет преобразовано в x.operator<=>(20) < 0. Таким образом, поддержка сравнения, для обработки операторов типа <, <=, >= и >, может быть реализована с помощью одной или двух операторных функций, а не дюжины. Выражение типа x == yпреобразуется в operator<=>(x, y) == 0.

Прим. перев.:

Более подробную информацию об операторе «космический корабль» смотрите в статье @Viistomin «Новый оператор spaceship (космический корабль) в C++20»

Но перейдём к более интересным вещам и рассмотрим функциональные возможности С++20 которые будут интересны разработчикам встраиваемых систем, а именно:

Константы этапа компиляции

Разработчикам встраиваемых систем нравится возможность делать что-то на этапе компиляции программы, а не на этапе её выполнения. С++11 добавил ключевое слово constexpr позволяющее определять функции, которые вычисляются на этапе компиляции. С++20 расширил эту функциональность, теперь она распространяется и на виртуальные функции. Более того, её можно применять с конструкциями try/catch. Конечно есть ряд исключений.

Новое ключевое слово consteval тесно связано с constexpr что, по сути, делает его альтернативой макросам, которые наряду с константами, определенными через директиву #define, являются проклятием Си и С++.

Сопрограммы

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

Прим. перев.:

Более подробную информацию о сопрограммах и о том для чего они нужны, смотрите в статье @PkXwmpgN «C++20. Coroutines» и в ответе на вопрос, заданный на stackoverflow.

С++20 поддерживает функционал сопрограмм с помощью coroutine_traits и coroutine_handle. Сопрограммы являются стеконезависимыми и сохраняют свое состояние в «куче».  Могут передавать управление и при необходимости предоставлять результат своей работы себе же, когда выполнение сопрограммы возобновляется.

Концепты и ограничения

Концепты и ограничения были экспериментальной функциональной возможностью С++17, а теперь являются стандартной. Поэтому можно предположить, что эксперимент прошел успешно. Если вы надеялись на контракты Ada и SPARK, то это не тот случай, но концепты и ограничения C++20 являются ценными дополнениями.

Плюсом ограничений является то, что они позволяют обнаруживать ошибки на этапе компиляции программы. Ограничения могут накладываться на шаблоны классов, шаблоны функций и обычные функции. Ограничение – это предикат (прим. перев.: утверждение), который проверяется на этапе компиляции программы. Именованный набор таких определений называется концептом. Пример кода, взятый с cppreference.com, показывает синтаксис и семантику этой функциональной возможности:

#include <string>
#include <cstddef>
#include <concepts>
using namespace std::literals; 

// Объявляем концепт "Hashable", которому удовлетворяет
// любой тип 'T' такой, что для значения 'a' типа 'T',
// компилируется выражение std::hash{}(a) и его результат преобразуется в std::size_t
template <typename T>
concept Hashable = requires(T a) {
    { std::hash{}(a) } -> std::convertible_to<std::size_t>;
};
 
struct meow {};
 
template <Hashable T>
void f(T); // Ограниченный шаблон функции С++20
 
// Другие способы применения того же самого ограничение:
// template<typename T>
//    requires Hashable<T>
// void f(T); 
// 
// template <typename T>
// void f(T) requires Hashable<T>; 
 
int main() {
  f("abc"s); // OK, std::string удовлетворяет Hashable
  f(meow{}); // Ошибка: meow не удовлетворяет Hashable
}

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

Модули

Повсеместный #include заменяется модулями. Ключевые слова import и export находятся там, где когда-то находился #include.

Директива #include упростила написание компиляторов. Когда компилятор её встречает он просто выполняет чтение файла, указанного в ней. По сути получается, что включаемый файл как бы является частью оригинального файла. Такой подход зависит от порядка, в котором объединяются файлы. В целом это работает, однако такое решение плохо масштабируется при работе с большими и сложными системами, особенно с учетом всех взаимосвязей, которые могут принести шаблоны и классы С++.

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

Существующие стандартные библиотеки теперь будут поддерживать включение в виде модулей, но для обеспечения обратной совместимости поддержка директивы #include остается. Модули хорошо работают с существующей поддержкой пространства имен.

Я по-прежнему склонен программировать на Ada и SPARK, но новые изменения в C++20 делают его лучшей платформой C++ для разработки безопасного и надежного программного обеспечения. На самом деле мне нужно поработать с новыми функциями C++20, чтобы увидеть, как они влияют на мой стиль программирования. Я старался избегать сопрограмм, поскольку они были нестандартными. Хотя концепты и ограничения будут немного сложнее, они будут более полезны в долгосрочной перспективе.

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

Комментарии 24

    +19
    не совсем понял чем эти фичи с++20 примечательны для разработчиков встраиваемых систем более, чем для буквально любых других разработчиков на с++.
      +3
      «Интригующей возможностью» было бы возвращение произвольного преобразования указателей в constexpr, из-за отмены которого вес embedded вынужден пользоваться адовыми макросами с совершенно нечитаемыми сообщения об ошибках.
      Всё остальное с C++'14 на голом железе на фиг не нужно или непринципиально.
        0
        Железо железу рознь, вон ESP-шки и под 400Мгц бывают, и памяти 512Мб не так уж фантастично.
          0
          Угу и в результате всё тот же ад с макросами для работы с аппаратурой.
          0

          А что это за ситуация с constexpr указателями? Пример или ссылку можете дать почитать?

            +3
            Представьте, что у вас есть структура отображаемая на абсолютные адреса — как её описать на С++?
            Можно было бы, что-то вроде:
            constexpr uint32_t* HW_ADDR = 0x80001234;
            struct PortStruct {
                volatile uint32_t control;
                volatile unit32_t data;
            };
            constexpr HW_PORT* HwPort = (PortStruct *) HW_ADDR;
            // И юзаем hwPort->control = 12345;
            

            Красиво, оптимизируемо и нормальные ошибки, если с типа накосачишь.
            Да? Нет! В С++11 можно было, а с C++14 нельзя потому, что программисты в ногу выстрелят (ха-ха, типа, на плюсах больше нечем в ноги стрелять).
            И возвращается старый добрый макросный ад:
            #define HW_ADDR 0x80001234
            struct PortStruct {
                volatile uint32_t control;
                volatile unit32_t data;
            }
            #define HW_PORT ((PortStruct *) HW_ADDR)
            

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

              –2

              Зачем тут constexpr? В стандартных хедерах полно таких структур, для примера можно все переписать таким образом:


              struct GPIO_TypeDef
              {
                  __IO uint32_t MODER;
                  __IO uint32_t OTYPER;
                  __IO uint32_t OSPEEDR;
              .....
              };
              
              static constexpr uint32_t PERIPH_BASE = 0x40000000;
              static constexpr uint32_t D3_AHB1PERIPH_BASE = PERIPH_BASE + 0x18020000;
              static constexpr uint32_t GPIOD_BASE = D3_AHB1PERIPH_BASE + 0x0C00;
              static const auto GPIOD = (GPIO_TypeDef*)GPIOD_BASE;

              Если теперь написать GPIOD->ODR = 123, то даже для -O0 получим три инструкции, меньше просто некуда...

                +2

                Вообще-то всегда можно было вот так:


                inline PortStruct* HwPort() { return (PortStruct*) 0x80001234; }

                Если компилятор не в состоянии заинлайнить такую простую функцию — проблема явно не в языке.


                А ещё можно сделать вот так:


                extern PortStruct HwPort;

                и расположить эту штуку по нужному адресу линкером.

                  +2

                  Inline непосредственно для инлайнинга уже давно практически бесполезен, нужно использовать всякие attribute((always_inline)), но по сути все верно.

                    +1

                    Inline тут был для включения в заголовочный файл.

                    0
                    А теперь покажите мне эту структуру или хотя бы её адрес (не зглядывая в исходник структуры) под отладчиком? Ой, отладчик макросы не видит и функции не вызывает?
                    Использование линкера жестоко ломает портабельность не говоря уже о том, что вы фактически призываете использовать другой язык программированя (язык описния скриптов линкера).
                      0

                      Лол, о какой портабельностьи вообще речь, когда указывается специфичный для платформы адрес?


                      Что же до отладчиков — то я как видел отладчики, которые умеют вызывать функции — так и запросто могу представить отладчик, который не сможет показать значение constexpr-переменной (потому что сама переменная в бинарник не попала). А надежнее всего с точки зрения отладчика как раз вариант с extern — уж адрес глобальной переменной-то отладчик точно покажет.

                        0
                        Портабельность означает, что это ещё и возможность собрать разными компиляторами. И вы начали юлить и говорить о каких-то воображаемых абстрактных отладчиках, а мы живём в суровой объективной реальности.
                    –1

                    Касательно constexpr Вам уже ответили, я дополню что в конкретном случае constexpr в принципе не применим — его назначение это вычисления времени компиляции, что в принципе не возможно для отображаемых структур (да еще и в кросскомпиляции зачастую), так что ужесточение правил в этом случае показывает логическую ошибку в коде.
                    Еще volatile поля в структурах в c++20 вам код вероятно попортят, особенно если вы используете вложенные структуры. Опять таки ничего криминального в этом нет, наоборот это подчеркивает что volatile не так прост и использовать его следует с умом.

                  0

                  Можно подробнее? Помнится в gcc 6 или 7 я в либе портов передавал в качестве шаблонного параметра (int)GPIOA и т.д., а потом уже пришлось передавать GPIOA_BASE, который не volatile. Соответственно внутри класса сейчас так:


                  static auto _inline_ base() { return (GPIO_TypeDef*)Gpio; }
                  static void _inline_ write(bool data) { base()->BSRR = (0x10000 | data) << pin; }

                  А раньше вместо base() можно было использовать constexpr константу, но по сути изменения минимальны, никаких макросов и непонятных сообщений об ошибках, на генерации кода это также никак не отразилось. Больше ничего на эту тему и не вспомню, при этом C++20 у меня задействуется по-полной...

                    0
                    Куда подробнее — вы, вроде, всё правильно понимаете.
                    В вашем примере вся грязь просто перехала внутрь inline-функций, а количество кода только выросло. «Gpio» это у вас что? Макрос. А там же на самом деле ещё есть «GpioA»,«GpioB» и т.п.
                    Вам этих функций нужно будет по две на каждый порт, а внутри всё равно будут макросы. А порты это очень просто случай, хотя и их тысячи. Там же ещё вложенные однотипные структуры и массивы сруктур, всякие аппаратные очереди, буфера CAN/Ethernet и прочие DMA, никзоуровенвый API OS в конце-концов.
                    А как вы под отладчиком доберётесь до этих структур? Это вообще особая боль.
                    И всё это из-за идиотского запрета на преобразование типов указателей внутри constexpr выражений, который всё равно концептуально ничего не меняет.
                    Получается, что есть (в совершенно любой системе) некоторая частоиспользуемая физическая сущность — структора определённого типа, находящаяся по фиксированну адресу, но коммитет упорно делает вид, что её нет, запрещает описывать средствами языка и все продолжают есть кактус.
                      0

                      Передаю в качестве шаблонного параметра GPIOx_BASE, внутри класса получаю его как Gpio, далее использую base() и при -O0 никакого вызова функции не будет, потому что за inline(с подчерками) скрывается always_inline O2. Более того, можно взять не пин, а значительно более сложный список пинов, тогда write() будет выглядеть так:


                      static void _inline_ write(uint32_t value)
                      {
                          ports_.foreach([=](auto port) __inline__ { GpioT<unbox<port>::gpio, 
                             getPinsMask<unbox<port>::gpio>(pins_)>::write(readWrite<PinShift::Write>(value, 
                             getIndexedPins<unbox<port>::gpio>(pins_))); });
                      }

                      Тут видно 7 вызовов функций, base() — 8-я, она вызывается в самом конце, а readWrite() — это достаточно тяжелая функция вызывающая еще ряд других. Смотрим что будет для простого списка пинов с -O0:


                      PinList<PC4, PC3, PC2> pins;
                      pins.write(5);

                      И получим 8 инcтрукций, никаких вызовов функций здесь нет:


                      movs r3, #5 
                      str.w r3, [r7, #404]
                      ldr.w r3, [r7, #404]
                      lsls r3, r3, #2 
                      and.w r3, r3, #28 
                      orr.w r3, r3, #1835008
                      ldr r2, [pc, #164]
                      str r3, [r2, #24]

                      А вот так всего 3 инструкции:


                      pins.write<5>();
                      
                      // ldr r3, [pc, #168]
                      // ldr r2, [pc, #184]
                      // str r2, [r3, #24]

                      Первый вариант с оптимизацией — две инструкции :)

                  +5
                  По правде говоря даже С++11 временами поддерживается с горем пополам, имеющимися тулчейнами. Бывало такое что вообще фичи были в пространстве имен std::tr1. Так что С++20 для многих это фантазии, к сожалению.
                    –2
                    Где киллер фича которая была у C++ изначально: возможность компилировать в plain C?
                      +1
                      Плюсом ограничений является то, что они позволяют обнаруживать ошибки на этапе компиляции программы.

                      Либо плохо написанная, либо некорректная фраза. Концепты позволяют:


                      1. В некоторых случаях убрать месиво из SFINAE-подобных техник при метапрограммировании.
                      2. Улучшить сообщения от ошибках при инстанциировании шаблонов не теми типами.

                      Никаких новых ошибок на этапе компиляции они обнаружить не позволяют.

                        –2

                        Есть у меня класс таймеров, один на все серии STM32, если я, допустим, хочу разрешить какое-то прерывание, то делаю так:


                        tim.enableInterrupts<TimInt::Break>();

                        При этом на стадии компиляции проверяется есть ли у данного таймера этот Break и проверяется именно при помощи концептов, потому что если использовать static_assert(), то получим ошибку внутри enableInterrupts() и не понятно что именно является ее источником, а с концептом будет ошибка в месте вызова.

                          +2
                          потому что если использовать static_assert(), то получим ошибку внутри enableInterrupts() и не понятно что именно является ее источником

                          Ну так это же и есть


                          Улучшить сообщения от ошибках при инстанциировании шаблонов не теми типами.
                            0

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

                              0

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

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое