Многоядерная обработка повышает производительность и энергоэффективность во многих сценариях программирования. Алгоритмы для систем без ОС дополнительно усиливают эти преимущества.

Многие разработчики встроенного ПО, возможно, еще не пробовали многоядерную обработку. Между тем, использование процессоров с более чем одним ядром может значительно упростить архитектуру и кодирование. И что, возможно, вас удивит, настроить и использовать многоядерные процессоры очень просто.
Как правило, многоядерные программы используются в системах с ОС, но если вы похожи на меня, то мои проекты, как правило, реализуются на «пустых» процессорах. Я давно использую многоядерные процессоры под управлением RTOS, но исторически избегал многоядерных систем без ОС. Однако с тех пор, как я обнаружил, насколько просто использовать многоядерные процессоры в системах без ОС, они стали стандартной основой для моих проектов.
Давайте посмотрим, как это реализуется. Приведенные ниже примеры и рассуждения основаны на использовании двухъядерного микроконтроллера RP2040 с кодом, разработанным в среде Arduino IDE. Отладочные платы на базе RP2040 можно приобрести примерно за 5 долларов США. Кроме того, хотя речь здесь пойдет о двухъядерной конфигурации, при переходе к процессорам с бóльшим количеством ядер будут применяться те же самые принципы.
Так почему же я не использовал многоядерные архитектуры раньше? У меня были некоторые опасения, что возникнут трудности, к которым я не буду готов. Вот некоторые из них:
- Как обеспечить изоляцию кода каждого ядра
- Как запустить несколько ядер
- Как ядра взаимодействуют друг с другом (т. е., как передавать данные между ядрами)
- Какие периферийные устройства может использовать каждое ядро; нужно ли их, например, временно присоединять или регистрировать
Оказывается, на самом деле все эти проблемы решаются очень легко. Давайте рассмотрим их по порядку.
Во-первых, как разделить код для каждого ядра? В одноядерной программе на C для Arduino есть два основных раздела: раздел настройки (который может начинаться так: void setup()) и раздел цикла (который может начинаться так: void loop()). Если вы используете два ядра, первое ядро, (ядро 0) будет использовать эти разделы точно так же, как и в одноядерной системе.
В коде второго ядра (ядро 1) должна быть функция, определяющая его основной цикл. Назовем ее core1_main. Затем в разделе настройки ядра 0 добавьте строку multicore_launch_core1(core1_main);. Эта строка запустит функцию core1_main на втором ядре. (Примечание: я считаю, что гораздо удобнее разместить код ядра 1 в отдельной вкладке в IDE Arduino). В отличие от основного цикла в программе Arduino на C, код ядра 1 необходимо заключить в цикл while(1);. Еще один элемент, который необходимо включить, – это строка #include "pico/multicore.h" в начале кода.
Имейте в виду, что существуют и другие подходы к коду настройки во втором ядре. Они включают методы, позволяющие ядру 1 использовать собственную функцию настройки. Используйте свой любимый ИИ или другой исследовательский инструмент, чтобы найти другие методы реализации кодов настройки и исполнения во втором ядре.
Вот очень простой пример, где каждое ядро мигает своим собственным светодиодом:
#include
#include "pico/multicore.h"
// -----------------------------
// Код ядра 1
// -----------------------------
void core1_main() {
pinMode(14, OUTPUT);
while (1) {
digitalWrite(14, HIGH);
delay(500);
digitalWrite(14, LOW);
delay(500);
}
}
// -----------------------------
// Код ядра 0
// -----------------------------
void setup() {
pinMode(15, OUTPUT);
// Запуск ядра 1
multicore_launch_core1(core1_main);
}
void loop() {
digitalWrite(15, HIGH);
delay(300);
digitalWrite(15, LOW);
delay(300);
}
Этот пример дает представление о том, как заставить два ядра выполнять свои собственные задачи. Однако, как правило, требуется обеспечить какой-либо обмен данными между ядрами. Сделать это очень просто, используя переменную, которую каждое ядро может видеть и изменять. Оказывается, все пространство памяти микроконтроллера может быть просмотрено и изменено любым ядром. Таким образом, если определить глобальную переменную в начале кода (сразу после операторов #include), ее можно использовать для передачи данных между ядрами.
Убедитесь, что переменная помечена как volatile, поскольку ее значение может измениться в любой момент. Также помните, что RP2040 – это 32-разрядный микроконтроллер, и операции чтения/записи 64-разрядных значений не являются атомарными, поэтому следует проявлять осторожность, чтобы не считывать передаваемое 64-разрядное значение до того, как будут переданы обе его половины. Здесь может помочь простой флаг. Этот метод использования общей памяти для передачи данных прост, но может быть опасен, если не проявлять осторожность, – подобно использованию глобальных переменных, – однако разработчики систем без ОС обычно ценят такой жесткий контроль над ресурсами.
Этот способ передачи данных хорошо подходит для простых задач, однако для работы с большими объемами данных и устранения некоторых проблем с синхронизацией целесообразнее использовать FIFO. Написать это несложно, к тому же в Интернете можно найти уже готовые программные пакеты. Для более сложных программ можно рассмотреть использование таких механизмов, как почтовые ящики, семафоры, флаги и т. д., информации о которых много в различных источниках… впрочем, здесь мы уже переходим к функционалу RTOS.
Теперь давайте рассмотрим вопрос совместного использования периферийных устройств двумя ядрами. В нашей архитектуре без ОС лучшее объяснение заключается в том, что любое ядро может использовать любое периферийное устройство в любое время. Эта ситуация одновременно и хорошая, и плохая. Хорошая, потому что не нужно устанавливать флаги, выполнять проверки и согласовывать параметры; просто используйте периферийное устройство. Плохая в том смысле, что без какой-либо координации два ядра могут одновременно попытаться настроить одно и то же периферийное устройство в разных конфигурациях.
Работая над своими проектами, я пришел к выводу, что полезно разделять код между двумя ядрами таким образом, чтобы каждое ядро всегда использовало периферийные устройства, которые не задействованы другим ядром. Если же в ваших проектах это условие не выполняется, возможно, вам стоит реализовать механизм блокировки ресурсов с использованием флагов. С этим связан интересный факт: оба ядра могут использовать последовательный порт (настроенный только одним ядром) без необходимости в каком-либо квитировании или использовании флагов. Однако обратите внимание, что данные последовательной связи будут чередоваться. Тем не менее, я считаю это очень удобным, поскольку во время отладки могу использовать функцию Serial.print() с любого из ядер.
Давайте ответим на последний вопрос: зачем использовать более одного ядра? Первая причина очевидна: вы получаете больше вычислительной мощности. Но, помимо этого, разделение задач может сделать написание кода более простым и понятным. Это объясняется тем, что не возникает проблем с конкуренцией за ресурсы процессора между несколькими задачами, особенно в случае задач, чувствительных к времени. Кроме того, если вы используете несколько прерываний, распределение этих задач между ядрами позволяет избежать сложностей, связанных с одновременным возникновением прерываний и, как следствие, блокировкой одного или другого. Еще одним преимуществом является возможность более быстрого реагирования на происходящие события, поскольку фактически можно отслеживать и обрабатывать в два раза больше событий.
Вот еще один пример кода, в котором используются некоторые из обсуждавшихся ранее концепций. В этом коде ядро 1 следит за последовательным портом в поисках символов G или R. При обнаружении символа G он устанавливает общую переменную led_color в значение 1. Ядро 0 постоянно отслеживает значение led_color и включает зеленый светодиод, если значение led_color равно 1. Аналогично, если ядро 1 увидит символ R, оно изменяет значение led_color на 0, после чего ядро 0 включает красный светодиод:
#include
#include "pico/multicore.h"
// ----------------------------
// Назначение выводов, управляющих светодиодами
// ----------------------------
#define RED_LED_PIN 14
#define GREEN_LED_PIN 15
// ----------------------------
// Переменные, общие для обоих ядер
// 0 = RED (КРАСНЫЙ), 1 = GREEN (ЗЕЛЕНЫЙ)
// ----------------------------
volatile int led_color = 0;
// ======================================================
// Ядро 1: Монитор порта
// ======================================================
void core1_entry() {
while (!Serial) { delay(10); }
while (1) {
if (Serial.available() > 0) {
char c = Serial.read();
if (c == 'G' || c == 'g') {
led_color = 1;
Serial.println("Set LED = GREEN");
}
else if (c == 'R' || c == 'r') {
led_color = 0;
Serial.println("Set LED = RED");
}
}
delay(2);
}
}
// ======================================================
// Настройка ядра 0
// ======================================================
void setup() {
Serial.begin(115200);
pinMode(RED_LED_PIN, OUTPUT);
pinMode(GREEN_LED_PIN, OUTPUT);
// Запуск ядра 1
multicore_launch_core1(core1_entry);
}
// ===========================================================
// Цикл ядра 0 – Логика управления светодиодами теперь здесь
// ===========================================================
void loop() {
if (led_color == 1) {
digitalWrite(GREEN_LED_PIN, HIGH);
digitalWrite(RED_LED_PIN, LOW);
}
else {
digitalWrite(RED_LED_PIN, HIGH);
digitalWrite(GREEN_LED_PIN, LOW);
}
delay(5);
}
Возможно, теперь вам становится понятнее, в чем заключаются преимущества использования нескольких ядер. Представьте себе более сложную задачу, например, программу, которая отслеживает изменения настроек последовательного порта, а также считывает данные с высокоскоростного АЦП с жесткими требованиями к джиттеру. Выполнение кода управления последовательным портом на одном ядре, а кода для работы с АЦП – на другом значительно упростит реализацию этой комбинации.
Попробуйте разработать многоядерный код! Это несложно, и я уверен, что вы найдете ему множество применений, а также обнаружите, что он делает программирование более простым и организованным.
P.S. Оба фрагмента кода, приведенные в этой статье, изначально были написаны чат-ботом CoPilot в соответствии с указаниями автора. Впоследствии автор внес лишь незначительные изменения.


