Comments 64
В чем смысл, Бэрримор?!
Одноразовый вызов функции или "две строчки", кроме повышенной когнитивной нагрузки как на писателя, так и на будущего читателя — разницы нет.
Так ради чего?
Как человек, недавно писавший bootloader, скажу, что тянуть STM32 HAL в загрузчик — форменное самоубийство. Я в итоге конфигурацию пинов свел последовательности записей в регистры.
Кстати да, очень жаль, что у ST есть утилита генерации кода, но она не умеет в гиперминималистичный формат "лишь бы заработало"
У STM32F030C8T6 размер страницы — 1 килобайт и 64к всего.
Так что 5 кило HAL это овердохрена для загрузчика. Я влез в работу с I2C EEPROM, флешкой и tinyAES в 4к и там еще осталось место.
Ну и зачем спрашивается экономить?
Вообще моя претензия к HAL не в большом объеме кода, что он занимает(хотя и это тоже), а в чудовищно-отвратительном стиле работы с периферией, когда от тебя ее как бы прячут, но все равно заставляют лезть под капот, чтобы понять, как с ней работать.
Ну и дескрипторы периферии отдельный прикол, когда они порядком занимают в оперативке, но все то же самое вычитывается из регистров статуса
Переносимость путем лишения (порой очень нужного) функционала. К примеру прием по DMA, когда мы заранее не знаем объем пакета, который придет — без HAL это сделать куда проще.
Ну и о какой переносимости кода работы с Flash можно говорить, когда у разных контроллеров разный размер страницы — насколько я помню, HAL так далеко не прячет периферию.
Я сам пользуюсь HAL (когда другого нет), но это не тот опыт, которым я наслаждаюсь. STM32-LLL другое дело, ты знаешь как работает периферия и просто указываешь ей, что надо сделать
В итоге, на тот момент остался совершенно нечитаемый вариант, где половина через HAL, а половина через регистры. Понять, на какое поведение рассчитывает код, исходя из кода инициализации, было решительно невозможно.
Переносимость? Я какое-то время метался между F0 и F103 — довольно сильные различия, HAL не сильно помогает. Даже банальные GPIO отличаются.
На мой взгляд, важнее иметь сильно типизированный API к регистрам периферии. Учитывая, что производитель публикует SVD файлы, которые (в идеале, конечно) описывают все нужные детали, этот типизированный API можно просто генерировать из этих SVD. При этом за счёт сильной типизации уже на этом уровне будут ловиться ошибки типа неправильных битовых масок, и.т.д, т.к все разрешённые значения будут иметь имена и привязку к конкретным позициям в конкретных регистрах.
Вот, например, как выглядит использование подобного API на Rust. Да, этот код ни разу не переносим, но для одноразового переноса на другой чип мне быстрее будет прочитать документацию по новому чипу и переписать его (и компилятор мне в этом поможет), а для разных целевых платформ я просто сделаю свой HAL, на том уровне абстракции, на котором мне нужно. Как пример, HAL для LCD экрана.
Я вижу только минусы:
— Переносимость кода никакая, по сравнению с HAL. Вот прибежит завтра начальник и скажет: давай свой код, который ты писал по STM32F4 запусти на STM32F2, срок до вечера. И все, копай инициализацию по новой.
— Читаемость регистрового кода — никакая, нужно постоянно смотреть в ДШ
Иногда случается так, что кот в принципе нельзя впихнуть в кристалл поменьше. Как раз из-за этого самого HAL, который прилично раздувает код и достаточно сильно может его затормозить в критичных моментах. Ну и немного RAM.
> — Читаемость регистрового кода — никакая
Зависит от комментариев и предефайнов. Довольно неплохо использовать CMSIS + заголовочник от HAL'а, где значения регистров прописаны уже более понятно.
Например: FLASH->ACR |= FLASH_ACR_ACC64 | FLASH_ACR_LATENCY | FLASH_ACR_PRFTEN; // Разрешаем читать по 64 бита, увеличиваем латентность доступа и разрешаем работу предвыборки данных
> нужно постоянно смотреть в ДШ
Зачастую работа даже с библиотекой HAL не исключает частого окунания в DS.
Внезапно даже с HAL надо смотреть в даташит, чтобы знать, почему не работает чтение RTC из прерывания будильника, или почему передаяа с суффиксом _IT не запускается, пока принудительно не включишь NVIC_EnableIRQ(...).
Что до современных тенденций, то ST на хрензнаеткакойраз все-таки выкатила более-менее вменяемую библиотеку: STM32 Low-Level Library, которая почти полностью состоит из инлайновых функций модификаций регистровых флагов, которые позволят разрулить большую часть зависимостей на этапе компиляции. Вот только на STM32F4 я ее не нашел, обидно
Простые вещи, типа GPIO, нормально переносятся (хотя я и там словил грабли, т.к не было привычки инициализировать структуры нулями), а вот сложная настройка PWM, master-slave у таймеров, да ещё чтоб правильные выходы были включены и прерывания — вот это ни разу не просто делается. Даже банальный энкодер у меня не завёлся чисто через HAL, какие-то не те параметры они выставляют.
Неудобно писать:
// Alternate function mode
GPIOA->MODER &= ~GPIO_MODER_MODER8_0; //0
GPIOA->MODER |= GPIO_MODER_MODER8_1; //1
Зато какое удовольствие писать вот так (код взят из реального проекта, и на выходе по ассемблерному выхлопу он даёт тоже самое что и выше, при одинаковых флагах оптимизации):
// enable usart3 pins,
// connect to alternative func 7 (usart)
hal::gpiod::set_mode<hal::pin_mode::alt_func,
hal::p8, hal::p9>();
hal::gpiod::set_alt_func<hal::pin_alt::af7,
hal::p8, hal::p9>();
Да большинство библиотек использует принцип лапшекода, не следуя хотя бы минимальным стандартам о переносимости и компактности. Но это не значит что все библиотеки таковы. В общем использование прямого обращения к регистрам хоть и хорошо с точки зрения выхлопа ассемблерного кода, но очень сильно заставляет человека следить за каждым шагом его действий, что порождает кучу ошибок.
Gpio *pUart3TxPort = new Gpio(USART3_TX_PORT, USART3_TX_PIN);
pUart3TxPort->SetMode(PM_Alternate);
pUart3TxPort->SetAlternateFunction(AF_Uart3Tx);
Плюс, всё равно где-то (кроме спецификации) всё равно должна быть зафиксирована привязка к реальным номерам портов, чтобы удобнее было отслеживать соответствие кода и разрабатываемой аппаратной части.
Это для демо-платы пойдёт подход «а сконфигурируй мне USART3 и пофиг где — я потом куда надо провода воткну», а для аппаратной части, сделанной под задачу, важно знать этот пин. Причём, диктовать пин могут обе стороны — программной части надо, чтобы пин бы первым каналом «продвинутого» таймера TIM1 или TIM2 (их таких будет 4 варианта, а если соглашаться на инвертированный канал, то и все 8 вариантов), а аппаратной части какой-то пин может оказаться удобнее, чем другой.
В общем, моя мысль в том, что код настраивающий/работающий с периферией не должен быть «умным», так как проблема находится на более высоком уровне. Код не может знать, какой вариант был выбран «основной пин»/«второй пин». Не может знать, какой именно таймер был выбран «TIM1»/«TIM2». Не может знать, какой подвариант был выбран «CH1»/«CH1N». Поэтому, код должен просто принимать россыпь констант (ссылка на пин, на таймер, на конкретный USART) и предполагать, что эти выданные ему константы соотносятся правильным образом.
А потом, можно сделать какой-нибудь решатель, который бы верифицировал спецификацию (или выдавал разные опции) в зависимости от заданных ограничений и выдавал значения констант. За неимением такого решателя, пусть это будет просто конфигурационный файл с константами, который проверяет человек по спецификации.
Хорошо, железо вы проинициализировали прямой записью в регистры. А обращаться к этому железу в процессе работы так же будете? Нужно в программе поднять ногу порта — пишем
GPIOA->BSRR = (1<<15);
в сотне разных мест. А потом понадобилось сменить ногу с 15 на 16, например. Искать все эти 100 мест в коде и менять везде? Конечно, нет. Надо написать функцию-обёртку (или макрос) для управления портом. И ещё пару для UART. И еще штуки три для таймеров. И несколько для DMA…Вот и написали собственную библиотеку для работы с периферией. От чего убегали, к тому и пришли.
#define Work_Led_ON GPIOB->BSRR = GPIO_BSRR_BS_2; //Port B Set bit 2
#define Work_Led_OFF GPIOB->BSRR = GPIO_BSRR_BR_2; //Port B Reset bit 2
В примере выше — это специфичный «HAL на коленке» под задачу. А HAL от STM тебе выдаст вон тот самый HAL_GPIO_WritePin, к которому ты ещё и номер пина будешь таскать.
И вот как раз использование такого макроса понятнее — «включить LED»/«выключить LED», всё ровно в терминах высокоуровневой задачи. И сам макрос тупее некуда.
И зафиксированные значения (по уму) будут локализованы в отдельном файле, где будет привязка к железу.
Не понимаю, как тут поможет HAL? Ну то есть можно прям в самом макросе HAL вызвать, но это не сильно поможет. Весь остальной код и так уже написан в терминах Work_Led_ON/Work_Led_OFF.
P.S. Плюс код инициализации, но это отдельная песня.
Итого куча телодвижений. Если подходить к макросам более грамотно то можно добиться смены всего в паре мест, но тоже не то. В случае с библиотекой это будет ровно одно место.
Код на выхлопе будет аналогичным (сам проверял), но при этом не нужно думать, а всё ли я поправил и на те ли значения поправил. Ещё раз — я не советую использовать HAL или SPL заместо CMSIS, я советую использовать правильные библиотеки которые не дают того оверхеда который вы постоянно везде видите.
1) Человек не читал документацию на библиотеку (а она есть и есть аппноты).
2) Человек не внимательно читал документацию и упустил какой-то момент.
3) Человек затребовал от библиотеки невозможное (см п 1, 2).
и много чего другого.
Таких случаев бывает много, также как и случаев когда человек напрямую работает с регистрами. Вот к примеру DMA — он в отличие от таймера не допускает работы в половинами своих регистров, можно только целыми записывать. Где это написано? Правильный ответ нигде — однако если взять HAL или SPL то можно увидеть что там этой проблемы не возникает. Второе — без определённого флага в RTC нельзя просто так побитого менять данные в регистрах времени и даты. Третье — не все NVIC позволяют выставлять прерывания ядра. И таких примеров на низком уровне много.
Вот что будет с HAL.
Делаешь, например, настройку имя порта + пины для вывдов DATA, EN, R/S LCD экрана и всё хорошо. А потом оказывается, что EN и R/S на другом порту, а ты это не предусмотрел. Придётся вводить ещё один параметр.
А потом что? Правильно, DATA пины (4 штуки) оказываются разбиты между двумя портами. И опять вся вот эта идея «меняем в одном месте» летим к чертям.
А потом ещё и не забыть нужные подсистемы включить (и, насколько я помню, у разных чипов раскладка периферии по подсистемам разная и HAL это не абстрагирует).
Я эти грабли все на своём проекте собирал, пока метался между F0 и F103, разными платами, языками (C -> C++ -> Rust), библиотеками (StdPeriph -> HAL -> Rust), подходами (C++ с шаблонами <-> C++ с классами).
Самая жизнеспособная в данном случае абстракция — это «интерфейс» с тремя методами «выставить EN», «выставить R/S», «выставить DATA». А уже что оно там внутри будет делать — пофиг, хоть HAL, хоть не HAL, там кода будет — кот наплакал.
P.S. А правильные библиотеки — согласен, как я уже говорил, я переехал на Rust и весьма счастлив. :)
То есть я согласен, строгая типизация — это важно, я про это писал выше. По этому параметру он, конечно же, лучше.
Но меня это в меньшей степени беспокоит, и вот почему.
Например, я перенёс свой датчик Холла с TIM1 на TIM2. А порт взял такой, который на второй канал попадает. И может быть, это альтернативный порт для этого канала, и нужно переназначение каналов таймера настраивать. И поставил триггер Шмитта, то есть вход стал инвертированный.
В обоих вариантах код будет сильно зависеть от конкретного железа. Соответственно, его всё равно придётся изолировать от остального кода. В обоих вариантах мне придётся существенно переписывать этот код (особенно, настройку). И вникать в техническую спецификацию, чтобы понять, какие регистры и как мне надо настроить (например, переназначение каналов таймера — это же совсем ни разу не очевидным образом настраивается).
И если мне и так приходится а) делать свой слой абстракции б) вникать, фактически, до уровня регистров, то в чём существенный плюс (кроме типизации, за которую я «за» обеими руками, о чём я уже писал)?
Последний пункт, правда, не понял, про сложные операции. Код на Rust должен компилироваться в прямой доступ к регистрам.
tim2.ccer.write(|w| w
.cc1p().set_bit()
.cc1e().set_bit());
без проблем соптимизируются в одну запись константы в регистр (потому что запись делается один раз в конце ccer_write, а установка битов делается в лямбда-функции).
Думаю, в C++ аналогичный код будет ровно так же соптимизирован.
if (Block_mode)
{
Power_Relay_OFF
}
Вот пример из реальной программы…
?
Под капотом
void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState)
{
/* Check the parameters */
assert_param(IS_GPIO_PIN(GPIO_Pin));
assert_param(IS_GPIO_PIN_ACTION(PinState));
if(PinState != GPIO_PIN_RESET)
{
GPIOx->BSRR = GPIO_Pin;
}
else
{
GPIOx->BSRR = (uint32_t)GPIO_Pin << 16U;
}
}
HAL строит абстракции вокруг конкретной переферии. «Порты»/«Таймеры»/и.т.д.
Это может нормально работает для случаев, когда подсистема сильно отделена от всего остального. Не знаю, USB какой-нибудь, что-ли (хотя всегда есть DMA который на себя всё подряд замыкает). Я с такими крупными подсистемами не работал — не знаю. С FLASH работал, там HAL более-менее был удобен.
А вот когда у тебя подсистема — это хитрым образом сконфигурированный таймер и разные пины на разных портах, HAL тебе никак не поможет. Всё равно придётся писать свой слой абстракции, который будет предлагать API специфичный для подсистемы (например «выставить задержку на следующий цикл PWM»/«установить следующий цикл PWM последним»). И если ты такой слой пишешь, то по моему опыту, настроить регистрами проще и быстрее, чем пытаться найти комбинацию вызовов HAL, которая приведёт к нужному результату.
И которая, в 90% случаев всё равно не будет работать на другом устройстве, или из-за разницы в чипах, или банально из-за существенной разницы в раскладке на переферию/пины (тут выход PWM был основной пин, там — альтернативный, и.т.д).
В этом случае мы работаем напрямую с регистрами, но пишем вполне прозрачный код:
#define PIN_LED D, 12, LOW, MODE_OUTPUT_PUSH_PULL_PULL_DOWN, SPEED_2MHZ, AF_NO
// Здесь все просто: D - порт, 12 - пин, режимы и т. д.
void main(void)
{
PIN_ON(PIN_LED);
// И т. д.
}
Ни в коем случае не хочу обидеть автора, но меня несколько удивляет методология такого подхода. Автор берет STM32F7 (внимание, контроллер с мощнейней перефирией), показывает, что через регистры можно изменять состоние пинов и управлять таймерами и делает вывод, что таким образом можно делать все. Но ведь это не так. Если я беру STM32F7, то, очевидно, мне нужно много больше, чем мигать светодиодом. Например, RTC (включая все зубодробильную логику работы с датами и временем в разных часовых поясах и високосных/невисокосных годах), SDIO со всей логикой общения с карточкой (включая CRC контроль данных), USB-стек, внешняя память, и все это через DMA. Именно этот функционал и является основной частью HAL, а поморгать светодиодом — это так, базовый уровень. Вы высокоуровневый протокол работы SD карты, FatFS или USB-стек тоже сами будете с нуля переписывать? А если Вам этот слой HAL неинтересен (то есть нет потребности в этом функционале), то зачем Вы берете F7, а, скажем, не Atmega16?
Спасибо за развернутый комментарий. :)
Более того, у них есть даже специальная утилита, которая берет файлы с кодом на SPL и переименовывает функции в LL аналоги. Недавно помогла в переносе старого SPL проекта с F4 на F7.
На дворе 21 век, ребята. Все идет к тому, что писать на си — моветон. И уж тем более лазить ручками по регистрам, которые меняются быстрее, чем успеешь осилить документацию.
STM32 без HAL и SPL