Pull to refresh

Comments 44

Мне во всех реализациях классов gpio для stm не хватает массовых (желательно ещё и ленивых) настроек нескольких портов. Я понимаю, что можно или писать на регистрах, или делать по одному, благо перенастройка портов явление редкое, но мой внутренний перфекционист ждёт этого решения.

Сам брался писать его, но пока всё придумать элегантно решение не хватает знания плюсов. В общем, планировал сделать это так: метод класса порта при настройке возвращает временный объект, эти объекты присоеденяются друг к другу а вся работа по настройке происходит в их деструкторе.

IO(A,12)->cfg(OutPP) <<
IO(A,13)->cfg(OutPP) <<
IO(A,15)->cfg(OutPP);

Пока не знаю как это сделать гарантированно compile-time с выводом ошибки в случае невозможности разрешения.

Всегда было интересно: что мешает сделать все настройки в before compile time обычным скриптом (perl,python,lua,php,js,groovy,...), который сгенерирует из входного запроса оптимальный код инициализации, необходимые константы, картинки и документацию. И затем с чистой совестью вызывать инициализацию периферии. Зачем это всё пихать в compile-time, при этом требуя компилятор с поддержкой последних стандартов? Не ужели стоя и в гамаке предпочтительнее.

Поддерживал я как-то проект с таким подходом. Очень сложно при отладке и при мажорных изменениях.

Это есть у Чижова, список типов, инстанцированный нужным набором портов. При разработке форка его mcucpp я тоже такое добавил, чтобы при настройке PinList-а одной строкой все используемые порты настроить (+ добавил уникальность). Вот так примерно выглядит:
template<typename... _Ports>
class PortList<TemplateUtils::TypeList<_Ports...> >
{
public:
	static void Enable()
	{
		(_Ports::Enable(), ...);
	}

	static void Disable()
	{
		(_Ports::Disable(), ...);
	}
};

Тут _Ports понимается, как список пинов уже? или отдельный пин?

My bad, не сразу понял, что Goron_Dekar имел ввиду. Перечитал и осознал, что идея именно для списка пинов применять настройки. Мой пример делает это для списка портов, но и для пинов есть соответствующий функционал, который заключается в получении для списка пинов списка портов (причем уникального).
Получаю список портов так:
using UsedPorts = typename Unique<TypeList<typename _Pins::Port...>>::type;

Ну, собственно, этот список портов уже применим для предложенного выше кода.

Прошу прощения, тут ниже ветка с вашим упоминанием:

Недавно я общался с @DSarovsky , он делает библиотеку, похожую по идеологии, но базирующуюся на более современных стандартах C++. Я там смотрел GPIO и USB

подскажите пожалуйста, а все желающие могут посмотреть код? интерес скорее академический.

Конечно, буду более чем рад. Можно зайти в любую из моих статей, везде внизу есть ссылка на github (здесь не буду оставлять, думаю, это не очень красиво будет, как реклама).

а слона ссылку то я и не заметил, спасибо)

не самое удачное решение, так как:

  • нет автоподсказок по параметрам при использовании макроса, что выливается в необходимости поиска места написания макроса каждый раз, когда через полгода приходится возвращаться к коду. Настройка пинов да и в целом периферии это 5%, ну максимум 10% , от объема кода проекта. Поэтому настройка должна быть простой и с подсказками;

  • макросы это иногда хорошо, если код на чистом Си. Тут же обсуждали С++ и шаблоны, что немного безопасней, так как есть проверка кода на этапе компиляции. Тут же ошибка проявится только после компиляции, и повезет если сразу станет понятно где она;

  • "BIN(1010101010101010)" - блондинка в красном платье?

Лучше тогда смотреть: http://easyelectronics.ru/rabota-s-portami-vvoda-vyvoda-mikrokontrollerov-na-si.html

// Например, для списка пинов
typedef PinList<Pa1, Pa2, Pa3, Pb3, Pb4> MyPins;
MyPins::Write(0x55);

Выше как раз упоминали автора сего кода, Константина Чижова. Статья образцовая, так как последовательная и достаточно детальная.

Спасибо за пояснения!
Но для простолюдинов непонятны все эти высокие материи, соответственно никто не пользуется.

Вот еще статья про использование шаблонов и описаний из SVD - Безопасный доступ к полям регистров на С++ без ущерба эффективности (на примере CortexM)

BIN() - функция для препроцессора по конвертации последовательности бит в байты/слова.

BIN() - функция для препроцессора по конвертации последовательности бит в байты/слова

это знаю. проблема в том, что в последовательности 0 и 1 очень легко ошибся и весьма не просто сказать какой пин стоит в каком состоянии. Поэтому видя "010010010101" попробуй угадать какой пин в какое состояние встал. особенно если для одних stm32 00 - это режим работы на вход, а для других 00 - режим работы с аналоговым сигналом.

Но для простолюдинов непонятны все эти высокие материи, соответственно никто не пользуется.

с вами сейчас готова поспорить миллионная армия пользователей arduino. Они пишут на языке, близком по синтаксису к С++. При этом большая часть не задумывается и, наверно, не понимает как это работает. Просто берет и пользует.

Вопрос в другом, что сами вендоры не стремятся внедрять код на С++ к себе. У тех же ST явно хватит ресурсов сделать HAL на шаблонах (учитывая что touchGFX который они купили, как раз плюсовый код и дает на выходе). но почему это не идет в массы, я не понимаю.

Спасибо за пояснения!

да не за что, это просто мнение основанное на опыте чтения своих же проектов спустя 2-3 года после закрытия)

p.s.

"BIN(1010101010101010)" - блондинка в красном платье

это отсылка на фильм матрица. где оператор в бинарном коде на экране видел людей.

Тоже задумывлася над чем то похожим. На данный момент пришел к следующему решению. Еще мыслю над тем, как оптимизировать.

https://github.com/hwswdevelop/MemoryMappedRegAccess

На мой взгляд, получилось достаточно удобное описание регистров в виде структур. Плюс, возможность писать как битовые поля по отдельности, так и регистр целиком. Единственное, пока не знаю, как сделать чтобы обращения были более красивыми. Удобно то, что изменив одно поле в конфигурации, можно, например добавить барьерные инструкции.

А зачем здесь все эти constexpr, какой в них смысл? По-моему, они здесь не нужны вообще. Этот класс и без них скомпилируется в 2-3 инструкции.

И зачем такое количество reinterpret_cast, почему бы сразу не объявить константу нужного типа?

Добрый день!

Возможно, я не совсем правильно понял вопрос. Дело в том, что любая константа будет занимать место в памяти, не важно, будет ли это оперативная память или сегмент ".text", который, как правило, располагается во flash памяти микроконтроллера. Если скомпилировать данный код без оптимизации, т.е. с опцией "-O0", то, он реально скомпилируется в 2-3 ассемблерных инструкции, чего нельзя сказать об обычном классе. Касательно, "-Os" или "-O3", вероятно разница будет небольшой. Если Вы можете показать как это сделать другим способом, я с удовольсивием приму Ваши предложения. Можно поэксперементировать с различными online компиляторами для проверки результата.

Насчёт того, почему постоянно вызывается reinterpret_cast, понял - его нельзя использовать в compile-time, поэтому нельзя объявить constexpr-указатель (не нашёл сходу, как это обойти).

Насчёт static const - непонятно, зачем вы вообще компилируете без оптимизации.

И ещё небольшое замечание - в методе mode() условие if constexpr (GpioPinNo < GpioConfPerReg){ можно заменить на static_assert(GpioPinNo < GpioConfPerReg)

любая константа будет занимать место в памяти

Если нет ODR-использования константы (как и любой другой переменной), то компилятор постарается её выкинуть. Если он не может избавиться от её значения - то да, придётся сохранить в сегменте .text.

 зачем такое количество reinterpret_cast

Для того чтобы использовать структуру, определенную в хедере на контроллер.

Тут либо делать каст, либо как написал @juramehanik ниже воспользоваться парсером и получить уже готовые структуры для работы с периферией.

Для начала определим адреса портов.

ох не так надо людей завлекать плюсами в микроконтроллерах=)
Что мешает подключать cmsis и быть более кросплатформенным, ну хотя бы не использовать магические числа?
Есть готовые плюсовые проекты которые парсят SVD файлы камней (и не только одного конкретного производителя) и на основе этого создают классы работы с периферией, почему бы там не применить свои идеи, чтоб не строить весь этот велосипед с нуля, а добавить к существующему свою блестящую звездочку? (здесь точно про это статьи две было, но пока не нашел с наскоку).

Доброе утро.

Я и не пытаюсь кого либо завлекать плюсами (C++) таким образом. Изначально, я написал прототип загрузчика для STM32, который работает по USB, и позволяет практически полноценно отлаживать код в отладчике среды разработки. Это вещь не кросплатформенная, от слова совсем. Затем понял что получилась неплохая альтернатива Arduino, но еще и с отладкой. Многие привыкли использовать эту среду разработки, но... Ее библиотеки мне не нравились ввиду неоптимальности. Ничего не имею против, но хотелось сделать это более оптимально. Так пришел к разработке собственной недорогой платформы для обучения программированию МК. (Откровенно говоря, даже 9 летнего племянника научил ей пользоваться). В итоге, решил доработать загрузчик, и написать базовый набор библиотек, которые будут с одной стороны просты для использования детьми, а с другой максимально оптимизированы вплоть до уровня ассемблерных инструкций.

Кстати да. Мне нравится, как сделано в Zephyr - стандартный DTS на этапе компиляции превращается в вызовы CMSIS, соответствующего объявленному камню. И переход с STM32, например, на Nordic происходит максимально безболезненно.

Можно не делать constexpr const объекты, они не нужны.

using PA0 = GpioPin<GPIOA_BASE, 2> PA2;;
PAO::set();

Далее пины можно опять в шаблон запихать и управлять и настраивать их пачками, также без издержек, все в одну ассемблерную команду переведется.

Вообще тема норм, я что то подобное писал тут:

,https://habr.com/ru/post/459204/

И тут

https://habr.com/ru/post/473612/

https://habr.com/ru/post/474732/

Спасибо. Полностью согласен с использованием "using", но мне хотелось получить Arduino-like код, и при этом оптимизированный с точки зрения машинных инструкций. Не хочется на начальном этапе объяснять детям чем отличается "::" от "->" или ".". За ссылки еще раз спасибо, обязательно посмотрю.

Касательно настройки пачками, думал, как это правилно сделать. Обязательно воспользуюсь способом. Спасибо.

Дополню, что в той статье скорее рассказывается про GPIO и группы контактов, но по факту, эта библиотека связывает эти самые GPIO с классами прочих устройств (таймеров, UART, SPI, I2C...). Потому что в наше время линии этих устройств могут быть выведены на произвольные ноги. И вот GPIO этой библиотеки прекрасно всё связывает. Плюс драйверы устройств написаны весьма и весьма компактно. В общем, mcupp охватывает так много всего, что я сам ею пользуюсь, и информирую о её мощи при любом удобном случае. Так что после прочтения статьи по ссылке, надо понимать, что статья показывает только часть возможностей.

Недавно я общался с @DSarovsky , он делает библиотеку, похожую по идеологии, но базирующуюся на более современных стандартах C++. Я там смотрел GPIO и USB. GPIO реализовано в соответствии с заветами товарища Чижова, а USB у Чижова нет, не сравнить. Формирование дескрипторов - замечательное и простое. Работа с конечными точками - понятная. Скорость - предельно возможная, за счёт использования двойного буфера. Про драйвера других устройств пока не скажу, не довелось изучить в бою.

 эта библиотека связывает эти самые GPIO с классами прочих устройств

а вот связывания действительно не хватает. Прокидывание GPIO в шаблон того же I2C/SPI/USART описано, да и самому написать не сложно. А вот например, привязку DMA канала через шаблон с проверкой, что этот канал не был настроен раньше, я пока не встречал. Сам пробую сделать, но пока особо не получается.

 Я там смотрел GPIO и USB

я б тоже глянул с целью повышения образованности

Я как-то вот так недавно написал:

//
enum class PinFunct : uint32_t
{
  // 0x03(11) - Резерв. ODR - 1/0 Подтяжка верх/вниз.
  AnalogInput = 0x00UL, FloatInput = 0x01UL << 2, PullInput = 0x02UL << 2,
  PushPull = 0x00UL << 2, OpenDrain = 0x01UL << 2,
  AF_PushPull = 0x02UL << 2, AF_OpenDrain = 0x03UL << 2,
};

enum class PinMode : uint32_t
{
    Input = 0x00UL,
    Out2MHz = 0x02UL, Out10Mhz = 0x01UL, Out50Mhz = 0x03UL,
};
//
inline uint32_t operator|(PinFunct funct, PinMode mode)
{
    return (uint32_t)funct | (uint32_t)mode;
}
//
template<PinFunct Funct, PinMode Mode>
class TPin : public TReg
{
    private:
        TPin() = delete;
protected:
    GPIO_TypeDef &amp;Gpio;
    uint8_t Pin;

public:
    TPin(GPIO_TypeDef &amp;gpio, uint8_t pin)
        : Gpio(gpio), Pin(pin)
    {
        // ASSERT(Pin &lt;= 15);
        // ASSERT(apb2enr(Gpio) != 0);

        if((Rcc.APB2ENR &amp; apb2enr(Gpio)) == 0)
        {
            Rcc.APB2ENR |= apb2enr(Gpio);
        }

        if(Pin &lt; 8)
        {
            SetCRL();
        }
        else
        {
            SetCRH();
        }
    }

    void __attribute__((always_inline))
    On()
    {
        Gpio.BSRR = Mask();
    }

    void __attribute__((always_inline))
    Off()
    {
        Gpio.BRR = Mask();
    }

    bool __attribute__((always_inline))
    IsOn()
    {
        return Gpio.IDR &amp; Mask();
    }

    void SetCRL(PinFunct funct, PinMode mode)
    {
        Gpio.CRL &amp;= ~(0x0FUL &lt;&lt; Pin * 4);
        Gpio.CRL |= (Funct | Mode) &lt;&lt; Pin * 4;
    }
    void SetCRH(PinFunct funct, PinMode mode)
    {
        Gpio.CRH &amp;= ~(0x0FUL &lt;&lt; (Pin - 8) * 4);
        Gpio.CRH |= (funct | mode) &lt;&lt; (Pin - 8) * 4;
    }

//
protected:
    constexpr uint32_t __attribute__((always_inline))
    Mask()
    {
        return 0x01UL &lt;&lt; Pin;
    }

//
private:
    constexpr uint32_t apb2enr(GPIO_TypeDef &amp;gpio) const
    {
        if(&amp;Gpio == &amp;GpioA)
        {
            return RCC_APB2ENR_IOPAEN;
        }
        else if(&amp;Gpio == &amp;GpioB)
        {
            return RCC_APB2ENR_IOPBEN;
        }
        else if(&amp;Gpio == &amp;GpioC)
        {
            return RCC_APB2ENR_IOPCEN;
        }
        else
        {
            return 0;
        }
    }

    void SetCRL()
    {
        Gpio.CRL &amp;= ~(0x0FUL &lt;&lt; Pin * 4);
        Gpio.CRL |= (Funct | Mode) &lt;&lt; Pin * 4;
    }
    void SetCRH()
    {
        Gpio.CRH &amp;= ~(0x0FUL &lt;&lt; (Pin - 8) * 4);
        Gpio.CRH |= (Funct | Mode) &lt;&lt; (Pin - 8) * 4;
    }

};

//
inline TPin<PinFunct::, PinMode::Out2MHz> TestPA15pin(GpioA, 15);

Про массовую настройку выводов у меня есть статья: https://habr.com/ru/post/448288/

static inline void mode(const GpioMode mode, const GpioOutputSpeed oSpeed = GpioOutputSpeed::Input){

if constexpr (GpioPinNo < GpioConfPerReg)

Попробуй static_assert, чтобы не писать "if else и в else ошибка"

Доброе утро!

Касательно применения static_assert, полностью согласен. Но на самом деле это даже не срвсем вопрос отображения ошибок времени компиляции. Это просто пример как можно сделать и как проверить. На самом деле, если я правильно помню, в коныигурационеый регистр помещаются настройки первых 8ми пинов, а вторые 8 пинов 16и битного порта должны конфигурироваться с использованием другого регистра, по этому в коде даже не ошибка должна генерироваться, а использоваться другой регистр.

За комментарий еще раз спасибо.

Автор, вы меня заинтриговали! Каким образом инструкции dmb, dsb и isb могут заменить необходимость использования volatile?

Они не могут заменить. Просто часто, на собеседованиях, человеку который знает что: "

  1. Компилятор в процессе оптимизации делает реордеринг инструкций.

  2. В процессе выполнения инструкции конвейерезируются, иногда приостанавливаются, иногда ускорябтся с применением байпасов (не ожидая окнчания предыдущей инструкции).

  3. Еще к тому же и выполняются не в том порядке. А для ожидания применяются барьерные инструкции (кода, данных) "

    задают вопросы про volatile и непременно хотят услышать тупой книжный ответ про оптимизацию. А ответ своими словами не принимается, что говорит о некомпетентности собеседующего.

Я даже могу назвать одну из компаний в СПБ, где посчитали что у меня недостаточно опыта из за таких вот вопросов. Или еще была ситуация, когда мне задали вопрос о том, сколько можно помледовательно поставить операционных усилителей для того чтобы не было самовозбуждения. Я понял что нажо мной решили постебаться, и спокойно сказал: "главное чтобы полюсов не было", хотя мог бы оиветить "главное чтобы положительная обратная связь была недостаточна или чтобы не соблюдался юаланс фаз и амплитуд". Полагаю не все поняли о каких поюсах шла речь. А я имел ввиду расчет схемы с использованием преобразования Лапласа, когда возникает понятие нулей и полюсов, что определят устойчивость схемы....

Небольшое замечание - constexpr объекты автоматически будут const, так что нет смысла писать для них `constexpr const`. А методы, объявленные в теле класса, автоматически inline, так что тоже нет необходимости это указывать ещё раз.

@EvgenySbl, Касательно dsb и прочих барьеров, все у них хорошо, если не считать того, что в некоторых случаях можно получить пенальти по тактам. Т.е. процессор будет сидеть и педалить в ожидании завершения транзакций на шине AXI/AHB/whatever через которую он соединен с внешним миром.

В некоторых случаях это может быть критично, особенно при ногодрыге или быстром обмене, когда у тебя Chip select на spi, например с ручным приводом и ты общаешься на частотах 5-10 МГц и выше. Что приведет к задержке включения CS например или отключения его. Как следствие страстная любовь с трудноотлавливаемыми глюками.

Как по мне, каждому решению свое место должно быть. Сие есть мое IMHO :-)

С одной стороны да, с другой стороны нет. В некоторых случаях это как раз помогает. Предположим что мы в ассемблерном коде подготовим две инструкции записи в порт заранее. А затем пропишем две str или им подобные инструкции последовательно. Есть ли гарантия что они выполнятся именно в том порядке? Во первых процессор может их зареордерить. Во вторых нужно учесть является ли данная память cachable, если да, то какой тип кеша и как это отразить на записи данных. Во третьих возникнет вопрос того что реально попадет на gpio порт и будет ли раельно сформирован glitch. В третих скорость gpio порта может быть, а как правило так и есть, значительно ниже скорости процесора....

Безусловно. Мой посыл был в том, что нужно понимать когда и для чего барьер следует использовать.

Volatile дает гарантии строгого порядка для volatile объектов, т.е. если дважды что-то записать в BSRR, то генерируемые инструкции будут идти в том же порядке, хотя между ними компилятор может вставить обращение к не volatile объектам. А если у нас есть две инструкции записи в порт, то никакого реордеринга не будет, кортексы так просто не умеют, уж точно не для портов висящих на одной шине, потому DSB тут не нужна, она только тормозов добавит, а у F1 и так порты медленные.

Спасибо за дополнение. Об этом собственно и речь, что нужно понимать что ты делаешь и зачем ты это делаешь. )))

На самом деле в психологии есть два хороших правила.

  1. Единственный способ выиграть спор,- это не спорить.

  2. Если Вы (я) ошиблись, сразу признавайте свою ошибку.

Хорошо, пррнято. А что если сначала записать odr, а потом прочитать idr? Транзакция на шине успеет пройти или конвейерезированная инструкция прочитает предыдущее значение? Это не спор, а попытка показать что все зависит от реализации в данном случае.

А реализация от чего зависит? Мы же с регистрами работаем, тип памяти у нас Device и никакого кеширования, строгий порядок записи и чтения гарантируется уже только этим. Есть же периферия которая требует установки одного бита, а следом другого в том же регистре. Не вместе и не в обратном порядке и все это работает без никаких барьеров. А чтение из IDR вообще зависит от многих факторов, если есть быстрый мк, но медленные порты или задан не самый подходящий режим, типа OpenDrain с подтяжкой, то после записи в ODR придется вставлять задержку просто потому, что уровень сигнала на выходе нарастает слишком медленно и одного DSB может не хватить. Toggle() довольно часто пытаются так делать, читают IDR, инвертируют биты и пишут в ODR... Твой invert(), кстати, тоже проблемный, т.к. не атомарный, второй invert() над тем же портом в прерывании может все поломать.

ОК. Еще один психологический прием, полезный руководителям и не только. Стоит выждать пока собеседник остынет и появится возможность возобновить общение. Это все из Карнеги. Но если почитать Э.Берна, то сечас Вы, (заметте, я написал с большой буквы, и не "ты", а "Вы"), играете в игру "Спор". А я не собираюсь спорить, спасибо. Мы похоже уже несколько ушли от обсуждения основной темы, и близки к тому чтобы перейти на личности ).

Если говорить по теме, то .... Конечно есть переферия и конечно работает, но, не всегда. Кто-то программирует простые системы, кто-то системы более сложные. И в сложных системах очень многое зависит от настроек MMU/MPU, от использования кэша. Если работает в одном случае, это не означает что будет работать в другом.

Касательно toggle, то, тут Вы безусловно правы. Правда я не использую IDR в качестве базовой величины, в моем случае это ODR, что вполне логично (ODR = ORD xor Bitmask).

А дальше вопрос процессора, компилятора и уровня оптимизации. К сожалению , в системе команд thumb/tumb2, насколько я помню, не предусмотрены битовые операции с ячейкой памяти, а соответственно операция инвертирования бита порта происходит посредством <load, invert, store> c применением регистра общего назначения, и именно по этому не является атомарной. Если бы stm32, имел регистр аналогичный BSRR, BRRR, но с названием BERR (где E - EOR), вероятно операция была бы атомарной.

PA1.reset();     // Первое обращение идет с подготовкой. Загрузка адреса и битовой маски 2 инструкции + 1 запись в порт + dsb.
  ldr r3, .L2    // Грузим адрес порта
  eor r2, r2, #2     // Битовая маска PIN1 r2 = 0;  r2 |= 0x02
  str r2, [r3, #12]  // Запись в порт (сброс бита).
  dsb                // Барьерная инструкция

PA1.set();       // Далее одна ассемблерная инструкция + dsb
  str r2, [r3, #16]  // Запись в порт (установка бита)
  dsb;               // Барьерная инструкция

PA1.reset();     // Далее одна ассемблерная инструкция + dsb
  str r2, [r3, #20]  // Запись в порт (сброс бита)
  dsb;               // Барьерная инструкция

PA1.invert();    // Три ассемблерных инструкции + dsb
  ldr r2, [r3, #12] // Читаем ODR
  eor r2, r2, #2    // Делаем OR
  str r2, [r3, #12] // Записываем в порт
  dsb               // Барьерная инструкция

PA1.invert(); // Три ассемблерных инструкции + dsb
  ldr r2, [r3, #12]
  eor r2, r2, #2
  str r2, [r3, #12]
  dsb

Если говорить о прерываниях, то смотря о чем речь. Насколько я помню, NVIC, повторно не позволит войти в прерывание с тем же приоритетом. А использование того же пина порта, при обработке в другом приерывании без выхода из текущего будет являться логической ошибкой программирования. Если же такая необходимость сущетвует можно защитить операцию инвертирования с применением запрета вызова прервыаний совсем или только некоторых прерываний. Для этих целей можно использовать, как флаг в регистре процессора, так и basepri, или же запрет прерывания на уровне переферии.

Касательно (PullUP / PullDown c режимами OpenDrain) и PushPull, в целом, это немного из другой оперы. Сигнал на линии не имеет прямого отношения к теме касающейся барьерных инструкций. За комментарий спасибо. "Если хочешь научиться играть, то всегда выбирай сильного противника/союзника",- это про Вас.

Никто в здравом уме не станет для региона памяти в котором расположены регистры периферии менять тип памяти с Device на Normal и т.д., иначе вообще ничего работать не будет, потому рассуждать о подобным гипотетических ситуациях не имеет смысла.

И я говорил не про один и тот же пин порта, а про разные пины одного порта, один можно тоглить в главном цикле, а второй в прерывании и будет глючить. Даже HAL_GPIO_TogglePin() раньше работал чисто с ODR, а теперь тоже переделали через BSRR.

Sign up to leave a comment.

Articles