Pull to refresh

Поворотный энкодер: насколько сложен он может быть

Reading time 7 min
Views 11K
Original author: Al Williams

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

Мне нужен был поворотный энкодер — и я вытянул дешевый экземпляр из одного набора «49 плат для Arduino», какие продаются повсюду. Уверен, это не самый филигранный поворотный энкодер из имеющихся в природе, но для поставленной задачи его должно было хватить. К сожалению, в Mbed OS нет драйвера для такого датчика, а первые несколько сторонних библиотек, которые я нашел, либо работали по принципу опроса, либо не компилировались под последнюю версию Mbed. Разумеется, для чтения поворотного энкодера никакой магии не требуется. Но насколько сложно самостоятельно написать для него код? В самом деле, довольно сложно. Подумал, поделюсь моим кодом и расскажу, как к этому коду пришел.

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

Так что цель моя была проста: хотел найти что-нибудь, управляемое прерываниями. Найденные мной образцы по большей части требовали периодически вызывать некоторую функцию или устанавливать прерывание таймера. Затем соорудили машину состояний, чтобы отслеживать состояние энкодера. Это хорошо, но такая конструкция будет отжирать много процессорного времени только лишь для проверки энкодера, даже если он не движется. Процессор STM32 может с легкостью выполнять прерывания при изменении пина, и именно этого я хотел.

Загвоздка

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

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

Теория

Теоретически, прочитать энкодер – проще простого. У него два выхода, назовем их A и B. Перещелкиваешь рычажок – и эти выходы испускают импульсы. Механическая компоновка внутри такова, что, когда рычажок поворачивается в одном направлении, импульсы от A на 90 градусов опережают импульсы от B. Если перещелкнуть рычажок в другую сторону, фаза будет обратной.

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

В левой части схемы отметим, что сигнал B всякий раз падает перед сигналом A. Если замерить B на нисходящем фронте A, то в таком случае у вас всегда получится 0. Ширина импульсов, конечно же, зависит от скорости перещелкивания. Перещелкивая рычажок в другую сторону, оказываемся на правой стороне схемы. Здесь сигнал A сначала идет на низкий уровень. Если замерить B в той же точке, что и в предыдущем примере, то теперь он будет равен 1.

Обратите внимание: нет никакого волшебства ни с A, ни с B, ни с метками, указывающими движение по часовой стрелке или против часовой стрелки. Все это, в сущности, означает «туда» или «сюда». Если вам не нравится, как движется энкодер, то просто можете поменять местами A и B или сделать это на уровне программы. Я выбрал эти направления произвольно. Как правило, считается, что канал A «ведет» по часовой стрелке, но это зависит и от того, какой фронт сигнала вы измеряете и как все подключили. На программном уровне обычно добавляем единицу к счетчику в одном направлении и вычитаем единицу из счетчика в другом – чтобы представлять, где вы окажетесь со временем.

Есть много способов читать подобный ввод. Если вы замеряете его, то весьма просто собрать из двух битов машину состояний – и таким образом обрабатывать ввод. Вывод образует код Грея, что позволяет вам отбросить плохие состояния и плохие переходы между состояниями. Однако, если вы уверены в вашем входном сигнале, то все может быть гораздо проще. Просто читаем B на фронте A (или наоборот). Можно проверить второй фронт, если хочется добиться немного большей надежности.

Практика

В реальности, к сожалению, механические энкодеры выглядят не так, как на вышеприведенной схеме. Скорее, они выглядят вот так:

Здесь возникает проблема. Если сделать прерывание по обоим фронтам входа A (верхняя линия в области видимости), то получим серию импульсов по обоим фронтам. Обратите внимание: состояния B отличаются на каждом фронте A, поэтому, если у вас в сумме получится четное количество импульсов, то общий счет будет равен нулю. Если вам повезет, то вы можете получить нечетное число в правильном направлении. Либо в неправильном. Что за каша.

Но на замеряемом фронте A значение B незыблемо. Нижняя линия в области видимости кажется прямой, поскольку все переходы B слишком мелкие и не видны в масштабах экрана. В этом и есть секрет, как с легкостью устранить дребезг энкодера. Когда A меняется, B стабильно и наоборот. Поскольку перед нами код Грея, в этом есть смысл, но это же обстоятельство позволяет запрограммировать простой декодер.

План

Наш план таков: заметить, когда A переходит с верхнего уровня на нижний, и именно тогда прочитать B. Далее игнорировать A, пока B не изменится. Конечно же, если вы хотите отслеживать B, то возникнет такая же проблема, поэтому его нужно замкнуть на значение A, которое в момент изменения будет стабильным.  Я в данном случае не хочу использовать еще два прерывания, поэтому стану следовать такой логике:

  1. Когда A падает, записать состояние B и обновить счетчик. Затем установить флаг блокировки.

  2. Если A снова падает, то: если флаг блокировки установлен или B не изменилось – ничего не делать .

  3. Когда A поднимается, то: если B изменилось, записать состояние B и снять флаг блокировки.

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

Проблема

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

Когда вы полагаете, что предыдущее состояние B вам известно, и за последнее время (допустим, за несколько сотен миллисекунд) ничего не изменилось, то код «забудет», каково было состояние B и, таким образом, следующий сигнал B будет считаться действительным, что бы ни случилось.

Я воспользовался фичей Kernel::Clock::now из Mbed. Непонятно, требуется ли от вас вызывать ее из обработчика прерывания (ISR), но я так и делаю и, как кажется, тут все работает без проблем.

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

Код

Весь код выложен на GitHub. Если вы продрались через мои объяснения, то вам не составит труда его прочитать.

void Encoder::isrRisingA()
{
   int b=BPin; // прочитать B
   if (lock && lastB==b) return; // не время для блокировки 
// если lock=0 и _lastB==b, то две эти строки ничего не делают
// но, если lock = 1 и/или _lastB!=b, то в одной из них что-то делается
   lock=0;
   lastB=b;
   locktime=Kernel::Clock::now()+locktime0; // даже если не заблокировано,
 // выдержать задержку для lastB
}
 
// Падающий фронт – там, где выполняется счет
// Обратите внимание: если выдержать паузу в бит, то блокировка истечет,
// так как в противном случае
// нам также придется отслеживать B, чтобы знать, 
// состоялось ли изменение направления 
// Здесь очень хочется попытаться взаимно заблокировать/разблокировать ISR,
// но в реальной практике
// за фронтами следует ряд дребезжащих фронтов, пока B стабильно
// B изменится, пока A стабильно
// Поэтому, если вы не хотите также наблюдать B в сравнении с A,
// то придется пойти на какой-то компромисс,
// и на практике это работает достаточно хорошо
void Encoder::isrFallingA()
{
   int b;
   // снять блокировку в случае timedout, и в любом случае забыть lastB,
// если мы достаточно давно не видели фронт
   if (locktime<Kernel::Clock::now())
     {
     lock=0;
     lastB=2; // такого значения быть не может, поэтому данное   
// событие необходимо прочитать.
     }
   if (lock) return; // блокировка состоялась, так что все готово
   b=BPin; // прочитать B
   if (b==lastB) return; // без изменений в B
   lock=1; // не читать последующего дребезга
   locktime=Kernel::Clock::now()+locktime0; // установить задержку для блокировки
   lastB=b; // запомнить, где сейчас B 
   accum+=(b?-1:1); // наконец, посчитать!
}

Установить прерывание просто, поскольку есть класс InterruptIn. Он подобен объекту DigitalIn, но предусматривает способ прикрепления функции к восходящему или нисходящему фронту. В данном случае используем обе.

Задержка

Я заинтересовался, сколько времени требуется на обработку прерывания в такой конфигурации, так что этот код будет доступен, если установить #define TEST_LATENCY 1. Также можете посмотреть видео о том, что у меня получилось, но, если вкратце: чтобы получить прерывание, уходило не более 10 микросекунд, часто даже около пяти.

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

Если вы хотите освежить знания о коде Грея и вспомнить, в чем он может быть полезен – об этом я говорил ранее. Если все это кажется вам до странности знакомым, напомню, что в 2017 году я писал об использовании энкодера со старой версией Mbed. Тогда я использовал готовую библиотеку, периодически опрашивавшую входные значения при прерываниях таймера. Но, как я и говорил, такие задачи, как описанная здесь, всегда можно решить несколькими способами.

Tags:
Hubs:
+12
Comments 28
Comments Comments 28

Articles