MBED, или о дырявых абстракциях

Понадобилось взглянуть в сторону mbed. На первый взгляд выглядело весьма интересно — железонезависимый фреймворк, на С++, с поддержкой кучи микроконтроллеров и демо-плат, онлайн-компилятор с интеграцией в систему контроля версий. Куча примеров, еще более убеждающих в элегантности фреймворка. Прямо «из коробки» доступны практически все интерфейсы микроконтроллера при помощи соответствующих, уже реализованных классов. Вот прямо из коробки бери и программируй на С++, не заглядывая в даташит от микроконтроллера — ну не мечта ли?

Тестовой платформой стала давно лежащая без дела STM Nucleo F030, поддерживаемая этой платформой. О том, как зарегистрироваться и начать первый проект, есть много хороших туториалов, об этом не будем. Перейдем сразу к интересному.

Данная плата не содержит слишком много периферии «на борту». Светодиод и кнопка — вот и все богатство. Что ж, первый проект — классический «Hello world» из мира микроконтроллеров — помигать светодиодом. Вот код:

#include "mbed.h"
DigitalOut myled(LED1);
int main() {
    while(1)
    {
        wait_ms(500);
        myled = myled ^ 1;
    }
}


Красиво ведь, нет? В даташит контроллера действительно даже заглядывать не нужно!

Кстати, классический «Hello world» тоже доступен сразу же из коробки:

#include "mbed.h"
Serial pc(USBTX, USBRX);
int main() {
    pc.printf("Hello world\n\r");
    while(1)
    {
    }
}


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

Но вернемся к мигающему светодиоду. Компилим, загружаем на плату — мигает! Но как-то слишком быстро, явно не раз в секунду, а минимум раз 5… Упс…

Немного отвлекусь на упомянутые в заголовке «дырявые абстракции». Про этот термин я прочитал у Джоэля Спольски. Вот его цитата:»Если я обучаю программистов C++, было бы здорово, если бы мне не нужно было рассказывать им про char* и арифметику указателей, а можно было сразу перейти к строкам из стандартной библиотеки темплейтов. Но в один прекрасный день они напишут «foo»+«bar», и возникнут странные проблемы, а мне придётся всё равно объяснить им, что такое char*. Или они попытаются вызвать функцию Windows с параметром типа LPTSTR и не смогут, пока не выучат char* и указатели и Юникод и wchar_t и хедерные файлы TCHAR — все то, что просвечивает через дырки в абстракциях.»

Итак, как бы парадоксально это ни было, закон дырявых абстракций не позволяет вот так вот взять и начать программировать микроконтроллер (даже если это самый простой «hello world» из трех строчек), не заглядывая в его даташит и не имея представления о том, что же у микроконтроллера внутри, а также о том, что стоит за всеми этим классами в абстракции. Вопреки обещаниям маркетологов…

Итак, тяжело вздохнув, начинаем искать. Будь у меня перед глазами не абстракция, а полноценная среда разработки для контроллера, было бы понятно, куда копать. Но вот куда копать в случае приведенного выше кода? Если даже содержимое файла mbed.h абстракция не позволяет увидеть?

К счастью, сама библиотека mbed — c открытым кодом, и ее можно скачать и посмотреть, что же там внутри. Оказалось, опять же к счастью, что сквозь абстракцию для программиста вполне доступны все регистры микроконтроллера, хоть они явно и не объявлены. Уже лучше. Например, можно проверить, что у нас с тактовой частотой, запустив вот это (для чего пришлось и в даташиты позаглядывать, и сам mbed изрядно покопать):

  RCC_OscInitTypeDef str;
  RCC_ClkInitTypeDef clk;
    pc.printf("SystemCoreClock = %d Hz\n\r", HAL_RCC_GetSysClockFreq());
    
    HAL_RCC_GetOscConfig(&str);
    pc.printf("->HSIState %d\n\r", str.HSIState);
    pc.printf("->PLL.PLLState %d\n\r", str.PLL.PLLState);
    pc.printf("->PLL.PLLSource %d\n\r", str.PLL.PLLSource);
    pc.printf("->PLL.PLLMUL %d\n\r", str.PLL.PLLMUL);
    pc.printf("->PLL.PREDIV %d\n\r", str.PLL.PREDIV);
    pc.printf("\n\r");
    
    HAL_RCC_GetClockConfig(&clk, &flat);
    pc.printf("ClockType %d\n\r", clk.ClockType);
    pc.printf("SYSCLKSource %d\n\r", clk.SYSCLKSource );
    pc.printf("AHBCLKDivider  %d\n\r", clk.AHBCLKDivider );
    pc.printf("APB1CLKDivider %d\n\r", clk.APB1CLKDivider );



С частотой оказалось все хорошо, она была 48 МГц, как и было задумано.
Что ж, копаем дальше. Пробуем вместо wait_ms () подключить таймер:

   Timer timer;
    timer.start();
    while(1) {
        myled ^= 1;
        t1 = timer.read_ms();
        t2 = timer.read_ms();
        while (t2 - t1 < 500)
        {
            t2 = timer.read_ms();
        }
    }

Ведь красиво же, нет? Вот только светодиод по-прежнему мигает раз в 5 быстрее желаемого…

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

А скрывается за ним в случае STM F030 аппаратный таймер контроллера TIM1, запрограммированный так, чтобы отсчитывать микросекундные тики. А на этой основе уже построено все остальное.

Так, уже горячо. Помните, я говорил, что все регистры доступны? Смотрим следующее:

pc.printf("PSC: %d\n\r", TIM1->PSC);

Вуаля! Это ставит точку в расследовании того, кто виноват: на консоль отправляется число 7. В регистре PSC (писец? неужели просто совпадение?) находится значение, при достижении которого таймер сгенерирует прерывание и начнет считать заново. А на это прерывание и повешен микросекундный счетчик. И чтобы при частоте в 48 МГц прерывание происходило раз в микросекунду, там должно быть совсем не 7, а 47. А 7 туда попало, скорее всего, на самом первом этапе загрузки микроконтроллера, т.к. он запускается на частоте 8 МГц, а потом перенастраивает PLL так, чтобы частота умножилась на 6, дав желаемые 48 МГц. И похоже, что таймер инициализируется слишком рано…
Кто виноват, понятно. Но что же делать? Раскопки фреймворка привели к следующим цепочкам вызова:

Первая: SetSysClock_PLL_HSI () → HAL_RCC_OscConfig (), HAL_RCC_ClockConfig () → HAL_InitTick () — при изменении частоты вызвать функцию, настраивающую микросекундный тик.

Вторая: HAL_Init () → HAL_InitTick () → HAL_TIM_Base_Init () → TIM_Base_SetConfig () → TIMx→PSC — вызываемая из одной из самых-самых глобальных функций HAL_InitTick записывает значение в регистр PSC необходимое значение, в зависимости от текущей тактовой частоты…
Что осталось для меня загадкой — так это то, что именно для семейства STM F0 вторая цепочка не вызывается. Я не нашел ни одного вызова функции HAL_Init ()!

Более того, если заглянуть в реализацию HAL_InitTick (), то в самом ее начале будут следующие строчки:

    static uint32_t ticker_inited=0;  
    if(ticker_inited)return HAL_OK;
    ticker_inited=1;

Вызвать эту функцию получится ровно один раз. То есть вызвать-то ее можно сколько угодно раз, но все последующие вызовы она не будет делать ничего. И то, что фреймворк вызывает ее в случае изменения тактовой частоты, оказывается бесполезным…
Пересобирать библиотеку мне было лень, поэтому исправление обрело вот такой вид: первой строчкой в функции main стало вот это:

TIM1->PSC = (SystemCoreClock / 1000000) - 1;

После этого светодиод наконец-то стал мигать с правильной частотой 1 Гц…

Интересно, на каком этапе бы остановился гипотетический кто-то, кого маркетологи mbed убедили попробовать ставшее столь легким программирование микроконтроллера с нуля? Если с микроконтроллерами он до этого не сталкивался?

Ладно, если я еще не сильно утомил, движемся дальше. Мигать светодиодом — это хорошо, но неинтересно. Хочется чего-то большего. Из бОльшего нашелся датчик температуры DS1820, да и готовая библиотека для него нагуглилась. Простая в использовании, скрывающая всю сложность работы с этим датчиком внутри. Все, что надо — это написать что-то вроде

#include "DS1820.h"
DS1820 probe[1] = {D4}; // D4 – имя пина, к которому подключен датчик
probe[0].convert_temperature(DS1820::all_devices);
float temperature = probe[0].temperature('c');

Как вы думаете, что произошло после компиляции и запуска? Правильно. Оно не заработало :)

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

Датчик весьма интересный. Со своим цифровым интерфейсом. По одной линии, т.е. работает в режиме полудуплекса. Контроллер инициирует обмен данных, датчик посылает последовательность бит в ответ. Специально для таких случаев фреймворк mbed имеет класс DigitalInOut, при помощи которого можно у GPIO пина на лету менять направление его работы. Как-то так:

bool DS1820::onewire_bit_in(DigitalInOut *pin) {
    bool answer;
    pin->output();
    pin->write(0);
    wait_us(3);                 
    pin->input();
    wait_us(10); 
    answer = pin->read();
    wait_us(45); 
    return answer;
}

Контроллер посылает импульс »1 → 0 → 1» датчику, что является для него сигналом послать в ответ один бит. Что же тут может не работать? Вот кусочек даташита датчика:

1354956dcab04ddab94c48a2e49a3586.png

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

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

Не буду много писать о том, как я пришел к тому, чтобы выполнить вот этот код:

    pin->output();
    t1 = timer.read_us();
    pin->input();
    t4 = timer.read_us();
    pc.printf("Time: %d us\n\r", t4-t1);

Ну что, кто угадает, не читая дальше, сколько времени на микроконтроллере с тактовой частотой 48 МГц занимает изменение направления работы одного пина GPIO (вообще-то это ОДНА запись в регистр, если что), если его делать при помощи фреймворка mbed, написанного на С++ с использованием слоя, независимого от железа?

13 микросекунд.

А чтобы прочитать ответ датчика, у нас есть целых 15. И даже если совсем убрать wait_us (10) из кода выше, команда answer = pin→read (); тоже занимает некоторое количество времени, бОльшее, чем 2 микросекунды. Чего достаточно, чтобы не успеть прочитать ответ.

К счастью, послать импульс на STM оказалось возможным и без изменения направления работы GPIO. В режиме Input при подключении встроенного PullDown резистора эффект тот же. И еще раз к счастью, вызов pin→mode (PullUp) вместо pin→input () требует всего 6 микросекунд.

Чего оказалось достаточно, чтобы успеть прочитать ответ.

И напоследок еще одна дырка в абстракции. После того, как DS1820 заработал, следующим попавшим под руку датчиком стал DHT21, датчик, меряющий и температуру, и влажность. Тоже с 1-wire интерфейсом, но на этот раз кодирующий ответ длительностью импульсов. Казалось бы, очевидным решением будет повесить эту линию на GPIO input interrupt, что позволит легко измерять продолжительность импульсов. И даже класс в mbed для этого есть. И он даже работает так, как задокументировано.

Но проблема в том, что датчик также работает в полудуплексе. И чтобы он начал передавать данные, ему нужно послать импульс »1 → 0 → 1» как в примере выше.

И вот тут mbed этого сделать не позволяет. Либо ты объявляешь порт как DigitalInOut, но тогда не можешь использовать прерывания. Либо объявляешь порт как InterruptIn, но тогда не можешь ничего на него посылать. Трюк с PullDown, к сожалению, здесь не прокатил, т.к. датчик имеет встроенный PullUp, и встроенного PullDown микроконтроллера недостаточно, чтобы притянуть пин к 0.

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

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

© Geektimes