Тестирование встроенных систем

image Я являюсь участником проекта по разработке ОСРВ Embox для встроенных систем. Чаще всего ОС для встроенных систем поддерживает множество аппаратных платформ, и мы не исключение. Также в проекте имеется множество сервисов и библиотек: ssh, telnet, Qt и т.д. Все эти сервисы и библиотеки хотелось бы иметь в рабочем состоянии на различных платформах.

Я хорошо помню то время, когда именно мне приходилось поддерживать в рабочем состоянии Qt. Это был ужас! Вот я пришел днем на работу, что-то опять сломано. Начинаю разбираться. Оказывается, что кто-то пофиксил багу в сетевом стеке и теперь Qt не может создать сокет. Короче говоря, Qt ломалось практически ежедневно и по самым неожиданным причинам.

Естественно, напрашивалось решение внедрить в проект некоторое автоматизированное тестирование различных сервисов. В чем же проблема сделать сервер, который будет все это тестировать?

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

Сборка и запуск


Как я уже говорил мы поддерживаем несколько архитектур. Поэтому, чтобы поддерживать проект в рабочем состоянии, прежде всего, необходимо собирать его под различные платформы. Для этой цели мы используем компилятор gcc, который как известно умеет генерировать код для разных архитектур. Но делать это в ручную конечно не стоит. К счастью, для решения проблемы автоматизации сборки существует множество различных средств под общим названием Continuous IntegrationJenkins/Hudson, Integrity, Buildbot и пр. Берем один из этих тулов, и настраиваем его так, чтобы он собирал проект под различные аппаратные архитектуры по мере поступления новых коммитов в репозиторий. Мы используем Buildbot. Когда какая-то конфигурация не собирается, это отмечается на билдсервере. На самом деле еще можно автоматически посылать гневные письма тому кто сломал, но мы в основном находимся в одной комнате и справляемся посредством голосовой связи по воздуху.

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

Лирическое отступление. Изначально в QEMU для процессора Leon 3 была бага, и мы пользовались эмулятором tsim. Но хотелось единообразия как минимум чтобы наладить автоматизированное тестирование. И поскольку проект QEMU открытый, мы поправили его исходники для поддержки процессора Leon3. В более поздних версиях наши правки были внесены в исправлены.

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

image

Unit тестирование


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

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

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

Пример проверки работоспособности функций создания и выполнения потока:

TEST_CASE("thread_create should return -EINVAL if thread function is NULL") {
	struct thread *t;
	t = thread_create(0, NULL, NULL);
	test_assert_equal(err(t), -EINVAL);
}

И еще один пример. Работоспособность прерываний от таймера.
static void test_timer_handler(sys_timer_t* timer, void *param) {
	*((int *) param) = 1;
}

TEST_CASE("testing timer_set function") {
	unsigned long i;
	sys_timer_t * timer;
	volatile int tick_happened;

	/* Timer value changing means ok */
	tick_happened = 0;

	if (timer_set(&timer, TIMER_ONESHOT, TEST_TIMER_PERIOD, test_timer_handler,
			(void *) &tick_happened)) {
		test_fail("failed to install timer");
	}

	i = -1;

	while (i-- && !tick_happened) {
	}

	timer_close(timer);

	test_assert(tick_happened);
}

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

image

Интеграционное тестирование


После выполнения модульных тестов переходим к интеграционным. Как уже говорилось выше, выполнять интеграционные тесты внутри встроенной системы довольно проблематично. Начал разбираться какой бы инструмент начать использовать для тестирования снаружи. Исследовал несколько фреймворков для интеграционного тестирования — TETware RT, OpenTest, tcltest, autotestnet, DejaGnu. Выяснились следующие проблемы:

  • Некоторые инструменты тулы требуют unix окружение — утилиты grep, awk и тд. Иными словами, что-то типа Linux. Проблема в том, что не на любую плату все это влезет (TETware RT, OpenTest);
  • Отсутствие встроенных средств для проверки вывода и результата исполнения тестируемой программы (DejaGnu);
  • Отсутствие средств для взаимодействия со встраиваемой системой, то есть нет возможности легко проверить, что результат выполнения программы на удаленной системе совпадает с ожидаемым (TclTest и его улучшения — PTL, TTXN, New Test Package).

В то время я уже был знаком с googletest для С++, и мне нравился их синтаксис для написания модульных тестов. Тесты пишутся в месте их объявления и нигде отдельно не регистрируются, имеется множество assert’ов, есть возможность определять обработчики, которые вызываются до и после выполнения каждого теста. И я подумал, а почему бы не перенести это удобство в сферу интеграционного тестирования для встроенных систем?

Опираясь на знания о недостатках существующих решений, я поставил себе задачу максимально совместить легковесность DejaGnu, мощь TETware RT и эстетичность googletest.

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

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

image

Hello embedded tester!

Давайте рассмотрим как выглядит простейший тест.

package require autotest
namespace import autotest::*

TEST_CASE {echo “hello” print hello} {
	test_assert_regex_equal “echo “hello”" hello
}

Первые две строчки означают то, что мы подключаем реализованную мной библиотеку autotest, которая представляет собой package в Tcl. Далее объявляется тест кейз. Суть теста проста — встраиваемая система должна правильно исполнить команду echo “hello”, напечатав hello. Давайте разберемся как он работает.

Сразу отмечу, что все тесты запускаются только на хостовой системе, а со встроенной мы взаимодействуем по TELNET. Процедура TEST_CASE сначала устанавливает соединение со встраиваемой системой. Конечно же для этого внутри встроенной системы должен быть предварительно запущен сервис telnetd. То есть схема такая — запускаем QEMU с подготовленным образом нашей ОС, и ждем пока запустится telnetd. Далее можно подключиться и запускать тесты.

Итак, после того как соединение установлено исполняется строчка test_assert_regexp_equal “echo “hello”" hello. В ней посылается команда “echo “hello”” встроенной системе. Встроенная система исполняет команду и весь ее вывод автоматически попадает на хост (это же telnet!). Хост в свою очередь получает результат и сравнивает его со строкой “hello”.

Обзор возможностей фреймворка

Фреймворк содержит набор библиотечных процедур test_assert_*. Все они работают по одному сценарию — послать команду с хоста встраиваемой системе, а затем получить вывод команды и сравнить его с ожидаемым. Ниже приведен код процедуры test_assert_regex_equal из примера выше:

proc test_assert_regexp_equal {cmd success_output} {
	send $cmd
	set cmd_name [lindex [split $cmd " "] 0]
	expect {
	         timeout { puts "$cmd_name timeout\n"; exit 1 }
	        -regexp "$cmd_name:.*" { puts "$expect_out(buffer)\n"; exit 1  }
	        "$success_output" { }
	}
}

Если одна из процедур test_assert_* завершилась по exit 1, то выводится информация о том, что тест не пройден (печатается имя файла и номер строки) и управление передается следующему набору тест кейзов.

Проникшись удобством googletest я реализовал процедуры TEST_SETUP и TEST_SUITE_SETUP (аналогично для TEARDOWN). Хочу обратить внимание на одну особенность. Эти процедуры существуют как для хоста так и для встроенной системы — TEST_SETUP_HOST и TEST_SETUP_TARGET. Вся разница в том, что первая из них будет исполняться на хосте, а вторая на встроенной системе.

Пошаговая отладка тестов

Давайте на минуту представим, что наш “Hello embedded tester!” тест не прошел. То есть при его запуске фреймворк выдал ошибку, что в строке 5 не сработала проверка test_assert_regex_equal. Хочется выяснить, что в нашей встроенной системе пошло не так. Для этого давайте модифицируем наш тест следующим образом, добавив символ “b” в конец строки, на которой произошла ошибка.

package require autotest
namespace import autotest::*

TEST_CASE {echo “hello” print hello} {
	test_assert_regex_equal “echo “hello”" hello b
}

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

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

Что исполнять “внутри”, а что “снаружи”?

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

Ах да, важный момент состоит в том, что не все интеграционные тесты имеет смысл выполнять “снаружи” встроенной системы. Например, тест на пинг с целевой платформы на хост прекрасно можно выполнить и “изнутри”. Для этого достаточно выполнить команду пинг и проверить результат исполнения последней команды (в unix это ‘echo $?’). Это реализовано при помощи стартового скрипта. Он выполняет настройку системы после ее загрузки — например, выполняется настройка сети. В конце стартового скрипта добавлены, условно говоря, простые интеграционные тесты, которые можно выполнить внутри встраиваемой системы.

Пример стартового скрипта.
"ifconfig eth0 10.0.2.16 netmask 255.255.255.0 hw ether AA:BB:CC:DD:EE:02 up",
"route add 10.0.2.0 netmask 255.255.255.0 eth0",
"route add default gw 10.0.2.10 eth0",

"export PWD=/",
"export HOME=/",

"mkdir /mnt",
"mkdir /mnt/fs_test",
"test -t fs_test_read",

В конце скрипта есть строчка “test -t fs_test_read”, которая запускает тест на файловую систему.

Появляется закономерный вопрос: какие тесты так выполнить нельзя и как с этим быть? Ответ на этот вопрос простой. Например, для выполнения теста может понадобиться обработать вывод программы при помощи утилит grep и awk. Естественно, такие утилиты запихивать внутрь встраиваемой системы не хочется. Поэтому такие тесты у нас выполняются “снаружи” с использованием реализованного фреймворка.

В качестве примера я приведу тест на ntpdate. Ntpdate — это программа, которая выставляет дату и время через протокол NTP. Она реализована в нашей ОС. В тесте проверяется, что время выставляемое внутри встроенной системы совпадает с текущим временем на хосте.

Тест ntpdate.
namespace import autotest::*

set host_date ""

proc get_host_date {} {
	global host_date
	spawn date -u --rfc-3339=date
	expect -regexp {.{10}}
	set host_date $expect_out(0,string)
}

TEST_SETUP_HOST {get_host_date}

TEST_CASE {ntpdate sets current date in UTC format correctly} {
	variable host_ip
	global host_date
	
	test_assert_regexp_equal "ntpdate $host_ip\r"    ":/#"
	test_assert_regexp_equal "date\r"                "$host_date"

	return 0
}


Функция get_host_date получает текущее время на хосте в формате UTC. Для этого она регистрируется при помощи процедуры TEST_SETUP_HOST, то есть она будет вызываться на хосте. Ниже расположен TEST_CASE. В нем сначала выполняется на встраиваемой системе команда “ntpdate $host_ip”, а затем команда “date” и проверяется, что $host_date содержится в выводе команды.

Итоги


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

image

А вот так выглядит наш тестовый полигон:

image

Буду рад, если кто-нибудь поделится своим опытом в организации процесса тестирования встроенных систем.
Embox
Открытая и свободная ОС для встроенных систем

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

    +1
    Есть литература по данной тематике www.amazon.com/Driven-Development-Embedded-Pragmatic-Programmers/dp/193435662X/ref=sr_1_2
      +3
      Я как-то просматривал эту книгу. Она сильная, но там по большей части методология тестирования приведена — например, паттерны и стратегии, тестирование легаси кода. Я в статье постарался сделать упор на реализацию на практике. К тому же в данной книге, насколько мне известно, примеры приведены на Unity, что в какой-то мере закрывает лишь вопрос с unit тестированием.
      P.S. Кстати говоря, Unity синтаксически хуже чем googletest, как минимум потому что в нем приходится проделывать лишние действия для регистрации тест кейзов.
        0
        Недавно начал её читать. В первой половине примеры приведены на Unity, во второй на Cpputest. Автор книги — один из разработчиков второго фреймворка. В нём больше возможностей, чем в Unity.
        TDD объясняется для полных новичков, но в то же время объясняется, как его применять в embedded.
      +1
      А как запускаются тесты в вашем фреймворке для unit-тестирования?
      И кстати, вы этим фреймворком поделитесь или просто похвастаться хотели? :)
        +1
        Внутри макроса TEST_CASE происходит регистация функции, которая будет вызвана при запуске теста:
        #define __TEST_CASE_NM(_description, test_case_nm, run_nm) \
        	static void run_nm(void);                            \
        	static const struct test_case test_case_nm = {       \
        		/* .run         = */ run_nm,                     \
        		/* .description = */ _description,               \
        		/* .location    = */ LOCATION_INIT,              \
        	};                                                   \
        	ARRAY_SPREAD_ADD(__TEST_CASES_ARRAY, &test_case_nm); \
        	static void run_nm(void)
        

        Структуры struct test_case попадают в некий глобальный массив, а далее все эти функции run_nm будут вызваны при старте системы одна за одной:
        static const struct __test_assertion_point *test_run(test_case_run_t run) {
        	struct test_run_context ctx;
        	int caught;
        
        	current = &ctx;
        	test_emit_buffer_init(¤t->emitting, emit_buffer, EMIT_BUFFER_SZ);
        	if (!(caught = setjmp(ctx.before_run))) {
        		run();
        	}
        	current = NULL;
        
        	return (const struct __test_assertion_point *) caught;
        }
        


        Фреймворк на данный момент в составе проекта (кстати, проект открытый). Но в будущем мы планируем его отделить.
          0
          Я спрашиваю потому, что сам читал книжку из комментария выше и там как основной минус Unity как раз приводилась его невозможность регистрировать новые тесты автоматически.
          И мне, собственно, казалось, что на чистом С этого сделать и нельзя. Не могли бы вы рассказать поподробнее?

          В частности, поэтому сам я использую cppUtest, который тоже в той книжке упоминался, в симуляторе среды Keil. На мой взгляд он не лишен проблем, в частности, ему нужно море динамической памяти и вообще для embedded он толстоват.
            +2
            Да, средствами чистого Си такую регистрацию тестов сделать не возможно. У нас это реализовано при помощи gcc расширения __attribute__ ((section(«name»))). То есть все структуры struct test_case из моего комментария выше упорядочиваются в отдельных линкер секциях.
            Часть кода, которая реализует добавление в такой «массив»:
            /* The relative placement of sections within a particular array is controlled
             * by the value of order_tag argument. */
            #define __ARRAY_SPREAD_SECTION(array_nm, order_tag) \
            	".array_spread." #array_nm order_tag ".rodata,\"a\",%progbits;#"
            
            /* Every array entry, group of entries or marker symbols are backed by an
             * individual array (empty for markers) defined as follows. */
            #define __ARRAY_SPREAD_ENTRY_DEF(type, array_nm, entry_nm, section_order_tag) \
            	type volatile const entry_nm[] __attribute__ ((used,                  \
            			section(__ARRAY_SPREAD_SECTION(array_nm, section_order_tag)), \
            			aligned(__alignof__(array_nm[0]))))
            

            То есть под каждый массив создается секция ".array.spread__array_nm*" и в нее помещаются элементы (section(__ARRAY_SPREAD_SECTION(array_nm, section_order_tag)) в макросе __ARRAY_SPREAD_ENTRY_DEF)
              +1
              Офигеть. Могущественно!
              Как ни странно, у компилятора armcc (родного для keil) есть атрибут с точно таким же именем (и, вероятно, действием).
              Здорово!
              0
              Да мы в Си делаем аналог секции .ctor в плюсах, для этого мы используем линкер скрипты, то есть помещая адреса функций в какую то секцию. Затем мы можем пробежаться по ней, ведь адреса начала и конца секции мы знаем, и вызвать данные функции. Мы используем __attribute__ ((section(«name»))) для gcc, для keil думаю есть аналоги.
              Идея была обойтись без динамической памяти, переложив работу на линкер, вроде у нас получилось.
          +2
          Тест на ntpdate некорректен.
          Во-первых, никак не учтена вероятность того, что за время исполнения теста может измениться секунда.
          Во-вторых, нет проверки того, что время было выставлено правильно именно из-за вызова ntpdate — как минимум, нужна проверка на то, что время хоста и девайса не совпадают до вызова, а возможно и сброс времени девайса в начале.
            0
            Да, Вы правы, тест не совсем корректный. Но:
            1. «date -u --rfc-3339=date» выдает только дату без времени, то есть проблемы с секундами не возникает.
            2. Соглсен, по хорошему нужно сбрасывать время на целевой платформе макросом TEST_SETUP_TARGET, в котором вызвать команду «date» c нужными параметрами. Но кроме как с помощью ntpdate точное время в нашей системе никак не выставить, только если эта команда не вызывается в стартовом скрипте. Таким образом, если считать, что стартовый скрипт заранее подготовлен и ntpdate в нем не вызывается, то тест корректен (в таком предположении)
            0
            Тоже интересовался данной тематикой, только условия были еще жестче, не было операционной системы и памяти сильно не хватало, потому приходилось «отключать» часть функционала что бы тесты влазили в память. Мне очень помогла вот эта книга Test Driven Development for Embedded C, ссылку на нее привели в первом комментарии. В книге все крутится вокруг того, что нужно разделять логику приложения от аппаратно зависимого кода, тогда становится возможным unit-тестирование логики не на целевой платформе, а на рабочем PC, что сильно упрощает задачу.
              +1
              Ну на счет жесче я бы поспорил.
              Дело в том, что Embox о котором идет речь в статье, изначально не был ОС, а был вспомогательной утилитой для разработки и отладки железа, в частности FPGA (в ПЛИС стоял процессор Leon). Тесты применялись в том числе для потактовой симуляции, а это очень медленно. То есть скорость старта теста должна была быть очень высокой. Поэтому нам пришлось делать систему сборки которая бы включала только требуемые модули и ничего лишнего.
              Уже потом проект развился в ОС поскольку оказалось, что когда отлаживаешь оборудование появляются драйвера, ну а полноценный тест сетевухи можно сделать только имея сетевой стек ну и так далее. При этом свойство включения только требуемых модулей сохранилось и Embox запускаемся на контроллере EFM32ZG222F32 Zero Gecko c 16кБ ПЗУ и 4 кБ ОЗУ
                +1
                Ну я не знал как развивался проект, потому и сделал такой вывод.
                0
                Полностью согласен, что логику нужно отделять от аппаратно зависимого кода. Это помогает, но не является решением всех проблем. Но, как выше писал Антон, сделать тест сетевухи без сетевого стека не получится, также не получится сделать его и на PC. То есть получается, что нам все равно нужно запускать тесты либо на железке, либо на qemu. Ну а раз мы запускаем тесты на таймер, mmu, сетевуху, то почему бы не запустить тесты на список тоже на qemu? С интеграционным тестированием еще сложней. Если список можно протестировать и на хосте, так это всего лишь алгоритм, то исполнение интеграционных тестов может зависить глубоко внутри от планировщика, файловой системы, да и от частоты процессора в конце концов. То есть все равно исполнения на PC и на целевой платформе могут различаться.
                0
                Про тестирование Embedded — когда мне пришлось тестировать плату я поступил схожим образом.
                Часть тестов выполняются внутри устройства при старте (список модулей я тоже делал через __attribute__ ((section(«name»)))) — здесь я провожу тестирование основных «железных» компонентов, необходимых для запуска платы. Т.к. здесь тестируются жизненно важные компоненты (работоспособность тактового генератора, например), то эти тесты никак отдельно не выделяются, а встроены в код инициализации платы.
                А тесты качества и интеграционные выполняются «снаружи», подачей команд через COM-порт. А в качестве framework я выбрал pyunit, выполняющийся на хосте. Вообще автоматизация тестирования на Python мне приглянулась из-за своей интерактивности. Я могу в интерпретаторе писать какой-то код, посылать команды на целевое устройство и сразу же видеть отклик в логах. Мне не надо для этого пересобирать код.

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

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