Машина времени для крошек.

AVR
При программировании микроконтроллеров часто требуется отслеживать временные промежутки. Даже банальное «помигать светодиодом» требует учета времени погашенного состояния и времени светимости.

В Си есть функция delay_ms(X), которая выполняет пустой цикл X ms, то есть по сути вешает проц на X*fcpu/1000 тактов, так как ничего иного в это время он делать не может.

Многие прогеры, намучившись с этой функцией (или ей подобными), переходят на использование прерываний и аппаратных таймеров, но тут другая засада — их всегда мало.

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

Пройдя все эти стадии, я наконец-то дорос до понимания, что «что-то тут не так». Нарисовалась необходимость диспетчеризации задач по таймеру.
Прочитав AVR. Учебный Курс. Архитектура Программ Часть 2 в очередной раз убедился, что изобрел велосипед… ну и пусть, это просто еще один вариант решения.

Итак, берем один аппаратный таймер и навешиваем на него несколько тайм-слотов, столько сколько нужно. Тайм-слот это область в ОЗУ размером в 4 байта, первая пара байт содержит время задержки, вторая пара — точку возврата, то есть адрес куда надо вернуться, когда время истечет.

Вот и вся идея, остальное — детали реализации.

Как это работает.
Для начала инициализируем аппаратный таймер, чтобы он выставлял флаг… скажем 50 раз в секунду.

    \ T0 используется для тактирования
    \ делаем           v--50гц
    ldi r,fclk @ 1024 50 * /  
    mov OCR0A,r
    ldi r,{b WGM01 }     mov TCCR0A,r \ режим CTC
    mov TCNT0,(0) \ обнулить таймер0
    ldi r,{b CS02 CS00 } mov TCCR0B,r \ 1024


Получается нечто вроде этого:

    LDI   R24,216
    OUT   OCR0A,R24
    LDI   R24,2
    OUT   TCCR0A,R24
    OUT   TCNT0,R5
    LDI   R24,5
    OUT   TCCR0B,R24


Теперь осталось в главном цикле регулярно вызывать подпрограмму Time.

code main
    ... что-то важное
    begin \ главный цикл
       rcall Time
       ... что-то полезное
    again 
    c;


Эта подпрограмма проверяет флаг таймера, нет флага — нет действия, возвращаемся в главный цикл. Если флаг установлен, значит прошел один (или несколько) тик времени, пора начинать обработку очередного тайм-слота, то есть уменьшить его поле time на 1 и посмотреть, что получилось:
  • если <0, значит тайм-слот пустой, возвращаемся в главный цикл
  • если получилось >0, то сохраним новое значение и вернемся
  • если =0, значит время пришло и пора вызывать отложенное действие
За один вызов Time обрабатывается один тайм-слот, когда обработаны все слоты, программно сбрасываем флаг таймера (поэтому нельзя использовать прерывание таймера, оно автоматом гасит флаг). За порядком обработки слотов следит отдельная переменная NextTimer, так что и ее нужно учесть и обработать в подпрограмме Time.

code Time \ вызывать периодически в главном цикле
    mov r,TIFR0 skip_b rOCF0A ret \ нет флага - нет работы
    
    \ сработал флаг основного периода таймера
    \ определимся с текущим тайм-слотом
    mov r,NextTimer inc r mov rH,r
    cpi r,#timers \ проверить не последний-ли
    if= 
        clr rH \ последний, переход на 0
    then  
    mov NextTimer,rH \ сохранить новое значение счетчика тайм-слотов
    if= 
        ldi rH,{b OCF0A } mov TIFR0,rH \ сбросить флаг после проверки всех таймеров
    then 
         
    \ проверить таймеры, по одному за вызов
    ldiW Y,Timers \ получить адрес массива слотов
    begin \ переход к нужному таймеру... скачками
        dec r 
    while  
        adiW Y,sizeTimers 
    repeat 
    ldd rL,Y+0 timeOn  ldd rH,Y+1 timeOn
    sbiw R,1 \ декремент текущего счетчика времени
    if_nC \ > или = 0 -  сохранить его
        std Y+0 timeOn,rL std Y+1 timeOn,rH 
        if0 \ =0 - получить адрес и перейти на точку
            ldd zL,Y+0 point  ldd zH,Y+1 point ijmp  
        then
    then ret c; \ возврат в главный цикл


Вот собственно и получилось: программный таймер и диспетчер задач «в одном флаконе». Размер 64 байта.
Время переключения задач примерно от 36 до 60 тактов, зависит от номера тайм-слота.
Когда время истечет, то вместо возвращения в главный цикл, подпрограмма Time передает управление по адресу из поля point, вызывая тем самым выполнение отложенного действия и уже оттуда ret возвращает управление в главный цикл.

Для заполнения тайм-слотов используется подпрограмма Delay:. В качестве параметров она получает время задержки (через регистр) и адрес возврата (через стек возвратов). Эти параметры она и записывает в тайм-слот, а вот в какой именно, это вопрос отдельный.
Сначала Delay: ищет слот с таким-же адресом возврата (т.е. поле point можно рассматривать как ID слота). Если такой найдется, то его время обновиться указанным значением, если нет, то ищется свободный слот, т.е. такой у которого time=0, если и такого нет, то уходит обиженным.
Таким образом задача сама себя ставит в очередь на исполнение, нужно только раз ее вызвать, дать ей такую возможность, а дальше она уже сама о себе пусть заботиться… когда, куда и сколько раз.

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

Как это использовать.

Пример №1 Бесконечный цикл
Это типовое использование для организации независимых подзадач, в данном случае задача мигания светодиодом:

code migni
    begin
        ldiW X,1 sek 4 / rcall Delay: \ сделать задержку в 1/4 сек и уйти
        \ вернуться сюда через 1/4 сек 
        if_b Led_Low \_ Led_Low else _/ Led_Low then \ погасить или зажечь светодиод
    again c;

Это задачка с бесконечным циклом, запустив один раз о ней можно забыть. СД будет мигать не мешая остальным задачам.
Вот как она выглядит в ассемблере:
migni:      LDI   R27,0   \ это попадет в поле time
            LDI   R26,4   \ это попадет в поле time
            RCALL Delay:  \ тут мы выйдем на время
            SBIS  PORTB,7 \ этот адрес окажется в поле point и сюда мы вернемся через время
            RJMP  m47
            CBI   PORTB,7
            RJMP  m48
m47:        SBI   PORTB,7
m48:        RJMP  migni   \ сделал дело - начинай с начала

Пример №2 Однократное и последовательное выполнение
Допустим нужно при включении выполнить проверку исправности светодиодов, пусть они загорятся все по очереди:
\ code Migni2 \ пример разовой последовательной работы
     _/ Led_Jen    ldiW X,1 sek 4 / rcall Delay: \ зажечь первый, подождать..
     _/ Led_OnMoto ldiW X,1 sek 4 / rcall Delay: \ .. второй ..
     _/ Led_Low    ldiW X,1 sek 4 / rcall Delay: \ и т.д.
     _/ Led_Norm   ldiW X,1 sek 4 / rcall Delay: 
     _/ Led_Full   ldiW X,1 sek 4 / rcall Delay: 
     mov r,portB cbr r,{b AllLeds } mov portB,r  \ погасить все
    ret c;

Несмотря на обилие rcall Delay: реально используется только один тайм-слот, так как когда он отработал свое, он остается с нулем и становится свободным, следующий rcall Delay: вполне вероятно выберет его-же.
После отработки последнего Delay: и гашения всех светодиодов, команда ret вернет управление не туда откуда вызывалась Migni2, а в главный цикл. Тут главное не запутаться. Нужно помнить, что отложенное действие вызывается к жизни именно из главного цикла, откуда стартует подпрограмма Time.

Пример №2 1/2 Однократное последовательного выполнение
В примере №2 строчка «ldiW X,1 sek 4 / rcall Delay:» повторяется пять раз, не хорошо это. Лучше сделать так:
code 1/4sek: ldiW X,1 sek 4 / goto Delay: 
    c;
code Migni2.5 \ пример разовой последовательной работы
    _/ Led_Jen    rcall 1/4sek: 
    _/ Led_OnMoto rcall 1/4sek: 
    _/ Led_Low    rcall 1/4sek: 
    _/ Led_Norm   rcall 1/4sek: 
    _/ Led_Full   rcall 1/4sek: 
    mov r,portB cbr r,{b AllLeds } mov portB,r
    ret c;

Подпрограмма 1/4sek: вызывается по rcall, то есть в стеке появляется адрес возврата после нее, а сама она вызывает Delay: прыжком, через goto, ничего не добавляя на стек возвратов. Следовательно в тайм-слоте будет запомнен адрес расположенный после rcall 1/4sek:, а не после goto Delay:.
После goto Delay: даже ret не нужен, так как туда нет возврата. Сам Delay:, заполнив тайм-слот, вернет управление туда откуда вызывалась Migni2.5.

В общем и целом подпрограмма Delay: записывает в тайм-слот адрес с вершины стека возврата, а сама возвращается по следующему в стеке адресу и это у же забота прграммиста, чтобы там был именно адрес и чтобы он вел куда надо. Иными словами неосторожный push пошлет систему «на деревню к дедушке».

Пример №3 Продолжение и убийство.
Допустим нужно контролировать наличие радиосвязи с объектом управления, для этого он (объект) через определенные промежутки времени шлет сигнал «Ок». Если прием идет регулярный, то все хорошо, а если сигнала долго нет, то нужно что-то сделать, например пискнуть:
code WaitRxOk ( X=delay --) \ ждать ответа
    rcall Delay:
    \ недождался - пикни --v
    _/ Buser ldiW X,1 sek 5 / rcall Delay: \_ Buser
    ret c;

Задача, занимающаяся приемом, при каждом принятом сообщении вызывает WaitRxOk, сообщая ей время задержки:

    ... \ прием
    ldiW X,3 sek rcall WaitRxOk \ тут нет застоя!
    ... \ дальнейшие дела

Если период вызова WaitRxOk будет меньше времени задержки, то время до писка будет постоянно продлеваться. WaitRxOk не вешает программу. Как только она займет тайм-слот, так сразу-же и возвращает управление к "… дальнейшие дела".
«Пискун» постоянно наготове, но ему говорят «погоди», «рано», «еще немного»… Так может продолжаться очень долго, но стоит один раз не успеть его упредить, он включит бизер и полсекунды будет громко возмущаться… потом, правда, успокоится.

Можно и вовсе заткнуть его навеки:

    ldiW X,0 rcall WaitRxOk \ тут произойдет убийство!

Вызов Delay: с нулевым параметром задержки, прибьет задачу с тем же адресом возврата, если такая найдется, или, в противном случае, найдет свободный тайм-слот… и оставит его таким-же свободным.

Как это сделано.
Все это хозяйство занимает 148 байт во флэше и 4n+1 байт RAM, где n — число используемых тайм-слотов. Таким образом оно легко помещается в тиньки. Мне обычно хватает 4-х слотов (17 байт ОЗУ) (индикация, клавиатура, связь и еще чего-то), но можно и больше. Тайм-слоты не привязаны к какой-то определенной задаче, они так сказать «общего пользования», их число определяет только количество «одновременно» выполняющихся задач.
Использую в AVRtiny с 2К и выше, в принципе можно и в 1К затолкать… (только зачем?)

К топику приложен zip файл с исходниками, как на форт-ассемблере, на котором я пишу, так и на обычном ассемблере. Листинг «обычного ассемблера» получен при компиляции и поэтому лишен коментариев и вменяемости, но зато его можно проиграть в AVRstudio. Есть там и схема проекта типа «помигай мне», вместе с hex, это для «попробовать».
  • +2
  • 24 января 2018, 13:02
  • iva
  • 1
Файлы в топике: TM-Led.zip

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

RSS свернуть / развернуть
Какой странный синтаксис у ассемблера. Фортом попахивает.
на форт-ассемблере, на котором я пишу
Э, вона как. А где такой гибрид ужа с ежом водится?
0
  • avatar
  • Vga
  • 24 января 2018, 16:52
В достаточном количестве нигде. Это мой самопал (написан с 0 по мотивам Firmware Studio Брэда Эккерта), мне в нем удобно. Выложен на гитхабе, где и лежит невостребованным.
0
Хороший олдскул. Еще можно добавить прямое управление приоритетами задач, чтобы вручную не прописывать.
0
Как именно?
Тут приоритета совсем нет, Time тупо перебирает тайм-слоты по очереди, и не учитывает какая задача в каком сидит. О приоритете стоит задуматься, когда задержки кончаются одновременно у нескольких задач, такое возникает сравнительно редко, в основном задачи работают асинхронно.
0
Вот именно, следующим будет исполнятся таймслот Т+1. Если переписывать указатели на процессы в тайм-слотах на лету, можно примитивно, но управлять приоритетами.
0
Если флаг установлен, значит прошел один (или несколько) тик времени, пора начинать обработку очередного тайм-слота, то есть уменьшить его поле time на 1 и посмотреть…
За один вызов Time обрабатывается один тайм-слот, когда обработаны все слоты, программно сбрасываем флаг таймера (поэтому нельзя использовать прерывание таймера, оно автоматом гасит флаг).
Мне не нравится этот момент. Предпочитаю реализацию, что между простоями инкрементируется счётчик слотов, а при вызове функции обработки происходит смещение сразу на указанное значение. Так значительно ниже влияние «длительных» процессов в основном цикле, без необходимости увеличения длительности тайм-слота.
Но как реализация для тини и в такой форме вполне применимо.
0
Простите, не совсем понял.
Вы предлагаете сделать цикл внутри Time? Типа, если уж залетел туда, то продекрементируй все счетчики. Так можно сделать, слегка увеличив размер кода.
Но можно и по другому — вынести «длительные» процессы из главного цикла и фрагментировать их работу, пусть они сами регулярно вызывают Time (или проверяют флаг таймера) и дают дорогу другим.
0
Не, я предлагал без внутренних циклов. По прерыванию инкрементируем счётчик. Чтобы все таймеры тикали равномерно, в функции тайм, при обработке первой ячейки, запоминает значение счётчика. Это будет период времени, отнимаемый от всех таймеров, вместо текущей единицы.
Только у такой простой модели есть минус. Если таймер добавляется в ячейку за текущей обрабатываемой, то получится, что из него так же вычтут большой интервал. У вас, на сколько я понимаю, существует та же проблема, только она ограничена 1 тиком. Так что лучше пусть всё остаётся как есть. Ежели дорабатывать, то там гораздо тяжелее код станет.
0
От прерываний я отказался, чтобы переключение задач происходило в нужном месте кода, это избавляет от обязательного сохранения контекста и экономит стек (в главном цикле стек возвратов пуст).
Если прерывание от таймера необходимо, например для пробуждения чипа, то я бы в нем только выставлял программный флаг (лучше в GPIO, одна команда и без изменения SREG), а дальше как обычно.
В поле time хранится не само время, а количество тиков таймера, которое нужно оттикать до вызова point. Поэтому каждый тик уменьшает time ровно на 1, что гарантирует точное попадание счетчика на 0. Вычитание иного значения может дать перелет в отрицательную область.
Если требуется точное время для операции, то лучше использовать другой таймер и его прерывания, а этот велосипед оставить для задач которым +/- тик не важен.
Например в одном проекте я оцениваю напряжение батарейки по времени заряда конденсатора (нету АЦП). Сам замер делает 16-ти битный таймер1 по прерыванию захвата, а вот выбор момента запуска идет через диспетчер, это синхронизирует действия подзадач. Дело в том, что тот же таймер1 используется в другой подзадаче, он формирует меандр 2440Гц для пищалки. В итоге таймер либо пищит, либо измеряет, конфликтов не возникает, так как между этими подзадачами есть логическая связь.
0
по прерыванию вы всего лишь инкрементируете счетчик. более ничего не надо там делать. Теперь вместо флага у вас (что по сути является однобитный счетчик) — 8-ми битное число. В основной программе вы теперь можете знать, сколько раз таймер протикал в момент вызова вашей подпрограммы Time
0
Таймер задает такт исполнения, «квантует» время. Все подзадачи должны уложится в этот «квант». Если они не могут это сделать, то проц «прозевает» тик и выполнит подзадачу в следующем «кванте». «Зевание» никак не контролируется. Если это критично, то либо надо увеличить «квант», либо урезать хотелки подзадачи.

Обычно я ставлю тик на 1/16 секунды (в топике пример 1/50, просто потому что частота у чипа 8Мгц и 8-битный таймер0 просто не может тикать медленнее). Трудно сделать подзадачу у тиньки, которая съесть такой «квант».
Самая длительная процедура у меня была подсчет CRC24 всего флэша, да и то это делается один раз при старте.

В одном проекте нужно было контролировать переменную SOC, если за 8 часов она не изменилась, то следовало уйти в спячку.
Сделал подзадачу:
code SOCTimer \ учет неизменного SOC
    mov OldSOC,curSOC
    ldi cntHr,8 
    for
        ldiW X,60 mnt rcall Delay: \ 1 раз в час
    next cntHr
    \ прошло 8 часов с последнего изменения SOC
    \_ fPower 
    ret  c;

Эта подзадача вызывалась один раз при старте.
Другая подзадача, которая измеряла SOC, при изменении параметра просто делала cntHr=8 и подзадача SOCTimer не могла сработать до конца, до отключения (как в притче про осла и морковку).
0
Велосипед, конечно, занятный, но куда полезнее было бы покопать в сторону SST (super-simple tasker). Размер будет сравнимый, а вот функциональность на порядок шире и полезнее.
0
  • avatar
  • evsi
  • 26 января 2018, 18:04
0
Да, оно.
0
Хорошая машинка, я бы даже сказал мотоцикл!
Если в TMT (time machine for tiny) добавить очередь событий, task control blocks (TCBs), оформлять задачи по типу прерываний, с сохранением контекстов, да еще перейти на AVRmega и ARM, так как у тинек ОЗУ тоже крошечное, то получится SST.
А пока задачи переключает только одно событие — «пора», TMT можно назвать uST (ultra-simple tasker).
0
С удовольствием посмотрю на такой.
0
0
Правда, это сильно продвинутый вариант. А так легко найти версии и попроще, даже в статье есть исходники.
0
И все же, вижу сдесь недостаток в том, что необходима функция диспетчера, которая бы выполняла много однотипных задач по декременту счетчика на каждой задаче, так же минус в том, что имеется необходимость наличия самой таблицы этих задач, их заданного числа. При добавлении задачи нужно расширять это поле… Поскольку этот дтиспетчер все равно работает в основном цикле без прерываний — есть другие более красивые и дешевые варианты.
0
Например? Дешевле 148 байт во флэше (74 слова) и 4n+1 байт RAM, где n — число одновременно используемых тайм-слотов.
Если есть подзадачи, то нужно и место, где они дожидаются своего часа и код который следит за ними. За все нужно платить.
Прерывания никто не запрещает, но применять их нужно там, где это реально необходимо, иное — извращение.

P.S.
Мне как-то встречался товарищ, которому проще делать +1 в цикле, чем разбираться с аппаратными таймерами, он так и не понял для чего они вообще нужны.
0
я не о прерываниях. Я о том, что логичнее использовать всего один счетчик, а в самих таймерах хранить стартовое число счетчика и интервал, при котором таймер должен сработать. В этом случае таблица не нужна и все переменные могут храниться локально в модуле
0
Так ведь и используется один аппаратный таймер, на его основе работают все остальные программные таймеры. В тайм слотах хранятся стартовые числа, их декремент и определяет интервал когда таймер (программный) должен сработать, что значит «перейти по адресу», который хранится в том же тайм-слоте.
По моему все так, как Вы и описали… только с модулем не понял. Какой именно модуль имелся ввиду?
Если «модуль» это подзадача, то свои переменные она сама и обслуживает, с таблицей напрямую не работает, только сообщает ей «разбуди меня через Х секунд».
Если «модуль» это сама система таймеров, то счетчики в таблице и есть ее локальные переменные, а сама таблица просто связывает время и действие.
0
Занятный велосипед. А не проще использовать RTOS?
При потере одного таймера, значительно повысится читаемость кода и скорость разработки и не придётся городить то, что уже давно реализовано.
Хотя судя по коду, таймер вы всеж юзаете. Следовательно — никаких преимуществ нет.
Из своей практики скажу, что я хардварные таймеры вообще не использую, если не требуется получать часоты с периодом более 1мс. Следовательно зависимость от таймеров пропадает как класс, что и ставится целью в статье.
0
Посудите сами, где tiny, а где RTOS.
Целью статьи являлось показать, что и на тиньках можно использовать многозадачность. Возможность разбиение проекта на множество мелких подзадач, упрощает код, уменьшает его размер, улучшает читаемость и сопровождение… ну для меня во всяком случае.

Недавно потребовалось исправить баг (неудобство) в старом проекте, построенном по монолитной схеме с кучей флагов, на ATtiny2313 (2K+128b). Место, где происходил временной затык, я нашел быстро… но исправить не смог, так как рушилась логика работы, постоянно что-то отваливалось или начинало «тупить».
Промучившись несколько дней, я плюнул и переписал код как многозадачный, кнопки отдельно, радио отдельно, контроль батареи отдельно, пищалка отдельно, светики отдельно.
Управился за день. Код вырос на 96 байт, что меньше добавленного «велосипеда», большая часть флагов исчезла за ненадобностью, программа стала прозрачной как березовая роща и более «юзабельной».
Когда заказчик пришел и попросил еще немного изменить поведение — сделал тут же, при нем, так как изменения локализовались в отдельных задачах.
+1
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.