©
Dale, 04.11.2011 — 05.11.2011.
Начало:
часть 1,
часть 2.
В
предыдущей статье мы остановились на том, что решили вынести функции инициализации и главного цикла приложения в отдельный модуль. Этим сейчас и займемся.
Сначала нужно подготовить поддиректорию для этого модуля, а в ней создать поддиректории для интерфейса, исходных текстов, объектных модулей, тестов... Впрочем, я уже
повторяюсь.
Стоп. Мы уже один раз проделывали эти действия для главного модуля программы. Кроме того, наша программа, хоть и небольшая, вряд ли ограничится двумя этими модулями. Следовательно, мы не раз повторим подобные действия. А это значит, что их необходимо автоматизировать. Во-первых, мы сэкономим немного времени. Во-вторых, что важнее, мы избежим ошибок, которые нередки при выполнении рутинной работы (опечатки, забытые поддиректории и т.п.). В-третьих, такая работа, хоть она и не требует выдающихся умственных усилий, отвлекает от мыслей о главном — архитектуре нашего приложения. Поэтому потратим несколько минут и напишем небольшой скрипт для создания поддиректории модуля:
:: директория для модуля
mkdir %1
:: поддиректория для интерфейса
mkdir %1\include
:: поддиректория для исходных текстов
mkdir %1\src
:: поддиректория для объектных файлов инструментальной системы
mkdir %1\obj\PC
:: поддиректория для тестов
mkdir %1\test
Разместим его в поддиректории Software, из которой «растут» все модули проекта. Теперь достаточно набрать в командной строке:
> CreateModule.bat Application
, и мы получим желаемое:
E:\Projects\Shelek\Blinker\Software>mkdir Application
E:\Projects\Shelek\Blinker\Software>mkdir Application\include
E:\Projects\Shelek\Blinker\Software>mkdir Application\src
E:\Projects\Shelek\Blinker\Software>mkdir Application\obj\PC
E:\Projects\Shelek\Blinker\Software>mkdir Application\test
Все готово, можно наполнять модуль содержимым. Впрочем, раз уж мы решили минимизировать малопродуктивный и чреватый ошибками ручной труд, сделаем еще шаг в этом направлении.
Прежде всего в поддиректорию vendor скопируем нашу старую знакомую Unity. Читатели, ознакомившиеся с предыдущими статьями, уже знают, где ее взять (а возможно, уже и успели обзавестись).
Затем пишем скрипт:
ruby vendor\unity\auto\generate_module.rb -i"%1/include" -s"%1/src" -t"%1/test" %2 %3
Выполняем:
GenMod.bat Application Application
Результат:
E:\Projects\Shelek\Blinker\Software>ruby vendor\unity\auto\generate_module.rb -i"Application/include" -s"Application/src" -t"Application/test" Application
File Application/src/Application.c created
File Application/include/Application.h created
File Application/test/TestApplication.c created
Generate Complete
Посмотрим, что создал генератор модулей.
#ifndef _APPLICATION_H
#define _APPLICATION_H
#endif // _APPLICATION_H
#include "Application.h"
#include "unity.h"
#include "Application.h"
void setUp(void)
{
}
void tearDown(void)
{
}
void test_Application_NeedToImplement(void)
{
TEST_IGNORE();
}
Пока что ничего неожиданного, нечто подобное мы уже видели при
знакомстве с Unity. Теперь мы можем наконец реализовать свои намерения по переносу функций инициализации и главного цикла приложения в модуль
Application:
#ifndef _APPLICATION_H
#define _APPLICATION_H
void Application_init(void);
void Application_run(void);
#endif // _APPLICATION_H
Ограничимся пока пустышками вместо реальных функций, только чтобы пройти компиляцию без ошибок:
#include "Application.h"
void Application_init(void)
{
}
void Application_run(void)
{
}
Чтобы скомпилировать модуль, нам понадобится Makefile:
# Подставьте здесь команду для вызова вашего компилятора C
CC=mingw32-gcc
CPPFLAGS=-I include
.DEFAULT : obj\PC\Application.o
obj\PC\Application.o : src\Application.c include\Application.h
$(COMPILE.c) -o obj\PC\Application.o src\Application.c
.PHONY : clean
clean :
del /q obj\PC\*.o
Он представляет собой почти точную копию
Makefile для главного модуля программы — я попросту скопировал его в новое место и немного подкорректировал. Конечно же, это очень плохой стиль программирования, который ведет к множеству копий одного фрагмента кода и, как следствие, к трудностям модификации и сопровождения кода в целом, а значит, к низкому качеству продукта. Например, вы решили перейти с
MinGW на
Cygwin (или наоборот, не имеет значения). Попробуйте вручную синхронно модифицировать значения переменной
CC в полусотне make-файлов, раскиданных по разным поддиректориям проекта. Поэтому мы непременно вернемся к этой проблеме чуть позже. Впрочем, в то же время не следует забывать,
чем чревата преждевременная оптимизация. Выберем золотую середину: сначала делаем, как проще (пусть даже это выглядит безобразно), добиваемся, чтобы это правильно заработало, а затем подвергаем рефакторингу до безупречного состояния.
Сначала убеждаемся, что наш Makefile работает: создаем с его помощью obj\PC\Application.o, затем выполняем очистку поддиректории модуля командой make clean. Затем интегрируем наш модуль с главной программой, добиваясь сборки без сообщений об ошибках:
#include "Application.h"
int main()
{
Application_init();
for (;;)
Application_run();
}
# Подставьте здесь команду для вызова вашего компилятора C
CC=mingw32-gcc
APPLICATION_DIR=..\Application
CPPFLAGS=-I $(APPLICATION_DIR)\include
.DEFAULT : Blinker.exe
Blinker.exe : obj\PC\Blinker.o ..\Application\obj\PC\Application.o
$(LINK.c) -o Blinker.exe obj\PC\Blinker.o ..\Application\obj\PC\Application.o
obj\PC\Blinker.o : src\Blinker.c ..\Application\include\Application.h
$(COMPILE.c) -o obj\PC\Blinker.o src\Blinker.c
..\Application\obj\PC\Application.o : ..\Application\src\Application.c ..\Application\include\Application.h
$(MAKE) -C ..\Application
.PHONY : clean
clean :
del /q obj\PC\*.*
del /q Blinker.exe
.PHONY : depend
depend :
$(CC) $(CPPFLAGS) -M src\Blinker.c
(помимо новых зависимостей от модуля
Application, я добавил еще цель
depend для упрощения отслеживания зависимостей между модулями).
Убеждаемся, что наш проект собирается без ошибок (разумеется, запускать полученную в результате программу не имеет смысла, она попросту зациклится).
Перед тем, как двигаться дальше, оценим архитектуру нашего творения. Вот как она выглядит в представлении UML:
|
Рис. 1. Архитектура приложения. |
Приверженцы «чистого кода» поставят плохую оценку такой архитектуре. Действительно, в ней модуль более высокого уровня Blinker «знает» все о модуле более низкого уровня Application. В свою очередь, модуль Application не имеет понятия о существовании модуля Blinker; он всего лишь предлагает свой сервис (описанный в заголовочном файле Application.h) всем желающим им воспользоваться. Направление зависимости от верхнего уровня к нижнему чревато тем, что изменение, внесенное на нижнем (вспомогательном) уровне, затрагивает все зависимые уровни вплоть до самого верхнего. Хорошего в этом мало.
Хорошая архитектура предполагает противоположный подход: модули верхних уровней не должны зависеть от модулей нижних уровней. Модули нижних, наоборот, должны зависеть от модулей верхних, ведь единственное их назначение — обслуживать модули верхних уровней. В этом случае можно свободно изменять реализацию вспомогательных модулей на нижних уровнях, не рискуя затронуть архитектуру приложения в целом.
Обычно привести архитектуру в порядок можно применением инверсии зависимостей. Это хороший метод; однако в нашем случае применить его было бы нелегко. Инверсия зависимостей в классическом варианте реализуется посредством широкого использования интерфейсов. Поэтому пользоваться ей весьма просто, когда вы пишете на одном из языков, явно поддерживающих концепцию интерфейса. При использовании языка C мы можем лишь имитировать объектно-ориентированное программирование с переменным успехом. Так, мы имитируем интерфейс модуля посредством специально выделенного для этого заголовочного файла. Отделение же файла заголовка от файла реализации и перенос его на более высокий уровень внесет в нашем случае только путаницу. Поэтому придется пойти на компромисс и отказаться от инверсии зависимостей, как бы нам этого ни хотелось.
Загрузить текущее состояние проекта можно
здесь.
До сих пор мы говорили об архитектуре встроенного приложения в самом общем виде, без учета назначения приложения. Пора начинать заполнение наших «пустышек» реальным проблемно-ориентированным кодом. Но сначала нужно разобраться с сутью самой задачи.
В реальных проектах фаза анализа сама по себе столь трудоемка, объемна и сложна, что ей следовало бы посвятить минимум десяток-другой аналогичных статей. Впрочем, мы специально выбрали для обучения вырожденную задачу, которую можно свести к единственному требованию: включать светодиод на 0.5 секунды каждые 2 секунды.
|
Рис. 2. Метод Application.run() |
Эта задача как раз под силу методу Application.run(). Его работа сводится к последовательности действий:
- включить светодиод;
- выждать паузу 0.5 сек;
- выключить светодиод;
- выждать паузу 1.5 сек.
Для наглядности зафиксируем эту нехитрую процедуру в виде соответствующей диаграммы UML (
рис. 2), которую подошьем к пакету проектной документации.
Что необходимо для работы этой процедуры? Прежде всего, очевидно, нам понадобится средство для включения/выключения светодиода. Этим займется модуль Led. Затем нам необходимо средство для отслеживания временных интервалов. В реализации GNU C для платформы AVR ATmega есть несколько библиотечных функций, которые выдерживают заданную паузу. К сожалению, они используют программную задержку, выполняя пустой цикл заданное число раз. С этим можно было бы смириться на первых порах, пока у микроконтроллера нет других задач; однако неразумно было бы расходовать все ресурсы на такую примитивную задачу, когда параллельно можно было бы заняться чем-то более полезным. Поэтому наше решение должно позволить впоследствии расширение до полноценного варианта с использованием аппаратного таймера и обработки прерываний от него.
И еще одна важная деталь. Для того, чтобы модуль управления свечением светодиода мог работать, он должен сначала соответствующим образом подготовить (инициализировать) необходимые для этого аппаратные средства (какие именно — уточним позже, пока нам важен сам принцип). То же относится и к модулю таймера: аппаратные средства таймера также нуждаются в предварительной инициализации до использования. Этой задачей займется метод
Application.init() (
рис. 3).
|
Рис. 3. Метод Application.init() |
Крайне неразумно было бы заниматься столь низкоуровневыми задачами, как инициализация оборудования, непосредственно в модуле
Application. Прежде всего, такое решение сильно привязано к конкретной реализации оборудования и поэтому крайне негибко. Кроме того, в нем перемешиваются совершенно разные логические уровни: бизнес-логика (что и в какой последовательности включать) и уровень оборудования (как именно включать). Очевидно, что модуль
Application должен делегировать подобные задачи специализированным низкоуровневым модулям, в которых сосредоточены все детали конкретной реализации. Эти соображения приводят нас к такой архитектуре приложения (
рис. 4):
|
Рис. 4. Архитектура приложения |
Устроит ли нас такая архитектура? Пожалуй, устроила бы, если бы мы согласились на последующую отладку на целевой системе «методом тыка». Но поскольку мы с самого начала решили отказаться от такого подхода, нам потребуется привести архитектуру к виду, более пригодному для тестирования.
Прежде всего очевидно, что, если мы намереваемся выполнить максимум работы на инструментальной системе, нам потребуются средства для реализации низкоуровневых модулей в ее среде. Но аппаратные средства микроконтроллера
AVR ATmega весьма отличаются от аппаратных средств
IBM PC, не говоря уже об различиях выполнения программ на «голом железе» и в среде ОС
MS Windows. Совместить столь отличающиеся реализации мы можем лишь с использованием абстракций. Заменим конкретные модули интерфейсами (
рис. 5):
|
Рис. 5. Замена конкретных модулей интерфейсами |
Лучше? Да, значительно. Во-первых, мы явно зафиксировали интерфейсы модулей нижнего уровня, и теперь мы сколько угодно можем менять их реализацию, не затрагивая при этом функциональность системы в общем. Так, главный цикл приложения даже не заметит, если мы перейдем с отсчета временных пауз через программную задержку на работу с аппаратным таймером по прерываниям; бизнес-логика при этом не изменится, хотя эффективность использования ресурсов в целом существенно возрастет. Во-вторых, мы развязали себе руки для создания мультиплатформенной реализации. Теперь мы можем выполнить максимум тестов на инструментальной платформе, оставив непротестированной лишь
тонкую прослойку HAL.
Остается нерешенной проблема, о которой мы уже говорили
ранее, а именно: как писать тесты для модулей высокого уровня, которые зависят от еще не реализованных модулей низкого уровня? Впрочем, использование интерфейсов и эту проблему позволяет решить достаточно легко, ведь теперь добавить к проекту
модули-двойники — вполне тривиальная задача (
рис. 6):
|
Рис. 6. Добавлены тестовые двойники |
В качестве тестовых двойников мы будем использовать
подставные объекты (широко известные также как мок-объекты; впрочем, это понятие очень часто используется некорректно, и мок-объектами называют любые двойники, что, вообще говоря, неправильно). Для этого нам придется изучить новый для нас инструмент под названием
CMock. Он неоднократно упоминался ранее в данной серии статей; пришла пора познакомиться с ним поближе.
Продолжение:
часть 4.