В данной статье я расскажу, как я в течение пяти лет переводил предприятия, на которых работал, с ведения проектов под микроконтроллеры на C на C++ и что из этого вышло (спойлер: все плохо).
Немного о себе
Я начал писать под микроконтроллеры на C, имея лишь школьный опыт работы с Pascal, потом изучил ассемблер и порядка 3 лет потратил на изучение различных архитектуры микроконтроллеров и их периферии. Затем был опыт реальной работы на C# и C++ с параллельным их изучением, который занял несколько лет. После этого периода я вновь и надолго вернулся к программированию микроконтроллеров, уже имея необходимую теоретическую базу для работы над реальными проектам.
Первый год
Я ничего не имел против процедурного стиля C, однако предприятие, на котором началась моя реальная практика над реальными проектами использовало «программирование на Си в объектно-ориентированном стиле». Это выглядело примерно так.
typedef const struct _uart_init {
USART_TypeDef *USARTx;
uint32_t baudrate;
...
} uart_cfg_t;
int uart_init (uart_cfg_t *cfg);
int uart_start_tx (int fd, void *d, uint16_t l);
int uart_tx (int fd, void *d, uint16_t l, uint32_t timeout);
Данный подход имел следующие преимущества:
- код продолжал оставаться кодом на C. Отсюда вытекают следующие достоинства:
- проще контролировать «объекты», поскольку несложно проследить кто и где что вызывает и в какой последовательности (за исключением прерываний, но о них не в этой статье);
- для хранения «указателя на объект» достаточно запомнить возвращенный fd;
- если «объект» был удален, то при попытке его использовать вы получите соответствующую ошибку в возвращаемом значении функции;
- абстракция таких объектов над использовавшимся там HAL-ом позволяла писать настраиваемые под задачу из собственной структуры инициализации объекты (а в случае недостачи функционала HAL-а можно было прятать обращение к регистрам внутри «объектов»).
Минусы:
- если кто-то удалил «объект», а потом создал новый другого типа, то может случиться так, что новый получит fd старого и дальнейшее поведение будет не определено. Данное поведение можно было бы легко изменить ценой небольшого расхода памяти под связанный список вместо использования массива имеющего «ключ-значение» (массив по каждому индексу fd хранил указатель на структуру объекта).
- невозможно было статически разметить память под «глобальные объекты». Так как в большинстве приложений «объекты» создавались единожды и далее не удалялись, то это выглядело как «костыль». Тут можно было бы при создании объекта передавать указатель на его внутреннюю структуру, выделенную статически при компоновке, но это бы еще больше запутало код инициализации и нарушало бы инкапсуляцию.
На вопрос о том, почему не был выбран C++ при формировании всей инфраструктуры, мне отвечали примерно следующее: — «Ну C++ ведет к сильным дополнительным расходам, неконтролируемым расходам памяти, а так же громоздкому исполняемому файлу прошивки». Возможно, они были правы. Ведь в момент начала проектирования был лишь GCC 3.0.5, который не блистал особым дружелюбием к C++ (до сих пор приходится работать с ним для написания программ под QNX6). Не было constexpr и C++11/14, позволяющих создавать глобальные объекты, которые по сути представляли собой данные в .data области, вычисленные на этапе компиляции и методы к ним.
На вопрос, почему бы не писать на регистрах — я получил однозначный ответ, что использование «объектов» позволяет конфигурировать однотипные приложения «за день».
Осознав все это и поняв, что сейчас С++ уже не такой, каким был при GCC 3.0.5 я принялся переписывать основную часть функционала на C++. Для начала работу с аппаратной периферией микроконтроллера, затем периферию внешних устройств. По сути, эта была лишь более удобная оболочка над тем, что имелось на тот момент.
Год второй и третий
Я переписал все необходимое для своих проектов на C++ и продолжал писать новые модули уже сразу на C++. Однако это были всего лишь оболочки над C. Поняв, что я недостаточно использую C++ я начал использовать его сильные стороны: шаблоны, header-only классы, constexpr и прочее. Все шло хорошо.
Год четвертый и пятый
- все объекты глобальные и включают ссылки друг на друга на этапе компиляции (согласно архитектуре проекта);
- всем объектам выделена память на этапе компоновки;
- по объекту класса на каждый пин;
- объект, инкапсулирующий все пины для их инициализации одним методом;
- объект контроля RCC, который инкапсулирует все объекты, которые находятся на аппаратных шинах;
- проект конвертера CAN<->RS485 по протоколу заказчика содержит под 60 объектов;
- в случае, если что-то на так на уровне HAL-а или класса какого-то объекта, то приходится не просто «исправлять проблему», а еще и думать, как ее исправить так, чтобы это исправление работало на всех возможных конфигурациях данного модуля;
- используемые шаблоны и constexpr невозможно просчитать до просмотра map, asm и bin файлов конечной прошивки (или запуска отладки в микроконтроллере);
- в случае ошибки в шаблоне выходит сообщение длиной с треть конфигурации проекта от GCC. Прочитать и понять из него что-то — отдельное достижение.
Итоги
Сейчас я понял следующее:
- использование «универсальных конструкторов модулей» лишь без надобности усложняет программу. Куда проще оказывается поправить регистры конфигурации под новый проект, чем копаться в связях между объектами, а потом еще и в библиотеке HAL-а;
- не стоит бояться использовать C++ опасаясь того, что он «сожрет много памяти» или «будет менее оптимизирован чем C». Нет, это не так. Нужно бояться того, что использование объектов и множество слоев абстракции сделает код не читаемым, а его отладку — героическим подвигом;
- если не использовать ничего «усложняющего», как шаблоны, наследование и прочие манящие прелести C++, то зачем вообще использовать C++? Только ради объектов? А оно того стоит? Причем ради статических глобальных объектов без использования запрещенных на некоторых проектах new/delete?
Подытожив можно сказать, что кажущаяся простота использования C++ оказалась лишь поводом многократно увеличить сложность проекта без какого-то выигрыша по скорости или памяти.
Автор: Вадим