Почему мы не переписали наше IoT-приложение на C ++

Это вторая часть серии статей о том, как мы в Dwelo переписали их программное обеспечение шлюза Интернета вещей на Rust. Сериал начинается здесь, а следующая глава - здесь.

Изначально я собирался написать о том, почему мы в Dwelo не выбрали C ++ для переписывания IoT, но потом я понял, что есть более широкий вопрос, который нужно обсудить. Я хочу поместить это решение в контекст грехов всего нашего исторически плохого кода, в совокупности, как вида. В 80-х и 90-х годах мы использовали свой прометеанский дар C для написания программного обеспечения для каждой встраиваемой системы повсюду. То, что мы могли сделать, не было предела. Но многие вещи, которые мы тогда считали умными, в ретроспективе явно являются ужасными привычками. Эти знакомые ошибки продолжают причинять боль и разрушения десятилетия спустя. Я собираюсь рассказать о некоторых бесспорно ужасных вещах, которые я видел в производственном коде как на C, так и на C ++. Я собираюсь поговорить об ошибках, которые я сделал и видел, а в следующей главе мы обсудим, как простой выбор языка категорически устраняет многие из них, но при этом дает нам достаточно гибкости, чтобы писать наши низкоуровневые программное обеспечение.

Мы могли бы переписать наше приложение для платформы Интернета вещей на C ++. Он поставил все флажки. Мне это было бы знакомо, даже легко. Но это также позволило бы мне или другим слишком легко совершать ошибки. Использование C похоже на использование свечи для освещения. Его основные свойства хорошо известны, он существует с начала цивилизации, и, если вы неправильно его используете, он воспламенит ваш дом вокруг вас. (В этой метафоре C ++ будет «набором всего, что можно зажечь, чтобы произвести свет».)

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

Аргумент загадочного указателя

void myfunc(char* c);
char c;
myfunc(&c);

Аргумент myfunc - ввод? Выход? Оба? Действительно ли это должен быть массив с завершающим нулем? Все ли пути кода инициализируют переданное значение? Будет ли myfunc выйти из строя, если я передам NULL? Сохранит ли функция переданный мной указатель и будет ли использовать его позже? Невозможно взглянуть на определение функции и точно определить, что она будет делать, потому что это разрешено делать довольно много. Для небольших программ это немного раздражает. Для программ с более чем дюжиной функций быстро становится невозможным рассуждать о правильности программы, и нам приходится полагаться на документацию по API. И, как все мы знаем, документация всегда актуальна и актуальна.

Я уверен, что среди вас педанты кричат ​​монитору, что если аргумент указателя предназначен исключительно для ввода, тогда подпись должна принимать константный указатель. Хотя это правильно, я никогда не работал с устаревшей кодовой базой, которая правильно и последовательно объявляет аргументы const. (Примечание: я не уверен, что это связано с тем, что древние компиляторы не поддерживали ключевое слово const, или из-за какого-то коллективного артрита, из-за которого вводить эти дополнительные символы было больно несколько десятилетий назад. Тем не менее, это очевидно обычное дело в старом коде .)

Постоянная правильность все еще не касается другого слона в комнате ...

Нулевой указатель

const char* foo = 0; /* I remembered to initialize my variable! */
printf(foo);

Я предполагаю, что вы читали об ошибке на миллиард долларов и что в какой-то момент своего опыта программирования вы видели Ошибка сегментации или NullPointerException. Если нет, перейдите по ссылке выше. Я все еще буду здесь.

Еще не вернулись? Потрясающие.

Я собираюсь прийти сюда и сказать следующее: сэр Тони Хоар не только блестящий, но и удивительно скромный человек. Очень мало компьютерных ученых или инженеров-программистов, которые категорически признают, что проектное решение было ошибкой. И очень жаль, что эта конкретная ошибка 1960-х годов настолько распространена, что я уверен, что многие из вас взглянут на приведенный выше код и подумают: «Компилятор предупредит вас, в чем дело?»

Большая проблема возникает, когда вы передаете аргументы указателя от A к B к C через десять различных функций в восьми разных файлах, и компилятору (или рецензенту) не сразу очевидно, что вы только что передали нулевые или неинициализированные указатели. между функциональными контекстами.

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

Хорошо, тогда давайте воспользуемся ссылками на C ++

#include <string>
bool isEqualToLast(const std::string& s) {
  static const char * last = "";
  bool foo = s.compare(last) == 0;
  last = s.c_str();
  return foo;
}

Этот код компилируется без предупреждений в GCC 8 с -Wextra, мы правильно использовали const и знаем, что параметр указывает на реальные данные. Он будет работать нормально ... пока каждый параметр выделен статически или выделен в куче и не освобожден преждевременно. Без ошибок, вплоть до вызова после, когда вы вызываете его один раз с переменной стека.

Неявные ошибки приведения

char data[ENORMOUS_BUF_SZ];
for (int i = 0; i < sizeof(data); ++i) {
  /* do stuff */
}

Одна из самых новаторских концепций C - это система типов. У каждого выражения есть тип, и если вы попытаетесь использовать массив символов, в котором, например, требуется целое число, вы получите ошибку компиляции. Часто это предохраняет вас от грубых ошибок, например от неправильного порядка аргументов функции. Однако компилятор иногда помогает, незаметно преобразуя 8-битное в 32-битное, подписанное в беззнаковое, немного подменяя типы, пока они не выровняются. Требуется соблюдать определенные правила неявного приведения типов, даже если эти правила могут не соответствовать вашей интуиции. Поскольку это ожидаемое поведение, компилятор не обязан предупреждать вас об этом.

У нас было size_t уже несколько десятилетий. К сожалению, современный код по-прежнему изобилует циклами, которые используют int или unsigned int, когда им следует использовать size_t, и множество неверно указанных типов аргументов функций. Работает нормально, до тех пор, пока не перестает. И не заставляйте меня начинать с неправильным использованием time_t.

Явные ошибки трансляции

const int data[512] = {0};
volatile uint32_t* WDT_REG = 0xFFFFFFE0;
/* ... */
byte_sending_function((char *)data, sizeof(data));
handle_watchdog((uint32_t *)WDT_REG);

Приведение к byte_sending_function должно быть (const char *), а подпись для handle_watchdog должна принимать volatile указатель.

Да, я знаю о static_cast и reinterpret_cast в C ++. Но приведение типов в стиле C все еще находится в книгах и преподается в классах, и они все еще превращаются в новый код C ++. И в каждом компиляторе, который я видел, совершенно законно отбрасывать const или volatile.

Кому вообще нужна обработка ошибок?

#include <stdio.h>
#include <stdlib.h>
int main() {
   FILE * fp = fopen("file.txt", "w+");
   fprintf(fp, "This cannot possibly go wrong.\n");
   fclose(fp);
   
   return 0;
}

Этот замечательный пример из хит-парада Google №1 для «fopen example» (слегка переформатированный) не пытается проверить, можем ли мы действительно открыть файл, а компилятор не требует и даже не напоминает нам об этом. Работает для меня, а не ошибка, поторопитесь и скомпилируйте, потому что у меня есть больше потенциальных ошибок, которые мне нужно добавить в эту кодовую базу. Отбивная котлета.

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

Переполнение буфера $% ^ & # \ b0x9328A7F0 Ошибка сегментации

В этом абзаце была произведена недопустимая операция, и ее необходимо прекратить.

Если вы считаете, что видите это сообщение по ошибке, обратитесь в службу поддержки.

Типы союзов

union {
  int id;
  void * widget_ptr;
} widget;
#ifdef LINUX
widget.id = 42;
#else
widget.widget_ptr = malloc(64);
#endif
/* many lines later... */
/* I'm quite certain I stored a pointer in here */
free(widget.widget_ptr);

Ребята, я поставлю эту кнопку прямо здесь с маленькой надписью «не трогайте». Пока никто не использует это неправильно, все будет в порядке.

Безопасность потоков

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

Хорошо, я понял. Но мне нравится C. Не можем ли мы просто исправить C / C ++?

Много работы было вложено в то, чтобы сделать предупреждения более умными, улучшить линтер, документировать передовой опыт и т. Д. В C ++ 11/14/17 есть новые блестящие полезные элементы: unique_ptr, циклы for на основе диапазона и RAII - все они могут помочь предотвратить ошибки (если вы их используете). Существуют организации по стандартизации, такие как MISRA, и организации по безопасности, такие как CERT, которые помогут вам найти и исправить критические проблемы безопасности, если все в вашей команде безоговорочно будут следовать этим рекомендациям. Но острый обломок K&R C все еще разбросан по полу, и ничто не мешает вам или парню рядом с вами проигнорировать ленту с предупреждением и споткнуться о нее.

Несмотря на значительные усилия, затраченные на создание инструментов и процессов, стандарт C по-прежнему имеет массу неопределенного поведения. В конце концов, пара языков программирования, на которых основано большинство мирового программного обеспечения, тонко, но фундаментально сломана, потому что обученные, умные профессионалы постоянно совершают дорогостоящие ошибки в производстве. Мы коллективно пытались скрыть проблему, и это свидетельство невероятного мастерства всего двух инженеров Bell Labs: их язык достаточно гибкий, чтобы мы могли попробовать все эти исправления! Но нам действительно нужно решить основные структурные проблемы. И, к сожалению, мы не можем исправить C без нарушения обратной совместимости.

Код C и C ++ управляет дроссельной заслонкой, подушками безопасности и антиблокировочной тормозной системой вашего автомобиля. Он лежит в основе программного обеспечения авионики для пассажирских самолетов, как критических, так и некритических. Он незаметно запускает встроенную операционную систему ​​на множестве вещей, которые вы используете, не задумываясь, вещи, которые вы ожидаете, настолько просты, что по умолчанию они должны быть безопасными и стабильными. Терминалы для кредитных карт. Электросетевые системы. Лифты. Военная техника. Wi-Fi роутеры. Банкоматы. Машины для голосования. Как человек, склонный к ошибкам, который зарабатывает на жизнь написанием встроенного программного обеспечения и видел, что происходит в дикой природе, меня это пугает.

Мы все вместе используем эти инструменты, потому что они знакомы, но опыт показывает, что мы не можем использовать их безопасно. Мне нравится C, и мы многим обязаны его наследию. Но мы также обязаны писать на языках, которые помогают нам преодолевать наши собственные недостатки. Проблема не в том, что мы устанавливаем дорожные фонари, чтобы осветить наши дома, тогда как газовые лампы были бы более уместными, хотя этого, безусловно, достаточно. Проблема в том, что мы вообще не должны использовать огонь. Теперь у нас есть светодиодные фонарики. Пора перестать зажигать свечи.

В следующей главе мы поговорим о том, как дизайн языка Rust смягчает некоторые из этих проблем.