Статья
Версия для печати
Обсудить на форуме (17)
«Hello World!» в embedded-исполнении. Часть 7

© Dale, 14.11.2011 — 19.11.2011.

Окончание.
Начало: часть 1, часть 2, часть 3, часть 4, часть 5, часть 6.

Оглавление


Реализуем HAL

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


Рис. 1. Принципиальная схема устройства
Принимаясь за написание HAL, недурно бы вначале выяснить, как выглядит оборудование, программную поддержку которого нам предстоит реализовать. Для этого придется вновь посетить наших коллег-электронщиков и взять у них схему устройства (уж ее-то можно было нарисовать за столько времени).
Вот он, наш трофей (рис. 1).
Что мы можем почерпнуть их этой схемы? Прежде всего, мы видим, что светодиод подключен к младшему разряду порта C микроконтроллера. Кроме того, балластный резистор R1 подключен к шине питания +5V. Следовательно, чтобы зажечь светодиод, мы должны подать на младший разряд порта C низкий потенциал, то есть логический ноль. И наоборот, чтобы погасить светодиод, на младший разряд порта C следует подать высокий потенциал, или логическую единицу. Такое «инверсное» управление начинающим может показаться нелогичным, а инженеры со стажем воспринимают это как само собой разумеющееся. Оно имеет исторические корни: во времена господства логики TTL, которая долгое время была стандартом де-факто в области цифровой электроники, выходные каскады могли принимать втекающий ток (при низком потенциале на выходе) во много раз больше, чем выдавать вытекающий (при высоком потенциале). Современные микросхемы CMOS (к которым, в частности, относится и выбранный нами микроконтроллер ATmega16) имеют выходные каскады с симметричными характеристиками, но подход уже устоялся. Кстати, оттуда же берет начало привычка «подтягивать» логические входы к логической единице посредством резистора.
Разобравшись со схемой, мы можем загрузить Atmel AVR Studio (я использую версию 5, но знаю, что и у прежней, 4-й, осталось немало поклонников; я не вижу явных причин, по которой наш проект не мог бы быть откомпилирован в предыдущей версии).

Порт управления светодиодом

У нас уже есть модуль Led и его инструментальная реализация Led\src\PC\Led.c, которую мы использовали в модели устройства на инструментальной системе. Теперь мы разработаем целевую версию модуля Led\src\AVR\Led.c. Разумеется, он реализует тот же интерфейс Led\include.Led.h:

Код: (C) Led\src\AVR\Led.c
#include "Led.h"
#include <avr/io.h>

/* Инициализаци порта светодиода.
 * Выбран порт C, бит 0, управление инверсное.
 */

void Led_init(void)
{
        // программируем бит 0 порта C на вывод
        DDRC |= _BV(PC0);
        // выключить светодиод
        Led_off();
}

// включить светодиод
void Led_on(void)
{
        PORTC &= ~_BV(PC0);
}

// выключить светодиод
void Led_off(void)
{
        PORTC |= _BV(PC0);
}

Несколько комментариев для тех, кому это все в новинку:
  • регистр DDRC задает направление работы соответствующего разряда порта C: 0 = вход, 1 = выход;
  • регистр PORTC является регистром данных; в частности, содержимое битов, запрограммированных как выход, подается на соответствующие выводы микросхемы;
  • доступ к этим регистрам из программы на языке C производится как к обычным переменным благодаря объявлениям из библиотечного файла avr/io.h;
  • функциональный макрос _BV() дает в результате байт с установленным в единицу битом, номер которого задан в аргументе.
Этой информации должно быть достаточно для того, чтобы разобраться в приведенном коде.

Программная задержка

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

Код: (C) Timer\src\AVR\Timer.c
#include "Timer.h"
#include <stddef.h>
#include <util/delay_basic.h>

void Timer_init(void)
{
    // в версии в программной задержкой инициализация не требуется
}

// программная задержка 1 ms
static void wait1ms(void)
{
        _delay_loop_2(4000);
}

void Timer_wait(uint16_t ms)
{
    for (unsigned short i = 0; i < ms; ++i)
    {
                wait1ms();
    }
}

В библиотечном файле util/delay_basic.h находятся уже готовые к употреблению функции программной задержки. Одной из них, _delay_loop_2(), мы и воспользуемся. Эта функция выполняет задержку в 4 такта на каждый внутренний цикл, а аргумент задает количество этих самых циклов. Нетрудно подсчитать, что при тактовой частоте 16 мегагерц такая задержка составит четверть микросекунды. Чтобы выждать одну миллисекунду, таких задержек потребуется ровно 4000. Остальное комментировать нет необходимости.

Симуляция целевой системы


Мы вроде бы написали HAL и теперь со спокойной совестью можем ожидать прототип, чтобы испытать на нем написанную программу.
Впрочем, у нас есть возможность продвинуться еще на шаг дальше. Эту возможность дарит нам симуляция целевой системы.
Как я уже говорил ранее, существует множество инструментов для симуляции микроконтроллеров (в частности, интересующих нас Atmel AVR). Наиболее простой из них (и впридачу совершенно бесплатный) — VMLAB. Конечно, его простота накладывает множество ограничений, но для нашего проекта почти все эти ограничения несущественны. За исключением одного: VMLAB требует, чтобы все проектные файлы находились в одной директории. Если бы мы не использовали TDD, наша программа уместилась бы в одном файле, и проблема с деревом иерархии файлов проекта ушла бы сама собой. Однако я считаю это слишком высокой ценой за отказ от TDD. В конце концов, оттестированная программная модель на инструментальной системе внушает мне куда больше доверия, чем вроде бы работающая симуляция на VMLAB.
Примечание. Если кому-то известны способы преодоления этой трудности (например, каким-то образом подключить к VMLAB готовый модуль ELF и таким образом вообще отказаться от использования исходных текстов в модели), прошу поделиться, буду очень признателен. Если бы не это досадное недоразумение, VMLAB хорошо подходил бы для решения множества несложных задач.
Я воспользуюсь симулятором, входящим в состав Proteus. Для симуляции нам потребуется:
  • скомпилировать проект, получив загрузочный файл в формате ELF;
  • ввести схему в Proteus;
  • «прошить» исполняемый код в модель;
  • собственно запустить симуляцию.
Я не буду приводить здесь подробную инструкцию по этим действиям. Все это прекрасно описано в сопутствующей документации, изучить которую вам все равно придется, поэтому не стоит откладывать этот момент. Приведу лишь видеозапись конечного результата:

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

Так ли страшен C, как его малюют

Раз уж мы добрались до реального машинного кода, трудно побороть любопытство и удержаться от искушения взглянуть, как же он устроен.
Одна из неиссякаемых тем для холиваров — это битва C против ассемблера. В среде тех, кто использует микроконтроллеры в своих конструкциях, очень популярно мнение, что компиляторы C генерируют чрезмерно тяжеловесный и неэффективный код, поэтому программы для микроконтроллеров следует писать исключительно на ассемблере.
Если вас интересует мое личное мнение по этому вопросу, то оно таково. Стремительное снижение цен на микроконтроллеры и одновременный рост (возможно, не столь стремительный) оплаты труда программистов приводит к тому, что стоимость «прошивки» многократно превосходит стоимость кристалла, в который она будет прошита. Сделаем грубую прикидку: пусть микроконтроллер вместе с платой, кварцевым резонатором, разъемами и прочей мелочью обходится в 300 рублей. Программа к нему обошлась в один человеко-месяц программиста с окладом 60.000 рублей. Тогда стоимость оборудования сравняется со стоимостью программы при тираже 200 штук. Конечно, крупносерийным производством такую партию не назовешь, но и штучным тоже. К тому же разработка нетривиальной программы едва ли уложится в человеко-месяц (один лишь тщательный сбор требования с их последующей обработкой отнимет не меньше половины этого срока), а значит, цены на аппаратуру и программу сравняются лишь при многотысячных тиражах.
Поскольку далеко не все конструкции расходятся такими тиражами, доля стоимости программного обеспечения зачастую оказывается существенно выше, чем аппаратной части. Отсюда следует первый важный вывод: нужно постараться снизить стоимость программной части проекта. Разумеется, делать это следует цивилизованными средствами, не в ущерб качеству, ибо потребительская стоимость сбойной программы стремится к нулю.
Самый очевидный способ удешевить разработку программы без снижения качества кода известен уже более полувека — нужно использовать языки программирования высокого уровня. Чем меньше семантический разрыв между средствами языка и понятиями предметной области, тем проще и, следовательно, дешевле будет написанная на этом языке программа. К сожалению, выбор компиляторов языков высокого уровня, пригодных для программирования микроконтроллеров, невелик, и выбор C, пожалуй, близок к оптимальному. Конечно, уровень C как языка программирования по современным меркам невысок, но в сравнении с ассемблером он все же неизмеримо выше. Поэтому программист, использующий язык C, окажется в итоге существенно (до нескольких раз) продуктивнее, чем его коллега, пишущий на ассемблере, даже при условии равной квалификации.
Еще один довод в пользу применения языков программирования высокого уровня — их относительная независимость от архитектуры процессора, для которого они генерируют исполняемый код. Время от времени разработчики меняют аппаратную платформу. Иногда это происходит по доброй воле (стали доступны более подходящие кристаллы), иногда это вынужденный шаг (вспомним ситуацию примерно годовалой давности, когда из-за проблем на фирме Atmel ее продукция исчезла со складов поставщиков, и передо мной замаячила вполне реальная перспектива вынужденного перехода на микроконтроллеры семейства PIC). Те, кто программирует на ассемблере, после такого перехода будут вынуждены отправить все предыдущие наработки в мусорную корзину и начать работу заново. Те же, кто использует ЯВУ, имеют шанс отделаться в основном перекомпиляцией, переписав небольшую аппаратно-зависимую часть кода.
Следует также иметь в виду, что арсенал инструментальных средств разработчика не исчерпывается одним лишь транслятором. В случае языка C в распоряжении разработчика оказывается также масса различных утилит, которые помогают ему:
  • находить вероятные ошибки в программе, которые не являются ошибками с точки зрения компилятора, но вызывают обоснованные подозрения статического анализатора (Splint);
  • производить тестирование модулей программы (Unity, CMock);
  • обрабатывать исключительные ситуации (CException);
  • документировать программу (Doxygen).
Этот список утилит, конечно же, неполон, продолжать его можно еще долго. Их аналоги для ассемблера если и существуют, то по крайней мере мне не известны.
Сторонники использования ассемблера обосновывают свой выбор тем, что при непосредственной работе с микроконтроллером на самом низком уровне (машинных команд, регистров и ячеек памяти) можно более эффективно использовать его ресурсы, создавая более компактные и быстрые программы, чем компилятор ЯВУ. Ресурсов и правда слишком много не бывает, равно как и слишком быстрых программ, а в случае микроконтроллеров это тем более актуально. Поэтому на первый взгляд такой выбор представляется вполне разумным. Но мы не будем ничего принимать на веру, а попробуем проверить лично.
Для начала откомпилируем наш проект с оптимизацией. Когда компиляция завершится успешно, посмотрим, какой же код сгенерировал компилятор. Для наглядности сгенерированный код приведу сразу после исходного.

Код: (C) Blinker.c
#include "Application.h"

int main()
{
    Application_init();
       
    for (;;)
        Application_run();
}

Код:
00000090 <main>:
  90: 0e 94 36 00 call 0x6c ; 0x6c <Application_init>
  94: 0e 94 3b 00 call 0x76 ; 0x76 <Application_run>
  98: fd cf       rjmp .-6       ; 0x94 <main+0x4>

Пока не вижу ничего лишнего, только вызов функции, а затем зацикленный вызов другой функции.

Код: (C) Application.c
#include "Application.h"
#include "Led.h"
#include "Timer.h"

void Application_init(void)
{
    Led_init();
    Timer_init();
}

void Application_run(void)
{
    Led_on();
    Timer_wait(500);
    Led_off();
    Timer_wait(1500);
}

Код:
0000006c <Application_init>:
  6c: 0e 94 51 00 call 0xa2 ; 0xa2 <Led_init>
  70: 0e 94 55 00 call 0xaa ; 0xaa <Timer_init>
  74: 08 95       ret

00000076 <Application_run>:
  76: 0e 94 4d 00 call 0x9a ; 0x9a <Led_on>
  7a: 84 ef       ldi r24, 0xF4 ; 244
  7c: 91 e0       ldi r25, 0x01 ; 1
  7e: 0e 94 56 00 call 0xac ; 0xac <Timer_wait>
  82: 0e 94 4f 00 call 0x9e ; 0x9e <Led_off>
  86: 8c ed       ldi r24, 0xDC ; 220
  88: 95 e0       ldi r25, 0x05 ; 5
  8a: 0e 94 56 00 call 0xac ; 0xac <Timer_wait>
  8e: 08 95       ret

В функции Application_run мы видим, что компилятор использовал регистры r24 и r25 для передачи параметра в вызываемую функцию Timer_wait. Передача параметров через регистры общего назначения эффективнее, чем через стек, благо микроконтроллер с архитектурой RISC располагает множеством таких регистров. Подобный прием наверняка использовал бы и программист на ассемблере. Пока что подкопаться не к чему.

Код: (C) Led.c
#include "Led.h"
#include <avr/io.h>

/* Инициализаци порта светодиода.
 * Выбран порт C, бит 0, управление инверсное.
 */

void Led_init(void)
{
        // программируем бит 0 порта C на вывод
        DDRC |= _BV(PC0);
        // выключить светодиод
        Led_off();
}

// включить светодиод
void Led_on(void)
{
        PORTC &= ~_BV(PC0);
}

// выключить светодиод
void Led_off(void)
{
        PORTC |= _BV(PC0);
}

Код:
0000009a <Led_on>:
  9a: a8 98       cbi 0x15, 0 ; 21
  9c: 08 95       ret

0000009e <Led_off>:
  9e: a8 9a       sbi 0x15, 0 ; 21
  a0: 08 95       ret

000000a2 <Led_init>:
  a2: a0 9a       sbi 0x14, 0 ; 20
  a4: 0e 94 4f 00 call 0x9e ; 0x9e <Led_off>
  a8: 08 95       ret

В функциях Led_on и Led_off оптимизатор откровенно порадовал, сообразив реализовать битовые операции над регистрами инструкциями установки и сброса отдельных битов. В функции Led_init в принципе можно было бы развернуть вызов Led_off, сэкономив пару байт кода, пару байт стека и несколько тактов на вызове функции и возврате из нее. Впрочем, это легко лечится изменением опции оптимизации с -Os на -O3:

Код:
0000009a <Led_init>:
  9a: a0 9a       sbi 0x14, 0 ; 20
  9c: a8 9a       sbi 0x15, 0 ; 21
  9e: 08 95       ret

Код: (C) Timer.c
#include "Timer.h"
#include <stddef.h>
#include <util/delay_basic.h>

void Timer_init(void)
{
    // в версии с программной задержкой инициализация не требуется
}

// программная задержка 1 ms
static void wait1ms(void)
{
        _delay_loop_2(4000);
}

void Timer_wait(uint16_t ms)
{
    for (unsigned short i = 0; i < ms; ++i)
    {
                wait1ms();
    }
}

Код:
000000aa <Timer_init>:
  aa: 08 95       ret

000000ac <Timer_wait>:
  ac: 20 e0       ldi r18, 0x00 ; 0
  ae: 30 e0       ldi r19, 0x00 ; 0
  b0: 40 ea       ldi r20, 0xA0 ; 160
  b2: 5f e0       ldi r21, 0x0F ; 15
  b4: 05 c0       rjmp .+10     ; 0xc0 <Timer_wait+0x14>
  b6: fa 01       movw r30, r20
  b8: 31 97       sbiw r30, 0x01 ; 1
  ba: f1 f7       brne .-4       ; 0xb8 <Timer_wait+0xc>
  bc: 2f 5f       subi r18, 0xFF ; 255
  be: 3f 4f       sbci r19, 0xFF ; 255
  c0: 28 17       cp r18, r24
  c2: 39 07       cpc r19, r25
  c4: c0 f3       brcs .-16     ; 0xb6 <Timer_wait+0xa>
  c6: 08 95       ret

В функции Timer_wait оптимизатор догадался раскрыть не только вспомогательную функцию wait1ms(), но и библиотечную _delay_loop_2(). Конечно, использование r18 и r19 выглядит лишним, ведь можно было бы в качестве счетчика использовать r24 и r25.
Попытаемся немного помочь оптимизатору:

Код: (C) Timer.c
void Timer_wait(uint16_t ms)
{
    for (; ms > 0; --ms)
    {
                wait1ms();
    }
}

Код:
000000aa <Timer_wait>:
  aa: 00 97       sbiw r24, 0x00 ; 0
  ac: 39 f0       breq .+14     ; 0xbc <Timer_wait+0x12>
  ae: 20 ea       ldi r18, 0xA0 ; 160
  b0: 3f e0       ldi r19, 0x0F ; 15
  b2: f9 01       movw r30, r18
  b4: 31 97       sbiw r30, 0x01 ; 1
  b6: f1 f7       brne .-4       ; 0xb4 <Timer_wait+0xa>
  b8: 01 97       sbiw r24, 0x01 ; 1
  ba: d9 f7       brne .-10     ; 0xb2 <Timer_wait+0x8>
  bc: 08 95       ret

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

Лирическое отступление
Стремление оптимизировать программу за счет написания ее полностью на ассемблере — это лишь один из симптомов более общей болезни, которая называется преждевременной оптимизацией. Пораженные этой болезнью могут, к примеру, потратить неделю на то, чтобы сократить программу на десяток байт (имея при этом полтора килобайта незадействованной памяти) или сократить на пару микросекунд время выполнения функции (которая вызывается один раз во время инициализации устройства).
Из этого никоим образом не следует, что оптимизация как таковая совершенно бесполезна, и заниматься ею не следует вовсе. Напротив, оптимизация, проведенная с умом, может полностью преобразить программу.
Прежде всего, следует помнить знаменитый вездесущий закон Парето, который применительно к программам звучит так: выполнение примерно 20% кода занимает примерно 80% времени выполнения программы. Его не следует воспринимать буквально: в частном случае соотношение может быть, например, не 20/80, а 10/90 (а некоторые авторы упоминают даже 5/95). Дело не в точных цифрах, а в самом принципе: небольшая часть кода определяет основное время выполнения программы. Именно эту небольшую часть и следует оптимизировать в первую очередь. Возня с остальной частью программы может оказаться пустой тратой времени и не дать видимого результата.
Разумный подход к оптимизации предполагает в первую очередь выявление критических участков кода и лишь затем попытку их оптимизации. При этом необходимо располагать средствами снятия достоверных метрик кода, без которых невозможны ни диагностика кода, ни оценка результата оптимизации. Именно для кодирования критических участков может оказаться полезным применение ассемблера (я говорю лишь «может», поскольку наше небольшое исследование показало, что это вовсе не панацея и не гарантирует положительный результат автоматически).
Подробнее с принципами эффективной оптимизации кода вы можете познакомиться в переводе статьи Джозефа Ньюкамера «Оптимизация: ваш злейший враг» в библиотеке клуба.

Последние шаги

И вот, наконец, долгожданный прототип в наших руках:

Рис. 2. Плата прототипа изделия с подключенным программатором.

Прошиваем программу, включаем устройство:


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

Подводим итоги

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

  • Почти весь проект был реализован путем разработки через тестирование. Я говорю «почти», поскольку некоторые фрагменты (вроде управления битами портов светодиода) не были (да и не могли быть в данных условиях) протестированы. Впрочем, такое тестирование в принципе возможно, и при случае я продемонстрирую, как это делается. Для этого потребуется более развитая среда, чем предоставляет наш нынешний простейший прототип, поскольку модульное тестирование будет проводиться непосредственно на целевой системе.
  • Мы проектировали систему в направлении «сверху-вниз», от общей архитектуры к деталям реализации. Такой метод лучше подходит для разработки сложных систем, поскольку спецификации модулей нижнего уровня диктуются требованиями модулей верхнего уровня, а не наоборот, и в итоге мы получаем то, что требуется заказчику, а не то, что удается слепить из имеющихся в наличии кирпичиков.
  • Интерфейсы модулей были отделены от их реализации. Это позволило нам, во-первых, использовать абстракции при написании кода, во-вторых, при необходимости иметь несколько реализаций одного модуля (например, версию модуля для инструментальной системы, версию для целевой, а также версию в виде подставного объекта). Получилось некое подобие «полиморфизма времени компоновки», который был полезен как при кодировании, так и при тестировании.
  • Мы научились использовать тестовые двойники для тестирования модулей, зависящих от других, еще не реализованных модулей нижнего уровня. Это позволяет писать сначала тесты, потом код, а не вынуждает откладывать тестирование до лучших времен, когда можно будет провести его в естественном окружении модуля.
  • Мы начали работу над проектом сразу, не дожидаясь прототипа реального устройства. Это позволило нам получить рабочий код, протестированный на модели в инструментальной системе и затем проверенный на симуляторе. В результате, как только прототип попал к нам в руки, мы сразу же смогли прошить в него программу и убедиться в ее работоспособности без всякой отладки на целевой системе.
  • Мы имели возможность лично убедиться, что предрассудки по поводу якобы неэффективности кода, написанного на языке программирования C, мягко говоря, несостоятельны и ничем не обоснованы. Результирующий код получился вполне логичным, компактным и без наличия лишнего «жира», который следовало бы «вытопить».
  • Попутно были приобретены навыки использования Doxygen для создания проектной документации.



На этом, пожалуй, можно поставить точку. Мы довели наш учебный проект от идеи до реализации и можем приниматься за более серьезные дела, используя полученные навыки.
Желаю успехов в ваших разработках!
Версия для печати
Обсудить на форуме (17)