Arduino Serial: ASCII-данные и использование маркеров для разделения

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

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

Несколько символов

Распространённая ошибка многих новичков — проверять данные до того, как они полностью получены. При приёме более одного символа по последовательному порту легко предположить, что все данные приходят одновременно. Это не так. Когда устройство отправляет «HELLO», символы отправляются по одному и принимаются по одному. Принимающее устройство затем должно собрать все символы вместе, чтобы сформировать слово «HELLO».

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

Ещё одна проблема, которую я наблюдал — мнение, что Serial.read() считывает все доступные данные. Нет. Она считывает только один символ или байт, и именно вам нужно прочитать все данные и собрать их вместе.

Пример 1: использование Serial.readBytesUntil()

Давайте начнём с простого примера приёма строк из монитора порта. Пользователь вводит своё имя и нажимает «Отправить». Arduino не знает длину имени пользователя, поэтому нам нужен способ определить, что мы получили все данные. Довольно простой метод — использовать Serial.readBytesUntil(), который позволяет использовать завершающий символ в качестве маркера.

Схема не требуется. Просто Arduino, подключённый к компьютеру.

Arduino подключённый к компьютеру

Если вы читали предыдущие руководства, то помните, что Serial.readBytesUntil() читает из буфера Serial до выполнения одного из 3 условий:

  1. Найден завершающий символ

  2. Прочитано указанное количество символов

  3. Истекло время ожидания

В мониторе порта мы можем выбрать, какие символы конца строки добавляются к вводу. Здесь я использую только Newline. Это добавляет символ новой строки (»\n») к вводу, и мы можем использовать его в функции Serial.readBytesUntil() как завершающий символ. «\n» имеет десятичное значение 10.

Настройки монитора порта Arduino IDE
// Example 1: Using Serial.readBytesUntil()
// www.martyncurrey.com

int length = 30;
char buffer [31];
char termChar = '\n';

void setup()
{
  Serial.begin(115200);
  Serial.println("Set line endings Newline");
  Serial.println("");
  Serial.println("Please enter your name and click Send");
}

void loop()
{
  if (Serial.available())
  {
     int numChars = Serial.readBytesUntil(termChar, buffer, length);
     buffer[numChars]='\0';
     Serial.print("Hello ");  Serial.println(buffer);
  }
}

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

Попробуйте. Всё должно работать хорошо, если помнить об ограничениях.

Монитор порта — ввод имени Монитор порта — вывод приветствия

Что произойдёт, если вы не введёте символ новой строки? Попробуйте. Выберите «No line Ending» в мониторе порта и введите новое имя. Всё ещё работает, но с задержкой перед ответом Arduino. Это потому, что функция Serial.readBytesUntil() ждёт, пока истечёт время ожидания. Тайм-аут по умолчанию — 1000 мс (1 секунда), поэтому задержка должна составить примерно 1 секунду.

Для этого примера секундная задержка не критична, но может стать проблемой, если данные передаются недостаточно быстро. Функция может завершиться по тайм-ауту до получения всех данных. Выберите «No line Ending» и попробуйте ввести своё имя медленно, по одной букве. Нажимайте «Отправить» после каждой буквы. Скорее всего, ваше имя появится по частям.

Пример 2: функция чтения Serial до завершающего символа

Если вы не будете очень аккуратны с реализацией кода, скорее всего тайм-аут Serial.readBytesUntil() вызовет проблемы. Можно увеличить тайм-аут — это в какой-то мере сработает, но помните, что функция блокирует выполнение во время ожидания. Это значит, что Arduino не может делать ничего другого. Лучший способ — создать собственную функцию, которая собирает последовательный ввод без тайм-аута, одновременно выполняя другие задачи.

// Example 2: Function to Read Serial Until a Terminating Character
// www.martyncurrey.com

char c = ' ';
int length = 30;
char buffer [31];
char termChar = 10;

byte index = 0;

void setup()
{
  Serial.begin(115200);
  Serial.println("Set EOL to Newline");
  Serial.println("Please enter your name and click Send");
}

void loop()
{
  if (Serial.available())
  {
     c = Serial.read();
     if (c != termChar)
     {
       buffer[index] = c;
       index = index + 1;
     }

     else
     {
       buffer[index] = '\0';
       index = 0;
       processNewData();
     }
  }
}


void processNewData()
{
  Serial.print("Hello ");  Serial.println(buffer);
}

Как видите, при каждой итерации цикла проверяется наличие данных в Serial. Если данные есть, считывается один символ/байт. Только что прочитанный символ проверяется — является ли он завершающим. Если да — значит, у нас новые данные. Если нет — символ добавляется в массив символов buffer, и процесс продолжается. Если последний прочитанный символ — завершающий, мы что-то делаем с новыми данными.

Также обратите внимание, что мы не ждём поступления данных. Мы проверяем наличие новых данных, и если они есть — обрабатываем. Если нет — продолжаем. Это означает, что мы можем делать другие вещи, а не сидеть и ждать прихода всех данных.

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

Пример 3: функция чтения Serial до завершающего символа (улучшенная)

// Example 3: Function to Read Serial Until a Terminating Character Refined
// www.martyncurrey.com

char c = ' ';
int length = 30;
char buffer [31];
char termChar = 10;

byte index = 0;
boolean haveNewData = false;

void setup()
{
  Serial.begin(115200);
  Serial.println("Set EOL to Newline");
  Serial.println("Please enter your name and click Send");
}

void loop()
{
  readSerial();
  if ( haveNewData ) {  processNewData();  }
}


void readSerial()
{
    if (Serial.available())
    {
       c = Serial.read();
       if (c != termChar)
       {
         buffer[index] = c;
         index = index + 1;
       }
       else
       {
         buffer[index] = '\0';
         index = 0;
         haveNewData = true;
       }
    }
}


void processNewData()
{
  Serial.print("Hello ");  Serial.println(buffer);
  haveNewData = false;
}

Делает то же самое, что и предыдущий пример, но код проще читать. Всё, что у нас есть в основном цикле:

readSerial();
if ( haveNewData ) {  processNewData();  }

Это значительно упрощает добавление нового кода.

Объяснение кода

  • Переменная c используется для хранения последнего прочитанного символа из буфера Serial

  • Переменная length — максимальная длина буфера

  • Переменная buffer — массив char для хранения входящих данных

  • Переменная termChar — завершающий символ

  • Переменная index — позиция индекса буфера (куда копировать следующий символ)

  • Переменная haveNewData — флаг, сообщающий остальному скетчу о наличии новых данных

if (Serial.available())
{
  c = Serial.read();
  if (c != termChar)
  {
  buffer[index] = c;
  index = index + 1;
  }

Если данные доступны в Serial, мы читаем один символ в c. Затем проверяем, не является ли c завершающим символом, и если нет — копируем c в массив buffer на позицию, указанную index. Затем index увеличивается для следующего символа.

else
{
  buffer[index] = '\0';
  index = 0;
  haveNewData = true;
}

Если c — завершающий символ, нет необходимости копировать его в буфер. Мы просто закрываем буфер (добавляем „\0“ в конец), обнуляем index для следующего раза и устанавливаем haveNewData = true, чтобы показать наличие новых данных.

В основном цикле мы проверяем, установлен ли haveNewData, и если да — вызываем функцию processData(). Функция processData() также сбрасывает haveNewData.

Попробуйте. Результат должен быть таким же, как и раньше.

Монитор порта — ввод Монитор порта — вывод

Почему это лучше, чем Serial.readBytesUntil()?

Как упоминалось выше, Serial.readBytesUntil() имеет тайм-аут. При использовании монитора порта это вряд ли будет проблемой, но при получении данных от других устройств — будет.

Второй метод не имеет ограничения по времени. Он может принимать один символ в минуту или один символ в час и всё равно работать нормально. Вы можете проверить это сами через монитор порта. Установите EOL на «No line ending», введите A и нажмите «Отправить», введите B, нажмите «Отправить», введите C и нажмите «Отправить». Теперь смените EOL обратно на «Newline», введите D и нажмите «Отправить». В мониторе порта должно появиться «ABCD».

О чём следует знать

  1. По-прежнему возможно получить больше данных, чем может вместить буфер.

  2. Мы не знаем, получили ли мы самое начало данных. Мы знаем только, что получили конец.

Не существует 100% удовлетворительного способа решения проблемы #1 при использовании буфера. У Arduino недостаточно памяти для обработки очень больших буферов, и мы не можем просто увеличивать буфер (немного увеличить можно, в зависимости от размера остального скетча). Хотя скетч может не справляться с большими наборами данных, мы можем по крайней мере предотвратить повреждение памяти Arduino. К сожалению, при этом мы можем потерять данные.

Если вам нужно обрабатывать большие блоки данных, вы должны управлять данными по мере их поступления, а не сохранять в буфер.

Проблему #2 мы рассмотрим немного позже.

Пример 4: ограничение принимаемых данных размером буфера

Для ограничения размера нужно просто проверять текущую позицию index относительно максимально допустимого размера буфера. Если мы достигли конца буфера — не увеличиваем index. Это означает, что конец данных будет потерян, но по крайней мере скетч не «упадёт».

Переполнение массива char очень сложно и утомительно отлаживать. Arduino с удовольствием попытается скопировать 40 или 50 символов в массив на 20 символов, что может вызвать всевозможные проблемы. Память сразу после массива, скорее всего, используется другими переменными, поэтому при выходе за границы массива вы начинаете перезаписывать другие переменные.

Всё, что нужно добавить:

if (index < length)
{
   buffer[index] = c;
   index = index + 1;
}

Итоговый скетч:

// Example 4: Limiting Received Data to the Size of the Buffer
// www.martyncurrey.com

char c = ' ';
int length = 30;
char buffer [31];
char termChar = 10;

byte index = 0;
boolean haveNewData = false;

void setup()
{
  Serial.begin(115200);
  Serial.println("Set EOL to Newline");
  Serial.println("Please enter your name and click Send");
}

void loop()
{
  readSerial();
  if ( haveNewData ) {  processNewData();  }
}

void readSerial()
{
    if (Serial.available())
    {
       c = Serial.read();
       if (c != termChar)
       {
       if (index < length)
       {
          buffer[index] = c;
          index = index + 1;
       }
    }
    else
    {
      buffer[index] = '\0';
      index = 0;
      haveNewData = true;
    }
  }

}


void processNewData()
{
  Serial.print("Hello ");  Serial.println(buffer);
  haveNewData = false;
}

Если дать Arduino время запуститься и убедиться, что Serial стартовал, код выше довольно надёжен. Он мог бы быть чуть более защищённым, и мы по-прежнему не можем быть на 100% уверены, что получили начало данных — только конец. Эту проблему я решаю в следующей части, используя начальные и конечные маркеры вокруг данных.

Пример 5: Arduino-Arduino связь с использованием конечного маркера

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

Схема Arduino с LCD Макетная плата Arduino с LCD

(Зелёный светодиод ничего не делает. Он используется в другой схеме позже, и я забыл его убрать.)

В этом примере оба Arduino подключены к одному компьютеру. Это означает, что их GND соединены через USB-подключение, и мне не нужно было добавлять соединение на макетной плате. Когда Arduino имеют отдельные источники питания, GND нужно соединять.

Следующие скетчи используют AltSoftSerial и NewliquidCrystal.

Для получения дополнительной информации об LCD-экранах и библиотеке NewliquidCrystal смотрите Arduino with HD44780 based Character LCDs.

Для получения дополнительной информации об AltSoftSerial смотрите Arduino Serial: A Look at the Different Serial Libraries.

Master-устройство имеет кнопку и потенциометр. Скетч считывает состояние этих устройств и отправляет показания на slave-устройство через AltSoftSerial. Slave-устройство принимает данные и отображает их на LCD-экране.

// Example 5: Arduino to Arduino Serial Communication Using an End Marker. Master
// www.martyncurrey.com

#include <AltSoftSerial.h>
AltSoftSerial ALTserial;

byte switchPin = 2;              //  input pin for the switch
boolean newSwitchState1 = LOW;   // used for simple debouce
boolean newSwitchState2 = LOW;
boolean newSwitchState3 = LOW;
boolean oldSwitchState = LOW;    // variable to hold the switch state

int potPin = A0;    // input pin for the potentiometer
int val = 0;        // variable to store the value coming from the pot
int oldval = 0;     // variable to store the old pot value

unsigned long startTime = 0;
unsigned long nowTime = 0;
unsigned long waitTime = 500;

void setup()
{
  Serial.begin(9600);
  Serial.println("Press the button switch or twiddle the pot.");
  ALTserial.begin(9600);

  pinMode(switchPin, INPUT);
  startTime = millis();
}


void loop()
{
    newSwitchState1 = digitalRead(switchPin);     delay(1);
    newSwitchState2 = digitalRead(switchPin);     delay(1);
    newSwitchState3 = digitalRead(switchPin);

    // Simple debouce - if all 3 values are the same we can continue
    if (  (newSwitchState1==newSwitchState2) && (newSwitchState1==newSwitchState3) )
    {
        // only interested if the switch has changed state. HIGH to LOW or LOW to HIGH
        if ( newSwitchState1 != oldSwitchState )
        {
           oldSwitchState = newSwitchState1;

           // has the button switch been closed?
           if ( newSwitchState1 == HIGH )
           {
                 Serial.println("S=HIGH");
                 ALTserial.print("S=HIGH\n");
           }
           else
           {
                Serial.println("S=LOW");
                ALTserial.print("S=LOW\n");
           }
        }
    }


    // only want to check the pot every 500 ms
    // and only want to send if the value has changed
    nowTime = millis();
    if (nowTime - startTime > waitTime)
    {
      startTime = nowTime;
      val = analogRead(potPin);
      if ((val) != (oldval) )
      {
        oldval = val;
        Serial.print("P=");
        HS_printFixedFormat(val);
        Serial.print("\r\n");

        ALTserial.print("P=");
        ALT_printFixedFormat(val);
        ALTserial.print("\n");
      }
    }
}

void ALT_printFixedFormat(int num)
{
  if (num <1000) {  ALTserial.print("0");    }
  if (num <100)  {  ALTserial.print("0");    }
  if (num <10)   {  ALTserial.print("0");    }
  ALTserial.print(num);
}

void HS_printFixedFormat(int num)
{
  if (num <1000) {  Serial.print("0");    }
  if (num <100)  {  Serial.print("0");    }
  if (num <10)   {  Serial.print("0");    }
  Serial.print(num);
}

Master-скетч проверяет кнопку на каждой итерации цикла и при обнаружении изменения реагирует немедленно. Потенциометр считывается каждые полсекунды. Проверяется, изменилось ли значение, и только при изменении — отправляются данные на slave Arduino. Отправка данных только при изменении уменьшает объём последовательных данных.

Я использую значение фиксированной длины для потенциометра. Функция ALT_printFixedFormat() добавляет ведущие нули для дополнения числа: 1 становится «0001», а 999 — «0999».

void ALT_printFixedFormat(int num)
{
  if (num <1000) {  ALTserial.print("0");    }
  if (num <100)  {  ALTserial.print("0");    }
  if (num <10)   {  ALTserial.print("0");    }
  ALTserial.print(num);
}

Вы можете протестировать master-скетч, открыв монитор порта. Всё, что отправляется на AltSoftSerial, дублируется на аппаратный Serial. Это удобно для мониторинга и отладки, но не является обязательным.

// Example 5: Arduino to Arduino Serial Communication Using an End Marker. Slave
// www.martyncurrey.com

#include <AltSoftSerial.h>
AltSoftSerial ALTserial;

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

// Set the pins on the I2C chip used for LCD connections:
//                    addr, en,rw,rs,d4,d5,d6,d7,bl,blpol
LiquidCrystal_I2C lcd(0x27, 2, 1, 0, 4, 5, 6, 7, 3, POSITIVE);  // Set the LCD I2C address


char c;
int length = 30;
char buffer [31];
char termChar = '\n';

byte index = 0;
boolean haveNewData = false;


void setup()
{
  Serial.begin(9600);
  Serial.println("Ready");

  ALTserial.begin(9600);

  lcd.begin(20, 4);
  lcd.setCursor(0, 0);
  lcd.print("Arduino Serial");
  lcd.setCursor(0, 1);  lcd.print("Switch=");
  lcd.setCursor(0, 2);  lcd.print("Pot=");
}


void loop()
{
  readSerial();
  if ( haveNewData ) {  processNewData();  }
}


void readSerial()
{
    if (ALTserial.available())
    {
       c = ALTserial.read();
       if (c != termChar)
       {
       if (index < length)
       {
          buffer[index] = c;
          index = index + 1;
       }
    }
    else
    {
      buffer[index] = '\0';
      index = 0;
      haveNewData = true;
    }
  }
}

void processNewData()
{
  Serial.println(buffer);
  if (buffer[0] == 'S')
  {
    lcd.setCursor(7, 1);
    if (buffer[2] == 'H') {   lcd.print("HIGH");    }
    if (buffer[2] == 'L') {   lcd.print("LOW ");    }
  }

  if (buffer[0] == 'P')
  {
    lcd.setCursor(4, 2);
    char temp[5];
    temp[0] = buffer[2];
    temp[1] = buffer[3];
    temp[2] = buffer[4];
    temp[3] = buffer[5];
    temp[4] = '\0' ;
    lcd.print( temp);
  }

  haveNewData = false;
  buffer[0] = '\0';
}

Slave-скетч очень похож на пример 4. Разница — в функции processNewData().

При обработке данных кнопки видно, что я не проверяю всю команду, только первую букву (H и L). Это означает, что мне не нужна вся команда, и можно использовать «S=H» и «S=L». Символ «=» тоже не используется, поэтому его можно убрать, а команды сократить до «SH» и «SL». Здесь я не беспокоюсь о скорости, и «S=HIGH» и «S=LOW» проще читать при обзоре кода. Но если приоритет — производительность, объём передаваемых данных следует минимизировать.

При использовании AltSoftSerial вы сможете увеличить скорость до 38400 бит/с без проблем. Выше могут начаться проблемы. Стоит поэкспериментировать с более высокими скоростями, чтобы понять пределы.

Дополнительные символы конца строки

Стоит упомянуть проблему дополнительных символов EOL. Стандартный Arduino EOL — это 2 символа «\n» и «\r», которые добавляются в конец строки в таком порядке. Это значит, что если вы используете вышеописанный метод и данные имеют стандартные символы EOL, функция оставит символ «\r» в буфере Serial, который затем добавится к началу следующих данных. Это вызовет проблемы с форматированием и преобразованием ASCII в числовые значения и, скорее всего, будет очень сложно отладить.

Пример 6: Arduino-Arduino связь с начальными и конечными маркерами

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

Использование начальных и конечных маркеров — не оригинальная идея. Она основана на (или просто скопирована из) поста Robin2 на форуме Arduino. Я начал использовать эту функцию некоторое время назад, она мне понравилась, и с тех пор я её использую.

Функция Robin2

void recvWithStartEndMarkers()
{
     static boolean recvInProgress = false;
     static byte ndx = 0;
     char startMarker = '[';
     char endMarker = ']';
     char rc;

     if (Serial.available() > 0)
     {
          rc = Serial.read();
          if (recvInProgress == true)
          {
               if (rc != endMarker)
               {
                    receivedChars[ndx] = rc;
                    ndx++;
                    if (ndx > maxDataLength) { ndx = maxDataLength; }
               }
               else
               {
                     receivedChars[ndx] = '\0'; // terminate the string
                     recvInProgress = false;
                     ndx = 0;
                     newData = true;
               }
          }
          else if (rc == startMarker) { recvInProgress = true; }
     }
}

Я использую квадратные скобки ([ и ]) в качестве начального и конечного маркеров. Их можно заменить, если хотите. Важно выбрать символы, которые не будут использоваться в данных.

Функция читает буфер последовательного ввода, пока не найдёт начальный маркер, затем начинает копировать данные в буфер receivedChars. Когда находит конечный маркер — прекращает копирование и устанавливает newData в true. Всё, что находится вне маркеров, игнорируется.

Попробуйте. Загрузите следующий скетч, откройте монитор порта и введите что-нибудь. Если вы используете начальный и конечный маркеры, всё, что заключено в маркеры, отобразится в мониторе порта. Всё за пределами маркеров будет проигнорировано.

// Example 6a: Arduino to Arduino Serial Communication Using Start and End Markers
// www.martyncurrey.com

const byte maxDataLength = 30;  // maxDataLength is the maximum length allowed for received data.
char receivedChars[31] ;
boolean newData = false;        // newData is used to determine if there is a new command

void setup()
{
   Serial.begin(115200);
   Serial.println("Serial using start and end markers");
   newData = false;
}

void loop()
{
   recvWithStartEndMarkers();                // check to see if we have received any new commands
   if (newData)  {   processCommand();  }    // if we have a new command do something
}

void processCommand()
{
   Serial.print("Recieved data = ");   Serial.println(receivedChars);
   newData = false;
}


// function recvWithStartEndMarkers by Robin2 of the Arduino forums
// See  http://forum.arduino.cc/index.php?topic=288234.0
void recvWithStartEndMarkers()
{
     static boolean recvInProgress = false;
     static byte ndx = 0;
     char startMarker = '[';
     char endMarker = ']';

     if (Serial.available() > 0)
     {
          char rc = Serial.read();
          if (recvInProgress == true)
          {
               if (rc != endMarker)
               {
                    if (ndx < maxDataLength) { receivedChars[ndx] = rc; ndx++;  }
               }
               else
               {
                     receivedChars[ndx] = '\0'; // terminate the string
                     recvInProgress = false;
                     ndx = 0;
                     newData = true;
               }
          }
          else if (rc == startMarker) { recvInProgress = true; }
     }

}

В мониторе порта введите «[hello]»:

Монитор порта — ввод [hello] Монитор порта — вывод hello

Теперь попробуйте ввести «This will be ignored[This will be processed]»:

Монитор порта — ввод с маркерами Монитор порта — обработка маркеров

Данные за пределами начального и конечного маркеров игнорируются.

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

Давайте адаптируем пример с EOL для использования начальных и конечных маркеров.

Пример 7: Arduino-Arduino связь с начальными и конечными маркерами

// Example 7: Arduino to Arduino Serial Communication Using Start and End Markers. Master
// www.martyncurrey.com

#include <AltSoftSerial.h>
AltSoftSerial ALTserial;

byte switchPin = 2;              //  input pin for the switch
boolean newSwitchState1 = LOW;   // used for simple debouce
boolean newSwitchState2 = LOW;
boolean newSwitchState3 = LOW;
boolean oldSwitchState = LOW;    // variable to hold the switch state

int potPin = A0;    // input pin for the potentiometer
int val = 0;        // variable to store the value coming from the pot
int oldval = 0;     // variable to store the old pot value

unsigned long startTime = 0;
unsigned long nowTime = 0;
unsigned long waitTime = 500;


void setup()
{
  Serial.begin(9600);
  Serial.println("Press the button switch or twiddle the pot.");

  ALTserial.begin(9600);

  pinMode(switchPin, INPUT);
  startTime = millis();
}

void loop()
{
    newSwitchState1 = digitalRead(switchPin);     delay(1);
    newSwitchState2 = digitalRead(switchPin);     delay(1);
    newSwitchState3 = digitalRead(switchPin);

    // Simple debouce - if all 3 values are the same we can continue
    if (  (newSwitchState1==newSwitchState2) && (newSwitchState1==newSwitchState3) )
    {
        // only interested if the switch has changed state. HIGH to LOW or LOW to HIGH
        if ( newSwitchState1 != oldSwitchState )
        {
           oldSwitchState = newSwitchState1;

           // has the button switch been closed?
           if ( newSwitchState1 == HIGH )
           {
                 Serial.println("[S=HIGH]");
                 ALTserial.print("[S=HIGH]");
           }
           else
           {
                Serial.println("[S=LOW]");
                ALTserial.print("[S=LOW]");
           }
        }
    }

    // only want to check the pot every 500 ms (change this if you like)
    // and only want to send if the value has changed

    nowTime = millis();
    if (nowTime - startTime > waitTime)
    {
      startTime = nowTime;
      val = analogRead(potPin);
      if ((val) != (oldval) )
      {
        oldval = val;
        Serial.print("[P=");
        HS_printFixedFormat(val);
        Serial.println("]");

        ALTserial.print("[P=");
        ALT_printFixedFormat(val);
        ALTserial.print("]");
      }
    }
}


void ALT_printFixedFormat(int num)
{
  if (num <1000) {  ALTserial.print("0");    }
  if (num <100)  {  ALTserial.print("0");    }
  if (num <10)   {  ALTserial.print("0");    }
  ALTserial.print(num);

}

void HS_printFixedFormat(int num)
{
  if (num <1000) {  Serial.print("0");    }
  if (num <100)  {  Serial.print("0");    }
  if (num <10)   {  Serial.print("0");    }
  Serial.print(num);
}
// Example 7: Arduino to Arduino Serial Communication Using Start and End Markers. Slave
// www.martyncurrey.com

#include <AltSoftSerial.h>
AltSoftSerial ALTserial;

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

// Set the pins on the I2C chip used for LCD connections:
//                    addr, en,rw,rs,d4,d5,d6,d7,bl,blpol
LiquidCrystal_I2C lcd(0x27, 2, 1, 0, 4, 5, 6, 7, 3, POSITIVE);  // Set the LCD I2C address

char c;
int length = 30;
char buffer [31];

boolean haveNewData = false;

void setup()
{
  Serial.begin(9600);
  Serial.println("Ready");

  ALTserial.begin(9600);

  lcd.begin(20, 4);
  lcd.setCursor(0, 0);
  lcd.print("Arduino Serial");
  lcd.setCursor(0, 1);  lcd.print("Switch=");
  lcd.setCursor(0, 2);  lcd.print("Pot=");
}


void loop()
{
  recvWithStartEndMarkers();
  if ( haveNewData ) {  processNewData();  }
}


void recvWithStartEndMarkers()
{
     static boolean recvInProgress = false;
     static byte ndx = 0;
     char startMarker = '[';
     char endMarker = ']';
     char rc;

     if (ALTserial.available() > 0)
     {
          rc = ALTserial.read();

          if (recvInProgress == true)
          {
               if (rc != endMarker)
               {
                    buffer[ndx] = rc;
                    ndx++;
                    if (ndx > length) { ndx = length; }
               }
               else
               {
                     buffer[ndx] = '\0'; // terminate the string
                     recvInProgress = false;
                     ndx = 0;
                     haveNewData = true;
               }
          }
          else if (rc == startMarker) { recvInProgress = true; }
     }
}



void processNewData()
{
  Serial.println(buffer);
  if (buffer[0] == 'S')
  {
    lcd.setCursor(7, 1);
    if (buffer[2] == 'H') {   lcd.print("HIGH");    }
    if (buffer[2] == 'L') {   lcd.print("LOW ");    }
  }

  if (buffer[0] == 'P')
  {
    lcd.setCursor(4, 2);
    char temp[5];
    temp[0] = buffer[2];
    temp[1] = buffer[3];
    temp[2] = buffer[4];
    temp[3] = buffer[5];
    temp[4] = '\0' ;
    lcd.print( temp);
  }

  haveNewData = false;
  buffer[0] = '\0';
}

Скетчи делают то же самое, что и в предыдущем примере. Единственное отличие — теперь используются начальные и конечные маркеры.

В master-скетче к данным добавлены начальные и конечные маркеры:

ALTserial.print("[S=HIGH]");
...
ALTserial.print("[S=LOW]");

А в slave-скетче функция recvWithStartEndMarkers() заменяет readSerial(). Всё остальное — то же самое.

Пример 8: продвинутый пример — неблокирующая последовательная связь

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

Чтобы продемонстрировать пользу неблокирующего подхода, в следующем примере добавлен мигающий светодиод. Когда значение потенциометра превышает определённый уровень, светодиод начинает мигать. Master-скетч остаётся таким же, как выше. Slave-скетч обновлён с добавлением нового кода (который очень похож на код проверки потенциометра с таймером в master-скетче).

Схема с мигающим светодиодом

Я добавил светодиод на пин D2 master-устройства.

Макетная плата с мигающим светодиодом

Светодиод действительно используется :-)

Когда значение потенциометра ниже порога — светодиод выключен. Когда значение превышает порог — светодиод начинает мигать.

Макетная плата — светодиод включён

Скетч master-устройства точно такой же, как в примере 7.

// Example 8: Advanced example. Non blocking Serial Communication With Blinking LED. Master
// www.martyncurrey.com

#include <AltSoftSerial.h>
AltSoftSerial ALTserial;

byte switchPin = 2;              //  input pin for the switch
boolean newSwitchState1 = LOW;   // used for simple debouce
boolean newSwitchState2 = LOW;
boolean newSwitchState3 = LOW;
boolean oldSwitchState = LOW;    // variable to hold the switch state

int potPin = A0;    // input pin for the potentiometer
int val = 0;        // variable to store the value coming from the pot
int oldval = 0;     // variable to store the old pot value

unsigned long startTime = 0;
unsigned long nowTime = 0;
unsigned long waitTime = 500;


void setup()
{
  Serial.begin(9600);
  Serial.println("Press the button switch or twiddle the pot.");

  ALTserial.begin(9600);

  pinMode(switchPin, INPUT);
  startTime = millis();
}

void loop()
{
    newSwitchState1 = digitalRead(switchPin);     delay(1);
    newSwitchState2 = digitalRead(switchPin);     delay(1);
    newSwitchState3 = digitalRead(switchPin);

    // Simple debouce - if all 3 values are the same we can continue
    if (  (newSwitchState1==newSwitchState2) && (newSwitchState1==newSwitchState3) )
    {
        // only interested if the switch has changed state. HIGH to LOW or LOW to HIGH
        if ( newSwitchState1 != oldSwitchState )
        {
           oldSwitchState = newSwitchState1;

           // has the button switch been closed?
           if ( newSwitchState1 == HIGH )
           {
                 Serial.println("[S=HIGH]");
                 ALTserial.print("[S=HIGH]");
           }
           else
           {
                Serial.println("[S=LOW]");
                ALTserial.print("[S=LOW]");
           }
        }
    }

    // only want to check the pot every 500 ms (change this if you like)
    // and only want to send if the value has changed

    nowTime = millis();
    if (nowTime - startTime > waitTime)
    {
      startTime = nowTime;
      val = analogRead(potPin);
      if ((val) != (oldval) )
      {
        oldval = val;
        Serial.print("[P=");
        HS_printFixedFormat(val);
        Serial.println("]");

        ALTserial.print("[P=");
        ALT_printFixedFormat(val);
        ALTserial.print("]");
      }
    }
}


void ALT_printFixedFormat(int num)
{
  if (num <1000) {  ALTserial.print("0");    }
  if (num <100)  {  ALTserial.print("0");    }
  if (num <10)   {  ALTserial.print("0");    }
  ALTserial.print(num);

}

void HS_printFixedFormat(int num)
{
  if (num <1000) {  Serial.print("0");    }
  if (num <100)  {  Serial.print("0");    }
  if (num <10)   {  Serial.print("0");    }
  Serial.print(num);
}

Скетч slave-устройства обновлён и включает код для мигания светодиода:

// Example 8: Advanced example. Non blocking Serial Communication With Blinking LED. Slave
// www.martyncurrey.com

#include <AltSoftSerial.h>
AltSoftSerial ALTserial;

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

// Set the pins on the I2C chip used for LCD connections:
//                    addr, en,rw,rs,d4,d5,d6,d7,bl,blpol
LiquidCrystal_I2C lcd(0x27, 2, 1, 0, 4, 5, 6, 7, 3, POSITIVE);  // Set the LCD I2C address

char c;
int length = 30;
char buffer [31];
boolean haveNewData = false;

int potVal = 0;
int warningThreshold = 800;
unsigned long startTime = 0;
unsigned long nowTime = 0;
unsigned long flashRate = 250;

byte LEDpin = 2;
boolean LEDflash = false;
boolean LEDstate = false;


void setup()
{
  Serial.begin(9600);
  Serial.println("Ready");

  ALTserial.begin(9600);

  lcd.begin(20, 4);
  lcd.setCursor(0, 0);
  lcd.print("Arduino Serial");
  lcd.setCursor(0, 1);  lcd.print("Switch=LOW");
  lcd.setCursor(0, 2);  lcd.print("Pot=0000");
  lcd.setCursor(0, 3);  lcd.print("Threshold="); lcd.print(warningThreshold);

  pinMode(LEDpin, OUTPUT);
  boolean LEDstate = LOW;
}


void loop()
{
  recvWithStartEndMarkers();
  if ( haveNewData == true )  { processNewData();  }
  if ( LEDflash == true)      { flashTheLED(); }
}



void flashTheLED()
{
    nowTime = millis();
    if (nowTime - startTime > flashRate)
    {
      startTime = nowTime;

      if (LEDstate == LOW)
      {
         LEDstate = HIGH;
         digitalWrite(LEDpin, HIGH);
      }
      else
      {
         LEDstate = LOW;
         digitalWrite(LEDpin, LOW);
      }
    }
}



void recvWithStartEndMarkers()
{
     static boolean recvInProgress = false;
     static byte ndx = 0;
     char startMarker = '[';
     char endMarker = ']';
     char rc;

     if (ALTserial.available() > 0)
     {
          rc = ALTserial.read();

          if (recvInProgress == true)
          {
               if (rc != endMarker)
               {
                    buffer[ndx] = rc;
                    ndx++;
                    if (ndx > length) { ndx = length; }
               }
               else
               {
                     buffer[ndx] = '\0'; // terminate the string
                     recvInProgress = false;
                     ndx = 0;
                     haveNewData = true;
               }
          }
          else if (rc == startMarker) { recvInProgress = true; }
     }
}



void processNewData()
{
  Serial.println(buffer);
  if (buffer[0] == 'S')
  {
    lcd.setCursor(7, 1);
    if (buffer[2] == 'H') {   lcd.print("HIGH");    }
    if (buffer[2] == 'L') {   lcd.print("LOW ");    }
  }

  if (buffer[0] == 'P')
  {
      lcd.setCursor(4, 2);
      char temp[5];
      temp[0] = buffer[2];
      temp[1] = buffer[3];
      temp[2] = buffer[4];
      temp[3] = buffer[5];
      temp[4] = '\0' ;
      lcd.print( temp);

      potVal = atoi(temp);
      if (potVal >= warningThreshold)
      {
       LEDflash = true;
      }
      else
      {
        LEDflash = false;
        // just in case the LED is on when the pot value goes below the threshold
        digitalWrite(LEDpin,LOW);
      }
  }

  haveNewData = false;
  buffer[0] = '\0';
}

Несколько замечаний по новому скетчу.

Я использую фактическое числовое значение потенциометра. Для этого нужно преобразовать ASCII-значение в фактическое числовое значение. Я делаю это с помощью atoi() (ASCII to Integer).

LEDflash используется для индикации, должен ли светодиод мигать или нет.

potVal = atoi(temp);
if (potVal >= warningThreshold)
{
   LEDflash = true;
}
else
{
   LEDflash = false;
   // just in case the LED is on when the pot value goes below the threshold
   digitalWrite(LEDpin,LOW);
}

Возможна ситуация, когда светодиод включён в момент, когда значение потенциометра опускается ниже порога и мигание прекращается. Поэтому, чтобы убедиться, что светодиод выключен, я устанавливаю LEDpin в LOW. Можно проверить LEDstatus и устанавливать пин в LOW только если светодиод включён, но в этом нет реальной необходимости.

Благодаря использованию функций, основной loop() довольно прост — всего три строки кода:

  • Проверить последовательные данные.

  • Проверить наличие новых данных.

  • Возможно, помигать светодиодом.

void loop()
{
  recvWithStartEndMarkers();
  if ( haveNewData == true )  { processNewData();  }
  if ( LEDflash == true)      { flashTheLED(); }
}

Функция flashTheLED() проверяет, сколько времени прошло, и если время превышает частоту мигания — переключает светодиод:

void flashTheLED()
{
    nowTime = millis();
    if (nowTime - startTime > flashRate)
    {
      startTime = nowTime;

      if (LEDstate == LOW)
      {
         LEDstate = HIGH;
         digitalWrite(LEDpin, HIGH);
      }
      else
      {
         LEDstate = LOW;
         digitalWrite(LEDpin, LOW);
      }
    }
}

Что попробовать

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

Вот и всё. Надеюсь, эти руководства стали достойным введением в последовательные данные и способы их реализации в ваших проектах.