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

57в. VESC и OpenInverter: силовая электроника под управлением по CAN

Разделы 57–57б дошли до ШИМ, двигателя и тока фазы — всё внутри одного контроллера. В самокате, лодке или переделанной в электромобиль машине контроллеров несколько, а команды им даёт другой блок: пульт, блок управления машиной, бортовой компьютер. Связывает их CAN (раздел 19).

Здесь — два открытых проекта, по которым видно, как это устроено на деле. VESC — контроллер бесколлекторного двигателя на STM32F405: команды «ток», «обороты», «заполнение» в одном кадре, шесть кадров состояния, длинные команды кусками. OpenInverter (прошивка stm32-sine на STM32F103 и библиотека libopeninv) — инвертор тягового двигателя: база параметров, карта «параметр → биты кадра», доступ к параметрам по SDO и кадр управления с газом, направлением и проверкой целостности. В конце — пульт для VESC на STM32F103, тайм-ауты и загрузка шины.

Опоры: исходники VESC 7.01 (comm/comm_can.c, datatypes.h, timeout.c, applications/appconf_default.h, описание documentation/comm_can.md) и OpenInverter: stm32-sine 5.41 (include/param_prj.h, src/vehiclecontrol.cpp, src/stm32_sine.cpp, тест test/test_vcu.cpp) и libopeninv (src/canmap.cpp, src/cansdo.cpp). Код не пересказан на глаз: функции отправки и разбора VESC вырезаны из comm_can.c дословно и собраны gcc на компьютере вместе с buffer.c и crc.c прошивки; карта CAN и SDO OpenInverter собраны g++ с настоящей базой параметров stm32-sine. Каждый кадр ниже отправлен одной стороной и разобран другой. Прогон — _verify/vesc57v/vesc57v.py, запись — _verify/vesc57v/vesc57v_host.txt. Лицензия: VESC — GPL-3.0 или новее (© Benjamin Vedder, в заголовке каждого файла); stm32-sine — GPL-3.0 (файл LICENSE); libopeninv — LGPL-3.0 по файлу LICENSE, в заголовках файлов — GPL-3.0 или новее (© Johannes Huebner); длины кадров — canframelen.c из can-utils (BSD-3-Clause или GPL-2.0).

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

Оригинал Перевод
controller ID, VESC ID номер контроллера на шине
extended ID (EID) 29-битный идентификатор
duty cycle заполнение ШИМ
ERPM (electrical RPM) электрические обороты: механические × число пар полюсов
brake current, handbrake ток торможения, «ручной тормоз» (удержание)
status message, status rate кадр состояния, частота его отправки
fill / process RX buffer «наполнить» и «обработать» приёмный буфер — длинная команда кусками
timeout, timeout brake current тайм-аут связи, ток торможения при тайм-ауте
parameter database, canmap база параметров, карта CAN (параметр → биты кадра)
SDO (service data object), node ID доступ к параметру запросом-ответом, номер узла
gain, offset множитель и сдвиг при упаковке значения
precharge, DC switch (contactor) предзаряд, главный контактор
throttle, regen газ, рекуперация (торможение двигателем)

57в.1 Два проекта и одна шина

У обоих проектов силовая часть — своя плата, а управление приходит по CAN. Устроены они по-разному.

VESC придумывает кадр под каждую команду. Идентификатор 29-битный, в нём два поля: младший байт — номер контроллера, следующий — номер команды. Данные — одно-два числа старшим байтом вперёд с множителем. Разбор жёстко зашит в прошивку.

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

Слева — кадр VESC: 29-битный идентификатор из трёх полей, биты 28…16 не используются, биты 15…8 — номер команды, биты 7…0 — номер VESC; список команд с множителями SET_DUTY 0 ×100 000, SET_CURRENT 1 ×1000, SET_CURRENT_BRAKE 2 ×1000, SET_RPM 3 ×1, SET_POS 4 ×1 000 000 и номера кадров состояния 9, 14, 15, 16, 27, 58; пример SET_RPM 10000 для VESC 5 — идентификатор 0x305 и байты 00 00 27 10; кадр STATUS_1 от VESC 5 с идентификатором 0x905 из трёх полей: ERPM 12345, ток × 10 = 23,3 А, заполнение × 1000 = 0,456. Справа — кадр управления OpenInverter с идентификатором 63: слово 0 с полями pot, pot2, canio и счётчиком, слово 1 с полями круиз, счётчик, рекуперация и CRC; значения битов canio и пример байтов DC 05 5F 4A 00 40 1E 57. Внизу — загрузка шины 500 кбит/с: 1 VESC — 6,23 %, 4 — 24,92 %, 10 — 62,30 %, 10 при 100 Гц — 114,70 %, не влезет

Скорость шины у VESC по умолчанию — 500 кбит/с (APPCONF_CAN_BAUD_RATE), можно выбрать 10, 20, 50, 75, 100, 125, 250 и 1000. Делители в comm_can.c рассчитаны на тактовую APB1 42 МГц (кварц 8 МГц, PLL до 168 МГц, APB1 = /4, hwconf/mcuconf.h), и все девять скоростей получаются ровно:

Скорость Делитель Квантов в бите Точка выборки
1000 кбит/с 3 14 78,6 %
500 кбит/с 6 14 78,6 %
250 кбит/с 8 21 76,2 %
125 кбит/с 16 21 76,2 %
10…100 кбит/с 300…30 14 85,7 %

У OpenInverter выбор 125, 250, 500, 800 кбит/с и 1 Мбит/с (параметр canspeed, по умолчанию 500 кбит/с).


57в.2 VESC: команда в одном кадре

Номер команды — из перечня CAN_PACKET_ID в datatypes.h, множитель — из функций comm_can_set_* в comm_can.c:

Команда Номер Что Множитель Приёмник вызывает
SET_DUTY 0 заполнение −1…1 100 000 mc_interface_set_duty
SET_CURRENT 1 ток двигателя, А 1000 mc_interface_set_current
SET_CURRENT_BRAKE 2 ток торможения, А 1000 mc_interface_set_brake_current
SET_RPM 3 обороты, ERPM 1 mc_interface_set_pid_speed
SET_POS 4 угол, градусы 1 000 000 mc_interface_set_pid_pos

Число — 32 бита со знаком, старшим байтом вперёд (buffer_append_int32). Идентификатор — (команда << 8) | номер. Кадры из прогона: функция VESC отправляет, ветка decode_msg того же файла разбирает.

Команда Кадр Приёмник получил
SET_RPM 10000 для VESC 5 0x305 [4] 00 00 27 10 set_pid_speed(10000)
SET_RPM −5000 для VESC 5 0x305 [4] FF FF EC 78 set_pid_speed(−5000)
SET_CURRENT 51 А для VESC 23 0x117 [4] 00 00 C7 38 set_current(51)
SET_DUTY 0,25 для VESC 5 0x005 [4] 00 00 61 A8 set_duty(0,25)
SET_CURRENT_BRAKE 10 А для VESC 5 0x205 [4] 00 00 27 10 set_brake_current(10)
SET_POS 90° для VESC 5 0x405 [4] 05 5D 4A 80 set_pid_pos(90)

Кадр для 51 А на VESC 23 совпал с примером из описания comm_can.md — 0x0117, 00 00 C7 38. Своя независимая упаковка (Python, struct.pack(">i", …)) дала те же шесть кадров.

Команду принимает VESC, чей номер стоит в кадре, и все — если номер 255. В прогоне VESC 5 пропустил SET_RPM для VESC 6 (двигатель не тронут, тайм-аут не сброшен) и выполнил SET_RPM 3000 для номера 255 (кадр 0x3FF). Каждая принятая команда сбрасывает тайм-аут связи (timeout_reset(), 57в.6).

SET_RPM — электрические обороты. Регулятор скорости VESC работает в ERPM; обороты вала VESC сам получает делением на число пар полюсов (mc_interface_get_rpm() / (conf->si_motor_poles / 2.0) в motor/mc_interface.c). Для двигателя с 14 полюсами (7 пар) 10 000 ERPM — это 10 000 / 7 ≈ 1429 об/мин вала.


57в.3 VESC: кадры состояния и несколько контроллеров

VESC сам рассказывает о себе шестью кадрами. Номера и содержимое — из comm_can_send_status1…6 и описания:

Кадр Номер Байты 0–3 Байты 4–5 Байты 6–7
STATUS_1 9 ERPM ток двигателя × 10 заполнение × 1000
STATUS_2 14 потрачено А·ч × 10 000 заряжено А·ч × 10 000 (4 байта)
STATUS_3 15 потрачено Вт·ч × 10 000 заряжено Вт·ч × 10 000 (4 байта)
STATUS_4 16 t ключей × 10, t двигателя × 10 ток от батареи × 10 положение × 50
STATUS_5 27 тахометр напряжение × 10 резерв
STATUS_6 58 АЦП1 × 1000, АЦП2 × 1000 АЦП3 × 1000 ШИМ-вход × 1000

Кадры собраны в два набора, у каждого своя частота: по умолчанию 50 Гц и 5 Гц (APPCONF_CAN_STATUS_RATE_1/2), а сами наборы — пустые (APPCONF_CAN_STATUS_MSGS_R1/R2 = 0). Новый VESC по шине молчит, пока ему не включат статусы. Команда автонастройки CAN_PACKET_DETECT_APPLY_ALL_FOC с флагом включает в первый набор кадры 1–4 (0b00001111).

Разбор STATUS_1 из прогона: VESC 5 с оборотами 12 345,6, током 23,4 А и заполнением 0,456 отправил 0x905 [8] 00 00 30 39 00 E9 01 C8, приёмник прочитал 12 345 ERPM, 23,3 А, 0,456. Потеря десятой доли — не ошибка шины: 23,4 во float — это 23,399 999 6…, после умножения на 10 и приведения к целому (отбрасыванием дробной части) остаётся 233. Второй кадр — −800 ERPM, −7,25 А, −0,0999: FF FF FC E0 FF B8 FF 9D, приёмник прочитал −800, −7,2 А и −0,099 — и здесь дробь отбрасывается к нулю. Свой разбор на Python дал те же числа.

Сколько контроллеров помнит приёмник. Кадры состояния VESC принимает от любого номера (разбор идёт вне проверки «кадр мне?») и хранит в таблице на CAN_STATUS_MSGS_TO_STORE = 10 мест. В прогоне приёмнику пришли STATUS_1 от 12 разных VESC — заняты все 10 мест, последний записан номер 18; от номеров 19 и 20 не осталось ничего.


57в.4 VESC: длинная команда кусками

Всё, что умеет последовательный порт VESC (чтение настроек, телеметрия COMM_GET_VALUES, обновление прошивки), можно отправить и по CAN. Для этого в comm_can_send_buffer() четыре служебные команды:

Команда Номер Данные кадра
PROCESS_SHORT_BUFFER 8 номер отправителя, режим ответа, до 6 байт команды
FILL_RX_BUFFER 5 смещение (1 байт), до 7 байт
FILL_RX_BUFFER_LONG 6 смещение (2 байта), до 6 байт
PROCESS_RX_BUFFER 7 номер отправителя, режим ответа, длина (2 байта), CRC16 (2 байта)

Команда до 6 байт уходит одним кадром. Длиннее — кусками по 7 байт с однобайтовым смещением, пока смещение не больше 255; дальше — по 6 байт с двухбайтовым; последним — кадр «обработать» с длиной и CRC16 (таблица crc16_tab из crc.c, та же, что у последовательного порта). Приёмник складывает куски в один из трёх буферов по 512 байт (RX_BUFFER_NUM, PACKET_MAX_PL_LEN), сверяет длину и CRC и только тогда выполняет. Номер отправителя запоминается — ответ уйдёт ему тем же способом. Прогон, VESC 2 шлёт VESC 5:

Команда, байт Кадров Из них
5 1 1 SHORT
6 1 1 SHORT
7 2 1 FILL + 1 PROCESS
20 4 3 FILL + 1 PROCESS
259 38 37 FILL + 1 PROCESS
300 45 37 FILL + 7 FILL_LONG + 1 PROCESS
512 81 37 FILL + 43 FILL_LONG + 1 PROCESS

Во всех случаях приёмник собрал команду байт в байт. 37 кадров по 7 байт — это 259 байт: последнее однобайтовое смещение — 252. Команда из 20 байт закончилась кадром 0x705 [6] 02 00 00 14 F2 1A: от VESC 2, режим 0, длина 0x0014 = 20, CRC16 0xF21A.


57в.5 OpenInverter: параметры, карта CAN и SDO

База параметров. В param_prj.h stm32-sine каждая строка — категория, имя, единица, пределы, значение по умолчанию и постоянный номер: udcsw (напряжение включения контактора, 330 В, номер 20), ocurlim (порог тока, 100 А, номер 22), nodeid (номер узла, 1), controlid (идентификатор кадра управления, 63). Измеряемые величины — тоже строки базы: udc (напряжение шины, номер 2001), speed (2012), tmphs (температура ключей, 2019). Значения хранятся с фиксированной точкой: 5 дробных бит (FRAC_DIGITS 5 в my_fp.h), то есть шаг 1/32.

Карта CAN — CanMap::AddSend() и AddRecv(): параметр, идентификатор, начальный бит, длина в битах, множитель, сдвиг. Длина со знаком минус — порядок «старшим вперёд». Отправка считает значение × множитель + сдвиг, приём — (биты + сдвиг) × множитель. Прогон с базой stm32-sine:

Строка карты Значение В кадре
udc → 0x101, биты 0…15, ×10 358,7 В 02 0E — 3586
speed → 0x101, биты 16…31, ×1 3000 B8 0B
tmphs → 0x101, биты 32…39, ×1, сдвиг +40 45 °C 55 — 85
speed → 0x102, начало 15, длина −16 3000 0B B8

Кадр 0x101 ушёл длиной 5 байт — ровно до последнего занятого бита. Напряжение 358,7 В ушло как 3586, а не 3587: в базе оно уже 358,6875 — ближайшее кратное 1/32 вниз, — а при упаковке дробь отбрасывается. Пределы из кода Add(): длина до 32 бит, бит не дальше 63, идентификатор не больше 0x7FF (без CAN_EXT); в прогоне длина 33 дала ошибку −3, начало 60 при длине 8 — −2, идентификатор 0x800 — −1. Сообщений на отправку — не больше 10 (MAX_MESSAGES, одиннадцатое — ошибка −4), строк во всей карте — не больше 50 (MAX_ITEMS).

Приём: строка pot ← 0x200, биты 0…11 и idc ← 0x200, биты 16…31, множитель 0,1. Кадр D0 07 F4 01 … дал pot 2000 и idc 50,0 А. Кадр D0 07 18 FC …, где отправитель имел в виду −100,0 А (0xFC18 = −1000), дал 6453,6 А: по умолчанию CAN_SIGNED = 0, и приёмник читает биты как число без знака.

Как часто карта уходит в шину, задаёт canperiod: раз в 100 мс (по умолчанию) или раз в 10 мс.

SDO. Запрос — на 0x600 + nodeid, ответ — с 0x580 + nodeid, то есть 0x601 и 0x581 по умолчанию. Индекс 0x2100 + старший байт номера и подындекс = младший байт — параметр по постоянному номеру; индекс 0x2000 с подындексом — по месту в списке, а оно зависит от сборки (в варианте FOC у udcsw место 82). Прогон:

Запрос на 0x601 Ответ с 0x581 Что
40 00 21 14 00 00 00 00 43 00 21 14 40 29 00 00 чтение udcsw: 0x2940 = 10560 = 330 × 32
23 00 21 16 80 0C 00 00 60 00 21 16 80 0C 00 00 запись ocurlim = 3200 / 32 = 100 А
23 00 21 16 00 00 40 00 80 00 21 16 30 00 09 06 131 072 А — больше предела 65 536: отказ 0x06090030

Запрос на 0x602 (узел 2) инвертор с nodeid 1 не заметил — ответа не было. Карту тоже можно править по SDO: индексы 0x3000 (отправка) и 0x3001 (приём) в cansdo.cpp.


57в.6 Управление инвертором, пульт на F103 и тайм-ауты

Кадр управления OpenInverter принимается на controlid (по умолчанию 63 = 0x3F) и разбирается в VehicleControl::CanReceive() мимо карты. Поля (биты слов, байты 0–3 и 4–7 младшим вперёд):

Слово Биты Поле
0 0…11 pot — газ, канал 1
0 12…23 pot2 — газ, канал 2
0 24…29 canio: 1 круиз, 2 старт, 4 тормоз, 8 вперёд, 16 назад, 32 BMS
0 30…31 счётчик
1 0…13 скорость круиза
1 14…15 тот же счётчик ещё раз
1 16…23 рекуперация, %
1 24…31 CRC

Газ из кадра берётся, только если в potmode включён режим CAN. CRC — младший байт CRC-32 блока CRC STM32 по двум словам с обнулённым байтом CRC (controlcheck = 1 по умолчанию; 0 — только счётчик). Функция FillInCanData из теста проекта test_vcu.cpp собрала кадр «газ 1500 и 1520, старт + вперёд, рекуперация 30»: счётчик 1 — DC 05 5F 4A 00 40 1E 57, счётчик 2 — DC 05 5F 8A 00 80 1E F2. Свой расчёт CRC на Python дал те же байты CRC 0x57 и 0xF2.

Счётчик защищает от зависшего отправителя: два поля счётчика должны совпасть, а сам счётчик — смениться с прошлого кадра. Неверные CRC или счётчик — ошибка; пока ошибок меньше 5, хорошие кадры их гасят, после пятой инвертор не принимает кадры до перезапуска.

Контакторы и старт. При включении stm32-sine замыкает реле предзаряда. Главный контактор замыкается, когда напряжение шины не ниже udcsw, газ отпущен, аварий нет и пришёл «старт» — ножкой или битом canio. Если через 5 с после включения (PRECHARGE_TIMEOUT 500 тиков по 10 мс) реле предзаряда ещё замкнуто, а напряжение ниже udcsw / 2, — ошибка предзаряда и реле размыкается. Направление — биты «вперёд» и «назад»; оба сразу при включённом режиме зарядки переводят инвертор в зарядку.

Тайм-ауты. Оба проекта останавливают двигатель, если команды перестали приходить:

VESC OpenInverter
Что считается командой любая SET_* по CAN (timeout_reset()) кадр управления
Тайм-аут APPCONF_TIMEOUT_MSEC 1000 мс; в comm_can.md — 0,5 с CAN_TIMEOUT 50 тиков RTC = 500 мс
Что делает ток торможения timeout_brake_current (по умолчанию 0 — двигатель отпущен) canio = 0, газ = 0, ошибка ERR_CANTIMEOUT
Проверка поток каждые 10 мс при опросе входов и газа

У VESC значение по умолчанию в коде и в описании разошлись — надо смотреть в настройках своего экземпляра. Описание советует слать команду непрерывно с постоянной частотой, например 50 Гц. Тот же поток тайм-аута кормит сторожевой таймер IWDG (сброс, если поток мотора или CAN завис дольше 12 мс).

Пульт на STM32F103. Файл vesc57v_f103.c: bxCAN на 500 кбит/с, раз в 20 мс — SET_RPM контроллеру 5, фильтр пропускает только 29-битный 0x905 (его STATUS_1). Если статуса нет дольше 300 мс, пульт шлёт 0 ERPM:

static int vesc_set_rpm(uint8_t id, int32_t erpm)
{
    uint8_t b[4] = {(uint8_t)(erpm >> 24), (uint8_t)(erpm >> 16), (uint8_t)(erpm >> 8), (uint8_t)erpm};
    return send_eid(((uint32_t)CAN_PACKET_SET_RPM << 8) | id, b, 4);
}
    CAN1->sTxMailBox[box].TIR = (eid << 3) | CAN_TI0R_IDE | CAN_TI0R_TXRQ;

Сборка arm-none-eabi-gcc 12.2 (-Os, -Wall -Wextra -Werror) — без предупреждений: 1252 байта флеш-памяти (1248 кода и 4 данных) и 24 байта ОЗУ; main — 472 байта, прерывание приёма — 132.

Загрузка шины. 29-битный кадр с 8 байтами — 131 бит без вставленных битов (с паузой между кадрами), до 160 в худшем случае; с 4 байтами — 99 и 120 (canframelen.c). Настоящий STATUS_1 из прогона занял 142 бита, SET_RPM — 107. Каждый VESC шлёт STATUS_1…4 с частотой f, пульт каждому — SET_RPM с 50 Гц; шина 500 кбит/с:

VESC f Кадров в секунду Загрузка Худший случай
1 50 Гц 250 6,23 % 7,60 %
4 50 Гц 1000 24,92 % 30,40 %
10 50 Гц 2500 62,30 % 76,00 %
4 100 Гц 1800 45,88 % 56,00 %
10 100 Гц 4500 114,70 % 140,00 %

Десять контроллеров на 50 Гц уже занимают больше половины шины; на 100 Гц не влезают вовсе — кадры с большими номерами команд (STATUS_4) будут проигрывать арбитраж и опаздывать.

Гальваническая развязка и оконечные резисторы. Силовая часть инвертора — сотни вольт; CAN-приёмопередатчик пульта и блока управления связаны с его землёй через шину. Развязку ставят между логикой инвертора и линией CAN (изолированный приёмопередатчик и отдельное питание его стороны), оконечные резисторы — на двух концах шины (раздел 19). Схемы развязки ни VESC, ни stm32-sine в исходниках не описывают; здесь это не проверено.


57в.7 Полный код пульта и своих кодеров

Своего кода в разделе два куска: пульт для VESC на STM32F103 — vesc57v_f103.c со скриптом компоновщика f103.ld — и независимые кодер и декодер на Python, которыми сверены кадры обеих прошивок. Ниже они целиком; куски из 57в.6 здесь повторяются внутри своих функций. Код VESC и OpenInverter — чужой (лицензии — в начале раздела) и здесь не приводится; заглушки, с которыми проверка собирает его на компьютере, нужны только прогону и здесь не приводятся.

Сборка пульта — та же команда, что в проверке. CMSIS_F1 — каталог Device/Include пака Keil STM32F1xx_DFP 2.4.1 (в нём stm32f10x.h), CMSIS_CORE — CMSIS/Core/Include из CMSIS 5.9.0 (github.com/ARM-software/CMSIS_5). Отдельный стартовый файл не нужен: таблица векторов и Reset_Handler — в самом vesc57v_f103.c, отсюда -nostartfiles:

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -Os -std=c99 -Wall -Wextra -Werror -ffunction-sections -fdata-sections \
  -DSTM32F10X_MD -I$CMSIS_F1 -I$CMSIS_CORE vesc57v_f103.c -T f103.ld -nostartfiles --specs=nano.specs \
  -Wl,--gc-sections -o fw.elf
arm-none-eabi-size fw.elf

Настройки и состояние. Делители CAN для 500 кбит/с проверяет сам компилятор (_Static_assert); всё, что пишет прерывание, — volatile:

/* Раздел 57в — пульт для VESC на STM32F103C8: 50 раз в секунду шлёт SET_RPM контроллеру 5
 * и принимает его STATUS_1. Кадры — 29-битные: идентификатор = (команда << 8) | номер VESC,
 * числа — старшим байтом вперёд (как buffer_append_int32 в прошивке VESC).
 *   PB8/PB9 — CAN_RX/CAN_TX после ремапа; кварц 8 МГц × 9 = 72 МГц, APB1 = 36 МГц;
 *   500 кбит/с: 36 МГц / 4 = 9 МГц, 1 + 15 + 2 = 18 квантов, точка выборки 16/18.
 * Если статус от VESC не приходит дольше 300 мс, пульт перестаёт считать связь живой;
 * сам VESC без команд дольше своего тайм-аута отпускает двигатель. */
#include "stm32f10x.h"

#define FCAN     36000000u
#define BRP      4u
#define TS1      15u
#define TS2      2u
_Static_assert(FCAN / (BRP * (1u + TS1 + TS2)) == 500000u, "500 кбит/с ровно");

#define VESC_ID            5u
#define CAN_PACKET_SET_RPM 3u
#define CAN_PACKET_STATUS  9u

static volatile uint32_t ms;
static volatile int32_t erpm_now;     /* из STATUS_1 */
static volatile int16_t current_x10;  /* ток × 10 */
static volatile int16_t duty_x1000;   /* заполнение × 1000 */
static volatile uint32_t last_status_ms;
static volatile uint8_t status_seen;
static volatile int32_t erpm_set = 10000;

void SysTick_Handler(void)
{
    ms++;
}

Отправка. Номер свободного ящика — из поля CODE регистра TSR; число — старшим байтом вперёд, как buffer_append_int32 у VESC:

/* 29-битный кадр в свободный ящик; 0 — все три заняты */
static int send_eid(uint32_t eid, const uint8_t *d, uint32_t len)
{
    uint32_t box = (CAN1->TSR & CAN_TSR_CODE) >> 24;
    uint32_t lo = 0, hi = 0;
    if ((CAN1->TSR & (CAN_TSR_TME0 << box)) == 0u) {
        return 0;
    }
    for (uint32_t i = 0; i < len; i++) {
        if (i < 4u) lo |= (uint32_t)d[i] << (8u * i);
        else        hi |= (uint32_t)d[i] << (8u * (i - 4u));
    }
    CAN1->sTxMailBox[box].TDTR = len;
    CAN1->sTxMailBox[box].TDLR = lo;
    CAN1->sTxMailBox[box].TDHR = hi;
    CAN1->sTxMailBox[box].TIR = (eid << 3) | CAN_TI0R_IDE | CAN_TI0R_TXRQ;
    return 1;
}

static int vesc_set_rpm(uint8_t id, int32_t erpm)
{
    uint8_t b[4] = {(uint8_t)(erpm >> 24), (uint8_t)(erpm >> 16), (uint8_t)(erpm >> 8), (uint8_t)erpm};
    return send_eid(((uint32_t)CAN_PACKET_SET_RPM << 8) | id, b, 4);
}

Приём STATUS_1. Фильтр пропускает только 0x905, но прерывание сверяет идентификатор ещё раз; байты из RDLR и RDHR переставляются в порядок «старшим вперёд»:

void USB_LP_CAN1_RX0_IRQHandler(void)
{
    while (CAN1->RF0R & CAN_RF0R_FMP0) {
        uint32_t rir = CAN1->sFIFOMailBox[0].RIR;
        uint32_t lo = CAN1->sFIFOMailBox[0].RDLR, hi = CAN1->sFIFOMailBox[0].RDHR;
        uint32_t eid = rir >> 3;
        if ((rir & CAN_RI0R_IDE) && eid == (((uint32_t)CAN_PACKET_STATUS << 8) | VESC_ID)) {
            /* байты 0–3 — ERPM, 4–5 — ток × 10, 6–7 — заполнение × 1000, старшим вперёд */
            erpm_now = (int32_t)(((lo & 0xFFu) << 24) | ((lo & 0xFF00u) << 8) | ((lo >> 8) & 0xFF00u) | (lo >> 24));
            current_x10 = (int16_t)(((hi & 0xFFu) << 8) | ((hi >> 8) & 0xFFu));
            duty_x1000 = (int16_t)(((hi >> 8) & 0xFF00u) | (hi >> 24));
            last_status_ms = ms;
            status_seen = 1;
        }
        CAN1->RF0R = CAN_RF0R_RFOM0;
    }
}

Такты и bxCAN. Кварц 8 МГц × 9 = 72 МГц, APB1 = 36 МГц; CAN на PB8/PB9 через ремап; фильтр 0 — маска на все 29 бит идентификатора и бит IDE:

static void clock_72mhz(void)
{
    RCC->CR |= RCC_CR_HSEON;
    while (!(RCC->CR & RCC_CR_HSERDY)) {
    }
    FLASH->ACR = FLASH_ACR_PRFTBE | FLASH_ACR_LATENCY_2;
    RCC->CFGR = RCC_CFGR_PLLSRC | RCC_CFGR_PLLMULL9 | RCC_CFGR_PPRE1_DIV2;
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY)) {
    }
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL) {
    }
}

static void can_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPBEN | RCC_APB2ENR_AFIOEN;
    RCC->APB1ENR |= RCC_APB1ENR_CAN1EN;
    AFIO->MAPR |= AFIO_MAPR_CAN_REMAP_REMAP2;                 /* CAN на PB8/PB9 */
    GPIOB->CRH = (GPIOB->CRH & ~0xFFu) | 0x4u | (0xBu << 4);  /* PB8 вход, PB9 альт. выход 50 МГц */

    CAN1->MCR = CAN_MCR_INRQ | CAN_MCR_ABOM;
    while (!(CAN1->MSR & CAN_MSR_INAK)) {
    }
    CAN1->BTR = ((TS2 - 1u) << 20) | ((TS1 - 1u) << 16) | (BRP - 1u);

    CAN1->FMR |= CAN_FMR_FINIT;
    CAN1->FA1R &= ~1u;
    CAN1->FS1R |= 1u;                                         /* один 32-битный фильтр */
    CAN1->FM1R &= ~1u;                                        /* режим маски */
    CAN1->sFilterRegister[0].FR1 = (((uint32_t)CAN_PACKET_STATUS << 8 | VESC_ID) << 3) | CAN_RI0R_IDE;
    CAN1->sFilterRegister[0].FR2 = (0x1FFFFFFFu << 3) | CAN_RI0R_IDE;   /* ровно 0x905, только 29 бит */
    CAN1->FFA1R &= ~1u;
    CAN1->FA1R |= 1u;
    CAN1->FMR &= ~CAN_FMR_FINIT;

    CAN1->IER = CAN_IER_FMPIE0;
    NVIC_EnableIRQ(USB_LP_CAN1_RX0_IRQn);
    CAN1->MCR &= ~CAN_MCR_INRQ;
    while (CAN1->MSR & CAN_MSR_INAK) {
    }
}

Главный цикл. Раз в 20 мс — SET_RPM; нет статуса дольше 300 мс — 0 ERPM:

int main(void)
{
    uint32_t next = 0;

    clock_72mhz();
    SysTick_Config(72000u);                   /* 1 мс */
    can_init();

    for (;;) {
        if ((int32_t)(ms - next) >= 0) {      /* 50 Гц: команду повторять, иначе VESC отпустит двигатель */
            next = ms + 20u;
            int link_ok = status_seen && (ms - last_status_ms) < 300u;
            (void)vesc_set_rpm(VESC_ID, link_ok ? erpm_set : 0);
        }
    }
}

Старт и таблица векторов. Копия .data, обнуление .bss, вход в main; в таблице — вершина стека, сброс, SysTick (вектор 15) и прерывание приёма CAN (16 + 20):

extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;

void Reset_Handler(void)
{
    uint32_t *src = &_sidata, *dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }
    for (dst = &_sbss; dst < &_ebss;) {
        *dst++ = 0;
    }
    main();
    for (;;) {
    }
}

void Default_Handler(void)
{
    for (;;) {
    }
}

typedef void (*vector_t)(void);

__attribute__((section(".isr_vector"), used))
const vector_t vectors[16 + 21] = {
    [0] = (vector_t)&_estack, [1] = Reset_Handler, [2] = Default_Handler, [3] = Default_Handler,
    [15] = SysTick_Handler, [16 + 20] = USB_LP_CAN1_RX0_IRQHandler,
};

Скрипт компоновщика. 64 КБ флеш-памяти, 20 КБ ОЗУ и 2 КБ под стек:

/* Раздел 57в: STM32F103C8 — 64 КБ флеш-памяти, 20 КБ ОЗУ. */
ENTRY(Reset_Handler)

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

_estack     = ORIGIN(RAM) + LENGTH(RAM);
_stack_size = 0x800;

SECTIONS
{
  .isr_vector : { KEEP(*(.isr_vector)) } > FLASH
  .text :
  {
    *(.text .text.*)
    *(.rodata .rodata.*)
    . = ALIGN(4);
  } > FLASH
  .data :
  {
    _sdata = .;
    *(.data .data.*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH
  _sidata = LOADADDR(.data);
  .bss (NOLOAD) :
  {
    _sbss = .;
    *(.bss .bss.*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM
  ASSERT(_ebss + _stack_size <= _estack, "RAM too small for .data + .bss + stack")
}

Свой кодер и декодер VESC на Python. Команда — идентификатор (команда << 8) | номер и число struct.pack(">i", …); STATUS_1 — struct.unpack(">ihh", …). Кроме import struct, ничего не нужно:

CMD = {"SET_DUTY": (0, 100000), "SET_CURRENT": (1, 1000), "SET_CURRENT_BRAKE": (2, 1000), "SET_RPM": (3, 1), "SET_POS": (4, 1000000)}


def vesc_cmd(name, vesc, value):
    cmd, scale = CMD[name]
    return (cmd << 8) | vesc, struct.pack(">i", int(value * scale))


def vesc_status1(data):
    erpm, cur, duty = struct.unpack(">ihh", data)
    return erpm, cur / 10.0, duty / 1000.0

Кадр управления OpenInverter. CRC — как у блока CRC STM32: полином 0x04C11DB7, начальное значение 0xFFFFFFFF, по 32-битным словам старшим битом вперёд; в кадр идёт младший байт:

def stm32_crc(words):
    crc = 0xFFFFFFFF
    for w in words:
        crc ^= w
        for _ in range(32):
            crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF if crc & 0x80000000 else (crc << 1) & 0xFFFFFFFF
    return crc


def oi_control(pot, pot2, canio, cruise, regen, seq):
    w0 = pot | (pot2 << 12) | (canio << 24) | (seq << 30)
    w1 = cruise | (seq << 14) | (regen << 16)
    w1 |= (stm32_crc([w0, w1]) & 0xFF) << 24
    return w0, w1

Вывод этих функций в прогоне — те же байты и числа, что у прошивок (57в.2, 57в.3, 57в.6):

PY SET_RPM 10000 для 5: 00000305 [4] 00 00 27 10
PY SET_RPM -5000 для 5: 00000305 [4] FF FF EC 78
PY SET_CURRENT 51 для 23: 00000117 [4] 00 00 C7 38
PY SET_DUTY 0.25 для 5: 00000005 [4] 00 00 61 A8
PY SET_CURRENT_BRAKE 10 для 5: 00000205 [4] 00 00 27 10
PY SET_POS 90 для 5: 00000405 [4] 05 5D 4A 80
PY STATUS_1 00 00 30 39 00 E9 01 C8: ERPM 12345, ток 23.3 А, заполнение 0.456
PY STATUS_1 FF FF FC E0 FF B8 FF 9D: ERPM -800, ток -7.2 А, заполнение -0.099
PY CTRL seq 1: 4A5F05DC 571E4000, байты DC 05 5F 4A 00 40 1E 57
PY CTRL seq 2: 8A5F05DC F21E8000, байты DC 05 5F 8A 00 80 1E F2
PY CTRL разбор: pot 1500, pot2 1520, canio 10, ctr1 1, cruise 0, ctr2 1, regen 30, crc 57

57в.8 Грабли

VESC не отвечает и не шлёт статус. Наборы статусов по умолчанию пустые (APPCONF_CAN_STATUS_MSGS_R1/R2 = 0). Включить кадры в настройках или автонастройкой с флагом.

Двигатель дёргается и останавливается. Команда пришла один раз, а через тайм-аут VESC отпустил двигатель. Слать команду непрерывно, например 50 Гц, — так советует и описание VESC.

Шестибайтный SET_CURRENT даёт чудовищный ток. В этой версии comm_can_set_current_off_delay() кладёт ток в байты 0–3, а задержку — в 4–5, а ветка разбора при длине 6 и больше сначала читает задержку из байтов 0–1, потом ток из 2–5. В прогоне «20 А, задержка 0,5 с» (00 00 4E 20 01 F4) превратилось в задержку 0 и ток 1 310 720,5 А; по описанию VESC значение вне предела обрезается до предела — то есть до максимального тока. Слать четырёхбайтный SET_CURRENT; шестибайтный — только после проверки на своей версии прошивки.

Ток в статусе на 0,1 А меньше. Упаковка ×10 отбрасывает дробь: 23,4 А пришло как 23,3, −7,25 А — как −7,2.

Одиннадцатый VESC пропал. Таблица статусов приёмника — 10 мест.

Отрицательный ток OpenInverter стал 6453,6 А. Карта по умолчанию читает биты без знака (CAN_SIGNED = 0). Собирать libopeninv с CAN_SIGNED = 1 или передавать со сдвигом.

358,7 В ушло как 358,6. База хранит значения с шагом 1/32, упаковка отбрасывает дробь.

SDO на 0x2000 читает не тот параметр. Подындекс там — место в списке, а оно разное в вариантах прошивки. Обращаться через 0x21xx по постоянному номеру.

Инвертор перестал слушаться до перезапуска. Пять ошибок счётчика или CRC в кадре управления подряд без хороших кадров — и он больше не принимает управление. Счётчик менять в каждом кадре, оба поля одинаково.

Шина захлёбывается. STATUS_1…4 по 100 Гц от десяти VESC — 114,70 % шины 500 кбит/с. Оставить в быстром наборе только STATUS_1, остальное — в медленный на 5 Гц.


57в.9 Итоги

Дальше: раздел 57г «Klipper: STM32 как исполнитель 3D-принтера — шаги по расписанию от компьютера» — шаговые двигатели 3D-принтера: компьютер считает моменты шагов, STM32 исполняет расписание.

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