Введение
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 пинов от PA0 Port::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 op static 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
