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 по умолчанию — 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 Итоги
- VESC: идентификатор 29 бит =
(команда << 8) | номер, 255 — всем; SET_RPM 10000 для VESC 5 —0x305 00 00 27 10. - Множители: заполнение ×100 000, ток ×1000, обороты ×1, угол ×1 000 000; в STATUS_1 — ток ×10, заполнение ×1000, дробь отбрасывается.
- Статусы по умолчанию выключены; частоты наборов — 50 и 5 Гц; приёмник помнит 10 контроллеров.
- Длинная команда: до 6 байт — 1 кадр, 512 байт — 81 кадр с CRC16.
- OpenInverter: параметры с шагом 1/32, карта — до 10 сообщений и 50 строк, SDO на 0x601/0x581, кадр управления на 0x3F с CRC и счётчиком.
- Тайм-ауты: VESC — 1000 мс в коде (0,5 с в описании), OpenInverter — 500 мс; команды слать непрерывно.
- 10 VESC со статусами 1–4 по 50 Гц — 62,30 % шины 500 кбит/с.
- Пульт на F103 — 1252 байта флеш-памяти.
Дальше: раздел 57г «Klipper: STM32 как исполнитель 3D-принтера — шаги по расписанию от компьютера» — шаговые двигатели 3D-принтера: компьютер считает моменты шагов, STM32 исполняет расписание.