Где ты видел диагностику или обработку ошибок? Программисты программы пишут не предполагая возникновения ошибок в используемых подсистемах (библиотечных функциях или аппаратуре), ошибок у себя и какой-либо их обработки (возможность аварийного завершения программы из-за исключительной ситуации), на этапе проектирования ровно ничего не делается в предположении, что ПО может работать неправильно или не работать и т.д. и т.п. Можно где-то в космическом программировании не так, но в бытовом именно так. Хорошо, если ватчдог включат.
Можно было бы составить минимальный список, что программа (не)должна делать, но не знаю кому это нужно. На каждом уровне нужно предусматривать возникновение ошибок или сбоев и какой-то сценарий их обработки. На уровне ПО прибора в целом -- аварийное завершение программы (нужен сценарий: запись информации о причинах сбоя, перезапуск, отключение и приведение в начальное состояние каких-либо механизмов до или после перезапуска), невозможность работы или запуска программы (неисправность МК, не верная контрольная сумма ПО, разрушение хранимой конфигурации, фатальная неисправность оборудования выявленная самотестированием -- результаты самотестирования должны как-то индицироваться...) На уровне отдельных функций реализуемых ПО прибора -- отказ в выполнении отдельных функций, скорей не должен затрагивать другие функции, нужна стратегия обработки такой ситуации. На уровне программиста -- обнаруженные во время исполнения ошибки скорей не должны вести к аварийному завершению программы, а должны "всплывать" на более верхние уровни, производить откат операций, в конечном счёте должны вести к сбою в какой-то высокоуровневой функции ПО прибора.
Для программистов нужна чёткая политика по использованию функций вроде assert() и политика по обработке ошибок. Скорей разумным правилом будет запрет на использования assert в целях отладки -- следует предполагать, что они всегда могут быть включены. Для целей отладки возможно следовало бы завести похожий макрос, например, включаемый только программистами для себя, и никогда не включаемый для релизных билдов или билдов попадающих в QA (если не предполагать обратное, мол для релиза всегда включено NDEBUG, что по-моему является плохой практикой...) Одновременно следует запретить проверки через assert для всех видов ошибок, кроме однозначно ведущих к аварийному завершению программы (memory corruption, невозможность обработки ошибок другим способом, переполнение очереди сообщений, например). В большинстве случаев ошибки должны именно "всплывать" на более верхние уровни, и сохранять в процессе всплытия информацию об ошибке (см. ниже). Хотел ещё заметить, может быть assert вообще не желателен, потому, что семантика у него не чёткая, а для необрабатываемых ошибок лучше было бы попросту сохранять запись об ошибке (вывод в лог, что-то подобное ещё) и вызывать явно abort(). В отличии от вызова assert, Который тоже вызывает abort, здесь хоть оно делается всегда явно и сохраняется какая-то осмысленная информация (можно распечатать переменные и т.п.), само условие assert'а выведенное в stderr часто же достаточно бестолково для анализа, а место где упало и так известно из бэктрейса (см. ниже).
Для программистов так же нужна чёткая политика по обработке ошибок библиотечных функций, при вызове собственных функций и т.п. Языки C/C++ позволяют игнорировать возвращаемое значение -- и это проблема. Некоторые другие языки (Go), например, навязывают обязательную проверку. Хотя в принципе такое обнаруживается статическими анализаторами. Результат работы функций, возвращающих ошибку в качестве результата -- нужно обязательно проверять и как-то осмысленно реагировать. В случае если функции предполагают исключение -- нужна обработка исключений, чтоб исключение не всплывало слишком высоко, когда уже ошибка не понятно, или вызывается std::terminate. На каком-то более высоком уровне в зависимости от возникновения ошибки должна меняться логика. Например, прибор должен останавливать выполнение какой-либо функции и индицировать ошибку, но при этом сохранять способность выполнения других функций. Та же нехватка памяти не повод сразу всё обрушить. Где-то можно подождать и память освободиться, и операцию можно повторить. Вообще за нехваткой памяти по хорошему должна следить отдельная мини-задача, фиксирующая что за определённое время, или цикл работы, количество занятой или свободной памяти не пересекло некий лимит.
Что я называю всплытием ошибки: это когда ошибка обнаруживается в какой-либо функции, вызывавшей другую функцию, и так далее вверх по стеку, до тех пор, пока какая-либо функция не просто возвращает ошибку, а имеет какую-то логику по её обработке. Что здесь важно: не допустимо во-первых терять или игнорировать ошибки. Во-вторых важно, чтоб информация об оригинальной ошибке сохранялась. Можно допустить, что в процессе прохождения между разными API меняются смыслы и коды ошибок, т.е. ошибка одного типа трансформируется в другой тип. Каждый раз, когда такое происходит -- должна сохраняться информация об оригинальной ошибке. Как минимум путём записи в лог -- протокол работы программы или прибора (см. ниже). В идеале должна быть доступна для анализа оригинальная ошибка и контекст, который может нести дополнительную информацию, в котором она возникла, на самом верхнем уровне. Это отдельная сложная тема. Такое можно сделать, если обработку ошибок реализовать примерно таким образом, как это сделано в Lisp: для каждого потока (да, уже нужен TLS) должна быть предусмотрена установка своего обработчика ошибок, который вызывается когда ошибка возникает. Именно вызывается, без выбрасывания исключения. В том контексте, где ошибка возникла, что сохраняет контекст. Обработчик же уже может либо пытаться откатить операцию путём выбрасывания исключения (контекст разрушается), либо игнорировать ошибку, либо запросить повтор операции (через возврат из обработчика). Причём обработчики ошибок в свою очередь могут выстраиваться в стек, и обработка может происходить на разных уровнях API, для разных ошибок по разному.
ПО в целом должно предусматривать механизмы и политику аварийного завершения программы. Любой современный процессор, начиная от PIC24, способен генерировать ряд исключительных сигналов. В Linux это специальные сигналы (SIGSEGV, SIGBUS, SIBABRT, SIGFPU...), в Windows это SEH исключения, у процессоров это специальные прерывания, куда попадает программа при ненормальной работе. Наверное минимально возможная обработка ошибки должна заключаться в фиксировании факта ошибки, её индикации, вывода отладочной информации (в ком-порт, в файл протокола работы), сохранение мини-дампа памяти (регистры, бэктрейс, номер/имя потока...) или отчёта об ошибке (если важно исправление ошибок эпизодически возникающих уже при эксплуатации) и перезапуск прибора. До перезапуска, или после исполнительные механизмы прибора должны быть остановлены и приведены в некое начальное состояние. Широко практикуемое на сахаре зависание (в ожидании подключения отладчика) на адресе вектора обработчика ошибок -- не метод.
Должен вестись какой-то протокол работы (лог) программы. Куда будут записаны ключевые события, возникшие ошибки, отчёты о аварийных завершениях, те же мини-дампы. Для сколько-нибудь сложного ПО -- это обязательно. Как минимум вывод в ком-порт, лучше с записью в NOR-flash и возможностью последующего вычитывания или отправки по сети.
Логика работы программы в целом не должна иметь бесконечных циклов, кроме одного главного. Где происходит ожидание ответа аппаратуры -- должны быть таймауты и обнаружение ошибки. Нужно предусматривать, что на многих шагах ответа от аппаратуры может не быть в силу неисправности или других причин. Вводимые данные нужно проверять на попадание в корректный диапазон. Ну я тут очевидные вещи говорю. Наконец ватчдог таймер должен сбрасываться в этом главном цикле или каким-то схожим образом (чтоб остановка основных задач препятствовала сбросу ватчдог таймера -- например, путём генерации некого синтетического события, на которое должны откликнуться все контролируемые задачи). И уж точно не должен ватчдог таймер сбрасываться не отдельной задачей в многозадачной ОС. И, причём, сбрасываться не по факту оборота цикла, а по таймеру, например, раз в секунду. Это обнаруживает сбои в работе счётчика времени (равно как и использовнаие "оконной" функции ватчдог таймера, вызывающей перезапуск при слишком частых сбросах).
Наконец за правильным функционированием основной рабочей программы может наблюдать параллельная, более простая и легко проверяемая. Которая проверяет правильность возникновения известных ей последовательностей ключевых событий, неких заданных проверочных условий, и при обнаружении несоответствия может либо аварийно завершить программу, либо перехватить управления для безопасной остановки процесса по каким-либо упрощённым алгоритмам управления. Например, очевидное условие для известной истории с "Тойотой" -- длительное нажатие на педаль тормоза и раскрученный на полные оборты двигатель, очевидно, взаимоисключающие условия и это признак сбоя в основной системе управления.
И в embedded качественное ПО должно минимально тестировать микроконтроллер и оборудование перед стартом работы. Начать с проверки целостности контрольной суммы ПЗУ (тут 90% этого не делают), исправности ОЗУ, исправности тактового генератора, являющегося, типично и критичным счётчиком времени (при/после запуска PLL сопоставить получившуюся скорость со внутренним генератором и сделать выводы), проверки внешней памяти, если есть... Наконец если есть хранимая конфгурация -- проверка её корректности.
Потом ещё циклические перезапуски или ошибки. Может быть очень важной темой. Со мной могут не согласиться, мол повторяемости никакой, рандомные глюки. Но по-моему лучше рандомные глюки, т.е. что иногда прибор таки запускается и выполняет свою функцию, чем 100%-й отказ всех приборов в один момент. Я считаю нужна некая "рандомизация" и процесса запуска ПО прибора, и может быть в работе. Почему это важно: допустим, какая-то определённая последовательность событий приводит к 100% сбою. Если после каждого перезапуска повторяется ровно та же последовательность, то, очевидно, это проблема. Хорошо, если она возникла при отладке, а не у потребителя. В случае же, когда от раза к разу работа ПО несколько отличается (разных порядок выполнения параллельных и независимых задач, разные задержки, разный layout в оперативной памяти) и последовательности событий получаются разные, и одна и та же последовательность уже не может вести к сбою и циклическому перезапуску. Как "рандомизацию" практически реализовать отдельный вопрос...
Циклические ошибки вообще. Допустим какая-то одна высокоуровневая функция прибора неизбежно вызывает сбой. И она неизбежно выполняется после старта прибора. Это тоже проблема. Потому, что в этот момент времени перестают выполняться другие функции, возможно более важные. Например возможность получить информацию об ошибке, удалённо обновить ПО, сменить конфигурацию и т.п. Поэтому ПО в целом должно быть разделено по меньшей мере на два слоя -- критический, который должен выполняться в любом случае, и состоит из преимущественно простых и безошибочных функций, и остальная часть ПО, работа которой может быть блокирована при обнаружении циклического перезапуска (N перезапусков подряд за короткий промежуток времени).
Обнаружение ошибок времени исполнения вообще. Выше я критиковал функцию assert(), но я всё-таки считаю, что лучше её иметь, как и соответствующие замедляющие работу программы проверки, чем не иметь. Обоснование простое: в случае отсутствия проверок работа программы начинает идти по неизвестному вообще сценарию, с лавинообразным накоплением невообразимых глюков. И ответить на вопрос потом, почему программа работает именно так -- уже невозможно. Лучше иметь аварийное завершение. Но проверки должны быть разумными. Проверка указателей после адресной арифметики над ними -- нормально. Проверка отсутствия файла не диске -- нет. В случае C++ можно использовать вместо массивов напрямую (вспоминая Лагунова) вектора или std::array, где есть функция at. Можно написать assert для индекса, если просто C. Можно проверять на допустимость значения передаваемые в аргументах функций.
Можно реализовывать дополнительные рантайм-проверки. Основная -- это проверка целостности кучи. Большинство аллокаторов так или иначе обнаруживают выход за границы массива и повреждения заголовков или окончаний выделенных блоков памяти. Но это проверяется обычно при (де)аллокации блока. Можно просто периодически, раз в несколько секунд, пробегаться по всем блокам кучи и проверять целостность. Обычно происходит "наезд" тела блока на его границу и виноват тот кто этим блоком владеет.
Так же следует включить в процессоре, если поддерживает, или включить в компиляторе проверку целостности стека (gcc имеет несколько разных опций, см. документацию). Программная проверка (-fstack-protector) несколько замедляет программы, но обнаруживает типичные ошибки переполнения буфера. Опции типа -finstrument-functions (вызывает __cyg_profile_func_enter и __cyg_profile_func_leave) или -pg (вызывает __mcount) позволяют вызывать собственную функцию при входе/выходе из любой другой функции, что можно тоже использовать для оценки глубины и (не)переполнения стека.
Наконец критичные структуры хранимые в памяти МК могут быть защищены хеш-кодами (контрольными суммами), пересчитываемыми при каждом изменении. Для структур с менее чем десятком членов это не слишком высокие накладные расходы, зато позволяют обнаружить порчу памяти.
И список можно долго продолжать. Только не для кого и ни для чего.
[ZX]
-
- Хорош! Согласен. Я вообще считаю, что embedded-разработка в большинстве случаев должна быть "поддержко-ориентирована", т.к. быстро решить проблемы или доработать, зачастую более приоритетно, чем быстро выпустить продукт (любые остановки, вызовы Oman(566 знак., 06.02.2019 14:55)
- Вдогонку вспомнилось. Ещё в embedded следует с большой осторожностью относится ко всем рекурсивным алгоритмам. В частности -- к сортировке. Вполне возможно, что в C-библиотеке идущей в комплекте с компилятором реализована функция qsort(), и что fk0легенда(1277 знак., 06.02.2019 03:39)