неділю, 21 лютого 2016 р.

Далекомір HC-SR04 -- використовуючи GPIO/CMSIS

Структура GPIO-піна, з
офіційної документації.
Клікабельно.
В попередньому пості було описано цікавий пристрій -- ультразвуковий далекомір, HC-SR04. Він може служити хорошим демонстраційним прикладом для багатьох технік роботи із мікроконтролерами. 

Подальші пости використовуватимуть плату STM32VLDiscovery із її мікроконтролером  STM32F100RB, архітектури ARM Cortex M3, котрий має 128Кб пам'яті програм та 8Кб RAM. Для інших споріднених мікроконтролерів відмінності будуть мінімальними. 

Спершу розглянемо найбільш базову (але, відповідно, і найбільш низькорівневу) методику -- із використанням CMSIS. Також, буде використано найбільш примітивну техніку роботи із пристроєм -- безпосереднє смикання ніжок мікроконтролера та читання рівнів на них (іншими словами, самим GPIO, без використання переривань).

CMSIS -- GPIO



В проекті, говорячи в термінах CoIDE, буде використано наступні компоненти: CMSIS core, STM32F10x_MD_VL_STDLIB -- хоча SPL ми не використовуємо, деякі файли CMSIS, котрі стосуються цього контролера, (в першу чергу -- stm32f10x.h), знаходяться в цій компоненті, Semihosting як допоміжний засоби наглядного вводу-виводу та C runtime, описаний в "Стандартна бібліотека C та SemiHosting (на прикладі STM32 і CoIDE)". (В принципі, для наших потреб, компоненти C_library, що йде із CoIDE, було б досить, але вона неохайна).

Зрозуміло, що слід включити їх файли заголовків:

#include <stdint.h>
#include <cstdio>

#include <stm32f10x.h>
#include <semihosting.h>

В проекті на C++ може бути потреба робити так (старіші варіанти CMSIS  не були готові до включення в С++ файли, зараз це виправлено):

#include <stdint.h>
#include <cstdio>
extern "C"
{
#include <stm32f10x.h>
#include <semihosting.h>
}
Перша задача, яка постає -- відлік часу для затримок і для заміру тривалості імпульсів. Підемо найпростішою дорогою, скористаємося SysTick. Це такий собі простенький таймер-лічильник. Детальніше про нього я вже писав тут, (зокрема, там є посилання на статтю DiHalt-a) Оголосимо глобальну змінну-лічильник та обробник переривання від таймера:

volatile uint32_t curTicks;

void SysTick_Handler(void) {
    curTicks++;       /* Зроблено ще один тік */
}

Нагадаємо, що функція із іменем SysTick_Handler замінить функцію-обробник переривання за замовчуванням. Для проектів на C++ пам'ятаємо обгорнути оголошення SysTick_Handler в extern "C", щоб name mangling його не зіпсувало, інакше все буде компілюватися та лінкуватися успішно, ОДНАК обробник не викликатиметься -- у нього буде інше ім'я зв'язування і дефолтне weak-оголошення не заміститься. Специфікатор volatile змушує компілятор читати змінну із пам'яті кожного разу, коли до неї відбувається звертання -- натяк йому, що вона може змінюватися "самовільно".

Так як імпульс слід подавати на 10-15 мкс, то роздільна здатність таймера повинна бути порядку мікросекунд. Тому, для задання кількості тактів, після якої SysTick має генерувати своє переривання, використаємо наступну "конструкцію".

Для С++, за умови, що ініціалізація статичних змінних підключена (див. тут) робимо так:

const uint32_t microseconds_granularity=4;
// Потрібна частота спрацювань таймера SysTick в герцах
const uint32_t freq=1000000/microseconds_granularity;
const uint32_t ticks=SystemCoreClock/freq;

Для чистого С (яке вимагає, щоб ініціалізатори глобальних змінних були відомі на момент компіляції, а const type не вважає такими):

#define MICROSECONDS_GRANULARITY 2
// Потрібна частота спрацювань таймера SysTick в герцах
#define FREQ ((1000000)/(MICROSECONDS_GRANULARITY))
#define TICKS ((SystemCoreClock)/(FREQ))


microseconds_granularity (MICROSECONDS_GRANULARITY) задає, раз в скільки мікросекунд має спрацьовувати таймер. На жаль, потужності нашої плати для спрацювання раз у мікросекунду не вистачає. freq міститиме частоту генерації переривань в герцах. SystemCoreClock містить частоту контролера, у нас -- 24000000 (24МГц). Відповідно, ticks (TICKS) -- кількість тіків таймера у секунді.

Зауваження 1: не знаю в чому справа, але при роботі з CoIDE іноді абсолютно загадково SystemCoreClock містило 0. Довге розслідування так і не виявило, в чому справа -- всі define і ifdef виглядали коректними. Потім само пройшло і поки більше не повторювалося. Можливо, було пов'язаним із ініціалізацією глобальних змінних, але проект був С-ний... Схожа, хоч і зрозуміла ситуація, якщо раптом, скажімо,  ticks стає рівним 0 -- значить ініціалізацію глобальних змінних забули здійснити, див. тут

Зауваження 2: на частоті 24МГц тривалість одного такту складає приблизно 42 наносекунди, а в одній мікросекунді буде всього 24 такти. Виклик переривання це 12 тактів (з нюансами, див., наприклад, "The Definitive Guide  to the ARM® Cortex-M3", глава 9), плюс інкремент змінної, плюс повернення з переривання -- контролер просто не встигатиме. Ось одне переривання в дві мікросекунди -- вже цілком нормально. Якщо вам обчислювальні ресурси більше ні для чого не потрібні, звичайно.

Таким чином, раз на microseconds_granularity мікросекунд curTicks буде збільшуватися на одиницю. На цьому легко будується функція затримки на заданий інтервал:

inline static void delay_some_us(uint32_t mks)
{
    uint32_t ticks=mks/microseconds_granularity;
    int stop_ticks=ticks+curTicks;
    while (curTicks < stop_ticks) {};
}

Кількість мікросекунд ділимо на "квант", microseconds_granularity; вираховуємо, скільки має відрахувати лічильник; додавши до його поточного значення, знаходимо на якому значенні лічильника слід зупинитися; і нарешті чекаємо, поки він не досягне того значення. Операція "<" використана замість "!=", щоб якщо з якихось причин у функцію виконання повернеться через кілька відліків лічильника (наприклад, через виникнення інших високопріоритетних переривань), вона не працювала поки лічильник не переповниться і знову не дорахує до  stop_ticks. (А переповнення 32-бітного лічильника треба буде трохи довго чекати.)

Нарешті, щоб та вся машинерія запрацювала, десь в тілі main слід ввімкнути SysTick викликом функції SysTick_Config() із CMSIS:

    // Ініціалізуємо таймер SysTick
    if (SysTick_Config (ticks))
    {
        // Помилка -- таймер не ініціалізувався
        GPIOC->BSRR = GPIO_BSRR_BS8; // Синій світлодіод позначає помилку
        printf("Error -- SysTick failed to start.\n");
        while(1); // Зависаємо
    }

Тіло програми працюватиме із чотирма пінами:
  • PB1 -- Trig
  • PB2 -- Echo
  • PC8 -- синій світлодіод (для візуальної наочності процесів у платі)
  • PC9 -- зелений світлодіод (для візуальної наочності процесів у платі)
Тому спочатку слід ввімкнути тактування портів B і C (деталі див. тут, у розділах "Шини і тактові генератори" та за посиланнями там):

    // HC-SR04 підключено до PB1 (Trig) i PB2 (Echo)
    // Дозволяємо тактування порту B
    RCC->APB2ENR     |= RCC_APB2ENR_IOPBEN;
    // Дозволяємо тактування порту C, на ньому -- світлодіоди плати STM32VLDiscovery
    // PC8 -- blue, PC9 -- green
    RCC->APB2ENR     |= RCC_APB2ENR_IOPCEN;

Не вдаючись в деталі, для довідки: RCC -- підсистема тактування,  APB2ENR -- "APB2 peripheral clock enable register",  RCC_APB2ENR_IOPBEN і RCC_APB2ENR_IOPСEN -- бітмаски бітів у APB2ENR, що керують тактуванням портів B і С відповідно (CMSIS -- він такий, нескінчені назви-абревіатури :).

Наступний крок -- сконфігурувати піни. PB1 (Trig), PC8 i PC9 -- на вивід, Push-Pull, PB2 -- на ввід, Floating.

Деталі роботи з пінами див у розділі GPIO за посиланнями тут. Лише для довідки: для кожного виводу слід задати 4 біти. Порти мають по 16 пінів, тому для керування використовують два 32-бітних регістри (4*8=32, 16=8+8, див. також гарну картинку від DiHalt-а та його статтю): CRL для молодших 8 і CRH для старших 8. Значення кожного із 4-х бітів пояснено у таблиці з Reference Manual:
(c) STM32F100xx advanced ARM-based 32-bit MCUs Reference manual

    // На  Trig (PB1) подаватимемо сигнал
    GPIOB->CRL &= ~GPIO_CRL_CNF1;// Скинути обидва біти CNF для піна 1. 
                                 // Режим 00 - Push-Pull. Це молодші піни порту, 
                                 // тому звертаємося до CRL - Low
    GPIOB->CRL |= GPIO_CRL_MODE1; //Встановити обидва біти MODE 
                                  //для піна 1 -- швидкість 50MHz

GPIO_CRL_CNF1 -- біт-маска для бітів CNF піна 1, операція & (and) з їх запереченням (~) занулить їх, вибравши режим Push-Pull. GPIO_CRL_MODE1 -- аналогічно маска двох бітів поля MODE, встановлюємо їх в одиниці, вибравши режим 50МГц. Нам така велика швидкість не потрібна (та й недоступна конкретному контролеру, з його 24МГц), але її найпростіше вибрати (див. таблицю вище :).

    // З Echo його зніматимемо
    GPIOB->CRL |= GPIO_CRL_CNF2_0; // CNF[0]=1, CNF[1]=0 для піна 2. 
                                   // Режим 01 - Floating-in
    GPIOB->CRL &= ~GPIO_CRL_CNF2_1;
    GPIOB->CRL &= ~GPIO_CRL_MODE2; // Скинути біти MODE, мають бути нуль для 
                                   // режиму входу

Тут вже використано дві бітмаски, окремо для 0-го і 1-го біта CNF2: GPIO_CRL_CNF2_0 і GPIO_CRL_CNF2_1.

Аналогічно налаштовуються PC8 i PC9, тільки робота відбувається з CRH:

    //! Порти світлодіодів - теж на вивід. Звертаємося до старших пінів, 
    //! тому CRH -- High
    GPIOC->CRH &= ~GPIO_CRH_CNF8;
    GPIOC->CRH |= GPIO_CRH_MODE8;
    GPIOC->CRH &= ~GPIO_CRH_CNF9;
    GPIOC->CRH |= GPIO_CRH_MODE9;

Для керування пінами можна скористатися цілим зоопарком регістрів: що в біт GPIOx_ODR (де x -- порт, A, B, C, D і т.д. ) записано, те і передається на вивід (0 -- він і є нуль, 1 -- притягує до 1 в режимі Push-Pull, переводить в HiZ у режимі Open Drain -- відкритого колектора). В ньому використовуються лише молодші 16 бітів. Запис у біт регістра GPIOx_BRR зануляє відповідний біт у ODR. Тобто, щоб занулити 3-ї біт, достатньо записати в BRR значення 1<<3 (що еквівалентно 1000b), тоді як без нього довелося б прочитати те, що в ODR, замаскувати 3-й біт і записати назад. І довше, і переривання може вклинитися. Аналогічно молодших 16 бітів регістра GPIOx_BSRR працюють на встановлення. Однак він хитріший! Старших його 16 бітів працюють на занулення, як молодші у BRR. Тому його і називають Bit Set/Reset Register. Стан піна можна прочитати із регістру GPIOx_IDR. (Подробиці цього зоопарку, традиційно, за посиланнями вище, або в даташіті на мікроконтролер).

Перевіряємо, чи на лінії Echo є нуль, до того, як ми подали перший імпульс на Trig. Бо, як кажуть, "якщо яка-небудь неприємність може трапитись, — вона таки трапиться":

    // Перевіримо, чи лінія Echo в нулі поки ми ще не подали імпульс
    if(GPIOB->IDR & GPIO_IDR_IDR2 )
    {
        // Помилка -- імпульсу не було, а на Echo вже одиниця
        GPIOC->BSRR = GPIO_BSRR_BS8; // Синім позначатимемо помилку
        printf("Error -- Echo line is high, though no impuls was given\n");
        while(1); // Зависаємо
    }

GPIO_IDR_IDR2 -- бітмаска для стану другого піна, якщо він не нуль, значить на Echo висить одиничка (мало, далекомір згорів, щось не так підключили, і т.д.) -- встановлюємо біт 8 порту C, що засвічує синій світлодіод, через Semihosting повідомляємо про помилку і зависаємо.

Раз все ОК -- можна перейти до вимірів. Для того знадобиться ряд змінних із очевидними назвами:

uint32_t starting_ticks;
uint32_t timeout_ticks;
uint32_t distance_mm;

bool are_echoing=false;

Далі, у нескінченому циклі, while(1), робимо таке:

//! Якщо не подавали сигнал - вперед, подаємо
if(!are_echoing)
{
    // Пауза між вимірами, двічі по 1/4 секунди
    delay_some_us(500000/2);
    // Гасимо світлодіоди
    GPIOC->BSRR = GPIO_BSRR_BR8;
    GPIOC->BSRR = GPIO_BSRR_BR9;
    delay_some_us(500000/2);
    are_echoing=true;
    // Посилаємо імпульс
    GPIOB->BSRR = GPIO_BSRR_BS1;
    delay_some_us(12);
    GPIOB->BSRR = GPIO_BSRR_BR1;
    // Починаємо заміряти час
    timeout_ticks=curTicks;
}else{

Чекаємо чверть секунди, гасимо обидва світлодіоди, чекаємо ще стільки ж, щоб встигнути побачити, що вони вимкнулися, виставляємо прапорець, що вказує -- відбувається вимір, подаємо на вивід Trig (PB1) сигнал, чекаємо 12 мікросекунд (похибка складатиме microseconds_granularity, допустимий інтервал 10-15 мкс), подаємо нуль. Тепер можна чекати на відповідь від далекоміра:

}else{
    // Якщо ж подали -- чекаємо на імпульс і заміряємо його тривалість
    bool measuring=false;
    uint32_t measured_time;
    // Скільки чекати, поки не вирішити, що імпульсу вже не буде
    uint32_t usoniq_timeout = 100000;
    while( (curTicks - timeout_ticks) < usoniq_timeout )
    {

Задаємо інтервал часу, який чекатимемо на імпульс, для простоти -- в тіках, а не в мікросекунда. Далі, в циклі:

// Перевіряємо логічний рівень на Echo
if(GPIOB->IDR & GPIO_IDR_IDR2 )
{
    // Якщо раніше сигналу не було, починаємо заміряти його тривалість
    if(!measuring)
    {
        starting_ticks=curTicks;
        measuring=true;
    }
}else{
    // Якщо сигнал на Echo був і зник -- заміряємо його тривалість
    if(measuring)
    {
        // Результат буде в міліметрах
        measured_time = (curTicks - starting_ticks)*10*microseconds_granularity;
        distance_mm = measured_time/58;
        // Повідомляємо про успішний вимір зеленим світлодіодом
        GPIOC->BSRR = GPIO_BSRR_BS9;
        break;
    }
}

Перевіряємо, чи є логічна одиничка на вході Echo -- PB2, якщо є, але на попередній ітерації не було (measuring=false), починаємо відраховувати час. Якщо одинички немає, але раніше була -- імпульс закінчився, можна виходити з циклу очікування і повертати результат. Про успішне завершення вимірювання повідомляє зелений світлодіод.

Зауваження: функція pulseIn в Arduino та LeafLabs Maple IDE (аналог програмного забезпечення Arduino для "кортексів") саме так примітивно і працює -- постійно опитує "ногу" контролера та й все.

Кількох слів вартує загадкове число 58, на яке ділиться виміряний час, щоб отримати віддаль. Швидкість звучу за кімнатної температури можна взяти рівною 343 м/с = 343 000 мм/с = 343 мм/мс = 0.343 мм/мкс. Тоді віддаль в міліметрах, із тривалості в мікросекундах, знаходитиметься так: \[l=t \frac{343}{2\times 1000} \approx \frac{t}{5.8} \]. Контролер не вміє працювати із числами з рухомою крапкою, а навіть якби умів, чи якщо використати програмну емуляцію, це повільно. Однак, можна схитрувати -- час в мікросекундах помножити на 10, а потім результат цілочисельно поділити на 58. Похибка таких розрахунків не перевищуватиме 1 мм. Результат можна ще покращити, додавши до чисельника одиницю, щоб забезпечити коректніше заокруглення, тоді похибка впаде до 0.5 мм, однак сенсу немає -- похибка самого далекоміра - не менше 3 мм.

if(!measuring)
{
    // Не отримали сигналу, повідомляємо про помилку і пробуємо ще
    GPIOC->BSRR = GPIO_BSRR_BS8;
    printf("Echo signal not arrived in time\n");
}else{
    printf("Distance: %u, measured time: %u\n",distance_mm, measured_time/10);
}
are_echoing=false;

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

Результат виглядатиме в CoIDE якось так (віддаль в міліметрах, час в мікросекундах):


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

Проект для CoIDE можна скачати тут.  Проект -- C, для простоти. В С++ можна перетворити без проблем.


Немає коментарів:

Дописати коментар