Pull to refresh

Гид по блокирующему, неблокирующему и квази-блокирующему вводу-выводу

Level of difficultyEasy
Reading time19 min
Views3.3K

В природе существуют два широкоизвестных метода ввода-вывода: блокирущий и неблокирующий. Отношение к блокирующему как правило пренебрежительное, мол, он для нубов, а серьезным людям использовать его не стоит.

Однако на практике ситуации бывают разные. Причем неожиданно и крайне неприятно, когда не получается реализовать неблокирующий ввод-вывод. Что делать?

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

Так как микроконтроллеры предоставляют разработчику полную свободу во взаимодействии с железом, примеры я тоже буду давать для абстрактного усредненного микроконтроллера семейства stm32. Но и на прочих NXP философия примерно такая же.

В общем, хочу поделиться собственным опытом, но не откажусь и от совета в комментариях.

Блокирующий ввод-вывод

Допустим, нужно вычислить числа Фибоначчи.

F_0 = 0, F_1 = 1 , F_{i} = F_{i-1} + F_{i-2}

Ну хотя бы те 46 штук, которые уместятся в беззнаковый целочисленный uint32.

Каждое вычисленное число нужно отправить через USART (довольно медленный последовательный асинхронный протокол передачи данных) на терминал компьютера.

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

Вычисление следующего числа Фибоначчи требует одного сложения. Так как мы договорились, что результат лежит в переменной типа uint32 (беззнаковый 32бит), то это означает, что передать по USART необходимо 4 байта.

Вы наивно кладете первый байт в регистр отправки USART и начинаете в бесконечном цикле ждать, пока периферия не поднимет бит Transmission Complete (TC) в регистре статуса (Status Register aka SR), что обозначит завершение передачи. И так 4 раза подряд.

Псевдокод выглядит примерно так:

uint32_t fib0 = 0; 
uint32_t fib1 = 1; 

int main()
{ 
  USART_Init(); // инициализация USART в качестве передатчика
  
  // Если прошлый результат умещается в 31 бит, 
  // значит, и новый результат уместится в 32 бита
  while(fib1 < 0x7FFFFFFF)
  {
    // Вычисление следующего числа Фибоначчи
    uint32_t temp = fib1;
    fib1 = fib1 + fib0;
    fib0 = temp;
    ////////////////////////////////////////

    // Передаем результат по USART
    for(int i = 0; i < 4; i++)
    {
      // порядок отправки байтов значения не имеет, 
      // главное, чтобы принимающая сторона всё правильно собрала обратно
      
      // Делаем побитовый сдвиг с последующим маскированием младших 8 бит
      USART->TD = (fib1 >> (8*i)) & 0xFF;
      // ждем окончания отправки по подъему флага TC в SR
      while((USART->SR & USART_SR_TC) == 0); // <<-- здесь блокируем процессор
    } 
    // никаких разделителей между числами, просто сырой поток байтов
  }
}

Давайте прикинем по времени. Возьмем самую высокую скорость USART 115200 бит/с. При частоте процессора моего любимого stm32F446 в 180МГц, при лучшем раскладе прескейлер USART будет в районе 200. То есть 200 циклов процессора будет тратиться на передачу одного бита. То есть 32 бита будет передано за 6400 циклов. При том, что вычисление самого числа занимает в районе 10 циклов процессоора с поправкой на ветер. Отсюда делаем вывод, что ожидание TC бита в бесконечном цикле (блокирование процессора) занимает 99% времени работы программы.

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

Это особенно актуально, когда информация не отправляется, а принимается. Она может прийти быстро, может прийти через сто лет, а может вообще никогда не прийти. Сидеть в цикле и ждать у моря погоды не наш метод. Необходимо использовать неблокирующий ввод-вывод.

Неблокирующий вывод

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

Мы вычислили 2-е число Фибоначчи (0-е и 1-е нам известны с самого начала).

Теперь нужно передать 4 байта. Допустим, что мы уже сделали конвертацию uint32 в 4 uint8.

Кладем первый байт в регистр передачи USART и уходим делать другие дела.

Когда передача этого байта закончится, в регистре статуса поднимется флаг Transmission Complete. Но нам уже не нужно его проверять, потому что по поднятию флага процессор прервет все свои дела и перейдет на функцию-обработчик.

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

То есть, взаимодействие с медленной периферией происходит эпизодически по факту завершения ее прошлой задачи - отправки байта. USART медленно отправляет данные в фоновом режиме, а приложение просто работает с мыслью "работа будет сделана без моего участия".

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

Заглянем на один шаг вперед. Мы закинули первый байт на отправку, еще три ждут своей очереди. А программа вычисляет еще одно число Фибоначчи, пока USART не передал даже один бит. Потом еще и еще. У нас очень быстро накопится много данных.

Чтобы не потерять данные, которые хотим отправить, необходимо их сохранять в специальную очередь отправки.

Из этой очереди функция-обработчик будет брать новые данные, если очередь не пуста.

Если функция-обработчик дошла до конца очереди, она ее инициализирует.

Если приложение кладет данные в пустую очередь, то оно должно положить первый байт в USART, чтобы запустить передачу. А также оно должно изменить размер очереди.

Если приложение кладет данные в непустую очередь, то оно должно просто изменить текущий размер очереди.

Ух-блин, всё усложнилось! Реализуем всё выше перечисленное в коде.

Псевдокод:

#defint MAXSZ   200 //размер очереди в байтах. в худшем случае должны поместиться все 46 чисел Фибоначчи
uint32_t fib0 = 0; 
uint32_t fib1 = 1;

uint8_t tx_queue[MAXSZ];
uint8_t tx_queue_ind  = 0;  // индекс текущего элемента
uint8_t tx_queue_sz   = 0;  // индекс последнего добавленного элемента
uint8_t tx_queue_busy = 0;  // флаг, который показывает запущена отправка очереди или нет


int main()
{ 
  // ...инициируем USART для работы в режиме прерываний
  // теперь у него есть специальная функция-обработчик
  USART_Init_IRQ();

  // Если прошлый результат умещается в 31 бит, 
  // значит, и новый результат уместится в 32 бита
  while(fib1 < 0x7FFFFFFF)
  {
    // Вычисление следующего числа Фибоначчи
    uint32_t temp = fib1;
    fib1 = fib1 + fib0;
    fib0 = temp;
    ////////////////////////////////////////

    //Коневертируем uint32 в 4 uint8 и добавляем в конец очереди
    for(int i = 0; i < 4; i++)
    {
      tx_queue[tx_queue_sz] = (fib1 >> (8*i)) & 0xFF;
      tx_queue_sz++; // увеличиваем размер очереди
    }

    // Если отправка не запущена, запускаем ее
    if(tx_queue_busy == 0)
    {
      tx_queue_busy = 1;
      USART->TD = tx_queue[tx_queue_ind];
      tx_queue_ind++; // сдвигаем курсор очереди
    }
  }
}

// Функция-обработчик прерывания
void USART_IRQ_Handler()
{
  // флаг о завершении передачи поднят
  if((USART->SR & USART_SR_TC) != 0)
  {
    if(tx_queue_ind < tx_queue_sz) // если не дошли до конца очереди, кладем следующий элемент
    {
      USART->TD = tx_queue[tx_queue_ind++];
    }
    else // дошли до конца очереди, сбрасываем очередь (удалять данные необязательно)
    {
      tx_queue_ind  = 0;
      tx_queue_sz   = 0;
      tx_queue_busy = 0; 
    }
    USART->SR &= ~(USART_SR_TC); // сбрасываем флаг, чтобы не зависнуть
  }
}

Нужно прояснить, что событие Transmission Complete заставляет процессор перейти на обработчик прерывания USART. Обработчик у USART один, а вот источников (или instance) для перехода к обработчику может быть множество. Поэтому внутри обработчика мы и проверяем, какой бит поднят. Так мы понимаем, кто был источником прерывания, и как на это нам реагировать (какой код исполнять).

Если вы уверены, что никакое другое событие произойти не может, можете смело игнорировать if. Просто шпарьте код, только не забудьте сбросить TC флаг. Не советую так делать на реальном проекте. Но советую попробовать и убедиться, что в простом примере ничего страшного не произойдет и код будет работать.

Вернемся к анализу приложения. По времени мы ничего не выиграли. Сейчас приложение практически мгновенно вычисляет 46 чисел Фибоначчи, умещающихся в uint32, наполняет ими очередь, а потом просто ничего не делает.

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

Допустим, что мы хотим ввести еще один процесс. Кроме вычисления чисел Фибоначчи, мы хотим вычислять факториалы чисел. T_{i} = iT_{i-1}, T_0 = 1. Причем нет никакого ограничения на количество вычисленных чисел.

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

Еще надо понимать, что если бы ограничения на число чисел Фибоначчи не было, то очередь любого размера довольно быстро забилась бы. У нас нет шансов вывести по медленному потоку USART быстрый поток чисел Фибоначчи. Тоже очевидно, но уже после того, как сфокусируешь на этом внимание.

Неблокирующий ввод

В случае приема данных в алгоритме меняются роли между приложением и прерыванием. Принятые данные помещаются в очередь внутри прерывания, ведь неизвестно, когда приложение сможет их обработать. Приложение достает данные из очереди и обрабатывает. Важный момент, на который мы не обратили внимание. А где сбрасывать очередь: в приложении или в обработчике прерывания?

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

Ответ - да. Приложение безболезненно может сбросить очередь перед очередным закладыванием данных, если обнаружит, что курсор очереди поравнялся в ее длиной (прерывание извлекло все байты из очереди).

Псевдокод:

#defint MAXSZ   200 //размер очереди в байтах. в худшем случае должны поместиться все 46 чисел Фибоначчи
uint32_t fib0 = 0; 
uint32_t fib1 = 1; 

uint8_t tx_queue[MAXSZ];
uint8_t tx_queue_ind  = 0;  // индекс текущего элемента
uint8_t tx_queue_sz   = 0;  // индекс последнего добавленного элемента
uint8_t tx_queue_busy = 0;  // флаг, который показывает запущена отправка очереди или нет


int main()
{ 
  USART_Init_IRQ();
  // ...инициируем USART для работы в режиме прерываний
  // теперь у него есть специальная функци-обработчик
  
  // Если прошлый результат умещается в 31 бит, 
  // значит, и новый результат уместится в 32 бита
  while(fib1 < 0x7FFFFFFF)
  {
    // Вычисление следующего числа Фибоначчи
    uint32_t temp = fib1;
    fib1 = fib1 + fib0;
    fib0 = temp;
    ////////////////////////////////////////
    
    // ... теперь сброс очереди в приложении 
    if(tx_queue_ind == tx_queue_sz) 
    {
      tx_queue_ind  = 0;
      tx_queue_sz   = 0;
      tx_queue_busy = 0; 
    }
    //Коневертируем uint32 в 4 uint8 и добавляем в конец очереди
    for(int i = 0; i < 4; i++)
    {
      tx_queue[tx_queue_sz] = (fib1 >> (8*i)) & 0xFF;
      tx_queue_sz++; // увеличиваем размер очереди
    }

    // Если отправка не запущена, запускаем ее
    if(tx_queue_busy == 0)
    {
      tx_queue_busy = 1;
      USART->TD = tx_queue[tx_queue_ind];
      tx_queue_ind++; // сдвигаем курсор очереди
    }
  }
}

// Функция-обработчик прерывания
void USART_IRQ_Handler()
{
  // флаг о завершении передачи поднят
  if((USART->SR & USART_SR_TC) != 0)
  {
    if(tx_queue_ind < tx_queue_sz) // если не дошли до конца очереди, кладем следующий элемент
    {
      USART->TD = tx_queue[tx_queue_ind++];
    }
    // ... раньше здесь был сброс очереди
    
    USART->SR &= ~(USART_SR_TC); // сбрасываем флаг, чтобы не зависнуть
  }
}

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

Опять подумайте немного.

Ответ - нет. Эти ситуации асимметричны. Что плохого может произойти, если мы позволим приложению дойдя до конца очереди попытаться ее сбросить?

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

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

А в итоге последние данные потеряны.

Вывод:

Сбрасывать очередь можно только на уровне с приоритетом не ниже уровня того, кто эту очередь наполняет. В случае отправки очередь наполняет приложение, поэтому сбрасывать очередь можно и в приложении, и в прерывании (приоритет которого выше приложенческого). В случае приема, очередь наполняет прерывание, поэтому сбрасывать очередь можно только в прерывании.

Мое скромное мнение, с которым можно не согласиться

В этой статье я не буду разбирать ситуации, когда писателей в очередь и читателей из нее больше одного. Это совсем другая история с мьютексами, семафорами, алгоритмом Петерсона и прочими вещами.

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

Я простой парень. Если в приложении несколько желающих стучатся в одну дверь, их число постоянно и известно аж на этапе компиляции приложения (почти 99,99% встроенных систем), тогда я просто создаю мастера, который через опрос собирает данные с желающих и аккуратно всё раскладывает, соблюдая порядок (только у мастера есть доступ к очередям). Да, что-то где-то я по времени теряю, но пока оно работает, я не буду ломать.

Псевдокод для приема:

#defint MAXSZ   200 //размер очереди в байтах. в худшем случае должны поместиться все 46 чисел Фибоначчи
uint32_t fib0 = 0; 
uint32_t fib1 = 1; 

uint8_t rx_queue[MAXSZ];
uint8_t rx_queue_ind  = 0;  // индекс текущего элемента
uint8_t rx_queue_sz   = 0;  // индекс последнего добавленного элемента


int main()
{ 
  USART_Init_IRQ();
  // ...инициируем USART для работы в режиме прерываний
  // теперь у него есть специальная функци-обработчик
  while(1)
  {
    if(rx_queue_ind < rx_queue_sz)
      uint8_t temp = rx_queue[rx_queue_ind++]; //просто извлекаем полученное число и ничего с ним не делаем    
  }

}

// Функция-обработчик прерывания
void USART_IRQ_Handler()
{
  // флаг поднят. Receive not Empty
  if((USART->SR & USART_SR_RXNE) != 0)
  {
    // если приложение обработало еще не всю очередь
    if(rx_queue_ind < rx_queue_sz) 
    {
      // помещаем полученное число в конец очереди
      rx_queue[rx_queue_sz++] = USART->RD; 
    }
    else // дошли до конца очереди, сбрасываем очередь (удалять данные необязательно)
    {
      rx_queue_ind  = 0;
      rx_queue_sz   = 0;
      
      // помещаем полученное число в конец очереди
      rx_queue[rx_queue_sz++] = USART->RD;
    }
    USART->SR &= ~(USART_SR_RXNE); // сбрасываем флаг, чтобы не зависнуть
  }
}

Познав очереди, вы познаете асинхронное программирование.

Неблокирующий ввод-вывод с DMA

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

Люди подумали, что процессор, вообще-то, вещь очень жирная, умеет вычитать и умножать, а иногда даже Multiply-Accumulate. И отвлекать его на выполнение такой приземленной операции как копирование - это крайне расточительное занятие.

В результате микропроцессорные системы обогатились сопроцессором Direct Memory Access (DMA). Он умеет в автоматическом режиме (то есть даже функцию-прерывание писать не нужно) осуществлять копирование и вставку данных из одной области памяти в другую. А еще он умеет делать смещение в памяти после каждого копировани-вставки. А еще он может делать это циклически. Покажу на примере.

Мы уже помним, что насколько бы быстро не вычислили 46 чисел Фибоначчи, отправка нивелирует все наши старания и будет проходить крайне долго. Так почему бы нам сначала не сформировать всю очередь байтов, которые надо отправить. А потом натравить DMA на эту очередь.

Как работае DMA? Вы настраиваете USART на работу не через прерывания, а через DMA-request (запрос). Если раньше поднятие флага в Status Register заставляло процессор перейти на код функции-обработчика прерывания, то теперь поднятие флага будет заставлять DMA делать операцию копирования-вставки.

Откуда куда? В конфигурационных регистрах DMA вы указываете адреса отправления и назначения. А также можете указать инкрементирование адресов.

В случае отправки по USART:

флаг

Transmission Complete

отправление

tx_queue (адрес 0-й ячейки)

инкрементирование отправления

1 байт

назначение

&(USART->TD) (адрес регистра)

инкрементирование назначения

отключено

количество передач

(46*4)

Как только приложение поместило все данные в очередь, приложение конфигурирует DMA канал (каналов может быть несколько, каждый осуществляет передачу между уникальными отправлением и назначением), приложение запускает этот канал. DMA делает весь объем требуемых работ без участия процессора.

Идеальный пример из моей практики это отправка данных на монохромный 128x64 OLED дисплей с управлением по SPI. Адресное пространство дисплея это восемь строк по 128 байтов, каждый бит которых кодирует яркость одной точки. Каждый кадр представляет собой массив из 1024 байтов.

Число байт постоянно при каждой отправке, поэтому я использую DMA. Настраиваю его, как показано выше. Опускаю nCS (not Chip Select) дисплея. Конфигурирую пин D/C (дата или команда) мультиплексора дисплея. Запускаю DMA на передачу из памяти в периферию. Когда DMA завершает передачу, он генерирует прерывание, в котором я отпускаю nCS дисплея (чтобы разблокировать модуль SPI) и выключаю DMA.

Теперь рассмотрим использование DMA для приема.

В случае отправки по USART по аналогии:

флаг

Receive not Empty

отправление

&(USART->RD) (адрес регистра)

инкрементирование отправления

отключено

назначение

rx_queue

инкрементирование назначения

1 байт

количество передач

N

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

В случае с DMA, нужно останавливать канал, перенастраивать адреса. А вдруг в промежутке произойдет прием, как железо его обработает, если запустить DMA, когда RXnE (Receive not Empty) уже поднят? Уверен, что разработчики железа это предусмотрели, но (пока жизнь не приперла) я с этим не разбирался - использую неблокирующие прерывания.

Мне пришлось использовать DMA при последовательных АЦП в системе векторного управления. У вас есть 3 тока в фазах, а еще синкосинусный энкодер, который выдает 2 аналоговых напряжения для определения положения и скорости вращения электродвигателя. Чем ближе друг к другу моменты измерения всех этих пяти чисел, тем лучше.

Когда я попробовал реализовать запись измерений из периферии в массив из 5 элементов в памяти через прерывания, модуль АЦП выдавал Overrun Error. Иными словами, прерывание по новому измерению происходило в момент, пока процессор находился в прерывании текущего измерения. Я не успевал скопировать данные в память.

Сколько я не пытался сделать код в прерывании максимально быстрым, это не помогло. Помогло только использование DMA, с которым проблем вообще не было.

Квази-блокирующий ввод-вывод

А теперь будет история из жизни. Как-то раз я заводил микросхему MPR121. Это процессор специального назначения, который имеет 12 независимых полностью настраиваемых каналов для измерения емкости контактов. Емкость пропорциональна, например, количеству приложенной плоти. Измерения производятся с достаточно высокой точностью и небольшой задержкой. Фильтры, пороги срабатывания и автокалибровка в наличии. В общем, вещь, всем советую, кто еще не знает.

Управляется эта микруха по I2C. I2C это вам не USART. Для начала посмотрим работу в блокирующем режиме.

// аргументы функции: адрес устройства и один байт для передачи
void I2C_Tx_byte_Blocking(uint8_t dev_addr, uint8_t data)
{
  // 1. Поднимаем Start Condition
  I2C->CR |= I2C_CR_START; 				
  // 2. Ждем, когда железо поднимет SB бит, что типа можно продолжать
  while((I2C->SR & I2C_SR_SB) == 0);   //<<-- блокируем процессор
  // 3. Кладем 7-бит адрес со смещением и бит чтения/записи в регистр приема/передачи
  I2C->DR = (dev_addr << 1) | WR_BIT; // WR_BIT = 0 для передачи
  // 4. Блокируем процессор, пока I2C не поднимет бит успешной передачи адреса
  while((I2C->SR & I2C_SR_ADDR) != 0); //<<-- блокируем процессор
  // 5. Кладем данные в регистр приема/передачи
  I2C->DR = data;
  // 6. Блокируем процессор, пока I2C не поднимет бит Transtite Empty, что всё отправилось 
  while((I2C->SR & I2C_SR_TXE) != 0);  //<<-- блокируем процессор
  // 7.  Устанавливаем Stop Condition, чтобы завершить транзакцию
  I2C->CR |= I2C_CR_STOP;
}

По аналогии передача массива

// аргументы функции: адрес устройства, указатель на массив, количество передач
void I2C_Tx_arr_Blocking(uint8_t dev_addr, uint8_t *data, uint8_t size)
{ 
  // 1. Поднимаем Start Condition
  I2C->CR |= I2C_CR_START; 				
  // 2. Ждем, когда железо поднимет SB бит, что типа можно продолжать
  while((I2C->SR & I2C_SR_SB) == 0); //<<-- блокируем процессор
  // 3. Кладем 7-бит адрес со смещением и бит чтения/записи в регистр приема/передачи
  I2C->DR = (dev_addr << 1) | WR_BIT; // WR_BIT = 0 для передачи
  // 4. Блокируем процессор, пока I2C не поднимет бит успешной передачи адреса
  while((I2C->SR & I2C_SR_ADDR) != 0);//<<-- блокируем процессор
  for(int i = 0; i < size; i++)
  {
    // передаем весь массив
    I2C->DR = data[i];
    while((I2C->SR & I2C_SR_TXE) != 0);//<<-- блокируем процессор    
  }
  // 7.  Устанавливаем Stop Condition, чтобы завершить транзакцию
  I2C->CR |= I2C_CR_STOP;
}

Как видите, при передаче есть несколько блокирующих участков, которые ожидают разные флаги. Если и пытаться натравить на эту передачу DMA, то только на участок передачи данных. Там всегда ожидается TXE флаг, который и будет дергать DMA. После завершения передачи DMA сгенерирует прерывание, в котором будет поднят Stop Condition.

Но если все остальные флаги придется обрабатывать через прерывания, то не стоит ли всю процедуру реализовать на них? Тем более, что в моем случае в устройство не нужно было слать длинные массивы (как, например, в дисплей). Взаимодействие ограничивалось короткими конфигурационными командами и считыванием регистров емкостного сенсора, о том, к каким в данный момент есть прикосновение.

Короче, мне нужно было один раз сконфигурировать устройство, чтобы потом периодически доставать из него два 8 бит регистра, которые хранили состояние 12 сенсоров в формате нажат/отпущен.

Порядок I2C-кондишенов для чтения был еще немного мудреней, чем при отправке. Опять посмотрим на блокирующий режим:

uin8_t mpr121_reg = 0;

// аргументы функции: адрес устройства и адрес регистра в памяти MPR121
void I2C_Rx_byte_Blocking(uint8_t dev_addr, uint8_t registet_addr)
{
  // 1. Поднимаем Start Condition
  I2C->CR |= I2C_CR_START; 				
  // 2. Ждем, когда железо поднимет SB бит, что типа можно продолжать
  while((I2C->SR & I2C_SR_SB) == 0);   //<<-- блокируем процессор
  // 3. Кладем 7-бит адрес со смещением и бит чтения/записи в регистр приема/передачи
  I2C->DR = (dev_addr << 1) | WR_BIT; // WR_BIT = 0 для передачи
  // 4. Блокируем процессор, пока I2C не поднимет бит успешной передачи адреса
  while((I2C->SR & I2C_SR_ADDR) != 0); //<<-- блокируем процессор
  // 5. Передаем адрес регистра, который хотим прочитать
  I2C->DR = register_addr;
  // 6. Блокируем процессор, пока I2C не поднимет бит Transtite Empty, что всё отправилось 
  while((I2C->SR & I2C_SR_TXE) != 0);  //<<-- блокируем процессор
  // 7.  Устанавливаем Start Condition, это еще называют Restart Condition
  I2C->CR |= I2C_CR_START;
  // 8. Ждем, когда железо поднимет SB бит, что типа можно продолжать
  while((I2C->SR & I2C_SR_SB) == 0);   //<<-- блокируем процессор
  // 9. Кладем 7-бит адрес со смещением и бит чтения/записи в регистр приема/передачи
  I2C->DR = (dev_addr << 1) | RD_BIT; // RD_BIT = 1 для чтения
  // 10. Блокируем процессор, пока I2C не поднимет бит успешной передачи адреса
  while((I2C->SR & I2C_SR_ADDR) != 0); //<<-- блокируем процессор
  // 11. Блокируем процессор, пока I2C не поднимет бит Receive not Empty, что данные приняты 
  while((I2C->SR & I2C_SR_RXNE) != 0);  //<<-- блокируем процессор
  // 12. Записываем результат в глобальную переменную
  mpr121_reg = I2C->DR;
  // 13. Устанавливаем Stop Condition
  I2C->CR |= I2C_CR_STOP;
}

Основные отличия отправки от приема.

Для отправки массива байтов мы говорим, что хотим на устройство с таким адресом начать отправку. Если все кондишены в порядке, то мы просто шлем байты, ни на что не отвлекаясь. В конце ставим стоп кондишен.

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

Всему виной конечный автомат, который реализован в mpr121. Но ничего не поделаешь, микросхема не имеет аналогов, придется смириться.

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

Но вот с неблокирующим приемом начались проблемы. В момент рестарта, когда в прерывании я поднимал Start бит, приложение начало буксовать, а I2C выдавал ошибки в статус. Я бился-бился, но так и не смог понять, почему я не могу считать регистры.

Возможно, я тогда допустил какую-то ошибку с флагами I2C, и это на самом деле возможно. Блокирующий режим слишком медлительный. Неблокирующий режим по какой-то причине не получается. Я выкрутился и назвал этот способ Квази-блокирующим.

Идея метода в том, чтобы не использовать прерывания, но и не использовать блокирующий цикл while. Как это возможно?

Сначала покажу на примере отправки чисел Фибоначчи через USART, а потом модифицирую для случая с I2C.

Псевдокод для USART:

#defint MAXSZ   200 //размер очереди в байтах. в худшем случае должны поместиться все 46 чисел Фибоначчи
uint32_t fib0 = 0; 
uint32_t fib1 = 1;

uint8_t tx_queue[MAXSZ];
uint8_t tx_queue_ind  = 0;  // индекс текущего элемента
uint8_t tx_queue_sz   = 0;  // индекс последнего добавленного элемента
uint8_t tx_queue_busy = 0;  // флаг, который показывает запущена отправка очереди или нет


int main()
{ 
  // ...инициируем USART 
  USART_Init();

  while(1) // бесконечный цикл приложения
  {
    // Если прошлый результат умещается в 31 бит, 
    // значит, и новый результат уместится в 32 бита
    if(fib1 < 0x7FFFFFFF)
    {
      // Вычисление следующего числа Фибоначчи
      uint32_t temp = fib1;
      fib1 = fib1 + fib0;
      fib0 = temp;
      ////////////////////////////////////////
  
      //Коневертируем uint32 в 4 uint8 и добавляем в конец очереди
      for(int i = 0; i < 4; i++)
      {
        tx_queue[tx_queue_sz] = (fib1 >> (8*i)) & 0xFF;
        tx_queue_sz++; // увеличиваем размер очереди
      }
  
      // Если отправка не запущена, запускаем ее
      if(tx_queue_busy == 0)
      {
        tx_queue_busy = 1;
        USART->TD = tx_queue[tx_queue_ind];
        tx_queue_ind++; // сдвигаем курсор очереди
      }
    }
    // проверяем флаг о завершении передачи
    // по сути копия кода из прерывания
    if((USART->SR & USART_SR_TC) != 0)
    {
      if(tx_queue_ind < tx_queue_sz) // если не дошли до конца очереди, кладем следующий элемент
      {
        USART->TD = tx_queue[tx_queue_ind++];
      }
      else // дошли до конца очереди, сбрасываем очередь (удалять данные необязательно)
      {
        tx_queue_ind  = 0;
        tx_queue_sz   = 0;
        tx_queue_busy = 0; 
      }
      USART->SR &= ~(USART_SR_TC); // сбрасываем флаг
    }
  }  
}

Здесь мы поместили всё приложение в бесконечный цикл. Внутри него мы опрос обоих процессов. Вычисление чисел и закладка данных из очереди в USART.

Если еще не все числа Фибоначчи посчитаны и помещены в очередь, то вычисляем следующее.

После этого проверяем не поднят ли TC бит. Если да, то просто выполняем тот код, который ранее выполнялся внутри обработчика.

Таким образом не происходит блокирования процессора, и при этом оба процесса получают свою возможность быть выполненными.

Получаем обыкновенный последовательный планировщик задач.

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

Псевдокод:

uint8_t mpr121_reg = 0;
uint8_t rx_stage = 0; 

// аргументы функции: адрес устройства и адрес регистра в памяти MPR121
void I2C_Rx_byte_Quasi_Blocking(uint8_t dev_addr, uint8_t registet_addr)
{
  // считываем один раз регистр периферии, 
  // потому что операция довольно долгая
  // и делает ее в каждом else if неэкономично
  uint16_t i2c_sr = I2C->SR; 
  
  if(rx_stage == 0)
  {
    rx_stage = 1;
    // 1. Поднимаем Start Condition
    I2C->CR |= I2C_CR_START; 				
  }
  // 2. Ждем, когда железо поднимет SB бит, что типа можно продолжать
  else if(rx_stage == 1 && ((i2c_sr & I2C_SR_SB) == 0))
  { 
    // 3. Кладем 7-бит адрес со смещением и бит чтения/записи в регистр приема/передачи
    I2C->DR = (arrd << 1) | WR_BIT; // WR_BIT = 0 для передачи  
    rx_stage = 2;  
  } 
  // 4. Ждем, пока I2C не поднимет бит успешной передачи адреса
  else if(rx_stage == 2 && (i2c_sr & I2C_SR_ADDR) != 0)
  {
    // 5. Передаем адрес регистра, который хотим прочитать
    I2C->DR = register_addr;    
    rx_stage = 3;
  }
  // 6. Ждем, пока I2C не поднимет бит Transtite Empty, что всё отправилось 
  else if(rx_stage == 3 && (i2c_sr & I2C_SR_TXE) != 0)
  {
    // 7.  Устанавливаем Start Condition, это еще называют Restart Condition
    I2C->CR |= I2C_CR_START;    
    rx_stage = 4;
  }
  // 8. Ждем, когда железо поднимет SB бит, что типа можно продолжать
  else if(rx_stage == 4 && (i2c_sr & I2C_SR_SB) == 0)
  {
    // 9. Кладем 7-бит адрес со смещением и бит чтения/записи в регистр приема/передачи
    I2C->DR = (arrd << 1) | RD_BIT; // RD_BIT = 1 для чтения    
    rx_stage = 5;
  }
  // 10. Ждем, пока I2C не поднимет бит успешной передачи адреса
  else if(rx_stage == 5 && (i2c_sr & I2C_SR_ADDR) != 0)
  {
    rx_stage == 6;
  }
  // 11. Ждем, пока I2C не поднимет бит Receive not Empty, что данные приняты 
  else if(rx_stage == 6 && (i2c_sr & I2C_SR_RXNE) != 0)
  {
    // 12. Записываем результат в глобальную переменную
    mpr121_reg = I2C->DR;
    // 13. Устанавливаем Stop Condition
    I2C->CR |= I2C_CR_STOP;
    rx_stage = 0;    
  }
}

int main()
{ 
  // ... инициализация ...
  
  while(1) // бесконечный цикл приложения
  {
    I2C_Rx_byte_Quasi_Blocking(MPRADDR, REGADDR);
  }  
}

Сейчас код бесконечно считывает один единственный регистр MPR121. Но в реальном приложении это не обязательно. Данная микросхема имеет выход IRQ, который сигнализирует об изменении регистра. Поэтому запускать процедуру считывания регистра можно из отдельного прерывания, привязанного к цифровому входу микроконтроллера, подключенного к IRQ выходу MPR121.

Заключение

Статья получилась обширная, но я надеюсь, что найдутся люди, которым она сослужит добрую службу.

В свое время я такую статью не прочитал, поэтому постигать всё пришлось на своем опыте. Вообще разработка асинхронных систем лишь на половину разработка.

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

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

Всем спасибо за внимание и удачи в ваших исследованиях!

Tags:
Hubs:
+13
Comments51

Articles