Привет, Хабровчане!
Это продолжение моего дневника разработки DIY струйного принтера предыдущая часть:
В прошлый раз одной из поднятых проблем стал интерфейс между ПК и устройством. В данной статье я постараюсь решить вопрос подключения STM32 по USB и убедить Windows и Linux что это принтер.

Содержание
§1. Кратко о USB
§2. Поиск реализаций USB Printer Class для STM32
§3. Реализация USB Printer Class для STM32
§4. Тест на Windows
§5. Тест на Linux
§6. Как добавить поддержку печати изображений?
§1. Кратко о USB
Для начала стоит разобраться что представляет собой USB. Неплохо с этим справились microtechnics и в статья Создаём своё первое USB-устройство на Хабре.
С аппаратной точки зрения USB проще веника:
2 провода питания (5В и GNG)
2 провода дифф-пара (Data- (D-) и Data+ (D+))

Протокольно это кадры с транзакциями.
Кадр начинается пакетом пакет SOF (Start Of Frame), заканчивается EOF (End Of Frame), который и не пакет на самом деле, а интервал времени, во время которого передача данных запрещена:

Транзакция может быть трёх видов:
Token
Data
Status
Data и Status достаточно простые, а Пакеты Token бывают нескольких типов: In, Out. Setup, Start Of Frame.
Для общего понимания этого достаточно. Лишь упомяну, что существуют различные классы USB устройств (смотрим тут), для каждого из которых написана своя спецификация (можно найти тут). Это и обеспечивает работу большого количество устройств независимо от производителя. Нас больше всего интересует USB Printer Class.
§2. Поиск реализаций USB Printer Class для STM32
В целом про реализацию USB устройств написано тут и в вышеупомянутой статье Хабре.
Не хочется заново изобретать велосипед, да ещё и с костылями вместо колёс, поэтому возвращаемся к реализации конкретно USB Printer Class
CubeIDE предлагает нам несколько вариантов в USB_DEVICE:
Audio Device Class - динамики, микрофоны и аудио-интерфейсы
Communication Device Class - устройства с телекоммуникационной или сетевой функциональностью (модемы, USB-Ethernet адаптеры и т.д.)
Download Firmware Update Class - загрузчик прошивки
Human Interface Device Class - клавиатуры, мыши, игровые контроллеры и другая периферия, которая позволяет человеку взаимодействовать с компьютером
Mass Storage Class - USB-диски, внешние жёсткие диски, считыватели карт и другие устройства хранения данных

Как видим, USB Printer Class тут нет и даже CustomClass нам не дадут. Заветный Printer Class предлагается нам в пакете с AzureRTOS:

На Github есть пример и документация. Звучит крайне заманчиво, но мы уже используем FreeRTOS и пока не намерены её менять. Хотелось бы иметь что-то подобное в пакете USB_DEVICE.
Почти смирившись с тем что придётся портировать решение из AzureRTOS, я наткнулся на Github репозиторий с разными USB Classes, которые, вероятно, используются в пакете USB_Device. Странно что они не включены в пакет из коробки и тяжело найти упоминания (лично я узнал после нескольких дней общения с Chat-GPT, а не на форуме STM32 или других профильных сайтах).
Устроен репозиторий крайне логично:
в
Core
находится ядро для работы USBв
Class
директории с шаблонами для реализации конкретных классов, которых намного больше, чем в пакете USB_Device у CubeIDE
Самое главное что это можно перенести в свой проект почти без подводных камней.
§3. Реализация USB Printer Class для STM32
Для начала нам нужно ядро для работы с USB в реалиях STM32. Можно взять его из Github репозитория, но я предпочту выбрать в Cube IDE USB Mass Storage Сlass. На вкус и цвет. MSC тоже будет интересен в рамках Composite Device Class в будущем (тссс! Спойлер к следующему сезону!), так что пусть пока останется.
Перенесём к себе поддержку класса Printer для USB и реализацию интерфейса. Для этого создаём папку Middlewares/ST/STM32_USB_Device_Library/Class/PRINTER
и переносим в неё низкоуровневый драйвер (ядро) - Inc/usbd_printer.h
+ Src/usbd_printer.c
. Эта часть останется почти без изменений.
Большая часть изменений будет касаться интерфейса, который мы поместим в USB_DEVICE\App
. Копируем туда usbd_printer_if.h
и usbd_printer_if.с
. Тут содержится необходимый интерфейс, который будет взаимодействовать с логикой основной программы.
Далее пойдут необходимые финальные изменения, которые уже вошли в основную ветку проекта.
Core
В примере не объявлен RxBuffer
, поэтому программа упадёт при первом же подключении, так что в начало usbd_printer.c
необходимо объявить его и отдать указатель структуре, представляющей собой наш принтер, а ещё я на всякий случай добавил подготовку буфера после приёма данных (сейчас нет реальной печати, так что вполне допустимо):
Изменения для usbd_printer.c
/** @defgroup USBD_PRNT_Private_Defines
* @{
*/
#define PRNT_RX_DATA_SIZE 512
...
/** @defgroup USBD_PRNT_Private_Variables
* @{
*/
static uint8_t PrinterRxBuffer[PRNT_RX_DATA_SIZE];
...
static uint8_t USBD_PRNT_Init(USBD_HandleTypeDef *pdev, uint8_t cfgidx)
{
hPRNT->RxLength = PRNT_RX_DATA_SIZE;
hPRNT->RxBuffer = PrinterRxBuffer;
...
static uint8_t USBD_PRNT_DataOut(USBD_HandleTypeDef *pdev, uint8_t epnum)
{
USBD_LL_PrepareReceive(pdev, epnum, hPRNT->RxBuffer, PRNT_DATA_FS_OUT_PACKET_SIZE);
return (uint8_t)USBD_OK;
}
Есть функция USBD_PRNT_SetRxBuffer
, который можно было бы воспользоваться, но пока пойдёт и так.
Print интерфейс
А вот в интерфейсе дело обстоит интереснее.
Для начала заменим все TEMPLATE
в названиях на PRNT
для большей чистоты кода.
Изменим DeviceID - в примере он и его объявление выглядят достаточно страшно, обойдёмся простой строкой:
static char PRNT_DEVICE_ID[] = "MFG:STM32;MDL:DIY_Printer;CMD:RAW;CLS:PRINTER;";
Добавим логирование в приём данных задания на печать:
static int8_t PRNT_Receive(uint8_t *Buf, uint32_t *Len)
{
char _buf[64] = {0};
for (uint32_t i = 0; i < *Len; i++) {
_buf[i] = Buf[i];
}
_buf[*Len] = '\r';
_buf[*Len + 1] = '\n';
LOG_Debug("%s", _buf);
return (USBD_OK);
}
LOG_Debug
— это функция одного из моих модулей, в конечном итоге это printf
в терминал порта.
Немного перепишем обработку некоторых команд.
Во-первых, в примере перед DeviceId была зашита его длина в один массив, мы же сделали строку. Таким образом нам необходимо в начало массива вручную положить длину, а затем скопировать саму строку с DeviceId и обработка PRNT_GET_DEVICE_ID
превратится в следующее:
case PRNT_GET_DEVICE_ID:
/* Not using for loop here due to MISRA-C2012-Rule-16.1 */
pbuf[0] = (sizeof(PRNT_DEVICE_ID) - 1) >> 8;
pbuf[1] = (sizeof(PRNT_DEVICE_ID) - 1) & 0xFF;
memcpy(&pbuf[2], PRNT_DEVICE_ID, sizeof(PRNT_DEVICE_ID) - 1);
*length = sizeof(PRNT_DEVICE_ID) + 2;
break;
Во-вторых, чисто для галочки сбросим буфер при PRNT_SOFT_RESET
:
case PRNT_SOFT_RESET:
// сбросить приёмные буферы
USBD_PRNT_HandleTypeDef *hPRNT = (USBD_PRNT_HandleTypeDef *)hUsbDeviceFS.pClassDataCmsit[hUsbDeviceFS.classId];
USBD_LL_PrepareReceive(&hUsbDeviceFS, PRNT_OUT_EP, hPRNT->RxBuffer, PRNT_DATA_FS_OUT_PACKET_SIZE);
*length = 0;
break;
Изменим инициализацию USB Device.
Добавим #define USE_USBD_PRINTER_ONLY
в usbd_printer_if.h
и отрежем с его помощью сгенерированный CubeIDE код для MSC:
MX_USB_DEVICE_Init
void MX_USB_DEVICE_Init(void)
{
/* USER CODE BEGIN USB_DEVICE_Init_PreTreatment */
#ifndef USE_USBD_PRINTER_ONLY
/* USER CODE END USB_DEVICE_Init_PreTreatment */
/* Init Device Library, add supported class and start the library. */
if (USBD_Init(&hUsbDeviceFS, &FS_Desc, DEVICE_FS) != USBD_OK)
{
Error_Handler();
}
if (USBD_RegisterClass(&hUsbDeviceFS, &USBD_MSC) != USBD_OK)
{
Error_Handler();
}
if (USBD_MSC_RegisterStorage(&hUsbDeviceFS, &USBD_Storage_Interface_fops_FS) != USBD_OK)
{
Error_Handler();
}
if (USBD_Start(&hUsbDeviceFS) != USBD_OK)
{
Error_Handler();
}
/* USER CODE BEGIN USB_DEVICE_Init_PostTreatment */
#else // USE_USBD_PRINTER_ONLY
/* Init Device Library, add supported class and start the library. */
if (USBD_Init(&hUsbDeviceFS, &FS_Desc, DEVICE_FS) != USBD_OK)
{
Error_Handler();
}
if (USBD_RegisterClass(&hUsbDeviceFS, USBD_PRNT_CLASS) != USBD_OK)
{
Error_Handler();
}
if (USBD_PRNT_RegisterInterface(&hUsbDeviceFS, &USBD_Printer_fops_FS) != USBD_OK)
{
Error_Handler();
}
if (USBD_Start(&hUsbDeviceFS) != USBD_OK)
{
Error_Handler();
}
#endif // USE_USBD_PRINTER_ONLY
/* USER CODE END USB_DEVICE_Init_PostTreatment */
}
§4. Тест на Windows
Попробуем подключить наш принтер к Windows.
Ставим Windows SDK, посмотрим как наше устройство видит usbview:
usbview
[Port4] : Поддержка USB принтера
Is Port User Connectable: yes
Is Port Debug Capable: no
Companion Port Number: 0
Companion Hub Symbolic Link Name:
Protocols Supported:
USB 1.1: yes
USB 2.0: yes
USB 3.0: no
Device Power State: PowerDeviceD0
---===>USB4 Information<===---
Root hub port is USB4 Capable: No
Device is tunneled through USB4: No
---===>Device Information<===---
English product name: "STM32 USB Printer"
ConnectionStatus:
Current Config Value: 0x01 -> Device Bus Speed: Full (is not SuperSpeed or higher capable)
Device Address: 0x28
Open Pipes: 2
===>Device Descriptor<===
bLength: 0x12
bDescriptorType: 0x01
bcdUSB: 0x0200
bDeviceClass: 0x00 -> This is an Interface Class Defined Device
bDeviceSubClass: 0x00
bDeviceProtocol: 0x00
bMaxPacketSize0: 0x40 = (64) Bytes
idVendor: 0x0483 = STMicroelectronics
idProduct: 0x572A
bcdDevice: 0x0200
iManufacturer: 0x01
English (United States) "STMicroelectronics"
iProduct: 0x02
English (United States) "STM32 USB Printer"
iSerialNumber: 0x03
English (United States) "366E34823435"
bNumConfigurations: 0x01
---===>Open Pipes<===---
===>Endpoint Descriptor<===
bLength: 0x07
bDescriptorType: 0x05
bEndpointAddress: 0x82 -> Direction: IN - EndpointID: 2
bmAttributes: 0x02 -> Bulk Transfer Type
wMaxPacketSize: 0x0040 = 0x40 bytes
bInterval: 0x00
===>Endpoint Descriptor<===
bLength: 0x07
bDescriptorType: 0x05
bEndpointAddress: 0x02 -> Direction: OUT - EndpointID: 2
bmAttributes: 0x02 -> Bulk Transfer Type
wMaxPacketSize: 0x0040 = 0x40 bytes
bInterval: 0x00
---===>Full Configuration Descriptor<===---
===>Configuration Descriptor<===
bLength: 0x09
bDescriptorType: 0x02
wTotalLength: 0x0020 -> Validated
bNumInterfaces: 0x01
bConfigurationValue: 0x01
iConfiguration: 0x00
bmAttributes: 0xC0 -> Self Powered
MaxPower: 0x32 = 100 mA
===>Interface Descriptor<===
bLength: 0x09
bDescriptorType: 0x04
bInterfaceNumber: 0x00
bAlternateSetting: 0x00
bNumEndpoints: 0x02
bInterfaceClass: 0x07 -> This is a Printer USB Device Interface Class
bInterfaceSubClass: 0x01
bInterfaceProtocol: 0x02
iInterface: 0x00
===>Endpoint Descriptor<===
bLength: 0x07
bDescriptorType: 0x05
bEndpointAddress: 0x82 -> Direction: IN - EndpointID: 2
bmAttributes: 0x02 -> Bulk Transfer Type
wMaxPacketSize: 0x0040 = 0x40 bytes
bInterval: 0x00
===>Endpoint Descriptor<===
bLength: 0x07
bDescriptorType: 0x05
bEndpointAddress: 0x02 -> Direction: OUT - EndpointID: 2
bmAttributes: 0x02 -> Bulk Transfer Type
wMaxPacketSize: 0x0040 = 0x40 bytes
bInterval: 0x00
Важно что данные в секциях Device Information
и Device Descriptor
совпадают с тем что указано в USB_DEVICE\App\usbd_desc.c
.
Спускаемся ниже к Interface Descriptor и видим что bInterfaceClass == 0x07
- пол дела сделано. Открываем Диспетчер устройств (для удобства я сменил вид на "Устройство по контейнеру") и находим наш STM32 DIY_Printer
- DeviceId, который возвращается при вызове команды PRNT_GET_DEVICE_ID
.Можно даже убедиться что Windows подхватил стандартный драйвер usbprint.sys:

Ура! Windows понял что это принтер и выдал соответствующий стандартный драйвер. Однако, нового принтера в списке не появилось. Дело в том что для стандартного драйвера нужна ещё одна логическая обёртка, которая создаётся Windows.
Создаём логический принтер
Отправляемся в настройки, находим "Принтеры и сканеры" и тут нам необходимо вручную добавить новое устройство:

Выбираем "Добавить локальный или сетевой принтер с параметрами, заданными вручную":

Выбираем тот, на котором указан наш DeviceId:

Далее необходимо выбрать драйвер, т.к. своего драйвера у нас нет, а как работают чужие мы пока не знаем, воспользуемся "Generic / Text Only":

Далее используем уже установленный драйвер (Windows уже определил нам usbprint.sys)

Дальше при желании можно поменять название принтера, я этого не делал (как видите - не первая моя попытка):

Теперь можно увидеть новое устройство:

при этом "STM32 DIY_Printer" никуда не делся, а загвоздка том что он работает как физически подключённое устройство USB Printer Class - грубо говоря, низкоуровневый драйвер, а "Generic / Text Only" - это логическая обёртка над ним, предоставляющая нам более удобный высокоуровневый драйвер. Можно отдалённо сравнить сравнить это с логическими томами у HDD и SSD.
Поскольку мы можем печатать только текст, попробуем создать текстовый документ в Notepad++ и отправить его на печать. Драйвер отправляет любой текст, но мой терминал может выводить только латиницу, поэтому для наглядности отправим текст на английском:
Пример текста
HELLO!
There is a text
with separated lines
in English
More text
Bla-bla-bla
BYE!
Во время печати терминал отладки показал следующее:
Вывод отладчика
[DEBUG]
1 HELLO!
2
3 There is a text
[DEBUG] 4 with separated lines
5 in English
6
[DEBUG] 7 More text
8
9 Bla-bla-bla
10
[DEBUG]
11 BYE!
И уже можно сказать, что это работающий USB Printer Device. Стоит заметить что данные приходят кусками по 64 байта. Это именно то значение, которое я использовал вUSBD_LL_PrepareReceive
. Хотя RxBuffer
имеет размер PRNT_RX_DATA_SIZE = 512
, я использовал PRNT_DATA_FS_OUT_PACKET_SIZE = 64
.
Почему так сложно добавить устройство в Windows?
Поскольку я использую стандартные для STM32 VID/PID, микроконтроллер и определяется соответственно. Насколько я понял, нельзя просто так взять и и поменять VID и PID, обычно, они покупаются за немаленькую цену, но есть проекты‑инициативы вроде pid.codes, где можно бесплатно получить PID внутри общего VID для open‑source устройств. Возможно, когда проект будет чуть ближе к полноценно работающему прототипу, можно воспользоваться этим.
§5. Тест на Linux
Из устройств на Linux у меня есть китайский WukongPi c убитой флешкой и калькулятор ноутбук на Debian, им и воспользуемся:

При подключении сразу появилось устройство /deb/usb/lp0
, а в настройках оно сразу определилось как Generic Text-Only Printer

Отправляем echo "HELLO" | sudo tee /dev/usb/lp0
и в терминале отладке тут же появляется сообщение [DEBUG] HELLO
.
Идём дальше. Устанавливаем CUPS (в принципе не обязательно, просто интересно что это такое)
sudo apt install cups
sudo usermod -aG lpadmin $USER
Открываем в браузере http://localhost:631 и добавляем принтер (скриншоты делать больно, поэтому только шаги):
В веб‑интерфейсе CUPS → Administration → Add Printer.
В списке устройств появится выбираем наш USB‑принтер (STM32 DIY_Printer).
Когда спросят драйвер, выбираем Raw Queue или Generic → Raw.
Создадим тестовый файл:
printTest.txt
HELLO, Linux!
This is a first message
Second line as second message
Third message
BYE, Linux!
Вызываем в терминале: lp -d STM32_DIY_Printer printTest.txt
и смотрим в отладчик:
[DEBUG] HELLO, Linux!
This is a first message
Second line as second mes
[DEBUG] sage
Third message
BYE, Linux!
Всё хорошо, принтер может работать и с Linux тоже.
Ещё я попробовал печать из стандартного GUI редактора текста в Debian, отладчик выдал:
[DEBUG] PDF-1.5
����
4 0 obj
<< /Length 5 0 R
/Filter /FlateDecode
[DEBUG] >>
stream
x�}P�N�0��+�[���vvPšRBA���8��A�&@D?�M��h�������
[DEBUG] #
[DEBUG] r�m�t&��v���� �!��R�O�L6������&D������:Ԝ���?�=76n��W�A�5a�
[DEBUG] �!�ǿ��i�!P�S�.���۳#�2���ڗ�����
��yߝ��P��JzZ�~��y�,
Очевидно что нужен драйвер на ПК, либо какой-то интерпретатор (PCL, PostScript). Но уже можно сказать что у нас есть заготовка под принтер.
§6. Как добавить поддержку печати изображений?
Вот тут мы выходим за пределы Generic / Text Only
. Этот драйвер умеет только слать «сырые» байты текста, и никакой графики Windows через него не рендерит. Чтобы печатать изображения, нужно решить два вопрос: Как Windows будет готовить данные?
-
Чтобы Windows могла «рисовать» картинку и отправлять её на принтер, нужен драйвер, который умеет преобразовывать графику в команды устройства.
В мире обычных принтеров это PCL, PostScript, ESC/POS или собственный протокол.
В Windows это реализуется через Unidrv/PSCRIPT (старые v3‑драйверы) или v4‑драйверы (XPSDrv).
Можно написать приложение, которое преобразует картинку поток байтов (например, bitmap → монохромные точки) и отправит напрямую в спулер печати как RAW‑задание.
Я не хочу использовать дополнительный софт для формирования задания - поддержка нативной печати важна (это же не CNC). Конечно, свой софт тоже придётся написать, но, в идеале нужен драйвер, но пока что модуль управления печатающей головкой не готов, поэтому останемся на Generic / Text Only
.
В будущем нужно будет реализовать XPSDrv
с фильтром, который преобразует XPS‑графику в протокол, который будет поддерживать принтер.
Заключение
Можно сказать что у меня получилось реализовать заготовку для USB Printer Class, как минимум теперь будет проще заняться разработкой протокольной части для печати. Уже можно принимать сырые данные и на основе их выполнять какое-то задание на печать.
Код, использованный для данной статьи уже лежит в main ветке репозитория проекта на Github. В будущем нужно подумать о реализации более продвинутого протокола для печати изображений.
Немного пояснений к фразе:
MSC тоже будет интересен в рамках Composite Device Class в будущем
Есть идея в будущем реализовать CompositeDevice и дать доступ к флешке (или её части). Так будет проще добираться до логов и выкидывать задание на печать прямо на принтер. Ещё меня заинтересовал классы Image и Video. Их вполне можно использовать для работы сканера (он есть в моём принтере - чего добру пропадать? :D). Где-то натыкался что CompositeDevice поддерживает максимум 4 интерфейса, но мне кажется что можно и больше. Непонятно только смогут ли они работать "параллельно".
Но сачала разберёмся с управлением печатающей головой!
Послесловие
С момента прошлой части добавились прототип модуля для работы с шаговыми двигателями, который будет контролироваться модулем печати, и небольшой модуль для сигнализации ошибок, ему пока ещё не хватает записи в энергонезависимую память (как и логгеру), но теперь, когда I2C или SPI не находят собеседников, я знаю об ошибке, а терминал не захламляется ошибками и я этому рад. А ещё получилось прикрутить PVS-Studio к CI/CD, спасибо статье на Хабре, правда я так и не смог заставить работать фильтр файлов так что код от STM тоже попадает в анализ.
Китайский логический анализатор не тянет обещанные 24 Мб/c, даже на 4 Мб/С не всегда ведёт себя стабильно. Будем надеяться что это не помешает заняться самым важным вопросом - управлением печатающей головой непосредственно, по крайней мере необходимо отработать алгоритм на контроллере без картриджа, а потом уже можно приниматься жечь головы ^(#`∀´)_Ψ
С точки зрения разработки было бы намного логичнее начать с управления печатающей головой. Но пока что проще решать чисто программные задачи и быть уверенным что проблема в коде, а не в железке.
Спасибо всем кто давал советы и подкидывал идеи в предыдущей части! Ещё увидимся!