Расширяем OpenPLC с CAN bus

от автора

Эта статья является продолжением серии для демо-проекта на базе OpenPLC. В предыдущей части были рассмотрены программирование Raspberry Pi Pico W в режиме Modbus RTU/TCP Slave, управление реле через Ladder-диаграмму. Теперь пришло время добавить в схему CAN-шину.
Что добавляется в этой части: узел Pico W получает модуль MCP2515 и подключается к CAN-шине. Для этого в OpenPLC Editor написан пользовательский функциональный блок, который работает поверх ардуино-библиотеки и предоставляет доступ к CAN-шине из обычной Ladder-программы через глобальные переменные-маркеры %MW.

Схема подключения

Для подключения MCP2515 к Raspberry Pi Pico W важно учитывать, что Pico работает с логическим уровнем 3.3V, в то время как многие модули MCP2515 рассчитаны на 5V. В статье Can bus на Orange pi 4 pro упоминается о том, что can-трансивер должен быть совместим по уровням напряжения, и что можно его заменить на модуле.

У Pico есть два аппаратных контроллера SPI. Чаще всего используют SPI0. Ниже приведена стандартная распиновка, которая обычно используется в проектах OpenPLC и Arduino IDE для этой связки:

MCP2515 Pin

Pico Pin (GPIO)

Pico Pin (Physical)

Примечание

VCC

3V3 или 5V

Pin 36 или 40

GND

GND

Любой GND (напр. Pin 38)

Общая земля

CS

GP17

Pin 22

Chip Select (можно менять)

SO (MISO)

GP16

Pin 21

SPI0 RX

SI (MOSI)

GP19

Pin 25

SPI0 TX

SCK

GP18

Pin 24

SPI0 SCK

INT

GP20

Pin 26

Прерывание

Чтобы не загромождать изображение, питание модулей на схеме не показано.
Все CAN-модули подключены к одной CAN-шине, на схеме это изображено в виде соединения на полярных шинах макетной платы. Ранее используемые Modbus-модули на схеме также не показаны. Слева внизу желтый круг — двигатель PMSM с платой управления (описан в в предыдущей статье).
Вверху справа находится Raspberry pi (см. шилд для Raspberry pi), а вверху слева — Raspberry Pi Pico W с двумя реле.

Структура C++ функционального блока

OpenPLC Editor позволяет создавать функциональные блоки на C++. Это описано в документации. Каждый такой блок обязан содержать ровно две функции, как в Arduino IDE:

void setup() {    // Вызывается один раз - в первом scan-цикле.    // Инициализация железа, установка начальных значений выходов.}void loop() {    // Вызывается каждый scan-цикл, начиная со второго.    // Основная логика: чтение входов, обработка, запись выходов.}

IDE проверяет наличие обеих функций при сборке — если одна отсутствует, компиляция завершится с ошибкой.

Как работает доступ к переменным

Переменные, объявленные в таблице блока (Inputs/Outputs/Internals), не передаются явными аргументами. Генератор кода создаёт структуру с указателями и макросы-псевдонимы, благодаря чему в теле блока можно писать:

OUTPUT_VAL = INPUT_VAL * 2.0;

Под капотом это разворачивается в (vars->OUTPUT_VAL) = (vars->INPUT_VAL) * 2.0. Управлять указателями вручную не нужно — но важно помнить, что vars доступен только внутри setup()/loop(), что накладывает ограничения при использовании callback-функций (подробнее — ниже).

Типы IEC → C

IEC тип

C тип

Размер

BOOL

uint8_t (IEC_BOOL)

8 бит

INT

int16_t (IEC_INT)

16 бит

UINT

uint16_t (IEC_UINT)

16 бит

DINT

int32_t (IEC_DINT)

32 бит

REAL

float (IEC_REAL)

32 бит

LREAL

double (IEC_LREAL)

64 бит

WORD

uint16_t (IEC_WORD)

16 бит

Замечание: IEC_BOOL — это uint8_t, а не C++ bool. Сравнивать следует с 0, а не с 1.

Состояние между циклами

Глобальные переменные C++ внутри файла блока сохраняют своё значение между scan-циклами — это стандартный способ хранить накопленное состояние (таймеры, флаги, буферы).

При сборке под Arduino-совместимую платформу доступен полный Arduino API через

Создание функционального блока

В OpenPLC editor v4 в стандартной библиотеке в Arduino function blocks содержатся блоки: ARDUINOCAN_CONF, ARDUINOCAN_WRITE, ARDUINOCAN_WRITE_WORD, ARDUINOCAN_READ.
Но по правде говоря, я не смог найти документацию или примеры того, как правильно использовать этот функционал. На форуме обсуждается, что Arduino создали для себя эти библиотеки, и что там содержится баг, а также есть смысл реализовать свой блок.

Поэтому было принято решение создать функциональный блок на C/C++.
Для этого в дереве проекта необходимо нажать на кнопку с «Плюсом», выбрать Functional Block, в поле POU name выбрать C/C++. Название блока в поле POU name введем CAN_INTERFACE.

Интерфейс функционального блока

Имя переменной

Тип

Тип данных

Описание

Пример значения

ID_FILTER

Вход

INT

CAN ID входящих сообщений, которые блок принимает

300 (0x12C)

ID_STATE

Вход

INT

CAN ID исходящих сообщений, рассылаемых блоком

310 (0x136)

STATE_PERIOD

Вход

INT

Период отправки ID_STATE-сообщений, мс

500

INPUT_ADDRESS

Вход

INT

Индекс первого %MW, чьё значение попадает в TX-фрейм

2%MW2

NUM_OF_INPUT

Вход

INT

Количество %MW-слов, последовательно упакованных в TX-фрейм (ID_STATE)

2

OUTPUT_ADDRESS

Вход

INT

Индекс первого %MW, в который записывается payload принятого RX-фрейма

0%MW0

NUM_OF_OUTPUT

Вход

INT

Количество %MW-слов, извлекаемых из RX-фрейма (ID_FILTER)

2

ERR

Выход

BOOL

TRUE, если инициализация CAN-интерфейса завершилась ошибкой

FALSE

Все входные и выходные переменные вносятся в таблицу в верхней части редактора блока. А логика реализуется в редакторе кода

Архитектура среды: OpenPLC + matiec + Arduino SDK

OpenPLC Editor 4 использует компилятор matiec (реализация стандарта IEC 61131-3), который транслирует программу на языках ST/LD/FBD в C-код. Для платформы Raspberry Pi Pico W этот C-код оборачивается в Arduino Sketch и компилируется цепочкой arm-none-eabi-gcc.

Этапы компиляции выглядят следующим образом:

OpenPLC Editor    │    ▼[matiec compiler]    │  IEC 61131-3 (ST/LD) → C-код    ▼ build/Raspberry Pico W/src/    ├── POUS.c / Config0.c / Res0.c   ← скомпилированная логика PLC    ├── glueVars.c                     ← int_memory[], bool_input[][], etc.    └── LOCATED_VARIABLES.h           ← объявления %MW, %MX, %IX, %QX    │    ▼[code generator: xml2st / jinja2]    │  генерирует обёртки для C++ блоков    ▼ build/Raspberry Pico W/examples/Baremetal/   ← sketch-папка (Arduino CLI компилирует все .cpp/.c/.h из неё)    ├── Baremetal.ino       ← точка входа sketch    ├── c_blocks_code.cpp   ← typedef IEC_UINT, структуры VARS, макросы + код из .cpp function block    ├── arduino_libs.h      ← условные #include для системных модулей (DS18B20, MQTT и т.д.)    └── ModbusSlave.cpp     ← и другие модули рядом    │    ▼[Arduino CLI + pqt-gcc arm-none-eabi]    │  компилирует весь sketch в .elf / .uf2    ▼ прошивка для Pico W

Ключевой артефакт генератора — файл c_blocks_code.cpp, в котором для каждого C++ блока:

  1. Объявляется структура CAN_INTERFACE_VARS с указателями на все переменные таблицы

  2. Генерируются макросы-псевдонимы (#define ID_FILTER (*(vars->ID_FILTER)))

  3. Объявляются функции can_interface_setup(CAN_INTERFACE_VARS*) и can_interface_loop(CAN_INTERFACE_VARS*)

  4. После всех объявлений — вставляется исходный код из .cpp файла функционального блока

Это означает, что setup() / loop() из .cpp файла на самом деле компилируются как can_interface_setup / can_interface_loop — обычные функции, вызываемые из основного цикла PLC.
Глобальные переменные типа ID_FILTER в коде — это макросы, раскрывающиеся в разыменование указателя через vars. Именно поэтому использование их вне setup/loop (например, внутри callback) вызывает ошибку компиляции: vars там недоступен.

Почему INPUT_ADDRESS и OUTPUT_ADDRESS — просто числа, а не указатели IEC

В стандарте IEC 61131-3 косвенная адресация и указатели формально существуют. Однако в OpenPLC Editor они не реализованы — об этом прямо сообщают разработчики на официальном форуме: передать адрес переменной через VAR_INPUT типа POINTER TO или использовать ADR() в Ladder/ST-программе нельзя.

Из-за этого ограничения архитектура блока выглядит так:

  • INPUT_ADDRESS и OUTPUT_ADDRESS — обычные целые числа (INT), которые задаются константой в свойствах блока прямо в OpenPLC Editor

  • Косвенная адресация реализована на стороне C++: по числу-индексу находим нужный указатель в массиве int_memory[] и разыменовываем его

// Вместо гипотетического (нереализованного в OpenPLC)://   VAR_INPUT addr : POINTER TO WORD; END_VAR//   addr^ := received_value;// Реальная реализация через C++:int idx = OUTPUT_ADDRESS + i;          // OUTPUT_ADDRESS - просто INT из таблицыif (idx < MAX_MEMORY_WORDS && int_memory[idx])    *int_memory[idx] = value;          // косвенная адресация через указатель C

Такой подход позволяет обойти ограничение среды и получить полноценную косвенную адресацию — но только внутри C++ кода функционального блока.

Почему int_memory[], а не accessor-функция

В runtime OpenPLC (glueVars.c) все %MW-переменные хранятся в глобальном массиве указателей:

// glueVars.c - генерируется автоматическиIEC_UINT *int_memory[MAX_MEMORY_WORD];  // MAX_MEMORY_WORD = 20 для Pico W

int_memory[n] — указатель на физическую ячейку %MWn. Запись в *int_memory[n] мгновенно обновляет значение в PLC-образе, делая его доступным для Ladder-диаграммы и Modbus.

Никакой функции get_uint_located_variable() в runtime не существует — это была ошибка в документации. Правильный способ доступа — extern IEC_UINT *int_memory[] и прямая работа с указателями.

Важное ограничение: массив имеет размер 20 (индексы 0–19). Использование адресов %MW20 и выше невозможно через int_memory[] — индекс выходит за границы массива и блокируется проверкой. Переменные PLC должны находиться в диапазоне %MW0%MW19.

Логика блока CAN_INTERFACE

Источники примеров исходного кода

В среду Arduino IDE устанавливается библиотека arduino-CAN. Фактически можно использовать любые совместимые библиотеки, и на ее основе писать свою логику.
После чего будут доступны примеры кода в меню Examples -> CAN

Исходный код блока будет основываться на примерах: CANSender, CANReceiverCallback. Решено использовать пример с чтением данных при их поступлении, то есть в коллбэк-функции, теоретически должны экономится ресурсы на обработку CAN-сообщений. Но проверка данных в каждом цикле также работает, это пример CANReceiver.

Инициализация (setup)

void setup() {    CAN.setPins(CAN_CS_PIN, CAN_INT_PIN);    CAN.setClockFrequency(8E6);    if (!CAN.begin(500E3)) {        ERR = true;        return;    }    // Hardware ID filter: only frames with exactly ID_FILTER pass to onReceive.    // mask 0x7FF = all 11 standard-ID bits must match.    CAN.filter(ID_FILTER, 0x7FF);    CAN.onReceive(onReceive);}

В инициализации блока прописана частота кварцевого резонатора в модуле. В моем случае — это 8МГц CAN.setClockFrequency(8E6);
Задана скорость обмена данными CAN-шины 500 кбит/сек — CAN.begin(500E3)
И здесь же установлены SPI-пины: CS и INT — CAN.setPins(CAN_CS_PIN, CAN_INT_PIN); адреса которых определены выше

const int CAN_CS_PIN  = 17;const int CAN_INT_PIN = 20;

_Если модуль тактируется другой частотой, скорость обмена другая, или используются другие пины CS и INT, то необходимо внести изменения в коде.

Затем устанавливается аппаратный фильтр по CAN ID: CAN.filter(ID_FILTER, 0x7FF). Маска 0x7FF означает, что все 11 бит стандартного ID должны совпадать точно. Фильтр программируется непосредственно в чип MCP2515: фреймы с чужим ID отбрасываются.

В последней строчке инициализации устанавливается коллбэк onReceive обработки входящих CAN-сообщений CAN.onReceive(onReceive);

Исходный код блока CAN_INTERFACE полностью можно посмотреть по ссылке.

Приём сообщения (ISR — onReceive)

Callback вызывается аппаратным прерыванием по фронту INT. Проверка ID не нужна — аппаратный фильтр CAN.filter() уже гарантирует, что вызывается только нужный ID. Отбрасываются только RTR-фреймы (запросы данных, без payload) и пустые фреймы. Байты полезного фрейма пишутся в rxbuf и выставляется флаг rxready = true. Больше callback ничего не делает — никакого обращения к переменным блока, вся обработка передана в loop().

Основной цикл (loop — каждый scan-цикл)

Каждый scan-цикл loop() проверяет флаг rxready. Если callback сохранил новый фрейм: копирует rxbuf в локальный буфер, сбрасывает флаг и декодирует bytes как uint16_t слова (little-endian) — пары байт в целые числа — и записывает их в int_memory[OUTPUT_ADDRESS + i]. Вне зависимости от RX, каждые STATE_PERIOD мс читает int_memory[INPUT_ADDRESS + i] и отправляет TX-фрейм на ID_STATE.

Ограничение: CAN 2.0 передаёт максимум 8 байт = 4 слова uint16_t в одном фрейме. Значения NUM_OF_INPUT и NUM_OF_OUTPUT обрезаются до 4 — лишние слова молча игнорируются, дополнительных фреймов не формируется. Блок не поддерживает несколько экземпляров: статические переменные (_rx_buf, rxready) и вызовы CAN.begin()/CAN.filter()/CAN.onReceive() глобальны — второй экземпляр перезапишет состояние первого. Для передачи более 4 слов потребуется доработка блока

Почему запись в int_memory[] происходит в loop(), а не в onReceive()

На первый взгляд логичнее было бы писать данные прямо в callback — зачем промежуточный буфер? Но перенести этот код в onReceive() невозможно из-за архитектуры генератора кода OpenPLC.

Напомним, что все переменные таблицы функционального блока (OUTPUT_ADDRESS, NUM_OF_OUTPUT и т.д.) в скомпилированном файле c_blocks_code.cpp — это макросы:

#define OUTPUT_ADDRESS  (*(vars->OUTPUT_ADDRESS))#define NUM_OF_OUTPUT   (*(vars->NUM_OF_OUTPUT))

Указатель vars передаётся как аргумент только в функции can_interface_setup и can_interface_loop. Callback onReceive — обычная глобальная функция без аргументов, vars в её области видимости не существует.

Попытка использовать OUTPUT_ADDRESS внутри onReceive() даёт ошибку компиляции:

error: 'vars' was not declared in this scope    #define OUTPUT_ADDRESS (*(vars->OUTPUT_ADDRESS))

Именно с этой ошибкой столкнулся при попытке использовать ID_FILTER напрямую в callback — и решил её кэшированием значения в статическую переменную idfilter_cached в setup().

Для OUTPUT_ADDRESS и NUM_OF_OUTPUT кэширование тоже возможно, но текущая архитектура с буфером rxbuf + флагом rxready решает обе проблемы сразу: callback остаётся минимальным (только чтение с SPI-шины), а вся логика адресации выполняется в loop(), где vars доступен.

Формат CAN-сообщений

Блок работает с двумя CAN ID:

ID

Направление

Назначение

ID_FILTER (по умолч. 0x12C)

RX

команды от внешнего устройства → запись в %MW

ID_STATE (по умолч. 0x136)

TX

состояние PLC → широковещательная рассылка

Формат payload — little-endian uint16_t слова:

Байты: [lo0] [hi0] [lo1] [hi1] ...         └─── %MW{OUTPUT_ADDRESS} ───┘  └─── %MW{OUTPUT_ADDRESS+1} ───┘

Пример для OUTPUT_ADDRESS=3, NUM_OF_OUTPUT=2:

cansend can0 12C#01 00 00 00   →  %MW3 = 0x0001,  %MW4 = 0x0000cansend can0 12C#03 00 02 00   →  %MW3 = 0x0003,  %MW4 = 0x0002

Потокобезопасность callback на RP2040

Callback onReceive вызывается из контекста прерывания. Применено двойное буферирование: ISR пишет в rxbuf и выставляет флаг rxready, а loop() копирует буфер и сбрасывает флаг. Запись в int_memory[] происходит только в loop() — вне ISR.

// В loop(): копирование буфера и сброс флагаuint8_t buf[8];int len = _rx_len;for (int i = 0; i < len; i++) buf[i] = _rx_buf[i];_rx_ready = false;// ... декодирование и запись в int_memory[] ...

Нужен ли noInterrupts()

Теоретически — да: ISR может вклиниться между отдельными load-инструкциями копирования и перезаписать rxbuf новыми данными, что даст смесь старого и нового пакета в одном буфере.
На практике для данного применения это не проявляется.

Вспомогательные блоки: BOOL_ON_WORD и WORD_ON_BOOL

CAN_INTERFACE работает исключительно с %MW-словами (16-битными целыми). Однако реальная логика ПЛК оперирует отдельными булевыми сигналами: состояниями реле, дискретными входами, флагами. Чтобы связать мир битов с миром слов, созданы два вспомогательных блока на языке ST. Идею таких блоков подсмотрел здесь.

BOOL_ON_WORD — упаковывает 16 булевых входов (B_IN0B_IN15) в одно слово WORD_OUT, устанавливая соответствующий бит для каждого TRUE-входа:

B_IN0 → бит 0 (0x0001)B_IN1 → бит 1 (0x0002)...B_IN15 → бит 15 (0x8000)

Применение: перед отправкой TX-фрейма через CAN_INTERFACE можно записать в %MW{INPUT_ADDRESS} упакованное слово состояний реле, флагов цикла и т.д. — и удалённый узел получит сразу 16 дискретных сигналов в одном CAN-фрейме.

WORD_ON_BOOL — обратная операция: распаковывает WORD_IN в 16 булевых выходов (B_OUT0B_OUT15), проверяя каждый бит через AND:

бит 0 → B_OUT0бит 1 → B_OUT1...бит 15 → B_OUT15

Применение: после приёма RX-фрейма через CAN_INTERFACE можно взять %MW{OUTPUT_ADDRESS} и распаковать командное слово — каждый бит становится отдельным булевым сигналом, который можно подключить напрямую к катушке реле или условию в Ladder-диаграмме.

Таким образом, один CAN-фрейм (8 байт = 4 слова) способен одновременно передавать до 64 дискретных сигналов в каждую сторону, если все 4 слова задействованы как битовые поля через эти блоки.

Применение блока CAN_INTERFACE в программе

В программе main блок используется для двусторонней связи с удалённым узлом по CAN-шине: приём командных слов и рассылка текущего состояния реле К1, К2.

Переменные, связанные с CAN

Переменная

Адрес

Тип

Назначение

CMD_WORD

%MW0

WORD

RX: принятое командное слово

CYRCLE_STATE

%MW1

WORD

Текущий шаг автоматического цикла

CAN_STATE

%MW2

WORD

TX: упакованное состояние ПЛК

CAN_ERR

BOOL

Ошибка инициализации CAN

Настройка блока CAN_INTERFACE0

В программе создан один экземпляр блока — CAN_INTERFACE0:

  • OUTPUT_ADDRESS = 0 → принятый CAN-фрейм пишется в %MW0 (CMD_WORD)

  • NUM_OF_OUTPUT = 1 → один uint16_t из payload

  • INPUT_ADDRESS = 1 → TX-фрейм читает %MW1 (CYRCLE_STATE) и %MW2 (CAN_STATE)

  • NUM_OF_INPUT = 2 → два слова в исходящем фрейме

  • ID_FILTER = 300 (0x12C) → CAN ID входящих RX-сообщений, транслируемых в CMD_WORD

  • ID_STATE = 310 (0x136) → CAN ID исходящих TX-сообщений, содержащих состояние реле

Поток данных

RX (приём команд):

Удалённый узел отправляет фрейм на 0x12C ID. Блок записывает первые 2 байта payload в CMD_WORD (%MW0). Последующие ранги интерпретируют значение командного слова:

Значение CMD_WORD

Действие

1

Включить K1

2

Включить K2

3

Выключить K1

4

Выключить K2

5

Запустить автоматический цикл

После обработки CMD_WORD сбрасывается в 0 каждый scan-цикл, чтобы команда не выполнялась повторно.

TX (рассылка состояния):

Каждые STATE_PERIOD мс блок читает CAN_STATE (%MW2) и отправляет его на 0x136 ID. Слово CAN_STATE формируется блоком BOOL_ON_WORD0, который упаковывает текущие дискретные сигналы в биты:

Бит

Сигнал

Описание

0

K1

Состояние реле K1

1

K2

Состояние реле K2

2

CYCLE_RUN_SET

Автоматический цикл активен

3–15

Зарезервировано

Таким образом удалённый узел знает актуальное состояние реле и цикла — блок рассылает его периодически сам.

Взаимодействие блоков в программе

BOOL_ON_WORD0  B_IN0 ← K1  B_IN1 ← K2  B_IN2 ← CYCLE_RUN_SET      │      ▼ WORD_OUT  CYRCLE_STATE (%MW1)  CAN_STATE (%MW2)      │      ▼ INPUT_ADDRESS=2CAN_INTERFACE0 ──TX──► ID_STATE (каждые 500 мс)      │      ▼ OUTPUT_ADDRESS=0  CMD_WORD (%MW0)      │      ▼  EQ=1 → SET K1  EQ=2 → SET K2  EQ=3 → RST K1  EQ=4 → RST K2  EQ=5 → CYCLE_RUN_SET

Тестирование CAN_INTERFACE

На видео показано, как происходит передача команд и данных между Raspberry pico и конвертором CANable V1.0 Nano.

Итог

В статье был реализован CAN-интерфейс для OpenPLC на Raspberry Pi Pico W:

  • собрана схема подключения MCP2515 по SPI0 с соблюдением уровней 3.3V

  • написан функциональный блок CAN_INTERFACE на Arduino C++, который проецирует CAN-шину на глобальной памятью %MW (приём команд и трансляция состояния)

  • созданы вспомогательные блоки BOOL_ON_WORD и WORD_ON_BOOL для упаковки дискретных сигналов в битовые поля CAN-фрейма

  • весь блок интегрирован в Ladder-программу управления реле и автоматическим циклом

Исходный код проекта доступен на GitHub.

Что можно улучшить:

  • Добавить поддержку CAN FD (через MCP2518FD) — до 64 байт в фрейме вместо 8

  • Реализовать передачу большего количества слов через сегментацию (CANopen-подобный протокол)

  • Перевести обработку сообщений на Core1 RP2040, освободив Core0 для логики ПЛК

ссылка на оригинал статьи https://habr.com/ru/articles/1028418/