Під час розробки програмного забезпечення для мікроконтролерів необхідно тестувати та налагоджувати роботу пристрою, отримувати різну інформацію про стан системи, відправляти команди і конфігурацію на пристрій. Для цього добре підходить UART-консоль.
Зазвичай на платах розробки вбудований UART-USB адаптер, і ми просто підключаємо плату до комп'ютера і одразу можемо завантажувати програму на мікроконтролер. Також, ми маємо доступ до UART з коду, цим і скористаємось.
Відкриваємо IDE (в моєму випадку це PlatformIO) і створимо проєкт MCUConfigurator. Вказуємо плату і фреймворк. Я взяв плату Arduino Nano, але для цього проєкту підійде майже будь-яка, бо ми не будемо робити нічого специфічного під конкретну плату. Фреймворк обираю Arduino - він простий і дружній для новачків і не потребує знань конкретного заліза. Детальніше про створення проєкту в PlatformIO можна подивитись тут. Одразу після створення файлів проєкту знайдіть файл platformio.ini і додайте рядок monitor_speed = 115200, якщо такого немає. Переходимо у файл main.cpp і видаляємо весь автоматично згенерований код, залишаємо лише пусті функції setup() і loop(). Звісно, підключення файлу Arduino.h також залишаємо. Для початку роботи з Serial Monitor (консоль UART) необхідно його запустити, цьому служить команда Serial.begin(int baud_rate). Параметр baud_rate обов'язково повинен мати значення, яке ми вказали перед цим в monitor_speed, як стандарт - 115200. Крім запуску монітора ми налаштуємо пін вбудованого в плату світлодіода для керування ним. Тепер виведемо початковий текст в консоль. Всі ці команди ми виконуємо в функції setup(), так як необхідно їх виконати лише один раз при включенні плати. Код на цьому етапі має бути таким:
#include <Arduino.h>void setup() {Serial.begin(115200); //запускаємо монітор, baud rate = 115200pinMode(LED_BUILTIN, OUTPUT); //пін вбудованого діода як вихідdigitalWrite(LED_BUILTIN, LOW); //встановлюємо початковий стан світлодіода - вимкнено//виводимо текст привітанняSerial.println("Welcome! Enter command.");Serial.println("Type 'help' for commands.");}void loop() {}
Перевіряємо код - для цього запускаємо білд (Build), і одразу завантажуємо код на плату кнопкою Upload:

Після того, як програма завантажиться на плату запускаємо Serial Monitor, натиснувши на кнопку з зображенням штепселя. При натисканні цієї кнопки плата автоматично перезапускається, відкривається консоль і якщо ніяких помилок не було, ми повинні побачити результат у вигляді тексту привітання, який ми вивели у функції setup командами Serial.println("Welcome! Enter command.") і Serial.println("Type 'help' for commands.").

Писати будемо програму-конфігуратор, з допомогою якої можна буде вводити і зберігати налаштування роботи плати. Візьмемо для цього самий простий варіант - миготіння вбудованого в плату діода. Зробимо можливим конфігурування часу - скільки діод світиться, скільки не світиться. Почнемо з додавання можливості вводити команди.
Обробка введення тексту в Serial Monitor
Важливо сказати, що фреймворк Arduino не має реалізації для зчитування тексту з Serial Monitor по строкам, тільки по одному знаку. Тому напишемо функцію вводу, яку будемо викликати в основному циклі програми (функція loop) і яка буде перевіряти чи не ввів користувач якісь дані і якщо так, то записуватиме введений знак в буфер вводу. Коли введено знак переводу строки ('\n'), функція записує в буфер '\0' - ознаку завершення строки. Також при цьому повертається true, що вказує програмі що введення завершено і можна обробляти команду. Тож, додамо таку функцію в файл main.cpp
int8_t cursor = 0; //курсор вказує на місце в буфері (індекс масиву), куди вводити наступний символ//допоміжна функція вводуbool input(char *boof, int8_t boof_size){if(Serial.available() > 0) //обов'язково перевіряємо чи є введені дані{char inpt = Serial.read(); //зчитуємо введений символSerial.print(inpt); //Arduino не виводить введений текст автоматично, тож виводимо саміif(inpt == '\n' || inpt == '\r') //перевірка: якщо користувач натиснув Enter{boof[cursor] = '\0'; //записуємо в буфер символ завершення текстуcursor = 0; //переводимо курсор в початок буферуreturn true; //повертаємо true - ознака що команду введено і її можна обробляти}else //якщо ж користувач ввів не Enter, а інший символ{if(cursor < boof_size - 1) //перевірка чи не переповнений буфер{input_boof[cursor] = inpt; //додаємо в буфер введений символ}cursor++; //курсор на наступний індекс буферу}}return false; //false - ознака що введення команди не завершено}
Функція Serial.read() очікує вводу символу користувачем, при цьому блокує потік виконання програми. Це означає, що програма призупиняє виконання до тих пір поки не з'являться дані. Для того щоб програма не блокувалась виконується перевірка if(Serial.available() > 0). Функція Serial.available() – це функція Arduino, яка повертає кількість байтів (символів), доступних для зчитування з буфера послідовного порту. Якщо даних немає, то Serial.read() не викликається, а отже програма продовжує виконання.
Другий важливий момент в коді це виклик Serial.print(inpt) одразу після отримання введеного символу. Serial Monitor не виводить автоматично введені символи, тому необхідно це робити самим.
Отже, використаємо функцію input в основному циклі програми:
//буфер, тут будемо зберігати введений текст, максимальна довжина вводу - 64 символиchar input_boof[64];void loop() {//обробка вводу користувача//функція input поверне true коли введення завершено (користувач натиснув Enter)if(input(input_boof, sizeof(input_boof))) {//порівнюємо введений текст, якщо перші 4 літери дорівнюють help...if(strncmp(input_boof, "help", 4) == 0) {//...то запускаємо функцію виводу довідкового текстуprintHelp();}//так як команда була введена і оброблено, очищаємо буферmemset(input_boof, '\0', sizeof(input_boof));}}
На кожному кроці циклу викликається функція input(...), і коли вона повертає true обробляється текст, збережений в input_boof. Функція strncmp порівнює перші чотири знака буфера і строку "help", і якщо співпадає - викликає printHelp (див.далі). Для використання strncmp слід підключити бібліотеку string.h.
Весь код файлу (включно з функцією printHelp()) має виглядати так:
#include <Arduino.h>#include <string.h> //бібліотека з функцією strncmp#include <stdbool.h> //для використання типу boolvoid setup() {Serial.begin(115200);pinMode(LED_BUILTIN, OUTPUT);digitalWrite(LED_BUILTIN, LOW);Serial.println("Welcome! Enter command.");Serial.println("Type 'help' for commands.");}//об'являємо допоміжні функціїbool input(char *boof, int8_t boof_size);void printHelp();char input_boof[64];void loop() {if(input(input_boof, sizeof(input_boof))) {if(strncmp(input_boof, "help", 4) == 0) {printHelp();}memset(input_boof, '\0', sizeof(input_boof));}}int8_t cursor = 0;bool input(char *boof, int8_t boof_size){if(Serial.available() > 0){char inpt = Serial.read();Serial.print(inpt);if(inpt == '\n' || inpt == '\r'){boof[cursor] = '\0';cursor = 0;return true;}else{if(cursor < boof_size - 1){input_boof[cursor] = inpt;}cursor++;}}return false;}//допоміжна функція для виведення тексту довідкиvoid printHelp() {Serial.println("\nCommand list:");Serial.println("---------------------------------------------------------------");Serial.println("cfg print all config");Serial.println("val [parameter] print current value of the parameter");Serial.println("set [parameter] [value] set value of specified parameter");Serial.println("---------------------------------------------------------------");}
Робимо білд, завантажуємо на плату, запускаємо Serial Monitor:

На зображенні видно, що при введені неіснуючої команди test (1) нічого не відбувається, а при введені команди help (2) в консоль виводиться текст довідки - список команд, які ми реалізуємо далі.
Миготіння діода, без блокування вводу
Перед тим як писати код конфігуратора, нам необхідно те, що власне ми і будемо конфігурувати. В реальних проєктах це може бути будь що, ми ж для простоти реалізуємо мигання вбудованого світлодіода і можливість конфігурувати час коли діод ввімкнений і вимкнений.
Щоб не забруднювати код в файлі main.cpp напишемо код діода в окремих файлах. Для цього створюємо заголовний файл:
//файл led.hvoid led_init(); //об'являємо функцію ініціалізації додуvoid led_switch(); //об'являємо функцію перемикання діода
Файл реалізації:
//файл led.cpp
#include <Arduino.h>#include "led.h"unsigned long led_ref_point; //змінна зберігатиме момент останньої зміни стану діодаbool led_on = false; //вказує чи ввімкнено зараз світлодіод//функція ініціалізації діода, викликається на початку програмиvoid led_init() {pinMode(LED_BUILTIN, OUTPUT); //встановлюємо пін вбудованого діода в режим виводуdigitalWrite(LED_BUILTIN, LOW); //встановлюємо початковий стан світлодіода - вимкненоled_ref_point = millis(); //початкове значення точки відліку часу}//функція перемикання, викликається в основному циклі програмиvoid led_switch() {//кількість мілісекунд від минулої зміни стануint16_t delta_time = millis() - led_ref_point;if(led_on) { //якщо діод ввімкненийif(delta_time >= 1000) { //...і якщо пройшло більше 1000мс (1 секунда)led_on = false; //вказуємо що діод вимкнулиled_ref_point = millis(); //оновлюємо час останньої зміни стануdigitalWrite(LED_BUILTIN, LOW); //вимикаємо діод}} else { //якщо ж діод вимкненийif(delta_time >= 1000) { //...і якщо пройшло більше 1000мсled_on = true; //вказуємо що діод ввімкненоled_ref_point = millis(); //оновлюємо час останньої зміни стануdigitalWrite(LED_BUILTIN, HIGH); //вмикаємо діод}}}
Тепер підключаємо файл led.h в main.cpp, додаємо виклик led_switch() в кінці функції loop, і викликаємо led_init() в setup (весь код ініціалізації діода видалити з setup, так як все необхідне тепер прописано в led_init). Файл main.cpp на цьому етапі виглядає приблизно так:
#include <Arduino.h>#include <string.h>#include <stdbool.h>#include "led.h" //підключаємо файл з кодом світлодіодаbool input(char *boof, int8_t boof_size);void printHelp();void setup() {Serial.begin(115200);led_init(); //викликаємо ініціалізацію світлодіодаSerial.println("Welcome! Enter command.");Serial.println("Type 'help' for commands.");}char input_boof[64];void loop() {if(input(input_boof, sizeof(input_boof))) {if(strncmp(input_boof, "help", 4) == 0) {printHelp();}memset(input_boof, '\0', sizeof(input_boof));}led_switch(); //викликаємо функцію перемикання світлодіода на кожному кроці циклу програми}int8_t cursor = 0;bool input(char *boof, int8_t boof_size) {...}void printHelp() {...}
Тут для короткості код що не змінився замінено на "...". Тепер білд, завантаження, пуск Serial Monitor і ми бачимо як світлодіод на платі мигає: одну секунду світиться, одну - ні.
Важливим моментом тут є те, що в консоль можна вводити команди - код не блокується. Якщо ви робили простий проєкт мигання кнопки для початківців, частіше за все там використовується функція wait(ms), яка блокує потік виконання програми на вказаний час. Наша функція led_switch не блокує потік, а використовує функцію millis(), яка повертає кількість мілісекунд від старту програми. На кожному кроці циклу рахується пройдені мілісекунди від останнього перемикання, і діод просто перемикається якщо час настав.
Логіка зберігання конфігурації в EEPROM
Для зберігання даних налаштувань скористаємось вбудованою постійною пам'яттю - EEPROM. Вона дає можливість зберігати дані навіть після відключення мікроконтролера від живлення. Знову, щоб не перевантажувати код файлу main.cpp створемо окремі файли з кодом логіки збереження конфігурації.
//файл config.htypedef struct {int led_on_time_ms;int led_off_time_ms;} config_t;void config_init();void config_set(config_t cfg);void config_save();config_t config_get();
В заголовному файлі об'явлено структуру для зберігання і передачі даних конфігурації і функції отримання-збереження даних на чіпі. Далі реалізуємо ці функції:
//файл config.cpp
#include "EEPROM.h" //бібліотека з фреймворку Ардуіно для роботи з постійною пам'яттю#include "config.h"#define SIGNATURE 0x2F //значення підпису - ознаки того, що в пам'яті вже писались дані#define SIGNATURE_ADDR 0x00 //адреса підпису в EEPROM#define CONFIG_START_ADDR 0x01 //адреса початку даних конфігураціїstatic void format();config_t cfg; //змінна для зберігання конфігурації в RAM//функція ініціалізації - викликається 1 раз на старті програмиvoid config_init() {//зчитуємо з EEPROM підписint actual_signature = EEPROM.read(SIGNATURE_ADDR);//якщо сигнатура не відповідає заданійif(actual_signature != SIGNATURE) {format(); //приводимо пам'ять EEPROM в правильний формат}//зчитуємо дані з EEPROMEEPROM.get(CONFIG_START_ADDR, cfg);}config_t config_get() {return cfg;}void config_set(config_t new_cfg) {cfg = new_cfg;}void config_save() {//зберігаємо конфіг в EEPROMEEPROM.put(CONFIG_START_ADDR, cfg);}//приватна функція, що приводить дані EEPROM до правильного формату//також записує початкові значенняstatic void format() {//записуємо сигнатуруEEPROM.write(SIGNATURE_ADDR, SIGNATURE);//створюємо структуру конфігурації з дефолтними значеннямиconfig_t init_cfg;init_cfg.led_on_time_ms = 1000;init_cfg.led_off_time_ms = 1000;//зберігаємо дефолтні значенняEEPROM.put(CONFIG_START_ADDR, init_cfg);}
Постійна пам'ять може містити сміття, тому важливо перевіряти чи є записані корисні дані, і якщо немає - записати. Для перевірки в коді використовується "магічне число" - SIGNATURE. Воно може бути будь яким, я взяв 0x2F. Тож, якщо за адресою 0x00 значення не відповідає зазначеному, то викликається функція format(). Ця функція записує "сигнатуру" і структуру config_t з дефолтними значеннями в постійну пам'ять мікроконтролера.
Зверніть увагу, що завжди при старті конфігурація завантажується в змінну cfg - таким чином всі дані завантажені в ОЗУ і можна багато разів отримувати і записувати дані швидко, без особливих витрат на запис в постійну пам'ять. Для того щоб зберегти дані у EEPROM викликається функція config_save.
Тепер цей код можна використати в основній програмі:
#include <Arduino.h>#include <string.h>#include <stdbool.h>#include "led.h"#include "config.h" //підключаємо файл з кодом роботи конфігуbool input(char *boof, int8_t boof_size);void printHelp();void printAllConfig(); //оголошуємо допоміжну функцію виведення конфігу на екранvoid setup() {Serial.begin(115200);led_init();config_init(); //викликаємо функцію ініціалізації конфігуSerial.println("Welcome! Enter command.");Serial.println("Type 'help' for commands.");}char input_boof[64];void loop() {if(input(input_boof, sizeof(input_boof))) {if(strncmp(input_boof, "help", 4) == 0) {printHelp();} else if(strncmp(input_boof, "cfg", 3) == 0) { //якщо введено "cfg"printAllConfig(); //відобразити конфіг}memset(input_boof, '\0', sizeof(input_boof));}config_t cfg = config_get(); //отримуємо конфігled_switch(cfg.led_on_time_ms, cfg.led_off_time_ms); //змінимо функцію перемикання діода - додаємо аргументи}int8_t cursor = 0;bool input(char *boof, int8_t boof_size) {...}void printHelp() {...}//функція відображення конфігуvoid printAllConfig() {config_t cfg = config_get();Serial.println("\nConfiguration:");Serial.println("---------------------------------------------------------------");Serial.print("LED_ON_TIME_MS ");Serial.println(cfg.led_on_time_ms);Serial.print("LED_OFF_TIME_MS ");Serial.println(cfg.led_off_time_ms);Serial.println("---------------------------------------------------------------");}
Зверніть увагу, що було змінено функцію led_switch - додано параметри, щоб вона могла вмикати-вимикати діод відповідно з налаштованим часом. Оновлена функція led_switch:

Тепер час витримки не жорстко закодований, а передається аргументами. В даному випадку зі збереженої конфігурації.
Як видно з коду, також додана команда відображення всього конфігу. Білдимо, завантажуємо, запускаємо і тепер при введені команди "cfg" буде виводитись весь конфіг, збережений в постійній пам'яті (на даний момент це дефолтні значення, записані при виклику функції format()).
Команда зчитування вказаного значення
В даному проєкті дуже мало значень, але зазвичай системи можуть мати величезну кількість налаштувань, і шукати може бути складно. Тому додамо команду виведення значення вказаного параметру. В файл main.cpp додаємо функцію:
void printConfigVal(char *val_name) {config_t cfg = config_get();Serial.print("\n");if(strncmp("LED_ON_TIME_MS", val_name, 14) == 0) {Serial.println(cfg.led_on_time_ms);} else if(strncmp("LED_OFF_TIME_MS", val_name, 15) == 0) {Serial.println(cfg.led_off_time_ms);} else {Serial.println("Error! Value is not found!");}}
Ця функція очікує строку з назвою параметру, який слід вивести. Завантажує конфігурацію і просто виводить на екран відповідний параметр. Якщо ж введено невідомий (неіснуючий) - виводить відповідне повідомлення.
Також змінимо функцію loop:
void loop() {if(input(input_boof, sizeof(input_boof))) {if(strncmp(input_boof, "help", 4) == 0) {printHelp();} else if(strncmp(input_boof, "cfg", 3) == 0) {printAllConfig();} else if(strncmp(input_boof, "val ", 4) == 0) { //якщо строка починається з val і пробілуprintConfigVal(input_boof + 4); //відсікаємо перші 4 знака (val + пробіл) і викликаємо відображення значення}memset(input_boof, '\0', sizeof(input_boof));}config_t cfg = config_get();led_switch(cfg.led_on_time_ms, cfg.led_off_time_ms);}
Таким чином, програма очікує що користувач введе "val [назва_параметру]". В роботі це виглядає приблизно так:

Оновлення значень в EEPROM
Останній і не менш важливий крок - збереження конфігу в пам'ять. Змінимо код main.cpp так:
...void loop() {if(input(input_boof, sizeof(input_boof))) {if(strncmp(input_boof, "help", 4) == 0) {printHelp();} else if(strncmp(input_boof, "cfg", 3) == 0) {printAllConfig();} else if(strncmp(input_boof, "val ", 4) == 0) {printConfigVal(input_boof + 4);} else if(strncmp(input_boof, "set ", 4) == 0) { //якщо команда починається з set + пробілsetConfigVal(input_boof + 4); //обрізаємо перші 4 символи і залишок передаємо в функцію збереження}memset(input_boof, '\0', sizeof(input_boof));}
...}...void setConfigVal(char *val_str) { //функція збереження значеньchar val_name[20];sscanf(val_str, "%s", val_name); //знаходимо ім'я параметруchar *p = strpbrk(val_str + strlen(val_name), "0123456789"); //відшукуємо цифри в тексті ПІСЛЯ імені параметруif(p == NULL) { //якщо цифрових символів не знайденоSerial.println("Error! Value is not digital!"); //попереджаємо користувача про помилкуreturn;}int val = (int)strtol(p, NULL, 10); //дістаємо цифри зі строки і конвертуємо в intSerial.print("\n");config_t cfg = config_get();
//визначаємо параметр і оновлюємо йогоif(strncmp("LED_ON_TIME_MS", val_name, 14) == 0) {cfg.led_on_time_ms = val;Serial.println("\nLED_ON_TIME_MS updated");} else if(strncmp("LED_OFF_TIME_MS", val_name, 15) == 0) {cfg.led_off_time_ms = val;Serial.println("\nLED_OFF_TIME_MS updated");} else {Serial.println("Error! Value is not found!");return;}config_set(cfg); //відправляємо оновлений конфігconfig_save(); //зберігаємо зміни в постійну пам'ятьSerial.println("\nConfig saved.");}
Тепер ми можемо налаштовувати мигання діода введенням параметрів LED_ON_TIME_MS і LED_OFF_TIME_MS, які визначають скільки часу діод має бути ввімкнений, а скільки ні. При оновленні діод одразу ж буде реагувати на зміни і після відключення живлення і перезавантаження поведінка зберігатиметься. Приклад використання програми:

При необхідності код можна розширювати, додаючи додаткові команди і функції програмі. Репозиторій з готовим проєктом для PlatformIO на github - Відкрити
