Введение
GPIO-линии — это базовая периферия любого микроконтроллера и первое с чего начинают знакомство с новым МК. Чего может быть проще помигать светодиодом. Библиотеки производителей МК, также как и сторонние, обычно предоставляют функции записи одной линии типа HAL_GPIO_WritePin, digitalWrite, либо же запись всего порта. А часто требуется управлять сразу группой разрозненных GPIO-линий: светодиодная лента, 7-сегментный индикатор, параллельная шина данных, матричная клавиатура и т.д.
С конфигурированием GPIO-линий дела обстоят не лучше, нужно заполнять поля неудобных структур в HAL-подобных библиотеках, и часто конфигурирование по одному пину — громоздко, неудобно, много однообразного copy-paste кода.
В этой статье представлен новый метод работы с GPIO для микроконтроллеров, который сочетает возможность динамического создания групп GPIO линий и хорошую оптимизацию для групп заданных на этапе компиляции, на примере Российского МК К1921ВГ015.
Исторический контекст
Mcucpp (2009): статический подход на C++03
Идея пакетных операций с группами пинов была реализована ещё в 2009 году в библиотеке Mcucpp, изначально для 8-битных AVR, затем расширенной на STM32. Основной ее инструмент — шаблоны C++03 со списками типов (Loki):
typedef PinList<Pb1, Pb0, Pb2, Pc3, Pc4, Pc5> Group1;Group1::SetConfiguration(Group1::Out);Group1::Write(0x55);
Достоинства старого подхода:
-
Полностью статический: нулевая накладная стоимость в рантайме.
-
Оптимизация пакетирования: если пины могут быть сгруппированы в одну shift+mask операцию на порт, они объединяются.
-
Оптимизация совпадения позиций: если битовые позиции пинов во входном значении совпадают с позициями в регистре порта, генерируется прямая запись без маски.
-
Поддержка инвертированных пинов, слайсов, произвольного порядка.
Недостатки:
-
Шаблонный кошмар: глубина вложенности шаблонов 5–7 уровней.
-
Ошибки компиляции плохо читаемы: при опечатке в имени пина компилятор выводит 200 строк шаблонных подстановок.
-
Невозможно создать список пинов в рантайме. Состав группы определяется на этапе компиляции.
Modm (ранее xpcc): идеологический последователь
Библиотека Modm (ранее xpcc) развивает ту же идею — статические списки пинов как «виртуальные порты», но с упрощённой реализацией и генерацией кода. Да, Modm — прямой идеологический последователь, один из его разработчиков связывался со мной, спрашивал не защищена ли идея списков GPIO линий какими либо авторскими правами, и не буду ли я возражать, если они её воспользуются.
using Port = SoftwareGpioPort<GpioA10, GpioA2, GpioA6, GpioA14>;Port::setOutput(Gpio::OutputType::PushPull);uint8_t nibble = Port::read();Port::write(nibble);// Эффективный доступ к строго упорядоченному набору:using Port = GpioPort<GpioA0, 8>; // 8 пинов от PA0Port::write(data);
Modm использует генерацию кода (CMake + Python) для создания заголовков с конкретными регистрами, что упрощает шаблонный слой. Однако подход остаётся статическим: состав группы фиксирован на этапе сборки.
Мотивация нового подхода
Ограничение старого подхода:
-
Только статические списки. В современных устройствах на МК настройки пользователя уже давно обыденность, в том числе настройки подключения внешних устройств. Со статическим подходом это сложно выполнимо, а ресурсы МК, память и быстродействие стали на порядок больше.
-
Потребитель должен быть шаблоном. Статический список линий можно либо использовать напрямую, либо передать использующий его код шаблонным параметром. Это накладывает достаточно много ограничений.
-
Шаблонная магия, нечитаемые ошибки.
Новая концепция
Тезисы
-
Группа GPIO линий представлена как фиксированный массив целочисленных элементов.
-
Каждый элемент этого массива представляет операцию записи под-группы пинов, принадлежащих одному порту.
-
Минимальный размер массива по возможности.
-
Под-группа пинов идущих последовательно должна быть обработана вместе — shift/mask/copy.
-
Если в под-группе последовательных пинов есть прогал — по возможности не разбивать группу на несколько.
-
Подготовка списка операций должна быть constexpr.
-
При использовании constexpr списка пинов, функции записи и чтения должны хорошо встраиваться (inline friendly).
-
Приемлемое время выполнения для динамически заданных списков пинов.
Формат кода операции
Самый простой вариант — это задать список линий как массив uint8_t, где представляет один пин:
Биты: 7 6 5 4 | 3 2 1 0 7 6 5 4 | 3 2 1 0 ┌────────────────┬────────────────┐ ┌────────────────┬────────────────┐ │ PORT (4) │ PIN_NUMBER (4) │ ... │ PORT (4) │ PIN_NUMBER (4) │ └────────────────┴────────────────┘ └────────────────┴────────────────┘
Старшие 4 бита — номер порта, младшие 4 бита — номер пина в порту, индекс элемента в массиве — номер бита во входном значении. Это работает для большинства МК, где портов не более 16 и опять же 16 пинов в порту. Однако, здесь нет никакой оптимизации для упорядоченных или частично упорядоченных списков, каждая операция чтения/записи будет проходить по всему списку, делать группировку по порту и т.д. Такой формат будем использовать в качестве входного для определения списка линий и в качестве базы для сравнения.
Какие значения вообще и какого размера нужны для представления группы упорядоченных пинов?
-
PORT — Порт — без него не обойтись, количество бит зависит от того сколько портов в МК: 2, 3 или 4. У К1921ВГ015 3 порта — достаточно 2 бит.
-
PORT_SHIFT — смещение (стартовый бит) группы в порту, размер зависит разрядности порта для 16 разрядного порта нужно 4 бита.
-
VALUE_SHIFT — смещение (стартовый бит) группы во входном значению. Зависит от размерности списков, которые мы хотим обрабатывать. Для списков из не более 16 линий достаточно 4 бит.
-
Остается определить размер группы.
Можно попробовать закодировать размер группы просто взяв ее длину, тогда для списков не более 16 линий достаточно 4 бит. Все 4 поля помещаются в 16 битное значение. Под порт можно всегда отдавать 4 бита.
Биты: 15 14 13 12 | 11 10 9 8 | 7 6 5 4 | 3 2 1 0 ┌────────────────┬────────────────┬─────────────────┬───────────────┐ │ PORT_SHIFT (4) │ VALUE_SHIFT(4) │ LENGTH (4 бит) │ PORT (4) │ └────────────────┴────────────────┴─────────────────┴───────────────┘
Одна такая закодированная операция может вмещать до 16 подряд идущих линий и представлять собой целый порт. Однако такое кодирование не оптимально обрабатывает разрывы в последовательностях пинов. Например:
PA2, PA3, PA4, PA15, PA6, PA7
Такая последовательность будет закодирована 3 операциями в этом способе кодирования.
PA2, PA3, PA4 -> PORT 0, PORT_SHIFT 2, VALUE_SHIFT 0, LENGTH 3 PA15 -> PORT 0, PORT_SHIFT 15, VALUE_SHIFT 3, LENGTH 1 PA6, PA7 -> PORT 0, PORT_SHIFT 6, VALUE_SHIFT 4, LENGTH 2
Но ведь пины PA2, PA3, PA4, PA6, PA7 можно скопировать одной операцией shift/mask/copy несмотря на прогал, если вместо длины хранить битовую маску:
Биты: 15 14 13 12 | 11 10 9 8 | 7 6 5 4 3 2| 1 0 ┌────────────────┬────────────────┬────────────────────────┬──────────┐ │ PORT_SHIFT (4) │ VALUE_SHIFT(4) │ MASK (6 бит) │ PORT (2) │ └────────────────┴────────────────┴────────────────────────┴──────────┘
Поле для порта уменьшили до достаточных для К1921ВГ015 2 бит, маска получилась 6 бит. Единичное значение в маске означает, что соответствующий бит входного значения начиная с VALUE_SHIFT позиции относится к данной операции. Тогда последовательность пинов выше может быть закодирована как:
PA2, PA3, PA4, PA6, PA7 -> PORT 0, PORT_SHIFT 2, VALUE_SHIFT 0, MASK 0b110111 PA15 -> PORT 0, PORT_SHIFT 15, VALUE_SHIFT 3, MASK 0b000001
Получилось 2 операции вместо 3. Но максимальный размер одной под-группы стал 6 вместо 16. Минимальное количество операций, чтобы закодировать список из 16-ти подряд идущих линий одного порта стало 3 вместо 1. Но зато теперь можно эффективнее обрабатывать группы чередующихся пинов:
PA2, PA8, PA4, PA10, PA6, PA12: -------------------------------- PA2, PA4, PA6 -> PORT 0, PORT_SHIFT 2, VALUE_SHIFT 0, MASK 0b010101 PA8, PA10, PA12 -> PORT 0, PORT_SHIFT 8, VALUE_SHIFT 1, MASK 0b010101
Можно заметить, что во всех операциях младший бит маски получается всегда равен 1. А это значит, что его можно не хранить в явном виде, также как старший бит мантиссы в числах с плавающей точкой. Таким образом максимальный размер под-группы становится BITS(MASK) + 1 = 7. Как еще можно оптимизировать формат хранения операций? Если рассматривать операции не как самостоятельную сущность, а именно как упорядоченную последовательность, от младшего бита входного значения к старшему, то можно избавиться от хранения VALUE_SHIFT. Само значение VALUE_SHIFT всё равно нужно чтобы выделить биты входного значения, но его можно вычислить скомбинировав маски всех предыдущих операций. Количество единичных битов справа будет равно VALUE_SHIFT текущей операции. Для первой операции в списке всегда VALUE_SHIFT=0. Формат операции становится:
Биты: 15:12 11:2 1:0 ┌──────────┬─────────────────────────┬─────┐ │PORT_SHIFT│ MASK │PORT │ │ (4 бита)│ (10 бит) │ (2) │ └──────────┴─────────────────────────┴─────┘
Теперь одна операция может содержать до 11 пинов. Пример вычисления VALUE_SHIFT:
PA0, PA1, PA2, PB2, PC0, PB4, PC2, PB6, PC4: -------------------------------------------- PA0, PA1, PA2 -> PORT 0, PORT_SHIFT 0, MASK 0b00000000111 PB2, PB4, PB6 -> PORT 1, PORT_SHIFT 2, MASK 0b00000010101 PC0, PC2, PC4 -> PORT 2, PORT_SHIFT 0, MASK 0b00000010101 claimed_value_mask = 0 VALUE_SHIFT[0] = std::countr_one(claimed_value_mask) = 0 claimed_value_mask |= MASK[0] << VALUE_SHIFT[0] = 0b00000000111 VALUE_SHIFT[1] = std::countr_one(claimed_value_mask) = 3 claimed_value_mask |= MASK[1] << VALUE_SHIFT[1] = 0b00010101111 VALUE_SHIFT[2] = std::countr_one(claimed_value_mask) = 4 claimed_value_mask |= MASK[2] << VALUE_SHIFT[2] = 0b00111111111 и т.д.
Получилось:
-
«Список пинов» это закодированный массив
uint16_tкодов операций (на этапе компиляции или в рантайме). Каждый код описывает группу из 1–N пинов на одном порту, отображённую на битовый диапазон в входном значении. Размер группы N адаптивен: чем меньше портов у МК, тем больше бит отводится маске и тем крупнее группа. -
Ключевая идея: 11 последовательных пинов на одном порту требуют всего 1 операции (группа из N=11 пинов), а не 11 отдельных записей. Запись в регистр, одна shift/mask/copy-операция на порт.
-
Неявный бит (аналог старшего разряда мантиссы в числах с плавающей точкой) экономит место и позволяет уместить группу на один пин крупнее: Бит 0 маски не хранится, он всегда установлен (базовый пин группы). Физическое поле MASK хранит биты 1…N, декодер восстанавливает бит 0.
-
VALUE_SHIFTгруппы не кодируется в коде операции: операции выводятся в порядке значений, поэтому декодер восстанавливает сдвиг накоплением размеров групп (countr_one восстановленных масок) при обходе списка. Освобождённые биты отданы маске, а группа стала крупнее на 4 пина. Плюс к тому, снимается ограничение на длину списка линий, теперь можно делать до 32-х пинов.
Кстати, уменьшив маску до 2 бит (3 пина на под-группу) можно уместить операцию в 8 бит, если портов не более 4. Но это менее универсально и в среднем хуже по производительности.
Не реализованные идеи:
-
уменьшить маску на 2-3 бита и добавить код операции.
-
обрабатывать обратные последовательности: PA3, PA2, PA1, PA0.
-
пины с шагом отличным от 1: PA0, PA2, PA4.
Правило группировки
Пины объединяются в одну операцию, если:
-
Они на одном порту, и
-
Их позиции в порту последовательны, и
-
Их позиции во входном значении последовательны (разница равна той же для позиций в порту), и
-
Группа помещается в MASK.
API
namespace Mcucpp{ constexpr unsigned PinListMaxPins = 32; // максимум пинов в одном списке using PinListValueT = uint32_t; // упакованное значение: бит i = пин i using OpListRef = std::span<const uint16_t>; // --- Создание (компиляция или рантайм) --- template <uint8_t... Pins> consteval auto make_pinlist(); // из констант P(port, pin) template <class... Pins> consteval auto make_pinlist(); // из типизированных пинов: Pa0, Pb3, … template <size_t NResults> constexpr std::array<uint16_t, NResults> make_pinlist_n(std::span<const uint8_t> pins); // рантайм, фиксированный буфер // --- Операции --- void gpio_configure(OpListRef ops, PinListValueT mask, PortCfg mode, …); void gpio_write(OpListRef ops, PinListValueT value, GpioOpType op = Write); PinListValueT gpio_read(OpListRef ops);}
OpListRef задан как std::span<const uint16_t> невладеющий (non-owning) вид на список операций. Это позволяет одной и той же функции принимать и std::array<uint16_t, N> (результат make_pinlist<>()), и обычный C-массив, и указатель, без каких-либо преобразований на границе вызова. const позволяет принимать constexpr списки созданные во время компиляции.
Входное (логическое) значение списка — PinListValueT (uint32_t), бит i соответствует i-му пину списка. Значение шире типа данных порта (GpioPort::DataT, 16 бит), потому что один список может перекрывать несколько портов: до PinListMaxPins = 32 пинов на вызов. Коды операций при этом остаются 16-битовыми — расширение значения не меняет формат списка, а лишь снимает прежний потолок в 16 пинов. make_pinlist<>() дополнительно проверяет static_assert(sizeof...(Pins) <= PinListMaxPins), поэтому превышение лимита — ошибка компиляции, а не усечение.
make_pinlist<>() возвращает std::array<uint16_t, N>, где N — минимальное количество операций, необходимое для кодирования данного списка пинов (вычисляется на этапе компиляции). Размер массива точно соответствует числу операций, без отступов и лишних слотов.
gpio_write, gpio_read, gpio_configure — все inline. Когда они вызываются с constexpr-списком пинов, компилятор полностью их инлайнит: циклы разворачиваются, все маски вычисляются заранее, и вызов сводится к нескольким регистровым записям.
Пример использования
// Группа: PA0–PA3 (светодиоды) + PB5 (статус)constexpr auto leds = make_pinlist<Pa0, Pa1, Pa2, Pa3, Pb5>();// leds = std::array<uint16_t, 2> = {0x001C, 0x5001}// Настроить первые 4 как выводы, PB5 как вход с подтяжкойgpio_configure(leds, 0x0F, PortCfg::Out);gpio_configure(leds, 0x10, PortCfg::In, PullMode::PullUp);// Управлять 4 светодиодами (бит i = пин i)gpio_write(leds, 0b1010);// Прочитать всю группу (бит i = пин i)uint32_t state = gpio_read(leds);// Инвертировать только светодиоды (PB5 не трогаем)gpio_write(leds, 0x0F, GpioOpType::Toggle);
Инлайнинг с constexpr-списками
Когда список пинов задан на этапе компиляции, компилятор полностью инлайнит gpio_write (и get_port_masks внутри него): циклы разворачиваются, все маски вычисляются заранее. В результате только регистровые записи, без циклов, без вызовов, без стека.
Для наглядности в тесте созданы два отдельных non-inline-функции (с __attribute__((noinline))), которые вызывают gpio_write с constexpr-списками:
static constexpr auto inl_4pins = make_pinlist<Pa0, Pa1, Pa2, Pa3>(); // 1 opstatic constexpr auto inl_12pins = make_pinlist<Pa0, Pa1, Pa2, Pa3, Pb0, Pb1, Pb2, Pb3, Pc0, Pc1, Pc2, Pc3>(); // 3 ops__attribute__((noinline)) void sample_write_4pins(uint16_t value){ gpio_write(inl_4pins, value);}__attribute__((noinline)) void sample_write_12pins(uint16_t value){ gpio_write(inl_12pins, value);}
Результат компиляции (RISC-V, -O3):
sample_write_4pins: 4 последовательных пина, 1 порт, 6 инструкций:
sample_write_4pins: lui a5, 0x28000 # a5 = 0x28000000 (база GPIOA) li a4, 15 # a4 = 0xF (маска порта) and a0, a0, a4 # a0 = value & 0xF sw a4, 12(a5) # GPIOA.DATAOUTCLR = 0xF sw a0, 8(a5) # GPIOA.DATAOUTSET = (value & 0xF) ret
sample_write_12pins: 12 пинов на 3 портах, 16 инструкций:
sample_write_12pins: li a5, 15 # a5 = 0xF (маска, переиспользуется для 3 портов) lui a4, 0x28000 # a4 = база GPIOA sw a5, 12(a4) # GPIOA.DATAOUTCLR = 0xF and a3, a0, a5 # a3 = value & 0xF sw a3, 8(a4) # GPIOA.DATAOUTSET = value & 0xF srai a4, a0, 0x4 # a4 = value >> 4 lui a3, 0x28001 # a3 = база GPIOB sw a5, 12(a3) # GPIOB.DATAOUTCLR = 0xF and a4, a4, a5 # a4 = (value >> 4) & 0xF sw a4, 8(a3) # GPIOB.DATAOUTSET srai a0, a0, 0x8 # a0 = value >> 8 lui a4, 0x28002 # a4 = база GPIOC and a0, a0, a5 # a0 = (value >> 8) & 0xF sw a5, 12(a4) # GPIOC.DATAOUTCLR = 0xF sw a0, 8(a4) # GPIOC.DATAOUTSET ret
Для сравнения: та же операция с рантайм-списком (std::span, non-inline-функция) компилируется в ~70 инструкций с циклом по списку операций, ctz-восстановлением сдвигов и массивом масок на стеке. Разница в том, что constexpr-список позволяет компилятору выкинуть весь цикл и вычислить всё на этапе компиляции.
Динамическое создание списка
// Пины известны только в рантайме (например, из конфигурации)uint8_t pins[] = { P('A', 3), P('A', 4), P('B', 0), P('B', 1) };auto ops = make_pinlist_n<4>(pins);// ops = {0x3004, 0x0005}gpio_configure(ops, 0x0F, PortCfg::Out);gpio_write(ops, 0x0A);
Реализация
Весь модуль находится в одном заголовке dyna_pin_list.h без .cpp: вся логика — inline/constexpr/consteval. Ниже представлены ключевые функции.
Построение списка: find_slot. Ядро построения списка это функция find_slot, которая добавляет один пин в уже частично заполненный список операций. Она обходит слоты по порядку и ищет группу, к которой пин можно присоединить (тот же порт, и позиции пина в порту и во входном значении идут с одинаковым шагом, и группа не превышает максимальный размер группы), либо пустой слот для создания новой группы. При обходе она восстанавливает value_shift каждой уже размещённой группы через countr_one накопленной маски занятых битов значения, именно это позволяет корректно работать с «разреженными» и переплетающимися масками, где простое суммирование размеров групп дало бы неверный сдвиг.
constexpr inline bool find_slot(std::span<uint16_t> slots, unsigned target_port, unsigned new_value_shift, unsigned new_port_shift){ size_t i = 0; uint32_t claimed_value_mask = 0; int group_value_shift = 0; do { uint16_t slot = slots[i]; if (slot == EmptyGpioOp) { slots[i] = (target_port << GpioOpPortFieldPos) | (new_port_shift << GpioOpPortShiftFieldPos); break; } unsigned port = (slot >> GpioOpPortFieldPos) & GpioOpPortMask; if (port == target_port) { int group_port_shift = (slot >> GpioOpPortShiftFieldPos) & GpioOpPortShiftMask; int pdiff = new_port_shift - group_port_shift; int vdiff = (int)new_value_shift - group_value_shift; if (vdiff == pdiff && (unsigned)vdiff < GpioOpMaxGroup) { slots[i] |= 1u << (vdiff - 1 + GpioOpMaskFieldPos); break; } } uint32_t value_mask = (((slot >> GpioOpMaskFieldPos) & GpioOpMaskMask) << 1) | 1u; claimed_value_mask |= (value_mask << group_value_shift); group_value_shift = std::countr_one(claimed_value_mask); i++; if (i >= slots.size()) return false; } while (true); return true;}
Сборка списка: make_pinlist_n. Простой цикл по пинам: для каждого пина вызывается find_slot, а value_shift увеличивается на единицу. Обработка останавливается на первом невалидном порту (порт >= NumGpio) или когда слоты заканчиваются, что даёт усечённый список.
template <size_t NResults>constexpr std::array<uint16_t, NResults> make_pinlist_n(std::span<const uint8_t> pins){ std::array<uint16_t, NResults> result; result.fill(EmptyGpioOp); uint32_t value_shift = 0; for (size_t i = 0; i < pins.size(); i++) { uint32_t port_shift = pins[i] & PinDefPinMask; uint32_t port = pins[i] >> PinDefPortShift; if (port >= NumGpio) break; bool found = find_slot(result, port, value_shift, port_shift); if (!found) break; value_shift++; } return result;}
make_pinlist<>() (consteval) — тонкая обёртка: считает нужное число операций через count_ops, вызывает make_pinlist_n<count> и проверяет по pinlist_pin_count, что список не усечён. Поэтому возвращаемый std::array имеет точный минимальный размер.
Декодирование: get_port_masks. Обратное направление — расшифровка списка операций в маски на порты. Список просматривается один раз; для каждой операции восстанавливается её value_shift (через countr_one накопленной маски), извлекаются соответствующие биты входного значения и накапливаются в 32-битное значение на порт: младшие 16 бит — маска set, старшие 16 маска clear. В STM32 у GPIO портов есть удобный регистр BSRR, который одной записью обнуляет и выставляет нужные биты. В К1921ВГ015 это два отдельных регистра.
inline auto get_port_masks(OpListRef ops, PinListValueT value){ std::array<uint32_t, NumGpio> gpio_clear_set = {}; uint32_t claimed_value_mask = 0; for (uint16_t op : ops) { uint32_t port = (op >> GpioOpPortFieldPos) & GpioOpPortMask; if (port >= NumGpio) continue; // пустой слот (старший код порта = sentinel) uint32_t value_mask = (((op >> GpioOpMaskFieldPos) & GpioOpMaskMask) << 1) | 1u; uint32_t port_shift = (op >> GpioOpPortShiftFieldPos) & GpioOpPortShiftMask; uint32_t value_shift = std::countr_one(claimed_value_mask); claimed_value_mask |= (value_mask << value_shift); uint32_t masked_value = (value >> value_shift) & value_mask; gpio_clear_set[port] |= (masked_value | (value_mask << GpioOpClearMaskShift)) << port_shift; } return gpio_clear_set;}
Запись: gpio_write. Вызывает get_port_masks, затем короткий цикл по портам: затронутые порты (у которых маска не ноль) записываются одной WriteMasked-операцией (clear, затем set).
inline void gpio_write(OpListRef ops, PinListValueT value){ auto port_masks = get_port_masks(ops, value); for (size_t i = 0; i < NumGpio; i++) { if (!port_masks[i]) continue; uint16_t set = port_masks[i] & 0xffff; uint16_t clear = (port_masks[i] >> GpioOpClearMaskShift) & 0xffff; GpioPort(i).WriteMasked(clear, set); }}
gpio_set/gpio_clear/gpio_toggle устроены так же, но вместо WriteMasked вызывают Set/Clear/Toggle (одна маска, одна регистровая запись на порт). gpio_write(ops, value, op_type) — перегруженная версия, которая вызывает на одну из четырёх функций в зависимости от параметра.
Производительность
Стоимость вызова gpio_write():
-
O(NumPorts) итераций цикла (3 для K1921VG015), но реальный доступ к регистрам только для портов, присутствующих в списке операций.
-
O(ops) операций shift-and-mask (обычно 1-4).
-
1 mask-запись в регистр на активный порт (например,
BSRR+BCRдля Write, одинBSRRдля Set). -
Итого O(NumPorts + ops).
Стоимость вызова gpio_read():
-
O(NumPorts) чтений портов, каждый порт читается один раз в небольшой массив.
-
O(ops) операций shift-and-mask, список операций просматривается один раз, извлекая пины из закэшированных значений портов.
-
Итого O(NumPorts + ops).
Измеренная производительность (K1921VG015, 50 МГц)
Замеры выполнены на реальном железе через fast_clock (CSR time, тикает на частоте CPU): 2000 итераций на случай после 32-итерационного прогрева. Контрольная проверка калибровки: delay(1ms) = 50007 цикла (ожидалось ~50000). Фоновая стоимость цикла замера (счётчик/сравнение/переход) = 3 цикла.
Измеряются все шесть операций: Конфиг (gpio_configure), Запись (gpio_write), Set (gpio_set), Clear (gpio_clear), Toggle (gpio_toggle) и Чтение (gpio_read). Конфигурация — самая дорогая операция, т.к. на каждый затронутый порт выполняется несколько регистровых записей (Enable + SetConfiguration + SetPullUp + SetSpeed + AltFuncNumber), в отличие от одной mask-записи при Write.
Все функции принимают список как std::span (рантайм-размер), поэтому constexpr-списки (std::array) и динамические (make_pinlist_n) обрабатываются одними и теми-же функциями.
|
Случай |
Пины |
Операции |
Конфиг (цикл) |
Конфиг (нс) |
Запись (цикл) |
Запись (нс) |
Set (цикл) |
Set (нс) |
Clear (цикл) |
Clear (нс) |
Toggle (цикл) |
Toggle (нс) |
Чтение (цикл) |
Чтение (нс) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
лучший: 4 последов., 1 порт |
4 |
1 |
74 |
1480 |
32 |
640 |
30 |
600 |
30 |
600 |
30 |
600 |
25 |
500 |
|
ср. 1: 9 последов., 1 порт |
9 |
1 |
74 |
1480 |
32 |
640 |
30 |
600 |
30 |
600 |
30 |
600 |
25 |
500 |
|
ср. 2: 12, 3×4 порта |
12 |
3 |
229 |
4580 |
88 |
1760 |
81 |
1620 |
81 |
1620 |
81 |
1620 |
63 |
1260 |
|
худший: 16 разрозненных |
16 |
16 |
502 |
10040 |
361 |
7220 |
367 |
7340 |
367 |
7340 |
367 |
7340 |
310 |
6200 |
Выводы:
-
Конфигурация (
gpio_configure) — самая дорогая операция: от 74 циклов для лучшей группы (1 порт) до 502 циклов для худшей (3 порта, 16 операций). Стоимость растёт с числом затронутых портов, т.к. на каждый порт выполняется несколько регистровых записей (Enable+SetConfiguration+SetPullUp+SetSpeed+AltFuncNumber). -
Запись (
gpio_write) — однопроходный подход: список операций просматривается один раз, маски накапливаются в массив по портам, затем короткий цикл пишет затронутые порты. От 32 циклов (4 пина, 1 порт) до 361 цикла (16 разрозненных). -
Set / Clear / Toggle — чуть дешевле Write (одна маска, одна регистровая запись на порт вместо clear+set): от 30 циклов до 367 цикла.
-
Чтение (
gpio_read) — тоже однопроходное: каждый порт читается один раз в массив, затем список операций просматривается один раз, извлекая пины из закэшированных значений портов. Это O(NumPorts + ops), а не O(NumPorts × ops) — для худшего случая (16 операций на 3 портах) чтение занимает 310 циклов вместо ~1000 при наивном по-портовом обходе, где список перебирается по числу портов. -
Практическое следствие: все операции имеют предсказуемую линейную стоимость по числу операций. Для статических групп используйте
make_pinlist<>(), для динамических —make_pinlist_n<>(); оба результата (std::array/std::span) идут через один и тот же однопроходный путь.
Использование памяти
Список пинов хранится как массив 16-битовых кодов операций: 2 байта на операцию. Размер зависит от порядка пинов, т.к. количество операций определяется степенью слияния. Для сравнения, обычный массив определений пинов uint8_t (P(port, pin)) занимает ровно 1 байт на пин, независимо от порядка.
|
Случай |
Пины |
Операции |
Список (байт) |
Обычный массив (байт) |
Отношение |
|---|---|---|---|---|---|
|
лучший: 4 последов., 1 порт |
4 |
1 |
2 |
4 |
0.5× |
|
лучший: 8 последов., 1 порт |
8 |
1 |
2 |
8 |
0.25× |
|
лучший: 11 последов., 1 порт |
11 |
1 |
2 |
11 |
0.18× |
|
лучший: 16 последов., 1 порт |
16 |
2 |
4 |
16 |
0.25× |
|
ср.: 12, 3×4 порта |
12 |
3 |
6 |
12 |
0.5× |
|
худший: 4 разрозненных |
4 |
4 |
8 |
4 |
2× |
|
худший: 16 разрозненных |
16 |
16 |
32 |
16 |
2× |
Цифры получены из теста test_memory_usage (размеры вычисляются на этапе компиляции и сверяются в рантайме).
Лучший случай (последовательные пины одного порта): пины сливаются в группы по максимум 11, поэтому список из 11 пинов занимает всего 2 байта против 11 байт обычного массива — в 5.5 раза меньше. Даже 16 последовательных пинов (2 операции: 11+5) занимают 4 байта против 16.
Худший случай (разрозненные пины): каждый пин — отдельная операция, поэтому список вдвое больше обычного массива: 16 разрозненных пинов — 32 байта против 16.
Точка безубыточности — когда в среднем на операцию приходится 2 пина (2 байта операции = 2 байта обычных определений). Если пины группируются (средний размер группы > 2), список экономит память; если пины разрозненнее (средний размер группы < 2), список занимает вдвое больше.
Практически: для упорядоченных групп (светодиодные ленты, 7-сегментные индикаторы, параллельные шины) список заметно компактнее обычного массива. Для полностью разрозненных линий список вдвое больше — но это плата за то, что он несёт в себе всю информацию о слиянии и порядке, необходимую для пакетных операций.
Ограничения
-
32-битное значение: один вызов
gpio_write/gpio_readобрабатывает максимум 32 логических пинов (PinListValueT=uint32_t,PinListMaxPins= 32). Для больших групп — несколько списков. -
Макс. N пинов на операцию (N =
GpioOpMaxGroup, адаптивно): поле MASK ограничивает каждую операцию N последовательными пинами. Для K1921VG015 (NumGpio = 3) N = 11, поэтому группа из 16 пинов на одном порту требует 2 операций (11+5). На MK с большим числом портов N уменьшается. -
Только последовательная группировка: Непоследовательные пины (например, PA0 и PA7) не могут быть закодированы в одной операции.
-
Нельзя определять в одном списке один и тот же пин несколько раз. В общем случае поведение не определено, обычно биты значения соответствующие повторяющемуся пину, будут скомбинированы через логическое ИЛИ и записаны в соответствующий разряд порта. Но это поведение не гарантировано.
-
Нет возможности пропустить бит из входного значения и никуда его не записывать. В старой версии Mcucpp для этого был NullPin.
-
Нет возможности инвертировать бит входного значения перед записью. В старой версии Mcucpp для этого был InvertedPin.
Обоснование дизайна
Почему 16-битовые коды операций?
-
8 бит — маловато,
MASKтолько 2 бита приPORTтоже два бита. -
32 бита — избыточно, большой перерасход памяти для неупорядоченных списков.
-
Простота: кодирование — 3 поля (
PORT_SHIFT4 бита + два адаптивных,PORTиMASK). Декодирование — несколько сдвигов, AND и накопление масок. Без таблиц, без массивов переменной длины. -
Совместимость с consteval: всё кодирование вычисляется на этапе компиляции без аллокаций (фиксированный
std::arrayв стеке).
Что взято из старого Mcucpp:
-
Идея пакетирования последовательных пинов в shift+mask операции.
-
Оптимизация: если позиции в значении совпадают с позициями в порту, генерируется прямой write без mask.
-
Концепция «виртуального порта» — группа пинов ведёт себя как единый регистр.
Что нового:
-
Рантайм-создание (
make_pinlist_n). -
Потребитель не является шаблоном — обычный
inlineвызов. -
C++20:
consteval,constexpr,std::span— ошибки компиляции указывают конкретный пин. -
Неявный бит 0 маски экономит место: группа на один пин крупнее при том же 16-битовом коде.
value_shiftне кодируется — восстанавливается накоплением маски, освобождённые биты отданы маске (группа +4 пина). -
Компактный код: один заголовок, ~400 строк, без внешних зависимостей.
-
Фокус на 32-битных платформах (16-битовые порты). Значение списка —
uint32_t(PinListValueT), поэтому один список покрывает до 32 пинов, перекрывая несколько портов; коды операций при этом остаются 16-битовыми.
Референсная реализация
Референсная реализация нацелена на K1921VG015 (RISC-V, 3 × 16-битовых GPIO портов), но спроектирована для переноса на любой MK с абстракцией GpioPort. Модуль — один заголовок (dyna_pin_list.h) без .cpp: вся логика — inline/constexpr/consteval. Тесты в проекте рассчитаны на выполнение на отладочной плате BlueBird: https://gitflic.ru/project/vitalii-nechaev/bluebird-vg015
Ссылка на проект: https://gitflic.ru/project/chizhovka/mcucpp_2 Модуль динамических списков линий: https://gitflic.ru/project/chizhovka/mcucpp_2/blob?file=inc/dyna_pin_list.h&branch=dev Набор тестов для динамических списков линий: https://gitflic.ru/project/chizhovka/mcucpp_2/blob?file=tests/dyna_pin_list/test.cpp&branch=dev
ссылка на оригинал статьи https://habr.com/ru/articles/1104222/