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

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

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

Так как микроконтроллеры предоставляют разработчику полную свободу во взаимодействии с железом, примеры я тоже буду давать для абстрактного усредненного микроконтроллера семейства 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 не совсем независимо от процессора общается с памятью.

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

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

Комментарии (43)


  1. rukhi7
    13.08.2024 12:27

    Так как микроконтроллеры предоставляют разработчику полную свободу во взаимодействии с железом

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

    Кроме вычисления чисел Фибоначчи, мы хотим вычислять факториалы чисел.

    Вы что с помощью микроконтроллеров задачи по математике школьникам решаете?

    Идеальный пример из моей практики это отправка данных на монохромный 128x64 OLED дисплей с управлением по SPI

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


    1. vadjuse Автор
      13.08.2024 12:27

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

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


      1. Yuri0128
        13.08.2024 12:27

        цифровые синтезаторы

        Звук/RF или еще какой? Если звук - то чего-б не взять контроллер с заточкой под звук с DSP внутри и SIMD на борту? И какие там особенные проблемы с асинхронным доступом? Вывести в i2s? Считать сэмплы? Принять по Midi пакет? Или опросить клавиатуру? При мощных контроллерах проц в нем простаивать будет.

        Если RF - по там частоты повыше и на высоких без FPGA невозможно становится.


  1. denisg2
    13.08.2024 12:27

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


    1. Yuri0128
      13.08.2024 12:27
      +3

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

      как-бы не факт. Оно не всегда оправдано. В данном случае - так точно нет. Просто у ТС не получилось сладить с контроллером i2c. И это не повод прибегать к кувалде/пушке FreeRTOS чтобы выстрелить в воробушка.

      Ну а HAL - ну... такое себе. Если нужно сильно быстро либо мало места, - то от него таки придется отказаться. Но это точно не случай ТС - тут HAL помог бы сильно упростить код.


    1. SIISII
      13.08.2024 12:27

      А я вот никогда не использую ни HAL от поставщика, ни фриртос. Если проект достаточно сложный, чтоб оправдать реализацию в виде нескольких потоков, а не простого главного цикла, просто делаю свою переключалку потоков -- там работы на несколько часов даже на незнакомой архитектуре, с которой впервые столкнулся (при условии, конечно, что уже писал на паре десятков ассемблеров :) )


      1. Yuri0128
        13.08.2024 12:27

        Ну у каждого своя лошадь.... Я HAL юзаю исходя из задачи и ресурсов (особено!!!). Если ресорсов дофига, тайминги не жмут - чего б и нет? По написанию диспетчера - ну без него немного проектов (у меня), это уж совсем что-то простенькое.

        А по RTOS - ну вот иногда она очень помогает и сильно упрощает разработку. За счет добавления занятого места и уменьшения доступного времени проца.

        паре десятков ассемблеров

        Это-ж как,! Огласите список. Я вот в своей работе так до десятка, ну может чуток больше (уже многие забыл, помнится еще даже на VLIW что-то писал). Но диспетчер написать и свою SPL (ну или по регистрам лазить) можно и без знания асма вообще, - описание структуры целевого контроллера в помощь... Правда на сильно тяжелой и незнакомой архитектуре это будет не несколько часов а дай Бог всего несколько дней...


        1. SIISII
          13.08.2024 12:27

          Ну, первым был 6502 на школьном Агате (кривом клоне Эпла-2), за ним -- СМ-4 и СМ-1420 (в девичестве PDP-11). Дальше точную хронологию уже не помню, но 8080 (куда ж без него), немного 8048, немного 8051, 8086, ЕС ЭВМ (System/360), немного Z80, немного Z8000, немного 68000, немного СМ-2М (HP21xx), AVR8 (один раз, зато коммерческий проект: МК выбрали до меня, и все хотелки на сях не полезли б в память :) ), обе 32-разрядные системы команд ARM (и собсно ARM, и Тумба), чуть-чуть MIPS, чуть-чуть PIC24 (но совсем чуть-чуть), сейчас вот начал поглядывать RISC-V... Ну, плюс теоретическое знакомство -- сходу 6800, IA-64 aka Itanium, БЭСМ-6, Минск-2/22/32, Минск-23... может, ещё кого забыл. В общем, если и не два десятка, то больше десятка точно наберётся, с чем прямо имел дело.

          Насчёт нескольких дней -- ну, в определённых ситуациях так и есть, тут Вы правы. Скажем, для 8086, любого ARMа или той же Системы 360 простую переключалку можно-таки за несколько часов написать, ничего до этого не зная об их архитектуре, но уже имея опыт решения такой задачи на других архитектурах, а вот для IA-32 (80386 и его последыши) -- вряд ли, ведь придётся кучу всяких структур данных заполнять (таблицы дескрипторов и всё такое прочее), чтоб заставить его работать в нормальном 32-разрядном режиме (т.е. в защищённом).


          1. Yuri0128
            13.08.2024 12:27
            +1

            О!!! Минск-32 - был у меня опыт работы с ней, запускал в многозадачном режиме. Ну и молоток резиновый возле шкафа ОЗУ помнится....

            Ну я начинал с НАИРИ-2 (было такое когда-то)... Потом информатику вел - там со студентами разбирались на Z80, плюс была разработка промкомпа на 580 серии. Запомнились 68000 Моторола (уже и не помню чем).


            1. SIISII
              13.08.2024 12:27

              Ну, с минсками у меня чисто теоретическое знакомство: в живом виде уже не застал (живое -- ЕСки и СМки, если самое старое).

              Ну а 68000, как и Z8000 -- куда приятней в плане системы команд, чем 8086. Может, этим запомнился -- в сравнении, так сказать?..


              1. Yuri0128
                13.08.2024 12:27

                 Может, этим запомнился -- в сравнении

                Может....Но это так давно было. Вот с МИПСом не сталкивался всеръез. А на ПИКах много чего наделано было....


  1. Gryphon88
    13.08.2024 12:27
    +2

    Спасибо за статью, всё по полочкам и на примерах.

    Про гейткипер (когда один ресурс хотят несколько процессов): это стандартная и общепринятая модель, которая применима к динамическому количеству процессов. По сути она превращается в паттерн издатель/подписчик.

    Про квазиблокирующий обмен данными. Как я понял, это это получается что-то типа продолжений (continuations): есть стадии подготовки, передачи и обработки данных (похоже на концепт top-bottom halves), и хочется не размазывать кодовую базу для удобства понимания и редактирования. Можно попробовать реализовать в лоб, когда состояние (например, порядковый номер вызова функции) хранится внутри функции и есть множественные точки входа и выхода в зависимости от этого состояния, а можно через кооперативную многозадачность. Мне больше нравится последний вариант, с явной реализацией на конечном автомате, но можно сделать легковеснее, шустрее и накуренней на protohreads.


    1. Yuri0128
      13.08.2024 12:27

      Про гейткипер (когда один ресурс хотят несколько процессов)

      Так вроде у ТС один процесс...?

      Про квазиблокирующий обмен данными. Как я понял, это это получается что-то типа продолжений (continuations)

      Кооперативный доступ, что-то по типу asyncio(). У ТС, имеется в ввиду.

      а можно через кооперативную многозадачность.

      Так у автора так и реализовано, просто свой планировщик, который по факту именно по такой схеме работает.


      1. Gryphon88
        13.08.2024 12:27

        Так вроде у ТС один процесс

        Случай нескольких под спойлером

        свой планировщик, который по факту именно по такой схеме работает.

        Не, я про более сложный случай, полностью асинхронный, когда у нас одна сопрограмма сидит в обработчике, а другая в бесконечном цикле; это может быть полезно, когда или обработка посылки, или обработка ошибки тяжеловесная. Вот старая статья на easyelectronics, там примерно как у ТС, но поразвесистее, и легче пилится на верхнюю и нижнюю части.


        1. Yuri0128
          13.08.2024 12:27

          Случай нескольких под спойлером

          Это который "Мое скромное мнение, с которым можно не согласиться ", - ну так там конкурентов может быть больше 10-ка. Тогда уж средствами ОС решать. Ну либо писать свой мост - собственно вы так и написали.

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

          В смысле - демона написать? А если сильно посеръезнее то там в демоне (обработчик прерывания или события) запускаем спящую обработку (отдельная нить в RTOS) с нужным приоритетом, которая после обработки снова засыпает. Либо все полностью повесить на ОС, если она это позволяет.


          1. Gryphon88
            13.08.2024 12:27
            +1

            В смысле - демона написать?

            По сути да. РТОС тут не сделает жизнь сильно проще, да и смысла в ней нет, когда достаточно флагового автомата. Deferred interrupt processing техника хорошая, но когда логика сложная и нелинейная, как в том же I2C, ее немного не хватает.


            1. SIISII
              13.08.2024 12:27

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


              1. Yuri0128
                13.08.2024 12:27

                Ну так у автора то ОСы нету и он пытается сам диспетчер написать.

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


  1. Yuri0128
    13.08.2024 12:27

    Ну как по мне, то "идеальный случай для DMA" - это когда нужно передать по SPI, работающему на 65 МГц пару десятков (или сотен) килобайт. Ибо в таком случае прерывания будут занимать в несколько раз больше времени чем передача (ну и скорость порежется сильно). Ну и ресурсов проца немало уйдет на это (если проц не многоядерный).


    1. SIISII
      13.08.2024 12:27

      На самом деле, даже для какого-нибудь там UARTа на 9600 DMA тоже выгодней, если нужно переслать достаточно большой объём данных: не отвлекать проц от работы (или от сна :) ) по пустякам. Вот для передачи двух байтов его задействовать глупо: проц больше времени потратит на настройку DMA, чем на "ручную" пересылку этих самых двух байтов.


      1. Yuri0128
        13.08.2024 12:27

        Ну в статье - 4 байта.

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


        1. SIISII
          13.08.2024 12:27

          Ну, что 2, что 4... Если весь объём лезет во внутренний буфер самого UARTа, то точно проще и быстрей сделать на проце, если данные требуется досылать (или допринимать) отдельно -- уже вопрос.

          Каналов DMA не очень много, но: а) иногда устройства, даже примитивные, сами умеют обращаться к памяти (например, в классических АТМЕЛовских армах так было -- не путать с современными микрочиповскими), либо центральное железо умеет работать с любыми устройствами (каналы ввода-вывода Системы 360); б) зависит от количества используемых устройств: если ДМА на всех хватает, то почему б (обычно) не использовать? Вот если не хватает -- тады конечно.


  1. old_merman
    13.08.2024 12:27
    +3

    Я 100 лет как не настоящий сварщик эмбеддер, и возможно, напишу глупость, но разве не надо в примерах неблокирующего ввода-вывода в main() запрещать прерывания на время операций с очередями??? Потому что например, операция tx_queue_sz++; на stm32 не атомарная, и, если она прервётся в середине, при этом управление получит USART_IRQ_Handler() и сбросит очередь - интересные спецэффекты в дальнейшем гарантированы!
    И ещё кажется, что все переменные, доступ к которым происходит и из main(), и из обработчиков прерываний, стоило бы объявить volatile - во избежание самодеятельности оптимизирующих компиляторов.


    1. vadjuse Автор
      13.08.2024 12:27

      volatile это намек компилятору, что переменную не надо располагать в статической памяти, ибо она изменчивая и будет перезаписана.

      Я не уверен, что компилятор прям обязательно поместит неволатильную переменную в статическую память.

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

      А про атомарность я в статье рассказываю, что в асинхронном мире все строится с предположением, что в любой момент процесс может быть прерван (в том числе и операционной системой)


      1. Yuri0128
        13.08.2024 12:27

        не надо располагать в статической памяти

        В stm32 все встроеное ОЗУ статическое. Динамического тама не наблюдается.

        Возможно вы нечто другое имели в виду? А вот об volatile  - вы разверните свою мысль. Ибо я как-то привык, что это просто указание компилятору отменить оптимизации по этой переменной ибо она в любой момент и непонятно кем может быть изменена. Не более того. В какой секции вы ее объявите тама она и будет.

        Я никогда не выключаю прерывания во время других прерываний, потому что хочу чтобы они происходили и обрабатывались. Каких-то плохих вещей совсем не происходит.

        Ну, при настроенной иерархии возможно и так. А по поводу "плохих вещей" - ну вы их либо не заметили еще либо проекты несложные.

        Все-ж про критические секции не стоит забывать и их таки объявлять.


      1. SIISII
        13.08.2024 12:27

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


      1. SIISII
        13.08.2024 12:27

        И насчёт "выключения" прерывания. Во-первых, их не выключают, а запрещают (да, придираюсь к терминам -- но от слишком вольного их использования возникают проблемы). А во-вторых, в любых ARMах М-профиля, в т.ч. в STM32, при входе в обработчик прерывания автоматом запрещаются прерывания с тем же и более низким (численно -- более высоким) приоритетом, а соответственно, до завершения данного обработчика прерывания они уже не могут быть обслужены. Это одна из причин, почему в обработчиках прерываний надо выполнять минимально необходимую работу, всё остальное перекладывая на код, выполняющийся "снаружи" (например, посредством механизма отложенной обработки, который использует Винда, а до неё использовала её "мамаша" -- VAX/VMS, а до этого -- её "бабка" -- RSX-11M, а наверняка и много какие другие системы).


        1. Yuri0128
          13.08.2024 12:27

          И насчёт "выключения" прерывания.  Во-первых, их не выключают

          Ну чего-ж так сразу. Можно и выключить, вместе с ядром. :)


      1. sami777
        13.08.2024 12:27

        А в какую память он ее может ещё поместить, кроме статической? Куча на микроконтроллерах, как правило, не используется. Ввод-вывод-это не тема потоков, это тема записи/чтения файла.


        1. Yuri0128
          13.08.2024 12:27
          +1

          Автор, походу, спутал статическую память (это к железу) со статическим размещением в памяти. А куча используется, хоть и не везде и не всеми компиляторами.


          1. SIISII
            13.08.2024 12:27

            Компиляторы все умеют её использовать -- другое дело, что не всегда используется на практике, но это уже не к компилятору, а к программисту. А у автора, да, терминология хромает.


    1. Yuri0128
      13.08.2024 12:27

      И ещё кажется, что все переменные, доступ к которым происходит и из main(), и из обработчиков прерываний, стоило бы объявить volatile - во избежание самодеятельности оптимизирующих компиляторов.

      Автор к этому еще придет.... Видно, что развивается...


      1. SIISII
        13.08.2024 12:27

        Ага, я вот наткнулся пару лет назад на слишком уж агрессивную оптимизацию, из-за чего пришлось волатильными объявлять даже те вещи, которые таковыми, строго говоря, не являются: просто нет иных разумных и стандартных средств ограничить компилятор в оптимизациях для некоторых вещей. (Мне, по сути, требовалось, чтобы он не обращался заранее к одному полю структуры, вот после первого обращения в порядке записи в программе можно было бы использовать уже загруженную в регистр копию этого поля, что волатильность запрещает делать; ну а компиль об этом не знал и выбирал одной командой два поля, хотя в том месте программы ему нужно было только одно).


    1. Devilar
      13.08.2024 12:27
      +3

      Вы тоже заметили эту ошибку! А автор так и не понял, что это ошибка! @vadjuse, обратите внимание, что здесь совершенно справедливо заметили: переменная tx_queue_sz меняется (проходит через процедуру: чтение-модификация-запись) и в основном потоке, и в потоке прерываний. То что ваши примеры не выявляют данную коллизию проблема исключительно ваших примеров. Коллизия есть и она вполне реальная. Довольно легко смоделировать ситуацию, когда ваш код продублирует отправку некоторых байтов. Задача для вас - найти и описать эту ситуацию (ждем правильный ответ)! Спасибо за материал, потренировал свои извилины.


  1. SIISII
    13.08.2024 12:27

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

    Пы.Сы. Претензии -- к заголовку, ибо термины "блокирующий" и "неблокирующий" -- из Унихов-Линухов, но означают, скажем так, немного не то, что синхронный и асинхронный.


    1. Yuri0128
      13.08.2024 12:27

      термины "блокирующий" и "неблокирующий" -- из Унихов-Линухов

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

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


      1. SIISII
        13.08.2024 12:27

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

        Ну а что взгляд программиста и электронщика различается -- это да. Я говорил чисто про программную сторону вопроса.

        И да, потоков как таковых в Линухе нет. Там изврат в виде процессов с общим адресным пространством (в Винде потоки -- это потоки, принадлежащие процессу, т. е. чётко отличается одно от другого).

        Пы. Сы. Строго говоря, сравнительно недавно в Линухе появился асинхронный ввод-вывод -- IO_URING, если не ошибаюсь. Мне лично он показался переусложнённым; как по мне, ядро оси должно предоставлять максимально простые и низкоуровневые сервисы, а уж навороты поверх, если они нужны, должны строиться на прикладном уровне.


  1. Indemsys
    13.08.2024 12:27

    Статью не читал, но поиск показывает что в ней нет слов FIFO или фифо.
    Странно как это можно проигнорить касаясь темы DMA.


    1. Yuri0128
      13.08.2024 12:27

      касаясь темы DMA.

      Так в статье про DMA ни слова...


    1. SIISII
      13.08.2024 12:27
      +1

      Вообще, можно. Я, например, склонен использовать термин "буфер", а не FIFO, и если б писал про DMA, то вряд ли FIFO упоминал бы -- хотя буферы были б оптом и в розницу.


  1. Devilar
    13.08.2024 12:27

    Вы в статье говорите, что ситуации чтения и записи ассиметричны. Это заблуждение, они очень даже симметричны. Просто ваш пример кода неудачный. Он выявляет коллизию только на чтении, а на записи не выявляет. Коллизия есть в обоих случаях в вашем псевдокоде. И на записи и на чтении. Проблема в том что tx_queue_sz читается-модифицируется-записывается с двух мест. Про volatile вам тоже верно сказали. Например в функции main компилятор может оптимизировать код так, что скопирует значение tx_queue_sz или tx_queue_ind в регистр и будет пользоваться регистром совершенно не предполагая о том, что надо обновлять значение из переменной. Особенно, если код обработки прерывания будет в другом модуле трансляции.


  1. Devilar
    13.08.2024 12:27

    По проблеме с работой по I2C на прерываниях. Вы это на STM32 делали? Случайно не F10x серия?


    1. Yuri0128
      13.08.2024 12:27

      Случайно не F10x серия

      Если внимательно почитать, то автор указывает stm32F446. То есть таки странно, что у него не получилось. Если б была F103 - тогда хоть как-то ситуация оправдана была-бы.