Генерирование и чтение сигналов с Arduino


Начнём с самого простого: генерация импульса заданной длины, такое часто бывает нужно. Проще всего сделать это на delay() и delayMicroseconds():

void setup() {
  pinMode(3, OUTPUT);
  digitalWrite(3, HIGH);   // вкл
  delayMicroseconds(500);  // ждём 500 мкс
  digitalWrite(3, LOW);    // выкл
}

Нужно помнить, что digitalWrite() сам по себе выполняется в районе 3.6 мкс (58 тактов процессора). Для ускорения можно использовать например библиотеку directIO или прямую работу с регистрами портов.

Генерирование квадратного сигнала

Программное


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

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
  delay(1000);                       // wait for a second
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
  delay(1000);                       // wait for a second
}

Если заменить 1000 например на 10, то получится квадратный сигнал с частотой 50 Гц. Этот способ называется программной генерацией сигнала, то есть микроконтроллер своими силами считает время и сам вручную дёргает ногой. Это как мешает работе остального кода, так и остальной код может сбивать частоту. Такую генерацию можно сделать более мене асинхронной на миллисе:

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

uint32_t tmr;
bool state;
void loop() {
  if (millis() - tmr >= 10) {  // таймер 10 мс
    tmr += 10;
    state = !state;
    digitalWrite(LED_BUILTIN, state);
  }
}

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

Функция tone()


В ядре Arduino есть встроенная функция для полуаппаратной генерации квадратного сигнала – tone(pin, frequency, duration):

  • pin – цифровой пин, с которого будет генерироваться сигнал.
  • frequency – частота в Герцах. Диапазон 31.. 8’000’000 Гц, целые числа. С увеличением частоты растёт шаг изменения реальной частоты.
  • duration – продолжительность сигнала в миллисекундах. Опциональный параметр, если не указывать – сигнал будет генерироваться всё время.

Для ручной остановки генерации сигнала можно вызвать noTone(). Также у генерации при помощи tone() есть особенности:

  • Генерация является полуаппаратной: пин дёргается МК “вручную” по прерыванию таймера, что на высокой частоте может чуть тормозить код.
  • Генерация использует Timer 2, перенастройка или использование его для других целей (включая ШИМ на пинах D3 и D11 у Nano) отключит активную генерацию или изменит её частоту.
    • При вызове tone() таймер перенастраивается на генерацию, то есть можно использовать таймер в своих целях между вызовами tone().
  • Генерация работает только на одном пине в один момент времени, причём для включения генерации на другом пине нужно сначала отключить текущую генерацию, то есть вызвать noTone().

ШИМ сигнал


Аппаратный таймер позволяет генерировать квадратный сигнал аппаратно и полностью асинхронно работе остального кода, не тратя ни такта процессорного времени: время считается самим таймером, и сам же таймер управляет состоянием ноги МК. Для генерации ШИМ сигнала в среде Arduino есть функция analogWrite(pin, duty), подробнее мы говорили в ней в уроке про ШИМ. Чтобы сделать ШИМ квадратным, нужно запустить его с duty, равной 128. Что касается частоты полученного сигнала, то Ардуино настраивает таймеры так, что частота в зависимости от таймера может быть 490 или 980 Гц. Частоту можно изменить с довольно большим шагом, об этом мы говорили в уроке про увеличение частоты ШИМ.

Аппаратный таймер


Можно вручную настроить аппаратный таймер на генерацию квадратного сигнала. Тонкости настройки регистров таймера мы в рамках этих уроков не разбираем, но это можно сделать и при помощи библиотеки, например GyverTimers. Работу библиотеки мы разбирали в уроке о прерываниях таймера. Данная библиотека позволяет настроить генерацию квадратного сигнала с максимально возможной точностью и частотой, а также поднять на одном таймере генерацию двух или трёх (Arduino MEGA) меандров со смещением по фазе. Пример:

// Пример генерации меандра на таймере 2 , канале B (D3 на Arduino UNO) 
#include "GyverTimers.h"

void setup() {
  pinMode(3, OUTPUT);                           // настроить пин как выход
  // из-за особенности генерации меандра таймером
  // частоту нужно указывать в два раза больше нужной!
  Timer2.setFrequency(500 * 2);                 // настроить частоту таймера в Гц
  Timer2.outputEnable(CHANNEL_B, TOGGLE_PIN);   // в момент срабатывания таймера пин будет переключаться
}
void loop() {
}

Генерирование ШИМ сигнала

Аппаратное


Для генерации ШИМ сигнала с заданным заполнением есть стандартная функция analogWrite(pin, duty), подробнее обсуждали в уроке про ШИМ сигнал, а частоту можно изменить перенастройкой таймера, как в уроке об увеличении частоты ШИМ. На самом деле таймеры позволяют настроить ШИМ сигнал с более точной или более высокой частотой и другими диапазонами заполнения (до 10 бит), но в ядре Arduino это не предусмотрено. Если такое будет нужно, можно воспользоваться библиотекой GyverPWM. Пример:

pinMode(10, OUTPUT);
// запустить ШИМ на D10 с частотой 150'000 Гц, режим FAST_PWM
PWM_frequency(10, 150000, FAST_PWM);

Программное


Программная генерация ШИМ сигнала может пригодиться, если не хватает лишнего таймера или частота ШИМ низкая и не повлияет на остальной код, а он на неё. Шим сигнал на миллисе можно организовать вот таким образом:

#define PWM_prd 50   // период ШИМ (1000 / Гц)
#define PWM_pin 13   // пин ШИМ

uint32_t tmr;
bool flag;

void setup() {
  pinMode(PWM_pin, OUTPUT);
}

void loop() {
  PWMgen(20);
}

void PWMgen(byte duty) {
  if (millis() - tmr >= (flag ? (PWM_prd * duty / 255.0) : (PWM_prd - PWM_prd * duty / 255.0))) {
    tmr = millis();
    flag = !flag;
    digitalWrite(PWM_pin, flag);
  }
}

Полуаппаратный ШИМ


Можно снизить нагрузку на процессор, отдав счёт времени аппаратному таймеру. Примеры на базе GyverTimers:

/*
   Самый компактный и простой софт шим,
   работает через переполнение байта,
   нельзя регулировать разрядность
   частота ШИМ = ~ (частота вызова tick / 256);
*/

#include <GyverTimers.h>        // подключаем библиотеку аппаратных таймеров
int pwm_freq = 100;             // частота ШИМ
uint8_t pwm_duty = 0;           // переменная для хранения заполнения шим 0-255
const uint8_t pwm_pin = 13;     // пин софт шима
uint8_t counter = 0;            // аналог счетного регистра таймера

void setup() {
  Timer2.setFrequency(256L * pwm_freq);  // прерывания по таймеру
  Timer2.enableISR();                       // включаем прерывания таймера 2
  pinMode(pwm_pin, OUTPUT);                 // устанавливаем пин на выход
  
  pwm_duty = 128;  // заполнение
}

void loop() {
}

ISR(TIMER2_A) {
  softPwm_tick();                  // вызывать в закрытых циклах или прерывании таймера
}

void softPwm_tick(void) {
  if (!pwm_duty) {                  // если заполнение ШИМ = 0
    digitalWrite(pwm_pin, LOW);     // принудительно 0 на выходе
    return;                         // не продолжать
  } if (!counter) {                 // при обнулении счетчика
    digitalWrite(pwm_pin, HIGH);    // пин устанавливается в HIGH
  } else if (counter >= pwm_duty) { // иначе при совпадении счетчика со значением заполнения
    digitalWrite(pwm_pin, LOW);     // сбросить пин в LOW
  } counter++;                      // инкремент счетчика
}

// быстрый digitalWrite
void digitalWriteFast(uint8_t pin, bool x) {
  if (pin < 8) {
    bitWrite(PORTD, pin, x);
  } else if (pin < 14) {
    bitWrite(PORTB, (pin - 8), x);
  } else if (pin < 20) {
    bitWrite(PORTC, (pin - 14), x);    // Set pin to HIGH / LOW
  }
}

/*
   Более продвинутый вариант софтШИМ,
   позволяет регулировать разрядность счета
   частота ШИМ = ~ (частота вызова tick / (глубина ШИМ + 1));
*/

#include <GyverTimers.h>          // подключаем библиотеку аппаратных таймеров

uint8_t pwm_duty = 0;             // переменная для хранения заполнения шим 0-255
const uint8_t pwm_pin = 13;       // пин софт шима
const uint16_t pwm_depth = 100;   // глубина шим (кол-во градаций заполнения)

void setup() {
  Timer2.setPeriod(10);           // прерывания по таймеру с частотой 100 кгц
  Timer2.enableISR();             // включаем прерывания таймера 2
  pinMode(pwm_pin, OUTPUT);       // устанавливаем пин на выход
}

void loop() {
  pwm_duty = map(analogRead(A0), 0, 1023, 0, pwm_depth); // получить заполнение ШИМ с потенциометра
}

ISR(TIMER2_A) {
  softPwm_tick();                  // вызывать в закрытых циклах или прерывании таймера
}

void softPwm_tick(void) {
  static uint8_t counter = 0;       // аналог счетного регистра таймера
  if (!pwm_duty) {                  // если заполнение ШИМ = 0
    digitalWrite(pwm_pin, LOW);     // принудительно 0 на выходе
    return;                         // не продолжать
  } if (!counter) {                 // при обнулении счетчика
    digitalWrite(pwm_pin, HIGH);    // пин устанавливается в HIGH
  } else if (counter >= pwm_duty) { // иначе при совпадении счетчика со значением заполнения
    digitalWrite(pwm_pin, LOW);     // сбросить пин в LOW
  } if (++counter > pwm_depth) {    // инкремент счетчика и проверка на переполнение
    counter = 0;                    // обнуление счетчика в случае переполнения
  }
}

Как известно, digitalWrite() является очень тяжёлой и долгой функцией, и для генерации софт ШИМ рекомендуется заменить её чем-то более быстрым, например прямым обращением к регистру или вот такой конструкцией (для ATmega328p):

void digitalWriteFast(uint8_t pin, bool x) {
  if (pin < 8) {
    bitWrite(PORTD, pin, x);
  } else if (pin < 14) {
    bitWrite(PORTB, (pin - 8), x);
  } else if (pin < 20) {
    bitWrite(PORTC, (pin - 14), x);
  }
}

Если не хватает количества стандартных ШИМ-выходов, можно поднять полуаппаратный ШИМ на таймере на несколько пинов сразу:

/*
   Многоканальный софт шим,
   работает через переполнение байта,
   нельзя регулировать разрядность
   частота ШИМ = ~ (частота вызова tick / 256);
*/

#include <GyverTimers.h>          // подключаем библиотеку аппаратных таймеров
#define PWM_PINS 4                // количество софт ШИМ пинов
int pwm_freq = 100;               // частота ШИМ
const uint8_t pwm_pin[PWM_PINS] = {2, 3, 4, 5};  // пины
uint8_t pwm_duty[PWM_PINS];       // переменная для хранения заполнения шим 0-255
uint8_t counter;                  // счётчик

void setup() {
  Timer2.setFrequency(256L * pwm_freq); // прерывания по таймеру
  Timer2.enableISR();             // включаем прерывания таймера 2
  for (int i = 0; i < PWM_PINS; i++)
    pinMode(pwm_pin[i], OUTPUT);       // устанавливаем пин на выход

  // заполнение
  pwm_duty[0] = 10;
  pwm_duty[1] = 20;
  pwm_duty[2] = 128;
  pwm_duty[3] = 128;
}

void loop() {
}

ISR(TIMER2_A) {
  softPwm_tick();                  // вызывать в закрытых циклах или прерывании таймера
}

#define digitalWrite digitalWriteFast

void softPwm_tick(void) {
  for (int i = 0; i < PWM_PINS; i++) {
    if (!pwm_duty[i]) {                  // если заполнение ШИМ = 0
      digitalWrite(pwm_pin[i], LOW);     // принудительно 0 на выходе
      return;                            // не продолжать
    }
    if (!counter) {                      // при обнулении счетчика
      digitalWrite(pwm_pin[i], HIGH);    // пин устанавливается в HIGH
    } else if (counter >= pwm_duty[i]) { // иначе при совпадении счетчика со значением заполнения
      digitalWrite(pwm_pin[i], LOW);     // сбросить пин в LOW
    }
  }
  counter++;                             // инкремент счетчика
}

void digitalWriteFast(uint8_t pin, bool x) {
  if (pin < 8) {
    bitWrite(PORTD, pin, x);
  } else if (pin < 14) {
    bitWrite(PORTB, (pin - 8), x);
  } else if (pin < 20) {
    bitWrite(PORTC, (pin - 14), x);
  }
}

Этот алгоритм является не самым оптимальным, более интересный можно посмотреть в GyverHacks.

Библиотека Servo


Как известно, RC сервоприводы управляются при помощи ШИМ сигнала с частотой ~50 Гц и длительностью импульса от~500 до ~2500 микросекунд. В стандартной библиотеке Servo.h реализована генерация полуаппаратного ШИМ сигнала, причём количество пинов можно менять во время работы. Библиотеку можно использовать как генерацию ШИМ, если его параметры подходят для использования.

Чтение сигналов


Чтение цифрового сигнала сводится к измерению времени между его импульсами, то есть изменениями состояния HIGH-LOW: так можно измерить период и частоту квадратного сигнала, заполнение и частоту ШИМ и вообще любой другой сигнал.

Функция pulseIn()


В ядре Ардуино есть готовые функции для измерения импульсов:

  • pulseIn(pin, value, timeout) – для импульсов от 10 мкс до ~3 минут, работает на счёте тактов процессора, лучше работает при отключенных прерываниях, более точно измеряет короткие импульсы.
  • pulseInLong(pin, value, timeout) – для импульсов от 10 мкс до ~3 минут, основано на micros() (т.е. на Таймере 0), не работает при отключенных прерываниях, более точно измеряет длинные импульсы.

Обе функции возвращают длину импульса в микросекундах. Возвращают 0, если импульса не было и был достигнут тайм-аут.  Обе функции блокирующие, то есть останавливают выполнение кода, пока не поймают импульс или не завершатся по тайм-ауту. Аргументы:

  • pin – цифровой пин (GPIO), на котором ожидается импульс.
  • value – направление импульса, HIGH или LOW.
  • timeout – тайм-аут ожидания импульса в микросекундах. Необязательный параметр, по умолчанию равен 1’000’000 мкс (1 секунда).

Как это работает: пусть мы настроили импульс на HIGH, функция будет ожидать изменение значения с LOW на HIGH. Если скачок с LOW на HIGH не произошёл за время, установленное тайм-аутом, функция завершит выполнение и вернёт 0.

Для превращения длины импульса (мкс) в частоту (Гц) достаточно поделить на него секунду (точнее, 1’000’000 мкс).

Измеряем сигналы вручную


Квадратный сигнал можно “измерить” вот таким образом:

// измеряем длину импульсов на 2 пине
#define READ_PIN 2

void setup() {
  Serial.begin(9600); 
}

bool flag = false;
uint32_t tmr;
void loop() {
  bool state =  digitalRead(READ_PIN);
  if (state && !flag) {   // фронт LOW-HIGH
    flag = true;
    tmr = micros();
  }
  if (!state && flag) {   // фронт HIGH-LOW
    flag = false;
    // выводим период
    Serial.println(micros() - tmr);
  }
}

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

Считаем импульсы и иногда делаем расчёт:

// измеряем длину импульсов на 2 пине
// расчёт каждые 500мс
#define READ_PIN 2

void setup() {
  Serial.begin(9600);
}

bool flag = false;
uint32_t tmr;
int counter = 0;

void loop() {
  bool state =  digitalRead(READ_PIN);
  if (state && !flag) {   // фронт LOW-HIGH
    flag = true;
    counter++;
  }
  if (!state && flag) {   // фронт HIGH-LOW
    flag = false;
  }

  if (millis() - tmr >= 500) {
    long prd = (millis() - tmr) / counter;
    counter = 0;
    Serial.println(prd);  // в миллисекундах
    tmr = millis();
  }
}

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

// измеряем длину импульсов на 2 пине
// расчёт каждые 500мс

void setup() {
  Serial.begin(9600);
  attachInterrupt(0, isr, RISING);  // прерывание на D2
}

uint32_t tmr;
volatile int counter = 0;

void isr() {
  counter++;  // импульсы считаем тут
}

void loop() {
  if (millis() - tmr >= 500) {
    long prd = (millis() - tmr) / counter;
    counter = 0;
    Serial.println(prd);  // в миллисекундах
    tmr = millis();
  }
}

Также импульсы можно считать  полностью аппаратно, при помощи тактового входа таймера. Такая возможность есть только у Таймера 1 (на Нано/Уно), такой таймер там один и его конечно жалко использовать для такой ерунды. Тактовый вход таймера на платах Уно/Нано находится на D5, то есть частоту подаём именно сюда:

// измеряем длину импульсов на D5 пине
// расчёт каждые 500мс

void setup() {
  Serial.begin(9600);
  // настройка таймера 1 на внешнее тактирование
  TCCR1A = 0;
  TCCR1B = 0b00000110;
}

uint32_t tmr;
void loop() {
  if (millis() - tmr >= 500) {
    long prd = (millis() - tmr) / TCNT1;
    prd = 1000 / prd;
    TCNT1 = 0;      // TCNT1 - счётчик таймера 1
    Serial.println(prd);  // в миллисекундах
    tmr = millis();
  }
}

Также рассмотрим измерение параметров ШИМ сигнала, например на прерываниях и micros():

// измеряем скважность ШИМ на 2 пине
// расчёт каждые 500мс

void setup() {
  Serial.begin(9600);
  attachInterrupt(0, isr, RISING); // прерывание на D2
}

volatile int pulse, period;
volatile bool state = false;
volatile uint32_t tmr;
uint32_t showTmr;

void isr() {
  if (!state) {
    state = true;
    attachInterrupt(0, isr, FALLING);
    period = micros() - tmr;  // период ШИМ
    tmr = micros();
  } else {
    state = false;
    attachInterrupt(0, isr, RISING);
    pulse = micros() - tmr; // импульс HIGH
  }
}

void loop() {
  if (millis() - showTmr >= 500) {
    // выводим скважность в процентах
    Serial.println(100.0 * pulse / period);
    showTmr = millis();
  }
}

 

Библиотека тахометра


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

// класс тахометра v1.1
// встроенный медианный фильтр
// вызывай tick() в прерывании по фронту
// забирай getRPM() частоту в оборотах в минуту
// забирай getHz() частоту в Герцах
// забирай getPeriod() период в мкс

#define _TACHO_TICKS_AMOUNT 10	  // количество тиков для счёта времени
#define _TACHO_TIMEOUT 1000000	  // таймаут прерываний (мкс), после которого считаем что вращение прекратилось

class Tacho {
  public:
    void tick() {   // tachoTime - время в мкс каждых _TACHO_TICKS_AMOUNT тиков
      if (!ticks--) {
        ticks = _TACHO_TICKS_AMOUNT - 1;
        tachoTime = micros() - tachoTimer;
        tachoTimer += tachoTime;  //== tachoTimer = micros();
        ready = true;
      }
    }

    uint16_t getRPM() {
      if (ready) {	// если готовы новые данные
        ready = false;
        if (tachoTime != 0) rpm = (uint32_t)_TACHO_TICKS_AMOUNT * 60000000 / median3(tachoTime);
      }
      if (micros() - tachoTimer > _TACHO_TIMEOUT) rpm = 0;
      return rpm;
    }

    float getHz() {
      if (ready) {  // если готовы новые данные
        ready = false;
        if (tachoTime != 0) hz = (float)_TACHO_TICKS_AMOUNT * 1000000 / median3(tachoTime);
      }
      if (micros() - tachoTimer > _TACHO_TIMEOUT) hz = 0;
      return hz;
    }

    uint32_t getPeriod() {
      return median3(tachoTime);
    }

  private:
    // быстрая медиана
    long median3(long value) {
      buf[counter] = value;
      if (++counter > 2) counter = 0;
      if ((buf[0] <= buf[1]) && (buf[0] <= buf[2])) return (buf[1] <= buf[2]) ? buf[1] : buf[2];
      else {
        if ((buf[1] <= buf[0]) && (buf[1] <= buf[2])) return (buf[0] <= buf[2]) ? buf[0] : buf[2];
        else return (buf[0] <= buf[1]) ? buf[0] : buf[1];
      }
    }

    volatile uint32_t tachoTime = 100000;   // для плавного старта значений
    volatile uint32_t tachoTimer = micros();
    volatile int ticks = 0;
    volatile bool ready = false;
    uint32_t buf[3] = {100000, 100000, 100000}; // для плавного старта значений
    byte counter = 0;
    int rpm = 0;
    float hz = 0.0;
};
 

Пользоваться этим тахометром очень просто: вызываем метод тик при срабатывании датчика оборотов, например во внешнем аппаратном или PCINT прерывании, а затем забираем результат в любое время при помощи методов getRPM(), getHz() и getPeriod(). Внутри библиотеки можно настроить параметр _TACHO_TICKS_AMOUNT, отвечающий за количество тиков, время которых будет измерено для расчёта.

#define TACH_PIN 2  // пин тахометра (желательна внешняя подтяжка 10к к VCC)

#include "Tacho.h"
Tacho tacho;

void setup() {
  Serial.begin(9600);
  pinMode(TACH_PIN, INPUT_PULLUP);  // тахо пин вентилятора тянем к VCC
  attachInterrupt(0, isr, FALLING); // настраиваем прерывание
}

// обработчик прерывания
void isr() {
  tacho.tick();   // дёргаем тик
}

void loop() {
  // выводим два раза в секунду
  Serial.println(tacho.getRPM());   // вывод оборотов в минуту
  //Serial.println(tacho.getHz());  // вывод Герц
  //Serial.println(tacho.getPeriod());  // вывод периода
  delay(500);
}

Важные страницы