micro::bit v2 Embedded Discovery: учебник
Откройте для себя мир микроконтроллеров с помощью Rust!
Эта книга — вводный курс по встраиваемым системам на базе микроконтроллеров, в котором для обучения используется Rust, а не привычные C/C++.
Охват тем
В книге планируется рассмотреть следующие темы (со временем, надеюсь):
-
Как написать, собрать, прошить и отладить «встраиваемую» программу на Rust.
-
Функциональные блоки («периферия»), которые обычно есть в микроконтроллерах: цифровой ввод и вывод, широтно-импульсная модуляция (ШИМ, PWM), аналого-цифровые преобразователи (АЦП, ADC), распространённые протоколы связи, такие как Serial, I2C и SPI, и т. д.
-
Концепции многозадачности: кооперативная и вытесняющая многозадачность, прерывания, планировщики и т. д.
-
Концепции систем управления: датчики, калибровка, цифровые фильтры, исполнительные механизмы, управление без обратной связи, управление с обратной связью и т. д.
Подход
-
Рассчитано на новичков. Опыт работы с микроконтроллерами или встраиваемыми системами не требуется.
-
Практика прежде всего. Много упражнений, чтобы применить теорию на деле. Основную работу здесь выполняете вы.
-
Ориентация на инструменты. Мы активно используем инструменты, чтобы упростить разработку. Настоящая отладка с GDB и логирование появятся уже в начале. Светодиоды как средство отладки здесь не используются.
Чего в книге нет
Что выходит за рамки этой книги:
-
Обучение самому Rust. Материалов по этой теме уже достаточно. Мы сосредоточимся на микроконтроллерах и встраиваемых системах.
-
Исчерпывающий учебник по теории электрических цепей или электронике. Мы разберём лишь минимум, необходимый, чтобы понять принцип работы некоторых устройств.
-
Подробности вроде скриптов компоновщика и процесса загрузки. Например, мы будем использовать готовые инструменты, чтобы доставить ваш код на плату, но не станем подробно объяснять, как эти инструменты устроены.
Кроме того, я не планирую переносить материал на другие отладочные платы: книга целиком построена на отладочной плате micro:bit.
Сообщения о проблемах
Исходный код книги находится в этом репозитории. Если вы заметили опечатку или проблему в коде, сообщите о ней в трекере задач.
Другие ресурсы по встраиваемому Rust
Эта книга Discovery — лишь один из нескольких ресурсов по встраиваемому Rust, которые предоставляет Embedded Working Group. Полный список можно найти в The Embedded Rust Bookshelf. Там же есть часто задаваемые вопросы.
Общие сведения
Вы собираетесь написать «bare-metal»-программу на Rust для микроконтроллера, то есть без операционной системы. Возможно, вы никогда раньше ничего подобного не делали. Это замечательно — добро пожаловать в увлекательное приключение!
Для начала ответим на несколько базовых вопросов, которые могут у вас возникнуть.
-
Что такое микроконтроллер?
Микроконтроллер — это система на кристалле. Ваш компьютер состоит из нескольких отдельных компонентов: процессора, оперативной памяти, накопителя, порта Ethernet и т. д. А в микроконтроллере все такие компоненты объединены в одном «чипе» или корпусе. Благодаря этому системы можно строить из меньшего числа деталей.
-
Что можно делать с помощью микроконтроллера?
Очень многое! Микроконтроллеры — основа так называемых «встраиваемых систем». Встраиваемых систем вокруг очень много, но вы обычно их не замечаете. Они управляют машинами, которые стирают одежду, печатают документы и готовят еду. Встраиваемые системы поддерживают комфортную температуру в зданиях, где вы живёте и работаете, и управляют узлами транспортных средств, благодаря которым вы едете и останавливаетесь.
Большинство встраиваемых систем работает без вмешательства пользователя. Даже если у них есть интерфейс, как у стиральной машины, основную работу они выполняют самостоятельно.
Встраиваемые системы часто используются, чтобы управлять физическим процессом. Для этого у них есть одно или несколько устройств, которые сообщают о состоянии окружающего мира («датчики»), и одно или несколько устройств, с помощью которых они что-то меняют («исполнительные механизмы»). Например, система климат-контроля здания может включать:
- Датчики, которые измеряют температуру и влажность в разных точках.
- Исполнительные механизмы, которые регулируют скорость вентиляторов.
- Исполнительные механизмы, которые добавляют тепло в здание или отводят его.
-
Когда стоит использовать микроконтроллер?
Многие из перечисленных выше встраиваемых систем можно было бы реализовать на компьютере под управлением Linux (например, на «Raspberry Pi»). Зачем тогда использовать микроконтроллер? Похоже, что программу для него разрабатывать сложнее.
Вот некоторые причины:
-
Стоимость: микроконтроллер гораздо дешевле компьютера общего назначения. Причём он не только дешевле, но и требует намного меньше внешних электронных компонентов для работы. Благодаря этому печатные платы (PCB) получаются меньше и дешевле и в проектировании, и в производстве.
-
Энергопотребление: большинство микроконтроллеров потребляет лишь малую долю энергии полноценного процессора. Для устройств, работающих от батарей, это огромная разница.
-
Отзывчивость: для выполнения своей задачи некоторым встраиваемым системам нужно всегда реагировать в ограниченный промежуток времени (например, антиблокировочная система тормозов автомобиля). Если система не уложится в такой срок, может произойти катастрофический отказ. Такое требование называют «жёстким реальным временем» (hard real time), а встраиваемую систему, которая связана таким сроком, — «системой жёсткого реального времени». У компьютера общего назначения с ОС обычно много программных компонентов, которые делят между собой вычислительные ресурсы. Из-за этого труднее гарантировать выполнение программы в жёстких временных рамках.
-
Надёжность: в системах с меньшим числом компонентов (как аппаратных, так и программных) меньше возможностей для сбоя!
-
-
Когда не стоит использовать микроконтроллер?
Микроконтроллеры часто плохо справляются с тяжёлыми вычислениями. Чтобы стоимость и энергопотребление оставались низкими, у микроконтроллеров ограничены вычислительные ресурсы.
Микроконтроллеры обычно выполняют меньше инструкций в секунду, чем «большие» процессоры. Самые медленные из них могут работать «всего лишь» на нескольких миллионах инструкций в секунду. Кроме того, полезной работы на одну инструкцию, как правило, выполняется меньше. Микроконтроллеры обычно 32-битные, но 16-битные встречаются нередко: из-за этого для работы с привычными типами данных Rust может понадобиться больше инструкций. У большинства микроконтроллеров нет или почти нет «кэша», поэтому инструкции выполняются ровно настолько быстро, насколько быстро доступна основная память.
Некоторые микроконтроллеры не имеют аппаратной поддержки операций с плавающей точкой. На таких устройствах сложение двух чисел одинарной точности может занять сотни тактов процессора.
Наконец, памяти у микроконтроллеров обычно немного. Её может быть всего 16 КБ для инструкций программы и 4 КБ для данных, что делает программирование для таких систем довольно непростым. Объём встроенной памяти на единицу стоимости и энергопотребления постоянно растёт, но процессор, с которым мы будем работать, по-прежнему располагает «всего лишь» 512 КБ памяти для инструкций и 128 КБ для данных — гораздо меньше, чем у «настоящего компьютера».
-
Почему Rust, а не C?
Надеюсь, убеждать вас тут не придётся: вы, вероятно, знакомы с различиями между Rust и C. Хочу отдельно отметить управление пакетами. У C нет официального и широко принятого решения для управления пакетами, а у Rust есть Cargo. Благодаря этому разработка становится заметно проще. А ещё, по моему мнению, удобное управление пакетами поощряет повторное использование кода: библиотеки легко подключаются к приложению, а это хорошо, потому что библиотеки получают больше «боевой проверки».
-
Почему не стоит использовать Rust?
Или почему стоит предпочесть C Rust’у?
Экосистема C более зрелая. Для многих задач уже есть готовые решения. Если нужно управлять процессом, чувствительным ко времени, можно взять одну из существующих коммерческих операционных систем реального времени (RTOS) и решить задачу. Коммерческих RTOS промышленного уровня на Rust (на момент написания) нет, поэтому придётся либо создать такую систему самим, либо попробовать одну из тех, что находятся в разработке. Список таких проектов есть в репозитории Awesome Embedded Rust.
Требования к оборудованию и знаниям
Главное требование к знаниям для чтения этой книги — знать некоторый Rust. Мне трудно точно определить, что такое «некоторый». Очень полезно быть знакомым с основами обобщённых типов (generics) и трейтов. Нужно уметь пользоваться замыканиями (closures). Также желательно знать идиомы текущей редакции Rust.
Также для работы с материалом понадобится:
-
Плата Micro:Bit v2 (MB2).
Эту плату можно купить у множества поставщиков, включая Amazon и Ali Baba. Список поставщиков можно получить непосредственно у BBC, производителя MB2: список.
Существует несколько версий платы
V2. Материал написан дляV2.00, но с любой платойV2всё должно работать нормально. -
Кабель micro-B USB (ничего особенного — такие, скорее всего, у вас есть). Он нужен, чтобы питать micro:bit, когда тот не работает от батареи, и чтобы обмениваться с ним данными. Убедитесь, что кабель поддерживает передачу данных: некоторые кабели рассчитаны только на зарядку.
ПРИМЕЧАНИЕ Некоторые наборы micro:bit комплектуются такими кабелями. Кабели от других мобильных устройств тоже подойдут, если они micro-B и умеют передавать данные.
Официальный набор
micro:bit Goвключает и USB-кабель, и удобный аккумуляторный блок для питания MB2 без USB.
FAQ: Погодите, зачем нужно именно это оборудование?
Так будет проще и мне, и вам.
Материал гораздо доступнее, если не нужно думать о различиях в оборудовании. Поверьте мне на слово.
FAQ: Можно ли работать с этим материалом на другой отладочной плате?
Возможно. Это зависит в основном от двух вещей: от вашего предыдущего опыта работы с микроконтроллерами и от того, есть ли для вашей отладочной платы готовый крейт верхнего уровня. Вам, вероятно, понадобится хотя бы крейт HAL, как nrf52833-hal, который используется здесь. Может оказаться удобнее плата с крейтом поддержки платы (BSP), как microbit-v2, который используется здесь. Если вы планируете использовать другой микроконтроллер, поискать поддерживаемые крейты можно в Awesome Embedded Rust или просто в интернете.
На мой взгляд, с другой отладочной платой этот текст потеряет большую часть своей дружелюбности к новичкам и простоты изложения, так что вы предупреждены.
Если у вас другая плата на базе Arm и вы не считаете себя полным новичком, стоит рассмотреть шаблон проекта quickstart.
Настройка среды разработки
Работа с микроконтроллерами требует нескольких инструментов: нам предстоит иметь дело с архитектурой, отличной от архитектуры вашего компьютера, и запускать и отлаживать программы на «удалённом» устройстве.
Документация
Но инструменты — не всё. Без документации работать с микроконтроллерами практически невозможно. Официальная техническая документация MB2 находится по адресу https://tech.microbit.org. В книге мы будем ссылаться и на другую техническую документацию.
Инструменты
Мы будем использовать все перечисленные ниже инструменты. Там, где минимальная версия не указана, должна подойти любая свежая версия, но мы указываем версию, которую проверяли сами.
-
Rust 1.79.0 или более новый тулчейн.
-
gdb-multiarch. Это отладчик. Самая старая проверенная версия — 10.2, но и другие версии, скорее всего, тоже подойдут. Еслиgdb-multiarchнедоступен в вашем дистрибутиве или платформе, подойдёт иarm-none-eabi-gdb. Кроме того, некоторые обычные сборкиgdbподдерживают несколько архитектур: подробнее об этом рассказано в главе про отладку этой книги. -
cargo-binutils. Версия 0.3.6 или новее. -
probe-rs-tools. Версия 0.24.0 или новее. -
minicomв Linux и macOS. Проверенная версия: 2.7.1. Другие версии, скорее всего, тоже подойдут. -
PuTTYв Windows.
Далее выполните универсальные инструкции по установке некоторых инструментов:
rustc и Cargo
Установите rustup, следуя инструкциям на https://rustup.rs.
Если rustup у вас уже установлен, убедитесь, что вы используете канал stable и что ваш стабильный тулчейн обновлён. Команда rustc -V должна выводить дату и версию не старше той, что показана ниже:
$ rustc -V
rustc 1.79.0 (129f3b996 2024-06-10)
cargo-binutils
$ rustup component add llvm-tools
$ cargo install cargo-binutils --vers '^0.3'
$ cargo size --version
cargo-size 0.3.6
probe-rs-tools
ПРИМЕЧАНИЕ Если в системе уже установлены старые версии probe-run, probe-rs или cargo-embed, удалите их перед этим шагом: они могут впоследствии вызвать проблемы. В частности, probe-run официально больше не существует. При необходимости попробуйте такие команды:
$ cargo uninstall cargo-embed
$ cargo uninstall probe-run
$ cargo uninstall probe-rs
$ cargo uninstall probe-rs-cli
Чтобы установить probe-rs-tools, перейдите на https://probe.rs и следуйте актуальным инструкциям по установке.
-
ПРИМЕЧАНИЕ Если вы предпочитаете установить
probe-rs-toolsчерезcargo install, можно попробовать следующие шаги. С этим способом часто возникают сбои, но попробовать его вы можете.-
Обновите Rust до самой свежей стабильной версии.
-
Установите необходимые зависимости для
probe-rs-tools. (Ссылка ведёт на часть общей документацииprobe-rs, набора инструментов для отладки встраиваемых систем.) -
Попробуйте установку:
$ cargo install --locked probe-rs-tools
-
Установка probe-rs-tools поставит несколько полезных инструментов, в том числе probe-rs и cargo-embed (который обычно запускается как команда Cargo). Перед тем как двигаться дальше, проверьте, что всё работает.
$ cargo embed --version
cargo-embed 0.24.0 (git commit: crates.io)
Этот репозиторий
Книга также содержит небольшие проекты на Rust, которые используются в разных главах. Проще всего воспользоваться ими, скачав исходный код книги. Сделать это можно одним из следующих способов:
-
Откройте репозиторий, нажмите зелёную кнопку «Code», а затем «Download ZIP». Книга находится в каталоге
discovery-mb2. -
Склонируйте его с помощью
git(если вы знаетеgit, он у вас, скорее всего, уже установлен) из того же репозитория, который указан выше для ZIP-архива.
Инструкции для конкретной ОС
Теперь следуйте инструкциям для вашей операционной системы:
Linux
Ниже приведены команды установки для нескольких дистрибутивов Linux.
Ubuntu 20.04 или новее / Debian 10 или новее
ПРИМЕЧАНИЕ
gdb-multiarch— это команда GDB, которой вы будете отлаживать программы для Arm Cortex-M.
$ sudo apt install gdb-multiarch minicom libunwind-dev
Fedora 32 или новее
ПРИМЕЧАНИЕ
gdb— это команда GDB, которой вы будете отлаживать программы для Arm Cortex-M.
$ sudo dnf install gdb minicom libunwind-devel
Arch Linux
ПРИМЕЧАНИЕ
gdb— это команда GDB, которой вы будете отлаживать программы для Arm Cortex-M.
$ sudo pacman -S arm-none-eabi-gdb minicom libunwind
Другие дистрибутивы
ПРИМЕЧАНИЕ
arm-none-eabi-gdb— это команда GDB, которой вы будете отлаживать программы для Arm Cortex-M.
Если для вашего дистрибутива нет пакетов готового тулчейна Arm, скачайте файл «Linux 64-bit» и добавьте его каталог bin в PATH. Вот один из способов:
$ mkdir -p ~/local
$ cd ~/local
$ tar xjf /path/to/downloaded/XXX.tar.bz2
Затем с помощью любимого редактора добавьте строку в PATH в подходящий файл инициализации оболочки (например, ~/.zshrc или ~/.bashrc):
PATH=$PATH:$HOME/local/XXX/bin
Правила udev
Эти правила позволяют работать с USB-устройствами, например с micro:bit, без привилегий root, то есть без sudo.
Создайте этот файл в /etc/udev/rules.d с содержимым, показанным ниже.
$ cat /etc/udev/rules.d/69-microbit.rules
# CMSIS-DAP для micro:bit
ACTION!="add|change", GOTO="microbit_rules_end"
SUBSYSTEM=="usb", ATTR{idVendor}=="0d28", ATTR{idProduct}=="0204", TAG+="uaccess"
LABEL="microbit_rules_end"
Затем перезагрузите правила udev командой:
$ sudo udevadm control --reload
Если к компьютеру были подключены платы, отключите их и подключите снова или выполните следующую команду.
$ sudo udevadm trigger
Проверка прав доступа
Подключите micro:bit к компьютеру с помощью USB-кабеля.
Теперь micro:bit должен появиться как USB-устройство (файл) в /dev/bus/usb. Узнаем, под каким номером его зарегистрировала система:
$ lsusb | grep -i "NXP Arm mbed"
Bus 001 Device 065: ID 0d28:0204 NXP Arm mbed
$ # ^^^ ^^^
В моём случае micro:bit подключился к шине №1 и был зарегистрирован как устройство №65. Это значит, что файл /dev/bus/usb/001/065 и есть micro:bit. Проверим права доступа к файлу:
$ ls -l /dev/bus/usb/001/065
crw-rw-r--+ 1 nobody nobody 189, 64 Sep 5 14:27 /dev/bus/usb/001/065
Права должны быть crw-rw-r--+, обратите внимание на + в конце. Затем посмотрите свои права доступа, выполнив следующую команду:
$ getfacl /dev/bus/usb/001/065
getfacl: Removing leadin '/' from absolute path names
# file: dev/bus/usb/001/065
# owner: nobody
# group: nobody
user::rw-
user:<YOUR-USER-NAME>:rw-
group::rw-
mask::rw-
other::r-
В списке выше должно быть ваше имя пользователя с правами rw-. Если его нет, проверьте свои правила udev и попробуйте перезагрузить их:
$ sudo udevadm control --reload
$ sudo udevadm trigger
Теперь перейдите к следующему разделу.
Windows
arm-none-eabi-gdb
Arm предоставляет установщики .exe для Windows. Скачайте нужный здесь и следуйте инструкциям. Незадолго до завершения установки отметьте пункт «Add path to environment variable» (добавить путь в переменную окружения). Затем проверьте, что инструменты есть в вашем %PATH%:
$ arm-none-eabi-gcc -v
(..)
gcc version 5.4.1 20160919 (release) (..)
PuTTY
Скачайте последнюю версию putty.exe с этого сайта и поместите его в папку, которая есть в вашем %PATH%.
Теперь перейдите к следующему разделу.
macOS
Все инструменты можно установить через Homebrew:
$ # Отладчик GDB: версия из brew собрана для всех архитектур, включая все встраиваемые ядра ARM
$ brew install gdb
$ # Minicom
$ brew install minicom
$ # lsusb выводит список USB-портов
$ brew install lsusb
Вот и всё! Переходите к следующему разделу.
Проверка установки
Давайте проверим, что все инструменты установлены правильно.
Проверка cargo-embed
Для начала подключите micro:bit к компьютеру с помощью USB-кабеля.
Как минимум должен загореться оранжевый светодиод рядом с USB-разъёмом micro:bit. Кроме того, если вы ещё не прошивали micro:bit другой программой, стандартная программа, которая на нём установлена, должна начать мигать красными светодиодами на задней стороне: их можно не замечать или поиграть с демонстрационным приложением.
Теперь проверим, видит ли probe-rs, а значит и cargo-embed, ваш micro:bit. Для этого выполните команду:
$ probe-rs list
The following debug probes were found:
[0]: BBC micro:bit CMSIS-DAP -- 0d28:0204:990636020005282030f57fa14252d446000000006e052820 (CMSIS-DAP)
Если же хотите узнать больше о возможностях отладки micro:bit, выполните:
$ probe-rs info
Probing target via JTAG
Error identifying target using protocol JTAG: The probe does not support the JTAG protocol.
Probing target via SWD
Arm Chip with debug port Default:
Debug Port: DPv1, DP Designer: Arm Ltd
├── 0 MemoryAP
│ └── ROM Table (Class 1), Designer: Nordic VLSI ASA
│ ├── Cortex-M4 SCS (Generic IP component)
│ │ └── CPUID
│ │ ├── IMPLEMENTER: Arm Ltd
│ │ ├── VARIANT: 0
│ │ ├── PARTNO: Cortex-M4
│ │ └── REVISION: 1
│ ├── Cortex-M3 DWT (Generic IP component)
│ ├── Cortex-M3 FBP (Generic IP component)
│ ├── Cortex-M3 ITM (Generic IP component)
│ ├── Cortex-M4 TPIU (Coresight Component)
│ └── Cortex-M4 ETM (Coresight Component)
└── 1 Unknown AP (Designer: Nordic VLSI ASA, Class: Undefined, Type: 0x0, Variant: 0x0, Revision: 0x0)
Debugging RISC-V targets over SWD is not supported. For these targets, JTAG is the only supported protocol. RISC-V specific information cannot be printed.
Debugging Xtensa targets over SWD is not supported. For these targets, JTAG is the only supported protocol. Xtensa specific information cannot be printed.
Затем убедитесь, что вы находитесь в каталоге discovery-mb2/src/03-setup исходного кода книги, и выполните команды:
$ rustup target add thumbv7em-none-eabihf
$ cargo embed --target thumbv7em-none-eabihf
Если всё работает правильно, cargo-embed сначала соберёт небольшую программу-пример из этого каталога, затем прошьёт её и, наконец, откроет текстовый интерфейс, который выводит «Hello World».
(Если этого не произошло, обратитесь к общим инструкциям по устранению неполадок.)
Этот вывод выдаёт небольшая программа на Rust, которую вы только что прошили на micro:bit. Всё работает как надо, и можно переходить к следующим главам!
Как получить максимум от IDE
Весь код в этой книге предполагает, что вы используете простой терминал для сборки, запуска и взаимодействия с программой. Книга также ничего не предполагает о вашем текстовом редакторе.
Однако у вас может быть любимая IDE, которая предоставляет автодополнение, аннотации типов, привычные горячие клавиши и многое другое. В этом разделе рассказано, как получить максимум от IDE, работая с кодом из репозитория книги.
Настройка IDE
Ниже объясняется, как настроить IDE, чтобы извлечь максимум из этой книги. Если вашей IDE нет в списке, пожалуйста, дополните книгу разделом о ней, чтобы следующие читатели тоже получили хороший опыт.
Сборка в IntelliJ
При редактировании конфигурации сборки IntelliJ вот несколько значений, отличающихся от стандартных:
- Нужно изменить команду. Когда книга предлагает выполнить
cargo embed FLAGS, замените стандартное значениеrunна командуembed FLAGS, - Нужно включить «Emulate terminal in output console». Иначе программа не сможет выводить текст в терминал.
- Убедитесь, что рабочий каталог —
discovery-mb2/src/N-name, гдеN-name— каталог главы, которую вы читаете. Запускать из каталогаsrcнельзя, так как в нём нет файла Cargo.
Знакомство с оборудованием
Познакомимся с оборудованием, с которым будем работать.
micro:bit
Вот некоторые из многочисленных компонентов платы:
- Микроконтроллер.
- Несколько светодиодов (LED), самый заметный из которых — матрица светодиодов на обратной стороне.
- Две пользовательские кнопки и кнопка сброса (та, что рядом с USB-разъёмом).
- Один USB-разъём.
- Датчик, который одновременно является магнитометром и акселерометром.
Из всех этих компонентов важнее всего микроконтроллер (его иногда сокращают до «MCU» от «microcontroller unit»), который является большим из двух чёрных квадратов на стороне платы с USB-разъёмом. MCU выполняет ваш код. Иногда можно встретить выражение «программирование платы», хотя на самом деле мы программируем MCU, установленный на плате.
Если вас интересует более подробное описание платы, загляните на сайт micro:bit.
Раз MCU так важен, давайте внимательнее посмотрим на тот, что установлен на нашей плате.
Nordic nRF52833 («nRF52», micro:bit v2)
Под нашим MCU находятся 73 крошечных металлических вывода (это так называемый чип aQFN73). Эти выводы соединены с дорожками — маленькими «дорогами», которые служат проводами, соединяющими компоненты на плате. MCU может динамически менять электрические свойства выводов. Это работает примерно как выключатель света: меняется то, как электрический ток проходит по цепи. Включая или выключая ток через конкретный вывод, можно зажигать и гасить светодиод, подключённый к этому выводу (через дорожки).
Каждый производитель использует собственную схему обозначений, но по номеру детали часто можно узнать о компоненте многое. Номер детали нашего MCU — N52833 QIAAA0 2024AL. Невооружённым глазом его, скорее всего, не разглядеть, но он нанесён на чип. (Если у вас более поздняя ревизия MB2, номер может немного отличаться. Это не проблема, но часть N52833 должна присутствовать.) Буква N в начале подсказывает, что деталь выпущена компанией Nordic Semiconductor. Найдя номер детали на их сайте, мы быстро попадаем на страницу продукта. Там мы узнаём, что главная особенность чипа — это «Bluetooth Low Energy и 2.4 GHz SoC» (SoC — «system on a chip», «система на кристалле»), что объясняет и RF в названии: RF — это радиочастота (radio frequency). Если немного поискать, на [странице продукта] обнаружится ссылка на спецификацию продукта. В ней глава 10, «Ordering Information», посвящена расшифровке странного обозначения чипа. Там мы узнаём следующее:
N52— серия MCU, указывающая на то, что существуют и другие MCUnRF52833— код моделиQI— код корпуса, сокращение отaQFN73AA— код варианта, показывающий, сколько в MCU оперативной и флеш-памяти: в нашем случае 512 КБ флеш-памяти и 128 КБ оперативной памятиA0— код сборки: версия аппаратного обеспечения (A) и конфигурация продукта (0)2024AL— служебный код, поэтому на вашем чипе он может отличаться
Конечно, [спецификация продукта] содержит гораздо больше полезной информации о чипе, например то, что это 32-битный процессор Arm® Cortex™-M4.
Что за Arm и Cortex-M4?
Если наш чип выпускает Nordic, то кто такой Arm? А если наш чип — nRF52833, то что такое Cortex-M4?
Может быть, вас удивит, что, хотя чипы на основе «Arm» очень популярны, компания, владеющая торговой маркой «Arm» (Arm Holdings), на самом деле не выпускает чипы для продажи. Её основная бизнес-модель — проектировать отдельные части чипов. Затем она лицензирует эти разработки производителям, которые, в свою очередь, реализуют их (иногда с собственными доработками) в виде физического оборудования, которое затем продаётся. Стратегия Arm отличается от стратегии таких компаний, как Intel, которая и проектирует, и производит свои чипы.
Arm лицензирует множество разных разработок. Семейство «Cortex-M» в основном используется как ядро микроконтроллеров. Например, Cortex-M4 (ядро, на котором основан наш чип) рассчитано на низкую стоимость и малое энергопотребление. Cortex-M7 дороже, но обладает большими возможностями и производительностью.
К счастью, для этой книги не нужно много знать о разных типах процессоров или проектах Cortex. Надеюсь, однако, что теперь вы немного лучше разбираетесь в терминологии своего устройства. Хотя вы работаете именно с nRF52833, вам может встретиться документация и инструменты для чипов на базе Cortex-M, ведь nRF52833 построен на разработке Cortex-M.
Терминология Rust Embedded
Прежде чем приступить к программированию micro:bit, быстро познакомимся с библиотеками и терминологией, которые будут важны во всех следующих главах.
Уровни абстракции
Для любого полностью поддерживаемого микроконтроллера или платы с микроконтроллером вы обычно услышите такие термины для обозначения уровней абстракции:
Peripheral Access Crate (PAC)
Задача PAC — предоставить относительно безопасный прямой интерфейс к периферии чипа, позволяя настроить каждый бит так, как вам нужно (разумеется, в том числе и неправильно). Обычно с PAC приходится работать, только если верхние уровни не удовлетворяют вашим потребностям или если вы разрабатываете для них код более высокого уровня. Неудивительно, что PAC, который мы (по большей части неявно) будем использовать, — это PAC для nRF52.
Hardware Abstraction Layer (HAL)
Задача HAL — надстроиться над PAC чипа и предоставить абстракцию, которой действительно удобно пользоваться тому, кто не знает всех особенностей этого чипа. Обычно HAL скрывает целую периферию за отдельными структурами, с помощью которых, например, можно передавать данные через периферийное устройство. Мы будем использовать nrf52-hal.
Board Support Crate (BSP)
(В не-Rust окружении это обычно называют Board Support Package — отсюда и аббревиатура.)
Задача BSP — абстрагировать сразу всю плату (например, micro:bit). Это значит, что он должен предоставлять абстракции и для работы с микроконтроллером, и для датчиков, светодиодов и прочих компонентов, которые могут быть на плате. Довольно часто (особенно для самодельных плат) готового BSP не существует. Вместо этого вы будете работать с HAL для чипа и либо сами писать драйверы для датчиков, либо искать их на crates.io. К счастью, для micro:bit есть BSP, поэтому мы будем использовать его поверх нашего HAL.
Объединение уровней
Далее рассмотрим очень важную часть программного обеспечения в экосистеме Rust Embedded: embedded-hal. Как следует из названия, она относится ко второму уровню абстракции, с которым мы уже познакомились: к HAL. Идея embedded-hal — предоставить набор трейтов, описывающих поведение, которое обычно общее для всех реализаций конкретной периферии во всех HAL. Например, почти всегда нужны функции, которые включают и выключают вывод (скажем, чтобы зажечь и погасить светодиод на плате).
embedded-hal позволяет написать драйвер для какого-либо устройства, например датчика температуры, который можно использовать на любом чипе, для которого существует реализация трейтов embedded-hal. Этого добиваются, написав драйвер так, чтобы он опирался только на трейты embedded-hal. Такие драйверы называют не зависящими от платформы (platform-agnostic). К счастью для нас, драйверы, которые мы будем брать с crates.io, почти все не зависят от платформы.
Дополнительные материалы
Если хотите узнать больше об этих уровнях абстракции, Франц Скарман (он же TheZoq2) выступил с докладом на эту тему на конференции Oxidize 2020: An Overview of the Embedded Rust Ecosystem.
Знакомство с программным обеспечением
В этой главе мы научимся собирать, запускать и отлаживать несколько очень простых программ. Цель — не углубляться в детали программирования MB2 на Rust (пока), а просто освоить сам процесс.
Сначала короткое пояснение об условных обозначениях, которые используются в остальной части книги. Предполагается, что вы получите копию репозитория со всеми книгами сборника с помощью команды:
git clone https://github.com/r007/EmbeddedRust-Rus.git
«Исходный код» книги находится в каталоге discovery-mb2/src этого репозитория. Зайдите туда в своей копии и осмотритесь. В каталоге каждой главы есть и исходный текст в Markdown, и полный исходный код всех программ этой главы. Когда мы упоминаем путь вида src/main.rs, имеется в виду место относительно главы, с которой вы работаете. Например, в вашей копии есть файл discovery-mb2/src/05-meet-your-software/examples/init.rs. В этой главе мы будем называть его просто examples/init.rs.
Есть два основных вида кода на Rust: исполняемые программы («binary») и библиотечный код («library»). Библиотечный код в этой книге большой роли играть не будет. Исходный код исполняемых программ может находиться в одном из нескольких мест:
-
Программа в
src/main.rsбудет автоматически собрана и запущена командойcargo embedилиcargo run. Специальные флаги не нужны. -
Программу в
examples/foo.rsможно собрать и запустить командойcargo embed --example fooилиcargo run --example foo. -
Программу в
src/bin/bar.rsможно собрать и запустить командойcargo embed --bin barилиcargo run --bin bar.
Это может запутать, но это стандартное соглашение Cargo.
Теперь перейдём к делу и поработаем со всем этим.
Настройка для встраиваемой разработки
Посмотрим на первую программу, которую будем собирать. Откройте файл examples/init.rs:
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m::asm;
use cortex_m_rt::entry;
use microbit as _;
use panic_halt as _;
#[entry]
fn main() -> ! {
#[allow(clippy::needless_late_init)]
let _y;
let x = 42;
_y = x;
// бесконечный цикл, чтобы не покидать этот стековый фрейм
loop {
asm::nop();
}
}
Программы для микроконтроллеров отличаются от обычных программ в двух аспектах: #![no_std] и #![no_main].
Атрибут no_std означает, что программа не будет использовать крейт std, который предполагает наличие операционной системы; вместо этого программа будет использовать крейт core — подмножество std, которое может работать на bare-metal системах (то есть на системах без абстракций ОС вроде файлов и сокетов).
Атрибут no_main означает, что программа не использует стандартный интерфейс main, рассчитанный на консольные приложения, которые получают аргументы. Вместо стандартного main мы используем атрибут entry из крейта cortex-m-rt, чтобы определить собственную точку входа. В этой программе точку входа назвали main, но можно было выбрать любое другое имя. Функция точки входа должна иметь сигнатуру fn() -> !; этот тип показывает, что функция не может вернуть управление. Это значит, что программа никогда не завершается возвратом из main: если компилятор обнаружит такую возможность, он откажется собирать программу.
Если вы внимательны, то заметите и скрытый каталог .cargo в проекте Cargo. В нём лежит файл конфигурации Cargo .cargo/config.toml.
[build]
target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip nRF52833_xxAA"
rustflags = [
"-C", "linker=rust-lld",
]
Этот файл настраивает процесс компоновки, подгоняя раскладку памяти программы под требования целевого устройства. Изменённый процесс линковки — требование крейта cortex-m-rt. Файл .cargo/config.toml также сообщает Cargo, как собирать и запускать код на нашем MB2.
Здесь также есть файл Embed.toml:
[default.general]
chip = "nrf52833_xxAA"
[default.reset]
halt_afterwards = true
[default.rtt]
enabled = false
[default.gdb]
enabled = true
Этот файл сообщает cargo-embed, что:
- Мы работаем с NRF52833.
- После прошивки чип нужно остановить, чтобы программа остановилась до
main. - Нужно отключить RTT. RTT — протокол, который позволяет чипу отправлять текст отладчику. Вы уже видели RTT в действии: именно по этому протоколу в главе 3 выводилась строка «Hello World».
- Нужно включить GDB. Это понадобится для отладки.
Теперь, когда мы разобрались, что происходит, начнём со сборки этой программы.
Сборка
Первый шаг — собрать наш «бинарный» крейт. Поскольку архитектура микроконтроллера отличается от архитектуры вашего компьютера, придётся выполнять кросс-компиляцию. В мире Rust кросс-компиляция делается просто: нужно передать дополнительный флаг --target в rustc или Cargo. Сложнее всего понять, какой аргумент передать этому флагу: название цели (target).
Как мы уже знаем, в микроконтроллере micro:bit v2 стоит процессор Cortex-M4F. rustc умеет кросс-компилировать под архитектуру Cortex-M и предоставляет несколько целей, которые покрывают разные семейства процессоров в этой архитектуре:
thumbv6m-none-eabi— для процессоров Cortex-M0 и Cortex-M1thumbv7m-none-eabi— для процессора Cortex-M3thumbv7em-none-eabi— для процессоров Cortex-M4 и Cortex-M7thumbv7em-none-eabihf— для процессоров Cortex-M4F и Cortex-M7Fthumbv8m.main-none-eabi— для процессоров Cortex-M33 и Cortex-M35Pthumbv8m.main-none-eabihf— для процессоров Cortex-M33F и Cortex-M35PF
«Thumb» здесь означает версию системы команд Arm с более короткими инструкциями, которые уменьшают размер кода (это игра слов). Части hf/F означают аппаратное ускорение операций с плавающей точкой. Благодаря этому вычисления с дробными числами («числами с плавающей точкой») работают заметно быстрее.
Для micro:bit v2 нам понадобится цель thumbv7em-none-eabihf.
Перед кросс-компиляцией нужно скачать предварительно собранную версию стандартной библиотеки (точнее, её урезанную версию) для вашей цели. Это делается с помощью rustup:
$ rustup target add thumbv7em-none-eabihf
Этот шаг нужно выполнить только один раз: затем rustup будет обновлять эту цель (переустанавливая новый компонент стандартной библиотеки rust-std, который содержит используемую нами библиотеку core) при каждом обновлении тулчейна. Поэтому этот шаг можно пропустить, если вы уже добавили нужную цель при проверке установки.
Когда компонент rust-std установлен, можно кросс-компилировать программу с помощью Cargo. Убедитесь, что вы находитесь в каталоге discovery-mb2/src/05-meet-your-software репозитория Git, и выполните сборку. Этот начальный код — пример, поэтому собираем его как пример.
$ cargo build --example init
Compiling semver-parser v0.7.0
Compiling proc-macro2 v1.0.86
...
Finished dev [unoptimized + debuginfo] target(s) in 33.67s
ПРИМЕЧАНИЕ Убедитесь, что собираете этот крейт без оптимизаций. Предоставленный файл
Cargo.tomlи команда сборки выше гарантируют, что оптимизации выключены, пока вы не передаётеcargoфлаг--release.
Итак, мы получили исполняемый файл. Он не будет мигать светодиодами: это упрощённая версия, на основе которой мы позже построим остальное в этой главе. Для проверки убедимся, что полученный файл действительно является бинарником для Arm. (Команда ниже эквивалентна
readelf -h ../../../target/thumbv7em-none-eabihf/debug/examples/init
в системах, где есть readelf.)
$ cargo readobj --example init -- --file-headers
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Arm
Version: 0x1
Entry point address: 0x117
Start of program headers: 52 (bytes into file)
Start of section headers: 793112 (bytes into file)
Flags: 0x5000400
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 4
Size of section headers: 40 (bytes)
Number of section headers: 21
Section header string table index: 19
Если ваши числа не совпадают в точности, не волнуйтесь: многое из этого сильно зависит от текущего окружения сборки.
Далее мы прошьём программу в наш микроконтроллер.
Прошивка
Прошивка — это процесс записи нашей программы в энергонезависимую память микроконтроллера. После прошивки микроконтроллер будет выполнять записанную программу каждый раз, когда его включают.
Наша программа будет единственной программой в памяти микроконтроллера. Я имею в виду, что на микроконтроллере больше ничего не работает: ни ОС, ни «демона», ничего. Наша программа полностью контролирует устройство.
Сама прошивка бинарника довольно проста благодаря cargo embed.
Но прежде чем выполнить эту команду, посмотрим, что она на самом деле делает. Если повернуть micro:bit USB-разъёмом вверх, на нём можно увидеть три чёрных квадрата. Самый большой — динамик. Другой — наш уже упомянутый MCU… а для чего нужен третий? Этот чип — ещё один MCU, NRF52820, почти такой же мощный, как NRF52833, который мы будем программировать! У этого чипа три основных назначения:
- Управление питанием и сбросом нашего MCU NRF52833 через USB-разъём.
- Мост между последовательным портом и USB для нашего MCU.
- Интерфейс для прошивки и отладки нашего NRF52833 (это назначение сейчас важнее всего).
Этот чип работает как мост между нашим компьютером (к которому он подключён по USB) и MCU (с которым он соединён дорожками и обменивается данными по протоколу SWD). Этот мост позволяет прошивать новые бинарники в MCU, проверять состояние программы с помощью отладчика и делать другие полезные вещи.
Ну что, прошьём!
$ cargo embed --example init
(...)
Erasing sectors ✔ [00:00:00] [####################################################################################################################################################] 2.00KiB/ 2.00KiB @ 4.21KiB/s (eta 0s )
Programming pages ✔ [00:00:00] [####################################################################################################################################################] 2.00KiB/ 2.00KiB @ 2.71KiB/s (eta 0s )
Finished flashing in 0.608s
Вы заметите, что cargo-embed не завершается после вывода последней строки. Так и должно быть: не закрывайте cargo-embed — в таком состоянии он понадобится на следующем шаге, то есть для отладки! Кроме того, вы, вероятно, заметили, что cargo build и cargo embed принимают одни и те же флаги. Это потому, что cargo embed сам запускает сборку, а затем прошивает получившийся бинарник в чип. Поэтому в будущем можно пропускать шаг cargo build, если хотите сразу прошить код.
Отладка
Разберёмся, как отлаживать нашу маленькую программу. В ней пока нет по-настоящему интересных ошибок, но для обучения отладке такая программа — лучший вариант.
Как это вообще работает?
Прежде чем отлаживать программу, уделим минуту тому, чтобы понять, что здесь происходит. В предыдущей главе мы уже обсуждали назначение второго чипа на плате и то, как он общается с нашим компьютером, но как именно мы можем им воспользоваться?
Небольшая опция default.gdb.enabled = true в Embed.toml заставила cargo embed после прошивки открыть так называемый «GDB-стаб». Это сервер, к которому может подключиться наш GDB и отправлять ему команды вроде «поставь точку останова по адресу X». Сервер сам решает, как обработать команду. В случае GDB-стаба cargo embed передаёт команду по USB «отладочному зонду» на втором чипе. Именно этот чип общается с MCU вместо нас.
Начинаем отладку!
cargo-embed работает в вашей текущей оболочке. Откройте новую оболочку и вернитесь в каталог проекта. Оказавшись там, сначала откройте бинарник в gdb:
$ gdb ../../../target/thumbv7em-none-eabihf/debug/examples/init
ПРИМЕЧАНИЕ В зависимости от того, какой GDB вы установили, для запуска может понадобиться другая команда. Если забыли, какая именно, посмотрите главу 3.
Часть ../../.. в этой команде нужна, потому что каждый пример-проект входит в рабочее пространство (workspace), которое включает всю книгу. У него единый общий каталог target. Подробнее см. в главе о рабочих пространствах книги по Rust.
ПРИМЕЧАНИЕ Если
cargo-embedвыводит здесь много предупреждений, не беспокойтесь. На данный момент он не полностью реализует протокол GDB, поэтому может не распознать некоторые команды, которые ему отправляет GDB. Пока GDB не падает, всё в порядке.
Теперь нужно подключиться к GDB-стабу. По умолчанию он работает на localhost:1337, поэтому для подключения выполните:
(gdb) target remote :1337
Remote debugging using :1337
0x00000116 in nrf52833_pac::{{impl}}::fmt (self=0xd472e165, f=0x3c195ff7) at /home/nix/.cargo/registry/src/github.com-1ecc6299db9ec823/nrf52833-pac-0.9.0/src/lib.rs:157
157 #[derive(Copy, Clone, Debug)]
ПРИМЕЧАНИЕ Пример в репозитории для этой главы может со временем меняться. Поэтому номера строк и другие детали исходного кода могут отличаться от показанных здесь и ниже.
Если программа не останавливается после запуска и вы оказываетесь где-то глубже в программе, как показано ниже, попробуйте выполнить
monitor reset halt, чтобы сбросить её. Это связано с ошибкой вprobe-rs; подробности см. в issue #27.(gdb) target remote :1337 Remote debugging using :1337 init::__cortex_m_rt_main () at mdbook/src/05-meet-your-software/examples/init.rs:19 19 asm::nop(); (gdb) monitor reset halt Resetting and halting target Target halted
Далее нам нужно попасть в функцию main нашей программы. Для этого сначала поставим там точку останова, а затем продолжим выполнение программы, пока не дойдём до неё:
(gdb) break main
Breakpoint 1 at 0x104: file src/05-meet-your-software/examples/init.rs, line 9.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) continue
Continuing.
Breakpoint 1, init::__cortex_m_rt_main_trampoline () at src/05-meet-your-software/examples/init.rs:9
9 #[entry]
Точки останова позволяют остановить обычный ход выполнения программы. Команда continue позволяет программе работать свободно, пока она не дойдёт до точки останова. В данном случае — до функции main, потому что там стоит точка останова.
Обратите внимание, что GDB пишет «Breakpoint 1». Помните, что процессор может использовать ограниченное количество таких точек, поэтому полезно следить за этими сообщениями. Если точки закончатся, можно вывести все текущие командой info break и удалить ненужные командой delete <breakpoint-num>.
Для более удобной отладки будем использовать текстовый пользовательский интерфейс GDB (TUI). Чтобы включить этот режим, введите в оболочке GDB следующую команду:
(gdb) layout src
ПРИМЕЧАНИЕ Извиняемся перед пользователями Windows: GDB из набора GNU Arm Embedded Toolchain не поддерживает этот режим TUI
:-(.
Команда break в GDB работает не только с именами функций: можно ставить точку и на определённую строку. Чтобы остановиться на строке 13, достаточно написать:
(gdb) break 13
Breakpoint 2 at 0x110: file src/05-meet-your-software/examples/init.rs, line 13.
(gdb) continue
Continuing.
Breakpoint 2, init::__cortex_m_rt_main () at src/05-meet-your-software/examples/init.rs:13
(gdb)
В любой момент можно выйти из режима TUI командой:
(gdb) tui disable
Теперь мы «стоим» на инструкции _y = x; эта инструкция ещё не выполнена. Это значит, что x уже инициализирована, а _y может содержать что угодно. Посмотрим на x с помощью команды print:
(gdb) print x
$1 = 42
(gdb) print &x
$2 = (*mut i32) 0x20003fe8
(gdb)
Как и ожидалось, x содержит значение 42. Команда print &x выводит адрес переменной x. Интересно здесь то, что вывод GDB показывает и тип ссылки: *mut i32 — указатель на изменяемое значение i32.
Если хотим выполнять программу построчно, используем команду next. Перейдём к оператору loop {}:
(gdb) next
16 loop {}
Теперь _y должна быть инициализирована.
(gdb) print _y
$5 = 42
Вместо того чтобы выводить локальные переменные по одной, можно использовать команду info locals:
(gdb) info locals
x = 42
_y = 42
(gdb)
Если снова вызвать next на операторе loop {}, мы застрянем, потому что программа никогда не пройдёт дальше этого оператора. Поэтому переключимся на вид дизассемблера командой layout asm и будем двигаться по одной инструкции с помощью stepi. Вернуться к исходному коду Rust можно в любой момент командой layout src.
ПРИМЕЧАНИЕ Если вы по ошибке вызвали
nextилиcontinue, и GDB завис, выйти из этого состояния можно нажатиемCtrl+C.
(gdb) layout asm
Если вы не используете режим TUI, можно воспользоваться командой disassemble /m, чтобы дизассемблировать программу вокруг текущей строки.
(gdb) disassemble /m
Dump of assembler code for function _ZN12init18__cortex_m_rt_main17h3e25e3afbec4e196E:
10 fn main() -> ! {
0x0000010a <+0>: sub sp, #8
0x0000010c <+2>: movs r0, #42 ; 0x2a
11 let _y;
12 let x = 42;
0x0000010e <+4>: str r0, [sp, #0]
13 _y = x;
0x00000110 <+6>: str r0, [sp, #4]
14
15 // бесконечный цикл, чтобы не покидать этот стековый фрейм
16 loop {}
=> 0x00000112 <+8>: b.n 0x114 <_ZN12init18__cortex_m_rt_main17h3e25e3afbec4e196E+10>
0x00000114 <+10>: b.n 0x114 <_ZN12init18__cortex_m_rt_main17h3e25e3afbec4e196E+10>
End of assembler dump.
Видите жирную стрелку => слева? Она показывает инструкцию, которую процессор выполнит следующей.
Если вы не в режиме TUI, после каждой команды stepi GDB выводит оператор и номер строки инструкции, которую процессор выполнит следующей.
(gdb) stepi
16 loop {}
(gdb) stepi
16 loop {}
Напоследок ещё один приём, прежде чем перейдём к чему-то более интересному. Введите в GDB такие команды:
(gdb) monitor reset
(gdb) c
Continuing.
Breakpoint 1, init::__cortex_m_rt_main_trampoline () at src/05-meet-your-software/src/main.rs:9
9 #[entry]
(gdb)
Теперь мы снова в начале main!
monitor reset сбросит микроконтроллер и остановит его прямо в точке входа в программу. Следующая команда continue позволит программе работать свободно, пока она не дойдёт до функции main, на которой стоит точка останова.
Эта комбинация удобна, когда вы по ошибке пропустили часть программы, которую хотели посмотреть. Так легко откатить состояние программы к самому началу.
Мелкий шрифт: команда
resetне очищает и не трогает RAM. Эта память сохранит значения, оставшиеся от предыдущего запуска. Обычно это не проблема, если только поведение программы не зависит от значений неинициализированных переменных, а это и есть определение неопределённого поведения (Undefined Behavior, UB).
На этом сеанс отладки заканчиваем. Завершить его можно командой quit.
(gdb) quit
A debugging session is active.
Inferior 1 [Remote target] will be detached.
Quit anyway? (y or n) y
Detaching from program: $PWD/target/thumbv7em-none-eabihf/debug/meet-your-software, Remote target
Ending remote debugging.
[Inferior 1 (Remote target) detached]
ПРИМЕЧАНИЕ Если стандартный интерфейс командной строки GDB вам не по душе, посмотрите gdb-dashboard. Он использует Python, чтобы превратить командную строку GDB в панель, которая показывает регистры, исходный код, ассемблер и многое другое.
Если хотите узнать больше о возможностях GDB, загляните в раздел Как пользоваться GDB.
Что дальше? Обещанный API высокого уровня.
Зажигаем светодиод
Закончим главу тем, что зажжём один из многочисленных светодиодов MB2. Для этого воспользуемся одним из трейтов, которые предоставляет embedded-hal, а именно трейтом OutputPin, который позволяет включать и выключать вывод.
Светодиоды micro:bit
На обратной стороне micro:bit видно квадрат 5×5 из светодиодов, который обычно называют матрицей светодиодов (LED matrix). Такое расположение позволяет обойтись не 25 отдельными выводами для управления каждым светодиодом, а всего 10 (5+5): ими задаётся, какой столбец и какая строка матрицы будут гореть.
Пока для управления светодиодами мы будем использовать крейт microbit-v2. В следующей главе подробно разберём все доступные варианты.
Зажигаем светодиод!
Код, который зажигает светодиод в матрице, на самом деле довольно прост, но требует небольшой подготовки. Сначала посмотрите на src/main.rs, а затем пройдёмся по нему шаг за шагом.
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use microbit::board::Board;
use panic_halt as _;
#[entry]
fn main() -> ! {
let mut board = Board::take().unwrap();
board.display_pins.col1.set_low().unwrap();
board.display_pins.row1.set_high().unwrap();
loop {
core::hint::spin_loop();
}
}
Первые строки до функции main — это базовые импорты и настройка, с которыми мы в основном уже встречались. А вот функция main выглядит совсем иначе, чем всё, что мы видели до сих пор.
Первая строка связана с внутренним устройством большинства HAL, написанных на Rust. Как мы обсуждали ранее, они строятся поверх крейтов PAC, которые владеют (в смысле Rust) всей периферией чипа. Когда мы пишем
let mut board = Board::take().unwrap();
мы забираем всю эту периферию у PAC и связываем её с переменной. В этом случае мы работаем не просто с HAL, а со всем BSP, поэтому вызов также забирает во владение Rust-представление других чипов на плате.
ПРИМЕЧАНИЕ: Если вам интересно, зачем здесь
unwrap(): в теорииtake()может быть вызван более одного раза. Тогда периферия будет представлена двумя разными переменными, и две переменные будут менять один и тот же ресурс, что приведёт к путанице в поведении. Чтобы этого избежать, PAC реализованы так, что при повторном взятии периферии программа запаникует.
(Если всё это вас запутало, в следующей главе всё объясняется ещё раз, подробнее.)
Теперь мы можем зажечь светодиод, подключённый к row1, col1, установив вывод row1 в высокий уровень (то есть включив его). Вывод col1 можно оставить в низком уровне, потому что так устроена схема матрицы светодиодов. Кроме того, embedded-hal спроектирован так, что любая операция с аппаратурой может вернуть ошибку, даже простое включение или выключение вывода. Поскольку в нашем случае это крайне маловероятно, можно просто вызвать unwrap() для результата.
Проверка
Проверить нашу маленькую программу несложно. Запускаем cargo embed и даём ему прошить программу, как и раньше. Затем открываем GDB и подключаемся к GDB-стабу.
$ gdb ../../../target/thumbv7em-none-eabihf/debug/meet-your-software
(gdb) target remote :1337
Remote debugging using :1337
cortex_m_rt::Reset () at /home/nix/.cargo/registry/src/github.com-1ecc6299db9ec823/cortex-m-rt-0.6.12/src/lib.rs:489
489 pub unsafe extern "C" fn Reset() -> ! {
(gdb)
Теперь запускаем программу командой GDB continue: один из светодиодов на передней стороне micro:bit должен загореться.
Привет, мир!
В предыдущем разделе вы написали нечто вроде программы «Hello World». Но для программистов встраиваемых систем «настоящий Hello World» — это заставить светодиод (любой) включаться и выключаться раз в секунду. Такую программу обычно называют «blinky» (мигалка).
Зачем мигалка? Потому что она показывает, что вы достаточно контролируете плату, чтобы выполнить эту простую задачу. Вы можете загрузить программу на устройство и запустить её, найти нужный вывод на MCU и включить его, сделать паузу на фиксированное время. Когда вы контролируете плату на таком уровне, остальные задачи становятся гораздо проще.
В предыдущих главах вы узнали несколько способов загрузить программу на MB2. Теперь остаётся вопрос: какой вывод включать и выключать и как делать паузы между этими действиями.
Начнём с того, как работать с нужными выводами. Один из способов — прочитать электрическую схему («schematic»), если вы умеете это делать. Найдите схему MB2, выберите на ней светодиод, который хотите включать и выключать, и определите, к каким GPIO-выводам nRF52833 он подключён. (В этом отношении MB2 немного необычен: обычно светодиод подключён к одному выводу, который его включает или выключает. Светодиодный «дисплей» MB2 устроен сложнее: он позволяет включать и выключать сразу комбинации светодиодов, и этой возможностью мы скоро воспользуемся.)
Мы будем работать со светодиодом в верхнем левом углу дисплея MB2. Проследив провода ROW1 и COL1, к которым подключён этот светодиод, видим, что они идут к выводам nRF52833 с обозначениями AC17/P0.21 и B11/AIN4/P0.28. Если копнуть документацию глубже, выясняется, что AC17 и B11 — это координаты физических выводов (точнее, шариков припоя) на нижней стороне чипа; нам они не нужны. AIN4 означает, что этот вывод может работать как «аналоговый вход» (Analog Input), но сейчас это тоже нам не важно. (Это пригодится позже.)
Остаются P0.21 и P0.28. Эти обозначения соответствуют битам в памяти nRF52833, которые можно включать и выключать, чтобы зажечь светодиод. Из-за законов электроники, если вывод P0.21 включён (то есть выдаёт 3,3 В), а вывод P0.28 выключен (то есть принимает напряжение), светодиод загорится.
Но что делать в программе, чтобы это произошло? Будем работать на уровне крейта nrf52833-hal. Hardware Abstraction Layer (HAL) — это программный слой, призванный упростить работу с конкретным микроконтроллером. Как видно из названия, у нас есть HAL для микроконтроллера MB2. В нём есть всё необходимое, чтобы включить нужный светодиод.
Посмотрите examples/light-up.rs в каталоге этой главы и попробуйте его запустить. Можно воспользоваться чем-то более продвинутым, как раньше, но мы настроили всё так, что
cargo run --example light-up
загрузит и запустит вашу программу. Тот самый светодиод должен ярко загореться!
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use nrf52833_hal::{gpio, pac};
use panic_halt as _;
#[entry]
fn main() -> ! {
let peripherals = pac::Peripherals::take().unwrap();
let p0 = gpio::p0::Parts::new(peripherals.P0);
let _row1 = p0.p0_21.into_push_pull_output(gpio::Level::High);
let _col1 = p0.p0_28.into_push_pull_output(gpio::Level::Low);
#[allow(clippy::empty_loop)]
loop {}
}
Обратите внимание, что к PAC этого чипа мы обращаемся через наш HAL-крейт. Чтобы получить доступ к выводам, нужна довольно сложная процедура. Наконец, поскольку можно сразу задать выводам нужные уровни при инициализации, их не нужно переключать отдельно. Переключение выводов — тема следующего раздела.
Переключение
Будем включать и выключать светодиод по очереди. Именно так он и начнёт мигать, верно?
В examples/fast-blink.rs находится следующая версия нашей мигалки. Я решил заставить мигать соседний светодиод, а исходный оставить включённым. Это простое изменение.
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use nrf52833_hal::{gpio, pac};
use panic_halt as _;
#[entry]
fn main() -> ! {
let peripherals = pac::Peripherals::take().unwrap();
let p0 = gpio::p0::Parts::new(peripherals.P0);
let _row1 = p0.p0_21.into_push_pull_output(gpio::Level::High);
let mut row2 = p0.p0_22.into_push_pull_output(gpio::Level::Low);
let _col1 = p0.p0_28.into_push_pull_output(gpio::Level::Low);
loop {
row2.set_high().unwrap();
row2.set_low().unwrap();
}
}
Здесь крейт embedded-hal предоставляет трейты Rust, которые нужны, чтобы устанавливать и сбрасывать состояние светодиода. Благодаря этому данная часть кода переносима на любой HAL на Rust, который реализует трейты embedded-hal, как и наш.
Но постойте: ни один светодиод не мигает! Второй горит чуть тусклее первого, но оба светятся ровно… или всё-таки нет? По умолчанию MB2 выполняет 64 миллиона инструкций в секунду. Допустим, включение или выключение светодиода «под капотом» занимает несколько десятков инструкций. (Возможно, столько в отладочной сборке, а в релизной — гораздо меньше. К тому же выводам нужно время, чтобы сменить состояние. Точно не знаю.) В любом случае второй светодиод включается и выключается сотни тысяч, а то и миллионы раз в секунду. Глаз просто не успевает за этим.
Между переключениями нужна пауза. Оказывается, ждать — самая сложная часть.
Активное ожидание
Чтобы светодиод мигал, между переключениями нужна пауза примерно в полсекунды. Как её сделать?
Что ж, вот самый простой, «глупый» способ. Он плохой, но с него можно начать. Посмотрите examples/spin-wait.rs.
#![no_main]
#![no_std]
use cortex_m::asm::nop;
use cortex_m_rt::entry;
use embedded_hal::digital::OutputPin;
use nrf52833_hal::{gpio, pac};
use panic_halt as _;
fn wait() {
for _ in 0..4_000_000 {
nop();
}
}
#[entry]
fn main() -> ! {
let peripherals = pac::Peripherals::take().unwrap();
let p0 = gpio::p0::Parts::new(peripherals.P0);
let mut row1 = p0.p0_21.into_push_pull_output(gpio::Level::High);
let _col1 = p0.p0_28.into_push_pull_output(gpio::Level::Low);
loop {
wait();
row1.set_high().unwrap();
wait();
row1.set_low().unwrap();
}
}
Запустите её командой cargo run --release --example spin-wait — флаг --release здесь очень важен — и вы увидите, что светодиод на MB2 мигает примерно раз в секунду.
Возможно, у вас возникнут вопросы:
-
Что за символы
_в числе? Rust разрешает их в числах и просто игнорирует. Это удобно, чтобы сделать большие числа читаемее. Здесь они выполняют роль разделителей разрядов (запятых или того, что принято в вашей стране для групп из трёх цифр). -
Если nRF52833 работает на 64 МГц, почему цикл ожидания выполняется всего 4 млн раз? Разве не должно быть 32 млн? На каждом проходе цикл выполняет несколько инструкций:
nop(см. следующий раздел), служебные операции и переход назад к началу цикла. Для первого вызоваwait()сгенерированный код примерно такой:.LBB1_4: adds r3, #1 nop cmp r3, r2 bne .LBB1_4а для второго:
.LBB1_6: subs r3, #1 nop bne .LBB1_6Это всего три-четыре инструкции, но обратный переход может стоить ещё немного времени. Обратите внимание, что они не одинаковы: компилятор выбрал разные инструкции для первого и второго циклов ожидания. См. «зависит от обстоятельств» ниже.
Тем не менее на каждой итерации выполняется около 4 инструкций. Значит, на нашем процессоре с частотой 64 МГц полусекундное ожидание должно занимать 64M/2/4 = 8 млн итераций. Получается, что что-то замедляет нас вдвое. Что именно? Честно говоря, не знаю. Всё это ужасно.
-
Почему
--releaseтак важен? Попробуйте запустить без него. Светодиод всё так же будет мигать, но период будет составлять много секунд. Цикл ожидания теперь не оптимизирован и на каждом проходе выполняет много инструкций. -
Что за вызов
nop()и зачем он нужен? Ответим на этот вопрос в следующем разделе. -
Почему вы называете это «глупым способом»?
-
Он неточный. Подогнать цикл так, чтобы он надёжно укладывался ровно в 0,5 секунды, — не реальная задача.
-
Он зависит от обстоятельств. Другой процессор? Другие флаги компиляции? Да что угодно? И время уже другое.
-
Он расходует энергию. Процессор выполняет инструкции на полной скорости просто ради того, чтобы «стоять на месте». Если делать больше нечего, ему лучше тихо уснуть до тех пор, пока он снова не понадобится. При питании от USB это не так важно. Но если подключить MB2 к аккумуляторному блоку, вы это почувствуете.
-
В следующем разделе мы разберём nop(). После этого поговорим и о других недостатках нашей мигалки, которые нужно исправить.
Для такой простой программы она довольно сложна. Поэтому мы и начинаем с мигалки.
NOP
Возможно, вам интересно, что делает вызов nop() в цикле wait() в src/bin/spin-wait.rs.
Ответ: он буквально ничего не делает. Функция nop() заставляет компилятор поместить в это место программы машинную инструкцию Arm NOP. NOP — особая инструкция, из-за которой процессор её пропускает. Игнорирует. Буквально выполняет No OPeration, «операцию-ничего» (отсюда и название).
Итак, уберите эту строку, пересоберите программу — не забудьте режим --release — и запустите её.
Светодиод снова горит ровным светом, только немного тусклее. Без тела цикла оптимизатор компилятора решил, что функция wait() ничего не делает, и просто убрал её на этапе сборки. Спасибо, оптимизатор. Ты сделал мой цикл ожидания бесконечно быстрым.
Как же nop() выполняет свою работу? Если заглянуть в реализацию nop() (после некоторых поисков), окажется, что она устроена так:
#![allow(unused)]
fn main() {
asm!("nop", options(nomem, nostack, preserves_flags));
}
Функция nop() «встраивается» (inlined), поэтому при вызове в код программы в этом месте вставляется настоящая ассемблерная инструкция Arm NOP. Поскольку это ассемблерная вставка, компилятор не удалит и не переместит эту инструкцию: она останется там, где вы её поставили.
Возможность вставлять ассемблерный код в программу там, где это нужно, во встраиваемом программировании иногда очень важна. Бывает, что у процессора есть инструкции, о которых компилятор не знает, но без которых нельзя эффективно использовать процессор. Директива asm!() в Rust даёт возможность это сделать.
Наш цикл ожидания по-прежнему никуда не годится. Поговорим о том, как сделать лучше.
Таймеры
Одно из главных преимуществ «bare-metal» встраиваемой системы в том, что вы контролируете всё, что происходит на вашем устройстве. Это позволяет очень точно управлять временем: ничто не замедлит вас, пока вы сами этого не допустите.
Однако, как мы видели, если действительно хотим отмерять время правильно, без помощи, скорее всего, не обойтись. Встраиваемые MCU вроде nRF52833 предоставляют такую помощь в виде «таймеров». Таймер — это периферийное устройство, которое, как следует из названия, работает как маленькие часы, очень точно отсчитывающие время.
В nRF52833 есть четыре таймера. Если заглянуть в документацию чипа, окажется, что настраивать и использовать их довольно сложно. К счастью, HAL предоставляет обёртку над таймерами, которая упрощает типичные сценарии. Самое частое применение таймера — отсчитывать точный промежуток времени, именно это и пытался делать наш wait() в предыдущих разделах.
Посмотрите examples/timer-blinky.rs. Этот код настраивает таймер и использует его, чтобы делать паузу 500 мс (0,5 с) между переключениями.
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use nrf52833_hal::{gpio, pac, timer};
use panic_halt as _;
#[entry]
fn main() -> ! {
let peripherals = pac::Peripherals::take().unwrap();
let p0 = gpio::p0::Parts::new(peripherals.P0);
let mut row1 = p0.p0_21.into_push_pull_output(gpio::Level::High);
let _col1 = p0.p0_28.into_push_pull_output(gpio::Level::Low);
let mut timer0 = timer::Timer::new(peripherals.TIMER0);
loop {
timer0.delay_ms(500);
row1.set_high().unwrap();
timer0.delay_ms(500);
row1.set_low().unwrap();
}
}
Запустите этот код командой cargo run --release --example timer-blinky и засеките время секундомером. Вы увидите, что каждый цикл «вкл — выкл» длится ровно одну секунду.
Вы могли заметить следующее:
-
Чтобы использовать метод
delay_ms(), нужно подключить трейтembedded_hal::Delay. -
Как и раньше, мы достаём периферию из структуры PAC и передаём её в HAL.
Теперь у нас мигалка промышленного качества. Поговорим немного о том, что из этого следует.
Переносимость
(Этот раздел необязательный. Можете смело перейти к следующему разделу, где мы немного почистим код и на этом закончим.)
Возможно, вы задаётесь вопросом, стоит ли вся эта продвинутая экосистема своих усилий. Настройка нашей мигалки довольно замысловата и использует много крейтов и возможностей Rust для такой простой задачи.
Но у этого есть и приятный плюс: наш код становится по-настоящему переносимым. На другой плате настройка может отличаться, но сам цикл мигалки будет точно таким же!
Посмотрим на мигалку для Sipeed Longan Nano. Это небольшая плата примерно за 5 долларов, которая, как и MB2, является встраиваемой платой с MCU. А во всём остальном она совершенно другая: другой процессор (GD32VF103 с системой команд RISC-V, совсем не похожей на Arm, которую используем мы), другая периферия, другая плата. Но к ней подключён светодиод через GPIO-вывод, так что её тоже можно заставить мигать.
#![no_std]
#![no_main]
use panic_halt as _;
use riscv_rt::entry;
use gd32vf103xx_hal::{pac, prelude::*, delay::McycleDelay};
use embedded_hal::{blocking::delay::DelayMs, digital::v2::OutputPin};
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let mut rcu = dp.RCU.configure().ext_hf_clock(8.mhz()).sysclk(108.mhz()).freeze();
let gpioc = dp.GPIOC.split(&mut rcu);
let mut led = gpioc.pc13.into_push_pull_output();
let mut delay = McycleDelay::new(&rcu.clocks);
loop {
delay.delay_ms(500);
led.set_high().unwrap();
delay.delay_ms(500);
led.set_low().unwrap();
}
}
Различия в настройке объясняются отчасти разным железом, а отчасти тем, что этот код использует более старый HAL-крейт, который ещё не обновлён под embedded-hal 1.0. Тем не менее основной цикл, как и обещано, идентичен, а остальной код довольно узнаваем. Благодаря переносимости, которую дают простая кросс-компиляция Rust и экосистема встраиваемого Rust, мигалка остаётся мигалкой.
Полный рабочий пример nanoblinky есть на GitHub: если хотите увидеть все детали или даже купить собственную плату и попробовать самостоятельно — пожалуйста.
Крейт поддержки платы
Работать напрямую с PAC и HAL — довольно здорово. У большинства Arm-микроконтроллеров и у многих других MCU, под которые умеет собирать Rust, есть PAC-крейт. Если для вашего MCU его нет, написать PAC-крейт может быть утомительно, но в целом это несложно. У многих MCU с PAC-крейтом есть и HAL-крейт; если его нет, его тоже придётся писать, и опять же работа довольно рутинная. Код на уровне PAC и HAL даёт доступ к тонким деталям MCU.
Однако, как мы уже видели, довольно неудобно постоянно следить за тем, что происходит на стыке нашего nRF52833 и остальной части MB2. Нам приходилось читать схемы и прочие документы, чтобы понять, как пользоваться остальным оборудованием платы.
«Крейт поддержки платы» (в не-Rust-сообществе встраиваемых систем известный как Board Support Package, BSP) — это крейт, построенный поверх HAL и PAC для конкретной платы; он скрывает подробности и предоставляет удобные средства. Крейт поддержки платы, с которым мы работали, — это крейт microbit-v2.
Используем microbit-v2, чтобы получить окончательную, аккуратную мигалку (src/main.rs).
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use microbit::hal::{gpio, timer};
use panic_halt as _;
#[entry]
fn main() -> ! {
let board = microbit::Board::take().unwrap();
let mut row1 = board
.display_pins
.row1
.into_push_pull_output(gpio::Level::High);
let _col1 = board
.display_pins
.col1
.into_push_pull_output(gpio::Level::Low);
let mut timer0 = timer::Timer::new(board.TIMER0);
loop {
timer0.delay_ms(500);
row1.set_high().unwrap();
timer0.delay_ms(500);
row1.set_low().unwrap();
}
}
В этот раз мы изменили немного. Наш крейт поддержки платы скрыл PAC (пока что). Что важнее, он сделал это, позволив нам просто использовать понятные имена для строк и столбцов GPIO-выводов светодиода.
Крейт microbit-v2 предоставляет ещё более продвинутую поддержку для светодиодного «дисплея». Скоро мы увидим, как она используется для задач поинтереснее мигалки.
LED-рулетка
Ну что ж, давайте построим «настоящее» приложение. Цель — получить вот такой дисплей с бегущими огоньками:
Работать с выводами светодиодов по отдельности довольно утомительно (особенно если, как здесь, нужны почти все), поэтому можно воспользоваться BSP-крейтом microbit-v2, о котором говорили ранее, и работать со светодиодным «дисплеем» MB2. Это выглядит так (examples/light-it-all.rs):
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use microbit::{board::Board, display::blocking::Display, hal::Timer};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
let mut display = Display::new(board.display_pins);
let light_it_all = [
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
];
loop {
// Показываем light_it_all 1000 мс
display.show(&mut timer, light_it_all, 1000);
// снова очищаем дисплей
display.clear();
timer.delay_ms(1000_u32);
}
}
Массив Rust light_it_all из примера содержит 1 там, где светодиод горит, и 0 там, где он выключен. Вызов show() принимает таймер, который код дисплея BSP использует для задержек, копию массива и время в миллисекундах, в течение которого показывается этот узор, прежде чем функция вернёт управление.
Задание
Теперь у вас достаточно инструментов, чтобы справиться с заданием! Как и прежде, ваше приложение должно выглядеть так:
Если не получается толком разглядеть, что здесь происходит, вот версия намного медленнее:
Если нужна подсказка, в templates/solution.rs есть почти готовый фрагмент кода, который нужно доделать. Но я советую сначала попробовать самостоятельно: к этому моменту это уже должно быть по силам…
Поняли?
Моё решение
Какое решение вы придумали?
Вот моё. Это, вероятно, один из самых простых (хотя, конечно, не самых красивых) способов получить нужную матрицу:
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use microbit::{board::Board, display::blocking::Display, hal::Timer};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
#[rustfmt::skip]
const PIXELS: [(usize, usize); 16] = [
(0, 0),
(0, 1),
(0, 2),
(0, 3),
(0, 4),
(1, 4),
(2, 4),
(3, 4),
(4, 4),
(4, 3),
(4, 2),
(4, 1),
(4, 0),
(3, 0),
(2, 0),
(1, 0),
];
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
let mut display = Display::new(board.display_pins);
#[rustfmt::skip]
let mut leds = [
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
];
let mut last_led = (0, 0);
loop {
for current_led in PIXELS {
leds[last_led.0][last_led.1] = 0;
leds[current_led.0][current_led.1] = 1;
display.show(&mut timer, leds, 200);
last_led = current_led;
}
}
}
И ещё кое-что! Проверьте, что ваше решение работает и в сборке «release»:
$ cargo embed --release
Если хотите отлаживать бинарник «release», понадобится другая команда GDB:
$ gdb ../../../target/thumbv7em-none-eabihf/release/led-roulette
Компилятор Rust изменяет машинные инструкции, которые генерируются в release-сборке (иногда очень сильно), чтобы сделать код быстрее или меньше. К сожалению, после этого GDB с трудом разбирается в происходящем. Поэтому отлаживать release-сборки с GDB бывает непросто.
За размером бинарника всегда стоит следить! Насколько велико ваше решение? Проверить это можно командой size для release-бинарника:
$ cargo size --release -- -A
Finished release [optimized + debuginfo] target(s) in 0.02s
led-roulette :
section size addr
.vector_table 256 0x0
.text 6332 0x100
.rodata 648 0x19bc
.data 0 0x20000000
.bss 1076 0x20000000
.uninit 0 0x20000434
.debug_loc 9036 0x0
.debug_abbrev 2754 0x0
.debug_info 96460 0x0
.debug_aranges 1120 0x0
.debug_ranges 11520 0x0
.debug_str 71325 0x0
.debug_pubnames 32316 0x0
.debug_pubtypes 29294 0x0
.Arm.attributes 58 0x0
.debug_frame 2108 0x0
.debug_line 19303 0x0
.comment 109 0x0
Total 283715
Ваши числа могут немного отличаться в зависимости от того, как собран код: это нормально.
Умеете читать этот вывод? Секция text содержит инструкции программы. Секция rodata содержит данные только для чтения, которые хранятся вместе с инструкциями программы. Секции data и bss содержат переменные, выделенные статически в RAM (static-переменные). Если вы помните характеристики micro:bit, то заметите, что его флеш-память меньше чем вдвое больше размера этого очень простого бинарника. Может ли это быть правдой? Как видно из статистики размеров, основную часть бинарника составляют отладочные секции. Однако они никогда не прошиваются в микроконтроллер: для выполнения программы они не нужны.
Входы и опрос
В предыдущих главах мы в основном использовали выводы GPIO как выходы: включали и выключали светодиоды. Однако выводы GPIO можно настроить и как входы: тогда программа сможет считывать сигналы из внешнего мира, например нажатия кнопок или переключение тумблеров. В этой главе мы научимся считывать такие входные сигналы и делать с ними что-нибудь полезное.
Чтение состояния кнопок
На micro:bit v2 есть две физические кнопки, Button A и Button B, подключённые к выводам GPIO, настроенным как входы. Точнее, Button A подключена к выводу P0.14, а Button B — к выводу P0.23. (Это можно проверить по официальной таблице выводов.)
Чтение состояния входа GPIO сводится к проверке того, высокий ли уровень напряжения на выводе (3,3 В, логическая единица) или низкий (0 В, логический ноль). Каждая кнопка micro:bit подключена к выводу. Когда кнопка не нажата, вывод удерживается в высоком уровне; когда кнопка нажата, вывод удерживается в низком.
Теперь применим эти знания, чтобы считать состояние Button A: проверим, находится ли она в «низком» уровне (то есть нажата).
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::digital::InputPin;
use microbit::Board;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let mut button_a = board.buttons.button_a;
let mut button_state = false;
loop {
if button_a.is_low().unwrap() {
if !button_state {
button_state = true;
rprintln!("Button A pressed");
}
} else if button_state {
button_state = false;
rprintln!("Button A not pressed");
}
}
}
Мы в цикле смотрим на состояние кнопки и сообщаем каждый раз, когда оно меняется.
Опрос
Теперь, когда мы научились читать входы GPIO, подумаем, как это можно применить на практике. Допустим, мы хотим, чтобы программа включала светодиод при нажатии Button A и выключала его при нажатии Button B. Это можно сделать, опрашивая состояние обеих кнопок в цикле и реагируя, когда одна из них оказывается нажатой. Вот как мог бы выглядеть такой код:
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::{InputPin, OutputPin};
use microbit::hal::timer::Timer;
use microbit::{hal::gpio, Board};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
// Настраиваем кнопки
let mut button_a = board.buttons.button_a;
let mut button_b = board.buttons.button_b;
// Настраиваем светодиод (верхний левый, row1, col1)
let mut row1 = board
.display_pins
.row1
.into_push_pull_output(gpio::Level::Low);
let _col1 = board
.display_pins
.col1
.into_push_pull_output(gpio::Level::Low);
loop {
let on_pressed = button_a.is_low().unwrap();
let off_pressed = button_b.is_low().unwrap();
match (on_pressed, off_pressed) {
// Сохраняем текущее состояние, пока ничего не нажато.
(false, false) => (),
// Переходим во включённое состояние.
(true, false) => row1.set_high().unwrap(),
// Переходим в выключенное состояние.
(false, true) => row1.set_low().unwrap(),
// Сохраняем текущее состояние, пока что-нибудь не отпустят.
(true, true) => (),
}
timer.delay_ms(10_u32);
}
}
Такой способ — многократно проверять входы в цикле — называется опросом (polling). Когда мы проверяем состояние какого-либо входа, говорим, что опрашиваем этот вход. В данном случае мы опрашиваем и Button A, и Button B.
Опрос прост, но позволяет делать интересные вещи, основанные на внешнем мире. Для всех входов устройства мы можем по очереди «опрашивать» их в цикле и каким-то образом реагировать на результат. Этот метод очень прост концептуально и хорошо подходит как отправная точка для многих проектов. Вскоре мы увидим, почему опрос может не быть лучшим методом для всех (или даже большинства) случаев, но сначала попробуем его в деле.
Примечание. Слово «опрос» (polling) используется на двух уровнях детализации. На одном уровне «опрос» означает однократный запрос состояния входа. На более высоком уровне «опрос», или «опрос в цикле», означает многократный запрос состояния входа в простом потоке управления, таком как показанный выше. Такое употребление слова для обозначения потока управления встречается только в самых простых программах и редко применяется в реальных проектах (как мы вскоре увидим, это непрактично). Поэтому, когда инженеры встраиваемых систем говорят об опросе, обычно имеют в виду первое: однократный запрос состояния входа.
Задание
Теперь ваша очередь применить опрос на практике. Ваша задача — написать простую программу, которая опрашивает кнопки и показывает стрелки в зависимости от действий пользователя:
- Если нажата кнопка A, показать на матрице светодиодов стрелку влево (←).
- Если нажата кнопка B, показать стрелку вправо (→).
- Если ни одна кнопка не нажата, показать один горящий светодиод в центре матрицы.
Вам понадобится:
- Инициализировать переменные для светодиодов и кнопок.
- Постоянно опрашивать Button A и Button B.
- Обновлять дисплей в зависимости от состояния кнопок, явно отражая каждое состояние (влево, вправо или нейтральное).
Надеюсь, вы не ошибётесь! Так тяжело делить дорогу с теми, кто не пользуется поворотниками как положено.
Моё решение
Вот моё решение (в src/main.rs). Надеюсь, оно было не слишком сложным. Вы скоро увидите, что простой опрос, как здесь, не очень практичен.
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::digital::InputPin;
use microbit::{board::Board, display::blocking::Display, hal::Timer};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
// Описываем узоры светодиодов
const LEFT_ARROW: [[u8; 5]; 5] = [
[0, 0, 1, 0, 0],
[0, 1, 0, 0, 0],
[1, 1, 1, 1, 1],
[0, 1, 0, 0, 0],
[0, 0, 1, 0, 0],
];
const RIGHT_ARROW: [[u8; 5]; 5] = [
[0, 0, 1, 0, 0],
[0, 0, 0, 1, 0],
[1, 1, 1, 1, 1],
[0, 0, 0, 1, 0],
[0, 0, 1, 0, 0],
];
const CENTER_LED: [[u8; 5]; 5] = [
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
];
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
let mut display = Display::new(board.display_pins);
let mut button_a = board.buttons.button_a;
let mut button_b = board.buttons.button_b;
loop {
if button_a.is_low().unwrap() {
display.show(&mut timer, LEFT_ARROW, 10);
} else if button_b.is_low().unwrap() {
display.show(&mut timer, RIGHT_ARROW, 10);
} else {
display.show(&mut timer, CENTER_LED, 10);
}
}
}
Опрос, честно говоря, никуда не годится
Ах да, поворотники обычно мигают, верно? Как бы нам расширить программу, чтобы светодиод-указатель мигал при нажатии кнопки? Мигание светодиода мы уже знаем по программе Hello World: включаем светодиод, ждём некоторое время, потом выключаем. Но как сделать это в главном цикле и при этом проверять нажатия кнопок? Можно попробовать так:
#![allow(unused)]
fn main() {
loop {
if button_a.is_low().unwrap() {
// Мигаем левой стрелкой
display.show(&LEFT_ARROW);
timer.delay_ms(500_u32);
display.show(&BLANK);
timer.delay_ms(500_u32);
} else if button_b.is_low().unwrap() {
// Мигаем правой стрелкой
display.show(&RIGHT_ARROW);
timer.delay_ms(500_u32);
display.show(&BLANK);
timer.delay_ms(500_u32);
} else {
display.show(&BLANK);
}
timer.delay_ms(10_u32);
}
}
Видите проблему? Здесь мы пытаемся делать две вещи одновременно:
- Проверять нажатия кнопок
- Мигать светодиодом
Но процессор может делать только одно дело за раз. Если нажать кнопку во время паузы мигания, процессор не сможет отреагировать, пока пауза не закончится и цикл не начнётся заново. В итоге программа почти не реагирует (попробуйте сами и оцените, насколько медленно срабатывает кнопка).
Более «умная» программа понимала бы, что во время паузы мигания процессор на самом деле ничего не делает. Пока программа ждёт окончания паузы, она вполне может заниматься другими делами, а именно проверять нажатия кнопок.
Суперциклы
Термин суперцикл (superloop) во встраиваемых системах обозначает главный управляющий цикл, который последовательно выполняет множество действий. Это естественное развитие простого потока управления, которым мы пользовались до сих пор. Чтобы обрабатывать логику, которая может восприниматься как несколько одновременно происходящих дел, нужно немного поумнее структурировать программу, чтобы достаточно быстро реагировать на события.
В случае нашей программы-поворотника, где нужно мигать светодиодами при нажатии кнопки и быстро прекращать мигание, когда кнопку отпускают, можно создать «конечный автомат» (state machine), который описывает различные состояния программы. Для кнопок у нас три состояния:
- Ни одна кнопка не нажата
- Нажата кнопка A
- Нажата кнопка B
Для дисплея тоже есть три состояния:
- Ни один светодиод не горит
- Дисплей в активной фазе мигания (светодиоды горят)
- Дисплей в неактивной фазе мигания (светодиоды погашены и ждут, когда закончится период мигания, чтобы снова загореться)
Поскольку важно сохранить отзывчивость, эти состояния нужно объединить. Чтобы полностью описать все состояния программы, получим следующее:
- Ни одна кнопка не нажата
- Нажата кнопка A, и дисплей в активной фазе мигания (на дисплее показана стрелка влево)
- Нажата кнопка A, и дисплей в неактивной фазе мигания (на дисплее ничего не показано)
- Нажата кнопка B, и дисплей в активной фазе мигания (на дисплее показана стрелка вправо)
- Нажата кнопка B, и дисплей в неактивной фазе мигания (на дисплее ничего не показано)
Когда какая-либо кнопка впервые нажимается и мы переходим из состояния (1) в состояние (2) или (4), запускаем счётчик таймера, который отсчитывает время с момента нажатия. Когда таймер достигает порога (например, полсекунды), а кнопка всё ещё нажата, переходим, соответственно, в состояние (3) или (5) и заново инициализируем счётчик. Когда таймер снова достигает порога, возвращаемся в состояние (2) или (4) соответственно. Если в любой момент в состояниях (2), (3), (4) или (5) кнопка оказывается отпущенной, переходим обратно в состояние (1).
Главный поток управления суперцикла будет многократно опрашивать кнопки, сравнивать текущее значение счётчика таймера (если он есть) с порогом и менять состояния, если выполняется одно из перечисленных условий.
Мы реализовали этот суперцикл в качестве демонстрации (examples/blink-held.rs), но упростили конечный автомат: светодиод мигает только тогда, когда удерживается кнопка A.
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::{InputPin, OutputPin};
use microbit::hal::timer::Timer;
use microbit::{hal::gpio, Board};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
const ON_TICKS: u16 = 25;
const OFF_TICKS: u16 = 75;
#[derive(Clone, Copy)]
enum Light {
Lit(u16),
Unlit(u16),
}
impl Light {
fn flip(self) -> Self {
match self {
Light::Lit(_) => Light::Unlit(OFF_TICKS),
Light::Unlit(_) => Light::Lit(ON_TICKS),
}
}
fn tick_down(self) -> Self {
match self {
Light::Lit(ticks) => Light::Lit(ticks.max(1) - 1),
Light::Unlit(ticks) => Light::Unlit(ticks.max(1) - 1),
}
}
}
#[derive(Clone, Copy)]
enum Indicator {
Off,
Blinking(Light),
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
// Настраиваем кнопки
let mut button_a = board.buttons.button_a;
// Настраиваем светодиод (верхний левый, row1, col1)
let mut row1 = board
.display_pins
.row1
.into_push_pull_output(gpio::Level::Low);
let _col1 = board
.display_pins
.col1
.into_push_pull_output(gpio::Level::Low);
let mut state = Indicator::Off;
loop {
let button_pressed = button_a.is_low().unwrap();
match (button_pressed, state) {
// Выключаем индикатор, когда кнопка не нажата.
(false, _) => {
row1.set_low().unwrap();
state = Indicator::Off;
}
//
(true, Indicator::Off) => {
row1.set_high().unwrap();
state = Indicator::Blinking(Light::Lit(ON_TICKS));
}
(true, Indicator::Blinking(light)) => match light {
Light::Lit(0) | Light::Unlit(0) => {
let light = light.flip();
match light {
Light::Lit(_) => row1.set_high().unwrap(),
Light::Unlit(_) => row1.set_low().unwrap(),
}
state = Indicator::Blinking(light);
}
Light::Lit(_) | Light::Unlit(_) => {
state = Indicator::Blinking(light.tick_down());
}
},
}
timer.delay_ms(10_u32);
}
}
Это всё ещё немного сложно. Задержки в 10 мс в цикле вполне достаточно, чтобы заметить изменения состояния кнопок.
Суперциклы работают и часто используются во встраиваемых системах, но программисту нужно внимательно следить за тем, чтобы сохранять высокую отзывчивость на события. Обратите внимание, чем наша программа с суперциклом отличается от предыдущего простого примера с опросом. Любой шаг перехода между состояниями в суперцикле, как он написан выше, должен занимать относительно мало времени (например, здесь уже нет задержек, которые могли бы надолго заблокировать процессор и привести к пропуску событий). Не всегда легко превратить простую программу с опросом в суперцикл, в котором все переходы между состояниями быстрые и практически не блокируют выполнение. В таких случаях придётся полагаться на альтернативные техники обработки разных событий, которые происходят одновременно.
Конкурентность
Выполнение нескольких дел одновременно называют конкурентным программированием. Конкурентность встречается во многих областях программирования, но особенно часто — во встраиваемых системах. Существует целый набор техник для построения систем, которые взаимодействуют с периферией и при этом сохраняют высокую отзывчивость (например, обработка прерываний, кооперативная многозадачность, очереди событий и т. д.). Некоторые из них мы рассмотрим в следующих главах.
Хорошее введение в конкурентность во встраиваемом контексте есть здесь; перед тем как двигаться дальше, его стоит прочитать.
А пока давайте внимательнее посмотрим, что происходит, когда мы вызываем button_a.is_low() или display_pins.row1.set_high().
Регистры
Эта глава — технический разбор. Её можно пока пропустить и вернуться к ней позже, если захотите. Впрочем, здесь много полезного, поэтому рекомендую погрузиться.
Пора разобраться, что на самом деле происходит при вызове display_pins.row1.set_high() или button_a_pin.is_high().
Если коротко, вызов display_pins.row1.set_high() просто записывает значения в особые области памяти. Перейдите в каталог 09-registers и пройдём стартовый код (src/main.rs) оператор за оператором.
#![no_main]
#![no_std]
#[allow(unused_imports)]
use registers::entry;
#[entry]
fn main() -> ! {
registers::init();
unsafe {
// Волшебный адрес!
const PORT_P0_OUT: u32 = 0x50000504;
// Включаем верхний ряд
*(PORT_P0_OUT as *mut u32) |= 1 << 21;
// Включаем нижний ряд
*(PORT_P0_OUT as *mut u32) |= 1 << 19;
// Выключаем верхний ряд
*(PORT_P0_OUT as *mut u32) &= !(1 << 21);
// Выключаем нижний ряд
*(PORT_P0_OUT as *mut u32) &= !(1 << 19);
}
loop {
core::hint::spin_loop();
}
}
Что здесь за магия?
Адрес 0x50000504 указывает на регистр. Регистр — это особая область памяти, которая управляет периферией. Периферия — это электронный блок, расположенный рядом с процессором внутри корпуса микроконтроллера, который добавляет процессору дополнительные возможности. Ведь сам по себе процессор умеет только считать и выполнять логические операции.
Этот конкретный регистр управляет выводами (pins) GPIO — универсального ввода-вывода (GPIO является периферией) — и может использоваться, чтобы выставлять каждый из этих выводов в низкий или высокий уровень.
(В nRF52833 больше 32 GPIO, но процессор 32-битный. Поэтому выводы GPIO разбиты на две группы, «P0» и «P1», с набором регистров для чтения, записи и настройки каждой группы. Адрес выше — это адрес выходного регистра для выводов P0.)
Отступление: светодиоды, цифровые выходы и уровни напряжения
Выставлять? Вывод? Низкий? Высокий?
Вывод — это электрический контакт. У нашего микроконтроллера их несколько, и некоторые из них подключены к светодиодам (Light Emitting Diode, LED). Светодиод излучает свет, когда на него подано напряжение. Как следует из названия, светодиод также работает как «диод»: он пропускает электрический ток только в одном направлении. Подключите светодиод «в прямом направлении» — и он светится. Подключите «в обратном» — ничего не произойдёт.
К счастью, выводы микроконтроллера подключены так, что светодиоды можно включать правильной стороной. Нужно лишь подать на выводы достаточное напряжение, чтобы зажечь светодиод. Выводы, к которым подключены светодиоды, обычно настраивают как цифровые выходы: они могут выдавать два уровня напряжения — «низкий», 0 В, или «высокий», 3 В. Высокий уровень зажигает светодиод, а низкий — гасит.
Эти состояния «низкий» и «высокий» напрямую соответствуют понятиям цифровой логики: «низкий» — это 0 или false, а «высокий» — 1 или true. Поэтому такая конфигурация вывода и называется цифровым выходом.
Противоположность цифровому выходу — цифровой вход. Так же как цифровой выход может быть 0 или 1, цифровой вход тоже может быть 0 или 1. Разница в том, что цифровые выходы задают напряжение, а цифровые входы считывают его. Когда микроконтроллер считывает уровень напряжения выше порога высокого уровня, он интерпретирует его как 1, а когда уровень ниже порога низкого уровня — как 0.
Хорошо. Но как узнать, что делает этот регистр? Пора обратиться к RTRM (Read The Reference Manual, «прочитай справочное руководство»)!
RTRM: читаем справочное руководство
Мы уже встречались с выводами GPIO на nRF52833. В этом чипе (как и во многих других) выводы GPIO сгруппированы в порты. Есть два порта, Port 0 и Port 1, их сокращённо обозначают P0 и P1. Выводы внутри каждого порта нумеруются с нуля. В порту 0 есть 32 вывода, от P0.00 до P0.31, а в порту 1 — 10 выводов, от P1.00 до P1.09.
Первое, что нужно узнать, — какой вывод к какому светодиоду подключён. Раньше мы делали это, прослеживая схему. Но это сложный путь: нужная информация есть в таблице выводов MB2.
В таблице сказано:
ROW1, верхний ряд светодиодов, подключён к выводуP0.21.P0.21— это сокращение от «вывод 21 на порту 0».ROW5, нижний ряд светодиодов, подключён к выводуP0.19.
Итак, мы знаем, что нужно менять состояние выводов P0.21 и P0.19, чтобы включать и выключать верхний и нижний ряды. Эти выводы относятся к порту 0, поэтому для их настройки используем периферию P0.
С каждой периферией связан блок регистров. Блок регистров — это набор регистров, расположенных в непрерывной области памяти. Адрес, с которого начинается блок регистров, называется базовым адресом. Нам нужно узнать базовый адрес периферии P0. Эта информация есть в следующем разделе спецификации продукта микроконтроллера:
Раздел 4.2.4 «Instantiation» — стр. 22
В таблице сказано, что базовый адрес блока регистров P0 — 0x5000_0000.
У каждой периферии есть собственный раздел в документации. Каждый такой раздел заканчивается таблицей регистров, которые входят в блок регистров периферии. Для семейства периферии GPIO эта таблица находится в:
Раздел 6.8.2 «Registers» — стр. 144
OUT — это регистр, который мы будем использовать для установки и сброса. Его смещение от базового адреса P0 равно 0x504. Регистр OUT можно найти в спецификации продукта.
Описание этого регистра находится сразу под таблицей регистров GPIO:
Подраздел 6.8.2.1 «OUT» — стр. 145
Итак, 0x5000_0000 + 0x504 = 0x50000504. Знакомый адрес! Наконец-то!
Это тот самый регистр, в который мы писали. Документация сообщает кое-что интересное. Во-первых, в этот регистр можно и записывать, и читать из него. Во-вторых, регистр — это 32-битная область памяти, и каждый бит отражает состояние соответствующего вывода: например, бит 19 соответствует выводу 19. Установка бита в 1 включает выход соответствующего вывода, а установка в 0 — выключает его. Кроме того, видно, что по умолчанию все выходы отключены, так как значение всех битов после сброса — 0.
Мы будем использовать команду GDB examine: x. В зависимости от настроек GDB-сервера GDB может отказаться читать память, которая не описана. Это поведение можно отключить так:
set mem inaccessible-by-default off
Итак, начнём. Сначала отключим флаг inaccessible-by-default, затем поставим несколько точек останова, сбросим устройство и остановим его.
(gdb) set mem inaccessible-by-default off
(gdb) break 16
Breakpoint 1 at 0x172: file src/07-registers/src/main.rs, line 16.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) break 19
Breakpoint 2 at 0x17c: file src/07-registers/src/main.rs, line 19.
(gdb) break 22
Breakpoint 3 at 0x184: file src/07-registers/src/main.rs, line 22.
(gdb) break 25
Breakpoint 4 at 0x18c: file src/07-registers/src/main.rs, line 25.
(gdb) monitor reset halt
Resetting and halting target
Target halted
Хорошо. Продолжим до первой точки останова, прямо перед строкой 16, и выведем содержимое регистра по адресу 0x50000504.
(gdb) c
Continuing.
Breakpoint 1, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:16
16 *(PORT_P0_OUT as *mut u32) |= 1 << 21;
(gdb) x 0x50000504
0x50000504: 0x00000000
Итак, на этом этапе значение регистра — 0x00000000, то есть 0. Это совпадает с данными спецификации продукта, где сказано, что 0 — «значение после сброса» (reset value) этого регистра. Значит, после сброса MCU в регистре будет 0.
Продолжим. Эта строка состоит из нескольких инструкций (чтение, побитовое ИЛИ и запись), поэтому нужно несколько раз давать отладчику команду продолжить выполнение, пока не дойдём до следующей точки останова.
(gdb) c
Continuing.
Program received signal SIGINT, Interrupt.
0x00000174 in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:16
16 *(PORT_P0_OUT as *mut u32) |= 1 << 21;
(gdb) c
Continuing.
Breakpoint 2, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:19
19 *(PORT_P0_OUT as *mut u32) |= 1 << 19;
Мы остановились прямо перед строкой 19, значит, строка 16 полностью выполнена. Посмотрим содержимое регистра OUT ещё раз:
(gdb) x 0x50000504
0x50000504: 0x00200000
Значение регистра OUT на этом этапе — 0x00200000, то есть 2097152 в десятичной записи, или 2^21. Значит, бит 21 установлен в 1, а остальные — в 0. Это соответствует строке 16, где в регистр OUT записывается 1 << 21, то есть единица, сдвинутая влево на 21 позицию, объединённая побитовым ИЛИ с текущим значением OUT (которое было 0).
Запись 1 << 21 (OUT[21] = 1) в OUT выставляет P0.21 в высокий уровень. Это включает верхний ряд светодиодов. Проверьте, что верхний ряд действительно горит.
(gdb) c
Continuing.
Да, я как раз собирался это сказать. Теперь нажмите c ещё раз, чтобы продолжить выполнение до следующей точки останова, и выведите её значение.
Program received signal SIGINT, Interrupt.
0x0000017e in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:19
19 *(PORT_P0_OUT as *mut u32) |= 1 << 19;
(gdb) c
Continuing.
Breakpoint 3, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:22
22 *(PORT_P0_OUT as *mut u32) &= !(1 << 21);
(gdb) x 0x50000504
0x50000504: 0x00280000
В строке 19 мы установили бит 19 регистра OUT в 1, оставив бит 21 без изменений. Результат — 0x00280000, то есть 2621440 в десятичной записи, или 2^19 + 2^21: значит, оба бита, 19 и 21, установлены в 1.
Запись 1 << 19 (OUT[19] = 1) в OUT выставляет P0.19 в высокий уровень. Это включает нижний ряд светодиодов. Значит, нижний ряд должен гореть.
Следующие строки снова выключают ряды: сначала верхний, затем нижний. На этот раз используется побитовое И в сочетании с побитовым НЕ. Мы вычисляем !(1 << 21): все биты равны 1, кроме бита 21. Затем применяем побитовое И с текущим значением OUT, так что только бит 21 становится 0, а остальные биты сохраняются.
Продолжайте выполнение и проверьте, что значения регистра OUT совпадают с вашими ожиданиями. Когда устройство попадёт в бесконечный цикл в конце функции main, можно нажать CTRL+C, чтобы приостановить выполнение.
(gdb) c
Continuing.
Program received signal SIGINT, Interrupt.
0x00000186 in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:22
22 *(PORT_P0_OUT as *mut u32) &= !(1 << 21);
(gdb) c
Continuing.
Breakpoint 4, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:25
25 *(PORT_P0_OUT as *mut u32) &= !(1 << 19);
(gdb) x 0x50000504
0x50000504: 0x00080000
(gdb) c
Continuing.
Program received signal SIGINT, Interrupt.
0x0000018e in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:25
25 *(PORT_P0_OUT as *mut u32) &= !(1 << 19);
(gdb) c
Continuing.
^C
Program received signal SIGINT, Interrupt.
0x00000196 in registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:28
28 loop {}
(gdb) x 0x50000504
0x50000504: 0x00000000
И вот теперь все светодиоды должны снова погаснуть!
Ошибочная оптимизация
Чтения и записи в регистры — особенная вещь. Осмелюсь даже сказать, что они воплощают побочные эффекты. В предыдущем примере мы четыре раза записали разные значения в один и тот же регистр. Если бы вы не знали, что этот адрес — регистр, вы могли бы упростить логику и просто записать в регистр итоговое значение 0x00000000.
На самом деле LLVM, бэкенд и оптимизатор компилятора, не знает, что мы работаем с регистром, и объединит записи, изменив поведение программы. Быстро проверим это.
Сначала с помощью cargo objdump получим ассемблерный код собранных артефактов, и оптимизированной, и неоптимизированной сборки.
# Без оптимизации
cargo objdump -- --disassemble --no-show-raw-insn --source > debug.dump
# С оптимизацией
cargo objdump --release -- --disassemble --no-show-raw-insn --source > release.dump
Посмотрим, что внутри. В частности, найдём ассемблерный код, который манипулирует регистром OUT.
Сначала посмотрим содержимое debug.dump, то есть ассемблер из неоптимизированной сборки. Я пропустил часть и добавил свои комментарии после ; <--: они указывают номер строки исходного кода, которой соответствует инструкция.
$ cat debug.dump
[...]
00000158 <main>:
158: push {r7, lr}
15a: mov r7, sp
15c: bl 0x160 <registers::__cortex_m_rt_main::h0b7888ca966441cf> @ imm = #0x0
00000160 <registers::__cortex_m_rt_main::h0b7888ca966441cf>:
160: push {r7, lr}
162: mov r7, sp
164: sub sp, #0x8
166: bl 0x198 <registers::init::hb6346637538e8ec5> @ imm = #0x2e
16a: movw r1, #0x504 ; <-- Загружаем младшую половину адреса регистра `OUT` в регистр `r1`
16e: movt r1, #0x5000 ; <-- Загружаем старшую половину адреса регистра `OUT` в регистр `r1`
172: str r1, [sp, #0x4]
174: ldr r0, [r1] ; <-- (16) Загружаем значение по адресу из `r1` в `r0`.
176: orr r0, r0, #0x200000 ; <-- (16) Побитовое ИЛИ значения `r0` с `0x200000`, результат сохраняем в `r0`
17a: str r0, [r1] ; <-- (16) Записываем содержимое `r0` в память по адресу из `r1`
17c: ldr r0, [r1] ; <-- (19) Загружаем значение по адресу из `r1` в `r0`.
17e: orr r0, r0, #0x80000 ; <-- (19) Побитовое ИЛИ значения `r0` с `0x80000`, результат сохраняем в `r0`
182: str r0, [r1] ; <-- (19) Записываем содержимое `r0` в память по адресу из `r1`
184: ldr r0, [r1] ; <-- (22) Загружаем значение по адресу из `r1` в `r0`.
186: bic r0, r0, #0x200000 ; <-- (22) Побитовое И значения `r0` с побитовой инверсией `0x200000`, результат сохраняем в `r0`
18a: str r0, [r1] ; <-- (22) Записываем содержимое `r0` в память по адресу из `r1`
18c: ldr r0, [r1] ; <-- (25) Загружаем значение по адресу из `r1` в `r0`.
18e: bic r0, r0, #0x80000 ; <-- (25) Побитовое И значения `r0` с побитовой инверсией `0x80000`, результат сохраняем в `r0`
192: str r0, [r1] ; <-- (25) Записываем содержимое `r0` в память по адресу из `r1`
194: b 0x196 <registers::__cortex_m_rt_main::h0b7888ca966441cf+0x36> @ imm = #-0x2
196: b 0x196 <registers::__cortex_m_rt_main::h0b7888ca966441cf+0x36> @ imm = #-0x4
[...]
Как видите, неоптимизированный ассемблер содержит 4 загрузки, 4 записи и 4 битовые операции. Они хорошо соответствуют написанному нами коду. Теперь посмотрим на оптимизированный ассемблер.
$ cat release.dump
[...]
00000158 <main>:
158: push {r7, lr}
15a: mov r7, sp
15c: bl 0x160 <registers::__cortex_m_rt_main::h1f38525e07b97485> @ imm = #0x0
00000160 <registers::__cortex_m_rt_main::h1f38525e07b97485>:
160: push {r7, lr}
162: mov r7, sp
164: bl 0x17a <registers::init::h4390f1d4f8a071f7> @ imm = #0x12
168: movw r0, #0x504 ; <-- Загружаем младшую половину адреса регистра `OUT` в регистр `r0`
16c: movt r0, #0x5000 ; <-- Загружаем старшую половину адреса регистра `OUT` в регистр `r0`
170: ldr r1, [r0] ; <-- (?) Загружаем значение по адресу из `r0` в `r1`.
172: bic r1, r1, #0x280000 ; <-- (?) Побитовое И значения `r1` с побитовой инверсией `0x280000`, результат сохраняем в `r1`
176: str r1, [r0] ; <-- (?) Записываем содержимое `r1` в память по адресу из `r0`
178: b 0x178 <registers::__cortex_m_rt_main::h1f38525e07b97485+0x18> @ imm = #-0x4
[...]
Хм, всего одна загрузка, одна битовая операция и одна запись? Состояние светодиодов в этот раз не изменилось! Инструкция str — та, что записывает значение в регистр. В отладочной (неоптимизированной) программе их было четыре, по одной на каждую запись в регистр, а в release (оптимизированной) программе только одна.
Как не дать LLVM ошибочно оптимизировать программу? Вместо обычных чтений и записей используем volatile-операции (examples/volatile.rs):
#![no_main]
#![no_std]
use core::ptr;
#[allow(unused_imports)]
use registers::entry;
#[entry]
fn main() -> ! {
registers::init();
unsafe {
// «Магический» адрес!
const PORT_P0_OUT: u32 = 0x50000504;
// Включаем верхний ряд
let out = ptr::read_volatile(PORT_P0_OUT as *mut u32);
ptr::write_volatile(PORT_P0_OUT as *mut u32, out | 1 << 21);
// Включаем нижний ряд
let out = ptr::read_volatile(PORT_P0_OUT as *mut u32);
ptr::write_volatile(PORT_P0_OUT as *mut u32, out | 1 << 19);
// Выключаем верхний ряд
let out = ptr::read_volatile(PORT_P0_OUT as *mut u32);
ptr::write_volatile(PORT_P0_OUT as *mut u32, out & !(1 << 21));
// Выключаем нижний ряд
let out = ptr::read_volatile(PORT_P0_OUT as *mut u32);
ptr::write_volatile(PORT_P0_OUT as *mut u32, out & !(1 << 19));
}
loop {
core::hint::spin_loop();
}
}
Запустим cargo objdump ещё раз, с включёнными оптимизациями.
cargo objdump -q --release --example volatile -- --disassemble --no-show-raw-insn > release.volatile.dump
Хорошо, теперь посмотрим, что внутри:
$ cat release.volatile.dump
[...]
00000158 <main>:
158: push {r7, lr}
15a: mov r7, sp
15c: bl 0x160 <registers::__cortex_m_rt_main::h1f38525e07b97485> @ imm = #0x0
00000160 <registers::__cortex_m_rt_main::h1f38525e07b97485>:
160: push {r7, lr}
162: mov r7, sp
164: bl 0x192 <registers::init::h4390f1d4f8a071f7> @ imm = #0x2a
168: movw r0, #0x504
16c: movt r0, #0x5000
170: ldr r1, [r0]
172: orr r1, r1, #0x200000
176: str r1, [r0]
178: ldr r1, [r0]
17a: orr r1, r1, #0x80000
17e: str r1, [r0]
180: ldr r1, [r0]
182: bic r1, r1, #0x200000
186: str r1, [r0]
188: ldr r1, [r0]
18a: bic r1, r1, #0x80000
18e: str r1, [r0]
190: b 0x190 <registers::__cortex_m_rt_main::h1f38525e07b97485+0x30> @ imm = #-0x4
[...]
Смотрите-ка! Теперь у нас снова четыре цикла «загрузка — изменение — запись». Пройдите код ещё раз в GDB, чтобы увидеть volatile-операции в действии!
Адрес 0xBAAAAAAD
Не вся периферийная память доступна для чтения и записи. Посмотрите на эту программу (examples/bad.rs).
#![no_main]
#![no_std]
use core::ptr;
#[allow(unused_imports)]
use registers::entry;
#[entry]
fn main() -> ! {
registers::init();
unsafe {
ptr::read_volatile(0x5000_A784 as *const u32);
}
loop {
core::hint::spin_loop();
}
}
Этот адрес находится рядом с адресом OUT, который мы использовали раньше, но он недействителен — по этому адресу нет никакого регистра.
Теперь попробуем.
$ cargo run
(..)
Resetting and halting target
Target halted
(gdb) continue
Continuing.
Breakpoint 1, registers::__cortex_m_rt_main_trampoline () at src/07-registers/src/main.rs:9
9 #[entry]
(gdb) continue
Continuing.
Program received signal SIGINT, Interrupt.
registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:10
10 fn main() -> ! {
(gdb) continue
Continuing.
Breakpoint 3, cortex_m_rt::HardFault_ (ef=0x2001ffb8) at src/lib.rs:1046
1046 loop {}
(gdb)
Мы попытались выполнить недопустимую операцию — чтение несуществующей памяти, поэтому процессор вызвал исключение — аппаратное исключение.
В большинстве случаев исключения возникают, когда процессор пытается выполнить недопустимую операцию. Исключения прерывают обычный ход программы и заставляют процессор выполнить обработчик исключения — обычную функцию (подпрограмму).
Существуют разные виды исключений. Каждый вид возникает при своих условиях и обрабатывается своим обработчиком.
Крейт registers зависит от крейта cortex-m-rt, который определяет обработчик hard fault по умолчанию — HardFault_ — и обрабатывает исключение «недопустимый адрес памяти». embed.gdb поставил точку останова на HardFault, поэтому отладчик остановил программу во время выполнения обработчика исключения. Подробности об исключении можно получить из отладчика. Смотрим:
(gdb) list
1040 #[allow(unused_variables)]
1041 #[doc(hidden)]
1042 #[cfg_attr(cortex_m, link_section = ".HardFault.default")]
1043 #[no_mangle]
1044 pub unsafe extern "C" fn HardFault_(ef: &ExceptionFrame) -> ! {
1045 #[allow(clippy::empty_loop)]
1046 loop {}
1047 }
1048
1049 #[doc(hidden)]
1050 #[no_mangle]
ef — это снимок состояния программы непосредственно перед возникновением исключения. Посмотрим на него:
(gdb) print/x *ef
$1 = cortex_m_rt::ExceptionFrame {
r0: 0x5000a784,
r1: 0x3,
r2: 0x2001ff24,
r3: 0x0,
r12: 0x1,
lr: 0x4403,
pc: 0x43ea,
xpsr: 0x1000000
}
Здесь несколько полей, но самое важное — pc, регистр счётчика команд (Program Counter). Адрес в этом регистре указывает на инструкцию, которая вызвала исключение. Дизассемблируем программу вокруг неверной инструкции.
(gdb) disassemble /m ef.pc
Dump of assembler code for function core::ptr::read_volatile<u32>:
1654 pub unsafe fn read_volatile<T>(src: *const T) -> T {
0x000043d2 <+0>: push {r7, lr}
0x000043d4 <+2>: mov r7, sp
0x000043d6 <+4>: sub sp, #16
0x000043d8 <+6>: str r0, [sp, #4]
0x000043da <+8>: str r0, [sp, #8]
1655 // SAFETY: the caller must uphold the safety contract for `volatile_load`.
1656 unsafe {
1657 assert_unsafe_precondition!(
0x000043dc <+10>: b.n 0x43de <core::ptr::read_volatile<u32>+12>
0x000043de <+12>: ldr r0, [sp, #4]
0x000043e0 <+14>: movs r1, #4
0x000043e2 <+16>: bl 0x43f4 <core::ptr::read_volatile::precondition_check>
0x000043e6 <+20>: b.n 0x43e8 <core::ptr::read_volatile<u32>+22>
1658 check_language_ub,
1659 "ptr::read_volatile requires that the pointer argument is aligned and non-null",
1660 (
1661 addr: *const () = src as *const (),
1662 align: usize = align_of::<T>(),
1663 ) => is_aligned_and_not_null(addr, align)
1664 );
1665 intrinsics::volatile_load(src)
0x000043e8 <+22>: ldr r0, [sp, #4]
0x000043ea <+24>: ldr r0, [r0, #0] ; <-- That's the one!
0x000043ec <+26>: str r0, [sp, #12]
0x000043ee <+28>: ldr r0, [sp, #12]
1666 }
1667 }
0x000043f0 <+30>: add sp, #16
0x000043f2 <+32>: pop {r7, pc}
End of assembler dump.
Исключение вызвала инструкция ldr r0, [r0, #0] — инструкция чтения. Она пыталась прочитать память по адресу, который находился в регистре процессора r0. Кстати, регистр процессора — это не регистр, отображённый в память: у него нет адреса, как, например, у OUT.
Было бы неплохо проверить значение регистра r0 в тот самый момент, когда возникло исключение. Но мы уже это сделали! Поле r0 в значении ef, которое мы выводили раньше, — это значение регистра r0 в момент исключения. Вот оно ещё раз:
(gdb) print/x *ef
$1 = cortex_m_rt::ExceptionFrame {
r0: 0x5000a784,
r1: 0x3,
r2: 0x2001ff24,
r3: 0x0,
r12: 0x1,
lr: 0x4403,
pc: 0x43ea,
xpsr: 0x1000000
}
r0 содержит значение 0x5000_A784 — тот недопустимый адрес, с которым мы вызвали функцию read_volatile.
Жуткое действие на расстоянии
OUT — не единственный регистр, который управляет выводами порта P0. Регистр OUTSET тоже позволяет менять значения выводов, как и OUTCLR. Однако OUTSET и OUTCLR не позволяют узнать текущее состояние выходов порта P0.
Регистр OUTSET описан в спецификации продукта:
Подраздел 6.8.2.2. «OUTSET» — стр. 145
Посмотрим на программу ниже. Ключевая её часть — fn print_out. Эта функция выводит текущее значение OUT в консоль RTT (examples/spooky.rs):
#![no_main]
#![no_std]
use core::ptr;
#[allow(unused_imports)]
use registers::{entry, rprintln};
// Выводим текущее содержимое P0.OUT
fn print_out() {
const P0_OUT: u32 = 0x5000_0504;
let out = unsafe { ptr::read_volatile(P0_OUT as *const u32) };
rprintln!("P0.OUT = {:#08x}", out);
}
#[entry]
fn main() -> ! {
registers::init();
unsafe {
// Куча «магических» адресов!
const P0_OUTSET: u32 = 0x5000_0508;
const P0_OUTCLR: u32 = 0x5000_050C;
// Выводим начальное содержимое OUT
print_out();
// Включаем верхний ряд светодиодов
ptr::write_volatile(P0_OUTSET as *mut u32, 1 << 21);
print_out();
// Включаем нижний ряд светодиодов
ptr::write_volatile(P0_OUTSET as *mut u32, 1 << 19);
print_out();
// Выключаем верхний ряд светодиодов
ptr::write_volatile(P0_OUTCLR as *mut u32, 1 << 21);
print_out();
// Выключаем нижний ряд светодиодов
ptr::write_volatile(P0_OUTCLR as *mut u32, 1 << 19);
print_out();
}
loop {
core::hint::spin_loop();
}
}
Если запустить эту программу, увидите следующее:
$ cargo embed
# консоль cargo-embed
(..)
15:13:24.055: P0.OUT = 0x000000
15:13:24.055: P0.OUT = 0x200000
15:13:24.055: P0.OUT = 0x280000
15:13:24.055: P0.OUT = 0x080000
15:13:24.055: P0.OUT = 0x000000
Побочные эффекты! Хотя мы многократно читаем один и тот же адрес и не меняем его сами, значение меняется каждый раз, когда производится запись в OUTSET или OUTCLR.
Типобезопасная работа с регистрами
Один из регистров P0, регистр IN, в документации описан как регистр только для чтения.
6.8.2.4 «IN» — стр. 145 и 146
Обратите внимание: в колонке «Access» таблицы для этого регистра указано только «R». Писать в него нельзя, иначе может случиться что-то плохое.
У регистров разные права доступа на чтение и запись. Одни доступны только для записи, другие — и для чтения, и для записи, а третьи, очевидно, только для чтения.
Работа с шестнадцатеричными адресами напрямую тоже чревата ошибками. Вы уже видели, что попытка обратиться к недопустимому адресу памяти вызвала исключение, которое нарушило выполнение программы.
Было бы неплохо иметь API для работы с регистрами «безопасным» способом. В идеале этот API должен учитывать три пункта, о которых я говорил: никакой возни с реальными адресами, соблюдение прав на чтение и запись и защита зарезервированных битов регистра от изменения.
Что ж, такой API у нас есть! registers::init() возвращает значение, которое предоставляет типобезопасный API для работы с регистрами портов P0 и P1.
Как вы помните, группа регистров, связанная с периферией, называется блоком регистров и занимает непрерывную область памяти. В этом типобезопасном API каждый блок регистров описан как struct, где каждое поле соответствует регистру. Каждое поле регистра — это отдельный newtype над, например, u32, который предоставляет набор методов read, write или modify в зависимости от прав доступа. Наконец, эти методы принимают не примитивные значения вроде u32, а ещё один newtype, который собирается по шаблону «строитель» (builder) и не позволяет менять зарезервированные биты регистра.
Лучший способ познакомиться с этим API — перенести на него наш пример (examples/type-safe.rs).
#![no_main]
#![no_std]
#[allow(unused_imports)]
use registers::entry;
#[entry]
fn main() -> ! {
let (p0, _p1) = registers::init();
// Включаем верхний ряд
p0.out.modify(|_, w| w.pin21().set_bit());
// Включаем нижний ряд
p0.out.modify(|_, w| w.pin19().set_bit());
// Выключаем верхний ряд
p0.out.modify(|_, w| w.pin21().clear_bit());
// Выключаем нижний ряд
p0.out.modify(|_, w| w.pin19().clear_bit());
loop {
core::hint::spin_loop();
}
}
Первое, что вы заметите: никаких «магических» адресов здесь нет. Вместо них используется более понятная запись p0.out для обращения к регистру OUT в блоке регистров порта P0.
У блока регистров есть метод modify, который принимает замыкание. Перед вызовом этого замыкания значение регистра OUT читается и передаётся в замыкание как параметр r. Зная значение r, с помощью методов w вы задаёте нужное новое значение регистра. Результат записывается в регистр, когда замыкание возвращает управление. В нашем случае текущее значение регистра также передаётся в параметр w, поэтому можно менять только w, когда нужно сохранить остальные биты регистра без изменений.
Метод modify определён для регистров, которые допускают и чтение, и запись. Если нужно только прочитать значение регистра, не меняя его, используйте метод read. А если хотите просто записать значение в регистр, не читая его, есть метод write.
Регистры только для чтения предоставляют только read, а регистры только для записи — только write. Так пользователь не может обратиться к регистру недопустимым способом, и поэтому вызовы не нужно оборачивать в блок unsafe. И вам не нужно самим вычислять точный адрес регистра и позиции битов!
Запустим эту программу! Во время отладки можно сделать кое-что интересное.
p0 — это ссылка на блок регистров порта P0. Команда print p0 выведет базовый адрес блока регистров, а print *p0 — его содержимое.
$ cargo run
(..)
Target halted
(gdb) set mem inaccessible-by-default off
(gdb) break main.rs:12
Breakpoint 4 at 0x162: main.rs:12. (2 locations)
(gdb) continue
Continuing.
Program received signal SIGINT, Interrupt.
cortex_m_rt::DefaultPreInit () at src/lib.rs:1058
1058 pub unsafe extern "C" fn DefaultPreInit() {}
(gdb) continue
Continuing.
Breakpoint 1, registers::__cortex_m_rt_main_trampoline () at src/07-registers/src/main.rs:7
7 #[entry]
(gdb) continue
Continuing.
Program received signal SIGINT, Interrupt.
registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:8
8 fn main() -> ! {
(gdb) continue
Continuing.
Breakpoint 4.2, registers::__cortex_m_rt_main () at src/07-registers/src/main.rs:12
12 p0.out.modify(|_, w| w.pin21().set_bit());
(gdb) print *p0 ; ⬅️ Выводим `*p0` здесь!
$1 = nrf52833_pac::p0::RegisterBlock {
_reserved0: [0 <repeats 1284 times>],
out: nrf52833_pac::generic::Reg<nrf52833_pac::p0::out::OUT_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 0
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::out::OUT_SPEC>
},
outset: nrf52833_pac::generic::Reg<nrf52833_pac::p0::outset::OUTSET_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 0
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::outset::OUTSET_SPEC>
},
outclr: nrf52833_pac::generic::Reg<nrf52833_pac::p0::outclr::OUTCLR_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 0
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::outclr::OUTCLR_SPEC>
},
in_: nrf52833_pac::generic::Reg<nrf52833_pac::p0::in_::IN_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 0
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::in_::IN_SPEC>
},
dir: nrf52833_pac::generic::Reg<nrf52833_pac::p0::dir::DIR_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3513288704
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::dir::DIR_SPEC>
},
dirset: nrf52833_pac::generic::Reg<nrf52833_pac::p0::dirset::DIRSET_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3513288704
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::dirset::DIRSET_SPEC>
},
dirclr: nrf52833_pac::generic::Reg<nrf52833_pac::p0::dirclr::DIRCLR_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3513288704
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::dirclr::DIRCLR_SPEC>
},
latch: nrf52833_pac::generic::Reg<nrf52833_pac::p0::latch::LATCH_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 0
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::latch::LATCH_SPEC>
},
detectmode: nrf52833_pac::generic::Reg<nrf52833_pac::p0::detectmode::DETECTMODE_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 0
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::detectmode::DETECTMODE_SPEC>
},
_reserved9: [0 <repeats 472 times>],
pin_cnf: [nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
} <repeats 11 times>, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
--Type <RET> for more, q to quit, c to continue without paging--c
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 2
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}, nrf52833_pac::generic::Reg<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC> {
register: vcell::VolatileCell<u32> {
value: core::cell::UnsafeCell<u32> {
value: 3
}
},
_marker: core::marker::PhantomData<nrf52833_pac::p0::pin_cnf::PIN_CNF_SPEC>
}]
}
Всё это обилие newtype и замыканий может показаться причиной раздутого кода. Однако если собрать программу в release-режиме с включённым LTO, вы увидите в точности те же инструкции, что и в «небезопасной» версии, которая использовала write_volatile и шестнадцатеричные адреса!
Используйте cargo objdump, чтобы сохранить ассемблерный код в release.type-safe.dump:
cargo objdump -q --release --example type-safe -- --disassemble --no-show-raw-insn > release.type-safe.dump
Затем найдите main в release.type-safe.dump:
00000158 <main>:
158: push {r7, lr}
15a: mov r7, sp
15c: bl 0x160 <registers::__cortex_m_rt_main::h0e9b57c6799332fd> @ imm = #0x0
00000160 <registers::__cortex_m_rt_main::h0e9b57c6799332fd>:
160: push {r7, lr}
162: mov r7, sp
164: bl 0x192 <registers::init::hec71dddc40be11b5> @ imm = #0x2a
168: movw r0, #0x504
16c: movt r0, #0x5000
170: ldr r1, [r0]
172: orr r1, r1, #0x200000
176: str r1, [r0]
178: ldr r1, [r0]
17a: orr r1, r1, #0x80000
17e: str r1, [r0]
180: ldr r1, [r0]
182: bic r1, r1, #0x200000
186: str r1, [r0]
188: ldr r1, [r0]
18a: bic r1, r1, #0x80000
18e: str r1, [r0]
190: b 0x190 <registers::__cortex_m_rt_main::h0e9b57c6799332fd+0x30> @ imm = #-0x4
Можно убедиться, что получается в точности тот же бинарник, что и при вызовах ptr::read_volatile и ptr::write_volatile.
Лучше всего то, что никому не пришлось написать ни строчки кода для реализации API GPIO. Весь код был автоматически сгенерирован из файла System View Description (SVD) с помощью инструмента svd2rust. Этот файл SVD — по сути XML-файл, который производители микроконтроллеров предоставляют и который содержит карту регистров их микроконтроллеров. В файле описаны расположение блоков регистров, базовые адреса, права на чтение и запись каждого регистра, структура регистров, наличие зарезервированных битов и множество другой полезной информации.
Последовательный порт
Ближайший аналог универсального стандарта ввода-вывода для современных встраиваемых плат — «последовательный порт». Почти у каждого микроконтроллера есть возможность сделать несколько своих выводов последовательным портом, и почти на каждой плате с микроконтроллером эти выводы легко доступны. MB2 — не исключение.
В этой главе мы объясним, что вообще такое последовательный порт. Затем покажем, как настроить компьютер с «виртуальным последовательным портом» через USB и как использовать этот виртуальный порт с помощью «программы-терминала», чтобы общаться с последовательным портом MB2.
Так что же такое последовательный порт? Это способ, которым два устройства обмениваются данными по одному биту за раз (последовательно), используя по одной линии данных в каждом направлении (полный дуплекс) и общий провод земли. Последовательный порт появился как «RS-232»: подробности истории см. далее в этой главе. Однако у протокола, которым обмениваются по линиям передачи и приёма, насколько мне известно, нет официального названия — его просто называют «serial», а иногда «async serial» или «UART serial».
Важно уточнить: большинство каналов связи в современных компьютерах последовательные. USB («Universal Serial Bus») — последовательный канал; I2C, о котором мы поговорим позже, — тоже. Эта и следующая главы посвящены не общему понятию последовательной связи, а конкретной вещи, которая называется «последовательным портом» и имеет собственную реализацию и историю.
Связь через последовательный порт асинхронная: ни по одной из общих линий не передаётся тактовый сигнал. Вместо этого обе стороны должны договориться до начала обмена, примерно с какой скоростью будут передаваться данные по проводу. Периферийное устройство, которое называется универсальным асинхронным приёмопередатчиком (Universal Asynchronous Receiver/Transmitter, UART), выдаёт биты с заданной скоростью на выходной провод и отслеживает начало битов на входном.
Протокол последовательного порта работает с кадрами, каждый из которых несёт байт данных. Каждый кадр содержит один стартовый бит, от 5 до 9 битов полезных данных (передаются от младшего к старшему; современные приложения редко передают 9-битные байты; если в кадре 7 или меньше битов, до 8-битного байта он дополняется нулями слева) и от 1 до 2 стоповых битов. На схеме выше символ ASCII «E» передаётся с 8 битами данных и 1 стоповым битом.
Скорость протокола называется скоростью в бодах (baud rate), и её указывают в битах в секунду (bps). (Если вам кажется, что это звучит неправильно, — так и есть. «Бод» должен означать символов в секунду; символ должен соответствовать кадру; даже если считать «символом» отдельный бит данных, из-за остальной части протокола их передают не с такой скоростью. Это условность, и она не обязана быть логичной.) Исторически распространённые скорости UART — 9600, 19200 и 115200 bps, но в наше время нередко передают данные и на 921 600 bps.
При «обычной» конфигурации — 1 стартовый бит, 8 бит данных, 1 стоповый бит и скорости 921,6 Кбит/с — можно передавать и принимать 92,16 Кбайт в секунду: этого достаточно, чтобы передавать одноканальное несжатое аудио CD. На скорости 115 200 bps, которую мы будем использовать, можно передавать и принимать 11,52 Кбайт в секунду. Для большинства задач этого достаточно.
Мы будем косвенно использовать последовательный порт для обмена данными между MB2 и вашим компьютером. Возможно, вы спросите: почему бы не использовать для этого RTT, как раньше? RTT — протокол, предназначенный исключительно для отладки. Вы не найдёте устройств, которые бы общались друг с другом по RTT. А последовательная связь используется очень часто: например, некоторые GPS-приёмники передают принятые данные о местоположении по последовательному порту. Кроме того, RTT, как и многие отладочные протоколы, медленнее скоростей последовательной передачи.
У современных компьютеров обычно нет последовательного порта, а если он и есть, напряжение, которое он использует (+5 В у современного последовательного порта, ±12 В у древнего RS-232), выходит за пределы того, что принимает аппаратура MB2, и может её повредить. Напрямую подключить компьютер к микроконтроллеру нельзя.
Зато можно купить недорогие USB←→serial преобразователи (обычно меньше 5 долларов США), которые поддерживают входы +3,3 В большинства современных плат с микроконтроллерами. Плата на картинке выше — один из распространённых вариантов, которым я регулярно пользуюсь. Мы будем общаться с последовательным портом MB2 через встроенный USB-порт MB2. Однако если хотите подключиться напрямую к аппаратному последовательному порту — на MB2 или на какой-либо другой плате, — лучший вариант — преобразователь.
Отдельный USB-канал на USB-порте MB2 можно использовать для связи со встроенным USB←→serial преобразователем MB2 (это правый путь на схеме выше). Такое преобразование USB и последовательного порта реализовано с помощью «коммуникационного микроконтроллера» MB2: он выставляет последовательный интерфейс для основного микроконтроллера и виртуальный USB-интерфейс последовательного порта для вашего компьютера. Компьютер видит виртуальный последовательный интерфейс через класс USB-устройств CDC-ACM («Communications Device Class — Abstract Control Model», ох уж эти названия). Микроконтроллер MB2 увидит ваш компьютер как устройство, подключённое к его аппаратному последовательному порту; ваш компьютер увидит последовательный порт MB2 как виртуальное последовательное устройство.
Теперь познакомимся с интерфейсом USB-последовательного порта, который предоставляет ваша ОС. Выберите путь:
Для macOS смотрите документацию по Linux, хотя ваш опыт может немного отличаться.
Инструменты USB←→serial в Linux
Эмулируемое USB-устройство последовательного порта MB2 появляется в Linux, когда вы подключаете MB2 к USB-порту Linux-компьютера.
Подключение платы MB2
Если подключить плату MB2 к компьютеру, в /dev должно появиться новое устройство TTY.
$ sudo dmesg -T | tail | grep -i tty
[63712.446286] cdc_acm 1-1.7:1.1: ttyACM0: USB ACM device
Это и есть USB←→serial устройство. В Linux оно называется tty (от «TeleTYpe», хотите верьте, хотите нет). Оно должно появиться как ttyACM0 или, возможно, ttyUSB0. Если подключено больше устройств «ACM», номер будет больше. (В macOS последовательное устройство показывает ls /dev/cu.usbmodem*.)
Но что же такое ttyACM0? Конечно же, это файл! В Unix всё является файлом:
$ ls -l /dev/ttyACM0
crw-rw----+ 1 root plugdev 166, 0 Jan 21 11:56 /dev/ttyACM0
Обратите внимание: чтобы читать и писать в это устройство, нужно либо работать от root (не рекомендуется), либо быть членом группы, которая указана в выводе ls (обычно plugdev или dialout). Тогда данные можно отправить, просто записав их в этот файл:
$ echo 'Hello, world!' > /dev/ttyACM0
Каждый раз при выполнении этой команды оранжевый светодиод на MB2 рядом с USB-портом должен на мгновение мигнуть. Скорость передачи и другие параметры порта могут быть настроены не для последовательного порта MB2, но MB2 всё равно понимает, что ему что-то отправляют по последовательному каналу.
minicom
Для взаимодействия с последовательным устройством с клавиатуры будем использовать программу minicom. Настройки возьмём по умолчанию для современного minicom: 115200 bps, 8 бит данных, один стоповый бит, без бита чётности, без управления потоком. (Скорость 115200 bps как раз подходит для MB2.)
$ minicom -D /dev/ttyACM0
Эта команда говорит minicom открыть последовательное устройство /dev/ttyACM0. Появится текстовый пользовательский интерфейс (TUI).
Теперь можно отправлять данные с клавиатуры! Попробуйте что-нибудь напечатать. Обратите внимание: текстовый интерфейс не выводит обратно то, что вы печатаете. Однако если посмотреть на жёлтый светодиод сверху на MB2, вы заметите, что он мигает при каждом вводе.
Команды minicom
minicom предоставляет команды через сочетания клавиш. В Linux они начинаются с Ctrl+A. (В macOS — с клавиши Meta.) Некоторые полезные команды:
Ctrl+A+Z. Сводка команд MinicomCtrl+A+C. Очистить экранCtrl+A+X. Выйти и сброситьCtrl+A+Q. Выйти без сброса
ПРИМЕЧАНИЕ Пользователи Mac: в командах выше замените
Ctrl+AнаMeta.
Инструменты для Windows
Начните с того, что отключите MB2.
Перед тем как подключить MB2 обратно, выполните в терминале такую команду:
$ mode
Команда выведет список устройств, подключённых к компьютеру. Устройства, в названии которых есть COM, — последовательные. Именно с такими устройствами мы и будем работать. Запомните вывод mode по всем портам COM до того, как подключите последовательный модуль.
Теперь подключите MB2 и снова выполните команду mode. Если в списке появился новый порт COM, значит, именно он назначен для последовательной связи с MB2.
Теперь запустите putty. Откроется графическое окно.
На начальном экране, где должна быть открыта категория «Session», выберите «Serial» в поле «Connection type». В поле «Serial line» введите порт COM, полученный на предыдущем шаге, например COM3.
Затем выберите в меню слева категорию «Connection/Serial». На этом экране убедитесь, что последовательный порт настроен так:
- «Speed (baud)»: 115200
- «Data bits»: 8
- «Stop bits»: 1
- «Parity»: None
- «Flow control»: None
Наконец, нажмите кнопку Open. Появится консоль:
Если печатать в этой консоли, жёлтый светодиод сверху на MB2 будет мигать. Каждое нажатие клавиши должно заставлять светодиод мигнуть один раз. Обратите внимание: консоль не выводит обратно введённый текст, поэтому экран останется пустым.
RS-232
RS-232
Это точно необязательный раздел, но знать эти вещи может быть полезно при разговорах о последовательных портах с коллегами.
Самые ранние стандартные последовательные порты, о которых мне известно, появились примерно в 1960 году, когда был принят стандарт RS-232 (Revised Standard 232, «пересмотренный стандарт 232»; «непересмотренной» версии у этого стандарта, похоже, не было). Было бы интересно взглянуть на стандарт RS-232: если у вас завалялись 65 долларов США, которые вам не терпится отдать Ассоциации телекоммуникационной промышленности, — пожалуйста. (Отраслевые ассоциации почему-то совершенно не знают понятия «стыд». Вздох.)
RS-232 был способом подключить оборудование передачи данных (DCE; модем) к оконечному оборудованию данных (DTE; терминалу), используя всего один провод для передачи в каждом направлении: протокол передачи данных описан в другом месте этой главы. Скорость передачи можно было настраивать: обычно использовали скорость модема, если он был подключён, поэтому «стандартные» скорости RS-232 обычно совпадали и со «стандартными» скоростями модемов — 300, 600, 1200, 2400, 9600 bps и т. д.
RS-232 также поддерживал дополнительные провода для аппаратного «рукопожатия» (handshaking): каждая сторона могла сообщить, что сейчас не принимает данные, потому что занята, а также различные телефонные функции («Звонит ли телефон?», «Снять трубку» и т. д.). RS-232 использовал 25-контактный разъём типа «D»; уровни сигналов: -12 В для логической единицы и +12 В для логического нуля — старый стандарт телефонных компаний.
Когда в дело вступили компьютеры, RS-232 стал неудобным. Большие компьютеры обычно подключали как DCE, чтобы общаться с терминалами, а микрокомпьютеры — как DTE, чтобы общаться с модемами.
IBM-PC AT популяризировал более компактную версию порта RS-232 с 9-контактным разъёмом (DE-9, часто называемым DB-9).
В итоге стало обычным убирать большую часть или все служебные провода последовательных портов, сводя интерфейс к трём проводам (передача — приём — земля). Также стало обычным использовать уровни сигналов 0 В/+5 В, а позже 0 В/+3,3 В, чтобы избежать «странных» напряжений и проблем с интерфейсом.
Microsoft намеренно разработала USB как стандарт на замену RS-232: с его громоздкой проводкой, разными разъёмами, напряжениями и скоростями передачи обычному пользователю было слишком сложно подключать устройства к ПК. Поскольку на момент появления USB большинство модемов ещё работали по RS-232, Microsoft разработала класс USB-устройств CDC-ACM специально для общения с адаптерами RS-232, которые использовались с этими модемами.
UART
Наш микроконтроллер (как и большинство) содержит периферийные устройства UART («Universal Asynchronous Receiver/Transmitter», универсальный асинхронный приёмопередатчик). На MB2 есть два вида UART: старый UART и новый UARTE («UART with Easy DMA», UART с простым DMA). Для связи с аппаратным последовательным портом мы будем использовать периферию UARTE.
На протяжении этой главы мы будем использовать последовательную связь, чтобы обмениваться информацией между микроконтроллером и вашим компьютером.
Отправка одного байта
Наша первая задача — отправить один байт с микроконтроллера на компьютер по последовательному соединению.
Для этого используем следующий фрагмент (он уже есть в 11-uart/examples/send-byte.rs):
#![no_main]
#![no_std]
use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
use microbit::{
hal::uarte,
hal::uarte::{Baudrate, Parity},
};
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let mut serial = {
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
UartePort::new(serial)
};
serial.write(b'X').unwrap();
serial.flush().unwrap();
loop {
wfi();
}
}
Возможно, вы заметили, что одна из используемых здесь библиотек, модуль serial_setup, не с crates.io, а написана специально для этого проекта. Назначение serial_setup — дать удобную обёртку над периферией UARTE. Если хотите, можете посмотреть, что именно делает этот модуль, но для понимания главы в целом это не обязательно.
Далее рассмотрим инициализацию UARTE. UARTE инициализируется таким фрагментом кода:
uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
Эта функция забирает во владение представление периферии UARTE в Rust (board.UARTE0) и выводы TX/RX платы (board.uart.into()), так что никто другой не может вмешаться ни в периферию UARTE, ни в наши выводы, пока мы ими пользуемся. Затем конструктору передаются два параметра: скорость в бодах (с ней вы уже знакомы) и параметр «parity». Чётность (parity) — это способ, с помощью которого линии последовательной связи проверяют, не были ли данные повреждены при передаче. Здесь он нам не нужен, поэтому мы его просто исключаем. Затем результат оборачивается в тип UartePort, чтобы им было удобно пользоваться.
После инициализации отправляем нашу X (ASCII-код 88) через только что созданный экземпляр UART. Эти последовательные функции «блокирующие»: они ждут, пока данные будут отправлены, и только потом возвращают управление. Это не всегда то, что нужно: пока байт уходит по проводу, микроконтроллер мог бы выполнить много работы. Но в нашем случае это удобно, да и другой работы у нас всё равно нет.
И наконец, мы вызываем flush() для последовательного порта. Дело в том, что UARTE может решить буферизовать вывод, пока не накопит определённое количество байтов для отправки. Вызов flush() заставляет его немедленно записать имеющиеся байты, вместо того чтобы ждать ещё.
Проверка
Перед прошивкой убедитесь, что вы запустили minicom или PuTTY: данные, которые мы получаем через последовательную связь, никуда не сохраняются, их нужно смотреть сразу. Когда монитор порта будет запущен, прошейте программу так же, как в главе 5:
$ cargo embed --example send-byte
(...)
После завершения прошивки в терминале minicom или PuTTY должен появиться символ X. Поздравляю!
Если пропустили, можно нажать кнопку сброса на обратной стороне MB2. Программа начнёт работу сначала и снова отправит X.
Отправка строки
Следующая задача — отправить целую строку с микроконтроллера на ваш компьютер.
Отправьте строку "The quick brown fox jumps over the lazy dog." с микроконтроллера на ваш компьютер.
Теперь ваша очередь написать программу.
Наивный подход и write!
Наивный подход
Скорее всего, вы написали программу, похожую на следующую (examples/naive-send-string.rs):
#![no_main]
#![no_std]
use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
use microbit::hal::uarte::{self, Baudrate, Parity};
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let mut serial = {
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
UartePort::new(serial)
};
for byte in b"The quick brown fox jumps over the lazy dog.\r\n".iter() {
serial.write(*byte).unwrap();
}
serial.flush().unwrap();
loop {
wfi();
}
}
Это вполне рабочая реализация, но рано или поздно может понадобиться всё то приятное, что есть у print!, например форматирование аргументов и т. д. Если вам интересно, как это сделать, читайте дальше.
write! и core::fmt::Write
Трейт core::fmt::Write позволяет использовать любую структуру, которая его реализует, практически так же, как мы используем print! в мире std. В данном случае структура Uart из HAL nrf реализует core::fmt::Write, поэтому мы можем переписать нашу прошлую программу так (examples/send-string.rs):
#![no_main]
#![no_std]
use core::fmt::Write;
use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
use microbit::hal::uarte::{self, Baudrate, Parity};
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let mut serial = {
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
UartePort::new(serial)
};
write!(serial, "The quick brown fox jumps over the lazy dog.\r\n").unwrap();
serial.flush().unwrap();
loop {
wfi();
}
}
Если прошить эту программу на micro:bit, вы увидите, что по функциональности она эквивалентна программе на итераторах, которую вы написали.
Приём одного байта
Пока что мы умеем отправлять данные с микроконтроллера на компьютер. Пора попробовать обратное: принимать данные с компьютера (examples/receive-byte.rs).
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::hal::uarte::{self, Baudrate, Parity};
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let mut serial = {
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
UartePort::new(serial)
};
loop {
let byte = serial.read().unwrap();
rprintln!("{}", byte);
}
}
По сравнению с программой отправки байта изменилась только часть — цикл в конце main(). Здесь мы используем функцию serial.read(), чтобы дождаться доступности байта и прочитать его. Затем выводим этот байт в нашу отладочную консоль RTT, чтобы проверить, действительно ли данные поступают.
Обратите внимание: если вы прошьёте эту программу и начнёте печатать символы в minicom, чтобы отправить их микроконтроллеру, в консоли RTT вы увидите только числа: мы не преобразуем полученный u8 в настоящий char. Поскольку преобразование из u8 в char довольно простое, оставлю эту задачу вам, если действительно хотите видеть символы в консоли RTT.
Эхо-сервер
Объединим передачу и приём в одной программе и напишем эхо-сервер. Эхо-сервер отправляет клиенту обратно тот же текст, который получил. В этом приложении сервером будет микроконтроллер, а клиентом — вы и ваш компьютер.
Реализовать это должно быть несложно. (Подсказка: работайте побайтово.)
Переворот строки
Ну что ж, сделаем сервер поинтереснее: пусть он отвечает клиенту перевёрнутым текстом, который тот прислал. Сервер будет отвечать каждый раз, когда клиент нажмёт клавишу ENTER. Каждый ответ сервера будет выводиться с новой строки.
На этот раз понадобится буфер; можно использовать heapless::Vec. Вот стартовый код:
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use core::fmt::Write;
use heapless::Vec;
use rtt_target::rtt_init_print;
use panic_rtt_target as _;
use microbit::{
hal::prelude::*,
hal::uarte,
hal::uarte::{Baudrate, Parity},
};
mod serial_setup;
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let mut serial = {
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
UartePort::new(serial)
};
// Буфер ёмкостью 32 байта
let mut buffer: Vec<u8, 32> = Vec::new();
loop {
buffer.clear();
// TODO Принять запрос пользователя. Каждый запрос заканчивается нажатием ENTER
// NOTE `buffer.push` возвращает `Result`. Обработайте ошибку, ответив
// сообщением об ошибке.
// TODO Отправить обратно строку в обратном порядке
}
}
Моё решение
Моё решение находится в src/main.rs:
#![no_main]
#![no_std]
use core::fmt::Write;
use cortex_m_rt::entry;
use heapless::Vec;
use microbit::hal::uarte::{self, Baudrate, Parity};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let mut serial = {
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
UartePort::new(serial)
};
// Буфер ёмкостью 32 байта
let mut buffer: Vec<u8, 32> = Vec::new();
loop {
buffer.clear();
loop {
// Считаем, что приём не может завершиться ошибкой
let byte = serial.read().unwrap();
if buffer.push(byte).is_err() {
write!(serial, "error: buffer full\r\n").unwrap();
break;
}
if byte == b'\r' {
for byte in buffer.iter().rev().chain(b"\r\n") {
serial.write(*byte).unwrap();
}
break;
}
}
serial.flush().unwrap()
}
}
I2C
Мы только что разобрали формат последовательной связи UART. UART широко используется, потому что он прост и существует почти вечно. (Помните, почему устройство в системе называется «tty» — от «TeleTYpe»? Ну вот.) Эта распространённость и простота делают его популярным выбором для простой связи.
Из-за аппаратных ограничений, связанных с соотношением длины линии и качества сигнала, а также из-за сложности точного декодирования, UART обычно ограничен примерно 115 200 бодами в идеальных условиях. Последовательный порт UART обладает и низкой пропускной способностью (11,5 КБ/с), и высокой задержкой (87 мкс на байт).
UART — это соединение «точка-точка»: к одному проводу нельзя подключить три и более устройств, а каждый провод требует выделенного аппаратного блока на каждом конце.
Хорошая новость (и одновременно плохая) в том, что во встраиваемых системах есть множество других аппаратно поддерживаемых протоколов последовательной связи, которые преодолевают эти ограничения. Некоторые из них широко используются в цифровых датчиках.
В плате micro:bit, которую мы используем, есть два датчика движения: акселерометр и магнитометр. Оба датчика упакованы в один компонент и доступны по шине I2C.
I2C произносится «ай-квадрат-си» и расшифровывается как Inter-Integrated Circuit («межмикросхемная связь»). I2C — это синхронный последовательный протокол для шины: он использует две линии для обмена данными, линию данных (SDA) и линию тактирования (SCL). Линия тактирования синхронизирует обмен. Синхронная последовательная связь может работать быстрее и надёжнее асинхронной. У устройств I2C есть адреса на шине: аппаратная реализация позволяет отправлять байты конкретному устройству, а остальные устройства на тех же проводах игнорируют такой обмен.
I2C использует модель контроллер/target: контроллер — это устройство, которое начинает обмен с target-устройством и управляет им. К одной шине одновременно может быть подключено несколько устройств, и каждое может работать либо как контроллер, либо как target. Контроллер обращается к конкретному target-устройству, сначала передавая на шину его адрес. Этот адрес может быть 7- или 10-битным. Как только контроллер начал обмен с target-устройством, никакое другое устройство, кроме контроллера и этого target, не может передавать данные по шине, пока контроллер не завершит обмен.
ПРИМЕЧАНИЕ Пару «controller/target» раньше называли «master/slave» (ведущий/ведомый). Такое название вы ещё можете встретить в литературе или на надписях на платах. Официальные стандарты и новые документы теперь считают эту терминологию устаревшей, но её всё ещё использует руководство Nordic по нашему чипу nRF52833 и некоторые документы по встраиваемому Rust.
Линия тактирования определяет, как быстро можно обмениваться данными. Интерфейс I2C на MB2 может работать на скоростях 100, 250 или 400 Кбит/с. Другие устройства поддерживают и более высокие режимы.
Общий протокол
Протокол I2C сложнее протокола последовательной связи, потому что поддерживает структурированный обмен между несколькими устройствами. Посмотрим, как он работает:
Контроллер → Target
Если контроллер хочет отправить данные target-устройству:
- Контроллер: передаёт START
- К: передаёт адрес target (7 бит) и восьмой бит R/W, установленный в WRITE (запись)
- Target: отвечает ACK (подтверждение)
- К: отправляет один байт
- T: отвечает ACK
- Повторить шаги 4 и 5 ноль или более раз
- К: передаёт STOP или начинает новую транзакцию чтения
ПРИМЕЧАНИЕ Адрес target мог бы быть 10-битным, а не 7-битным. Ничего другого при этом не изменилось бы.
Контроллер ← Target
Если контроллер хочет прочитать данные из target-устройства:
- К: передаёт START
- К: передаёт адрес target (7 бит) и восьмой бит R/W, установленный в READ (чтение)
- T: отвечает ACK
- T: отправляет байт
- К: отвечает ACK
- Повторить шаги 4 и 5 ноль или более раз
- К: передаёт STOP или начинает новую транзакцию записи
ПРИМЕЧАНИЕ Адрес target мог бы быть 10-битным, а не 7-битным. Ничего другого при этом не изменилось бы.
«Регистры устройства»
Многие target-устройства I2C внутри организованы как набор «регистров устройства», у каждого из которых есть 8-битный адрес и 8-битное содержимое. Обычно регистры записываются двухбайтовой записью: первый байт — адрес регистра, второй — новое значение регистра.
Так называемая «комбинированная» или «разделённая» транзакция может состоять из записи в target, после которой сразу же читается ответ от target, как показано на схеме выше. Обычно регистры устройства читаются именно так: сначала записывается адрес регистра, а затем сразу читается его текущее значение.
Некоторые target-устройства I2C могут читать и записывать несколько регистров с соседними адресами благодаря некой форме «автоинкремента адреса»: достаточно передать адрес первого регистра, а дальше устройство само увеличивает адрес для последующих чтений или записей.
I2C — сложный протокол, и существует много его вариаций и особенностей. Внимательно читайте руководство к вашему target-устройству, чтобы понять, что нужно сделать для связи с ним.
LSM303AGR
Оба датчика движения micro:bit — магнитометр и акселерометр — упакованы в один компонент: интегральную схему LSM303AGR. К этим двум датчикам можно обратиться по шине I2C. Каждый датчик ведёт себя как target I2C и имеет свой адрес.
У каждого датчика есть собственная память, где хранятся результаты измерений окружающей среды. Наше взаимодействие с датчиками будет в основном сводиться к чтению этой памяти.
Память этих датчиков организована как регистры с адресацией по байтам. Датчики можно и настраивать, для этого в их регистры производится запись. Так что в некотором смысле эти датчики очень похожи на периферию внутри микроконтроллера. Разница в том, что их регистры не отображаются в память микроконтроллера. Вместо этого к ним приходится обращаться по шине I2C.
Основной источник информации о LSM303AGR — его даташит. Прочитайте его, чтобы понять, как читать регистры датчиков. Эта часть находится здесь:
Раздел 6.1.1 «I2C Operation» — стр. 38 — даташит LSM303AGR
Другая часть документации, важная для этой книги, — описание регистров. Она находится здесь:
Раздел 8 «Register description» — стр. 46 — даташит LSM303AGR
Чтение одного регистра
Пора применить всю эту теорию на практике!
Прежде всего нужно узнать адреса target-устройств акселерометра и магнитометра внутри чипа. Их можно найти в даташите LSM303AGR на странице 39. Они такие:
- 0011001 — для акселерометра
- 0011110 — для магнитометра
ПРИМЕЧАНИЕ Помните, что это только 7 старших битов адреса: восьмой бит определяет, выполняем мы чтение или запись.
Дальше нужен регистр, из которого будем читать. Многие I2C-чипы предоставляют регистр идентификации устройства, который контроллер может прочитать. Учитывая тысячи (а то и миллионы) I2C-чипов, весьма вероятно, что рано или поздно будут выпущены два чипа с одинаковым адресом (ведь адрес — «всего лишь» 7 бит). С помощью регистра идентификации драйвер может убедиться, что он действительно общается с LSM303AGR, а не с каким-то другим чипом, который случайно имеет тот же адрес. Как сказано в даташите LSM303AGR (в частности, на страницах 46 и 61), у этого устройства есть два таких регистра: WHO_AM_I_A по адресу 0x0f и WHO_AM_I_M по адресу 0x4f. В них записаны битовые шаблоны, уникальные для этого устройства. («A» — от «Accelerometer», акселерометр, а «M» — от «Magnetometer», магнитометр.)
Осталась только программная часть: нужно понять, каким API microbit или HAL-крейта воспользоваться. Если прочитать спецификацию продукта nRF52833, вы скоро обнаружите, что отдельной периферии именно для I2C там нет. Вместо неё есть более универсальные периферийные блоки, совместимые с I2C, которые называются TWI («Two-Wire Interface», двухпроводный интерфейс), TWIM («Two-Wire Interface Master») и TWIS («Two-Wire Interface Slave»). Обычно мы будем работать в режиме контроллера и воспользуемся более новым TWIM, который поддерживает «Easy DMA»; TWI оставлен в основном для совместимости со старыми устройствами.
Теперь, если сопоставить документацию модуля twi(m) из крейта microbit со всей остальной собранной информацией, получится вот такой код, который читает и выводит оба идентификатора устройств (examples/chip-id.rs):
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m::asm::wfi;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use embedded_hal::i2c::I2c;
use microbit::{hal::twim, pac::twim0::frequency::FREQUENCY_A};
const ACCELEROMETER_ADDR: u8 = 0b0011001;
const MAGNETOMETER_ADDR: u8 = 0b0011110;
const ACCELEROMETER_ID_REG: u8 = 0x0f;
const MAGNETOMETER_ID_REG: u8 = 0x4f;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let mut i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
let mut acc = [0u8];
let mut mag = [0u8];
// Сначала записываем адрес и регистр на шину, затем читаем ответы микросхем
i2c.write_read(ACCELEROMETER_ADDR, &[ACCELEROMETER_ID_REG], &mut acc)
.unwrap();
i2c.write_read(MAGNETOMETER_ADDR, &[MAGNETOMETER_ID_REG], &mut mag)
.unwrap();
rprintln!("The accelerometer chip's id is: {:#b}", acc[0]);
rprintln!("The magnetometer chip's id is: {:#b}", mag[0]);
loop {
wfi();
}
}
Если не считать инициализации, этот код должен быть понятен, если вы разобрались в протоколе I2C, описанном выше. Инициализация работает так же, как в главе про UART. В конструктор мы передаём периферию, а также выводы, которые используются для связи с чипом, а затем частоту, на которой должна работать шина, — в данном случае 100 кГц (K100, потому что идентификаторы в Rust не могут начинаться с цифры).
Проверка
Как обычно
$ cargo embed --example chip-id
чтобы проверить нашу небольшую программу-пример.
Использование драйвера
Как мы уже говорили в главе 5, embedded-hal предоставляет абстракции, с помощью которых можно писать не зависящий от платформы код для работы с оборудованием. Фактически все методы, которыми мы пользовались для работы с оборудованием в главе LED-рулетка и вплоть до этой главы, были методами трейтов, определённых в embedded-hal. Теперь впервые по-настоящему воспользуемся трейтами, которые предоставляет embedded-hal.
Писать драйвер для LSM303AGR под каждую платформу, которую поддерживает встраиваемый Rust (и под новые, которые могут появиться), было бы бессмысленно. Чтобы этого избежать, драйвер можно написать так, чтобы он принимал обобщённые типы, реализующие трейты embedded-hal, — тогда получается не зависящая от платформы версия драйвера. К счастью, это уже сделано в крейте lsm303agr. Поэтому чтение реальных значений акселерометра и магнитометра теперь почти работает по принципу «подключи и пользуйся» (плюс немного чтения документации). На странице crates.io уже есть всё, что нужно, чтобы читать данные акселерометра, — но для Raspberry Pi. Нам остаётся адаптировать этот пример под наш чип:
Посмотрите пример кода для Raspberry Pi под Linux на той странице.
Поскольку мы уже знаем, как создать объект, реализующий трейты embedded_hal::blocking::i2c, из предыдущего раздела, адаптировать пример просто (examples/show-accel.rs):
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{twim, Timer},
pac::twim0::frequency::FREQUENCY_A,
};
use lsm303agr::{AccelMode, AccelOutputDataRate, Lsm303agr};
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
let mut timer0 = Timer::new(board.TIMER0);
// Код из документации
let mut sensor = Lsm303agr::new_with_i2c(i2c);
sensor.init().unwrap();
sensor
.set_accel_mode_and_odr(
&mut timer0,
AccelMode::HighResolution,
AccelOutputDataRate::Hz50,
)
.unwrap();
loop {
if sensor.accel_status().unwrap().xyz_new_data() {
let (x, y, z) = sensor.acceleration().unwrap().xyz_mg();
// RTT вместо обычного print
rprintln!("Acceleration: x {} y {} z {}", x, y, z);
}
}
}
Как и в предыдущем фрагменте, попробовать эту программу можно так:
$ cargo embed --example show-accel
Кроме того, если немного подвигать micro:bit (физически), вы увидите, как меняются выводимые значения ускорения.
Задание
Задание этой главы — написать небольшую программу, которая обменивается данными с внешним миром через последовательный интерфейс из предыдущей главы. Она должна принимать из последовательного порта команды «mag» (магнитометр) и «acc» (акселерометр). В ответ программа должна отправлять в последовательный порт соответствующие данные датчика.
На этот раз шаблона кода не будет: всё, что нужно, уже есть в главах UART и в этой. Однако вот несколько подсказок:
-
Вам может пригодиться
core::str::from_utf8, чтобы преобразовать байты из буфера в&str: ведь нужно сравнивать их со строками"mag"и"acc". -
Придётся почитать документацию по API и возможностям магнитометра. Интерфейс API предоставляет крейт
lsm303agr, а даташит LSM303AGR описывает параметры измерения магнитного поля датчиком. Характеристики датчика — на страницах 13–15, а самое важное — на страницах 66–67, где описан формат выходных регистров.
Моё решение
Моё решение находится в src/main.rs.
#![no_main]
#![no_std]
use core::str;
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::uarte::{self, Baudrate, Parity},
hal::{twim, Timer},
pac::twim0::frequency::FREQUENCY_A,
};
use core::fmt::Write;
use heapless::Vec;
use lsm303agr::{AccelMode, AccelOutputDataRate, Lsm303agr, MagMode, MagOutputDataRate};
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
let mut serial = UartePort::new(serial);
let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
let mut timer0 = Timer::new(board.TIMER0);
let mut sensor = Lsm303agr::new_with_i2c(i2c);
sensor.init().unwrap();
sensor
.set_accel_mode_and_odr(
&mut timer0,
AccelMode::HighResolution,
AccelOutputDataRate::Hz50,
)
.unwrap();
sensor
.set_mag_mode_and_odr(
&mut timer0,
MagMode::HighResolution,
MagOutputDataRate::Hz50,
)
.unwrap();
let mut sensor = sensor.into_mag_continuous().ok().unwrap();
let mut buffer: Vec<u8, 32> = Vec::new();
rprintln!("setup complete");
loop {
buffer.clear();
loop {
let byte = serial.read().unwrap();
serial.write(byte).unwrap();
if byte == b'\r' {
serial.write(b'\n').unwrap();
break;
}
if buffer.push(byte).is_err() {
write!(serial, "error: buffer full\r\n").unwrap();
break;
}
}
if str::from_utf8(&buffer).unwrap().trim() == "acc" {
while !sensor.accel_status().unwrap().xyz_new_data() {
timer0.delay_ms(1u32);
}
let (x, y, z) = sensor.acceleration().unwrap().xyz_mg();
write!(serial, "Accelerometer: x {} y {} z {}\r\n", x, y, z).unwrap();
} else if str::from_utf8(&buffer).unwrap().trim() == "mag" {
while !sensor.mag_status().unwrap().xyz_new_data() {
timer0.delay_ms(1u32);
}
let (x, y, z) = sensor.magnetic_field().unwrap().xyz_nt();
write!(serial, "Magnetometer: x {} y {} z {}\r\n", x, y, z).unwrap();
} else {
write!(serial, "error: command not detected\r\n").unwrap();
}
}
}
LED-компас
В этом разделе мы реализуем компас на светодиодах MB2. Как и настоящие компасы, наш LED-компас должен каким-то образом показывать север. Для этого он будет зажигать один из внешних светодиодов; зажжённый светодиод должен указывать на север.
У магнитного поля есть и величина, измеряемая в гауссах или теслах, и направление. Магнитометр MB2 измеряет и величину, и направление внешнего магнитного поля, но возвращает разложение этого поля по его осям.
У магнитометра три связанные с ним оси. Когда плата лежит плоско, светодиодами вверх, а логотипом вперёд, оси X и Y задают плоскость пола. Ось X указывает на левый край платы. Ось Y указывает на нижний край платы (с разъёмом для карты). Ось Z направлена «в пол», то есть вниз: «вверх ногами», ведь чип установлен на обратной стороне. Это система координат «правой руки». Всё это немного запутано, потому что возвращаемые значения напряжённости поля — это компоненты вектора магнитного поля.
Вы уже должны уметь написать программу, которая непрерывно выводит данные магнитометра в консоль RTT, — см. главу про I2C. Напишите такую программу (examples/show-mag.rs), определите, где находится север в вашем месте, затем выровняйте MB2 по этому направлению и посмотрите, какие значения X и Y выдаёт датчик.
Теперь поверните плату на 90 градусов, оставляя её параллельно земле. Какие значения X, Y и Z вы видите на этот раз? Затем поверните её ещё на 90 градусов. Какие значения теперь?
ПРИМЕЧАНИЕ Из двух MB2, которые у меня были под рукой на момент написания, один, похоже, имеет немного сломанный магнитометр: ось Z смещена так, что ею невозможно пользоваться. У производителя есть процедура самопроверки для обнаружения такой неисправности и процедура калибровки для её компенсации — это так называемая «жёсткая» магнитная погрешность (hard iron), которая обычно возникает, если MB2 однажды оказался в сильном магнитном поле. Однако крейт
lsm303agrпока не поддерживает ни одну из этих процедур, да и для вводного руководства по встраиваемым системам это слишком много. Если у вас только один MB2 и он, кажется, не работает, можно просто перейти к следующей главе. Дешёвое оборудование — что поделаешь?
Магнитный север Земли — капризная вещь: в большинстве мест на Земле он отличается от географического севера, иногда довольно сильно. Он может заметно «уходить» в землю. И он со временем меняется. Не учитывая всё это, вы не получите очень точный компас, даже если магнитометр в вашем MB2 идеален (а он не идеален). С помощью калькулятора NOAA (США) https://www.ngdc.noaa.gov/geomag/calculators/mobileDeclination.shtml на мобильном устройстве можно получить хорошую оценку географического и магнитного севера; а в британском калькуляторе BGS можно указать широту, долготу и высоту, чтобы получить и склонение, и наклонение. В моём месте «склонение» (разница между географическим и магнитным севером) составляет около 15°; «наклонение» — поразительные 67° вниз, в землю.
ПРИМЕЧАНИЕ Магнитометр LSM303AGR сам по себе не слишком точен. Производитель рекомендует сложную процедуру калибровки для поиска поправок к показаниям магнитометра. Подробнее, пример реализации калибровки и более красивую графику компаса можно найти в приложении 3: поскольку мы делаем с магнитометром довольно простые вещи, в этой главе им заниматься не будем.
Модуль вектора
Насколько сильно магнитное поле Земли? Согласно документации к методу magnetic_field(), значения x, y, z, которые мы получаем, выражены в нанотеслах. Значит, чтобы получить величину магнитного поля в нанотеслах, нужно вычислить только модуль трёхмерного вектора, который описывают наши значения x, y, z. Как вы, возможно, помните из школы, он равен:
#![allow(unused)]
fn main() {
use libm::sqrtf;
let magnitude = sqrtf(x * x + y * y + z * z);
}
В core у Rust нет математических функций для чисел с плавающей точкой, таких как sqrtf(), поэтому наша программа no_std должна взять их реализацию откуда-то. Для этого мы используем крейт libm.
Соберём всё это в программу (examples/magnitude.rs):
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use libm::sqrtf;
use microbit::{
hal::{twim, Timer},
pac::twim0::frequency::FREQUENCY_A,
};
use lsm303agr::{Lsm303agr, MagMode, MagOutputDataRate};
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
let mut timer0 = Timer::new(board.TIMER0);
let mut sensor = Lsm303agr::new_with_i2c(i2c);
sensor.init().unwrap();
sensor
.set_mag_mode_and_odr(
&mut timer0,
MagMode::HighResolution,
MagOutputDataRate::Hz10,
)
.unwrap();
let mut sensor = sensor.into_mag_continuous().ok().unwrap();
loop {
while !sensor.mag_status().unwrap().xyz_new_data() {
timer0.delay_ms(1u32);
}
let (x, y, z) = sensor.magnetic_field().unwrap().xyz_nt();
let (x, y, z) = (x as f32, y as f32, z as f32);
let magnitude = sqrtf(x * x + y * y + z * z);
rprintln!("{} mG", magnitude / 100.0);
}
}
Запустите её командой cargo run --example magnitude.
Программа выводит величину (силу) магнитного поля в нанотеслах (nT) и миллигауссах (mG, где 1 mG = 100 nT). Величина магнитного поля Земли лежит в диапазоне от 250 mG до 650 mG (она зависит от географического положения), так что в идеале вы должны увидеть значение примерно в этом диапазоне. Ваше значение, скорее всего, будет заметно отличаться, потому что датчик не откалиброван: о калибровке см. приложение 3. После калибровки у меня получается величина около 340 mG.
Несколько вопросов:
-
Какое значение вы видите, не двигая плату? Всегда ли оно одинаковое?
-
Меняется ли величина, если поворачивать плату? Должна ли она меняться?
Задание
Воспользуемся немного сложной математикой, чтобы получить точный угол, который магнитное поле образует с осями X и Y магнитометра. Это позволит определить, какой светодиод указывает на север.
Будем использовать функцию atan2. Она возвращает угол в диапазоне от -PI до PI. На схеме ниже показано, как измеряется этот угол:
Хотя это и не показано явно, на этом графике ось X направлена вправо, а ось Y — вверх. Обратите внимание, что наша система координат повёрнута на 180° относительно этой.
Вот стартовый код (templates/compass.rs). theta в радианах уже вычислен. Нужно выбрать, какой светодиод включить, исходя из значения theta.
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
// Эти штуки вам пригодятся ;-).
use core::f32::consts::PI;
use libm::{atan2f, floorf};
use microbit::{
display::blocking::Display,
hal::{Timer, twim},
pac::twim0::frequency::FREQUENCY_A,
};
use lsm303agr::{Lsm303agr, MagMode, MagOutputDataRate};
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
let mut timer0 = Timer::new(board.TIMER0);
let mut display = Display::new(board.display_pins);
let mut sensor = Lsm303agr::new_with_i2c(i2c);
sensor.init().unwrap();
sensor.set_mag_mode_and_odr(
&mut timer0,
MagMode::HighResolution,
MagOutputDataRate::Hz10,
).unwrap();
let mut sensor = sensor.into_mag_continuous().ok().unwrap();
let mut leds = [[0u8; 5]; 5];
// Индексы 16 светодиодов, которые используются на дисплее, и их
// направления компаса.
#[rustfmt::skip]
let indices = [
(2, 0) /* W */, (3, 0) /* W-SW */, (3, 1) /* SW */, (4, 1) /* S-SW */,
(4, 2) /* S */, (4, 3) /* S-SE */, (3, 3) /* SE */, (3, 4) /* E-SE */,
(2, 4) /* E */, (1, 4) /* E-NE */, (1, 3) /* NE */, (0, 3) /* N-NE */,
(0, 2) /* N */, (0, 1) /* N-NW */, (1, 1) /* NW */, (1, 0) /* W-NW */,
];
loop {
// Измеряем магнитное поле.
let (x, y) = todo!();
// Получаем угол от -180° до 180° относительно оси x.
let theta = atan2f(y as f32, x as f32);
// Определяем, какой индекс светодиода мигнуть.
let index = todo!();
// Мигаем выбранным светодиодом.
let (r, c) = indices[index];
leds[r][c] = 255u8;
display.show(&mut timer0, leds, 50);
leds[r][c] = 0u8;
display.show(&mut timer0, leds, 50);
}
}
Подсказки:
- Полный оборот составляет 360 градусов.
PIрадиан соответствуют 180 градусам.- Если
thetaравен нулю, в каком направлении вы смотрите? - Если
thetaочень близок к нулю, в каком направлении вы смотрите? - Если
thetaпродолжает расти, при каком значении нужно поменять направление?
Моё решение
Вот моё решение (в src/main.rs):
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
// Эти штуки вам пригодятся ;-).
use core::f32::consts::PI;
use libm::{atan2f, floorf};
use microbit::{
display::blocking::Display,
hal::{twim, Timer},
pac::twim0::frequency::FREQUENCY_A,
};
use lsm303agr::{Lsm303agr, MagMode, MagOutputDataRate};
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
let mut timer0 = Timer::new(board.TIMER0);
let mut display = Display::new(board.display_pins);
let mut sensor = Lsm303agr::new_with_i2c(i2c);
sensor.init().unwrap();
sensor
.set_mag_mode_and_odr(
&mut timer0,
MagMode::HighResolution,
MagOutputDataRate::Hz10,
)
.unwrap();
let mut sensor = sensor.into_mag_continuous().ok().unwrap();
let mut leds = [[0u8; 5]; 5];
// Индексы 16 светодиодов, которые используются на дисплее, и их
// направления компаса.
#[rustfmt::skip]
let indices = [
(2, 0), /* W */
(3, 0), /* W-SW */
(3, 1), /* SW */
(4, 1), /* S-SW */
(4, 2), /* S */
(4, 3), /* S-SE */
(3, 3), /* SE */
(3, 4), /* E-SE */
(2, 4), /* E */
(1, 4), /* E-NE */
(1, 3), /* NE */
(0, 3), /* N-NE */
(0, 2), /* N */
(0, 1), /* N-NW */
(1, 1), /* NW */
(1, 0), /* W-NW */
];
loop {
while !sensor.mag_status().unwrap().xyz_new_data() {
timer0.delay_ms(1u32);
}
let (x, y, _) = sensor.magnetic_field().unwrap().xyz_nt();
// Получаем угол от -180° до 180° относительно оси x.
let theta = atan2f(y as f32, x as f32);
// Делим единичную окружность на тридцать два сегмента,
// где пары соседних сегментов соответствуют
// каждому направлению компаса.
let seg = floorf(16.0 * theta / PI) as i8;
// Определяем, какой индекс светодиода мигнуть.
let index = if seg >= 15 || seg <= -15 {
8
} else if seg >= 0 {
(seg / 2) as usize
} else {
((31 + seg) / 2) as usize
};
// Мигаем выбранным светодиодом.
let (r, c) = indices[index];
leds[r][c] = 255u8;
display.show(&mut timer0, leds, 50);
leds[r][c] = 0u8;
display.show(&mut timer0, leds, 50);
}
}
Измеритель удара
В этом разделе мы поиграем с акселерометром, который есть на плате.
Что будем строить на этот раз? Измеритель удара! Мы будем измерять силу ваших ударов. Точнее, максимальное ускорение, которого вы можете достичь, потому что акселерометры измеряют именно ускорение. Однако сила и ускорение пропорциональны, так что это хорошее приближение.
Как мы уже знаем из предыдущих глав, акселерометр встроен в корпус LSM303AGR. Как и магнитометр, он доступен по шине I2C. У акселерометра та же система координат, что и у магнитометра.
Где верх?
Что мы сделаем в первую очередь?
Проверим здравый смысл!
Вы уже должны уметь написать программу, которая непрерывно выводит данные акселерометра в консоль RTT, — см. главу про I2C. Моя программа находится в examples/show-accel.rs в той главе. Замечаете ли вы что-нибудь интересное, даже если держать плату параллельно полу обратной стороной вверх? (Помните, что акселерометр установлен на обратной стороне платы, так что при таком положении «вверх ногами» ось Z смотрит вверх.)
Держа плату так, вы должны увидеть, что значения X и Y довольно близки к 0, а значение Z — около 1000. Это странно: плата не движется, но её ускорение ненулевое. В чём дело? Наверное, дело в гравитации, верно? Ведь ускорение свободного падения равно 1 g (ага, 1 g = -1000 у акселерометра). Но гравитация тянет предметы вниз, поэтому ускорение по оси Z должно было бы быть положительным, а не отрицательным.
Может, программа перепутала направление оси Z? Нет: можно проверить, поворачивая плату так, чтобы гравитация оказалась вдоль оси X или Y, — но ускорение, которое измеряет акселерометр, всегда направлено вверх.
Происходит вот что: акселерометр измеряет собственное ускорение платы, а не то ускорение, которое наблюдаете вы. Собственное ускорение — это ускорение платы с точки зрения наблюдателя в свободном падении. Наблюдатель в свободном падении движется к центру Земли с ускорением 1g; с его точки зрения плата на самом деле движется вверх (от центра Земли) с ускорением 1g. Вот почему собственное ускорение направлено вверх. Это также означает, что если бы плата находилась в свободном падении, акселерометр показывал бы собственное ускорение, равное нулю. Пожалуйста, не пробуйте это дома. Или пробуйте, если готовы рискнуть своим MB2, уронив его.
Да, физика — штука непростая. Пойдём дальше.
Задание
Чтобы программа оставалась простой, будем считать, что вы бьёте, держа плату параллельно земле. Чтобы измерить силу удара, пришлось бы учитывать и ускорение по X, и по Y (а ось Z можно игнорировать, так как она отражает только гравитацию). Чтобы упростить задачу ещё больше, будем считать, что плату держат кнопкой B к себе, а кнопкой A — от себя, и бьют от себя. Значит, вы бьёте в положительном направлении оси X.
Вот что должен делать измеритель удара:
- По умолчанию приложение не «следит» за ускорением платы.
- Когда обнаружено значительное ускорение по X (то есть ускорение превышает некоторый порог), приложение должно начать новое измерение.
- В течение этого интервала измерения приложение должно отслеживать максимальное наблюдаемое ускорение.
- По окончании интервала приложение должно вывести максимальное наблюдаемое ускорение. Значение можно вывести с помощью макроса
rprintln!.
Попробуйте и скажите мне, как сильно вы умеете бить ;-).
ПРИМЕЧАНИЕ Для этой задачи должен пригодиться ещё один API, о котором мы пока не говорили:
set_accel_scale. Он нужен, чтобы измерять большие значения g.
Моё решение
Вот моё решение (src/main.rs). Обратите внимание: довольно большие значения g можно получить, постучав краем MB2 по столу. Заметьте также, что так можно сломать акселерометр, так что, пожалуй, лучше не стоит?
#![deny(unsafe_code)]
#![no_main]
#![no_std]
const TICKS_PER_SEC: u32 = 400;
const THRESHOLD: f32 = 1.5;
use cortex_m::asm::nop;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{twim, Timer},
pac::twim0::frequency::FREQUENCY_A,
};
use lsm303agr::{AccelMode, AccelOutputDataRate, AccelScale, Lsm303agr};
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
let mut delay = Timer::new(board.TIMER0);
let mut sensor = Lsm303agr::new_with_i2c(i2c);
sensor.init().unwrap();
sensor
.set_accel_mode_and_odr(&mut delay, AccelMode::Normal, AccelOutputDataRate::Hz400)
.unwrap();
// Разрешаем датчику измерять до 16 g, ведь удары человека
// могут быть очень быстрыми
sensor.set_accel_scale(AccelScale::G16).unwrap();
let mut max_g = 0.;
let mut countdown_ticks = None;
loop {
while !sensor.accel_status().unwrap().xyz_new_data() {
nop();
}
// ускорение по x в g
let (x, _, _) = sensor.acceleration().unwrap().xyz_mg();
let g_x = x as f32 / 1000.0;
if let Some(ticks) = countdown_ticks {
if ticks > 0 {
// отсчёт ещё не закончился
if g_x > max_g {
max_g = g_x;
}
countdown_ticks = Some(ticks - 1);
} else {
// Отсчёт закончен: выводим максимальное значение
rprintln!("Max acceleration: {}g", max_g);
// Сбрасываем
max_g = 0.;
countdown_ticks = None;
}
} else {
// Если ускорение превысило порог, начинаем измерение
if g_x > THRESHOLD {
rprintln!("START!");
max_g = g_x;
countdown_ticks = Some(TICKS_PER_SEC);
}
}
}
}
Прерывания
Прерывания
До сих пор мы «потрогали» немало оборудования MB2. Мы считывали кнопки, ждали таймеры, выполняли последовательную связь и общались с устройствами по I2C. Во всех этих случаях нам приходилось ждать, пока одна или несколько периферийных схем будут готовы. До сих пор ожидание шло через «опрос» (polling): мы снова и снова спрашивали у периферии, готова ли она, пока та не станет готова.
Поскольку у нашего микроконтроллера всего одно ядро процессора, пока он ждёт, он не может делать ничего другого. К тому же ядро, которое непрерывно опрашивает периферию, тратит энергию впустую, а во многих приложениях так позволить себе нельзя. Можно ли сделать лучше?
Можно! Хотя наш маленький микроконтроллер не может вычислять параллельно, он легко переключается между разными задачами во время выполнения, реагируя на события внешнего мира. Такое переключение делается с помощью механизма «прерываний» (interrupts).
Название «прерывания» вполне оправдано: они позволяют периферии прервать выполнение основной программы в любой момент времени. В nRF52833 нашего MB2 периферия подключена к Nested Vectored Interrupt Controller (NVIC), вложенному векторному контроллеру прерываний ядра. NVIC может мгновенно остановить процессор, велеть ему заняться чем-то другим, а когда это закончится, вернуть процессор к тому, чем он занимался до прерывания. О словах «вложенный» и «векторный» в названии контроллера мы поговорим позже: сначала разберёмся, как ядро переключает задачи.
Обработка прерываний
Модель вычислений, которую использует наш nRF52833, — та же, что у почти всех современных процессоров. Внутри процессора есть ячейки «черновой» памяти, которые называются «регистрами процессора». (Путаницы добавляет то, что регистры процессора — не то же самое, что «регистры устройства», о которых мы говорили в главе Регистры.) Чтобы выполнить вычисление, процессор обычно загружает значения из памяти в регистры процессора, выполняет вычисление с их помощью, а затем сохраняет результат обратно в память. (Это известно как «архитектура загрузки-сохранения», load-store architecture.)
Всё, что касается текущего вычисления, хранится в регистрах процессора. Если ядру нужно переключить задачу, оно должно где-то сохранить содержимое регистров процессора, чтобы новая задача могла использовать регистры как собственную черновую память. Когда новая задача закончится, процессор может восстановить значения регистров и продолжить прерванное вычисление. И действительно, именно это первое, что делает ядро в ответ на запрос прерывания: оно немедленно останавливает текущую работу и сохраняет содержимое регистров процессора в стеке.
Следующий шаг — переход к коду, который должен выполниться в ответ на прерывание. Служебная подпрограмма прерывания (Interrupt Service Routine, ISR), которую часто называют обработчиком прерывания (interrupt «handler»), — это особая функция в коде вашего приложения, которую ядро вызывает в ответ на прерывания. В памяти есть «таблица прерываний», которая содержит «вектор прерывания» для каждого возможного прерывания: вектор указывает, какую ISR вызвать при получении конкретного прерывания. Подробности о том, как выбирается ISR, описаны в разделе NVIC и приоритеты прерываний.
ISR «возвращает управление» с помощью специальной машинной инструкции возврата из прерывания, которая заставляет процессор восстановить регистры процессора и вернуться к тому месту, откуда была вызвана ISR.
Ткнём в MB2
Определим ISR и настроим прерывание, которое будет «тыкать» MB2 при нажатии Button A (examples/poke.rs). Плата ответит фразой «ouch» и запаникует.
#![no_main]
#![no_std]
use cortex_m::asm;
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
gpiote,
pac::{self, interrupt},
},
Board,
};
/// Эта «функция» будет вызвана при получении прерывания. Пока что просто
/// выводим сообщение и паникуем.
#[interrupt]
fn GPIOTE() {
rprintln!("ouch");
panic!();
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let button_a = board.buttons.button_a.into_floating_input();
// Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
// линия переходит в низкий уровень).
let gpiote = gpiote::Gpiote::new(board.GPIOTE);
let channel = gpiote.channel0();
channel
.input_pin(&button_a.degrade())
.hi_to_lo()
.enable_interrupt();
channel.reset_events();
// Настраиваем NVIC для обработки прерываний GPIO.
unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
loop {
// «wait for interrupt»: процессор засыпает до прихода прерывания.
asm::wfi();
}
}
Функция-обработчик ISR «особенная». Имя GPIOTE здесь обязательно: оно указывает, что эта ISR должна быть записана в место для прерывания GPIOTE в таблице прерываний.
Атрибут #[interrupt] используется на этапе компиляции, чтобы пометить функцию как ISR, которую нужно обрабатывать особым образом. (Это «процедурный макрос» (proc macro): подробнее о нём можно прочитать в книге по Rust, если хотите.)
По сути, «процедурный макрос» превращает исходный код в другой исходный код. Если вам интересно, во что превращается конкретный вызов макроса, его можно раскрыть. Для этого можно воспользоваться инструментами в Rust Playground или командой «rust-analyzer: Expand macro» в вашей IDE.
Пометка функции атрибутом #[interrupt] подразумевает для неё несколько особых вещей:
-
Компилятор проверит, что функция не принимает аргументов и не возвращает значение (или никогда не возвращает управление). У процессора нет аргументов, которые можно передать ISR, и нет места, куда можно вернуть значение из ISR. Всё потому, что у обработчиков прерываний собственный стек вызовов (по крайней мере, концептуально, хотя на практике не всегда).
-
Указатель на эту функцию (то есть адрес функции) будет помещён в таблицу прерываний в позицию, которая соответствует имени функции.
-
Компилятор не даст вызывать ISR напрямую из обычного кода.
Для настройки прерывания есть два шага. Сначала GPIOTE нужно настроить так, чтобы он генерировал прерывание, когда вывод, подключённый к Button A, переходит из высокого уровня напряжения в низкий. Затем NVIC нужно настроить так, чтобы он разрешал это прерывание. Порядок важен: если сделать всё в «неправильном» порядке, прерывание может возникнуть раньше, чем вы будете к нему готовы.
Примечание. Как и у большинства микроконтроллеров, у GPIOTE есть много гибкости в том, когда он может генерировать прерывание. Прерывания могут возникать при переходе вывода от низкого к высокому, от высокого к низкому (как здесь), при любом изменении («фронт»), когда вывод в низком уровне или когда в высоком. На nRF52833 прерывание генерирует событие, которое в ISR нужно сбросить вручную, иначе ISR будет вызвана повторно по тому же прерыванию. У других микроконтроллеров всё может работать немного иначе: чтобы разобраться в деталях для другой платы, читайте документацию крейта Rust и микроконтроллера.
Когда вы нажимаете кнопку A, вы видите сообщение «ouch», а затем панику. Почему обработчик прерывания вызывает panic!()? Попробуйте закомментировать вызов panic!() и посмотрите, что произойдёт при нажатии кнопки. Вы увидите, как сообщения «ouch» бесконечно пролетают по экрану. NVIC запоминает, когда прерывание произошло: это «событие» сохраняется, пока программа явно его не сбросит. Без panic!() при возврате из обработчика прерывания NVIC (в этом случае) снова разрешит прерывание, заметит, что событие прерывания всё ещё ожидает обработки, и запустит обработчик снова. Так будет продолжаться вечно: каждый раз, когда обработчик прерывания возвращает управление, его вызовут снова. Как мы скоро увидим, индикацию прерывания можно сбросить внутри обработчика с помощью метода периферии reset_event().
Вы можете определить ISR для многих разных источников прерываний: когда готов I2C, когда истекает таймер и так далее. Внутри ISR можно делать почти всё что угодно, но хорошей практикой считается держать обработчики прерываний короткими и быстрыми.
Обычно после завершения ISR основная программа продолжает работать так, как работала бы, если бы прерывания не было. Но это немного проблема: как же ваше приложение узнает, что ISR выполнилась и что-то сделала? Раз у ISR нет входных параметров и результата, как код ISR может взаимодействовать с кодом приложения?
NVIC и приоритеты прерываний
NVIC и приоритеты прерываний
Мы видели, что прерывания заставляют процессор мгновенно перейти к другой функции в коде, но что происходит за кулисами, чтобы это стало возможным? В этом разделе рассказаны технические детали, которые не понадобятся для остальной части книги, так что смело пропускайте его, если вам это не интересно.
Контроллер прерываний
Прерывания позволяют процессору реагировать на события периферии, например на изменение состояния входного вывода GPIO, на завершение цикла таймера или на получение нового байта по UART. Периферия содержит схемы, которые замечают событие и сообщают о нём специальной периферии для обработки прерываний. В процессорах Arm эта периферия называется NVIC — Nested Vector Interrupt Controller.
ПРИМЕЧАНИЕ В других архитектурах микроконтроллеров, например RISC-V, названия и детали, описанные здесь, будут отличаться, но базовые принципы, как правило, очень похожи.
NVIC может получать запросы на инициирование прерываний от многих периферийных устройств. Более того, у одной периферии часто бывает несколько возможных прерываний: например, у порта GPIO — прерывание для каждого вывода, а у UART — и прерывание «данные получены», и прерывание «передача завершена». Задача NVIC — расставить эти прерывания по приоритету, запомнить, какие из них ещё нужно обработать, и заставить процессор выполнить соответствующий код обработчика.
Приоритеты прерываний
У каждого прерывания в NVIC есть настраиваемый «приоритет». В зависимости от настроек NVIC может гарантировать, что текущее прерывание полностью обработается, прежде чем начнётся новое, или может «вытеснить» процессор из середины одного прерывания, чтобы обработать другое, с более высоким приоритетом.
Вытеснение позволяет процессорам очень быстро реагировать на критические события. Например, контроллер робота может использовать прерывания низкого приоритета для отправки статусной информации оператору, но принять прерывание высокого приоритета, когда датчик обнаружит неминуемое столкновение, чтобы немедленно остановить моторы. Вы же не хотите, чтобы робот дождался окончания отправки пакета данных, прежде чем дойти до остановки!
Если во время ISR происходит прерывание того же или более низкого приоритета, оно «откладывается» (pended): NVIC запомнит новое прерывание и запустит его ISR где-то после завершения текущей ISR. Когда функция ISR возвращает управление, NVIC проверяет, не произошли ли за время её работы другие прерывания, которые нужно обработать. Если да, NVIC смотрит в таблицу прерываний и вызывает ту ISR, у которой наивысший приоритет из записанных там. Иначе процессор возвращается к выполняемой программе.
Обратите внимание: если прерывания полностью отключены, все входящие прерывания будут отложены. Отложенные прерывания обработаются, когда прерывания снова будут включены.
В embedded Rust NVIC можно программировать с помощью крейта cortex-m, который предоставляет методы для разрешения и запрета прерываний (их называют unmask и mask), для задания приоритетов прерываний и для инициирования прерываний программно. Такие фреймворки, как RTIC, могут сами настраивать NVIC за вас, используя его гибкость для удобного разделения ресурсов и управления задачами.
Подробнее о NVIC можно прочитать в документации Arm.
Таблица векторов
Когда мы описывали NVIC, я сказал, что он может «заставить процессор выполнить код соответствующего обработчика прерывания». Но как это на самом деле работает?
Во-первых, процессору нужен способ узнать, какой код выполнять для каждого прерывания. На процессорах Cortex-M в этом участвует область памяти, которая называется таблицей векторов. Обычно она расположена в самом начале флеш-памяти с нашим кодом (эта память перепрограммируется при каждой загрузке нового кода) и содержит список адресов — расположений в памяти каждой функции прерывания. Конкретная раскладка начала памяти определена Arm в Architecture Reference Manual; для наших целей важно, что байты с 64 по 256 содержат адреса всех 48 обработчиков прерываний для используемого нами процессора nRF, по четыре байта на адрес. У каждого прерывания есть номер от 0 до 47. Например, TIMER0 — прерывание номер 8, поэтому байты с 96 по 100 содержат четырёхбайтовый адрес его обработчика. Когда NVIC велит процессору обработать прерывание номер 8, процессор читает адрес, сохранённый в этих байтах, и переходит к нему.
Как эта таблица векторов генерируется в нашем коде? За это отвечает крейт cortex-m-rt, который делает всё необходимое. Он задаёт прерывание по умолчанию для каждой неиспользуемой позиции (поскольку каждая позиция должна быть заполнена) и позволяет нашему коду переопределить это значение, когда нам нужно указать собственный обработчик прерывания. Для этого мы используем макрос #[interrupt], который требует, чтобы функция имела определённое имя, связанное с обрабатываемым прерыванием. Затем крейт cortex-m-rt использует свой скрипт компоновщика, чтобы адрес этой функции оказался в нужной части памяти.
Подробнее о том, как в Rust управляются эти обработчики прерываний, см. главы об исключениях и прерываниях во встраиваемой книге Rust.
Совместное использование данных через глобальные переменные
Совместное использование данных через глобальные переменные
ПРИМЕЧАНИЕ Этот материал частично взят (с разрешения автора) из статьи Interrupts Is Threads Джеймса Манса, в которой эта тема обсуждается подробнее.
Как я упоминал ранее, при прерывании нам не передают никаких аргументов и мы не можем вернуть никакого результата. Из-за этого программе трудно взаимодействовать с периферией и с состоянием основной программы. Прежде чем разбираться с этой проблемой «голого железа», стоит, вероятно, вспомнить про потоки в «std»-Rust.
Rust «std»: совместное использование данных с потоком
В «std»-Rust тоже нужно думать о совместном использовании данных, когда, например, создаём поток.
Если вы хотите отдать что-то потоку, можно передать это в замыкание с передачей владения.
#![allow(unused)]
fn main() {
// Создаём строку в текущем потоке
let data = String::from("hello");
// Теперь создаём новый поток и ОТДАЁМ ему владение строкой,
// которую только что создали
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1000));
println!("{data}");
});
}
Если же вы хотите поделиться чем-то и сохранить доступ к этому в исходном потоке, обычно нельзя просто передать ссылку на него. Если сделать так:
use std::{thread::{sleep, spawn}, time::Duration};
fn main() {
// Создаём строку в текущем потоке
let data = String::from("hello");
// Создаём ссылку, которую передадим дальше
let data_ref = &data;
// Теперь создаём новый поток и ОТДАЁМ ему владение строкой,
// которую только что создали
spawn(|| {
sleep(Duration::from_millis(1000));
println!("{data_ref}");
});
println!("{data_ref}");
}
вы получите ошибку примерно такого вида:
error[E0597]: `data` does not live long enough
--> src/main.rs:6:20
|
3 | let data = String::from("hello");
| ---- binding `data` declared here
...
6 | let data_ref = &data;
| ^^^^^ borrowed value does not live long enough
...
10 | / spawn(|| {
11 | | sleep(Duration::from_millis(1000));
12 | | println!("{data_ref}");
13 | | });
| |______- argument requires that `data` is borrowed for `'static`
...
16 | }
| - `data` dropped here while still borrowed
Нужно убедиться, что данные живут достаточно долго и для текущего потока, и для нового потока, который вы создаёте. Этого можно добиться, поместив данные в Arc (Atomically Reference Counted heap allocation, выделение в куче с атомарным подсчётом ссылок), например так:
use std::{sync::Arc, thread::{sleep, spawn}, time::Duration};
fn main() {
// Создаём строку в текущем потоке
let data = Arc::new(String::from("hello"));
let handle = spawn({
// Делаем копию дескриптора, которую ОТДАЁМ новому потоку.
// `data` и `new_thread_data` указывают
// на одну и ту же строку!
let new_thread_data = data.clone();
move || {
sleep(Duration::from_millis(1000));
println!("{new_thread_data}");
}
});
println!("{data}");
// ждём, пока поток завершится
let _ = handle.join();
}
Это здорово! Теперь вы можете сколько угодно обращаться к данным и в основном потоке, и в новом. Но что, если хочется изменять данные в обоих местах?
Для этого обычно нужна какая-то форма «внутренней изменяемости» (inner mutability) — тип, который не требует &mut для изменения. На десктопе обычно берут такой тип, как Mutex, и вызывают lock(), чтобы получить изменяемый доступ к данным.
Это может выглядеть примерно так:
use std::{sync::{Arc, Mutex}, thread::{sleep, spawn}, time::Duration};
fn main() {
// Создаём строку в текущем потоке
let data = Arc::new(Mutex::new(String::from("hello")));
// блокируем её из исходного потока
{
let guard = data.lock().unwrap();
println!("{guard}");
// guard освобождается здесь, в конце области видимости!
}
let handle = spawn({
// Делаем копию дескриптора, которую вы ОТДАЁТЕ новому потоку.
// `data` и `new_thread_data` указывают
// на один и тот же `Mutex<String>`!
let new_thread_data = data.clone();
move || {
sleep(Duration::from_millis(1000));
{
let mut guard = new_thread_data.lock().unwrap();
// теперь мы можем менять данные!
guard.push_str(" | thread was here! |");
// guard освобождается здесь, в конце области видимости!
}
}
});
// ждём, пока поток завершится
let _ = handle.join();
{
let guard = data.lock().unwrap();
println!("{guard}");
// guard освобождается здесь, в конце области видимости!
}
}
Если запустить этот код, вы увидите:
hello
hello | thread was here! |
Почему «std»-Rust заставляет нас так делать? Rust помогает, заставляя задуматься о двух вещах:
- Данные живут достаточно долго (потенциально «вечно»!)
- Только один фрагмент кода может изменять данные в каждый момент времени
Если бы Rust позволял обращаться к данным, которые могут не прожить достаточно долго, например к данным, заимствованным из одного потока в другой, могли бы возникнуть проблемы. Мы могли бы получить повреждённые данные, если исходный поток завершится или упадёт в панику, а второй поток попытается обратиться к уже недействительным данным. Если бы Rust позволял двум фрагментам кода одновременно пытаться изменить одни и те же данные, возникла бы гонка данных (data race) или данные могли бы оказаться повреждены.
Embedded Rust: совместное использование данных с ISR
В embedded Rust нас волнует то же самое, когда речь идёт о совместном использовании данных с обработчиками прерываний! Как и потоки, прерывания могут случиться в любой момент, примерно как если бы поток просыпался и обращался к каким-то общим данным. Это значит, что данные, которые мы разделяем с прерыванием, должны жить достаточно долго, и мы должны следить, чтобы основной код не работал с какими-то данными, общими с ISR, в тот момент, когда эта ISR запускается и тоже пытается с ними работать!
Фактически в embedded Rust мы моделируем прерывания так же, как потоки в Rust: действуют те же правила по тем же причинам. Однако в embedded Rust есть некоторые важные отличия:
-
Прерывания работают не совсем как потоки: мы настраиваем их заранее, и они ждут, пока не случится какое-то событие (например, нажата кнопка или истёк таймер). В этот момент они выполняются, но без доступа к каким-либо переданным контекстам.
-
Прерывания могут срабатывать многократно: по одному разу на каждое наступление события.
Поскольку мы не можем передавать контекст прерываниям как аргументы функции, нужно найти другое место для хранения этих данных. В «голом» (bare metal) embedded Rust у нас нет доступа к выделению памяти в куче: поэтому Arc и подобные типы нам не подходят.
Без возможности передавать значения и без кучи для хранения данных у нас остаётся одно место для общих данных, к которым может обратиться наша ISR: глобальные переменные static.
Совместное использование данных с ISR в Embedded Rust: «стандартный способ»
Глобальные переменные в Rust — граждане второго сорта, с гораздо большим количеством ограничений, чем локальные переменные. Глобальную переменную состояния можно объявить так:
#![allow(unused)]
fn main() {
static COUNTER: usize = 0;
}
Конечно, это не слишком полезно: вам нужно менять COUNTER. Можно написать
#![allow(unused)]
fn main() {
static mut COUNTER: usize = 0;
}
но тогда все обращения к ней будут unsafe.
#![allow(unused)]
fn main() {
unsafe { COUNTER += 1 };
}
Небезопасность здесь не просто так: представьте, что посреди обновления COUNTER запускается обработчик прерывания и тоже пытается обновить COUNTER. Начнётся обычный хаос. Очевидно, что нужна какая-то блокировка.
Крейт critical-section предоставляет что-то вроде типа Mutex, но с необычным API и необычными операциями. Если посмотреть на Cargo.toml этой главы, вы увидите, что для крейта cortex-m включена функция critical-section-single-core. Она утверждает, что в системе только одно ядро процессора и что поэтому синхронизацию можно выполнить простым отключением прерываний на время критической секции. Если код не в прерывании, это гарантирует, что только основная программа имеет доступ к глобальной переменной. Если код в прерывании, это гарантирует, что основная программа не может обращаться к глобальной переменной (управление у обработчика прерывания) и что никакое другое прерывание с более высоким приоритетом не сработает.
critical_section::Mutex немного странный: он обеспечивает взаимное исключение, но сам по себе не даёт изменяемости. Чтобы данные можно было менять, нужно защитить мьютексом тип с внутренней изменяемостью — обычно RefCell. Этот Mutex также странен тем, что его не .lock()-ают. Вместо этого вы открываете критическую секцию с помощью замыкания, которое получает «токен критической секции», подтверждающий, что выполнение другой программы предотвращено. Этот токен можно передать методу borrow() у Mutex, чтобы получить доступ.
Собрав всё вместе, получаем возможность разделять состояние между ISR и основной программой (examples/count-once.rs):
#![no_main]
#![no_std]
use core::cell::RefCell;
use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section::Mutex;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
gpiote,
pac::{self, interrupt},
},
Board,
};
static COUNTER: Mutex<RefCell<usize>> = Mutex::new(RefCell::new(0));
/// Эта «функция» будет вызвана при получении прерывания. Пока что просто
/// выводим сообщение и паникуем.
#[interrupt]
fn GPIOTE() {
critical_section::with(|cs| {
let mut count = COUNTER.borrow(cs).borrow_mut();
*count += 1;
rprintln!("count: {}", count);
});
panic!();
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let button_a = board.buttons.button_a.into_floating_input();
// Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
// линия переходит в низкий уровень).
let gpiote = gpiote::Gpiote::new(board.GPIOTE);
let channel = gpiote.channel0();
channel
.input_pin(&button_a.degrade())
.hi_to_lo()
.enable_interrupt();
channel.reset_events();
// Настраиваем NVIC для обработки прерываний GPIO.
unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
loop {
// «wait for interrupt»: процессор засыпает до прихода прерывания.
asm::wfi();
}
}
Вы по-прежнему не можете безопасно вернуть управление из ISR, но теперь можно что-то с этим сделать: передать GPIOTE в ISR, чтобы она могла сбросить прерывание.
Совместное использование периферии (и не только) через глобальные переменные
Остаётся решить ещё одну проблему: глобальные переменные Rust должны инициализироваться статически, то есть до запуска программы. Для счётчика это было легко: просто инициализировать нулём. Но если хочется разделить периферию GPIOTE, это не сработает. Периферию нужно взять из структуры Board и настроить уже после запуска программы: для неё нет const-инициализатора (да и не может быть разумного).
Перепишем счётчик кнопки немного. Сначала переведём сам счётчик в AtomicUsize. Это более естественный тип для такой глобальной переменной. Затем добавим глобальную переменную GPIOTE_PERIPHERAL с типом LockMut из крейта critical-section-lock-mut. Этот крейт — удобная обёртка для шаблона из предыдущего раздела.
Теперь, когда основная программа может настроить периферию GPIOTE и передать её обработчику прерывания, можно перестать паниковать и позволить счётчику увеличиваться при каждом нажатии кнопки. Перенесём вывод счётчика в основной цикл, чтобы показать, что счётчик разделяется между обработчиком прерывания и остальной программой.
Запустите этот пример (examples/count.rs) и обратите внимание, что счётчик увеличивается на 1 при каждом нажатии кнопки A на MB2.
#![no_main]
#![no_std]
use core::sync::atomic::{AtomicUsize, Ordering::AcqRel};
use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
gpiote,
pac::{self, interrupt},
},
Board,
};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
static GPIOTE_PERIPHERAL: LockMut<gpiote::Gpiote> = LockMut::new();
#[interrupt]
fn GPIOTE() {
let count = COUNTER.fetch_add(1, AcqRel);
rprintln!("ouch {}", count + 1);
GPIOTE_PERIPHERAL.with_lock(|gpiote| {
gpiote.channel0().reset_events();
});
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let button_a = board.buttons.button_a.into_floating_input();
// Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
// линия переходит в низкий уровень).
let gpiote = gpiote::Gpiote::new(board.GPIOTE);
let channel = gpiote.channel0();
channel
.input_pin(&button_a.degrade())
.hi_to_lo()
.enable_interrupt();
channel.reset_events();
GPIOTE_PERIPHERAL.init(gpiote);
// Настраиваем NVIC для обработки прерываний GPIO.
unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
loop {
// «wait for interrupt»: процессор засыпает до прихода прерывания.
asm::wfi();
}
}
ПРИМЕЧАНИЕ Всегда хорошая идея собирать примеры с обработкой прерываний с
--release. Долгие обработчики прерываний могут привести к большой путанице.
Впрочем, вызов rprintln!() в обработчике прерывания — плохая практика: пока обработчик выполняет вывод, ничто другое не может продвинуться вперёд. Перенесём вывод в основной цикл, сразу после wfi(), «ожидания прерывания». Тогда счётчик будет выводиться каждый раз, когда обработчик прерывания завершится (examples/count-bounce.rs).
#![no_main]
#![no_std]
use core::sync::atomic::{
AtomicUsize,
Ordering::{AcqRel, Acquire},
};
use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
gpiote,
pac::{self, interrupt},
},
Board,
};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
static GPIOTE_PERIPHERAL: LockMut<gpiote::Gpiote> = LockMut::new();
#[interrupt]
fn GPIOTE() {
let _ = COUNTER.fetch_add(1, AcqRel);
GPIOTE_PERIPHERAL.with_lock(|gpiote| {
gpiote.channel0().reset_events();
});
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let button_a = board.buttons.button_a.into_floating_input();
// Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
// линия переходит в низкий уровень).
let gpiote = gpiote::Gpiote::new(board.GPIOTE);
let channel = gpiote.channel0();
channel
.input_pin(&button_a.degrade())
.hi_to_lo()
.enable_interrupt();
channel.reset_events();
GPIOTE_PERIPHERAL.init(gpiote);
// Настраиваем NVIC для обработки прерываний GPIO.
unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
loop {
// «wait for interrupt»: процессор засыпает до прихода прерывания.
asm::wfi();
let count = COUNTER.load(Acquire);
rprintln!("ouch {}", count);
}
}
В этом примере счётчик увеличивается на 1 при каждом нажатии кнопки A на MB2. Наверное. Особенно если ваш MB2 старый (!), одно нажатие может увеличить счётчик сразу на несколько. Это не программная ошибка. В основном. В следующем разделе я расскажу, что может происходить и как с этим бороться.
Устранение дребезга
Устранение дребезга
Как я упоминал в предыдущем разделе, аппаратура может быть немного… особенной. Это точно относится к кнопкам MB2, да и вообще почти к любой кнопке или переключателю почти в любой системе. Если вы видите несколько прерываний для одного нажатия, скорее всего, это результат так называемого «дребезга» контактов (switch bouncing). Название говорит само за себя: когда электрические контакты переключателя смыкаются, они могут отскакивать, снова и снова замыкаясь за очень короткое время, прежде чем установится надёжное соединение. К сожалению, наш микропроцессор очень быстр по механическим меркам: каждый такой отскок порождает новое прерывание.
Чтобы «устранить дребезг» (debounce) переключателя, нужно не обрабатывать прерывания от нажатия кнопки в течение короткого времени после того, как получили одно. Обычно хорошим интервалом подавления дребезга считаются 50–100 мс. Подобрать время подавления дребезга непросто: в обработчике прерывания точно не стоит крутиться в ожидании, а разбираться с этим в основной программе было бы трудно.
Решение приходит через ещё одну форму аппаратной конкурентности: через TIMER, которым мы уже много раз пользовались. Можно запустить таймер, когда получено «хорошее» прерывание от кнопки, и не реагировать на дальнейшие прерывания от этой кнопки, пока периферия таймера не отсчитает достаточно времени. Таймеры в nrf-hal настроены с 32-битным счётчиком и «частотой тиков» 1 МГц: миллион тиков в секунду. Для подавления дребезга в 100 мс достаточно, чтобы таймер отсчитал 100 000 тиков. Всякий раз, когда обработчик прерывания кнопки видит, что таймер запущен, он может просто ничего не делать.
Реализация всего этого показана в следующем примере (examples/count-debounce.rs). При запуске примера вы должны увидеть по одному счёту на каждое нажатие кнопки.
#![no_main]
#![no_std]
use core::sync::atomic::{
AtomicUsize,
Ordering::{AcqRel, Acquire},
};
use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
self, gpiote,
pac::{self, interrupt},
},
Board,
};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
static GPIOTE_PERIPHERAL: LockMut<gpiote::Gpiote> = LockMut::new();
static DEBOUNCE_TIMER: LockMut<hal::Timer<pac::TIMER0>> = LockMut::new();
// 100 мс при частоте счёта 1 МГц.
const DEBOUNCE_TIME: u32 = 100 * 1_000_000 / 1000;
#[interrupt]
fn GPIOTE() {
DEBOUNCE_TIMER.with_lock(|debounce_timer| {
if debounce_timer.read() == 0 {
let _ = COUNTER.fetch_add(1, AcqRel);
debounce_timer.start(DEBOUNCE_TIME);
}
});
GPIOTE_PERIPHERAL.with_lock(|gpiote| {
gpiote.channel0().reset_events();
});
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let button_a = board.buttons.button_a.into_floating_input();
// Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
// линия переходит в низкий уровень).
let gpiote = gpiote::Gpiote::new(board.GPIOTE);
let channel = gpiote.channel0();
channel
.input_pin(&button_a.degrade())
.hi_to_lo()
.enable_interrupt();
channel.reset_events();
GPIOTE_PERIPHERAL.init(gpiote);
// Настраиваем таймер подавления дребезга.
let mut debounce_timer = hal::Timer::new(board.TIMER0);
debounce_timer.disable_interrupt();
debounce_timer.reset_event();
DEBOUNCE_TIMER.init(debounce_timer);
// Настраиваем NVIC для обработки прерываний.
unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
// Поскольку мы не отключаем прерывания GPIOTE даже во время отсчёта таймера подавления дребезга,
// дополнительные прерывания от кнопки могут приходить и внутри интервала подавления.
// Разумно было бы отключать прерывания кнопки, когда запущен таймер подавления дребезга,
// и включать их снова по его истечении, но тогда понадобился бы обработчик прерывания таймера.
// Чтобы простое «исправление» не ухудшало читаемость,
// вводим переменную-«охранник» cur_count.
let mut cur_count = 0;
loop {
// «wait for interrupt»: процессор засыпает до прихода прерывания.
asm::wfi();
let count = COUNTER.load(Acquire);
if count > cur_count {
rprintln!("ouch {}", count);
cur_count = count;
}
}
}
ПРИМЕЧАНИЕ. Кнопки MB2 немного капризны: очень легко нажать на кнопку достаточно, чтобы почувствовать «щелчок», но недостаточно, чтобы контакт действительно замкнулся. При тестировании рекомендую нажимать кнопку ногтем.
Ожидание прерывания
Возможно, вам было интересно, почему в основном цикле мы использовали asm::wfi() (wait for interrupt, «ждать прерывания»), а не что-то вроде asm::nop().
Как говорилось раньше, asm::nop() означает no-operation, «нет операции», и это инструкция, которую процессор выполняет, ничего не делая. Мы вполне могли бы использовать asm::nop() в основном цикле, и программа вела бы себя так же. А вот микроконтроллер вёл бы себя по-другому.
Вызов asm::wfi() переводит процессор в режим «Wait For Interrupt» (WFI). Когда процессор в режиме WFI, он спит, пока не разбудит его прерывание. Во время сна процессор перестаёт выбирать инструкции, отключает некоторые тактовые сигналы и периферию и переходит в состояние низкого энергопотребления, но ядро при этом продолжает оставаться включённым. Когда происходит прерывание, процессор просыпается и продолжает работать как обычно.
Главное различие между asm::wfi() и asm::nop() в том, что инструкция NOP выполняется мгновенно, поэтому в цикле она будет выполняться снова и снова. NOP всё равно нужно выбрать из памяти программы и выполнить, хотя ничего не происходит. У большинства микроконтроллеров, которые вы найдёте, есть режим низкого энергопотребления (у некоторых даже несколько режимов, в каждом из которых включены разные части и потребление различается), который можно (и во многих случаях нужно) использовать для экономии энергии. Инструкция WFI останавливает выполнение в режиме низкого энергопотребления, пока не придёт прерывание.
Некоторые программы, управляемые прерываниями, состоят из одного лишь asm::wfi() в основном цикле, а вся логика программы реализована в обработчиках прерываний.
Динамик MB2
На вашем MB2 есть встроенный динамик — большое чёрное квадратное устройство с надписью «SPEAKER» в центре обратной стороны платы.
Динамик работает так: он перемещает воздух в ответ на сигнал с GPIO-вывода. Когда вывод динамика в высоком уровне (3,3 В), внутренняя диафрагма — «конус динамика» — выдвигается полностью наружу; когда вывод в низком уровне (GND), конус втягивается полностью внутрь. Когда воздух выталкивается наружу и втягивается обратно, он проходит через маленькое прямоугольное отверстие — «порт динамика» — на боковой стороне устройства. Если делать это достаточно быстро, перепады давления создадут звук.
При правильной схеме управления этот конус динамика можно было бы переместить в любое положение в своём диапазоне с помощью соответствующего тока. Это позволило бы довольно хорошо воспроизводить любой звук, как у «обычного» динамика. К сожалению, ограничения аппаратуры MB2, управляющей динамиком, означают, что доступны только крайние положения — полностью внутрь и полностью наружу.
Давайте выталкивать конус динамика наружу и внутрь 220 раз в секунду. Это создаст «квадратную» волну давления с 220 циклами в секунду. Единица «циклов в секунду» — герц; мы получим тон 220 Гц (музыкальная нота «ля» малой октавы, A3), который на этом пронзительном динамике звучит неплохо.
Мы сделаем так, чтобы тон звучал пять секунд, а затем прекратился. Важно помнить, что наша программа хранится во флеш-памяти MB2: тон будет начинаться снова при каждом сбросе или даже включении MB2. Если позволить тону звучать вечно, такое поведение быстро станет очень раздражающим.
Вот код (examples/square-wave.rs).
#![no_main]
#![no_std]
use cortex_m::asm;
use cortex_m_rt::entry;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
use microbit::{
hal::{gpio, timer},
Board,
};
/// «Период» — это время одного цикла. Он равен
/// 1/f, где f — частота в Гц. В этом случае
/// время измеряем в миллисекундах.
const PERIOD: u32 = 1000 / 220;
/// Количество циклов для пяти секунд звучания.
const CYCLES: u32 = 5000 / PERIOD;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let mut speaker_pin = board.speaker_pin.into_push_pull_output(gpio::Level::Low);
let mut timer = timer::Timer::new(board.TIMER0);
for _ in 0..CYCLES {
speaker_pin.set_high().unwrap();
timer.delay_ms(PERIOD / 2);
speaker_pin.set_low().unwrap();
timer.delay_ms(PERIOD / 2);
}
loop {
asm::wfi();
}
}
Задание
Давайте превратим MB2 в сирену! Но не в какую-нибудь сирену, а в сирену, управляемую прерываниями. Тогда мы сможем включить сирену, а остальная программа будет продолжать работать, не обращая на неё внимания.
Заставьте сирену плавно менять высоту тона от 440 Гц до 660 Гц и обратно за период в одну секунду. Основная программа должна запустить сирену, затем вывести десятисекундный обратный отсчёт от 10 до 1, затем остановить сирену и вывести «launch!». Во время отсчёта основная программа не должна вмешиваться в работу сирены: она должна управляться только прерываниями.
Подсказка: мне было проще всего использовать глобальную блокируемую структуру Siren, которая владела состоянием сирены и периферией, нужной для её работы.
Это сложная программа, в которой используется много новых идей. Не удивляйтесь, если на её разбор уйдёт некоторое время.
Моё решение
Мне было немного непросто понять, как обработчику прерывания вычислить время следующего прерывания, чтобы сирена продолжала звучать. В итоге я завёл пару переменных состояния: чтобы отслеживать, включён ли вывод динамика или выключен (можно было бы проверить и аппаратно), и чтобы отслеживать, на каком этапе цикла «вверх — вниз» находится сирена.
Все подробности есть в моём коде (src/main.rs).
#![no_main]
#![no_std]
use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use embedded_hal::{delay::DelayNs, digital::OutputPin};
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
gpio,
pac::{self, interrupt},
timer,
},
Board,
};
/// Базовая частота сирены в Гц.
const BASE_FREQ: u32 = 440;
/// Максимальный подъём частоты сирены в Гц.
const FREQ_RISE: u32 = 220;
/// Время одного полного цикла в мкс.
const RISE_TIME: u32 = 500_000;
/// Эти удобные типы упрощают жизнь.
type SpeakerPin = gpio::Pin<gpio::Output<gpio::PushPull>>;
type SirenTimer = timer::Timer<pac::TIMER0>;
/// Текущее состояние сирены. Обновляется обработчиком прерывания
/// во время работы.
struct Siren {
/// Таймер, который использует сирена.
timer: SirenTimer,
/// Вывод динамика MB2. Должен принадлежать
/// этой структуре, чтобы его мог использовать обработчик прерывания.
speaker_pin: SpeakerPin,
/// Находится ли сейчас вывод динамика в высоком уровне?
pin_high: bool,
/// Время в мкс с начала текущего цикла сирены.
cur_time: u32,
}
impl Siren {
/// Создать новую сирену с заданной периферией.
fn new(speaker_pin: SpeakerPin, timer: SirenTimer) -> Self {
Self {
timer,
speaker_pin,
pin_high: false,
cur_time: 0,
}
}
/// Запустить сирену.
fn start(&mut self) {
self.speaker_pin.set_low().unwrap();
self.pin_high = false;
self.cur_time = 0;
self.timer.enable_interrupt();
// Интервал таймера задаётся в тиках.
// Таймер [nrf52833_hal] жёстко настроен на 1 млн тиков в секунду.
self.timer.start(1_000_000 / BASE_FREQ);
}
/// Остановить сирену.
fn stop(&mut self) {
self.timer.disable_interrupt();
}
/// Перевести сирену к следующему изменению состояния динамика.
/// Обычно вызывается из обработчика прерывания таймера.
fn step(&mut self) {
// Переключаем вывод динамика.
if self.pin_high {
self.speaker_pin.set_low().unwrap();
self.pin_high = false;
} else {
self.speaker_pin.set_high().unwrap();
self.pin_high = true;
}
// Вычисляем следующий период. Математика здесь немного
// особенная.
// Сначала, если нужно, переходим к следующему циклу сирены.
while self.cur_time >= 2 * RISE_TIME {
self.cur_time -= 2 * RISE_TIME;
}
// Затем определяем, где мы находимся в текущем цикле сирены.
let cycle_time = if self.cur_time < RISE_TIME {
self.cur_time
} else {
2 * RISE_TIME - self.cur_time
};
// Наконец, вычисляем частоту и период.
let frequency = BASE_FREQ + FREQ_RISE * cycle_time / RISE_TIME;
let period = 1_000_000 / frequency;
// Предсказываем время следующего прерывания.
self.cur_time += period / 2;
// Обязательно сбросьте текущее прерывание перед
// запуском следующего, иначе может произойти
// новое прерывание сразу же.
self.timer.reset_event();
self.timer.start(period / 2);
}
}
/// Сирена. Доступна и из обработчика прерывания,
/// и из основной программы.
static SIREN: LockMut<Siren> = LockMut::new();
/// Прерывание таймера для сирены. Просто переводит сирену к следующему шагу.
#[interrupt]
fn TIMER0() {
SIREN.with_lock(|siren| siren.step());
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
// Удобно использовать вывод, преобразованный через `degrade()`:
// так не придётся выяснять точный тип вывода динамика.
// Вывод хранится глобально в `SIREN`, поэтому его
// размер должен быть известен.
//
// Типобезопасность при этом теряется, но вряд ли
// это будет важно после этого места.
let speaker_pin = board
.speaker_pin
.into_push_pull_output(gpio::Level::Low)
.degrade();
let timer0 = timer::Timer::new(board.TIMER0);
let mut timer1 = timer::Timer::new(board.TIMER1);
// Настраиваем NVIC для обработки прерываний.
unsafe { pac::NVIC::unmask(pac::Interrupt::TIMER0) };
pac::NVIC::unpend(pac::Interrupt::TIMER0);
// Размещаем структуру сирены там, где её найдёт обработчик прерывания.
let siren = Siren::new(speaker_pin, timer0);
SIREN.init(siren);
// Запускаем сирену и отсчитываем время.
SIREN.with_lock(|siren| siren.start());
for t in (1..=10).rev() {
rprintln!("{}", t);
timer1.delay_ms(1_000);
}
rprintln!("launch!");
SIREN.with_lock(|siren| siren.stop());
loop {
asm::wfi();
}
}
Дополнение: ШИМ
Последнее замечание, прежде чем двигаться дальше.
Прерывания довольно дороги. Процессор должен завершить или прервать текущую инструкцию, затем сохранить достаточно состояния, чтобы потом продолжить выполнение, и только потом вызвать обработчик прерывания. Всё это занимает несколько тактов процессора, то есть драгоценного времени работы.
Решение предыдущего раздела написано так, что на каждый цикл выходного сигнала динамика приходится два прерывания. Это примерно тысяча прерываний в секунду. На таком процессоре, как наш nRF52833, это работает нормально.
У nRF52833 есть встроенная периферия, которая могла бы сильно снизить частоту прерываний сирены. Блок широтно-импульсной модуляции (Pulse-Width Modulation, PWM) может, среди прочего, генерировать циклы на выводе динамика с частотой, которую задаёт регистр PWM. Его можно использовать для генерации базовой квадратной волны для нашей сирены. Прерывание по-прежнему понадобилось бы каждый раз, когда мы захотим поменять частоту, но это было бы что-то вроде 10 прерываний в секунду вместо 1000.
Я не использовал блок PWM в своём решении. Отчасти потому, что хотел сосредоточиться на прерываниях. Ещё одна важная причина: блок PWM nRF52833 довольно сложен и трудно понимаем. Заставить что-то работать простым способом в суровом окружении «голого железа» всегда привлекательно.
Если вы готовы к вызову, попробуйте использовать блок PWM для вашей сирены.
Игра «Змейка»
Теперь мы реализуем простую игру «Змейка», в которую можно играть на MB2: светодиодная матрица 5×5 служит экраном, а две кнопки — органами управления. Попутно мы будем опираться на понятия из предыдущих глав этой книги, а также познакомимся с новой периферией и новыми концепциями.
Модульность
Исходный код здесь модульнее, чем, вероятно, следовало бы. Такая мелкая модульность позволяет разбирать код понемногу. Мы будем строить код снизу вверх: сначала создадим три модуля — game, controls и display, а затем соберём из них итоговую программу. У каждого модуля будет главный файл и один или несколько подключаемых файлов: например, модуль game состоит из src/game.rs, src/game/coords.rs, src/game/movement.rs и т. д. Для объединения частей модуля используется оператор Rust mod. В книге The Rust Programming Language есть хорошее описание системы модулей Rust.
Игровая логика
Первым мы построим игровую логику. Вероятно, вы знакомы с игрой «Змейка», но если нет, суть проста: игрок направляет змейку по двумерной сетке. В каждый момент в случайной клетке сетки есть «еда», и цель игры — заставить змейку съесть как можно больше еды. Каждый раз, съедая еду, змейка становится длиннее. Игрок проигрывает, если змейка врезается в собственный хвост.
В некоторых вариантах игры игрок также проигрывает, если змейка врезается в край сетки, но, учитывая небольшой размер нашей сетки, мы реализуем правило «сквозного перехода» (wraparound): если змейка уходит за один край сетки, она появляется с противоположного края.
Модуль game
Игровую механику мы соберём в модуле game.
Координаты
Начнём с системы координат для нашей игры (src/game/coords.rs).
#![allow(unused)]
fn main() {
use super::Prng;
use heapless::index_set::FnvIndexSet;
/// Одна точка на сетке.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct Coords {
// Знаковые целые, чтобы допускать отрицательные значения (удобно при проверке того, не вышли ли мы
// за верхний или левый край сетки)
pub row: i8,
pub col: i8,
}
impl Coords {
/// Получить случайные координаты внутри сетки. `exclude` — необязательное множество
/// координат, которые нужно исключить из результата.
pub fn random(rng: &mut Prng, exclude: Option<&FnvIndexSet<Coords, 32>>) -> Self {
let mut coords = Coords {
row: ((rng.random_u32() as usize) % 5) as i8,
col: ((rng.random_u32() as usize) % 5) as i8,
};
while exclude.is_some_and(|exc| exc.contains(&coords)) {
coords = Coords {
row: ((rng.random_u32() as usize) % 5) as i8,
col: ((rng.random_u32() as usize) % 5) as i8,
}
}
coords
}
/// Находится ли точка за границами сетки.
pub fn is_out_of_bounds(&self) -> bool {
self.row < 0 || self.row >= 5 || self.col < 0 || self.col >= 5
}
}
}
Для обозначения позиции на сетке мы используем структуру Coords. Поскольку Coords содержит только два целых числа, мы просим компилятор автоматически реализовать для неё трейт Copy, чтобы можно было передавать структуры Coords и не думать о владении.
Генерация случайных чисел
Мы определяем ассоциированную функцию Coords::random, которая выдаёт случайную позицию на сетке. Позже мы используем её, чтобы определять, где появится еда для змейки.
Чтобы генерировать случайные координаты, нужен источник случайных чисел. В nRF52833 есть аппаратный генератор случайных чисел (HWRNG), описанный в разделе 6.19 спецификации nRF52833. HAL предоставляет простой интерфейс к HWRNG через структуру microbit::hal::rng::Rng. Аппаратного генератора может не хватать по скорости для игры; кроме того, для тестирования удобно иметь возможность воспроизводить одну и ту же последовательность случайных чисел от запуска к запуску, а аппаратный генератор по своей конструкции это не позволяет. Поэтому мы также определяем псевдослучайный генератор чисел (PRNG). PRNG использует алгоритм xorshift для генерации псевдослучайных значений u32. Алгоритм простой и не является криптографически стойким, но он эффективен, легко реализуется и вполне подходит для нашей скромной игры «Змейка». Структуре Prng нужно начальное значение (seed), которое мы получаем из периферии RNG.
Всё это составляет src/game/rng.rs.
#![allow(unused)]
fn main() {
use crate::Rng;
/// Простой псевдослучайный генератор чисел.
pub struct Prng {
value: u32,
}
impl Prng {
pub fn seeded(rng: &mut Rng) -> Self {
Self::new(rng.random_u32())
}
pub fn new(seed: u32) -> Self {
Self { value: seed }
}
/// Простая функция xorshift PRNG: см. <https://en.wikipedia.org/wiki/Xorshift>
fn xorshift32(mut input: u32) -> u32 {
input ^= input << 13;
input ^= input >> 17;
input ^= input << 5;
input
}
/// Вернуть псевдослучайное u32.
pub fn random_u32(&mut self) -> u32 {
self.value = Self::xorshift32(self.value);
self.value
}
}
}
Движение
Нам также нужно определить несколько enum, которые помогают управлять состоянием игры: направление движения, направление поворота, текущий статус игры и результат отдельного «шага» игры (то есть одного движения змейки). Все они находятся в src/game/movement.rs.
#![allow(unused)]
fn main() {
use super::Coords;
/// Определяем направления, в которых может двигаться змейка.
pub enum Direction {
Up,
Down,
Left,
Right,
}
/// В каком направлении должна повернуть змейка.
#[derive(Debug, Copy, Clone)]
pub enum Turn {
Left,
Right,
None,
}
/// Текущий статус игры.
pub enum GameStatus {
Won,
Lost,
Ongoing,
}
/// Результат одного хода (шага).
pub enum StepOutcome {
/// Сетка заполнена (игрок выигрывает)
Full,
/// Змейка врезалась в себя (игрок проигрывает)
Collision,
/// Змейка съела еду
Eat(Coords),
/// Змейка переместилась (и больше ничего не произошло)
Move(Coords),
}
}
Змейка (Змееее!)
Далее определим структуру Snake, которая хранит координаты, занятые змейкой, и направление её движения. Для порядка координат используем очередь (heapless::spsc::Queue), а для быстрого обнаружения столкновений — хеш-множество (heapless::FnvIndexSet). У Snake есть методы, которые позволяют ей двигаться. Это находится в src/game/snake.rs.
#![allow(unused)]
fn main() {
use super::{Coords, Direction, FnvIndexSet, Turn};
use heapless::spsc::Queue;
pub struct Snake {
/// Координаты головы змейки.
pub head: Coords,
/// Очередь координат остального тела змейки. Конец хвоста находится
/// в начале очереди.
pub tail: Queue<Coords, 32>,
/// Множество всех координат, которые сейчас занимает змейка (для быстрой
/// проверки столкновений).
pub coord_set: FnvIndexSet<Coords, 32>,
/// Направление, в котором сейчас движется змейка.
pub direction: Direction,
}
impl Snake {
pub fn make_snake() -> Self {
let head = Coords { row: 2, col: 2 };
let initial_tail = Coords { row: 2, col: 1 };
let mut tail = Queue::new();
tail.enqueue(initial_tail).unwrap();
let mut coord_set: FnvIndexSet<Coords, 32> = FnvIndexSet::new();
coord_set.insert(head).unwrap();
coord_set.insert(initial_tail).unwrap();
Self {
head,
tail,
coord_set,
direction: Direction::Right,
}
}
/// Переместить змейку на клетку с заданными координатами. Если `extend` ложно,
/// хвост змейки освобождает самую заднюю клетку.
pub fn move_snake(&mut self, coords: Coords, extend: bool) {
// Место головы становится началом хвоста
self.tail.enqueue(self.head).unwrap();
// Голова перемещается на новые координаты
self.head = coords;
self.coord_set.insert(coords).unwrap();
if !extend {
let back = self.tail.dequeue().unwrap();
self.coord_set.remove(&back);
}
}
fn turn_right(&mut self) {
self.direction = match self.direction {
Direction::Up => Direction::Right,
Direction::Down => Direction::Left,
Direction::Left => Direction::Up,
Direction::Right => Direction::Down,
}
}
fn turn_left(&mut self) {
self.direction = match self.direction {
Direction::Up => Direction::Left,
Direction::Down => Direction::Right,
Direction::Left => Direction::Down,
Direction::Right => Direction::Up,
}
}
pub fn turn(&mut self, direction: Turn) {
match direction {
Turn::Left => self.turn_left(),
Turn::Right => self.turn_right(),
Turn::None => (),
}
}
}
}
Верхний уровень модуля игры
Структура Game хранит состояние игры. В ней находятся объект Snake, текущие координаты еды, скорость игры (по ней определяется время между движениями змейки), статус игры (продолжается ли она, или игрок выиграл или проиграл) и счёт игрока.
В этой структуре есть методы для обработки каждого шага игры: определения следующего движения змейки и соответствующего обновления состояния игры. Там же есть два метода, game_matrix и score_matrix, которые возвращают двумерные массивы значений; их можно вывести на светодиодную матрицу, чтобы показать состояние игры или счёт игрока (как мы увидим позже).
Структуру Game мы разместим на верхнем уровне модуля game, в src/game.rs.
#![allow(unused)]
fn main() {
mod coords;
mod movement;
mod rng;
mod snake;
use crate::Rng;
pub use coords::Coords;
pub use movement::{Direction, GameStatus, StepOutcome, Turn};
pub use rng::Prng;
pub use snake::Snake;
use heapless::index_set::FnvIndexSet;
/// Структура для хранения состояния игры и связанного с ним поведения
pub struct Game {
pub status: GameStatus,
rng: Prng,
snake: Snake,
food_coords: Coords,
speed: u8,
score: u8,
}
impl Game {
pub fn new(rng: &mut Rng) -> Self {
let mut rng = Prng::seeded(rng);
let snake = Snake::make_snake();
let food_coords = Coords::random(&mut rng, Some(&snake.coord_set));
Self {
rng,
snake,
food_coords,
speed: 1,
status: GameStatus::Ongoing,
score: 0,
}
}
/// Сбросить состояние игры, чтобы начать новую игру.
pub fn reset(&mut self) {
self.snake = Snake::make_snake();
self.place_food();
self.speed = 1;
self.status = GameStatus::Ongoing;
self.score = 0;
}
/// Случайным образом разместить еду на сетке.
fn place_food(&mut self) -> Coords {
let coords = Coords::random(&mut self.rng, Some(&self.snake.coord_set));
self.food_coords = coords;
coords
}
/// «Сквозной переход» для координат за пределами сетки (например, координаты левее
/// левого края сетки появятся в крайнем правом столбце). Предполагается, что
/// координаты выходят за границы только по одному измерению.
fn wraparound(&self, coords: Coords) -> Coords {
if coords.row < 0 {
Coords { row: 4, ..coords }
} else if coords.row >= 5 {
Coords { row: 0, ..coords }
} else if coords.col < 0 {
Coords { col: 4, ..coords }
} else {
Coords { col: 0, ..coords }
}
}
/// Определить следующую клетку, на которую переместится змейка (без
/// фактического перемещения).
fn get_next_move(&self) -> Coords {
let head = &self.snake.head;
let next_move = match self.snake.direction {
Direction::Up => Coords {
row: head.row - 1,
col: head.col,
},
Direction::Down => Coords {
row: head.row + 1,
col: head.col,
},
Direction::Left => Coords {
row: head.row,
col: head.col - 1,
},
Direction::Right => Coords {
row: head.row,
col: head.col + 1,
},
};
if next_move.is_out_of_bounds() {
self.wraparound(next_move)
} else {
next_move
}
}
/// Оценить следующий ход змейки и вернуть результат. Состояние игры при этом
/// фактически не обновляется.
fn get_step_outcome(&self) -> StepOutcome {
let next_move = self.get_next_move();
if self.snake.coord_set.contains(&next_move) {
// Змейку мы ещё не двигали, поэтому если следующий ход приходится на конец
// хвоста, столкновения на самом деле не будет (хвост к тому моменту
// уже сдвинется, когда голова заедет на клетку)
if next_move != *self.snake.tail.peek().unwrap() {
StepOutcome::Collision
} else {
StepOutcome::Move(next_move)
}
} else if next_move == self.food_coords {
if self.snake.tail.len() == 23 {
StepOutcome::Full
} else {
StepOutcome::Eat(next_move)
}
} else {
StepOutcome::Move(next_move)
}
}
/// Обработать результат шага, обновив внутреннее состояние игры.
fn handle_step_outcome(&mut self, outcome: StepOutcome) {
self.status = match outcome {
StepOutcome::Collision => GameStatus::Lost,
StepOutcome::Full => GameStatus::Won,
StepOutcome::Eat(c) => {
self.snake.move_snake(c, true);
self.place_food();
self.score += 1;
if self.score.is_multiple_of(5) {
self.speed += 1
}
GameStatus::Ongoing
}
StepOutcome::Move(c) => {
self.snake.move_snake(c, false);
GameStatus::Ongoing
}
}
}
pub fn step(&mut self, turn: Turn) {
self.snake.turn(turn);
let outcome = self.get_step_outcome();
self.handle_step_outcome(outcome);
}
/// Вычислить время ожидания между шагами игры в миллисекундах.
/// Обычно оно уменьшается по мере роста счёта игрока, но нужно
/// следить, чтобы оно не стало отрицательным.
pub fn step_len_ms(&self) -> u32 {
let result = 1000 - (200 * ((self.speed as i32) - 1));
if result < 200 {
200u32
} else {
result as u32
}
}
/// Вернуть массив, описывающий состояние игры; его можно использовать, чтобы вывести
/// состояние на светодиодной матрице micro:bit. Каждый параметр `_brightness` должен быть
/// значением от 0 до 9.
pub fn game_matrix(
&self,
head_brightness: u8,
tail_brightness: u8,
food_brightness: u8,
) -> [[u8; 5]; 5] {
let mut values = [[0u8; 5]; 5];
values[self.snake.head.row as usize][self.snake.head.col as usize] = head_brightness;
for t in &self.snake.tail {
values[t.row as usize][t.col as usize] = tail_brightness
}
values[self.food_coords.row as usize][self.food_coords.col as usize] = food_brightness;
values
}
/// Вернуть массив, описывающий счёт игры; его можно использовать, чтобы вывести
/// счёт на светодиодной матрице micro:bit (зажигая соответствующее количество
/// светодиодов, слева направо и сверху вниз).
pub fn score_matrix(&self) -> [[u8; 5]; 5] {
let mut values = [[0u8; 5]; 5];
let full_rows = (self.score as usize) / 5;
#[allow(clippy::needless_range_loop)]
for r in 0..full_rows {
values[r] = [1; 5];
}
#[allow(clippy::needless_range_loop)]
for c in 0..(self.score as usize) % 5 {
values[full_rows][c] = 1;
}
values
}
}
}
Далее добавим возможность управлять движениями змейки.
Управление
Нашим главным героем будут управлять две кнопки на передней стороне micro:bit. Кнопка A будет поворачивать змейку влево, а кнопка B — вправо.
Для обработки нажатий кнопок конкурентным образом мы используем макрос microbit::pac::interrupt. Прерывание будет генерироваться периферией General Purpose Input/Output Tasks and Events (GPIOTE) MB2.
Модуль controls
Нам понадобятся два отдельных глобальных изменяемых состояния: ссылка на периферию GPIOTE и запись о выбранном направлении следующего поворота.
Общие данные оборачиваются в RefCell, чтобы обеспечить внутреннюю изменяемость и блокировку. Подробнее о RefCell можно прочитать в документации RefCell и в главе о внутренней изменяемости книги Rust. Сам RefCell, в свою очередь, оборачивается в cortex_m::interrupt::Mutex, чтобы обеспечить безопасный доступ. Mutex из крейта cortex_m опирается на понятие критической секции. Данные в Mutex можно использовать только внутри функции или замыкания, переданного в cortex_m::interrupt::free (для ясности здесь переименованной в interrupt_free), что гарантирует: код этой функции или замыкания не может быть прерван сам по себе.
Инициализация
Сначала инициализируем кнопки (src/controls/init.rs).
#![allow(unused)]
fn main() {
use super::{Buttons, GPIO};
use cortex_m::interrupt::free as interrupt_free;
use microbit::{
hal::{
gpio::{Floating, Input, Pin},
gpiote::{Gpiote, GpioteChannel},
},
pac,
};
/// Инициализировать кнопки и разрешить прерывания.
pub fn init_buttons(board_gpiote: pac::GPIOTE, board_buttons: Buttons) {
let gpiote = Gpiote::new(board_gpiote);
fn init_channel(channel: &GpioteChannel<'_>, button: &Pin<Input<Floating>>) {
channel.input_pin(button).hi_to_lo().enable_interrupt();
channel.reset_events();
}
let channel0 = gpiote.channel0();
init_channel(&channel0, &board_buttons.button_a.degrade());
let channel1 = gpiote.channel1();
init_channel(&channel1, &board_buttons.button_b.degrade());
interrupt_free(move |cs| {
*GPIO.borrow(cs).borrow_mut() = Some(gpiote);
unsafe {
pac::NVIC::unmask(pac::Interrupt::GPIOTE);
}
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
});
}
}
Периферия GPIOTE в nRF52 имеет 8 «каналов», каждый из которых можно подключить к выводу GPIO и настроить на реакцию на определённые события, в том числе на нарастающий фронт (переход сигнала из низкого уровня в высокий) и на спадающий фронт (из высокого в низкий). Кнопка — это вывод GPIO, который в ненажатом состоянии имеет высокий сигнал, а в нажатом — низкий. Поэтому нажатие кнопки — это спадающий фронт.
Обратите внимание на немного неуклюжее использование функции init_channel() при инициализации: она позволяет не копировать код инициализации кнопок. Типы, которые различные встраиваемые крейты для MB2 от вас скрывают, иногда немного пугают. Рекомендую однажды внимательно изучить структуру типов крейтов HAL и PAC: она немного странная и требует привыкания. В частности, обратите внимание, что каждый вывод micro:bit имеет собственный уникальный тип. Функция degrade() при инициализации нужна, чтобы привести их к общему типу, который можно разумно передать в init_channel() и далее в input_pin().
Мы подключаем channel0 к button_a, а channel1 к button_b. В каждом случае настраиваем кнопку на генерацию событий по спадающему фронту (hi_to_lo). Ссылку на нашу периферию GPIOTE сохраняем в Mutex GPIO. Затем вызываем unmask для прерываний GPIOTE, позволяя аппаратуре их передавать, и unpend, чтобы сбросить все прерывания в состоянии ожидания (они могли появиться до разрешения прерываний).
Обработчик прерывания
Далее пишем код, обрабатывающий прерывание. Мы используем макрос interrupt, реэкспортированный из крейта nrf52833_hal. Определяем функцию с тем же именем, что и прерывание, которое хотим обработать (все их можно увидеть здесь), и помечаем её атрибутом #[interrupt] (src/controls/interrupt.rs).
#![allow(unused)]
fn main() {
use super::{Turn, GPIO, TURN};
use cortex_m::interrupt::free as interrupt_free;
use microbit::pac::{self, interrupt};
#[pac::interrupt]
fn GPIOTE() {
interrupt_free(|cs| {
if let Some(gpiote) = GPIO.borrow(cs).borrow().as_ref() {
let a_pressed = gpiote.channel0().is_event_triggered();
let b_pressed = gpiote.channel1().is_event_triggered();
let turn = match (a_pressed, b_pressed) {
(true, false) => Turn::Left,
(false, true) => Turn::Right,
_ => Turn::None,
};
gpiote.channel0().reset_events();
gpiote.channel1().reset_events();
*TURN.borrow(cs).borrow_mut() = turn;
}
});
}
}
Когда генерируется прерывание GPIOTE, мы проверяем каждую кнопку, нажата ли она. Если нажата только кнопка A, записываем, что змейка должна повернуть налево. Если нажата только кнопка B, записываем, что направо. Во всех остальных случаях записываем, что поворота не будет. (Нажатие обеих кнопок «одновременно» крайне маловероятно: нажатия фиксируются почти мгновенно, а этот обработчик прерывания работает очень быстро, так что одновременно нажать обе кнопки успеть будет трудно. Точно так же трудно нажать кнопку так коротко, чтобы этот код её пропустил и сообщил, что ни одна кнопка не нажата. Тем не менее Rust требует предусмотреть и такие неожиданные случаи: код не скомпилируется, пока вы не проверите все варианты.) Нужный поворот сохраняется в Mutex TURN. Всё это происходит внутри блока interrupt_free, чтобы гарантировать, что другое событие не прервёт обработку этого прерывания.
Наконец, открываем простую функцию для получения следующего поворота (src/controls.rs).
#![allow(unused)]
fn main() {
mod init;
mod interrupt;
pub use init::init_buttons;
use crate::game::Turn;
use core::cell::RefCell;
use cortex_m::interrupt::{free as interrupt_free, Mutex};
use microbit::{board::Buttons, hal::gpiote::Gpiote};
pub static GPIO: Mutex<RefCell<Option<Gpiote>>> = Mutex::new(RefCell::new(None));
pub static TURN: Mutex<RefCell<Turn>> = Mutex::new(RefCell::new(Turn::None));
/// Получить следующий поворот (то есть поворот, соответствующий последней нажатой кнопке).
pub fn get_turn(reset: bool) -> Turn {
interrupt_free(|cs| {
let turn = *TURN.borrow(cs).borrow();
if reset {
*TURN.borrow(cs).borrow_mut() = Turn::None
}
turn
})
}
}
Эта функция просто возвращает текущее значение Mutex TURN. Она принимает один логический аргумент reset. Если reset равен true, значение TURN сбрасывается, то есть устанавливается в Turn::None.
Далее добавим поддержку «высококачественного» игрового дисплея.
Неблокирующий вывод на дисплей
Далее выведем змейку и еду на светодиоды экрана MB2. До сих пор мы пользовались блокирующим интерфейсом, который позволяет светодиодам либо гореть на полную яркость, либо быть выключенными. С ним вполне можно сделать простую рабочую игру «Змейка». Но если змейка станет немного длиннее, будет трудно отличить её от еды и понять, куда она движется. Разберёмся, как сделать яркость светодиодов переменной: например, сделать тело змейки чуть тусклее, и это поможет разобраться в картинке.
Крейт microbit предоставляет два разных интерфейса к светодиодной матрице. Есть блокирующий интерфейс, с которым мы уже работали в предыдущих главах. Есть и неблокирующий интерфейс, который позволяет настраивать яркость каждого светодиода. На уровне железа каждый светодиод либо «включён», либо «выключен», но модуль microbit::display::nonblocking имитирует десять уровней яркости для каждого светодиода, быстро включая и выключая его.
(Нет особой причины, по которой два режима вывода в крейте microbit должны быть разделены и использовать разный код. Более полная конструкция позволила бы использовать один API дисплея как в блокирующем, так и в неблокирующем режиме, с уровнями яркости и частотой обновления, которые задаёт пользователь. Никогда не считайте, что то, что вам передали, отточено или хотя бы близко к этому. Всегда думайте о том, что можно было бы сделать иначе. Но пока будем работать с тем, что есть, — этого достаточно для нашей текущей задачи.)
Код для работы с неблокирующим интерфейсом (src/display.rs) довольно прост и устроен примерно так же, как код для работы с кнопками. На этот раз начнём с верхнего уровня.
Модуль display
#![allow(unused)]
fn main() {
pub mod interrupt;
pub mod show;
pub use show::{clear_display, display_image};
use core::cell::RefCell;
use cortex_m::interrupt::{free as interrupt_free, Mutex};
use microbit::display::nonblocking::Display;
use microbit::gpio::DisplayPins;
use microbit::pac;
use microbit::pac::TIMER1;
static DISPLAY: Mutex<RefCell<Option<Display<TIMER1>>>> = Mutex::new(RefCell::new(None));
pub fn init_display(board_timer: TIMER1, board_display: DisplayPins) {
let display = Display::new(board_timer, board_display);
interrupt_free(move |cs| {
*DISPLAY.borrow(cs).borrow_mut() = Some(display);
});
unsafe { pac::NVIC::unmask(pac::Interrupt::TIMER1) }
}
}
Сначала создаём структуру microbit::display::nonblocking::Display, которая представляет светодиодный дисплей, передавая ей периферию TIMER1 платы и DisplayPins. Затем сохраняем дисплей в Mutex. Наконец, разрешаем прерывание TIMER1.
API дисплея
Затем определяем несколько удобных функций, которые позволяют легко задавать (или сбрасывать) изображение на дисплее (src/display/show.rs).
#![allow(unused)]
fn main() {
use super::DISPLAY;
use cortex_m::interrupt::free as interrupt_free;
use tiny_led_matrix::Render;
/// Вывести изображение.
pub fn display_image(image: &impl Render) {
interrupt_free(|cs| {
if let Some(display) = DISPLAY.borrow(cs).borrow_mut().as_mut() {
display.show(image);
}
})
}
/// Очистить дисплей (выключить все светодиоды).
pub fn clear_display() {
interrupt_free(|cs| {
if let Some(display) = DISPLAY.borrow(cs).borrow_mut().as_mut() {
display.clear();
}
})
}
}
display_image принимает изображение и велит дисплею его показать. Как и метод Display::show, который она вызывает, эта функция принимает структуру, реализующую трейт tiny_led_matrix::Render. Этот трейт гарантирует, что структура содержит данные и методы, нужные Display для вывода на светодиодную матрицу. Две реализации Render, которые предоставляет модуль microbit::display::nonblocking, — это BitImage и GreyscaleImage. В BitImage каждый «пиксель» (или светодиод) либо светится, либо нет (как в блокирующем интерфейсе), тогда как в GreyscaleImage каждый «пиксель» может иметь свою яркость.
clear_display делает именно то, что следует из названия.
Обработка прерываний дисплея
Наконец, с помощью макроса interrupt определяем обработчик прерывания TIMER1. Это прерывание срабатывает много раз в секунду, и именно оно позволяет Display быстро включать и выключать разные светодиоды, создавая иллюзию переменной яркости. Весь наш код обработчика просто вызывает метод Display::handle_display_event, который всё и делает (src/display/interrupt.rs).
#![allow(unused)]
fn main() {
use super::DISPLAY;
use cortex_m::interrupt::free as interrupt_free;
use microbit::pac::{self, interrupt};
#[pac::interrupt]
fn TIMER1() {
interrupt_free(|cs| {
if let Some(display) = DISPLAY.borrow(cs).borrow_mut().as_mut() {
display.handle_display_event();
}
})
}
}
Теперь понятно, как наша функция main будет работать с дисплеем: мы вызовем init_display и будем использовать новые функции, чтобы с ним взаимодействовать.
Игра «Змейка»: финальная сборка
Код в нашем файле src/main.rs объединяет все рассмотренные ранее механизмы и создаёт итоговую игру.
#![no_main]
#![no_std]
mod controls;
mod display;
pub mod game;
use controls::{get_turn, init_buttons};
use display::{clear_display, display_image, init_display};
use game::{Game, GameStatus};
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use microbit::{
display::nonblocking::{BitImage, GreyscaleImage},
hal::{Rng, Timer},
Board,
};
use panic_rtt_target as _;
use rtt_target::rtt_init_print;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0).into_periodic();
let mut rng = Rng::new(board.RNG);
let mut game = Game::new(&mut rng);
init_buttons(board.GPIOTE, board.buttons);
init_display(board.TIMER1, board.display_pins);
loop {
loop {
// Игровой цикл
let image = GreyscaleImage::new(&game.game_matrix(6, 3, 9));
display_image(&image);
timer.delay_ms(game.step_len_ms());
match game.status {
GameStatus::Ongoing => game.step(get_turn(true)),
_ => {
for _ in 0..3 {
clear_display();
timer.delay_ms(200u32);
display_image(&image);
timer.delay_ms(200u32);
}
clear_display();
display_image(&BitImage::new(&game.score_matrix()));
timer.delay_ms(2000u32);
break;
}
}
}
game.reset();
}
}
После инициализации платы, её таймера и генератора случайных чисел мы создаём структуру Game и Display из модуля microbit::display::blocking.
В нашем «игровом цикле» (он выполняется внутри «главного цикла» в функции main) мы многократно выполняем такие шаги:
-
Получить массив байтов 5×5, представляющий сетку. Метод
Game::get_matrixпринимает три целочисленных аргумента (от 0 до 9 включительно), которые в итоге задают яркость головы, хвоста и еды. -
Вывести матрицу на время, которое определяет метод
Game::step_len_ms. В текущей реализации этот метод в основном задаёт 1 секунду между шагами, уменьшая её на 200 мс каждый раз, когда игрок набирает 5 очков (одна съеденная еда = 1 очко), но не менее 200 мс. -
Проверить статус игры. Если он
Ongoing(его начальное значение), выполнить шаг игры и обновить состояние игры (включая свойствоstatus). Иначе игра окончена: мигнуть текущим изображением три раза, затем показать счёт игрока (в виде количества горящих светодиодов, соответствующего счёту) и выйти из игрового цикла.
Главный цикл просто многократно запускает игровой цикл, сбрасывая состояние игры после каждой итерации.
Что вам осталось исследовать
Мы едва затронули поверхность! Вам предстоит исследовать ещё многое.
ПРИМЕЧАНИЕ: Если вы читаете это и хотите помочь добавить в книгу Discovery примеры или упражнения по любой из тем ниже или по другим важным темам встраиваемых систем, мы будем рады вашей помощи!
Если хотите помочь, но нужна помощь или наставничество в том, как внести это в книгу, пожалуйста, откройте issue, либо откройте Pull Request с добавлением материала!
Ещё о MB2
В ходе этой книги мы затронули большую часть аппаратного обеспечения MB2. Тем не менее несколько тем, связанных с MB2, остаются неисследованными.
Прямой доступ к памяти (DMA)
У некоторых периферийных устройств есть DMA — что-то вроде асинхронного memcpy, которое позволяет периферии перемещать данные в память и из неё без участия процессора.
Если вы работаете с micro:bit v2, то DMA вы уже использовали: HAL делает это за вас для периферий UARTE и TWIM. Периферия DMA может выполнять массовые передачи данных: из RAM в RAM, из периферии вроде UARTE в RAM или из RAM в периферию. Можно запланировать передачу DMA — например, «прочитать 256 байт из UARTE в этот буфер» — и оставить её работать в фоне. Позже можно проверить некоторый регистр, чтобы узнать, завершилась ли передача, или запросить прерывание по её завершении. Так что вы можете запланировать передачу DMA и заниматься другой работой, пока она выполняется.
Детали низкоуровневого DMA могут быть немного хитрыми. Надеемся в ближайшее время добавить главу, посвящённую этой теме.
Для работы с ШИМ (PWM) есть некоторые абстракции в модуле pwm embedded-hal, а реализации этих трейтов вы найдёте в nrf52833-hal.
Цифровые входы и выходы
Мы использовали выводы микроконтроллера как цифровые выходы для управления светодиодами. При создании игры «Змейка» мы также увидели, как эти выводы можно настроить как цифровые входы. Как цифровые входы они могут считывать двоичное состояние переключателей (вкл/выкл) или кнопок (нажата/не нажата).
Цифровые входы и выходы абстрагированы в модуле digital embedded-hal, а у [nrf52833-hal] для них есть реализация.
(спойлер: считывание двоичного состояния переключателей и кнопок — не так просто, как кажется ;-) )
Аналого-цифровые преобразователи (АЦП, ADC)
Цифровых датчиков существует очень много. Их можно читать по протоколам вроде I2C и SPI. Но есть и аналоговые датчики! Такие датчики просто выдают значение напряжения, которое они измеряют, на вход АЦП процессора.
Периферия АЦП позволяет измерить «аналоговый» уровень напряжения — например, 1,25 В — и получить «цифровое» число — например, 24824 — которое процессор может использовать в вычислениях.
Раньше в embedded-hal были обобщённые трейты для АЦП, но для embedded-hal 1.0 их убрали: см. issue #377. Крейт nrf52833-hal предоставляет удобный интерфейс к АЦП, встроенному в nRF52833.
Цифро-аналоговые преобразователи (ЦАП, DAC)
Как и следовало ожидать, ЦАП — это ровно обратное АЦП. Можно записать цифровое число в регистр, чтобы получить определённое напряжение на каком-нибудь аналоговом выходе. Если этот аналоговый выход подключён к подходящей электронике, а регистр быстро записывается правильными значениями, можно, например, воспроизводить звуки или музыку.
Ни у nRF52833, ни у платы MB2 нет отдельного ЦАП. Обычно эффект ЦАП получают, выводя ШИМ и используя немного электроники на выходе (RC-фильтр), чтобы «сгладить» форму ШИМ-сигнала.
Часы реального времени (RTC)
Периферия часов реального времени (Real-Time Clock) ведёт отсчёт времени от собственного питания, обычно в «человеческом формате»: секунды, минуты, часы, дни, месяцы и годы. Некоторые часы реального времени даже автоматически учитывают високосные годы и переход на летнее время.
Ни nRF52833, ни плата MB2 не содержат часов реального времени. В nRF52833 есть «Real-Time Counter» (RTC) — низкочастотный тактовый счётчик, который поддерживается nrf52833-hal. Этот счётчик можно использовать как синтезированные часы реального времени. Ключевое требование — конечно, поддерживать питание периферии RTC даже тогда, когда MB2 не используется. Встроенной батареи у MB2 нет, но RTC может работать очень долго (возможно, годами), если подключить батарею к батарейному разъёму MB2 (например, к аккумуляторному блоку из набора micro:bit Go).
Другие протоколы связи
- SPI: «Serial Peripheral Interface» — высокоскоростной интерфейс связи, в чём-то похожий на I2C. SPI абстрагирован в модуле
spiкрейтаembedded-halи реализован в [nrf52-hal]. - I2S: протокол «Inter-IC Sound» — вариант I2C, адаптированный для передачи звука. Сейчас I2S не абстрагирован в
embedded-hal, но реализован в [nrf52-hal]. - Ethernet: существует небольшой стек TCP/IP под названием
smoltcp, который реализован для некоторых чипов. У MB2 нет периферии Ethernet. - USB: есть некоторые экспериментальные наработки, например с крейтом
usb-device. У MB2 USB-порт обслуживается интерфейсным MCU, а не основным, поэтому делать собственные USB-устройства трудно. - Bluetooth: обёртка
nrf-softdevice, которую предоставляет рантайм Embassy для MB2, вероятно, самый простой способ начать работать с Bluetooth на MB2. У Embassy также есть нативный для Rust BLE-хост-крейтTrouBLE. - CAN, SMBUS, IrDA и т. д.: в мире существуют всевозможные специализированные интерфейсы, и поддержка некоторых из них в Rust есть. Поинтересуйтесь текущим состоянием дел для нужного вам интерфейса.
Разные приложения используют разные протоколы связи. Пользовательские устройства обычно имеют USB-разъём, потому что USB повсеместен в ПК и смартфонах. А внутри автомобилей полно шин CAN. Некоторые цифровые датчики используют SPI, I2C или SMBUS.
Если вам интересно разрабатывать абстракции в embedded-hal или реализации периферии вообще, не стесняйтесь открывать issue в репозиториях HAL. Кроме того, можно присоединиться к чату Rust Embedded в Matrix и связаться с большинством людей, которые создали всё перечисленное выше.
Общие темы, важные для встраиваемых систем
Эти темы касаются не конкретно нашего устройства и не его аппаратуры, а полезных приёмов, которые можно применять во встраиваемых системах.
Большая часть оборудования, которое мы здесь обсудим, недоступна на MB2, но многое можно легко добавить, подключив недорогое устройство к краевому разъёму MB2 — либо управляя им напрямую, либо используя SPI или I2C.
Многозадачность
Большинство наших программ выполняли одну задачу. Как добиться многозадачности в системе без ОС, а значит, без потоков? Существует два основных подхода к многозадачности: вытесняющая многозадачность (preemptive) и кооперативная (cooperative).
При вытесняющей многозадачности выполняемая задача может в любой момент быть вытеснена (прервана) другой задачей. При вытеснении первая задача приостанавливается, а процессор выполняет вторую. Через некоторое время первая задача возобновляется. Микроконтроллеры поддерживают вытеснение на уровне железа в виде прерываний. С прерываниями мы познакомились, когда создавали игру «Змейка» в главе 16.
При кооперативной многозадачности выполняемая задача работает до тех пор, пока не дойдёт до точки приостановки. Достигнув такой точки, процессор прекращает текущую задачу и выполняет другую. Через некоторое время первая задача возобновляется. Главное отличие этих подходов в том, что при кооперативной многозадачности задача отдаёт управление в известных точках приостановки, а не принудительно вытесняется в произвольный момент своего выполнения.
Гироскопы
В задаче «Измеритель удара» мы использовали акселерометр, чтобы измерять изменения ускорения в трёх измерениях. Но есть и другие датчики движения, например гироскопы, которые позволяют измерять изменения «вращения» в трёх измерениях.
Это очень полезно при создании некоторых систем, например робота, который не должен опрокидываться. Кроме того, данные такого датчика, как гироскоп, можно объединять с данными акселерометра с помощью метода, который называется сенсорным слиянием (sensor fusion; подробнее см. ниже).
Сервоприводы и шаговые двигатели
Некоторые моторы используются в основном просто для вращения в ту или иную сторону, например чтобы ехать на радиоуправляемой машинке вперёд или назад. Но иногда полезно точнее измерять, как вращается мотор.
Микроконтроллер может управлять сервоприводами или шаговыми двигателями, которые позволяют точнее контролировать количество оборотов мотора или даже установить его в определённое положение — например, если нужно повернуть стрелки часов в нужное направление.
Сенсорное слияние (sensor fusion)
В micro:bit есть два датчика движения: акселерометр и магнитометр. По отдельности они измеряют (собственное) ускорение и (земное) магнитное поле. Но эти величины можно «слить» в нечто более полезное: «устойчивое» измерение ориентации платы с меньшей погрешностью, чем у любого отдельного датчика.
Идея получения более надёжных данных из разных источников известна как сенсорное слияние (sensor fusion).
Куда дальше?
Прежде всего присоединяйтесь к чату Rust Embedded в Matrix. Там собирается много людей, которые участвуют в разработке встраиваемого ПО, в том числе те, кто написал BSP microbit, крейт nrf52-hal, крейты embedded-hal и т. д. Мы будем рады помочь вам начать или продолжить программировать встраиваемые системы на Rust!
Есть и много других вариантов:
- Можно посмотреть примеры в крейте поддержки платы
microbit-v2. Все эти примеры работают на вашей плате micro:bit.
- Если хотите общий обзор того, что сейчас есть в Rust Embedded, посмотрите список Awesome Rust Embedded.
- Можно познакомиться с Embassy. Это современный эффективный фреймворк кооперативной многозадачности, который поддерживает конкурентное выполнение с помощью
async/awaitв Rust.
- Можно познакомиться с Real-Time Interrupt-driven Concurrency RTIC. RTIC — очень эффективный фреймворк вытесняющей многозадачности, который поддерживает приоритизацию задач и выполнение без взаимных блокировок (deadlock-free).
- Можно изучить другие абстракции проекта
embedded-halи даже попробовать написать на его основе собственный драйвер, не зависящий от платформы.
- Можно попробовать запустить Rust на другой отладочной плате. У популярных плат, таких как ESP-32, Raspberry Pi или Arduino, есть свои активные сообщества Rust-разработчиков.
📥 Скачать книгу в формате EPUB
Версию этой книги в формате EPUB можно скачать здесь: Скачать
Устранение неполадок
Проблемы с cargo-embed
Большинство проблем с cargo-embed связано с тем, что правила udev в Linux установлены неправильно, так что убедитесь, что вы сделали всё правильно.
Если застряли, можно открыть issue в трекере discovery или зайти в чат Rust Embedded в Matrix или чат probe-rs в Matrix и попросить помощи.
Проблемы с Cargo
“can’t find crate for core”
Симптомы:
Compiling volatile-register v0.1.2
Compiling rlibc v1.0.0
Compiling r0 v0.1.0
error[E0463]: can't find crate for `core`
error: aborting due to previous error
error[E0463]: can't find crate for `core`
error: aborting due to previous error
error[E0463]: can't find crate for `core`
error: aborting due to previous error
Build failed, waiting for other jobs to finish...
Build failed, waiting for other jobs to finish...
error: Could not compile `r0`.
To learn more, run the command again with --verbose.
Причина:
Вы забыли установить нужную цель (target) для вашего микроконтроллера thumbv7em-none-eabihf.
Решение:
Установите нужную цель.
$ rustup target add thumbv7em-none-eabihf
Не удаётся прошить устройство: No loadable segments were found in the ELF file
Симптомы:
> cargo embed
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
Config default
Target /home/user/embedded/target/thumbv7em-none-eabihf/debug/examples/init
WARN probe_rs::flashing::loader: No loadable segments were found in the ELF file.
Error No loadable segments were found in the ELF file.
Причина:
Cargo должен знать, как собрать программу и как её скомпоновать под требования целевого устройства. Поэтому нужно задать правильные параметры в файле .cargo/config.toml.
Решение:
Добавьте файл .cargo/config.toml с правильными параметрами:
[build]
target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip nRF52833_xxAA"
rustflags = [
"-C", "linker=rust-lld",
]
Подробности см. в разделе Настройка для встраиваемой разработки.
Как пользоваться GDB
Ниже приведены некоторые полезные команды GDB, которые помогут отлаживать программы. Предполагается, что вы прошили программу в микроконтроллер и подключили GDB к сеансу cargo-embed.
Общая отладка
ПРИМЕЧАНИЕ: Многие команды ниже можно записывать в сокращённом виде. Например,
continueможно записать какc, аbreak $location— какb $location. Когда освоитесь с командами ниже, попробуйте выяснить, насколько их можно укоротить, прежде чем GDB перестанет их распознавать!
Работа с точками останова
break $location: установить точку останова в месте вашего кода. Значение$locationможет быть таким:break *main— остановка по точному адресу функцииmainbreak *0x080012f2— остановка по точному адресу памяти0x080012f2break 123— остановка на строке 123 текущего файлаbreak main.rs:123— остановка на строке 123 файлаmain.rs
info break: показать текущие точки остановаdelete: удалить все точки остановаdelete $n: удалить точку останова$n(n— число, например:delete $2)
clear: удалить точку останова на следующей инструкцииclear main.rs:$function: удалить точку останова на входе в$functionвmain.rsclear main.rs:123: удалить точку останова на строке 123main.rs
enable: включить все установленные точки остановаenable $n: включить точку останова$n
disable: отключить все установленные точки остановаdisable $n: отключить точку останова$n
Управление выполнением
continue: начать или продолжить выполнение программыnext: выполнить следующую строку программыnext $n: повторитьnext$nраз
nexti: то же, чтоnext, но для машинных инструкцийstep: выполнить следующую строку; если в ней есть вызов другой функции, войти в её кодstep $n: повторитьstep$nраз
stepi: то же, чтоstep, но для машинных инструкцийjump $location: продолжить выполнение с указанного места:jump 123: продолжить выполнение со строки 123jump 0x080012f2: продолжить выполнение с адреса 0x080012f2
Вывод информации
-
print /$f $data— вывести значение переменной$data. Вывод можно форматировать с помощью$f, который может быть таким:x: шестнадцатеричный d: десятичный со знаком u: десятичный без знака o: восьмеричный t: двоичный a: адрес c: символ f: с плавающей точкойprint /t 0xA: выводит шестнадцатеричное значение0xAв двоичном виде (0b1010)
-
x /$n$u$f $address: посмотреть память по адресу$address. Необязательно:$n— количество единиц для вывода,$u— размер единицы (байты, полуслова, слова и т. д.),$f— любой из форматовprint, описанных вышеx /5i 0x080012c4: вывести 5 машинных инструкций, начиная с адреса0x080012c4x/4xb $pc: вывести 4 байта памяти, начиная с адреса, на который сейчас указывает$pc
-
disassemble $locationdisassemble /r main: дизассемблировать функциюmain;/rпоказывает байты, из которых состоит каждая инструкция
Просмотр таблицы символов
info functions $regex: вывести имена и типы данных функций, подходящих под$regex; без$regexвыводятся все функцииinfo functions main: вывести имена и типы определённых функций, в названии которых есть словоmain
info address $symbol: показать, где в памяти хранится$symbolinfo address GPIOC: показать адрес в памяти переменнойGPIOC
info variables $regex: вывести имена и типы глобальных переменных, подходящих под$regex; без$regexвыводятся все глобальные переменныеptype $data: вывести более подробную информацию о$dataptype cp: вывести подробную информацию о типе переменнойcp
Изучение стека программы
backtrace $n: вывести трассировку из$nкадров; без$nвыводятся все кадрыbacktrace 2: вывести трассировку первых 2 кадров
frame $n: выбрать кадр с номером или адресом$n; без$nпоказывается текущий кадрup $n: выбрать кадр на$nуровней вышеdown $n: выбрать кадр на$nуровней нижеinfo frame $address: описать кадр по адресу$address; без$address— текущий выбранный кадрinfo args: вывести аргументы выбранного кадраinfo registers $r: вывести значение регистра$rв выбранном кадре; без$r— все регистрыinfo registers $sp: вывести значение регистра указателя стека$spв текущем кадре
Удалённое управление cargo-embed
monitor reset: сбросить процессор и начать выполнение заново
Калибровка магнитометра
Очень важно перед использованием датчика и разработкой приложения с ним проверить, что его данные действительно правильны. Если это не так, датчик нужно откалибровать. Также датчик может быть сломан: по возможности полезно проверять работоспособность датчиков до и во время использования.
В моём случае на двух разных MB2 магнитометр LSM303AGR без калибровки заметно врёт. (У меня есть ещё один, у которого, похоже, сломана ось Z; у производителя есть дополнительное оборудование и процедура, которые помогают это выявить, но разбираться в этой сложности здесь мы не будем.)
У производителя есть описанная процедура калибровки магнитометра. Калибровка включает довольно много математики (матрицы), поэтому подробно разбирать её здесь не будем: если интересны детали, в этой технической заметке описана процедура.
К счастью, группа CODAL, которая создала исходное программное обеспечение micro:bit на C++, уже реализовала механизм калибровки производителя (или что-то похожее) на C++ здесь.
Перевод этой калибровки с C++ на Rust находится в src/lib.rs. Обратите внимание, что это перевод с Matlab на C++, а затем на Rust, и в нём сделаны некоторые интересные решения. В частности, при чтении откалиброванных значений оси переворачиваются, так что при взгляде сверху, с USB-разъёмом вперёд, оси X, Y и Z откалиброванного значения имеют «стандартную» ориентацию (вправо, вперёд, вверх).
Использование этого калибратора показано в src/main.rs.
Как пользователь проводит калибровку, показано в этом видео из версии на C++. (Начальный вывод можно пропустить: калибровка начинается примерно на середине.)
Нужно наклонять micro:bit, пока не загорятся все светодиоды матрицы. Мигающий курсор показывает текущий целевой светодиод.
Обратите внимание: матрица калибровки выводится демонстрационной программой. Эту матрицу можно «зашить» прямо в программу, например в компас из главы 13 (или как-то сохранить во флеш-памяти), чтобы не калибровать датчик при каждом запуске программы.
Лицензии и атрибуция
Лицензии
Документация распространяется по лицензии:
- Creative Commons «Атрибуция» 4.0 (LICENSE-CC-BY или https://creativecommons.org/licenses/by/4.0/legalcode)
Исходный код распространяется по одной из следующих лицензий, на ваш выбор:
-
Apache License, Version 2.0 (LICENSE-APACHE или http://www.apache.org/licenses/LICENSE-2.0)
-
MIT License (LICENSE-MIT или https://opensource.org/licenses/MIT)
Изображения с Wikimedia Commons
В книгу включены следующие изображения с Wikimedia Commons:
| Изображение | Автор | Лицензия | Источник |
|---|---|---|---|
| DB-25_male.jpg | Jacq54 | CC BY-SA 4.0 | Источник |
| Serial_port.jpg | Duncan Lithgow | Public Domain | Источник |
| UART_to_USB_adapter.jpg | Sunmist | CC0 1.0 | Источник |
| LED_circuit.svg | Dmccreary / StevenBell | CC BY-SA 2.5 | Источник |
| I2C_controller-target.svg | Tim Mathias | CC BY-SA 4.0 | Источник |
| Atan2_60.svg | Dmcq | CC BY-SA 3.0 | Источник |