Обработка множества инкрементальных энкодеров одновременно

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

Обозначим текущее состояние энкодера как «y1» и «y2», а предыдущее, как «x1» и «x2». Всего 4 бита — 16 состояний. Условимся, что направление «Вперёд» у нас будет от первого датчика энкодера ко второму. Запишем все возможные состояния в таблицу.

Таблица 1.
№	y2	y1	x2	x1	Вперёд	Назад	Состояние
------------------------------------------------------------------
0	0	0	0	0	0	0	Стоп	
1	0	0	0	1	0	1	Назад
2	0	0	1	0	1	0	Вперёд
3	0	0	1	1	0	0	Не определено
4	0	1	0	0	1	0	Вперед
5	0	1	0	1	0	0	Стоп
6	0	1	1	0	0	1/0	Назад*	
7	0	1	1	1	0	1	Назад	
8	1	0	0	0	0	1	Назад	
9	1	0	0	1	1/0	0	Вперёд*	
A	1	0	1	0	0	0	Стоп	
B	1	0	1	1	1	0	Вперёд	
C	1	1	0	0	0	0	Не определено	
D	1	1	0	1	1	0	Вперёд	
E	1	1	1	0	0	1	Назад	
F	1	1	1	1	0	0	Стоп

* — строчки 6 и 9 в таблице в принципе означают перемещение назад и вперёд соответственно, в случае если оба датчика энкодера никогда не срабатывают одновременно. Такая ситуация теоретически может иметь место если энкодер это две оптопары и колесо с отверстиями, причем размер отверстия меньше расстояния между оптопарами. На практике это встречается редко, по этому будем иметь этот случай ввиду, но учитывать не будем.

Теперь в соответствии с таблицей напишем код определяющий направление вращения энкодера. Самый простой и тем не менее достаточно эффективный вариант вариант это — упаковать все 4 бита в одну переменную и сделать switch по ней:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static uint8_t EncState=0;
 
static volatile uint16_t EncValue=0;
 
inline static void EncoderScan(void)
{
	uint8_t newValue = PINC & 0x03;
s	uint8_t fullState = newValue | EncState << 2;
 
	switch(fullState)
	{
		case 0x2: case 0x4: case 0xB: case 0xD:
			EncValue ++;
		break;
		case 0x1: case 0x7: case 0x8: case 0xE:
			EncValue --;
		break;
	}
	EncState = newValue;
}

Тут мы воспользовались возможностью задавать несколько меток case для одного блока. Значение меток соответствуют номерам строк из нашей таблицы — очень удобно и наглядно, легко обработать и другие состояния если надо.
Теперь приступим к количественным измерениям. Для чистоты эксперимента сканирование энкодера будем помещать в обработчик прерывания, например, от таймера — там будет сразу видно сколько регистров надо сохранять. Сами функции сканирования энкодера будем делать встраиваемыми, что их тело помещалось непосредственно в обработчик прерывания. Размер будем считать от начала обработчика по reti включительно. Целевой процессор — Mega16. Компиляторы avr-gcc 4.3 и IAR C/C++ Compiler for AVR 5.50.0 [KickStart]. Во всех случаях оптимизация кода по размеру. Такты затраченные на выполнение определялись на симуляторах AvrStudio и IAR EWAVR соответственно.

Результаты для этой функции:

  • gcc – 112 байт кода, время выполнения примерно 57-62 тактов.
  • IAR – 116 байт кода, 62-66 тактов.

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

Немного логики
Посмотрим на задачу определения направления вращения энкодера формально:
Есть две логические функции «Вперёд» и «Назад». Они принимают 4 логических параметра и возвращают 1 в случае движения вперёд или назад соответственно. Заданны эти функции таблицей истинности. А по таблице истинности можно уже синтезировать логическое выражение. В нашем контексте задачи это означает, что мы можем упаковать все значения x1, x2, y1, y2 всех энкодеров в отдельные целочисленные переменные, и обрабатывать разом данные со стольких энкодеров, сколько бит в этих переменных. Неплохо так параллельно определить направление вращения сразу до 8/16/32 энкодеров. Изменять значения счётчиков, конечно придётся в цикле, параллельно это сделать уже не удастся.
Теперь только остаётся синтезировать это самое логическое выражение. Возьмёмся для начала за функцию «Вперёд». Найдём в нашей таблице все единичные значения этой функции:

Таблица 2.
№	y2	y1	x2	x1	Вперёд	Назад	Состояние
------------------------------------------------------------------
2	0	0	1	0	1	0	Вперёд	
4	0	1	0	0	1	0	Вперед	
B	1	0	1	1	1	0	Вперёд	
D	1	1	0	1	1	0	Вперёд

Запишем для нашей функции логическое выражение, сразу на языке Си, чтоб не мучатся с математической нотацией:

Fwd =	~x1 &  x2 &  ~y1 & ~y2 |
	~x1 & ~x2 &   y1 & ~y2 |
	 x1 &  x2 &  ~y1 &  y2 |
	 x1 & ~x2 &   y1 &  y2;

Каждая строчка этого выражения соответствует одной строке в таблице. Если в таблице аргумент имеет значение «0», то в нашем выражении записываем его с отрицанием «~» (инверсия всех бит). Если он равен «1», то без отрицания. Например, для первой строчки, только x2 имеет единичное значение, x2 берём непосредственно, остальные аргументы с отрицанием: ~x1 & x2 & ~y1 & ~y2. Это выражение вернёт 1 только если x2 равен 1, а остальные параметры 0. Склеивая выражения для каждой строчки с помощью операции ИЛИ мы получим искомую функцию.
Но эта функция не оптимальна, её можно и нужно оптимизировать. Для этого воспользуемся законами логики.

В первых двух строчках вынесем за скобки ~x1 & ~y2, а в последних двух — вынесем x1 & y2:

	~x1 & ~y2 &  (x2 & ~y1 | ~x2 & y1)|
	 x1 &  y2 &  (x2 & ~y1 | ~x2 & y1) ;

Выражение в скобках (x2 & ~y1 | ~x2 & y1) это ни что иное, как исключающее ИЛИ — x2 ^ y1.
Выражение ещё упростилось:

	~x1 & ~y2 & (x2 ^ y1 )|
 	 x1 &  y2 & (x2 ^ y1 ) ;

Теперь вынесем за скобки x2 ^ y1 и получим:

	 (x2 ^ y1 ) &  (~x1  & ~y2  |  x1   &   y2 );

Во вторых скобках если заменить «x» на «~x», то у нас снова получается исключающее ИЛИ:

	(x2 ^ y1 ) &  (~x1  ^  y2 );

Инверсия одного из аргументов исключающего ИЛИ приводит к инверсии всего выражения, значит инверсию можно вынести за скобки:

	(x2 ^ y1 ) &  ~(x1  ^  y2 );

В результате получилось достаточно простое выражение (всего 4 операции) для определения вращения энкодера вперёд. Это выражение симметрично относительно индексов 1 и 2, и поменяв их местами получим выражение определяющее движение назад:

	(x1 ^ y2 ) &  ~(x2  ^  y1 );

Теперь осталось только реализовать обработку нескольких энкодеров на языке Си.
Ограничимся для начала максимум 8 энкодерами, чтобы значения умещались в тип uint8_t.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
	// Тип переменных-счетчиков
	typedef unsigned EncValueType;
 
	//количество обрабатываемых энкодеров
	enum{EncoderChannels = 8};
 
	// Массив переменных-счетчиков
	static volatile EncValueType EncoderValues[EncoderChannels];
	// предыдущие состояния энкодеров
	static uint8_t _x1, _x2;
 
	// Определение вращения вперёд/назад
	static inline uint8_t Detect(uint8_t x1, uint8_t x2, uint8_t y1, uint8_t y2) 
	{
		//вот оно наше волшебное выражение
		return (x2 ^ y1) & ~(x1 ^ y2);
	}
 
	//функции чтения текущего состояния энкодеров. Первый в второй датчики соответственно.
	// определим их позже
	inline uint8_t EncRead1();
	inline uint8_t EncRead2();
 
	static inline void EncoderCapture()
	{
		// читаем текущее состояние сразу всех энкодеров
		uint8_t y1 = EncRead1();
		uint8_t y2 = EncRead2();
 
		// определяем наличие движения вперёд
		uint8_t fwd  = Detect(_x1, _x2, y1, y2);
		// меняем индексы 1 и2 местами и определяем наличие движения назад
		uint8_t back = Detect(_x2, _x1, y2, y1);
 
		// сохраняем текущее состояние
		_x1 = y1;
		_x2 = y2;
 
		// в цикле проходим по массиву счётчиков энкодеров
		volatile EncValueType * ptr = EncoderValues;
		for(uint8_t i = EncoderChannels; i; --i)
		{	
			if(fwd & 1)
				 (*ptr) ++;
			else 
			if(back & 1)
				(*ptr) --;
			ptr++;
			fwd >>= 1;
			back >>= 1;
		}
	}
 
	// функции чтения текущего состояния энкодеров.
	// реализуем их как душе угодно, то есть как энкодеры подключены.
	//Нулевой бит в обоих значениях соответствует нулевому энкодеру, первый — первому и т.д.
	inline uint8_t EncRead1()
	{
		return PINC;
	}
 
	inline uint8_t EncRead2()
	{
		return PIND;
	}

Итак, посмотрим на результаты:

avr-gcc:

  • 1 энкодер 108 байт — 63-70 такта
  • 2 энкодера 162 байт — 77-84 тактов
  • 8 энкодеров 138 байт — 216-276 тактов

IAR:

  • 1 энкодер 128 байт — 86-94 такта
  • 2 энкодера 128 байт — 100-116 тактов
  • 8 энкодеров 128 байт — 186-248 тактов

При использовании только одного энкодера, результат примерно сопоставимый с вариантом на базе оператора switch, даже чуть похуже. Однако, уже при обсчете двух энкодеров преимущество становится очевидным. Для восьми — оно ещё более значимо. Здесь основное время уже занимает обход массива со счетчиками и изменение их значения.

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

20 thoughts on “Обработка множества инкрементальных энкодеров одновременно”

  1. Вот вариант моего одометра на Паскале. 2 колеса, по 2 оптопары на каждом. Нормально отрабатывает, даже если перегруженное колесо будет не вращаться, а дрожать возле одного из переходных состояний, чего обычно боятся простые алгоритмы валкодеров и одометров. У меня при этом просто будут чередоваться +1 и -1 к пути, не накапливая погрешность. Крутится этот кусок кода постоянно в главном цикле, затраты на обработку минимальны:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    
    // Одометр!
        D_kol := PORTB and $F0;
        if D_kol  D_kol_old then // Если сдвинулись!
          begin
            D_kol_x     := D_kol      and $C0;
            D_kol_x_old := D_kol_old  and $C0;
            if D_kol_x  D_kol_x_old then // Если левое колесо повернулось
              begin
                Fl_napr_L := 0; // Вперед!
                Case D_kol_x of
                  $00 : {00} if D_kol_x_old = $80 {10} then Fl_napr_L := 1; // Назад!
                  $40 : {01} if D_kol_x_old = $00 {00} then Fl_napr_L := 1; // Назад!
                  $80 : {10} if D_kol_x_old = $C0 {11} then Fl_napr_L := 1; // Назад!
                  $C0 : {11} if D_kol_x_old = $40 {01} then Fl_napr_L := 1; // Назад!
                 end;
          // Считаем путь!
                if Fl_napr_L = 0 then DWL_6 := DWL_6 + 1 else DWL_6 := DWL_6 - 1;
              end;
            D_kol_x     := D_kol      and $30;
            D_kol_x_old := D_kol_old  and $30;
            if D_kol_x  D_kol_x_old then // Если правое колесо повернулось
              begin
                Fl_napr_P := 0; // Вперед!
                Case D_kol_x of
                  $00 : {00} if D_kol_x_old = $20 {10} then Fl_napr_P := 1; // Назад!
                  $10 : {01} if D_kol_x_old = $00 {00} then Fl_napr_P := 1; // Назад!
                  $20 : {10} if D_kol_x_old = $30 {11} then Fl_napr_P := 1; // Назад!
                  $30 : {11} if D_kol_x_old = $10 {01} then Fl_napr_P := 1; // Назад!
                 end;
          // Считаем путь!
                if Fl_napr_P = 0 then DWP_6 := DWP_6 + 1 else DWP_6 := DWP_6 - 1;
              end;
          end;
        D_kol_old := D_kol;
    // Конец  Одометр!
        1. Да, так лучше структура видна. А то когда по краю выровнено, все как-то сливается. Я там для экономии, чтобы не проверять все 8 вариантов, принимаю сначала за направление «вперед», и потом проверяю только наличие одного из 4х условий для смещения «Назад». Если обнаружено — меняю направление на заднее, это почти вдвое уменьшает размер кода. А вначале проверяю, было ли изменение состояние относительно предыдущего, если не было — остальное вообще пропускается, что дает еще большую экономию. Главный цикл у меня вертится с периодом около 250мкс, а изменение состояния датчиков происходят гораздо реже. Такая вот оптимизация алгоритма — сначала всегда проверяю более вероятные и более короткие ветки. Привычка…

        2. Еще знаки «Больше» и «меньше», «не равно», в условиях потерялись, но их этот редактор видно не пропускает, принимая за тэги. Я наверно на днях в своей теме о роботе на форуме последние исходники выложу, если кому надо, там можно будет целиком листинги глянуть.

  2. Поправьте, если ошибаюсь, но как бы логику не старались «ужимать», компилятор «зная», что МК имеет в арсенале только определенные операции, развернет все «сжатия» (как поп-корн). Отсюда и сопоставимые интервалы.

    1. А что ее, логику, разворачивать? Комманды AND, OR, NOT, XOR есть в любом МК. И эта самая логика быстрее всего считается. Дольше всего работает загрузка счетчика из памяти, его изменение и сохранение обратно. Примерно 14 тактов на каждый энкодер. Плюс еще организация цикла. Хотя, ГЦЦ для 2-3 энкодеров цикл разворачивает, выигрывая в скорости за счет увеличения размера кода.

  3. Если не ошибаюсь, при использовании умных компиляторов (к коим относятся почти все компиляторы Си), можно записать просто

    Fwd = ~x1 & x2 & ~y1 & ~y2 |
    ~x1 & ~x2 & y1 & ~y2 |
    x1 & x2 & ~y1 & y2 |
    x1 & ~x2 & y1 & y2;

    — а компилятор сам сожмёт всё это до минимума, т.к. увидит всё то же, что увидел автор статьи. Но по-хорошему, конечно, всё надо сделать ручками.

  4. Коллеги, добрый вечер. А скажите мне, почему просто не использовать проинициализированную таблицу состояний? Т.е. для определения направления нужно будет писать нечто типа
    direction = state_table[ x1 | x2 | y1 | y2 ] ;

    1. Потому, что:
      — 16 байт будет занимать таблица;
      — считывание значения из таблицы (вычисление индекса + обращение к памяти) будет дороже, чем четыре логических операции с регистрами;
      — обрабатывать будем все энкодеры последовательно.
      Получается, проиграем во всём: скорости, размере кода, требуемой памяти (таблица).
      Читаемости это тоже не добавит.

  5. При таком условии на один щелчок энкодера будет происходить 4 срабатывания счетчика, что не есть гут для энкодеров со щелчками.
    Если я убираю два условия и оставляю ~x1 & ~y2 & (x2 ^ y1 ), то срабатывание происходит в двух соседних тактак вращения, что тоже не гут — для энкодеров с количеством импульсов меньшим, чем щелчков, будет происходить 2 срабатывания за один щелчок и 0 за последующий.

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

    1. А собственно в чем проблема 4 срабатываний на щелчек? Если нужно считать именно число щелчков, то делим счетчик на 4 посредствам сдвига на 2 разряда вправо и получаем искомое.

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

  7. Классная статья, мне как раз скоро понадобится обработка двух энкодеров.
    DiHalt, у тебя где-то была статья про составление алгоритмов с помощью «графов». Потерял, не могу найти.

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

    1. Я такой точно не писал. Но были статьи по конечным автоматам и возможно там ты мог видеть графы.

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

Добавить комментарий

Ваш e-mail не будет опубликован.

Перед отправкой формы:
Human test by Not Captcha