Оценка методов измерения низких частот на Arduino

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

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

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

Вариант 1. Переделываем pulseInLong ()


На функции pulseIn () я сначала и зациклился —, а нельзя ли ее приспособить к этому делу? В недрах папок Arduino (в файле wiring_pulse.c) обнаружился ее более продвинутый вариант под названием pulseInLong (). Введен он был, как я выяснил, где-то около версии 1.6.5, но чем этот вариант лучше оригинальной функции, так и не понял. Судя по тому, что функция не введена в официальный перечень — или ничем, или имеет какие-то невыясненные ограничения. Но структура ее мне показалась более прозрачной и проще поддающейся переделке в нужном направлении. Выглядит вызов функции так же, как и pulseIn ():

unsigned long pulseInLong(uint8_t pin, uint8_t state, unsigned long timeout)


В функции последовательно работают три условно-бесконечных цикла (с принудительным выходом по заданному timeout). В первом цикле ожидается перепад в состояние, заданное параметром state (HIGH или LOW) — чтобы пропустить текущий импульс, в середину которого мы, возможно, попали. Во втором ожидается начало следующего периода (обратный перепад), после чего определяется количество микросекунд на старте. Наконец, третий цикл — измерительный, ожидается опять перепад в состояние state, фиксируется разность количества микросекунд и возвращается в значении функции.

Переделка заключается в том, что после третьего цикла количество микросекунд не фиксируется, а добавляется четвертый цикл, идентичный второму. И только после достижения снова такого же перепада, как на старте, количество микросекунд фиксируется и возвращается в значении функции. Таким образом мы получаем длительность периода. Испытательный скетч с переделанной функцией, которую я переименовал в periodInLong, полностью выглядит так (часть комментов оставлена от оригинала):

#define Tone_PIN 12 // выход частоты – см. в тексте 
#define IN_PIN 8 //вход обнаружения частоты

volatile unsigned long ttime = 0;        //Период срабатывания датчика

unsigned long periodInLong(uint8_t pin, uint8_t state, unsigned long timeout)
{
  // cache the port and bit of the pin in order to speed up the
  // pulse width measuring loop and achieve finer resolution.  calling
  // digitalRead() instead yields much coarser resolution.
  uint8_t bit = digitalPinToBitMask(pin);
  uint8_t port = digitalPinToPort(pin);
  uint8_t stateMask = (state ? bit : 0);

  unsigned long startMicros = micros();

  // wait for any previous pulse to end
  while ((*portInputRegister(port) & bit) != stateMask) {
    if (micros() - startMicros > timeout)
      return 0;
  }

  // wait for the pulse to start
  while ((*portInputRegister(port) & bit) == stateMask) {
    if (micros() - startMicros > timeout)
      return 0;
  }

  unsigned long start = micros();
  // wait for the pulse to stop
  while ((*portInputRegister(port) & bit) != stateMask) {
    if (micros() - startMicros > timeout)
      return 0;
  }
  // wait for the pulse to start
  while ((*portInputRegister(port) & bit) == stateMask) {
    if (micros() - startMicros > timeout)
      return 0;
  }
  return micros() - start;
}

void setup() {
  pinMode(IR_PIN, OUTPUT); //на выход
  pinMode(IN_PIN, INPUT); //вывод обнаружения частоты на вход
  Serial.begin(9600);
//  tone(Tone_PIN, 1000); 
}

void loop() {
      ttime=periodInLong(IN_PIN, LOW, 1000000); //ожидание 1 сек
        Serial.println(ttime);
       if (ttime!=0) {//на случай, если частота пропала
       float f = 1000000/float(ttime); // Вычисляем частоту сигнала в Гц
        Serial.println(f,1);}
      delay(500);
}


Обратите внимание на вывод Tone_PIN и закомментированный вызов функции tone () в разделе setup (). Это сделано для проверки еще одного обстоятельства, о чем в конце статьи.

Для проверки работы на вывод 8 (произвольно выбранный в качестве IN_PIN) подавался сигнал от самодельного генератора на основе часового кварца и счетчика-делителя 561ИЕ16. На выходе его мы получаем частоты, кратные степеням двойки, от 2 до 2048 Гц, а также 16384 Гц (и при желании, еще 32768 Гц прямо с генератора).

Результаты выборки последовательных измерений для частот 2, 8, 64, а также 2048 и 16384 герца объединены в одну таблицу на рисунке (верхняя строчка — длительность в микросекундах, следующая — рассчитанная частота):

424f89ee19fe666d1614ef3000013c41.png
Как мы видим из этих данных, способ вполне удовлетворительно работает для низких частот (ниже примерно 100 герц), но «дребезжит» на высоких частотах. Это нормально, если вспомнить, что функция micros () завязана на переполнения и счетчики Timer0, а вызов ее и манипуляции с длинным целым занимают значительное время. Простые условно-бесконечные циклы в контроллерах в принципе не очень надежная штука.

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

Способ 2. Идеологически правильный: задействуем Timer1


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

Способ состоит в том, что мы запускаем 16-разрядный Timer1 сразу в двух режимах: счета и захвата событий. При счете удобно установить делитель тактовой частоты 1/8, тогда в 16-мегагерцовом контролере время подсчитывать будем тиками по половине микросекунды. При переполнении (65536 половин микросекунды) вызывается прерывание переполнения, которое инкрементирует счетчик третьего разряда (байта) длинного числа. Трех байтовых разрядов (16777216 половин микросекунды или около 8 секунд) вполне достаточно для наших целей подсчета периода частоты порядка единиц-десятков герц. По захвату события перепада уровня мы фиксируем трехразрядное число тиков, прошедших с предыдущего такого события (собирая его из значений регистров таймера плюс третий старший разряд), обнуляем все переменные и счетные регистры и ждем следующего перепада. По идее надо бы еще очищать счетчики предделителя тактовой частоты, но они все равно изменятся при работающем Timer0 (предделитель для таймеров 0 и 1 общий), а при делителе 1/8 эта ошибка будет незначимой.

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

Я предполагаю, что найдется немало народу, который захочет оспорить это утверждение. В Сети масса источников, выдвигающих тезис о том, что прерывания применять опасно, потому что якобы можно что-то потерять. Глубоко ошибочное мнение: все ровно наоборот — как раз в цикле loop () потерять легко, а в прерываниях очень трудно. Правильная программа должна работать преимущественно на прерываниях (за исключением процедур, которые там нельзя использовать — вроде команды sleep). Только тогда из контроллера можно выжать максимум возможного. Внутри контролера не бывает функций, которые длятся настолько долго, чтобы существенно помешать другим функциям в других прерываниях, даже если это операции c числами типа long или float. Самая долгая из операций — деление чисел типа long — выполняется примерно за 670–680 тактов, то есть где-то за 42 микросекунды и она редко бывает больше чем одна на все прерывание. Вот обмен с внешней средой длится гораздо дольше: так, передача байта со скоростью 9600 длится примерно миллисекунду. Но длинные процедуры обмена с ожиданием ответа вполне можно расставить в программе так, чтобы не мешать измерительным или иным операциям, критичным ко времени. А если все-таки ваш контроллер оказывается забит длительными вычислительными процедурами, то это значит, что неверно выбрана платформа: переходите на 32 разряда или вообще на Raspberry Pi.

Разберемся в этом плане с нашим примером подробнее. Сам подсчет тиков в Timer1 происходит аппаратно и ни от чего не зависит. Прерывания переполнения для наших условий (1/8 тактовой частоты 16 МГц) происходят каждые 32 миллисекунды. Само прерывание состоит из единственной операции инкрементирования переменной третьего разряда размером в один байт (см. далее). Если бы я реализовывал это на ассемблере, то хранил бы третий разряд в рабочем регистре и операция заняла бы ровно один такт. В случае Arduino (и вообще реализаций на С) переменная хранится в ОЗУ, потому еще теряется несколько тактов на извлечение/сохранение в памяти. Плюс вызов прерывания (7 тактов) и возврат (4 такта), то есть вся длительность процедуры составляет порядка микросекунды или чуть более. От длительности промежутка между переполнениями (32 мс) это составляет примерно 0,003%. И какова вероятность того, что некое случайное событие (например, нажатие внешней кнопки) произойдет именно в этот момент? Даже если вы будете все время нажимать на кнопку так быстро, как только сможете, вам едва ли удастся добиться совпадения.

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

Вот что способно реально помешать нашим прерываниям — это периодическое обновление функции millis () через прерывание переполнения Timer0, которое возникает каждую миллисекунду и длится несколько микросекунд (см. эту функцию в файле wiring.c). Относительно системного времени наши прерывания возникают также в случайный момент, но вероятность наткнуться на прерывание Timer0 составляет уже порядка процента, что немало. Если хотите пойти на поводу вашего перфекционизма, то на время измерений следует, строго говоря, отключать Timer0. Но если учесть, что максимальная ошибка составляет единицы микросекунд, а мы измеряем периоды длительностью от тысяч до сотен тысяч микросекунд, то на эту ошибку можно не обращать внимания.

Скажу сразу: все будет несколько иначе выглядеть тогда, когда все эти события не являются случайными относительно друг друга. Такое будет, если запускающий перепад синхронизирован с тактовым генератором самого контроллера. Именно с целью проверки, что происходит в этой ситуации, в скетче имеется закомментированная функция tone (). О результатах такой проверки — в конце статьи.


Скетч для проверки этой идеи выглядит следующим образом:

#define Tone_PIN 12 // выход частоты– см. в тексте
#define IN_PIN 8 //вход обнаружения частоты /ICP1 - для памяти

volatile byte time2 = 0; //старший разряд времени, два младших - регистры таймера
volatile unsigned long ttime = 0;        //Время срабатывания датчика
volatile unsigned long time_old = 0;        //Предыдущее время
volatile uint8_t flag=0;

void setup() {
  pinMode(IR_PIN, OUTPUT); //на выход
  pinMode(IN_PIN, INPUT); //вывод обнаружения частоты на вход
// установка регистров таймера1
  TCCR1A = 0;
// Устанавливаем захват по фронту и делитель 1/8 к тактовой частоте 16МГц
  TCCR1B =1<


При выводе результатов мы учли, что один тик таймера равен 0,5 микросекунды. Кроме того, здесь введен флаг, который на время вывода препятствует изменению подсчитанной величины периода. На сам процесс измерений он не повлияет. Большая задержка в конце setup обусловлена необходимостью выждать некоторое время, иначе первые измерения будут ошибочными. Минимальная ее величина равна периоду измеряемых колебаний.

А где таймаут на срабатывание?
Кстати, действительно, а что будет, если частоты на входе нет вовсе? Эта ситуация никак не отрабатывается, потому что для безопасного выполнения программы это не требуется. Если оставить вход 8 подключенным к какому-либо потенциалу, то значение периода (переменная ttime) просто не будет меняться — в ней задержится то, что было ранее. Если это была какая-то частота, она и будет демонстрироваться. Если с момента загрузки ни одного импульса не проскочило, то будет ноль (на этот случай и ограничение вывода). А если, кстати, оставить вход 8 висящим в воздухе, то c довольно высокой точностью будет измеряться помеха 50 Гц.

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


Результаты измерений для тех же частот 2, 8, 64, а также 2048 и 16384 герца объединены в таблицу:

image

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

Такой способ можно рекомендовать при необходимости проведения наиболее точных измерений длительных периодов. Недостаток его также очевиден: измерения можно проводить только через вывод номер 8 (вывод PB0 контроллера).

Способ 3. Самый простой: по внешнему событию


Это самый простой и довольно очевидный способ. Мы запускаем внешнее прерывание (по перепаду на выводе) и одновременно фиксируем системное время все той же функцией micros (). Как только произойдет второе такое прерывание, мы вычисляем разницу и таким образом получаем период.

Источников ошибок здесь должно быть больше, потому что счетчик и внешние прерывания системно разделены и до фиксации показаний проходит определенное время. Но, во-первых, можно ожидать, что для больших периодов они не будут иметь значения, во-вторых, в реальности получилось даже лучше, чем ожидалось.

Скетч, реализующий эту идею, выглядит таким образом:

#define Tone_PIN 12 // выход частоты– см. в тексте 
#define IN_PIN 2 //вход обнаружения частоты

volatile unsigned long ttime = 0;        //Время срабатывания датчика
volatile unsigned long time_old = 0;        //Предыдущее время
volatile uint8_t flag=0;

void setup() {
  pinMode(IR_PIN, OUTPUT); //на выход
  pinMode(IN_PIN, INPUT); //вывод обнаружения частоты на вход
  attachInterrupt(0, impuls, RISING);   //Прерывание по нарастающему фронту на D2
  Serial.begin(9600);
 // tone(Tone_PIN, 8); 
 delay(1000);
}

void impuls(){
    if(flag!=1) ttime =micros()-time_old;
    time_old = micros();
}

void loop() {
      flag=1; //чтобы ttime не изменилось в процессе вывода
        Serial.println(ttime);
       if (ttime!=0) {//на случай отсутствия частоты
       float f = 1000000/float(ttime); // Вычисляем частоту сигнала в Гц
        Serial.println(f,1);}
      flag=0;
      delay(500);
}


Вход прерывания здесь будет другой — вывод номера 2 (вывод PD2). Как и ранее, флаг защищает от изменения переменой ttime в процессе вывода. Касательно отсутствия частоты на входе здесь действительны те же соображения, что и в предыдущем случае. Результаты измерения тех же частот представлены в таблице:

image
Как мы видим, здесь ошибка в измерениях находится в полном соответствии с разрешением функции micros (), равным 4 мкс — то есть фактически результат колеблется в пределах плюс-минус одного тика системного таймера, все законно. Это не лучшим образом сказывается на измерениях высоких частот, но для нашего диапазона вполне подходит. Потому этот способ можно рекомендовать для обычных применений.

Измерение контроллером частоты, генерируемой им самим


В общем-то это чисто познавательная задача, вероятно, не имеющая практических применений. Но мне стало интересно:, а что будет, если измеряемая частота исходит от самого контролера? Удобно для этого использовать функцию tone (), которая занимает Timer2, и, следовательно, не будет пересекаться ни с системным временем, ни с Timer1 в случае его использования для измерения. Именно для этого в каждом из скетчей вставлена эта функция, работающая через вывод Tone_PIN.

Для каждого из скетчей функция раскомментировалась, и сначала проверялось, не влияет ли параллельная работа функции tone () на измерения при разных сочетаниях частот, как измеряемой, так и генерируемой. Ни в одном из вариантов явного влияния не было замечено. Затем вывод Tone_PIN подключался непосредственно ко входу измерения частоты IN_PIN и запускался монитор порта для контроля результатов.

Вообще-то я ожидал увидеть в результате один из двух вариантов: а) измерения будут работать, как ни в чем ни бывало; б) или, скорее, измерения будут безбожно врать, причем с регулярной систематической ошибкой (что должно было быть обусловлено сложением колебаний двух зависимых таймеров). Действительность оказалась интереснее предположений: измерения во всех трех случаях нормально работали, но в ограниченном диапазоне задаваемых частот. Причем нижняя граница определялась точно: входной период правильно измерялся, начиная от частоты 32 герца. Верхнюю границу точно определять я не стал — слишком хлопотно, но приблизительно она располагается несколько выше 1000 Гц. Все, что ниже или выше — определяется абсолютно неправильно. Причем, что самое интересное, без особой закономерности: показания в одной серии одни и те же, но после перезагрузки с теми же заданными значениями они становятся совсем другими.

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

© Geektimes