
Привет, Хабр!
В прошлой статье я разбирал накопление сигнала в NanoVNA-X-BM. Сейчас хочу вернуться к другой стороне того же проекта. Интересно не только то, как прошивка считает, но и то, как её разрабатывать и проверять, когда часть работы выполняет периферия, памяти мало, а каждый отказ не хочется разбирать с осциллографом.
До этого у меня уже был NanoVNA-X на C. Его низкоуровневая часть выросла из работы @DiSlord, а организацию приложения я существенно изменил. Новую прошивку я решил написать на no_std Rust. Мне хотелось заново разобраться с устройством приложения, а не перевести существующие исходники с одного синтаксиса на другой.
Про измерительный тракт здесь повторяться не буду. Для дальнейшего достаточно знать, что приложению приходится одновременно обслуживать захват данных, расчёты, экран, управление и USB. Оно работает без ОС и динамического выделения памяти. Сборки предназначены для NanoVNA-H на Cortex-M0 и H4 на Cortex-M4F. У младшей модели 16 КБ RAM, у старшей 40 КБ обычной SRAM и ещё 8 КБ CCM.
На таком устройстве многие архитектурные решения быстро перестают быть вопросом вкуса. Лишний буфер может не поместиться. Неудачный цикл ожидания способен остановить остальную работу. Слишком широкий доступ к периферии позволяет одному драйверу незаметно испортить состояние другого.
Обычно от Rust в первую очередь ждут помощи с памятью. Она действительно есть, но сама по себе не объясняет, как организовать приложение. Я попробую показать, что пришлось сделать с использованием языка, какие правила получилось поручить компилятору и какие проверки остались за пределами его возможностей.
Почему я не оставил всю логику внутри драйверов
Драйвер легко представить одной большой частью программы, которая знает устройство чипа. Она рассчитывает параметры, собирает команды, передаёт байты, ждёт флаги и сообщает результат. Пока всё работает, разделять её вроде бы незачем.
Но если тестировать такую часть только целиком, для проверки арифметики уже понадобится реальное оборудование. Даже ошибка в расчёте делителя будет выглядеть как неисправность устройства. Придётся последовательно выяснять, верно ли посчитали параметры, дошли ли они по шине и приняла ли их микросхема.
В NanoVNA-X-BM эти задачи разделены. Расчёты, образы регистров и переходы между состояниями находятся отдельно от кода, который обращается к периферии STM32. В каталоге board много сведений о конкретном устройстве, но нет необходимости выполнять их только на ARM. Числа и последовательности операций можно проверить на ПК.
Аппаратная часть в hw занимается тем, что нельзя сделать без контроллера. Она получает периферийные ресурсы, настраивает регистры, обслуживает прерывания и управляет реальной памятью DMA. Выше расположены переносимые расчёты, интерфейс и измерительная логика. Они обращаются к аппаратуре через типажи из hal.rs.

Название HAL иногда создаёт лишние ожидания. Здесь это не универсальная библиотека на все возможные устройства. Мне нужен ограниченный набор операций для этого приложения. Чем точнее их смысл, тем проще проверить реализацию и подставить вместо неё mock модель в тесте.
Например, приложению не требуется полный объект I2S с возможностью произвольно менять регистры. Ему нужно запросить захват, получить блок, остановить приём и узнать об ошибке. Если отдать наверх всю периферию, связь между слоями появится снова, независимо от названий каталогов.
Это разделение важно ещё и потому, что тестировать приходится разные вещи. Модель шины отвечает на вопрос, какие команды отправил драйвер. Реальная шина показывает, удалось ли выполнить передачу. Проверка одного не должна незаметно засчитываться за проверку другого.
Здесь нет какого-то особого приёма, доступного только Rust. На C расчёт тоже можно вынести в чистую функцию. Польза появилась, когда эти границы удалось провести через интерфейсы и сделать часть нежелательных зависимостей недоступной для обычного кода.
Что именно передавать драйверу
Если дать каждому драйверу доступ ко всему микроконтроллеру, разделение каталогов мало что изменит. В одном месте код будет прилично обращаться через интерфейс, а в другом возьмёт адрес регистра и сделает то, что ему сейчас требуется.
Для начала пришлось определить владельцев аппаратных ресурсов. При инициализации прошивка получает объекты периферии, которые нельзя штатно создавать в неограниченном количестве. Затем они передаются тем частям программы, которые должны ими управлять. Повторно пользоваться переданным объектом через прежнего владельца уже нельзя.
Особенно наглядно это видно на DMA. Контроллер один, но нужен и захвату, и SPI. Выдать обоим драйверам независимый полный доступ было бы удобно при написании кода. Дальше пришлось бы вручную следить, чтобы никто не трогал чужие каналы и общие флаги.
Вместо этого pac::DMA1 передаётся в функцию разделения. Она возвращает SpiDma и CaptureDma. Через первый доступны каналы 2 и 3, через второй канал 4. Эти объекты нельзя скопировать, а их закрытые поля не позволяют собрать дополнительного владельца снаружи модуля.
Драйвер захвата теперь не просто знает, какие каналы ему положены. Его интерфейс не предоставляет остальные! Если в нём всё-таки появится прямое обращение к чужой периферии, это будет заметно.

Как выглядит разделение DMA в коде
Ниже приведены структуры и сигнатура функции из src/hw/drivers/dma.rs. Тело инициализации здесь не нужно, поэтому его опускаю.
pub(crate) struct SpiDma { _private: (), } pub(crate) struct CaptureDma { _private: (), }
pub(crate) fn split( rcc: &pac::RCC, _dma: pac::DMA1, ) -> (SpiDma, CaptureDma)
Параметр _dma передаётся по значению. Ни у одной возвращаемой структуры нет Copy или Clone. Сами они не занимают памяти, поскольку обозначают право доступа, а не содержат ещё одну копию описания регистров.
Отдельный проверочный unit-тест пытается дважды передать один и тот же объект:
fn consume(_: dma::CaptureDma) {} fn check(channel: dma::CaptureDma) { consume(channel); consume(channel); }
Второй вызов должен быть отвергнут компилятором. Это реальный отрицательный пример из проекта, а не рабочий код приложения. Сценарий включён в автоматические проверки.
Общие регистры при этом никуда не исчезли. Например, IFCR подтверждает флаги разных каналов. Запись единицы сбрасывает соответствующий флаг, поэтому слишком широкая маска способна снять чужое событие.
Доступ к таким операциям остался внутри одного модуля. Маски ограничены каналами владельца, а подтверждение выполняется записью нужных битов, без чтения и последующего изменения общего значения. Эту часть пришлось реализовать вручную и сверить с документацией (передача владения не объясняет компилятору семантику аппаратного регистра).
Для меня здесь полезно не количество новых типов. Важнее, что одно правило теперь проверяется постоянно. При рефакторинге уже недостаточно случайно передать тот же объект второму пользователю. Если для изменения архитектуры понадобится другой порядок владения, придётся явно поменять интерфейс.
Что проверяет время жизни ссылки
С аппаратным ресурсом получилось запретить повторную передачу. Для данных нужно другое ограничение. Обработчик должен читать блок только тогда, когда драйвер может обеспечить его неизменность.
В предыдущей статье я уже касался работы вокруг DMA, поэтому подробно разбирать переключение банков не буду. В текущей реализации читаемый банк исключается из дальнейшей записи. Отметка сохраняется даже при остановке и перезапуске захвата. На H4 обработчик читает этот банк непосредственно, на H сначала получает отдельную копию.
В обоих случаях приложение работает одинаково. Драйвер вызывает функцию обработки и передаёт ей доступ к отсчётам вместе со сведениями о захвате. Функция может читать эти данные, пока выполняется. Если они понадобятся позже, их нужно скопировать в собственный массив. Сохранить переданный срез и продолжить читать через него после возврата из функции компилятор не позволит.
Но драйвер должен обеспечить ещё одно условие. Пока функция читает отсчёты, DMA не должен их перезаписывать. Сам компилятор остановить периферию не может. Если драйвер передал ссылку на память, в которую DMA продолжает писать, ошибка уже произошла внутри драйвера. Правильное использование ссылки в функции обработки её не устранит.
Как проверяется запрет сохранить срез
В реализации используется такая сигнатура:
fn poll_packet<F: for<'a> FnMut(hal::CaptureBlock<'a>)>( &self, cb: &mut F, ) -> hal::CapturePoll
Переданная функция должна принимать блок с временем жизни, выбранным драйвером. Она не может требовать, чтобы исходные отсчёты оставались доступны до завершения приложения.
В проекте есть следующий отрицательный пример. Импорт типов здесь опущен, сама проверяемая функция приведена без изменений.
fn check(capture: &Option<I2s<Running>>) -> &'static [i16] { let mut escaped = &[][..]; capture.poll_packet(&mut |block| escaped = block.samples); escaped }
Этот пример не должен компилироваться. Он пытается вернуть данные с временем жизни 'static, хотя захват выдаёт их только для ограниченной обработки.
Проверка не запрещает скопировать значения в собственный массив. Не запрещает она и сохранить сырой указатель. Но дальнейшее разыменование такого указателя потребует unsafe, и за допустимость обращения будет отвечать уже написавший его код.
Этот тест проверяет одно конкретное ограничение. Компилятор не даёт вернуть из функции ссылку на отсчёты, доступные только во время обработки блока. Но по этому результату нельзя судить о правильности всего драйвера (например, он всё ещё может неверно настроить DMA).
Почему отказ компилятора тоже нужен в тестах
Обычно тест проверяет, что программа выполняет разрешённое действие. Для интерфейса с ограничениями этого недостаточно. Важно убедиться, что запрещённое действие по-прежнему недоступно.
При изменении типов легко случайно покрошить интерфейс. Например, разрешить копирование объекта-владельца или вернуть ссылку с неподходящим временем жизни. Обычные тесты могут продолжить проходить, потому что они используют API корректно. В них никто не пробует нарушить нужное правило.
Поэтому в проекте есть небольшие unit-тесты, которые должны завершить компиляцию ошибкой. Они проверяют повторное использование ресурсов, утечку заимствований и некоторые границы доступа к данным. В полном прогоне такие файлы собираются отдельно, от всех прочих unit-тестов.
Важно проверить, почему именно компилятор отверг пример. Допустим, тест пытается дважды передать один объект, но после переименования модуля в нём перестал работать импорт. Компиляция завершится ошибкой ещё до проверки повторной передачи. Поэтому скрипт проверяет не только код выхода, но и сообщение компилятора. В этом случае он должен сообщить об использовании уже переданного объекта.
Рядом нужен unit-тест, в котором объект передаётся один раз и код успешно компилируется. Так мы проверяем, что интерфейс разрешает обычную работу, а ошибка возникает именно при повторной передаче.
После изменения интерфейса оба теста приходится обновлять. Нужно сохранить их смысл, а не просто добиться прежнего результата сборки. Один должен по-прежнему показывать допустимое использование, другой должен нарушать конкретное правило.
При следующих изменениях кода эти проверки запускаются заново. Если повторная передача вдруг станет допустимой, тест обнаружит это сразу. Не придётся вспоминать, какие действия компилятор запрещал в прежней версии.
Что делать с кодом, который делят прерывания и основной цикл
Владение хорошо работает там, где ресурс можно передать одному пользователю. Но часть состояния нужна и обработчику прерывания, и основному циклу. Спрятать этот факт за разными полями структуры не получится.
В проекте для небольших копируемых значений используется SharedCell. Внутри находится Cell, доступ к которой закрыт критической секцией. На ARM нужная защита обеспечивается маскированием прерываний, а в многопоточных тестах на ПК используется настоящий взаимно исключающий механизм.
Вторая часть так же важна. Если на ПК заменить критическую секцию пустой функцией, тесты могут перестать проверять именно то, ради чего написаны. Два потока начнут обращаться к общему состоянию без требуемой синхронизации. Успешный результат в одном запуске ничего не исправит.
Но и синхронизированные get() и set() ещё не делают любое их сочетание неделимым. Прочитать счётчик, прибавить единицу и записать обратно нужно под одной общей защитой. Если закрывать только чтение и только запись, между ними может вмешаться другой пользователь.

Это обычная ошибка конкурентного кода, не связанная с измерительной техникой. Встроенный проект добавляет к ней ограничение по времени. Нельзя без необходимости удерживать прерывания выключенными во время длинных расчётов или передачи данных!
Где проходит критическая секция
Два метода SharedCell выглядят так:
pub fn get(&self) -> T { critical_section::with(|cs| self.0.borrow(cs).get()) } pub fn set(&self, value: T) { critical_section::with(|cs| self.0.borrow(cs).set(value)); }
Метод get() запрещает прерывания только на время чтения, а set() только на время записи. Если вызвать их по очереди для увеличения счётчика, между вызовами может выполниться обработчик прерывания и изменить тот же счётчик. Тогда последующая запись затрёт его изменение. Поэтому чтение, прибавление единицы и запись нужно выполнять внутри одной общей критической секции. На ARM сама SharedCell занимает столько же памяти, сколько хранимое значение. Дополнительная память под блокировку не нужна. Размер обёртки проверяется тестом, а совместная работа с данными проверяется на ПК с несколькими потоками.
DMA продолжает работать и при запрещённых прерываниях. Поэтому одной критической секции недостаточно, чтобы защитить его буфер. Перед тем как дать процессору читать отсчёты, драйвер должен убедиться, что DMA больше не пишет в этот банк. Запись в него можно разрешить снова только после окончания чтения.
Здесь хорошо видно, почему я не хотел свести рассказ к «компилятор нашёл гонки». Часть требований выражена типами, часть обеспечивается критическими секциями, часть относится к периферии. Разработчику всё равно нужно знать, какой механизм действует в конкретном месте.
Как проверять драйвер, не Mock-ируя весь микроконтроллер
Чтобы запускать тесты без платы, вместо настоящей I2C-шины нужно дать драйверу её программную замену. Самый простой вариант всегда сообщает, что передача прошла успешно. Так можно проверить обычную настройку, но нельзя узнать, правильно ли драйвер обрабатывает ошибки.
Поэтому в mock-ах хранится копия регистров генератора и кодека. Когда драйвер отправляет байты, код меняет значения в этой копии так, как их должна была бы изменить микросхема. Она учитывает адрес устройства, а для кодека ещё и выбранную страницу регистров. После выполнения команды тест может проверить, что было записано и куда.
Особенно это нужно при ошибке конфигурирования. Допустим, чип успел принять несколько байтов, а затем передача завершилась ошибкой. Принятые значения уже изменили его регистры. Если драйвер просто вернёт своим переменным прежние значения, сам чип от этого в прежнее состояние не вернётся.
В тесте можно заранее выбрать, на какой передаче возникнет ошибка и сколько байтов чип успеет принять. Можно проверить и случай, когда все байты приняты, но драйвер всё равно получил сообщение об ошибке. По такому ответу он не может достоверно определить, какие настройки действуют в чипе.
Затем тест проверяет восстановление. Драйвер должен заново отправить необходимые настройки, а не пропустить их из-за сохранённой отметки «уже настроено». Сообщить об успехе он может только после завершения этих действий. Поэтому важно проверить содержимое регистров и порядок команд, а не просто учесть обращения к шине.

Как задаётся отказ передачи
В модели ControlBus настройка отказа сведена к небольшому методу:
pub fn fail_at(&self, operation: usize, prefix: usize) { self.fault.set(Some((operation, prefix))); }
Параметр operation выбирает операцию, а prefix задаёт принятую часть записи. Модель хранит образы регистров генератора и кодека отдельно. Благодаря этому можно проверить последствия частичной настройки, а не только факт возврата ошибки.
Внутри тестов допустимы средства стандартной библиотеки, которых нет в прошивке (помним, про no_std). Например, журнал операций использует Vec. Из этого не следует, что в сборке для STM32 появилась куча. Проверять нужно зависимости и код именно целевого варианта, а не запрещать тестовому окружению удобные средства!
При этом mock-модель не должна превращаться во вторую такую же реализацию драйвера :). Мы живые люди. И если повторить в ней ту же формулу и те же предположения, одинаковая ошибка может оказаться с обеих сторон сравнения...
Для расчётов лучше использовать независимые ожидаемые значения или известные примеры из документации. Для восстановления после ошибки важнее проверять наблюдаемые последствия. Если по сценарию несколько регистров уже изменены, тест должен начинаться с этого состояния, а не с синтетически чистого состояния чипа.
Разумеется, модель тоже можно написать неправильно. Поэтому я не считаю её заменой аппаратных испытаний. Она позволяет быстро и воспроизводимо проверить определённый набор условий. Это уже полезно, особенно для редких отказов, тестирование которых трудно организовать на плате.
Зачем разделять запрос и полученный результат
Допустим, приложение попросило драйвер изменить настройки. Драйвер начал отправлять команды, но одна из передач завершилась ошибкой. Приложение не должно продолжать работу с новыми параметрами, поскольку неизвестно, какие из них устройство успело принять.
Поэтому запрос и результат настройки хранятся в разных структурах. В TuneRequest приложение передаёт желаемые параметры. После успешного выполнения драйвер возвращает AppliedTune с параметрами, которые дальше использует обработка. При ошибке приложение получает сообщение об отказе, а не копию своего запроса, выданную за результат.
Такое разделение полезно не только для VNA. Например, команда записать данные во внешнюю память ещё не означает, что запись завершена. Если дальнейший код должен работать только после её завершения, ему нужен результат операции, а не сведения о том, что запись когда-то запросили.
При этом AppliedTune не содержит результатов независимого измерения частоты. Некоторые значения драйвер рассчитывает по записанным делителям и настройкам тактирования. Они описывают выполненную настройку, но её физическую точность всё равно нужно проверять на приборе.
Похожим образом устроено изменение частотной сетки. Сначала приложение проверяет новые параметры, не заменяя ими действующие. Если хотя бы одна точка недопустима, новая сетка отклоняется. Прежняя остаётся активной. Только после успешной проверки всей сетки приложение принимает её целиком.
Проверка выполняется постепенно. За один проход основного цикла проверяется одна частота, затем программа возвращается к остальным задачам. Поэтому экран и USB продолжают обслуживаться, даже если проверка всей сетки ещё не закончилась.
После успешной проверки функция возвращает отдельную структуру с принятыми параметрами. Другой модуль не может сам создать её или заменить значения в её полях. Поэтому код, получивший эту структуру, знает, что параметры уже прошли проверку. Если их нужно изменить, новые значения придётся проверить заново.
Компилятор не позволяет обойти этот порядок обычным присваиванием полям. Но сами условия проверки пишет разработчик. Если он забыл учесть недопустимое сочетание параметров, компилятор этого не обнаружит. Такие ошибки нужно искать тестами.
Как не остановить приложение ожиданием
Без ОС разные части прошивки приходится согласовывать явно. Нельзя предполагать, что пока одна функция ждёт периферию, кто-то другой сам обслужит экран и USB. Часть работы выполнят прерывания, но основной цикл должен продолжать крутить свои задачи.
Поэтому долгую настройку драйвер выполняет в несколько вызовов. Сначала приложение запускает её, а затем при каждом проходе основного цикла вызывает метод, который делает следующий шаг и сообщает, закончена ли работа. Например, при настройке радиочастотной части один такой вызов выполняет не больше одной передачи по I2C с ограниченным временем ожидания. После возврата из метода приложение может заняться экраном и USB, а к настройке вернуться на следующем проходе.

Приложению нужно знать, продолжается ли настройка, завершилась ли она успешно или произошла ошибка. Отдельно обрабатывается отмена. Кода для этого требуется больше, чем для одной функции, которая всё время ждёт завершения. Зато основной цикл получает управление между шагами.
Отмена не возвращает микросхему в прежнее состояние. Если драйвер уже отправил часть настроек, они могли изменить регистры. Поэтому после отмены он не должен полагаться на сохранённые сведения о предыдущей настройке. При следующем запуске необходимые параметры придётся передать заново.
Время ожидания тоже нужно ограничивать. Обычно драйвер запоминает показание системного счётчика и проверяет, сколько времени прошло. Но если счётчик не обновляется, такое ожидание никогда не закончится. Это возможно, например, на раннем этапе запуска. Поэтому для некоторых ожиданий дополнительно ограничено число проверок состояния периферии. Когда попытки заканчиваются, функция сообщает об ошибке.
Число попыток не задаёт точную длительность в микросекундах. Оно лишь не даёт программе навсегда остаться в цикле ожидания. Чтобы узнать, сколько времени функция действительно занимает на контроллере, её нужно измерить.
В unit-тестах вместо настоящего таймера можно передавать заранее заданные значения времени, а вместо устройства задавать нужные ответы. Тогда для проверки десятисекундного ограничения не нужно ждать десять секунд. Можно сразу проверить, что после истечения срока операция завершается ошибкой, даже если затем появляется ответ об успехе. Так же проверяется, что после отказа драйвер не пытается восстанавливаться бесконечно.
Однако разделить работу на шаги недостаточно. Если внутри одного шага останется долгий расчёт или неограниченное ожидание, экран и USB всё равно будут задерживаться. Поэтому нужно проверять, что делает каждый вызов, и измерять его длительность на устройстве.
Почему отсутствие виртуальных вызовов ещё не означает отсутствие затрат
Один и тот же код приложения можно собрать с настоящим драйвером или с его тестовой заменой. Набор доступных методов описан типажем, а конкретная реализация выбирается при компиляции. Во время работы прошивке не нужно искать нужный метод в таблице виртуальных функций.
Но это ещё не означает, что такое разделение ничего не стоит. Если обобщённая функция используется с несколькими типами, компилятор может создать отдельный вариант машинного кода для каждого из них. Итоговый файл от этого может вырасти. Поэтому после изменения интерфейсов я смотрю на размер сборки, а не делаю вывод об экономии только по исходнику.
Память под данные тоже нужна, даже если в программе нет кучи. Большая структура всё равно будет находиться в статической памяти или на стеке. При её создании и передаче функциям могут понадобиться дополнительные временные данные. Сколько места они займут, нужно проверять в конкретной сборке.
При рисовании приложение использует подготовленные координаты и уже выделенные буферы обмена с экраном. Полная копия изображения в памяти не хранится. Для вывода чисел используются функции с заранее ограниченным размером текстового буфера. Здесь не нужен универсальный механизм, способный форматировать любые данные.
Такой выбор продиктован объёмом памяти микроконтроллера. На устройстве с большим запасом RAM можно было бы выбрать другие средства. Здесь удобство каждого решения приходится сопоставлять с тем, сколько кода и данных оно добавляет в прошивку.
Что видно только после сборки
У H всего 16 КБ RAM. На H4 места больше, но дополнительная CCM недоступна DMA. Поэтому даже подходящая по размеру область памяти не обязательно подходит конкретному буферу. Кроме статических данных есть стек. no_std не ограничивает ни глубину вызовов, ни размер локальных массивов. Временные объекты, встраивание функций и обработчики прерываний продолжают влиять на расход памяти.
При запуске, функции настройки создают временные данные на стеке. После настройки прошивка переходит в бесконечный основной цикл. Если вызвать его прямо из функции настройки, она уже не завершится. Место под её локальные данные может остаться занятым на всё время работы, хотя сами данные больше не нужны. Поэтому функция инициализации сначала заканчивает работу и возвращает управление вызывающему коду. Только после этого запускается основной цикл. Для функции инициализации запрещено встраивание (inline), чтобы компилятор не объединил её с вызывающей функцией. Затем по ассемблерному коду проверяется, что занятое ею место на стеке действительно освобождается до запуска основного цикла.

Для проверки пришлось смотреть ELF и ассемблерный код ARM. Скрипты учитывают границы секций, загрузочный диапазон, статическое размещение SRAM и выбранные постоянно живущие кадры основного цикла. После учтённых затрат должен оставаться бюджет не меньше 3072 байт. Из этой проверки не следует, что максимальный расход стека обеспечен. Она не вычисляет автоматически самую длинную цепочку временных вызовов вместе со всеми вложенными прерываниями. Для полной оценки нужны дополнительный анализ и испытания на аппаратуре.
Ещё одна ловушка связана с удалением неиспользуемого кода. Драйвер может лежать в репозитории, но не попасть в приложение. Тогда линкер уберёт его вместе с буферами, и сборка получится небольшой. Это не означает, что все написанные функции поместятся одновременно.
Поэтому проверяется не абстрактный проект, а файл конкретной конфигурации. Для H и H4 отличаются целевые платформы и профили оптимизации. Аппаратный RF-вариант тоже выбирается при сборке.
Какие размеры получились в проверенной сборке
Для текущей версии использован Rust 1.99.1. Стандартная H собиралась с профилем release, H4 с release-fast. В обоих вариантах включены средства работы с интерфейсом по USB, отладочный ui-tools.
Метрика |
H / F072 |
H4 / F303 |
|---|---|---|
|
94 764 |
91 484 |
|
9 584 |
29 008 |
Диапазон загрузочного образа во flash, байт |
94 972 |
91 908 |
Остаток после статического размещения и учтённых удерживаемых кадров, байт |
3 832 |
9 152 |
Меньший код H4 не является опечаткой. Используются другое ядро и другой профиль сборки. В его образе также есть 8-байтовая служебная секция CCM. Всю область CCM размером 8 КБ на этом основании считать занятой нельзя.
Последняя строка показывает результат статического расчёта. Это не замер свободного стека в худшем рабочем режиме. Подменять одно другим нельзя.
Что оставлено для проверки на приборе
Тесты на ПК не показывают, какие сигналы появятся на выводах микросхемы. Они также не позволяют измерить, сколько времени обработчик прерывания займёт на STM32. Даже если все такие тесты прошли, эти вопросы остаются открытыми.
На плате нужно проверить, что устройства действительно принимают подготовленные команды, обмен укладывается в допустимые сроки, а драйверы правильно реагируют на ответы периферии. Программная модель помогает проверить код, но не подтверждает, что реальное устройство ведёт себя именно так.
Для части этих проверок в прошивку добавлены команды USB. С ПК можно узнать версию устройства, сохранить изображение экрана и передать нажатие в заданной точке. Так проще повторить одну и ту же последовательность действий и сравнить, что прибор показывал до неё и после.
Ответ на команду нажатия сообщает, что прошивка обработала нажатие. Перерисовка экрана к этому моменту может ещё продолжаться. Поэтому тест, которому нужно проверить изображение, должен дождаться окончания перерисовки и получить новый снимок. Одного успешного ответа на команду недостаточно.
Через эти команды удобно проверять меню и реакцию интерфейса. Но по снимку экрана нельзя определить точность измерения или максимальный расход стека. Расход стека нужно проверять во время работы прошивки, сигналы на выводах нужно измерять, а показания прибора сравнивать с известными значениями.
Поэтому результаты автоматических проверок и испытаний на приборе я рассматриваю отдельно. Первые показывают, что код выполнил проверенные сценарии. Чтобы утверждать, что прибор измеряет с определённой точностью, нужны результаты самих измерений.
Что в итоге изменилось в разработке
Почти все описанные решения можно реализовать на C. Разделение расчётов и обмена, модели аппаратуры, ограниченные операции, тесты и проверки файлов не принадлежат одному языку.
Для меня Rust оказался полезен там, где часть соглашений удалось сделать обязательной для вызывающего кода. После передачи ресурса прежний владелец не может продолжать им пользоваться обычным способом. Заимствование не удаётся продлить за разрешённую границу. Драйвер получает только предусмотренные для него возможности, а не все регистры.
При этом язык не освобождает от выбора самих границ. Неудачный интерфейс можно аккуратно реализовать и на Rust. Можно проверить время жизни ссылки и забыть, что данные меняет DMA. Можно передать проверенную структуру с неверно рассчитанными полями или собрать код, который не подходит к аппаратной конфигурации.
Поэтому главным результатом для меня стало не сокращение числа опасных строк. Стало проще. Одно действие запрещает компилятор. Другой переход проверяет тест. Размещение подтверждается по итоговому файлу. А то, что относится к реальному оборудованию, остаётся задачей аппаратной проверки.
Когда эти вещи не смешаны, легче бороться с багами. Не нужно каждый раз подозревать одновременно расчёт, указатели, настройку шины и неправильный файл прошивки. Можно по очереди проверить, на каком этапе перестали выполняться нужные условия.
Именно о такой пользе Rust мне и хотелось рассказать отдельно от измерительного алгоритма. Он не сделал разработку автоматической. Но помог сузить проблемы, которые нивелируются только памятью автора и комментариями к коду.
Мира всем и добра.
Меня зовут @momentics. Я строю HW/SW команды, процессы производства, делаю реинвент технологий, починяю старые и создаю новые HW/SW продукты.