Микроконтроллер + компьютер + своё программное обеспечение

Kate

Administrator
Команда форума
Своё устройство на микроконтроллере я начал "пилить" еще в начале 2019-го. Тогда я даже не думал, что захочу связать его с компьютером, но устройство постоянно эволюционирует, и вот настала пора. Причём нужно не просто связать, а написать своё фирменное ПО, которое будет управлять устройством через USB. С подобной задачей я столкнулся впервые, а беглый поиск в сети выдал такие результаты, после которых появилось ещё больше вопросов и каша в голове. Про свой опыт решения данной задачи "От" и "До" делюсь в этой статье.

Вступление​

О моём устройстве расскажу немного позже, сохраним интригу. Предвидя вопрос типа "В чём смысл этого топика?", отвечу, что простого и законченного решения в сети я так и не нашёл. Под простым и законченным решением я подразумеваю дуэт микроконтроллера (далее МК) с компьютером (далее ПК) и заготовки кода с функцией автоматического коннекта, а также мега-функцией "поморгать светодиодом". Пришлось повозиться и собрать все элементы мозайки в единую картину, которую я решил оформить в статью. Надеюсь, она поможет новичкам в вопросе "Как связать микроконтроллер с компьютером через USB" без лишних заумных слов. Уточню, что программированием занимаюсь для удовольствия, поэтому не претендую на образцово-показательный код. Это всего-лишь мой личный опыт, и вам судить, дорогой читатель, насколько он полезен.

Постановка задачи​

  1. Обеспечить двусторонний обмен данными между МК и ПК через USB. Никаких плат и софта типа Arduino в готовом решении мы не используем.
  2. Сделать так, чтобы ПО компьютера автоматически определяло наше устройство при подключении его к любому USB-разъему.
  3. USB-устройство должно отображаться в диспетчере устройств под нашим собственным именем (например, под именем нашей компании).
Примечание. Последний пункт я не стал раскрывать в рамках этой статьи, потому что бесконечную простыню текста и картинок читать утомительно. Оставлю материал для будущей статьи, если тема окажется интересной.

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

Что нужно уметь/иметь​

  • Знать C++ хотя бы на начальном уровне.
  • Уметь работать с любым МК (главное, чтобы у него был интерфейс UART).
  • Собственно сам МК (я взял Atmega328, т. к. она используется в моём проекте).
  • Макетная плата и несколько простых радиодеталей для сборки тестовой схемы.
  • Плата USB/UART преобразователя (о ней скажу ниже).
  • Знать C++ хотя бы на начальном уровне.
  • Уметь работать с любым МК (главное, чтобы у него был интерфейс UART).
  • Собственно сам МК (я взял Atmega328, т. к. она используется в моём проекте).
  • Макетная плата и несколько простых радиодеталей для сборки тестовой схемы.
  • Плата USB/UART преобразователя (о ней скажу ниже).
Как вы понимаете, информации предстоит много, и здесь не будет объяснения того, как прошивать МК или какой компилятор выбрать. Скажу только, что для прошивки я использовал Arduino IDE, а для ПО компьютера - Visual Studio C++ 12.

Не совсем USB-устройство​

Проект, для которого я делаю канал связи с ПК, называется "Teslafon". Это название музыкальных катушек тесла с микроконтроллерным управлением (описание их работы тут). Пока катушка является автономным устройством и ни к чему не подключается, но недавно мне пришла идея сделать управление молниями с ноутбука. Несмотря на то, что тема специфическая, в сети можно найти примеры даже на неё, но там всё довольно индивидуально и я решил пойти своим путём.

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

Теория​

Сперва я думал, что всё будет проще простого. Возьму библиотеки типа V-USB и libusb, подключу МК напрямую к USB-разъему, быстренько найду готовый пример кода и будет мне счастье. Но "быстренько" не получилось. Оказывается, там много заморочек - например, дополнительная библиотека кушает ресурсы МК, а моя прошивка предполагается довольно ёмкой. Мне хотелось использовать встроенные аппаратные фишки по максимуму, чтобы не занимать память. Кроме того, настройка всех этих библиотек требует опыта. Рабочая частота МК подойдет не абы какая, а та, что задал автор библиотеки. Но реальный шок был тогда, когда я узнал, что для моего устройства нужны PID (Product ID) и VID (Vendor ID), за которые сегодня надо заплатить, на секундочку, 3500$ организации usb.org. Ох, не туда меня завели поиски, не туда... Наконец до меня дошло, что к чему.

Оказалось, вопрос не в том, как передать данные через USB, а как передать данные через виртуальный COM-порт! Передача данных через COM-порт намного проще, хотя это устаревший вид портов. А раз так, то реального COM-порта у нас не будет, только виртуальный. Физически же мы по-прежнему будем использовать привычный USB. Виртуальный COM-порт будет предоставлен драйвером, о котором мы поговорим ниже. А еще к устройству добавится некая микросхема-посредник, которая снимет целый ворох проблем. Взгляните на схему:

Рис. 1 - Связующие звенья между хостом и микроконтроллером


Рис. 1 - Связующие звенья между хостом и микроконтроллером

Между ПО хоста и нашей прошивкой есть целых 4 звена. С аппаратным UART-ом всё понятно, что же с остальными?

Здесь роль посредника будет выполнять микросхема по кличке CP2102, у которой есть также куча аналогов (CH340, PL2303, FT232RL и другие). Её основная задача - преобразование интерфейса UART в USB и обратно. Как это делается, нам не важно, это просто чёрный ящик, имеющий вход и выход. CP2102 используется в некоторых программаторах, возможно на вашем она тоже есть, так как довольно распространена. Изучать распиновку этой микросхемы тоже не будем, потому что есть готовые платки с ней же и со всей обвязкой. И это хорошо, ведь наша задача - чем проще, тем целее нервы. Пример двух платок с CP2102 от компании Silicon Labs:

Рис. 2 - USB/UART преобразователи в разных формфакторах


Рис. 2 - USB/UART преобразователи в разных формфакторах

Купить подобную железку можно, например, здесь. Но чтобы не ждать, можете поискать в местных радиомагазинах, товар не редкий, думаю, найдёте. Далее я начал изучать, как применить этот USB/UART преобразователь. Тут тоже всё обошлось - производитель железки уже позаботился о нас и написал драйвер для контроллера USB (см. рис. 1). Этот драйвер и будет эмулировать виртуальный COM-порт. Вы спросите, а где же брать PID и VID для нашего устройства? Ответ - их предоставляет тот же Silicon Labs бесплатно.

Круто, теперь можно увидеть всю последовательность действий:

  1. Покупаем USB/UART преобразователь и собираем тестовое устройство;
  2. Скачиваем и устанавливаем драйвер;
  3. Пишем ПО хоста;
  4. Пишем прошивку МК;
  5. Тестируем интерфейс.

Макетная плата​

Помните, что наше устройство кроме моргания светодиодом ничего пока уметь не будет? Чтобы не было скучно, давайте сделаем три светодиода:

  • Зелёный - будет гореть, пока связь с хостом установлена (не путать с физическим подключением к порту).
  • Красный - загорается или гаснет по нажатию 'r' на клавиатуре.
  • Белый - загорается или гаснет по нажатию 'w' на клавиатуре.
Нажатие 'x' на клавиатуре будет разрывать сеанс передачи данных, гасить зелёный светодиод и завершать работу ПО хоста. Но я забежал немного вперед, вернёмся к схеме устройства:

Рис. 3 - Схема тестового устройства


Рис. 3 - Схема тестового устройства

Думаю, тут всё понятно. Я даже не стал ставить подтягивающий резистор R1, хотя в реальной схеме он нужен. Сейчас нам главное получить минимально рабочую модель. Вот так выглядит макетка:

Рис. 4 - Макетная плата


Рис. 4 - Макетная плата

Кстати, очень удобно использовать витую пару в качестве соединительных проводков, они дешёвые и нарезать их можно разной длины. В жгуте из 5 проводов используются только 4, зелёный провод идёт с контакта DTR преобразователя на макетку и никуда не подключается (оставлен про запас). Atmega328 в корпусе DIP-28 прошла огонь, воду и трубы, но ещё жива - когда-то она была подопытным кролем в создании первой музыкальной катушки тесла. Кварцевый генератор я не ставлю, а использую встроенный на 8 МГц. Для удобства я сделал преобразователь съёмным и слегка подогнул вверх вывод 3.3V, чтобы избежать неправильного подключения.

Примечание. Если у вас другой МК, подключите все пины в соответствии с его даташитом и схемой на рисунке 3.

Установка драйверов​

Наш USB/UART преобразователь уже вставлен в ПК, открываем диспетчер устройств и видим:

Рис. 5 - USB/UART преобразователь в диспетчере


Рис. 5 - USB/UART преобразователь в диспетчере

Мы могли бы сделать своё красивое имя устройству, но я решил, что это будет тема другой статьи, т. к. материала получается слишком много. Пока ограничимся тем, что есть. Качаем готовый драйвер, устанавливаем и переходим к программированию.

Программное обеспечение хоста​

Итак, схема имеет три светодиода: зелёный, красный и белый. Логика работы будет такая: запускаем программу на ПК, если девайс не подключён, то программа будет ждать, пока мы его не подключим. При подключении автоматически будет осуществлён коннект с устройством и загорится зелёный светодиод. Клавиши "w" и "r" будут управлять белым и красным светодиодом соответственно. При вводе "x" произойдёт разрыв связи с нашим устройством и зелёный светодиод погаснет. Как видите, все очень просто.

Сперва я написал программу в процедурном стиле, но если захочется работать сразу с двумя и более устройствами, то такой подход не годится. К тому же, по мере роста программа станет нечитабельной. И тогда я переписал код в стиль ООП. Прошивку МК наоборот, оставим в процедурном стиле, она будет маленькая и простая, оборачивать ее в классы не вижу смысла.

Создадим класс устройства, но на самом деле это будет скорее класс COM-порта, т. к. в нём будут его настройки, функции подключения/отключения и передачи данных. Начало файла tesladevice.h:

//Файл tesladevice.h

#include <windows.h>
#include <iostream>

class TeslafonDevice
{
private:
HANDLE hPort; //Идентификатор ком-порта (далее просто порта)
wchar_t portName[7]; //Строка с именем порта
LPCTSTR ptrPortName; //Указатель на строку с именем порта
unsigned int portNum; //Номер порта
DCB dcbParams; //Структура DCB с кучей полей для настройки порта
COMMTIMEOUTS ctParams; //Структура для определения параметров временных задержек при приеме и передаче данныхнных
Постараюсь максимально комментировать прямо в коде, но некоторые моменты буду выносить отдельно. Так как изначально номер порта мы не знаем, подготовим строку portName из 7 двухбайтовых символов типа wchar_t, где будем перебирать имена в формате "COMxxx". В Windows по умолчанию доступно 256 виртуальных ком-портов, и мы будем перебирать все, пока не найдем своё устройство. Структура dcbParams включает основную массу настроек порта (разбирать её не будем, если интересно, можете загуглить сами). Структура ctParams будет содержать настройки таймаутов, её описание тоже опустим. Далее объявим публичные и приватные функции:

public:
TeslafonDevice():portNum(0){}; //Конструктор инициализирует номер порта нулём
bool Connect() //Ищет наше USB-устройство и подключается к нему
{
//...
}
bool SendData(char transBuf) //Отправка одного байта transBuf в порт
{
//...
}
BOOL GetPortNum() //Возвращает номер порта, к которому подключено устройство
{
//...
}
BOOL Close() //Закрывает порт
{
//...
}
private:
bool helloFrom() //Получает приветствие от нашего устройства
{
//...
}
};

//Конец файла tesladevice.h
Разберём каждую из них подробно. Функция Connect() самая большая, она делает всю черновую работу по подключению к устройству. Работает она так: запускается цикл перебора всех 256 возможных портов и пробует подключиться к каждому. Если подключение удалось, устройству посылается приветствие, затем в течение заданного таймаута ожидается приветствие в ответ. Если оно не поступило, значит устройство не наше и связь с ним прекращается. Если приветствие поступило, значит устройство распознало своего "хозяина" и приготовилось к работе (при этом загорается зелёный светодиод). Функция Connect(), видя знакомый ответ, завершается и отдает истину в основную программу. Происходит всё это быстро, так как обычно львиная доля портов из 256 возможных просто не существует. Такой способ идентификации устройства мне кажется надёжным.

//Тело функции Connect():

for(unsigned int comnum = 1; comnum<=256; comnum++)
{
//Готовим строку с очередным номером порта, swprintf() аналогична sprintf(), но работает с юникодом
//Примечание 1: выражение sizeof(portName)>>1 равносильно sizeof(portName)/2, но выполняется быстрее;
//написать просто sizeof(portName) мы не можем, т. к. тут нужно количество символов, а не их суммарный размермер
//Примечание 2: буква L перед строкой расширяет её до формата юникод
swprintf(portName, sizeof(portName)>>1, L"COM%d", comnum);

//Присваиваем указателю адрес только что собранной строки
ptrPortName = portName;

//Попытка подключения к порту (напоминает работу с файлами)
hPort = ::CreateFile(ptrPortName,GENERIC_READ|GENERIC_WRITE,0,0,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,0);

//Если порт существует, делаем нужные настройки, посылаем приветствие и запрашиваем ответ
if(hPort != INVALID_HANDLE_VALUE)
{
//Заполнение структуры dcbParams
dcbParams.DCBlength = sizeof(dcbParams);
if (!GetCommState(hPort, &dcbParams)) //GetCommState() заполняет dcbParams значениями по умолчанию
{
//Ошибка GetCommState
}
dcbParams.BaudRate = CBR_9600; //Эти настройки меняем на стандартные
dcbParams.ByteSize = 8;
dcbParams.StopBits = ONESTOPBIT;
dcbParams.Parity = NOPARITY;
if(!SetCommState(hPort, &dcbParams)) //Установка новых значений
{
//Ошибка SetCommState
}
//Заполнение структуры ctParams
if (!GetCommTimeouts(hPort, &ctParams)) //GetCommTimeouts() считывает текущие таймауты в ctParams
{
//Ошибка GetCommTimeouts
}
ctParams.ReadIntervalTimeout = 2; //Эти таймауты меняем на свои
ctParams.ReadTotalTimeoutConstant = 2;
ctParams.ReadTotalTimeoutMultiplier = 2;
if (!SetCommTimeouts(hPort, &ctParams)) //Установка новых таймаутов
{
//Ошибка SetCommTimeouts
}
//Посылаем идентификационный запрос устройству (я отправляю просто символ 't')
char data = 't';
DWORD dataSize = sizeof(data);
DWORD dataWritten;
WriteFile (hPort,&data,dataSize,&dataWritten,NULL); //"Стучимся" в порт

if(helloFrom()) //Если ответ от нашего устройства получен
{
//Связь установлена, сохраняем номер порта и выходим в основную программу
portNum = comnum;
return true;
}
//Если ответа нет, закрываем порт и переходим к следующему
CloseHandle(hPort);
}
else
{
//Порт не существует
}
}
return false; //Все порты проверены, устройство не подключено ни к одному из них

//Конец функции Connect()
В коде я дал исчерпывающие пояснения, останавливаться на них не буду. Единственный вопрос, который может возникнуть - как отреагируют другие устройства (если они подключены) к вмешательству со стороны нашей программы? Скорее всего, никак. Я подключал одновременно несколько разных устройств, никаких проблем не было.

Теперь разберём приватную функцию helloFrom(). Чтобы на 100% отличить наше устройство от чужого, будем принимать от него кодовое слово, которое другим устройствам неизвестно. Я взял слово "teslafon", вы можете придумать своё. Вот как это выглядит в коде:

//Тело функции helloFrom():

DWORD strSize; //Количество принятых символов от устройства
char strReceived[9] = {0}; //Буфер для приёма этих символов
memset(strReceived, 0, sizeof(strReceived)); //Обнулим буфер на всякий случай
//Попытка прочитать ответ
ReadFile(hPort,&strReceived,sizeof(strReceived),&strSize,0);

if (strSize > 0) //Eсли что-то принято
{
if(strcmp(strReceived, "teslafon") == 0) //Если получено кодовое слово
{
return true; //Связь установлена!
}
}
return false; //Иначе нет

//Конец функции helloFrom()
Здесь функция ReadFile() ждёт ответа от устройства в пределах заданных таймаутов, хранящихся в структуре ctParams. Если ответа нет или он не совпадает с ожиданиями, helloFrom() выдаёт false. Далее напишем SendData(), которая вступает в работу после подключения к устройству:

//Тело функции SendData(char transBuf):

DWORD bufSize = sizeof(transBuf); //Размер буфера для отправки
DWORD dataWritten; //Количество переданных байт
WriteFile(hPort,&transBuf,bufSize,&dataWritten,NULL); //Отправка данных
return (bufSize == dataWritten); //Результат отправки

//Конец функции SendData()
Для простоты мы отправляем один байт, но если надо отправить более сложные данные, можно несколько раз вызвать SendData() в цикле. Следующая функция GetPortNum() просто возвращает номер порта, к которому мы подключились. Конечно, можно было сделать переменную portNum публичной, но правила хорошего тона велят закрывать данные класса от всеобщего доступа:

//Тело функции GetPortNum():

return portNum;

//Конец функции GetPortNum()
И последняя функция нашего класса просто завершает работу с портом, возвращая результат операции:

//Тело функции Close():

return CloseHandle(hPort);

//Конец функции Close()
Я решил сэкономить на файле tesladevice.cpp и все тела функций разместил в tesladevice.h. Вы можете разделить их или оставить как есть.

Теперь напишем основное тело нашего хоста. Так как это тестовый проект, я сделал его в консольной программе:

//Файл main.cpp

#include <stdio.h>
#include <tchar.h>
#include "tesladevice.h"

using namespace std;

int _tmain(int argc, _TCHAR* argv[])
{
cout << "Hi! We find Teslafon Device..." << endl << endl; //Приветствие
TeslafonDevice TeslaDev; //Объект нашего устройства
while(!TeslaDev.Connect()); //Ждем, пока его не подключат к USB
cout << "Device (COM" << TeslaDev.GetPortNum() << ") found!" << endl << endl; //Подключили!
char transBuf = 0; //Байт данных для передачи
while(1)
{
cout << "Enter 'w' or 'r' ('x' to exit):" << endl; //Мигнём белым или красным?
cin >> transBuf;
if(TeslaDev.SendData(transBuf)) //Данные отправлены?
{
cout << "Data sent: " << transBuf << endl;
}
else
{
cout << "Failed to send data..." << endl;
}
if(transBuf == 'x') break; //Выход из программы
}
TeslaDev.Close(); //Закрываем порт
cout << "Exit" << endl;
getchar();
return 0;
}

//Конец файла main.cpp
На этом работа с хостом закончена. Это всего лишь заготовка, но вполне рабочая, из которой можно слепить что душе угодно. Осталось написать прошивку для USB-устройства и переходить к долгожданным тестам.

Прошивка​

Как уже говорилось выше, моё устройство будет работать на Atmega328, а значит и код я буду строчить для неё. Эксперимент проводился на частоте встроенного генератора 8 МГц, с другими частотами я не работал. В коде используются регистры МК напрямую и глобальные переменные, и вообще попахивает хаосом, но повторюсь - это пример, набросок, может быть не самый лучший, но рабочий. Если у вас другой камень, вам всё равно придется изменить инициализирующую часть кода, так что можете его оформить как вам нравится. Вот мой полный скетч для Arduino IDE:

//Файл firmware.ino

#define FREQMC 8000000L //Рабочая частота МК
#define BAUD 9600L //Бодрейт по умолчанию
#define BAUDDIV (FREQMC/(16UL*BAUD)-1) //Манипуляция с делителем
#define HBYTE(x) ((x)>>8) //Макрос для получения старшего байта
#define LBYTE(x) ((x)& 0xFF) //Макрос для получения младшего байта

#define GREENPORT PC3 //Пины светодиодов
#define REDPORT PC4
#define WHITEPORT PC5

typedef unsigned char BYTE; //Для удобства объявления переменных
BYTE data = 0; //Глобальный буфер для хранения принятых данных от хоста
BYTE isConnect = 0; //Глобальный флаг состояния устройства

void UARTInit() //Инициализация интерфейса передачи данных
{
UBRR0H = HBYTE(BAUDDIV);
UBRR0L = LBYTE(BAUDDIV);
UCSR0B = (1 << RXEN0) | (1 << TXEN0);
UCSR0C = ((1 << UCSZ00) | (1 << UCSZ01));
}

void byteSend(BYTE data) //Посылает 1 байт хосту
{
while (!(UCSR0A & (1 << UDRE0)));
UDR0 = data;
}

void dataSend(BYTE* data) //Посылает массив байт хосту
{
while(*data) byteSend(*data++);
}

BYTE byteReceive() //Ждет и возвращает данные от хоста
{
while (!(UCSR0A & (1 << RXC0)));
return UDR0;
}

void setup() //Инициализация пинов, интерфейсов и т. д.
{
PORTC |= (1 << REDPORT)|(1 << WHITEPORT)|(1 << GREENPORT); //Инициализация пинов светодиодов
PORTC &= ~(1 << REDPORT); //Перевод пинов светодиодов в низкий уровень (на всякий случай)
PORTC &= ~(1 << WHITEPORT); //
PORTC &= ~(1 << GREENPORT); //
UARTInit(); //Инициализация интерфейса
}

void loop() //Главный цикл прошивки
{
data = byteReceive(); //Ожидаем данные от хоста бесконечно, пока они не придут

if(data == 'r') //Если прилетела буква r
{
PORTC ^= (1 << REDPORT); //Включить/выключить красный
}
else if(data == 'w') //Если буква w
{
PORTC ^= (1 << WHITEPORT); //Включить/выключить белый
}
else if(data == 't' && !isConnect) //Если буква t и состояние устройства "Отключён"
{
dataSend("teslafon"); //Послать хосту заветное слово
PORTC |= (1 << GREENPORT); //Включить зелёный
isConnect = 1; //Поменять флаг состояния на "Подключён"
}
else if(data == 'x' && isConnect) //Если буква x и состояние устройства "Подключён"
{
PORTC &= ~(1 << GREENPORT); //Выключить зелёный
isConnect = 0; //Поменять флаг состояния на "Отключён"
}
}
//Конец файла firmware.ino
Ну вот, проделана большая работа. Теперь всё компилируем, прошиваем устройство, втыкаем его в ПК и с замиранием сердца запускаем программу...

Тест всего проекта​

Если всё сделано правильно, программа выведет это:

Рис. 6 - Результат


Рис. 6 - Результат

Дальше вводим разные буквы и жмём Enter. Результат работы я продемонстрирую на видео:

Если у вас всё это добро не запускается или запускается криво, придется попыхтеть над отладкой. По крайней мере, я проверял эту систему на трёх разных компьютерах и на двух Windows (7 и 10), и результатами остался доволен. Желаю и вам удачного запуска.

Выводы​

Статья получилась объёмной, но некоторые детали так и остались не раскрыты. Однако, поставленные задачи выполнены. Только представьте, какие штуки теперь можно делать, имея такой инструмент. Например, можно сделать пульт ДУ для ПК, или обработку разных датчиков умного дома. Можно придумать и более нестандартные и опасные штуки, как в моём случае - управление катушкой тесла. И даже если кто-нибудь скажет, что всё уже давно придумано, я соглашусь. Наверно, есть и более изящные решения задачи, но мне интересно (а порой и необходимо) разобраться во всём самому. Ведь именно так рождается новое. Надеюсь, что дорогой читатель меня поймёт. Удачных всем разработок!

 
Сверху