Pull to refresh

Библиотека генератора ассемблерного кода для микроконтроллеров AVR. Часть 5

Reading time 12 min
Views 2.5K

← Часть 4. Программирование периферийных устройств и обработка прерываний


Библиотека генератора ассемблерного кода для микроконтроллеров AVR


Часть 5. Проектирование многопоточных приложений


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


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


Единицей исполнения процесса в потоке является задача. В системе может существовать неограниченное количество задач, но в каждый момент времени может быть активировано только определенное их количество, ограниченное количеством рабочих потоков в диспетчере. В данной реализации число рабочих потоков задается в конструкторе диспетчера и не может быть впоследствии изменено. В процессе работы потоки могут заниматься задачами или оставаться свободными. В отличие от других решений, диспетчер Parallel не занимается переключением задач. Чтобы задача вернула управление диспетчеру, в ее код должны быть вставлены соответствующие команды. Таким образом ответственность за длительность тайм-слота в задаче возлагается на программиста, который должен вставить в определенные места кода команды прерывания в случае, если задача выполняется слишком долго, а так же определить поведение потока по завершению задачи. Достоинством такого подхода является управление программистом точками переключения между задачами, что позволяет существенно оптимизировать код сохранения/восстановления при переключении задач, а так же избавиться от большей части проблем, касающихся потокобезопасного доступа к данным.


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


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


Задачи в системе могут находиться в следующих состояниях:


Деактивирована — начальное состояние для всех задач. Задача не занимает поток и управление на выполнение не передается. Возврат в это состояние у активированных задач происходит по команде завершения.


Активирована — состояние, в котором находится задача после активации. Процесс активации связывает задачу с потоком исполнения и сигналом активации. Диспетчер опрашивает потоки и запускает задачу если сигнал задачи активирован.


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


Ожидание — состояние в котором находится задача после выполнения команды Delay. В этом состоянии задача не получает управления до истечения требуемого интервала. В классе Parallel для управления задержкой используются 16 ms прерывания WDT, что позволяет не занимать под системные нужды таймеры. В случае, если нужна большая стабильность или разрешение в небольших интервалах, вместо Delay можно использовать активацию по сигналам таймера. При этом нужно учитывать, что точность задержки все равно будет невысокой и будет колебаться в диапазоне «время срабатывания диспетчера» — «максимальная длительность тайм-слота в системе + время срабатывания диспетчера». Для задач с точными временными диапазонами следует использовать гибридный режим, в котором не задействованный в классе Parallel таймер работает независимо от потока задач и обрабатывает интервалы в режиме чистого прерывания.


Каждая задача, исполняемая в потоке представляет из себя изолированный процесс. Это вызывает необходимость определения двух видов данных: локальные данные потока, которые должны быть видны и изменяться только в рамках этого потока и глобальные данные для обмена между потоками и доступа к общим ресурсам. В рамках данной реализации глобальные данные создаются уже ранее рассмотренными командами на уровне устройства. Для создания локальных переменных задачи их необходимо создавать методами из класса задачи. Поведение локальной переменной задачи следующее: при прерывании задачи перед передачей управления диспетчеру все локальные регистровые переменные сохраняются в памяти потока. При возврате управления перед выполнением следующей команды локальные регистровые переменные восстанавливаются.
За хранение локальных данных потока отвечает класс с интерфейсом IHeap, связанный со свойством Heap класса Parallel. Простейшей реализацией этого класса является StaticHeap, реализующий статическое выделение одинаковых блоков памяти для каждого потока. В случае, если задачи имеют большой разброс по требованию к объему локальных данных, можно использовать DynamicHeap, позволяющий определить размер локальной памяти индивидуально для каждой задачи. Очевидно, что накладные расходы по работе с памятью потока в этом случае будут существенно выше.


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


           var m = new Mega328
            {
                FCLK = 16000000,
                CKDIV8 = false
            };
            m.PortB.Direction(0x07);
            var bit1 = m.PortB[1];
            var bit2 = m.PortB[2];
            m.PortB.Activate();
            var tasks = new Parallel(m, 2);
            tasks.Heap = new StaticHeap(tasks, 16);
            var t1 = tasks.CreateTask((tsk) =>
            {
                var loop = AVRASM.NewLabel();
                bit1.Toggle();
                tsk.Delay(32);
                tsk.TaskContinue(loop);
            },"Task1");
            var t2 = tasks.CreateTask((tsk) =>
            {
                var loop = AVRASM.NewLabel();
                bit2.Toggle();
                tsk.Delay(48);
                tsk.TaskContinue(loop);
            }, "Task2");
            var ca = tasks.ContinuousActivate(tasks.AlwaysOn, t1);
            tasks.ActivateNext(ca, tasks.AlwaysOn, t2);
            ca.Dispose();
            m.EnableInterrupt();
            tasks.Loop();

Верхние строчки программы вам уже знакомы. В них мы определяем тип контроллера и назначаем первый и второй разряд порта B в качестве выходного. Далее следует инициализация переменной класса Parallel, где во втором параметре мы и определяем максимальное количество потоков исполнения. В следующей строке мы выделяем память для размещения локальных переменных потоков. Задачи у нас равноценные, поэтому мы используем StaticHeap. Следующий блок кода — определение задач. В нем мы определяем две практически идентичные задачи. Отличием будет только порт для управления и величина задержки. Для работы с локальными объектами задачи в блок кода задачи передается указатель на локальную задачу tsk. Сам текст задачи очень простой:


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

Полный список команд прерывания задачи для передачи управления диспетчеру следующий
AWAIT(signal) — поток сохраняет все переменные в памяти потока и передает управление диспетчеру. При следующей активации потока переменные восстанавливаются и исполнение продолжается, начиная со следующей после AWAIT инструкции. Команда предназначена для деления задачи на тайм-слоты и для реализации машины состояний по схеме Сигнал → Обработка 1 → Сигнал → Обработка 2 и т. д.


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


TaskContinue(label, signal) — команда завершает поток и отдает управление диспетчеру без сохранения переменных. При следующей активации потока управление передается на метку label. Опциональный параметр Signal позволяет переопределить сигнал активации потока для следующего вызова. Если его не указывать, сигнал остается прежним. Команда без указания сигнала может использоваться для организации циклов внутри одной задачи, где каждый цикл выполняется в отдельном тайм-слоте. Ее так же можно использовать для назначения текущему потоку новой задачи после завершения предыдущей. Достоинством такого подхода по сравнению с циклом Освобождение потока → Выделение потока является более эффективная работа программы. Использование TaskContinue избавляет диспетчер от необходимости поиска свободного потока в пуле и гарантирует от ошибок при попытке выделения потоков при отсутствии свободных.


TaskEnd() — очистка потока после завершения задачи. Задача завершается, поток освобождается и может быть использован для назначения новой задаче командой Activate.


Delay(ms) — поток так же, как и в случае использования AWAIT, сохраняет все переменные в памяти потока и передает управление диспетчеру. При этом в заголовок потока записывается величина задержки в миллисекундах. В цикле диспетчера, в случае ненулевого значения в поле задержки, активация потока не происходит. Изменение значений в поле задержки по всем потокам осуществляется по прерыванию таймера WDT каждые 16 ms. При достижении нулевого значения запрет исполнения снимается и устанавливается сигнал активации потока. В заголовке хранится только однобайтное значение для задержки, что дает сравнительно узкий диапазон возможных задержек, поэтому для реализации более длительных задержек, Delay() создает внутренний цикл с использованием локальных переменных потока.
Активация команд в примере производится при помощи команд ContinuousActivate и ActivateNext. Это специальный вид начальной активации задач при старте. На этапе начальной активации у нас гарантированно нет ни одного занятого потока, поэтому процесс активации не требует предварительного поиска свободного потока для задачи и позволяет активировать задачи последовательно. ContinuousActivate активирует задачу в нулевом потоке и возвращает указатель на заголовок следующего потока, а функция ActivateNext использует этот указатель для активации следующих задач в последовательных потоках.


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


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


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


            var m = new Mega328();
            m.FCLK = 16000000;
            m.CKDIV8 = false;
            var cData = m.DREG();
            var outDigit = m.ARRAY(4);
            var chex = Const.String("0123456789ABCDEF");
            m.ADC.Clock = eADCPrescaler.S64;
            m.ADC.ADCReserved = 0x01;
            m.ADC.Source = eASource.ADC0;
            m.Usart.Baudrate = 9600;
            m.Usart.FrameFormat = eUartFrame.U8N1;

            var os = new Parallel(m, 4);
            os.Heap = new StaticHeap(os, 8);
            var ADS = os.AddSignal(m.ADC.Handler, () => m.ADC.Data(cData));
            var trm = os.AddSignal(m.Usart.TXC_Handler);
            var starts = os.AddLocker();

            os.PrepareSignals();
            var t0 = os.CreateTask((tsk) =>
            {
                m.LOOP(m.TempL, (r, l) => m.GO(l), (r, l) =>
                   {
                       m.ADC.ConvertAsync();
                       tsk.Delay(500);
                   });

            }, "activate");
            var t1 = os.CreateTask((tsk) =>
            {
                var loop = AVRASM.NewLabel();
                var mref = m.ROMPTR();
                mref.Load(chex);
                m.TempL.Load(cData.High);
                m.TempL >>= 4;
                mref += m.TempL;
                mref.MLoad(m.TempL);
                m.TempL.MStore(outDigit[0]);
                mref.Load(chex);
                m.TempL.Load(cData.High);
                m.TempL &= 0x0F;
                mref += m.TempL;
                mref.MLoad(m.TempL);
                m.TempL.MStore(outDigit[1]);
                mref.Load(chex);
                m.TempL.Load(cData.Low);
                m.TempL >>= 4;
                mref += m.TempL;
                mref.MLoad(m.TempL);
                m.TempL.MStore(outDigit[2]);
                mref.Load(chex);
                m.TempL.Load(cData.Low);
                m.TempL &= 0x0F;
                mref += m.TempL;
                mref.MLoad(m.TempL);
                m.TempL.MStore(outDigit[3]);
                starts.Set();
                tsk.TaskContinue(loop);
            });
            var t2 = os.CreateTask((tsk) =>
            {
                var loop = AVRASM.NewLabel();
                trm.Clear();
                m.TempL.Load('0');
                m.Usart.Transmit(m.TempL);
                tsk.AWAIT(trm);
                m.TempL.Load('x');
                m.Usart.Transmit(m.TempL);
                tsk.AWAIT();
                m.TempL.MLoad(outDigit[0]);
                m.Usart.Transmit(m.TempL);
                tsk.AWAIT();
                m.TempL.MLoad(outDigit[1]);
                m.Usart.Transmit(m.TempL);
                tsk.AWAIT();
                m.TempL.MLoad(outDigit[2]);
                m.Usart.Transmit(m.TempL);
                tsk.AWAIT();
                m.TempL.MLoad(outDigit[3]);
                m.Usart.Transmit(m.TempL);
                tsk.AWAIT();
                m.TempL.Load(13);
                m.Usart.Transmit(m.TempL);
                tsk.AWAIT();
                m.TempL.Load(10);
                m.Usart.Transmit(m.TempL);
                tsk.TaskContinue(loop, starts);
            });
            var p = os.ContinuousActivate(os.AlwaysOn, t0);
            os.ActivateNext(p, ADS, t1);
            os.ActivateNext(p, starts, t2);
            m.ADC.Activate();
            m.Usart.Activate();
            m.EnableInterrupt();
            os.Loop();

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


В этом примере впервые упомянут ADC (аналого-цифровой преобразователь). Это периферийное устройство предназначено для преобразования напряжения входного сигнала в цифровой код. Цикл преобразования запускается функцией ConvertAsync, которая только запускает процесс без ожидания результата. При окончании преобразования ADC генерирует прерывание, которое активирует сигнал adcSig. Обратите внимание на определение сигнала adcSig. В нем, кроме указателя на прерывание указан еще и блок кода для сохранения значений из регистра данных ADC. Весь код, который предпочтительно выполнять сразу после возникновения прерывания (например чтение данных из регистров устройства), следует располагать в этом месте.
Задача conversion служит для преобразования бинарного кода напряжения в четырехзнаковое HEX представление для нашего условного терминала. Здесь можно отметить использование функций описания повторяющихся фрагментов, для уменьшения размера исходного кода и использование константной строки для преобразования данных.


Задача transmission интересна с точки зрения реализации форматного вывода строки, в которой совмещен вывод статических и динамических данных. Сам механизм нельзя считать идеальным, скорее это демонстрация возможностей по управлению обработчиками. Здесь так же можно обратить внимание на переопределение сигнала активации в процессе исполнения, которая меняет сигнал активации с ConvS на TxS и обратно.


Для лучшего понимания, опишем словами алгоритм работы программы.


В исходном состоянии у нас запущено три задачи. Две из них имеют неактивные сигналы, так как сигнал для задачи conversion (adcSig) активируется в конце цикла чтения аналогового сигнала, а ConvS для задачи transmission активируется кодом, который пока не выполнялся. В результате первой задачей, которая будет запущена после старта всегда будет measurment. Код этой задачи запускает цикл преобразования ADC, после чего задача на 500 ms уходит в цикл ожидания. По окончанию цикла преобразования активируется флаг adcSig, что приводит к запуску задачи conversion. В этой задаче реализован цикл преобразования полученных данных к строке. Перед выходом из задачи мы активируем флаг ConvS, давая понять, что у нас есть новые данные для отправки в терминал. Команда выхода переустанавливает точку возврата на начало задачи и отдает управление диспетчеру. Установленный флаг ConvS позволяет передать управление задаче transmission. После передачи первого байта последовательности, в задаче меняется сигнал активации на TxS. В результате этого, после завершения передачи байта, будет снова вызвана задача transmission, что приведет к передаче следующего байта. После передачи последнего байта последовательности задача возвращает сигнал активации ConvS и переустанавливает точку возврата на начало задачи. Цикл завершен. Следующий цикл начнется, когда задача measurment завершит ожидание и активирует следующий цикл измерения.


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


Для реализации очереди в программе лучше всего использовать класс RingBuff. Класс, как понятно из названия, реализует кольцевой буфер с командами записи и выборки. Чтение и запись данных производится командами Read и Write. Команды чтения и записи не имеют параметров. Буфер в качестве источника/приемника данных использует регистровую переменную, указанную в конструкторе. Доступ к этой переменной производится через параметр IOReg класса. Состояние буфера определяется по двум флагам Ovf и Empty, которые помогают определить состояния переполнения при записи и переопустошения при чтении. Кроме этого класс имеет возможность определить код, выполняющийся по событиям переполнения/переопустошения. RingBuff не имеет зависимостей от класса Parallel и может быть использован отдельно. Ограничением при работе с классом можно назвать допустимую емкость, которая должна быть кратной степени двух (8,16,32 и т.д.) из соображений оптимизации кода.


Пример работы с классом приведен ниже


 var m = new Mega328();
 var io = m.REG();
 // создаем кольцевой буфер длиной 16 байт с рабочим регистром io. 
 var bf = new RingBuff(m, 16, io)
  {
      // контроль по событиям
      OnOverflow = () =>
      {
          AVRASM.Comment("Здесь мы обрабатываем переполнение");
      },
      OnEmpty = () =>
      {
          AVRASM.Comment("Здесь мы обрабатываем переопустошение");
      }
  };
  var cntr = m.REG();
  cntr.Load(16);
  // Записываем данные в буфер в цикле
  m.LOOP(cntr, (r, l) =>
  {
      cntr--;
      m.IFNOTEMPTY(l);
  },(r)=> 
            {
             // Строка ниже нужна если контроль по флагам состояния
          //m.IF(bf.Ovf,()=>{AVRASM.Comment("Переполнились”)};  
                bf.IOReg.Load(cntr); //устанавливаем данные для записи в буфер
                bf.Write(); //сохраняем данные в буфере
            });
// читаем данные из буфера
m.LOOP(cntr, (r, l) =>
{
    m.GO(l);
}, (r) =>
{
     // Строка ниже нужна если контроль по флагам состояния
    //m.IF(bf.Ovf,()=>{AVRASM.Comment("Буфер пуст”)};   
    bf.Read();  // данные из буфера записываются в переменную IOReg
    // здесь обрабатываем данные    
});

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

Tags:
Hubs:
+8
Comments 0
Comments Leave a comment

Articles