Создание аналога посмертного сore dump для микроконтроллера

eitrjvnxg6dodyeivmmxh1xbcdi.jpeg

При разработке программного обеспечения любого класса и назначения, зачастую приходится заниматься поиском ошибок, которые привели к краху всего приложения. И если в случае обычного компьютера, анализ логов и core dump`ов как правило не вызывает сложностей, то для устройств на основе микроконтроллеров бывает сложно получить «посмертную» информацию, необходимую для изучения проблемы.

Конечно, при подключенном программаторе и при постоянном выводе логов в какой нибудь отладочный порт можно достаточно комфортно вести отладку, но что делать, когда требуется искать причину сбоев устройства во время штатной эксплуатации устройства, когда инструментальные средства для отладки не подключены и нет внешней памяти для хранения логов?

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

Ну и в соответствии с собственным наблюдением Хабр — ума палата, буду рад любым комментариям и предложениям, которые помогут протестировать или улучшить предлагаемое решение.

Исходные данные и ограничения реализации


  • Фрагменты кода в статье приведены для микроконтроллеров серий STM32F10xx, STM32F20xx и STM32F40xx, хотя адаптировать данный способ можно наверно для любых серий микроконтроллеров.
  • Сохранение посмертного дампа или просто дампа (буду называть его так), интегрировано со сторожевым таймером. Другими словами, при срабатывании сторожевого таймера в посмертном дампе сохраняется информация, предшествующая непосредственной перезагрузке устройства.
  • Сохранение дампа происходит в программную флеш память, так как не во всех устройствах могут быть внешние микросхемы флеш памяти или MicroSD карты, а хотелось сделать именно универсальное решение.
  • В качестве посмертного дампа используется не состояние регистров и стека, а текстовый буфер логов, предшествующих сбою. Данная информация является универсальной для любых типов устройств и не зависит от типа микроконтроллера, количества тредов в приложении и т.п.
  • Из-за ограниченных ресурсов, сохраняется только один последний дамп. И хотя при большом желании можно реализовать сохранение нескольких дампов (в том числе и с состоянием стека и регистров), мне кажется это излишним. Гораздо проще сделать индикацию/мониторинг самого факта наличия посмертного дампа и сразу его анализировать в случае сбоя.

Сторожевой таймер


Основная сложность с реализацией сохранения информации о креше микроконтроллера возникла при срабатывании сторожевого таймера.

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

По этому реализация сторожевого таймера состоит из двух частей:

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

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

Таким образом, при включении питания и старте прошивки, остается только проверить тип выполненной перезагрузки. Если перезагрузка была произведена штатным (программным) способ, то ничего не делаем (это означает, что если посмертный дамп перед этим и требовалось сохранить, то это уже было сделано перед перезагрузкой).

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

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

  /* USER CODE BEGIN Callback 1 */
  if (htim->Instance == TIM1) {
	LOG_FAULT("Writing CRASH DUMP on WDT!");
	stm32_save_dump_and_restart();
  }
/* USER CODE END Callback 1 */

Сохранение дампа


Наверно понятно, что для записи дампа в память и выполнения последующей перезагрузки, используется функция stm32_save_dump_and_restart (). Её вызов нужно вставить везде, где требуется сохранять состояние, приведшее к возникновению ошибки и перезагрузить микроконтроллер.

Для себя я решил, что буду таким образом анализировать возникновение любых исключений, ошибок при выделения памяти или инициализации стека, при контроле таймаута блокировок на dead lock, а так же срабатывание любого ASSERT`а или выявление перезагрузки по настоящему сторожевому таймеру.

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

Примерно так:

void stm32_check_wdt_status() {
	if (__HAL_RCC_GET_FLAG(RCC_FLAG_IWDGRST) 
			&& __HAL_RCC_GET_FLAG(RCC_FLAG_PORRST)) {
		LOG_FAULT("Start after IWDGRST. Timer WDG did not work!");
		stm32_save_dump_and_restart();
	}
}

Очень важный момент!


Так как ресурс программной флеш памяти довольно ограничен, то обязательно следует предусмотреть защиту от её постоянной перезаписи. Для этого служит проверка бита холодного старта __HAL_RCC_GET_FLAG (RCC_FLAG_PORRST).

Другими словами, после подачи напряжения питания дамп может быть записан только однократно. Для разбора ошибок это не принципиально, т.к. дамп все равно будет записан, но это предотвратит флеш память от исчерпания ресурса в случае постоянного циклического сбоя ПО.

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

Инициализация и обновление самих таймеров тривиальна:

void stm32_wdt_init() {
	__HAL_TIM_CLEAR_FLAG(&htim1, TIM_SR_UIF);
	__HAL_TIM_CLEAR_IT(&htim1, TIM_IT_UPDATE);
	HAL_TIM_Base_Start_IT(&htim1);
	__HAL_TIM_SetCounter(&htim1, 0);
	__HAL_IWDG_START(&hiwdg);
}

void stm32_wdt_refresh() {
	__HAL_TIM_SetCounter(&htim1, 0);
	HAL_IWDG_Refresh(&hiwdg);
}


Как и сама процедура сохранения дампа с последующей перезагрузкой:
void stm32_save_dump_and_restart() {

	stm32_wdt_refresh(); // Не перезагружаться по WDT

	if (__HAL_RCC_GET_FLAG(RCC_FLAG_PORRST)) { 

		// Если записываем дамп, то очищаем флаги
		// Запись дампа может быть после физического выключения питания только однократно!!!!!
		__HAL_RCC_CLEAR_RESET_FLAGS();

		uint8_t *ptr;
		size_t size;

		utils::Logger::Instance()->GetPointerToDump(ptr, size);
		BoardModbus::stm32_flash_erase(&dump_section[2][0]);

		size &= (~1); // Размер блока должен быть кратен 2
		size = std::min(size, static_cast(FLASH_PAGE_SIZE));
		stm32_flash_write((uint8_t*) &dump_section[2][0], ptr, size);
	}
	HAL_NVIC_SystemReset();
}


Куда сохраняем?


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

И для младшей серии микроконтроллеров (STM32F10xx) с этим не возникало никаких проблем, т.к. все сектора были одного размера. А вот в старших сериях, начиная уже с STM32F20xx, сектора программной памяти имеют переменный размер, который увеличивается для более старших секторов.

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

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

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

Для этого в ld файле немного изменяется секция .isr_vector. А сразу после таблицы векторов прерываний резервируется секция для хранения посмертного лога.

  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector)) /* Startup code */
    KEEP(*(.core_dump_section)) /* Место под дамп */
    . = ALIGN(4);
  } >FLASH

После этого, в коде приложения остается объявить буфер для сохранения дампа с аллокацией в правильном месте.

Очень важно, чтобы буфер был выровнен по границе сектора aligned (FLASH_PAGE_SIZE), а начало буфера инициализировано 0xFF (как будто страница флеш память чистая).

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

const uint8_t dump_section[3][FLASH_PAGE_SIZE] __attribute__ ((used, section(".core_dump_section"), aligned(FLASH_PAGE_SIZE))) = {{0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}, {0xFF, 0xFF, 0xFF, 0xFF}};

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

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

У STM32F10xx размер страницы (сектора) программной флеш памяти — 2k, значит дамп будет начинаться с адреса 0×08001800, а у STM32F20xx — STM32F40xx — 16к, а адрес буфера под дамп начинается с 0×0800C000.

Правда совсем без кодинга все же не обошлось, т.к. препроцессорные определения для разных серий микроконтроллеров STM32 отличаются. В частности у STM32F20xx и STM32F40xx макроопределение FLASH_PAGE_SIZE отсутствует (и это совершенно логично, т.к. размеры сегментов флеш памяти имеют разный размер), а у STM32F10xx в свою очередь не определен макрос FLASH_END.

Хотя именно это в конечном итоге и позволило остановиться на максимально простом заголовочном файле.

#ifdef FLASH_END
// У STM32F2xx и STM32F4xx размер начальных секторов 16к 
#define FLASH_PAGE_SIZE 0x4000
#endif

Запись дампа в память программ


В записи данных в память программ нет ничего особенного. Типовые функции очистки сектора и записи блока данных по указанному адресу с использованием штатных функций HAL.
Очистка и запись данных в программную флеш память
void stm32_flash_erase(void *address) {
	HAL_FLASH_Unlock();
	FLASH_EraseInitTypeDef log_erase;

#ifdef FLASH_END // STM32F4x и STM32F2x
	log_erase.TypeErase = FLASH_TYPEERASE_SECTORS;
	log_erase.Banks = FLASH_BANK_1;
	log_erase.Sector = reinterpret_cast(address) / FLASH_PAGE_SIZE;
	log_erase.NbSectors = 1;
	log_erase.VoltageRange = FLASH_VOLTAGE_RANGE_3;
#else // STM32F1x
	log_erase.TypeErase = FLASH_TYPEERASE_PAGES;
	log_erase.Banks = FLASH_BANK_1;
	log_erase.PageAddress = reinterpret_cast(address);
	log_erase.NbPages = 1;
#endif

	uint32_t PageError;
	HAL_FLASHEx_Erase(&log_erase, &PageError);
	HAL_FLASH_Lock();
}

void stm32_flash_write(void *address, uint8_t *data, size_t size) {
	ASSERT((((uint32_t )address) & 0x1) != 0x1); // Адрес выровненный на два
	if ((((uint32_t) address) & 0x1) == 0x1) {
		return;
	}
	HAL_FLASH_Unlock();

	uint32_t temp;
	for (size_t i = 0; i < size / 4; i++) {
		temp = *(uint32_t*) (&data[i * 4]);
		HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, (uint32_t) address, temp);
		address = (uint8_t*) address + 4;
	}

	uint32_t remainder = size % 4;
	if (remainder) {
		temp = *(volatile uint32_t*) address;
		if (remainder == 1) {
			temp &= 0xFFFFFF00;
			temp |= (uint32_t) data[size - 1];
		} else if (remainder == 2) {
			temp &= 0xFFFF0000;
			temp |= (uint32_t) data[size - 2];
			temp |= (uint32_t) data[size - 1] << 8;
		} else { // 3
			temp &= 0xFF000000;
			temp |= (uint32_t) data[size - 3];
			temp |= (uint32_t) data[size - 2] << 8;
			temp |= (uint32_t) data[size - 1] << 16;
		}
		HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, (uint32_t) address, temp);
	}

	HAL_FLASH_Lock();
}


Что сохраняем?


В текущей реализации сохраняется циклический буфер логов, который представляет собой последовательность текстовых строк и управляющих символов, отличных от »\xFF» и »\0». Ограничение на значения символов связаны с тем, что требуется каким-то образом детектировать наличие записанного посмертного дампа. А эти символы вполне подходят для таких целей.

Другими словами, если буфер начинается на 0xFF, значит флеш память чистая и буфер перед этим не сохранялся. А при чтении дампа, например, для его передачи в отладчный порт или по линии связи, он считывается либо до 0xFF, либо до нулевого байта.

Исходники логгера не привожу, т.к. каждый разработчик должен написать его сам :-). (шутка).

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

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

© Habrahabr.ru