Современные тенденции в технике идут по пути все большей интеграции – прогресс позволяет делать процесс разработки электронных устройств все больше похожим на сборку конструктора. Одним из наиболее ярких примеров является огромное количество так называемых «систем на чипе» — SoC, связка из микроконтроллера и периферии практически на любой вкус. Сегодня мы с вами рассмотрим одну из таких систем, чип NRF51822 от Nordic Semiconductor — решение для систем, заточенных под работу с технологией BLE, включенной в стандарт Bluetooth 4.0.
А поскольку электронные устройства все больше увеличивают уровень интеграции, то почему бы и в программировании не подняться на более высокий уровень абстракции и сделать Bluetooth приложение под управлением операционной системы реального времени – RTOS.
Нашей целью в рамках этой статьи будет сделать простое встроенное приложение для NRF51822 под управлением RTOS Keil-RTX которое будет опрашивать АЦП раз в секунду и записывать полученные значения в характеристики BLE. Если Вас заинтересовала эта тема — добро пожаловать под кат.
Коротко о RTOS и BLE
Про RTOS было сказано уже достаточно много, поэтому дабы не повторяться посоветую почитать об этом здесь и здесь. Из важных особенностей использования RTOS хочется отметить только то, что они позволяют очень просто наращивать функционал программы не добавляя путаницы в код. Это возможно благодаря тому, что отдельные участки программы могут работать условно одновременно не мешая друг другу и могут быть выполнены как изолированные блоки. А если мы делаем приложение для «системы на чипе», которая гарантированно выполняет большое количество разносортных задач, многие из которых требовательны ко времени выполнения – то использование RTOS может значительно упростить нам жизнь.
Про то, что такое BLE и как оно устроено есть замечательный цикл статей, который в том числе и мне помог понять некоторые вещи. Если вы еще не читали – обязательно ознакомьтесь. В рамках же данной статьи нам более интересны практические нюансы работы с данной технологией.
Вообще говоря, написав «дружим BLE и RTOS» я позволил себе немного слукавить, поскольку в каком-то смысле эти два понятия очень даже неплохо уживаются и без нашего участия. Дело в том, что Bluetooth стэк, который мы используем в контроллере для работы со стандартом BLE сам по себе является в некотором роде RTOS, в которой существуют задачи, диспетчер для этих задач, в ней тикают таймеры и происходят события. Поэтому, по моему личному мнению, использование RTOS в BLE приложениях будет являться хорошим тоном, поскольку это позволит привести приложение в целом к одному уровню абстракции.
Необходимые инструменты
Чтобы не затрагивать базовые вопросы запуска NRF51 мы будем опираться на статьи товарища Foxek, поэтому и инструменты нам понадобятся те же:
• Отладочная плата NRF51
• Смартфон под управлением OC Android
• Android приложение nRF Connect
• MKD ARM Keil uVision
Настройка RTOS
Начнем мы классически – загрузим шаблонный проект ble_app_template с помощью Pack Installer (подробрее об этом шаге в статье Коротко о nRF51822: Быстрый старт ) и добавим туда необходимые зависимости с помощью менеджера Run-Time Environment предварительно загрузив их в Pack Installer-e:
Таким образом мы добавили RTOS к нашему проекту. Теперь, чтобы она могла взаимодействовать с железом необходимо произвести некоторую настройку. CMSIS-RTOS хорошо адаптирована для работы с процессорами на ядрах Cortex-M и, как правило, использует системный таймер SysTick для квантования времени в ОС. Однако контроллер NRF51 имеет на своем борту ядро Cortex-M0 и не обладает таймером SysTick, поэтому производитель рекомендует использовать для тактирования RTOS часы реального времени RTC1. Такое решение с одной стороны является плюсом, поскольку RTC1 не выключается в спящих режимах контроллера, а значит мы можем строить приложение с очень низким потреблением под управлением RTOS. С другой стороны, блоки RTC тактируются частотой всего 32768 Гц, а значит, размер кванта системного времени не получится сделать достаточно маленьким.
Для того, чтобы дать нашей ОС понять, с каким таймером она работает нам нужно добавить несколько функций: инициализации таймера и пару обработчиков событий и прерываний таймера. Для этого открываем файл RTX_Conf_CM.c из вкладки CMSIS,
добавляем к нему заголовочный файл nrf.h, находим раздел Global Functions и заменяем содержание функций в нем на следующее:
/*----------------------------------------------------------------------------
* Global Functions
*---------------------------------------------------------------------------*/
/*--------------------------- os_idle_demon ---------------------------------*/
#define TIMER_MASK 0xFFFFFF
volatile unsigned int rtos_suspend;
/// brief The idle demon is running when no other thread is ready to run
void os_idle_demon (void)
{
unsigned int expected_time;
unsigned int prev_time;
NVIC_SetPriority(PendSV_IRQn, NVIC_GetPriority(RTC1_IRQn));
for (;; )
{
rtos_suspend = 1;
expected_time = os_suspend();
expected_time &= TIMER_MASK;
if (expected_time > 2)
{
prev_time = NRF_RTC1->COUNTER;
expected_time += prev_time;
NRF_RTC1->CC[0] = (expected_time > TIMER_MASK) ?
expected_time - TIMER_MASK :
expected_time;
NRF_RTC1->INTENCLR = RTC_INTENSET_TICK_Msk;
NVIC_EnableIRQ(RTC1_IRQn);
__disable_irq();
if (rtos_suspend)
{
NRF_RTC1->INTENSET = RTC_INTENSET_COMPARE0_Msk;
__WFI();
NRF_RTC1->EVENTS_COMPARE[0] = 0;
NRF_RTC1->INTENCLR = RTC_INTENSET_COMPARE0_Msk;
}
__enable_irq();
NRF_RTC1->INTENSET = RTC_INTENSET_TICK_Msk;
expected_time = NRF_RTC1->COUNTER;
expected_time = (expected_time >= prev_time) ?
expected_time - prev_time :
TIMER_MASK - prev_time + expected_time;
}
os_resume(expected_time);
}
}
#if (OS_SYSTICK == 0) // Functions for alternative timer as RTX kernel timer
/*--------------------------- os_tick_init ----------------------------------*/
/// brief Initializes an alternative hardware timer as RTX kernel timer
/// return IRQ number of the alternative hardware timer
int os_tick_init (void) {
NRF_CLOCK->LFCLKSRC = (CLOCK_LFCLKSRC_SRC_Xtal << CLOCK_LFCLKSRC_SRC_Pos);
NRF_CLOCK->EVENTS_LFCLKSTARTED = 0;
NRF_CLOCK->TASKS_LFCLKSTART = 1;
while (NRF_CLOCK->EVENTS_LFCLKSTARTED == 0)
{
// Do nothing.
}
NRF_RTC1->PRESCALER = 32;//OS_TRV;
NRF_RTC1->INTENSET = RTC_INTENSET_TICK_Msk;
NRF_RTC1->TASKS_START = 1;
return (RTC1_IRQn); /* Return IRQ number of timer (0..239) */
}
/*--------------------------- os_tick_val -----------------------------------*/
/// brief Get alternative hardware timer's current value (0 .. OS_TRV)
/// return Current value of the alternative hardware timer
uint32_t os_tick_val (void) {
return NRF_RTC1->COUNTER;
}
/*--------------------------- os_tick_ovf -----------------------------------*/
/// brief Get alternative hardware timer's overflow flag
/// return Overflow flagn
/// - 1 : overflow
/// - 0 : no overflow
uint32_t os_tick_ovf (void) {
return NRF_RTC1->EVENTS_OVRFLW;
}
/*--------------------------- os_tick_irqack --------------------------------*/
/// brief Acknowledge alternative hardware timer interrupt
void os_tick_irqack (void) {
if ((NRF_RTC1->EVENTS_TICK != 0) &&
((NRF_RTC1->INTENSET & RTC_INTENSET_TICK_Msk) != 0))
{
NRF_RTC1->EVENTS_TICK = 0;
}
}
#if defined (__CC_ARM) /* ARM Compiler */
__asm __declspec(noreturn) void RTC1_IRQHandler(void)
{
EXTERN OS_Tick_Handler
BL OS_Tick_Handler
}
#else
#error "Unknown compiler! Don't know how to create SVC function."
#endif
#endif // (OS_SYSTICK == 0)
Функция os_idle_demon() отвечает как раз за работу системы при использовании режимов энергосбережения – если наше приложение не занято никакой задачей, то система сама погружает контроллер в сон.
Далее скажем нашей системе, что мы не будем использовать SysTick, ведь для этого у нас уже есть все функции, ну и заодно скажем с какой частотой у нас работает тактирования и как мы хотим квантовать системное время. Для этого открываем конфигурационную утилиту в том же файле RTX_Conf_CM.c и выставляем подходящие для нас параметры системы:
Интервал между системными отсчетами в 10мс выбран не случайно: если при частоте тактирования 32768 Гц мы используем предделитель 327, то частота таймера получится 99.9 Гц, что хоть с какой-то приемлемой точностью будет обеспечивать тайминги системы. Если же необходимо использовать меньшие значения для данного интервала, то можно подобрать такие значения, которые дадут приемлемую точность (например, делитель 31 и длина интервала 977 мкс), но это будут некруглые числа, что вносит некоторую путаницу в код. Также разрешим создавать потоки с нестандартным размером стэка, выделим память для этих потоков и поставим галочку на водяной знак для стэка – это сделает отладку более наглядной.
На данном этапе можно попробовать собрать и загрузить программу в контроллер. Операционная система будет стартовать и начнет выполнять функцию main как свой единственный поток. Однако, смысл RTOS в том, что разные задачи крутятся в разных потоках, поэтому теперь мы создадим свой поток для нашего BLE стэка:
// Определение пула памяти для потока ble_stack_thread
osPoolDef(ble_evt_pool, 8, ble_evt_t);
// Идентефикатор пула
osPoolId ble_evt_pool;
// Определение почтового ящика
osMessageQDef(ble_stack_msg_box, 8, ble_evt_t);
// Идентификатор почтового ящика
osMessageQId ble_stack_msg_box;
// Идентификатор потока для стэка
osThreadId ble_stack_thread_id;
// Прототип функции потока для стэка
void ble_stack_thread(void const * arg);
// Определение потока для стэка
osThreadDef (ble_stack_thread, osPriorityAboveNormal, 1, 400);
Здесь мы определили пул памяти, который будет использоваться стэком, почтовый ящик, через который стэк будет получать сообщения от системы, а также сам поток, декларировали его номер и функцию, в которой он будет выполняться. Теперь осталось определить чем будет заниматься сам поток, а так же объявить функцию его создания в системе и обработчик событий стэка:
// Тело потока для стэка BLE
void ble_stack_thread(void const * arg)
{
uint32_t err_code;
osEvent evt;
ble_evt_t * p_ble_evt;
UNUSED_PARAMETER(arg);
while (1)
{
evt = osMessageGet(ble_stack_msg_box, osWaitForever); // wait for message
if (evt.status == osEventMessage)
{
p_ble_evt = evt.value.p;
switch (p_ble_evt->header.evt_id)
{
case BLE_GAP_EVT_CONNECTED:
err_code = bsp_indication_set(BSP_INDICATE_CONNECTED);
APP_ERROR_CHECK(err_code);
m_conn_handle = p_ble_evt->evt.gap_evt.conn_handle;
break;
case BLE_GAP_EVT_DISCONNECTED:
m_conn_handle = BLE_CONN_HANDLE_INVALID;
break;
default:
// No implementation needed.
break;
}
(void)osPoolFree(ble_evt_pool, p_ble_evt);
}
}
}
// Функция создания потока для стэка BLE
void ble_create_thread (void)
{
ble_evt_pool = osPoolCreate(osPool(ble_evt_pool));
ble_stack_msg_box = osMessageCreate(osMessageQ(ble_stack_msg_box), NULL);
ble_stack_thread_id = osThreadCreate(osThread(ble_stack_thread), NULL);
}
Как видите, стандартная функция обработчика события on_ble_evt заменяется на простую передачу сообщения в поток, внутри которого уже организована логика, которую раньше выполнял обработчик.
Для регистрации потока в системе создадим функцию, которая создает пул памяти и почтовый ящик, а после этого создает поток и регистрирует его с идентификатором, который мы задекларировали чуть ранее. Осталось только добавить это все в main и запустить:
int main(void)
{
uint32_t err_code;
bool erase_bonds;
static osStatus status;
// Останавливаем ОС
status = osKernelInitialize();
// Инициализация
timers_init();
ble_stack_init();
device_manager_init(erase_bonds);
gap_params_init();
advertising_init();
services_init();
conn_params_init();
// Запуск рассылки рекламных пакетов
err_code = ble_advertising_start(BLE_ADV_MODE_FAST);
APP_ERROR_CHECK(err_code);
ble_create_thread();
// Запускаем ОС
status = osKernelStart();
}
Обратите внимание на функции osKernelInitialize() и osKernelStart() – поскольку main уже является потоком, то к моменту входа в него RTOS уже запущена. Однако создание потоков и настройку всей периферии лучше производить на остановленной ОС, поэтому первой функцией мы совершаем приостановку, а второй запуск обратно в работу.
Кроме того, я удалил из проекта все, что касается bsp_ble, поскольку эта библиотека пытается вмешаться в работу стэка в обход RTOS, а нам этого совсем не хочется. Еще я убрал из main сам основной цикл, так как main является потоком и после всех инициализаций у нас нет необходимости держать его память зарезервированной. Поэтому main завершается, а система продолжает работать, вот такие чудеса.
Теперь можно собрать программу, загрузить ее в контроллер и подключиться к нашему устройству с помощью программы nRF Connect:
Как мы видим, у нас еще нет ни одного сервиса, так что пора это исправить. Как вы помните, мы собирались считывать данные с АЦП и записывать их в характеристику сервиса. Чтобы не повторяться и не строить свой собственный велосипед я возьму шаблон создания сервисов и характеристик отсюда.
// Функция инициализации сервисов
void services_init(void)
{
ble_uuid_t ble_uuid;
/* Основной 128 - битный UUID */
ble_uuid128_t base_uuid = ADC_BASE_UUID;
uint8_t uuid_type;
ble_uuid.type = BLE_UUID_TYPE_VENDOR_BEGIN;
ble_uuid.uuid = ADC_SERVICE_UUID;
sd_ble_uuid_vs_add(&base_uuid, &ble_uuid.type);
sd_ble_gatts_service_add(BLE_GATTS_SRVC_TYPE_PRIMARY, &ble_uuid, &adc_handles);
}
/* Функция создания характеристики
* UUID - Идентификатор характеристики
* handles - указатель на обработчик (необходим для стека)
* n_bytes - размер атрибута характеристики
* iswrite, isnotf, isread - разрешения на запись, нотификацию, чтение */
uint32_t char_add(uint16_t UUID, ble_gatts_char_handles_t * handles, uint8_t n_bytes, bool iswrite, bool isnotf, bool isread)
{
ble_gatts_char_md_t char_md;
ble_gatts_attr_md_t cccd_md;
ble_gatts_attr_t attr_char_value;
ble_uuid_t char_uuid;
ble_gatts_attr_md_t attr_md;
memset(&cccd_md, 0, sizeof(cccd_md));
BLE_GAP_CONN_SEC_MODE_SET_OPEN(&cccd_md.read_perm);
BLE_GAP_CONN_SEC_MODE_SET_OPEN(&cccd_md.write_perm);
cccd_md.vloc = BLE_GATTS_VLOC_STACK;
memset(&char_md, 0, sizeof(char_md));
char_md.char_props.notify = isnotf; // Разрешение на уведомление;
char_md.char_props.write = iswrite; // Разрешение на запись;
char_md.char_props.read = isread; // Разрешение на чтение;
char_md.p_char_user_desc = NULL;
char_md.p_char_pf = NULL;
char_md.p_user_desc_md = NULL;
char_md.p_cccd_md = &cccd_md;
char_md.p_sccd_md = NULL;
/* тип UUID - 128 - битный */
char_uuid.type = BLE_UUID_TYPE_VENDOR_BEGIN;
char_uuid.uuid = UUID;
memset(&attr_md, 0, sizeof(attr_md));
BLE_GAP_CONN_SEC_MODE_SET_OPEN(&attr_md.read_perm);
BLE_GAP_CONN_SEC_MODE_SET_OPEN(&attr_md.write_perm);
attr_md.vloc = BLE_GATTS_VLOC_STACK;
attr_md.rd_auth = 0;
attr_md.wr_auth = 0;
attr_md.vlen = 0;
attr_char_value.p_uuid = &char_uuid;
attr_char_value.p_attr_md = &attr_md;
attr_char_value.init_len = n_bytes;
attr_char_value.init_offs = 0;
attr_char_value.max_len = n_bytes; // Размер атрибута;
attr_char_value.p_value = NULL; // Начальное значение атрибута;
/* Зарегистрировать характеристику в стеке */
sd_ble_gatts_characteristic_add(adc_handles, &char_md, &attr_char_value, handles);
return 0;
}
Здесь ADC_BASE_UUID и ADC_SERVICE_UUID — это составной идентификатор сервиса, заданные в таком виде:
/* Основной UUID (одинаковая часть UUID для сервиса и характеристик) */
#define ADC_BASE_UUID {0x66, 0x9A, 0x0C, 0x20, 0x00, 0x08, 0x1A, 0x8F, 0xE7, 0x11, 0x61, 0xBE, 0x00, 0x00, 0x00, 0x00}
/* Частный UUID (различная часть UUID для сервиса и характеристик) */
#define ADC_SERVICE_UUID 0x1533
#define ADC_CHAR_UUID 0x1534
Отлично, теперь у нас есть сервис с одной характеристикой, в которой содержится 1 байт данных. Осталось только заполнить этот байт полезной информацией, поэтому обратимся к статье и инициализируем АЦП, которое будет производить преобразование раз в секунду и добавим функцию, которая будет обновлять содержимое характеристики:
// Функция настройки АЦП
void ADC_init (void)
{
// Настраиваем 2й аналоговый вход как канал АЦП в 8битном режиме
NRF_ADC->CONFIG = 0x00;
NRF_ADC->CONFIG |= (ADC_CONFIG_RES_8bit << ADC_CONFIG_RES_Pos)|
(ADC_CONFIG_INPSEL_AnalogInputOneThirdPrescaling << ADC_CONFIG_INPSEL_Pos)|
(ADC_CONFIG_PSEL_AnalogInput2 << ADC_CONFIG_PSEL_Pos);
NRF_ADC->INTENSET |= ADC_INTENSET_END_Enabled << ADC_INTENSET_END_Pos;
NRF_ADC->ENABLE |= ADC_ENABLE_ENABLE_Enabled << ADC_ENABLE_ENABLE_Pos;
// Разрешаем прерывания от АЦП
NVIC_SetPriority(ADC_IRQn, 1);
NVIC_EnableIRQ(ADC_IRQn);
// Настраиваем таймер для запуска АЦП
NRF_TIMER1->POWER = 1;
NRF_TIMER1->MODE = TIMER_MODE_MODE_Timer << TIMER_MODE_MODE_Pos;
NRF_TIMER1->PRESCALER = 10; // 16 MHz / 2^10 = 15625 Hz
NRF_TIMER1->CC[0] = 15625; // 15625 Hz / 15625 = 1 Hz
NRF_TIMER1->INTENSET = (TIMER_INTENSET_COMPARE1_Enabled <<
TIMER_INTENSET_COMPARE1_Pos);
NRF_TIMER1->SHORTS |= (TIMER_SHORTS_COMPARE0_CLEAR_Enabled <<
TIMER_SHORTS_COMPARE0_CLEAR_Pos);
// Настраиваем модуль PPI для запуска АЦП по событию таймера
NRF_PPI->CH[0].EEP = (uint32_t) &NRF_TIMER1->EVENTS_COMPARE[0];
NRF_PPI->CH[0].TEP = (uint32_t) &NRF_ADC->TASKS_START;
NRF_PPI->CHEN |= PPI_CHEN_CH0_Enabled;
NRF_PPI->CHENSET |= PPI_CHENSET_CH0_Enabled;
NRF_PPI->TASKS_CHG[0].EN = 1;
NRF_TIMER1->TASKS_START = 1;
}
// Обработчик прерывания АЦП
void ADC_IRQHandler(void)
{
rtos_suspend = 0;
NRF_ADC->EVENTS_END = 0;
if (NRF_ADC->CONFIG >> ADC_CONFIG_PSEL_Pos == ADC_CONFIG_PSEL_AnalogInput2)
{
NRF_TIMER1->EVENTS_COMPARE[0] = 0;
adc_val = NRF_ADC->RESULT;
osSignalSet(char_update_thread_id, 1<<CharUpdateSignal);
}
}
Заметьте, в обработчике прерывания есть такая конструкция как
rtos_suspend = 0;
Сама переменная rtos_suspend определена в конфигурационном файле RTOS и экспортирована в наш main.с. Так как операционная система не управляет прерываниями, эта переменная нужна, чтобы программа понимала, что в данный момент система не бездействует, а находится в обработчике прерывания, а значит функции энергосбережения сейчас не должны активироваться. Настоятельно рекомендуется в каждом обработчике прерываний обнулять эту переменную, поскольку противном случае поведение системы остается непредсказуемым.
Поскольку из обработчика прерывания нельзя выполнять действия со стэком (в нашем случае обновлять характеристики) то мы можем создать поток, который будет этим заниматься. Для взаимодействия с потоками из прерываний существуют такие инструменты как сигналы. По сути сигналы – это просто биты во флаговой переменной, которые можно выставлять и сбрасывать – очень напоминает прерывания. Создадим поток для записи характеристик и сигнал-оповещение для него и добавим его в main:
// Прототип функции потока для обновления характеристик
void char_update_thread (void const* arg);
// Идентификатор потока для обновления характеристик
osThreadId char_update_thread_id;
// Определение потока для обновления характеристик
osThreadDef(char_update_thread, osPriorityNormal, 1, 0);
// Сигнал обновления характеристик
int32_t CharUpdateSignal = 0x01;
// Тело потока обновления характеристик
void char_update_thread (void const* arg)
{
while(1)
{
osSignalWait(1<<CharUpdateSignal,osWaitForever);
ble_char_update(&adc_val,1,adc_char_handles.value_handle);
}
}
int main(void)
{
uint32_t err_code;
bool erase_bonds;
static osStatus status;
// Останавливаем ОС
status = osKernelInitialize();
// Инициализация
timers_init();
ble_stack_init();
device_manager_init(erase_bonds);
gap_params_init();
advertising_init();
services_init();
char_add(ADC_CHAR_UUID,&adc_char_handles, 1, false, false, true);
conn_params_init();
// Запуск рассылки рекламных пакетов
err_code = ble_advertising_start(BLE_ADV_MODE_FAST);
APP_ERROR_CHECK(err_code);
// Инициализация АЦП
ADC_init();
// Создание потоков
ble_create_thread();
char_update_thread_id = osThreadCreate(osThread(char_update_thread),NULL);
// Запуск ОС
status = osKernelStart();
}
Отлично, теперь все готово! Можно загружать программу в контроллер и проверять, что же у нас получилось:
Как видите, у нас есть сервис с одной характеристикой, которая хранит однобайтовое значение. Мы можем читать его и удостовериться, что значения изменяются согласно изменению напряжения на входе АЦП. Так же можно было сделать это характеристику нотифицирующей, тогда при изменении данных в характеристике на устройстве они автоматически обновлялись бы и на смартфоне.
Заключение
В заключение хочется сказать что на первый взгляд кажется будто бы RTOS вносит излишнее усложнение в проект, однако это не совсем так. Дело в том, что созданное нами приложение теперь можно с легкостью масштабировать до тех пор пока не перестанет хватать памяти на выполнение всех задач. Для каждого нового модуля, будь то какие-то математические операции или работа с периферией, достаточно будет создать собственный поток и единственное о чем нужно будет задуматься программисту — это как распределить приоритеты между задачами и, в сложных случаях, когда разные потоки обращаются к одним и тем же ресурсам выстроить синхронизацию с помощью семафоров и защелок.
Ссылки
Проект RTOS BLE
Keil-RTX web page
Коротко о nRF51822: Быстрый старт
Коротко о nRF51822: Энергосбережение и немного периферии
NRF51822 product page
Автор: Юрий Востренков