Embedded HandbookПриложения
Все разделы › VIII. Электроника вокруг микроконтроллера
STM32F103, STM32F407, STM32G0B1 · уровень продвинутый · 17 мин чтения
Открыть в интерактивном справочнике

57г. Klipper: STM32 как исполнитель 3D-принтера — шаги по расписанию от компьютера

В разделе 57а шаговый двигатель разгонялся самим микроконтроллером: интервал между шагами считался в прерывании рекуррентной формулой. Так устроены прошивки принтеров вроде Marlin — всё, от разбора G-кода до кинематики, живёт в одном STM32. В 57в команды двигателю уже приходили по CAN от другого блока.

Klipper делит работу иначе. Всё сложное — разбор G-кода, планирование скорости, кинематику, сглаживание — считает компьютер на Linux (обычно одноплатный). Он знает момент каждого шага с точностью до такта таймера микроконтроллера и присылает готовое расписание. STM32 только исполняет: «через столько-то тактов шагни, потом ещё столько-то раз с таким-то приращением интервала». Здесь — как это расписание сжимается, как устроен протокол, как два устройства договариваются о времени и что меняется, когда плата стоит на печатающей головке и связана по CAN.

Опоры: исходники Klipper (коммит 7bc4d09): МК-сторона — src/command.c, src/sched.c, src/stepper.c, src/endstop.c, src/trsync.c, src/generic/armcm_timer.c, src/stm32/stm32f0_timer.c, src/generic/canserial.c, src/generic/usb_canbus.c, src/stm32/Kconfig; компьютер — klippy/chelper/stepcompress.c, msgblock.c, serialqueue.c, itersolve.c, trapq.c, kin_cartesian.c, klippy/clocksync.py, klippy/stepper.py, klippy/serialhdl.py, klippy/extras/homing.py, klippy/extras/canbus_ids.py. Код не пересказан на глаз: настоящие trapq.c, itersolve.c, kin_cartesian.c, stepcompress.c и msgblock.c посчитали шаги движения и сжали их, настоящий src/command.c с crc16_ccitt.c разобрал поток байт как микроконтроллер, а вся прошивка собрана под пять вариантов STM32. Прогон — _verify/klip57g/klip57g.py, запись — _verify/klip57g/klip57g_host.txt. Лицензия: Klipper — GPL-3.0 (файл COPYING; в заголовках файлов «GNU GPLv3 license», © Kevin O'Connor).

Как это называется в документации

Оригинал Перевод
host, klippy компьютер, программа на нём
MCU, micro-controller микроконтроллер, исполнитель
print time, clock время печати (секунды), такты таймера МК
message block, sequence number блок сообщения, номер блока
VLQ (variable length quantity) число переменной длины
ACK / NAK подтверждение / просьба повторить
data dictionary, identify словарь данных, опознание МК при подключении
step compression, max error сжатие шагов, допустимая ошибка момента шага
queue_step interval count add команда «шагов count, первый через interval, интервал меняется на add»
clock synchronization синхронизация часов компьютера и МК
endstop, homing, trsync концевик, поиск нуля, синхронизация срабатывания
toolhead board, CAN bridge плата на печатающей головке, мост USB — CAN

57г.1 Кто что считает

Прошивка на STM32 не знает ни G-кода, ни миллиметров, ни ускорений. У неё есть объекты с номерами (oid): шаговый двигатель, концевик, нагреватель, датчик. Компьютер при подключении настраивает их командами вроде config_stepper oid=%c step_pin=%c dir_pin=%c …, а дальше присылает команды с моментами в тактах таймера МК.

Схема Klipper из трёх частей. Слева компьютер: G-код попадает в trapq как отрезки разгона, крейсера и торможения, itersolve с кинематикой считает момент каждого шага, stepcompress сжимает шаги в команды queue_step, clocksync переводит время компьютера в такты микроконтроллера. В середине линия USB, UART или CAN: блок с длиной, номером, командами, CRC16 и байтом 0x7E до 64 байт, числа переменной длины от 1 до 5 байт, подтверждение каждому блоку и повтор при сбое, по CAN идентификаторы 0x108 к МК и 0x109 от МК. Справа STM32: command.c разбирает блок, stepper.c держит очередь интервалов, sched.c список таймеров, время от DWT и SysTick на F1 и F4 или от TIM2 на G0, выход STEP и DIR к драйверу. Внизу пример команды queue_step interval 18683 count 25 add минус 206 и итог прогона: 4000 шагов, 16 команд, 143 байта

На компьютере движение живёт в очереди trapq отрезками с постоянным ускорением. Для каждого двигателя itersolve.c с кинематикой (декартовой, CoreXY, дельта…) ищет моменты, когда положение оси пересекает очередную границу шага, — с плавающей точкой, в секундах. stepcompress.c переводит их в такты МК и упаковывает в команды queue_step.

На микроконтроллере command.c разбирает блок и вызывает обработчик. command_queue_step кладёт тройку «interval, count, add» в очередь двигателя (stepper.c), а сам шаг делает обработчик таймера: поднял ножку STEP, прибавил интервал, интервал увеличил на add, поставил таймер снова. Все таймеры — один отсортированный список в sched.c; прерывание берёт первый из списка и перепланирует его.

Время. Klipper нужен свободно бегущий 32-битный счётчик, а не периодическое прерывание:

Семейство Счётчик времени Прерывание Частота в сборке
STM32F1, F4 (Cortex-M3/M4) DWT->CYCCNT — такты ядра SysTick на разницу до следующего таймера 72 МГц (F103), 168 МГц (F407)
STM32G0 (Cortex-M0+, DWT нет) TIM2, 32 бита сравнение канала 1 TIM2 64 МГц (G0B1)

Выбор — в src/stm32/Makefile: для F0 и G0 берётся stm32f0_timer.c, который использует TIM2, если он есть в заголовке кристалла (в stm32g0b1xx.h — есть), иначе 16-битный TIM3. Остальные STM32 работают через armcm_timer.c. Список поддерживаемых кристаллов — src/stm32/Kconfig: от F031 и F103 до G0B1, G474, H743 и L412.


57г.2 Протокол: блоки, числа переменной длины, CRC

Команды идут блоками. Формат задан в src/command.h:

Байт Что
0 длина всего блока: от 5 (MESSAGE_MIN) до 64 (MESSAGE_MAX)
1 0x10 + номер блока 0…15 (MESSAGE_DEST, MESSAGE_SEQ_MASK)
2…n−4 команды подряд: номер команды и параметры
n−3, n−2 CRC16 старшим байтом вперёд
n−1 0x7E (MESSAGE_SYNC)

Номер команды и все целые параметры — числа переменной длины (VLQ): по 7 бит в байте, старший бит байта — «дальше ещё байт». Знак передаётся без отдельного бита: если в первом байте биты 6 и 5 — 11, число отрицательное. Кодер из src/command.c:

    if (sv < (3L<<5)  && sv >= -(1L<<5))  goto f4;
    if (sv < (3L<<12) && sv >= -(1L<<12)) goto f3;
    if (sv < (3L<<19) && sv >= -(1L<<19)) goto f2;
    if (sv < (3L<<26) && sv >= -(1L<<26)) goto f1;

Прогон: компьютер кодирует (msgblock.c), МК разбирает (command_parsef из command.c):

Число Байты Длина
0 00 1
95 5F 1
96 80 60 2
−32 60 1
−33 FF 5F 2
1800 8E 08 2
12 288 80 E0 00 3
1 572 864 80 E0 80 00 4
2 147 483 647 87 FF FF FF 7F 5

Один байт — от −32 до 95, пять — самые большие 32-битные. Все 15 чисел прогона вернулись без искажений.

CRC16 — crc16_ccitt() с начальным значением 0xFFFF, одна и та же функция на МК (src/generic/crc16_ccitt.c) и на компьютере (msgblock_crc16_ccitt). Для строки «123456789» обе дали 0x6F91.

Словарь данных. Номера команд не зашиты в протокол: при сборке scripts/buildcommands.py собирает из исходников все объявления DECL_COMMAND, раздаёт номера и кладёт в прошивку сжатый zlib словарь. При подключении компьютер читает его командой identify и дальше знает, что queue_step oid=%c interval=%u count=%hu add=%hi — это команда 22, а частота таймера — константа CLOCK_FREQ. Во всех пяти сборках ниже queue_step получила номер 22, set_next_step_dir — 21. Словарь сборки F103 с USB — 116 команд, 25 ответов, 19 констант: 10 860 байт JSON, в прошивке сжатый — 3746 байт (command_identify_data).


57г.3 Номера блоков, ACK и NAK

На каждый целый блок с верным CRC и ожидаемым номером МК отвечает пустым блоком из 5 байт с номером следующего ожидаемого блока — это ACK. Если CRC не сошёлся или номер не тот, МК отвечает таким же блоком со своим ожидаемым номером — это NAK, компьютер повторяет всё начиная с него. Прогон через настоящий command_find_and_dispatch():

Что пришло на МК Результат Ответ МК
блок seq 1 0B 11 16 00 8E 08 0A 7D BB 4A 7E — свежий МК ждёт seq 0 отброшен 05 10 9E 81 7E
тот же queue_step с seq 0 выполнен: 10 шагов 05 11 8F 08 7E
seq 1, один байт испорчен отброшен 05 11 8F 08 7E
seq 2 раньше seq 1 отброшен 05 11 8F 08 7E
повтор seq 1 выполнен 05 12 BD 93 7E
повтор seq 2 выполнен, всего 30 шагов 05 13 AC 1A 7E

Первая строка — это и есть подключение: serialqueue.c начинает с номера 1, а получив ответ на блок, которого не отправлял, принимает номер МК как свой (update_receive_seq, «must be connection init»). Разбор блока 0B 11 16 00 8E 08 0A 7D BB 4A 7E: длина 11, номер 1, команда 22 (queue_step), oid 0, interval 1800 (8E 08), count 10, add −3 (7D), CRC 0xBB4A, 0x7E. Десять шагов с интервалом 1800 и приращением −3 кончаются на такте 10 × 1800 − 3 × 45 = 17 865 — столько и насчитал МК.


57г.4 Сжатие шагов: queue_step и max_error

Слать каждый шаг отдельной командой дорого. stepcompress.c ищет отрезки, которые можно описать тройкой interval, count, add: первый шаг через interval тактов после предыдущего, дальше интервал каждый раз меняется на add. Момент шага i (от 1) — interval·i + add·i(i−1)/2: при постоянной скорости add = 0, при разгоне интервал сокращается.

Точного совпадения не требуется. Для каждого шага допустимо окно (minmax_point в stepcompress.c):

    uint32_t max_error = (point - prevpoint) / 2;
    if (max_error > sc->max_error)
        max_error = sc->max_error;
    return (struct points){ point - max_error, point };

Шаг можно сделать раньше точного момента, но не больше чем на max_error и не больше половины промежутка до предыдущего шага, и никогда позже. max_error = MAX_STEPCOMPRESS_ERROR = 25 мкс (klippy/stepper.py), переведённые в такты МК: 1800 на 72 МГц. Функция compress_bisect_add() перебирает add делением диапазона, для каждого находит самую длинную серию, укладывающуюся во все окна, и берёт ту, что дальше всех продвинулась; check_line() ещё раз проверяет каждую точку.

Прогон. Одно движение по X: 50 мм, скорость 100 мм/с, ускорение 3000 мм/с², 80 шагов на мм, частота МК 72 МГц (F103). Разгон — 0,033333 с (1,6667 мм), крейсер — 0,466667 с, всего 0,533333 с. Настоящие trapq.c, itersolve.c и kin_cartesian.c дали 4000 шагов, stepcompress.c сжал их в 16 команд queue_step и одну set_next_step_dir. Начало разгона:

interval count add Байты команды
7 346 969 1 0 16 00 83 C0 B6 19 01 00
106 145 2 −30 626 16 00 86 BD 21 02 FE 90 5E
58 426 4 −5490 16 00 83 C8 3A 04 FF D5 0E
38 184 8 −1577 16 00 82 AA 28 08 F3 57
26 197 15 −533 16 00 81 CC 55 0F FB 6B
18 683 25 −206 16 00 81 91 7B 19 FE 32

Первая команда — один шаг через 7 346 969 тактов (0,102 с) от нулевого момента счёта; дальше серии растут: 2, 4, 8, 15, 25 шагов — чем ровнее разгон, тем длиннее серия. На крейсере интервал 9000 тактов = 125 мкс, ровно 100 мм/с × 80 шаг/мм = 8000 шагов/с.

Сверка. Блоки прошли через command.c, обработчик восстановил моменты так же, как stepper.c. Все 4000 шагов на месте; раньше точного момента — не больше 1797,259 такта (допуск 1800), позже — не больше 0,296 такта: это округление точного дробного момента до целого такта, само сжатие позже не сдвигает.

Сколько байт.

Команд Байт команд Блоков Байт в линии
без сжатия (count = 1 на каждый шаг) 4000 24 144
stepcompress 17 128 3 143

Сжатие — в 188,6 раза. 143 байта по UART на 250 000 бод (SERIAL_BAUD по умолчанию) идут 5,72 мс, по CAN на 1 Мбит/с — 19 кадров, 2025 бит, 2,02 мс (формула calculate_bittime() из serialqueue.c), а само движение длится 533,33 мс.

От чего зависит число команд. Тот же отрезок на разных частотах МК и скоростях:

Частота МК max_error, тактов queue_step Байт в линии Раньше, до тактов Позже, до тактов
64 МГц (G0B1) 1600 17 150 1600,073 0,303
72 МГц (F103) 1800 16 143 1797,259 0,296
168 МГц (F407) 4200 18 162 4200,481 0,441
Скорость, 72 МГц queue_step Байт команд Байт в линии
50 мм/с 12 100 110
100 мм/с 16 128 143
200 мм/с 24 182 202
300 мм/с 31 232 257

Быстрее — длиннее участки разгона и торможения, где серии короче. «Раньше» на 64 и 168 МГц чуть больше допуска (1600,073 и 4200,481) — это та же доля такта от округления точного момента: окно считается от целого такта.


57г.5 Часы и концевики

Шаги компьютер назначает во «времени печати» — секундах на своих часах. Чтобы перевести их в такты МК, нужна связь двух часов. Этим занят klippy/clocksync.py:

Оценку частоты и сдвига stepcompress_set_time() получает для каждого двигателя: момент шага в секундах превращается в такт как (время − сдвиг) × частота. Если плат несколько (основная и на головке), у каждой своя регрессия, и одно и то же время печати переводится в такты каждой платы отдельно.

Если команда опоздала. Команды уходят заранее: компьютер держит их до BUFFER_TIME_HIGH = 1 с впереди исполнения (klippy/toolhead.py), а МК кладёт их в очередь двигателя. Если команда пришла, когда её время уже прошло, МК не «догоняет»: sched_add_timer() останавливает всё с ошибкой «Timer too close», stepper_load_next() — с «Stepper too far in past» (больше 1 мс опоздания).

Концевик и поиск нуля. Компьютер присылает endstop_home с моментом начала, периодом опроса и числом подтверждений. Опрос — тоже таймер в sched.c: endstop_event() читает ножку, при совпадении переходит к повторным чтениям через sample_ticks, и только после sample_count совпадений подряд вызывает trsync_do_trigger(). По умолчанию ENDSTOP_SAMPLE_TIME = 15 мкс и ENDSTOP_SAMPLE_COUNT = 4 (klippy/extras/homing.py). Срабатывание останавливает двигатели, подписанные на тот же trsync (stepper_stop() сбрасывает их очереди), сразу на МК, без ожидания компьютера. Если концевик на одной плате, а двигатель на другой, их связывает компьютер: каждая плата сообщает, что жива; не пришло сообщение за TRSYNC_TIMEOUT = 25 мс (250 мс, если плата одна) — поиск нуля прерывается.


57г.6 CAN, плата на головке и сборка прошивки

CAN. Плату на печатающей головке (например, на G0B1) подключают витой парой CAN и питанием вместо жгута к каждому двигателю, нагревателю и датчику. Протокол тот же, байтовый поток режется на кадры по 8 байт (do_write() в serialqueue.c, стандартные 11-битные идентификаторы). Номера узлов раздаёт компьютер:

Что Идентификатор
запрос «кто без номера» и назначение номера 0x3F0 (CANBUS_ID_ADMIN)
ответ платы: 6 байт UUID 0x3F1
компьютер → плата с номером узла n n × 2 + 256: для первого n = 4 — 0x108
плата → компьютер на единицу больше: 0x109

UUID платы — 6 байт хеша fasthash64 от заводского номера кристалла (canserial_set_uuid), его пишут в конфигурацию (canbus_uuid). Первый номер узла — NODEID_FIRST = 4 (canbus_ids.py). Скорость по умолчанию — CANBUS_FREQUENCY 1 000 000.

Мост USB — CAN. Основную плату можно собрать режимом USBCANBUS: для Linux она выглядит как адаптер gs_usb (usb_canbus.c), а сама остаётся узлом Klipper на этой же шине. Режим есть там, где у кристалла и USB, и CAN: F042/F072, F207, F405/F407/F429, F446, G0B1, G4, H7. Кроме STM32F1 — в Kconfig у HAVE_STM32_USBCANBUS стоит depends on !MACH_STM32F1: у F103 USB и CAN делят одну память буферов и вместе не работают (198е), так что выбирается одно из двух.

Сборка без сети. Сборка Klipper — make menuconfig и make. Меню не нужно: достаточно файла .config из двух-трёх строк и make olddefconfig (он в lib/kconfiglib внутри дерева, Python 3). Для USB на F103:

CONFIG_MACH_STM32=y
CONFIG_MACH_STM32F103=y

Для CAN добавлена строка выбора ножек: CONFIG_STM32_CANBUS_PA11_PA12=y (F103) или CONFIG_STM32_MMENU_CANBUS_PB0_PB1=y (G0B1). Остальное — значения по умолчанию из Kconfig: частота, объём памяти, загрузчик. arm-none-eabi-gcc 12.2 в stm-arm:1:

Сборка Частота Код (text) Данные ОЗУ (bss) klipper.bin
F103, USB 72 МГц 43 682 52 1164 43 736
F103, CAN (bxCAN) 72 МГц 43 166 52 1284 43 220
F407, USB 168 МГц 46 410 64 1220 46 476
G0B1, USB 64 МГц 47 540 52 1160 47 592
G0B1, CAN (FDCAN) 64 МГц 47 260 52 1280 47 312

По умолчанию F103 собирается под 64 КБ флеш-памяти (FLASH_SIZE 0x10000) с загрузчиком на 8 КБ (прошивка с 0x8002000) — 43 736 байт влезают в оставшиеся 56 КБ. F407 — с 0x8008000 (загрузчик 32 КБ). ОЗУ под очереди команд Klipper выделяет при старте из оставшейся памяти, поэтому bss мал. Обработчик шага stepper_event_full — 96 байт на F1/F4 и 82 на G0; прерывание таймера — SysTick 296 байт (F103) и 304 (F407), TIM2 на G0B1 — 284; приём CAN — 360 (bxCAN F103) и 328 (FDCAN G0B1).

Две мелочи сборки на Windows-копии дерева: в образе нет cpp — компоновочный скрипт готовится через make CPP=arm-none-eabi-cpp; scripts/check-gcc.sh с переводами строк CRLF не запускается («No such file or directory»). Метка времени сборки попадает в словарь; для одинаковых размеров от прогона к прогону она заменена постоянной.


57г.7 Грабли

«Timer too close» или «Stepper too far in past» при печати. МК получил команду, время которой уже прошло: компьютер не успел (перегружен, USB-концентратор, медленная карта памяти) или синхронизация часов сбилась. МК не догоняет, а останавливается — так задумано.

Хвост очереди trapq «вернул» ось. В прогоне без завершающей стоянки itersolve дошёл до служебного конца очереди с нулевым положением и насчитал 4000 шагов обратно — stepcompress отказался: «Invalid sequence» с отрицательным интервалом. В Klipper движение всегда кончается стоянкой, в своей обвязке её надо добавить самому.

Первый блок после подключения отброшен. Это норма: свежий МК ждёт номер 0, компьютер начинает с 1 и принимает номер из NAK.

Шаг «раньше» больше, чем max_error. 1600,073 такта при допуске 1600 — доля такта от округления точного момента; окно считается от целого такта.

Номер команды из чужой сборки. Номера раздаются при сборке. Компьютер всегда берёт их из словаря подключённой платы; жёстко вписывать 22 в свою программу нельзя.

F103 и мост USB — CAN. В Kconfig для F1 режима нет: USB и CAN у F103 делят одну память буферов и вместе не работают. Мост — на F042/F072, F207, F405/F407/F429, F446, G0B1, G4, H7.

Флеш F103 кончается. Сборка по умолчанию считает флеш 64 КБ и загрузчик 8 КБ; остаётся 56 КБ, занято 43 736 байт. Лишние модули отключаются в src/Kconfig (WANT_ADC, WANT_SPI, WANT_I2C и др.); при флеш меньше 64 КБ HAVE_LIMITED_CODE_SIZE включает -Os.


57г.8 Итоги

Дальше: раздел 57д «FOC своими руками: SimpleFOC и ODrive — векторное управление бесколлекторным мотором» — от шаговых двигателей по расписанию к бесколлекторным с векторным управлением.

Открыть в интерактивном справочнике