Эта статья является продолжением серии для демо-проекта на базе 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 тип |
Размер |
|---|---|---|
|
|
|
8 бит |
|
|
|
16 бит |
|
|
|
16 бит |
|
|
|
32 бит |
|
|
|
32 бит |
|
|
|
64 бит |
|
|
|
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.

Интерфейс функционального блока
|
Имя переменной |
Тип |
Тип данных |
Описание |
Пример значения |
|---|---|---|---|---|
|
|
Вход |
|
CAN ID входящих сообщений, которые блок принимает |
|
|
|
Вход |
|
CAN ID исходящих сообщений, рассылаемых блоком |
|
|
|
Вход |
|
Период отправки |
|
|
|
Вход |
|
Индекс первого |
|
|
|
Вход |
|
Количество |
|
|
|
Вход |
|
Индекс первого |
|
|
|
Вход |
|
Количество |
|
|
|
Выход |
|
|
|
Все входные и выходные переменные вносятся в таблицу в верхней части редактора блока. А логика реализуется в редакторе кода

Архитектура среды: 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++ блока:
-
Объявляется структура
CAN_INTERFACE_VARSс указателями на все переменные таблицы -
Генерируются макросы-псевдонимы (
#define ID_FILTER (*(vars->ID_FILTER))) -
Объявляются функции
can_interface_setup(CAN_INTERFACE_VARS*)иcan_interface_loop(CAN_INTERFACE_VARS*) -
После всех объявлений — вставляется исходный код из
.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 |
Направление |
Назначение |
|---|---|---|
|
|
RX |
команды от внешнего устройства → запись в |
|
|
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_IN0…B_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_OUT0…B_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
|
Переменная |
Адрес |
Тип |
Назначение |
|---|---|---|---|
|
|
|
|
RX: принятое командное слово |
|
|
|
|
Текущий шаг автоматического цикла |
|
|
|
|
TX: упакованное состояние ПЛК |
|
|
— |
|
Ошибка инициализации 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). Последующие ранги интерпретируют значение командного слова:
|
Значение |
Действие |
|---|---|
|
|
Включить K1 |
|
|
Включить K2 |
|
|
Выключить K1 |
|
|
Выключить K2 |
|
|
Запустить автоматический цикл |
После обработки CMD_WORD сбрасывается в 0 каждый scan-цикл, чтобы команда не выполнялась повторно.
TX (рассылка состояния):
Каждые STATE_PERIOD мс блок читает CAN_STATE (%MW2) и отправляет его на 0x136 ID. Слово CAN_STATE формируется блоком BOOL_ON_WORD0, который упаковывает текущие дискретные сигналы в биты:

|
Бит |
Сигнал |
Описание |
|---|---|---|
|
0 |
|
Состояние реле K1 |
|
1 |
|
Состояние реле K2 |
|
2 |
|
Автоматический цикл активен |
|
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/