impl Rust для RP2350 — введение
В этой книге мы используем Raspberry Pi Pico 2 и программируем его на Rust, чтобы исследовать интересные проекты. Вы будете выполнять упражнения вроде плавного изменения яркости светодиода, управления сервомотором, измерения расстояния ультразвуковым датчиком, вывода изображения Ferris (🦀) на OLED-дисплей, работы с RFID-считывателем, воспроизведения мелодий на зуммере, включения светодиода, когда в комнате погас свет, измерения температуры и многого другого.
Знакомство с железом — Pico 2
Мы будем использовать Raspberry Pi Pico 2 на базе нового чипа RP2350. Он сочетает гибкость двухъядерной архитектуры с поддержкой ядер ARM Cortex-M33 и необязательных ядер RISC-V Hazard3. По умолчанию плата работает на стандартных ядрах ARM, но при желании можно поэкспериментировать с архитектурой RISC-V.
Подробнее — на официальном сайте.
Note
Существует более старая плата Raspberry Pi Pico на чипе RP2040. В этой книге мы используем более новую Pico 2 с чипом RP2350. При покупке оборудования убедитесь, что берёте правильную плату!
Если вы ищете версию этой книги для RP2040, она доступна здесь: impl Rust для Pico
Существует также вариант Pico 2 W, который включает Wi‑Fi и Bluetooth и тоже построен на чипе RP2350. Однако он не полностью совместим с нашими примерами. Если вы хотите повторять их без изменений, рекомендуем стандартную Pico 2 (без беспроводных модулей). Если вы уже купили Pico 2 W или собираетесь её купить, всё равно сможете следовать книге. Будут небольшие отличия: например, по умолчанию Wi‑Fi использует встроенный светодиод. Но основные идеи остаются прежними.
Версии H обеих плат поставляются с установленными штыревыми разъёмами, что немного упрощает работу.
Документация
Подробные технические характеристики и рекомендации см. в официальных даташитах:
Лицензия
Книга «impl Rust для RP2350» (этот проект) распространяется под следующими лицензиями:
- Примеры кода и самостоятельные Cargo-проекты в книге распространяются по лицензиям MIT License и Apache License v2.0.
- Текст книги распространяется по лицензии Creative Commons CC-BY-SA v4.0.
- Схемы подключения в книге созданы в Fritzing.
- Русский перевод выполнен участниками проекта и распространяется на тех же условиях, что и оригинал, автор которого — ImplFerris.
Поддержать проект
Поддержать книгу можно, поставив звезду проекту на GitHub или рассказав о ней другим 😊
Отказ от ответственности
Эксперименты и проекты, описанные в этой книге, работали у автора, но результаты могут отличаться. Автор не несёт ответственности за любые проблемы или повреждения, которые могут возникнуть при экспериментах. Действуйте осторожно и соблюдайте необходимые меры безопасности.
Дополнительное оборудование
В этом разделе мы рассмотрим дополнительное оборудование, которое можно использовать вместе с Raspberry Pi Pico.
Наборы электроники
Можно начать с базового набора для электроники или покупать компоненты по мере необходимости. Для начала достаточно простого недорогого набора, если в нём есть резисторы, перемычки и макетная плата. Они понадобятся на протяжении всех уроков.
Другие компоненты, которые используются в книге: светодиоды, ультразвуковой датчик HC-SR04, активные и пассивные зуммеры, микросервопривод SG90, фоторезистор LDR, NTC-термистор, RFID-считыватель RC522, адаптер для micro SD-карты, дисплей HD44780 и модуль джойстика.
Дополнительное оборудование: отладочный зонд
Отладочный зонд Raspberry Pi Debug Probe значительно упрощает прошивку Pico 2. Без него нужно каждый раз нажимать кнопку BOOTSEL, чтобы загрузить новую прошивку. Кроме того, зонд обеспечивает полноценную отладку, что очень полезно.
Этот инструмент необязателен. Вы можете пройти всю книгу без него (кроме глав, которые относятся именно к отладочному зонду). Когда я только начинал работать с Pico, я обходился без зонда и купил его позже.
Как решить?
Если бюджет ограничен, можно пока обойтись без него: зонд стоит примерно вдвое дороже Pico 2. Если цена не главный фактор, это хорошая покупка, которая очень пригодится. Также можно использовать другую Pico как недорогой отладочный зонд, если у вас есть вторая плата.
Макетная плата
Макетная плата — небольшая плата, которая помогает собирать схемы без пайки. На ней много отверстий, в которые можно вставлять провода и электронные детали. Внутри платы металлические полоски соединяют некоторые из этих отверстий. Это позволяет легко соединять детали между собой и замыкать цепь.
На рисунке показано, как отверстия соединены внутри макетной платы.
Шины питания
Длинные вертикальные линии по обеим сторонам называются шинами питания. Обычно питание подключают к шине, помеченной знаком «+», а землю — к шине, помеченной «-». Каждое отверстие в шине соединено с остальными отверстиями той же шины по всей её длине сверху вниз.
Допустим, нужно подать питание на множество деталей. Достаточно подключить источник питания (например, 3,3 В или 5 В) к одной точке на шине «+». Затем можно использовать любое другое отверстие на этой же шине, чтобы питать свои компоненты.
Средняя часть
Средняя часть макетной платы — место, где располагают большую часть компонентов. Отверстия здесь соединены короткими горизонтальными рядами. В каждом ряду по пять отверстий, которые соединены между собой внутри платы.
Как видно на изображении, каждый ряд отдельный, и группы, помеченные a b c d e, отделены от групп, помеченных f g h i j. Центральный разрыв делит плату на две стороны, поэтому соединения не переходят с одной стороны на другую.
Вот несколько простых примеров:
- Если вставить провод в гнездо 5a, а другой — в 5c, они соединены, так как находятся в одном ряду.
- Если один провод вставить в 5a, а другой — в 5f, они
несоединены, потому что находятся по разные стороны разрыва. - Если один провод вставить в 5a, а другой — в 6a, они
несоединены, так как находятся в разных рядах.
Компоненты
В этой книге мы будем использовать различные внешние компоненты вместе с Pico, чтобы собирать примеры и эксперименты. Эти компоненты взаимодействуют с внешним миром: считывают входные сигналы, управляют выходами и выводят информацию.
Некоторые компоненты — простые детали, например светодиоды, кнопки, резисторы, LDR и термисторы. Другие — готовые модули, такие как дисплеи, датчики и интерфейсные устройства, которые подключаются к Pico через небольшое количество выводов.
Important
Некоторым компонентам может потребоваться пайка, например, если нужно припаять штыревые выводы к дисплеям или модулям датчиков. Многие детали также продаются уже с припаянными выводами.
Если у компонента нет выводов, их придётся припаять перед использованием. Пайка — практический навык в embedded-разработке, поэтому не спешите и соблюдайте базовые правила безопасности.
Этот раздел лишь даёт обзор компонентов, которые встречаются в книге. Подробные объяснения, схемы подключения и примеры кода приведены в соответствующих главах. Не нужно заранее собирать все компоненты: их можно докупать постепенно по мере прохождения книги.
Компоненты, используемые в этой книге
Названия компонентов приведены для справки. Перед покупкой проверьте соответствующую главу, чтобы убедиться, что тип модуля и распиновка совпадают с примерами из книги.
- Макетная плата
- Перемычки (male-male, male-female)
- Светодиод
- Резисторы (распространённые номиналы: 330 Ом, 1 кОм, 2,2 кОм, 10 кОм)
- Кнопка (тактовая кнопка)
- LDR (фоторезистор)
- NTC-термистор (10 кОм)
- OLED-дисплей (SSD1306, 128x64, I2C)
- ЖК-дисплей (16x2, совместимый с HD44780)
- Ультразвуковой датчик (HC-SR04 / HC-SR04+)
- Зуммер (активный и пассивный)
- Сервопривод (SG90)
- Модуль светодиодной матрицы 8x8 на MAX7219
- RFID-модуль (RC522)
- Модуль считывателя micro SD-карт (SPI)
- Модуль джойстика (типа PS2)
Распиновка Raspberry Pi Pico 2
Tip
Не нужно сейчас запоминать или полностью понимать каждый вывод. Мы будем возвращаться к этому разделу по мере необходимости, выполняя упражнения книги.
Выводы питания
Выводы питания необходимы для работы Raspberry Pi Pico 2 и для подачи электричества на датчики, светодиоды, моторы и другие компоненты, которые вы к нему подключаете.
У Raspberry Pi Pico 2 есть следующие выводы питания. На схемах распиновки они отмечены красным (питание) и чёрным (земля). Через них плата получает питание и сама питает внешние компоненты.
-
VBUS подключён к 5 В с USB-порта. Когда плата питается по USB, на этом выводе будет около 5 В. Его можно использовать для питания небольших внешних схем, но он не подходит для нагрузок с большим током.
-
VSYS — основной вход питания платы. Сюда можно подать питание от батареи или стабилизированного источника напряжением от 1,8 до 5,5 В. Этот вывод питает встроенный стабилизатор 3,3 В, который снабжает RP2350 и другие элементы платы.
-
3V3(OUT) обеспечивает стабильный выход 3,3 В со встроенного стабилизатора. Его можно использовать для питания внешних компонентов, например датчиков или дисплеев, но лучше ограничить потребление тока значением менее 300 мА.
-
Выводы GND замыкают электрические цепи и подключены к общей земле системы. На Pico 2 несколько выводов GND расположены по всей плате для удобства подключения внешних устройств.
Выводы GPIO
Когда вы хотите, чтобы микроконтроллер (то есть Pico) взаимодействовал с внешним миром — включал свет, считывал нажатия кнопок, измерял температуру или управлял моторами, — нужен способ подключать эти внешние компоненты и обмениваться с ними данными. Именно для этого и служат выводы GPIO: это точки подключения вашей Raspberry Pi Pico 2 к внешним компонентам.
Pico 2 содержит 26 выводов ввода-вывода общего назначения (GPIO), обозначенных от GPIO0 до GPIO29, хотя не все номера выведены на штыревые разъёмы. Выводы GPIO очень гибкие: их можно использовать для чтения входов, таких как переключатели или датчики, или для управления выходами, например светодиодами, моторами и другими устройствами.
Все выводы GPIO работают с логическими уровнями 3,3 В. Это значит, что подаваемый на вход сигнал не должен превышать 3,3 В, иначе можно повредить плату. Многие выводы GPIO поддерживают базовый цифровой ввод-вывод, а некоторые выполняют и дополнительные функции: аналоговый вход (ADC) или работу линий связи для протоколов I2C, SPI или UART.
Нумерация выводов
Каждый вывод GPIO можно обозначить двумя способами: по номеру GPIO (используется в программе) и по физическому положению на плате. В коде вы будете использовать номер GPIO (например, GPIO0). При подключении проводов нужно знать, какой GPIO соответствует какому физическому контакту.
GPIO25 особенный: он подключён ко встроенному светодиоду и может управляться напрямую из кода без какой-либо внешней проводки.
Например, если в коде используется GPIO0, провод нужно подключить к физическому контакту 1 на плате. Аналогично GPIO2 соответствует физическому контакту 4.
Выводы ADC
Большинство выводов Raspberry Pi Pico 2 работают с простыми сигналами вкл/выкл — это идеально подходит для светодиодов и кнопок. Но что, если нужно измерить яркость комнаты, чтобы автоматически включать свет? Или контролировать влажность почвы, чтобы поливать растения? Или узнать, насколько повернули ручку громкости? Для таких задач нужны выводы, которые умеют фиксировать плавные изменения, а не только состояния «вкл» и «выкл».
Большинство выводов Raspberry Pi Pico 2 цифровые: они могут только читать или выдавать значения ВКЛ (высокий уровень) или ВЫКЛ (низкий уровень). Но некоторые устройства, например датчики света или температуры, выдают сигналы, которые меняются плавно. Чтобы считывать такие сигналы, нужны специальные выводы — ADC.
ADC расшифровывается как Analog-to-Digital Converter, то есть аналого-цифровой преобразователь. Он принимает напряжение и превращает его в число, которое может понять программа. Например, напряжение 0 В станет 0, а 3,3 В — 4095 (это наибольшее число, которое может выдать ADC, так как он работает с 12-битным разрешением). Подробнее об ADC мы поговорим позже в этой книге.
У Raspberry Pi Pico 2 есть три вывода, которые могут работать как входы ADC: GPIO26, GPIO27 и GPIO28, соответствующие ADC0, ADC1 и ADC2. Эти выводы можно использовать для чтения аналоговых сигналов от датчиков, например светового или температурного.
Есть также два специальных вывода, которые поддерживают аналоговые измерения:
-
ADC_VREF — опорное напряжение для ADC. По умолчанию подключён к 3,3 В, то есть ADC преобразует любое напряжение от 0 до 3,3 В в число. Но сюда можно подать и другое напряжение (например, 1,25 В), если нужны более точные измерения в меньшем диапазоне.
-
AGND — аналоговая земля, которая обеспечивает «чистую» землю для аналоговых сигналов. Это помогает снизить шумы и повысить точность показаний. Если вы используете аналоговый датчик, лучше подключить его землю к AGND, а не к обычному выводу GND.
Выводы I2C
Raspberry Pi Pico 2 поддерживает I2C — протокол, который позволяет подключать несколько устройств всего двумя проводами. Его часто используют с датчиками, дисплеями и другой периферией.
I2C использует два сигнала: SDA (линия данных) и SCL (линия тактирования). Эти линии общие для всех подключённых устройств. У каждого устройства на шине есть уникальный адрес, поэтому Pico 2 может обмениваться данными со многими устройствами по одной паре проводов.
У Raspberry Pi Pico 2 есть два контроллера I2C: I2C0 и I2C1. Каждый контроллер можно сопоставить с несколькими выводами GPIO, что даёт гибкость в зависимости от схемы.
-
I2C0 может использовать такие GPIO:
- SDA (данные): GPIO0, GPIO4, GPIO8, GPIO12, GPIO16 или GPIO20
- SCL (тактирование): GPIO1, GPIO5, GPIO9, GPIO13, GPIO17 или GPIO21
-
I2C1 может использовать такие GPIO:
- SDA (данные): GPIO2, GPIO6, GPIO10, GPIO14, GPIO18 или GPIO26
- SCL (тактирование): GPIO3, GPIO7, GPIO11, GPIO15, GPIO19 или GPIO27
Можно выбрать любую подходящую пару SDA и SCL от одного и того же контроллера (I2C0 или I2C1).
Выводы SPI
SPI (Serial Peripheral Interface) — ещё один протокол связи, который используют для подключения дисплеев, SD-карт и датчиков. В отличие от I2C, SPI требует больше проводов, но работает быстрее. Он работает с одним контроллером (таким как Pico 2) и одним или несколькими устройствами.
SPI использует четыре основных сигнала:
- SCK (Serial Clock): управляет таймингом передачи данных.
- MOSI (Master Out Slave In): данные от контроллера к устройству.
- MISO (Master In Slave Out): данные от устройства к контроллеру.
- CS/SS (Chip Select или Slave Select): используется контроллером, чтобы выбрать, с каким устройством общаться.
На схемах распиновки Pico 2 вывод MOSI обозначен как Tx, MISO — как Rx, а CS — как Csn.
У Raspberry Pi Pico 2 есть два контроллера SPI: SPI0 и SPI1. Каждый из них можно подключить к нескольким выводам GPIO, поэтому вы можете выбрать тот набор, который подходит для вашей схемы.
-
SPI0 может использовать:
- SCK: GPIO2, GPIO6, GPIO10, GPIO14, GPIO18
- MOSI: GPIO3, GPIO7, GPIO11, GPIO15, GPIO19
- MISO: GPIO0, GPIO4, GPIO8, GPIO12, GPIO16
-
SPI1 может использовать:
- SCK: GPIO14, GPIO18
- MOSI: GPIO15, GPIO19
- MISO: GPIO8, GPIO12, GPIO16
Группу совместимых выводов от одного контроллера можно выбрать в зависимости от схемы. Вывод CS (chip select) не фиксирован: для этой цели подойдёт любой свободный GPIO. Как настроить SPI и подключить устройства, мы разберём в следующих главах.
Выводы UART
UART (Universal Asynchronous Receiver/Transmitter) — один из самых простых способов обмена данными между двумя устройствами. Он использует всего два основных провода:
- TX (Transmit): передаёт данные наружу.
- RX (Receive): принимает данные.
UART часто используют для подключения последовательных устройств, таких как GPS-модули, Bluetooth-адаптеры или даже компьютер для вывода отладочных сообщений.
У Raspberry Pi Pico 2 есть два контроллера UART: UART0 и UART1. Каждый можно сопоставить с несколькими разными выводами GPIO, что даёт гибкость при монтаже схемы.
-
UART0 может использовать:
- TX: GPIO0, GPIO12, GPIO16
- RX: GPIO1, GPIO13, GPIO17
-
UART1 может использовать:
- TX: GPIO4, GPIO8
- RX: GPIO5, GPIO9
Нужно использовать согласованные пары TX и RX от одного и того же контроллера UART. Например, можно взять UART0 с TX на GPIO0 и RX на GPIO1 или UART1 с TX на GPIO8 и RX на GPIO9.
Выводы отладки SWD
Raspberry Pi Pico 2 имеет отдельный 3-контактный отладочный разъём для SWD (Serial Wire Debug) — стандартного интерфейса отладки ARM. SWD позволяет прошивать программы, просматривать регистры, ставить точки останова и выполнять отладку в реальном времени.
Интерфейс состоит из следующих сигналов:
- SWDIO — линия последовательных данных
- SWCLK — линия последовательного тактирования
- GND — опорная земля
Эти выводы не используются как обычные GPIO и находятся на отдельном отладочном разъёме у нижнего края платы. Обычно для подключения к ним используют внешний отладочный зонд, например Raspberry Pi Debug Probe, адаптер CMSIS-DAP или другие совместимые инструменты (например, OpenOCD, probe-rs).
Встроенный датчик температуры
Raspberry Pi Pico 2 содержит встроенный датчик температуры, который внутренне подключён к ADC4. Это значит, что температуру чипа можно считать через ADC так же, как с внешнего аналогового датчика.
Этот датчик измеряет температуру самого чипа RP2350. Он не отражает точно температуру в комнате, особенно когда чип работает под нагрузкой и нагревается.
Управляющие выводы
Эти выводы управляют поведением питания платы и могут использоваться для сброса или выключения чипа.
-
3V3(EN) — вывод разрешения работы встроенного стабилизатора 3,3 В. Подача на него низкого уровня отключает линию питания 3,3 В и фактически выключает RP2350.
-
RUN — вывод сброса RP2350. Он имеет встроенный подтягивающий резистор и по умолчанию находится в высоком уровне. Подача низкого уровня сбрасывает микроконтроллер. Это полезно, если вы хотите добавить физическую кнопку сброса или запускать сброс с другого устройства.
Настройка
Picotool
picotool — инструмент для работы с бинарными файлами RP2040/RP2350 и для взаимодействия с устройствами RP2040/RP2350, когда они находятся в режиме BOOTSEL.
Tip
Можно также скачать готовые бинарные файлы инструментов SDK отсюда — это проще, чем выполнять описанные ниже шаги.
Вот краткая сводка шагов, которые я выполнял:
# Установить зависимости
sudo apt install build-essential pkg-config libusb-1.0-0-dev cmake
mkdir embedded && cd embedded
# Клонировать Pico SDK
git clone https://github.com/raspberrypi/pico-sdk
cd pico-sdk
git submodule update --init lib/mbedtls
cd ../
# Задать переменную окружения для Pico SDK
PICO_SDK_PATH=/MY_PATH/embedded/pico-sdk
# Клонировать репозиторий Picotool
git clone https://github.com/raspberrypi/picotool
Сборка и установка Picotool
cd picotool
mkdir build && cd build
# cmake ../
cmake -DPICO_SDK_PATH=/MY_PATH/embedded/pico-sdk/ ../
make -j8
sudo make install
В Linux можно добавить правила udev, чтобы запускать picotool без sudo:
cd ../
# Внутри склонированной директории picotool
sudo cp udev/60-picotool.rules /etc/udev/rules.d/
Цели компиляции Rust
Чтобы собирать и загружать Rust-код для чипа RP2350, нужно добавить соответствующие цели:
rustup target add thumbv8m.main-none-eabihf
rustup target add riscv32imac-unknown-none-elf
probe-rs — инструмент для прошивки и отладки
probe-rs — современный инструментарий на Rust для прошивки и отладки embedded-устройств. Он поддерживает цели ARM и RISC-V и работает напрямую с аппаратными отладочными зондами. Если вы используете отладочный зонд с Pico 2, для прошивки и отладки вы будете опираться именно на probe-rs.
Установите probe-rs с помощью официального скрипта-установщика:
curl -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
Актуальные инструкции по установке лучше смотреть в официальной документации probe-rs.
По умолчанию отладочные зонды в Linux доступны только с правами root. Чтобы не вводить sudo для каждой команды, установите подходящие правила udev, которые позволят обычным пользователям работать с зондом. Следуйте приведённым инструкциям.
Краткая сводка:
- Скачайте файл правил udev из репозитория probe-rs
- Скопируйте его в
/etc/udev/rules.d/ - Перезагрузите правила udev командой
sudo udevadm control --reload - Отключите и снова подключите отладочный зонд
После этой настройки probe-rs можно использовать без прав root.
Быстрый старт
Прежде чем погружаться в теорию и устройство всего, перейдём сразу к делу. Используйте этот простой код, чтобы включить встроенный светодиод Pico 2.
Мы будем использовать Embassy — фреймворк на Rust для микроконтроллеров вроде Raspberry Pi Pico 2. Embassy позволяет писать асинхронный код, который обрабатывает несколько задач одновременно: например, мигает светодиодом и одновременно считывает нажатия кнопки, не дожидаясь завершения одной задачи перед началом другой.
Следующий код создаёт эффект мигания, переключая уровень вывода между высоким (включено) и низким (выключено). Как мы упоминали в разделе о распиновке, встроенный светодиод Pico 2 подключён к выводу GPIO 25. В этой программе мы настраиваем вывод как выход (Output) — так делают всегда, когда нужно включать и выключать светодиоды, управлять моторами или посылать сигналы другим устройствам — с начальным низким уровнем (выключено).
Фрагмент кода
Здесь показана только функция main. Для работы программы нужны также шаги инициализации и импорты. Мы подробно разберём их в следующей главе, чтобы понять, что они делают и зачем нужны. Сейчас наша цель — просто увидеть, как всё работает. Можно клонировать созданный мной проект быстрого старта и сразу его запустить.
Important
Этот код несовместим с вариантом Pico 2 W. На Pico 2 W вывод GPIO25 отвечает за беспроводной интерфейс, поэтому для управления встроенным светодиодом нужна другая процедура.
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// Встроенный светодиод на самом деле подключён к выводу 25
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
led.set_high(); // Включить светодиод
Timer::after_millis(500).await;
led.set_low(); // Выключить светодиод
Timer::after_millis(500).await;
}
}
Клонируйте проект быстрого старта
git clone https://github.com/ImplFerris/pico2-quick
cd pico2-quick
Как запустить?
Чтобы прошить приложение на Pico 2, нажмите и удерживайте кнопку BOOTSEL. Удерживая её, подключите Pico 2 к компьютеру micro USB-кабелем. Кнопку можно отпустить, как только USB подключён.
# Запустить программу
cargo run
Программа будет записана (прошита) в память Pico 2 и запущена автоматически. Если всё прошло успешно, встроенный светодиод начнёт мигать с регулярным интервалом. Если возникли ошибки, проверьте, что среда разработки настроена правильно и плата подключена как следует. Если проблема не решается, создайте issue на GitHub с подробным описанием, чтобы я смог обновить и улучшить это руководство.
С отладочным зондом
Если вы используете отладочный зонд, нажимать кнопку BOOTSEL не нужно. Можно просто выполнить cargo flash или cargo embed. Эти команды подробно описаны дальше в книге, но если хотите познакомиться с ними сейчас, перейдите к главе Отладочный зонд.
Уровни абстракции
Работая с embedded Rust, вы часто будете встречать термины PAC, HAL и BSP. Это различные уровни, через которые вы взаимодействуете с оборудованием. Каждый уровень даёт свой баланс между гибкостью и простотой использования.
Начнём с самого высокого уровня абстракции и спустимся к самому низкому.
Пакет поддержки платы (BSP)
BSP, в Rust также называемый Board Support Crate, — это пакет, созданный под конкретные отладочные платы. Он объединяет HAL с настройками, специфичными для платы, и предоставляет готовые интерфейсы для встроенных компонентов, таких как светодиоды, кнопки и датчики. Это позволяет разработчикам сосредоточиться на логике приложения, а не на низкоуровневых деталях оборудования. Поскольку популярного BSP именно для Raspberry Pi Pico 2 нет, в этой книге мы не будем использовать этот подход.
Уровень абстракции оборудования (HAL)
HAL находится непосредственно под уровнем BSP. Если вы работаете с платами Raspberry Pi Pico или платами на базе ESP32, в основном вы будете использовать именно HAL. HAL обычно пишут под конкретный чип (например, RP2350 или ESP32), а не под отдельные платы, поэтому один и тот же HAL подходит для разных плат с одним и тем же микроконтроллером. Для семейства микроконтроллеров Raspberry Pi существует крейт rp-hal, который предоставляет эту абстракцию.
HAL построен поверх PAC и предоставляет более простые интерфейсы высокого уровня для периферии микроконтроллера. Вместо работы с регистрами напрямую HAL предлагает методы и трейты, которые упрощают настройку таймеров, организацию последовательной связи или управление выводами GPIO.
HAL для микроконтроллеров обычно реализуют трейты embedded-hal — стандартные платформонезависимые интерфейсы для периферии, такой как GPIO, SPI, I2C и UART. Благодаря этому драйверы и библиотеки можно писать так, чтобы они работали на разном оборудовании, если используют совместимый HAL.
Embassy для RP
Embassy находится на том же уровне, что и HAL, но дополнительно предоставляет среду выполнения с поддержкой асинхронности. Embassy (а точнее, embassy-rp для Raspberry Pi Pico) построен поверх уровня HAL и предоставляет асинхронный исполнитель, таймеры и дополнительные абстракции, которые упрощают написание параллельных embedded-приложений.
Embassy предоставляет отдельный крейт embassy-rp для микроконтроллеров Raspberry Pi (RP2040 и RP235x). Этот крейт напрямую опирается на rp-pac (Raspberry Pi Peripheral Access Crate).
В этой книге мы будем использовать и rp-hal, и embassy-rp в разных упражнениях.
Note
Уровни ниже HAL редко используются напрямую. В большинстве случаев PAC доступен через HAL, а не сам по себе. Если только вы не работаете с чипом, для которого нет HAL, обычно нет необходимости взаимодействовать с нижними уровнями напрямую. В этой книге мы сосредоточимся на уровне HAL.
Крейт доступа к периферии (PAC)
PAC — самый низкий уровень абстракции. Это автоматически сгенерированные крейты, которые обеспечивают типобезопасный доступ к периферии микроконтроллера. Обычно их генерируют из SVD-файла (System View Description), который предоставляет производитель, с помощью инструментов вроде svd2rust. PAC дают структурированный и безопасный способ работать с регистрами оборудования напрямую.
Raw MMIO
Raw MMIO (memory-mapped IO, ввод-вывод, отображаемый в память) означает прямую работу с регистрами оборудования через чтение и запись по конкретным адресам памяти. Такой подход повторяет традиционную манипуляцию регистрами в стиле C и требует использования блоков unsafe в Rust из-за потенциальных рисков. Мы не будем касаться этой области: я ни разу не видел, чтобы кто-то использовал этот подход.
Шаблон проекта с cargo-generate
«cargo-generate — инструмент разработчика, который помогает быстро начать новый проект на Rust, используя существующий git-репозиторий в качестве шаблона».
Подробнее читайте на странице проекта cargo-generate на GitHub.
Требования
Прежде чем начать, убедитесь, что установлены следующие инструменты:
- Rust
- cargo-generate для создания проекта из шаблона.
Сначала установите пакет разработки OpenSSL, так как он нужен cargo-generate:
sudo apt install libssl-dev
Установить cargo-generate можно такой командой:
cargo install cargo-generate
Шаг 1: создание проекта
Чтобы создать проект из шаблона, выполните команду:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Команда задаст несколько вопросов:
- Имя проекта: укажите название проекта.
- Выбор HAL: можно выбрать
embassyилиrp-hal.
Шаг 2: пример мигания светодиодом по умолчанию
По умолчанию проект создаётся с простым примером мигания светодиодом. Структура кода может выглядеть так:
src/main.rs: содержит стандартную логику мигания.
Cargo.toml: включает зависимости для выбранного HAL.
Шаг 3: выбор HAL и изменение кода
После создания проекта можно оставить код мигания по умолчанию или удалить его и заменить своим — с учётом выбранного HAL.
Удаление лишнего кода
Логику мигания из src/main.rs можно удалить и заменить своим кодом. При необходимости измените зависимости в Cargo.toml и структуру проекта.
Запуск программы
Прежде чем разбирать дальнейшие примеры, рассмотрим общие шаги сборки и запуска любой программы на Raspberry Pi Pico 2. Pico 2 содержит и процессоры ARM Cortex-M33, и RISC-V Hazard3, и мы приведём инструкции для обеих архитектур.
Примечание: эти команды нужно выполнять из папки вашего проекта. Они приведены здесь как общий шаг, чтобы не повторять его в каждом разделе. Если проект ещё не создан, начните с раздела «Быстрый старт» или «Мигание светодиодом».
Сборка и запуск для ARM
Используйте эту команду, чтобы собирать и запускать программы на Raspberry Pi Pico 2 в режиме ARM, с процессорами Cortex-M33.
# собрать программу
cargo build --target=thumbv8m.main-none-eabihf
Чтобы прошить приложение на Pico 2, нажмите и удерживайте кнопку BOOTSEL. Удерживая её, подключите Pico 2 к компьютеру micro USB-кабелем. Кнопку можно отпустить, когда USB уже подключён.
# Запустить программу
cargo run --target=thumbv8m.main-none-eabihf
Note
В примерах кода в файле
.cargo/config.tomlзадан runner:runner = "picotool load -u -v -x -t elf". Это значит, что при выполненииcargo runна самом деле вызываетсяpicotoolс подкомандойload, которая и прошивает программу.
Сборка и запуск для RISC-V
Используйте эту команду, чтобы собирать и запускать программы на Raspberry Pi Pico 2 в режиме RISC-V, с процессорами Hazard3.
Important
Эта книга сосредоточена на ARM. Некоторые примеры могут потребовать изменений, чтобы заработать в режиме RISC-V. Для простоты при чтении книги рекомендуется следовать процессу для ARM.
# собрать программу
cargo build --target=riscv32imac-unknown-none-elf
Выполните те же действия с BOOTSEL, что описаны выше.
# Запустить программу
cargo run --target=riscv32imac-unknown-none-elf
С отладочным зондом
При использовании отладочного зонда можно прошить программу на Pico 2 напрямую:
# cargo flash --chip RP2350
# cargo flash --chip RP2350 --release
cargo flash --release
Если хотите одновременно прошить программу и видеть её вывод в реальном времени, используйте:
# cargo embed --chip RP2350
# cargo embed --chip RP2350 --release
cargo embed --release
cargo-embed — более продвинутая версия cargo-flash. Он умеет прошивать программу, а также открывать RTT-терминал и GDB-сервер.
Помощь и устранение неполадок
Если при выполнении упражнений вы столкнётесь с ошибками, багами или другими проблемами, вот несколько способов их найти и устранить.
1. Сравните с рабочим кодом
Посмотрите полные примеры кода или склонируйте эталонный проект для сравнения. Внимательно проверьте свой код и версии зависимостей в Cargo.toml. Ищите синтаксические и логические ошибки. Если нужная функция (feature) не включена или features не совпадают, включите нужные features, как показано в упражнении.
Если вы нашли несоответствие версий, можно либо адаптировать код под более новую версию (разобраться самостоятельно — отличный способ лучше понять материал), либо обновить зависимости до версий, которые использовались в руководстве.
2. Поищите или создайте issue на GitHub
Откройте страницу issues на GitHub, чтобы проверить, не сталкивался ли кто-то с той же проблемой: https://github.com/r007/EmbeddedRust-Rus/issues?q=is%3Aissue
Если нет, создайте новый issue и ясно опишите проблему.
3. Спросите сообщество
Сообщество Rust Embedded активно в Matrix-чате. Matrix — открытая децентрализованная сеть для безопасного общения.
Вот несколько полезных каналов Matrix по темам книги:
-
Рабочая группа по embedded-устройствам
#rust-embedded:matrix.orgОбщие обсуждения использования Rust в embedded-разработке. -
Разработка для семейства RP
#rp-rs:matrix.orgРазработка на Rust и обсуждения чипов Raspberry Pi серии RP. -
Отладка с probe-rs
#probe-rs:matrix.orgПоддержка и обсуждение инструментария отладки probe-rs. -
Embedded Graphics
#rust-embedded-graphics:matrix.orgДля работы сembedded-graphics— библиотекой для рисования в embedded-системах.
Вы можете создать аккаунт Matrix и присоединиться к этим каналам, чтобы получить помощь от опытных разработчиков.
Другие чаты сообщества можно найти в разделе Community Chat Rooms в Awesome Embedded Rust.
4. Discord
Существует неофициальное сообщество Embedded Rust в Discord, где можно задавать вопросы, обсуждать темы, делиться опытом и показывать свои проекты. Оно особенно полезно новичкам и для общих обсуждений.
Имейте в виду, что мейнтейнеры большинства HAL и embedded-экосистемы более активны в Matrix. Тем не менее этот Discord-сервер тоже может стать хорошим местом для учёбы и общения.
Присоединиться: https://discord.gg/NHenanPUuG
Отладочный зонд для Raspberry Pi Pico 2
Нажимать кнопку BOOTSEL каждый раз, когда нужно прошить новую программу, неудобно. На отладочных платах вроде ESP32 DevKit этот шаг почти полностью автоматизирован, потому что плата может сама перевести чип в режим загрузчика при необходимости. У Pico 2 такой функции нет, но ту же удобность и даже больше возможностей можно получить с помощью отладочного зонда.
В этой главе объясняется, почему отладочный зонд полезен, и пошагово показано, как его настроить и использовать для прошивки и отладки Pico 2 без нажатия BOOTSEL каждый раз.
Raspberry Pi Debug Probe
Raspberry Pi Debug Probe — официальный инструмент, рекомендуемый для отладки по SWD на Pico и Pico 2. Это небольшое USB-устройство, которое работает как адаптер CMSIS-DAP. CMSIS-DAP — открытый стандарт для отладчиков, который позволяет компьютеру обмениваться данными с микроконтроллерами по протоколу SWD.
Зонд предоставляет две основные возможности:
-
Интерфейс SWD (Serial Wire Debug) — подключается к отладочным выводам Pico и используется для прошивки и отладки в реальном времени. Можно ставить точки останова, просматривать переменные и отлаживать программу так же, как обычное настольное приложение.
-
UART-мост — обеспечивает соединение USB с последовательным портом, чтобы вы могли видеть вывод консоли или обмениваться данными с платой.
Обе возможности работают через один и тот же USB-кабель, подключённый к компьютеру, поэтому настройка остаётся простой: отдельное UART-устройство не нужно.
Пайка выводов SWD
Прежде чем подключить отладочный зонд к Pico 2, нужно сделать доступными выводы SWD. Они находятся у нижнего края платы Pico, на небольшом 3-контактном отладочном разъёме, отдельном от основных выводов GPIO.
Когда выводы SWD припаяны, ваш Pico готов к подключению к отладочному зонду.
Подготовка отладочного зонда
Ваш отладочный зонд может быть поставлен не с последней прошивкой, особенно с той версией, которая добавляет поддержку Pico 2 (чип RP2350). Перед началом рекомендуется обновить прошивку.
Официальная документация Raspberry Pi содержит понятные инструкции по обновлению отладочного зонда. Следуйте приведённым шагам.
Подключение Pico к отладочному зонду
На боковой стороне отладочного зонда есть два порта:
- Порт D — для соединения SWD (отладки)
- Порт U — для соединения UART (последовательного порта)
Соединение SWD (обязательно)
Соединение SWD позволяет прошивать программы и использовать отладчик. Используйте кабель JST–Dupont, который поставляется с вашим отладочным зондом.
Подключите провода от порта D отладочного зонда к выводам Pico 2 следующим образом:
| Провод зонда | Вывод Pico 2 |
|---|---|
| Оранжевый | SWCLK |
| Чёрный | GND |
| Жёлтый | SWDIO |
Перед подключением убедитесь, что выводы SWD на Pico 2 припаяны правильно.
Соединение UART (необязательно)
Соединение UART полезно, если вы хотите видеть последовательный вывод (например, логи println! из Rust) в терминале компьютера. Оно не зависит от соединения SWD.
Подключите провода от порта U отладочного зонда к выводам Pico 2:
| Провод зонда | Вывод Pico 2 | Физический номер вывода |
|---|---|---|
| Жёлтый | GP0 (TX на Pico) | Контакт 1 |
| Оранжевый | GP1 (RX на Pico) | Контакт 2 |
| Чёрный | GND | Контакт 3 |
Можно использовать любые выводы GPIO, настроенные для UART, но GP0 и GP1 — это выводы UART0 по умолчанию для Pico.
Питание Pico
Отладочный зонд не питает Pico 2, а передаёт только сигналы SWD и UART. Чтобы питать Pico 2, подключите отладочный зонд к компьютеру через его USB-порт, а затем отдельно питайте Pico 2 через его собственный USB-порт. Для корректной отладки оба устройства должны получать питание.
Итоговая настройка
Когда всё подключено:
- Подключите отладочный зонд к компьютеру по USB
- Убедитесь, что Pico 2 получает питание
- Красный светодиод на отладочном зонде должен загореться, показывая, что он получает питание
- Настройка готова — теперь не нужно нажимать кнопку BOOTSEL
Теперь вы можете прошивать и отлаживать Pico 2 напрямую из своей среды разработки без ручных действий.
Проверка
Чтобы убедиться, что отладочный зонд и Pico 2 подключены правильно, можно использовать проект быстрого старта. Прошейте его и проверьте, что всё работает.
git clone https://github.com/ImplFerris/pico2-quick
cd pico2-quick
Просто cargo run, как раньше, использовать нельзя, если только вы не меняли config.toml. Дело в том, что проект быстрого старта настроен на использование picotool в качестве runner. Вы можете закомментировать runner picotool и включить runner probe-rs. После этого можно использовать команду cargo run.
Или, что проще (я рекомендую именно этот вариант), используйте команды, которые предоставляет probe-rs. Они прошьют программу через отладочный зонд:
cargo flash
# или
cargo flash --release
cargo embed
С помощью cargo embed можно прошить программу и наблюдать вывод логов в терминале. Проект быстрого старта уже настроен на отправку логов через RTT, поэтому перед запуском ничего настраивать не нужно.
cargo embed
# или
cargo embed --release
О RTT мы расскажем позже, а сейчас достаточно просто выполнить команду, чтобы увидеть, как программа работает и выводит логи.
Если всё работает, вы увидите сообщение «Hello, World!» в системном терминале.
Чтобы выйти из терминала, используйте обычное сочетание [Ctrl]-[C].
Ссылки
Real-Time Transfer (RTT)
Во время разработки встраиваемых систем нужен способ видеть, что происходит внутри программы. На обычном компьютере для вывода сообщений в терминал используют println!, но у микроконтроллера нет ни экрана, ни подключённого терминала. Real-Time Transfer (RTT) решает эту проблему: он позволяет выводить отладочные сообщения и логи с микроконтроллера на ваш компьютер.
Что такое RTT?
RTT — это способ связи, который позволяет микроконтроллеру отправлять сообщения на компьютер через отладочный зонд, который вы уже используете для прошивки программ.
Когда вы подключаете отладочный зонд Raspberry Pi к Pico, создаётся соединение, которое умеет делать две вещи:
- Прошивать новые программы в чип
- Читать и записывать память чипа
RTT использует эту возможность доступа к памяти. Он создаёт специальные буферы памяти на микроконтроллере, а отладочный зонд считывает эти буферы и выводит сообщения на ваш компьютер. Всё это происходит в фоне, пока программа работает в обычном режиме.
Логирование с помощью defmt
Defmt (сокращение от «deferred formatting», то есть «отложенное форматирование») — фреймворк логирования, созданный специально для устройств с ограниченными ресурсами, таких как микроконтроллеры. В embedded-проектах на Rust вы будете использовать defmt для вывода сообщений и отладки программ.
Defmt достигает высокой производительности благодаря отложенному форматированию и сжатию строк. Отложенное форматирование означает, что форматирование выполняется не на машине, которая пишет логи, а на другой машине.
Ваш Pico отправляет небольшие коды вместо полных текстовых сообщений. Компьютер получает эти коды и превращает их в обычный текст. Это позволяет держать прошивку компактной и не тратить время микроконтроллера на медленное форматирование строк.
Крейт defmt можно добавить в проект:
defmt = "1.0.1"
И использовать его так:
#![allow(unused)]
fn main() {
use defmt::{info, warn, error};
...
info!("Starting program");
warn!("You shall not pass!");
error!("Something went wrong!");
}
Defmt RTT
Сам по себе defmt не знает, как отправлять сообщения с Pico на компьютер. Ему нужен транспортный уровень. Здесь на помощь приходит defmt-rtt.
Крейт defmt-rtt связывает defmt с RTT, поэтому ваши сообщения логов передаются на компьютер через отладочный зонд.
Крейт defmt-rtt можно добавить в проект:
defmt-rtt = "1.0"
Tip
Чтобы видеть логи RTT и
defmt, нужно запускать программу с помощью инструментов probe-rs, например командойcargo embed. Эти инструменты автоматически открывают сессию RTT и показывают логи в терминале.
Затем подключите его в коде:
#![allow(unused)]
fn main() {
use defmt_rtt as _;
}
Эта строка настраивает соединение между defmt и RTT. Вы не вызываете из неё функции напрямую, но её нужно импортировать, чтобы всё работало.
Сообщения о панике с panic-probe
Когда программа аварийно завершается (паникует), нужно понимать, что пошло не так. Крейт panic-probe выводит сообщения о панике через defmt и RTT.
Крейт panic-probe можно добавить в проект:
# Функция print-defmt — указывает panic-probe использовать defmt для вывода.
panic-probe = { version = "1.0", features = ["print-defmt"] }
Затем подключите его в коде:
#![allow(unused)]
fn main() {
use panic_probe as _;
}
Чтобы посмотреть, как работают сообщения о панике, можно вызвать панику вручную. Попробуйте добавить в код следующую строку:
#![allow(unused)]
fn main() {
panic!("something went wrong");
}
Асинхронность во встраиваемом Rust
Когда я только начинал эту книгу, я писал большинство примеров, используя только rp-hal. В этой редакции я переписал книгу, сосредоточившись главным образом на асинхронном программировании с Embassy. Официальная книга Embassy уже содержит хорошую документацию, но здесь я хочу дать короткое введение. Давайте кратко посмотрим, что такое асинхронность, и поймём, почему она так ценна во встраиваемых системах.
Представьте, что вы готовите ужин
Если вы знакомы с концепциями параллелизма и асинхронности, эта аналогия вам не нужна: Embassy, по сути, похож на Tokio для встраиваемых систем и предоставляет асинхронную среду выполнения. Если же вы только начинаете знакомиться с асинхронностью, объясню на примере.
Вы готовите ужин и ставите воду кипятиться. Вместо того чтобы стоять и смотреть на кастрюлю, вы режете овощи. Время от времени вы поглядываете на кастрюлю, и, когда появляются пузырьки, вы готовы к следующему шагу. Пока основное блюдо готовится, вы в другой сковороде делаете гарнир. Вы даже проверяете сообщение на телефоне. Вы — один человек, который постоянно переключается между задачами, отслеживает, что требует внимания, делает работу, как только что-то готово, и никогда не стоит без дела, ожидая.
Так и работает асинхронное программирование. Вы — исполнитель (executor), который постоянно решает, что требует внимания. Каждая кулинарная задача — это асинхронная операция. Плита греет без вашего постоянного присмотра. Это и есть неблокирующее ожидание. Вы не застываете на месте, глядя на кипящую воду. Вы занимаетесь другими полезными делами и возвращаетесь, когда всё готово. Ключевая идея — эффективная координация: один человек (исполнитель), несколько ожидающих задач, и вы всегда делаете что-то полезное, переключая внимание на ту задачу, которая готова прямо сейчас. Именно это асинхронное программирование делает для вашего микроконтроллера.
Разные подходы
Во встраиваемых системах микроконтроллер проводит много времени в ожидании. Он ждёт нажатия кнопки, истечения таймера или окончания заданного времени мигания светодиода. Без асинхронности есть два основных подхода.
Блокирующий код
Первый подход — блокирующий код. Программа буквально останавливается и ждёт. Если вы ждёте нажатия кнопки, ваш код крутится в цикле и снова и снова проверяет, изменилось ли состояние кнопки. В это время микроконтроллер не может делать ничего другого. Он не может мигать светодиодом, проверять другие кнопки или реагировать на таймеры. Вся мощность процессора тратится впустую в тесном цикле, который раз за разом спрашивает: «уже готово?»
Прерывания
Второй подход — прямое использование прерываний. Когда происходят аппаратные события, например нажатие кнопки или истечение таймера, запускается обработчик прерывания. Это лучше, потому что основной код может продолжать работать, но код на прерываниях быстро становится сложным и подверженным ошибкам. Нужно тщательно управлять разделяемым состоянием между основным кодом и обработчиками прерываний.
Пока не беспокойтесь о прерываниях. Мы подробно рассмотрим их в следующих главах.
Асинхронность
Асинхронное программирование даёт лучшее из двух миров. Ваш код выглядит чистым и последовательным, как блокирующий код, но на самом деле он не блокируется. Когда вы используете await, код как бы говорит: «мне нужно дождаться этого, но пока можно заняться другой работой». Асинхронная среда выполнения, которую для нас предоставляет Embassy, берёт на себя всю сложность эффективного переключения между задачами.
Как устроена асинхронность в Rust
Когда вы пишете асинхронную функцию на Rust, перед fn ставится ключевое слово async. Внутри этой функции можно использовать ключевое слово await для операций, которые могут занять время. Вот как это выглядит:
#![allow(unused)]
fn main() {
async fn blink_led(mut led: Output<'static>) {
loop {
led.set_high();
Timer::after_millis(500).await;
led.set_low();
Timer::after_millis(500).await;
}
}
}
Важна часть .await. Когда вы пишете Timer::after_millis(500).await, вы говорите среде выполнения: «мне нужно подождать 500 миллисекунд, но в это время процессор мне не нужен». Среда выполнения может тогда запускать другие задачи. Когда 500 миллисекунд пройдут, ваша задача продолжится ровно с того места, где остановилась.
Вспомните нашу аналогию с кухней. Когда вы ставите что-то на плиту и отходите, вы фактически «ожидаете» его готовности. Вы занимаетесь другими делами, а когда всё готово, возвращаетесь к этой задаче. Точно так же, как вы выступаете исполнителем на кухне, отслеживая, что требует внимания и когда, для вашей программы эту роль играет асинхронная среда выполнения.
Embassy
Embassy — одна из популярных асинхронных сред выполнения, которая делает всё это возможным во встраиваемом Rust. Она предоставляет исполнитель, который управляет вашими задачами и обрабатывает аппаратные прерывания.
Исполнитель (Executor)
Когда вы используете #[embassy_executor::main], Embassy автоматически всё настраивает: он запускает ваши задачи, переводит процессор в режим сна, когда все задачи ожидают, и будит его, когда происходят аппаратные события. Исполнитель — координатор, который решает, какую задачу опрашивать и когда. Исполнитель ведёт очередь задач, готовых к выполнению. Когда задача доходит до await и отдаёт управление, исполнитель переходит к следующей готовой задаче. Когда готовых задач нет, исполнитель переводит процессор в режим сна. Прерывания будят исполнитель, который затем опрашивает задачи, ставшие готовыми.
RTIC
RTIC (Real-Time Interrupt-driven Concurrency) — ещё один популярный фреймворк для embedded Rust. В отличие от Embassy, который предоставляет асинхронную среду выполнения вместе с драйверами оборудования, RTIC сосредоточен только на выполнении и планировании. В RTIC вы заранее объявляете задачи с фиксированными приоритетами и разделяемые ресурсы, а фреймворк на этапе компиляции проверяет, что ресурсы разделяются безопасно и без гонок данных. Задачи с более высоким приоритетом могут вытеснять задачи с более низким приоритетом, а планирование выполняется аппаратными прерываниями, что делает тайминги очень предсказуемыми. Поэтому RTIC хорошо подходит для систем жёсткого реального времени, где важны точное управление и детерминированность. Подробнее см. официальную книгу RTIC.
В этой книге мы в основном будем использовать Embassy.
Мигание внешним светодиодом
Начиная с этого момента мы будем использовать с Pico больше внешних компонентов. Прежде чем перейти к ним, полезно освоиться с простыми схемами и научиться подключать компоненты к выводам Pico. В этой главе начнём с простого: будем мигать светодиодом, подключённым вне платы.
Требования к оборудованию
- Светодиод
- Резистор
- Перемычки
Обзор компонентов
-
Светодиод (LED, Light Emitting Diode) загорается, когда через него течёт ток. Длинная ножка (анод) подключается к плюсу, короткая (катод) — к земле. Мы подключим анод к GP13 (через резистор), а катод — к GND.
-
Резистор ограничивает ток в цепи, защищая такие компоненты, как светодиоды. Его номинал измеряется в омах (Ω). Мы будем использовать резистор 330 Ом, чтобы безопасно питать светодиод.
| Вывод Pico | Провод | Компонент |
|---|---|---|
| GPIO 13 |
|
Резистор |
| Резистор |
|
Анод (длинная ножка) светодиода |
| GND |
|
Катод (короткая ножка) светодиода |
Подключить Pico к светодиоду можно напрямую перемычками или собрать всё на макетной плате. Если вы не уверены в настройке оборудования, можно обратиться к руководству Raspberry Pi.
Tip
На Pico подписи выводов нанесены на обратной стороне платы, и это может быть неудобно при подключении проводов. Я часто проверял схему распиновки, когда нужно было использовать вывод GPIO. Используйте логотип Raspberry Pi на лицевой стороне как ориентир и сверяйтесь со схемой распиновки, чтобы найти нужные выводы. Номера выводов 2 и 39 также напечатаны на лицевой стороне и могут служить дополнительными ориентирами.
Мигание светодиодом — симуляция
В этой симуляции задержка по умолчанию установлена 5000 миллисекунд, чтобы анимация была спокойнее и её было легче проследить. Можно уменьшить её, например, до 500 миллисекунд, чтобы светодиод мигал быстрее. Когда мы будем запускать реальный код на Pico, используем задержку 500 миллисекунд.
Мигание внешним светодиодом на Raspberry Pi Pico на embedded Rust
Начнём с создания проекта. Будем использовать cargo-generate и шаблон, который мы подготовили для этой книги.
В терминале введите:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Программа задаст несколько вопросов:
-
Для имени проекта можно выбрать любое. Мы будем использовать
external-led. -
Затем она спросит «Which HAL?» (какой HAL использовать). Нужно выбрать
embassy. -
Потом предложит включить логирование
defmt. Оно работает только при использовании отладочного зонда, поэтому выбирайте в зависимости от своей настройки. В этом упражнении мы всё равно не будем писать логи.
Импорты
Большинство необходимых импортов уже есть в шаблоне проекта. Для этого упражнения нужно добавить только структуру Output и перечисление Level из модуля gpio:
#![allow(unused)]
fn main() {
use embassy_rp::gpio::{Level, Output};
}
Пока вы пишете основной код, редактор обычно сам предлагает недостающие импорты. Если подсказки нет или возникла ошибка, загляните в раздел с полным кодом и добавьте недостающие импорты оттуда.
Основная логика
Код почти такой же, как в примере быстрого старта. Единственное отличие — мы используем GPIO 13 вместо GPIO 25. К GPIO 13 подключён светодиод (через резистор).
Добавим этот код в функцию main:
#![allow(unused)]
fn main() {
let mut led = Output::new(p.PIN_13, Level::Low);
loop {
led.set_high(); // Включить светодиод
Timer::after_millis(500).await;
led.set_low(); // Выключить светодиод
Timer::after_millis(500).await;
}
}
Здесь мы используем структуру Output, потому что хотим отправлять сигналы с Pico на светодиод. Настраиваем GPIO 13 как выходной вывод и запускаем его в низком состоянии (выключено).
Note
Если вы хотите считывать сигналы с компонента (например, кнопки или датчика), вывод GPIO нужно настроить как Input.
Затем мы вызываем set_high() и set_low() для вывода с задержкой между ними. Это переключает вывод между высоким и низким уровнями, а значит — включает и выключает светодиод.
Полный код
Вот полный код для справки:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut led = Output::new(p.PIN_13, Level::Low);
loop {
led.set_high(); // Включить светодиод
Timer::after_millis(500).await;
led.set_low(); // Выключить светодиод
Timer::after_millis(500).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"external-led"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку external-led:
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/external-led
Как запустить?
См. раздел «Запуск программы».
Пример Blinky с использованием rp-hal
В предыдущем разделе мы использовали Embassy. Схему и подключение оставляем те же. В этом примере переходим на rp-hal, чтобы показать, как выглядят оба подхода. Embassy подойдёт, если нужна поддержка async, а rp-hal — если вы предпочитаете блокирующий стиль. В этой книге мы в основном будем использовать Embassy.
Снова создадим проект с помощью cargo-generate и того же шаблона.
В терминале введите:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда программа предложит выбрать HAL, выберите на этот раз «rp-hal».
Импорты
Шаблон уже содержит большинство импортов. Для этого примера нужно добавить трейт OutputPin из embedded-hal:
#![allow(unused)]
fn main() {
// Трейт Embedded HAL для выходного вывода (Output Pin)
use embedded_hal::digital::OutputPin;
}
Этот трейт предоставляет методы set_high() и set_low(), которыми мы будем управлять светодиодом.
Основная логика
Если сравнить с версией на Embassy, разница в переключении светодиода невелика. Основное отличие — в том, как работает задержка. Embassy использует async и await, что позволяет программе приостанавливаться без блокировки и даёт другим задачам выполняться в фоне. rp-hal использует блокирующий delay, который останавливает программу, пока не пройдёт нужное время.
#![allow(unused)]
fn main() {
let mut led_pin = pins.gpio13.into_push_pull_output();
loop {
led_pin.set_high().unwrap();
timer.delay_ms(200);
led_pin.set_low().unwrap();
timer.delay_ms(200);
}
}
Полный код
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Трейт Embedded HAL для выходного вывода (Output Pin)
use embedded_hal::digital::OutputPin;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// Внешний высокоскоростной кварц на плате Raspberry Pi Pico 2 работает на 12 МГц.
/// Измените значение, если на вашей плате другая частота
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
// Получаем наши синглтон-объекты
let mut pac = hal::pac::Peripherals::take().unwrap();
// Настраиваем драйвер сторожевого таймера — он нужен для настройки тактирования
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Настраиваем тактирование
//
// По умолчанию генерируется системная частота 125 МГц
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// Блок SIO с однотактным доступом управляет нашими выводами GPIO
let sio = hal::Sio::new(pac.SIO);
// Настраиваем выводы в соответствии с их функциями на этой плате
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
let mut led_pin = pins.gpio13.into_push_pull_output();
loop {
led_pin.set_high().unwrap();
timer.delay_ms(200);
led_pin.set_low().unwrap();
timer.delay_ms(200);
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку external-led:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/external-led
От std к no_std
Мы успешно прошили и запустили нашу первую программу, которая создаёт эффект мигания. Однако код и структуру проекта мы пока подробно не разбирали. В этом разделе мы воссоздадим тот же проект с нуля. По ходу дела я объясню каждую часть кода и конфигурации. Готовы к испытанию?
Tip
Если эта глава кажется вам слишком сложной, особенно если вы занимаетесь просто хобби-проектом, смело пропустите её пока. Вернуться к ней можно позже, когда вы соберёте несколько интересных проектов и поработаете с упражнениями.
Создаём новый проект
Начнём с создания стандартного бинарного проекта на Rust. Используйте следующую команду:
#![allow(unused)]
fn main() {
cargo new pico-from-scratch
}
На этом этапе проект будет содержать обычные файлы, как и ожидается.
├── Cargo.toml
└── src
└── main.rs
Наша цель — получить следующую итоговую структуру проекта:
├── build.rs
├── .cargo
│ └── config.toml
├── Cargo.toml
├── memory.x
├── rp235x_riscv.x
├── src
│ └── main.rs
Кросс-компиляция
Вероятно, вы уже знакомы с кросс-компиляцией. В этом разделе мы разберём, как она работает и что означают такие понятия, как target triple (тройка целевой платформы). Проще говоря, кросс-компиляция — это сборка программ для машины, отличной от той, на которой вы работаете.
Можно писать код на одном компьютере и создавать программы, которые будут работать на совсем другом. Например, можно работать в Linux и собирать файлы .exe для Windows. Можно даже собирать программы для голого железа (bare-metal) микроконтроллеров, таких как RP2350, ESP32 или STM32.
Коротко
При сборке бинарного файла для Pico 2 нужно использовать в качестве цели либо
thumbv8m.main-none-eabihf, либоriscv32imac-unknown-none-elf.
cargo build --target thumbv8m.main-none-eabihfЦель также можно задать в
.cargo/config.toml, чтобы не вводить её каждый раз.
Сборка для вашей системы
Допустим, мы работаем на машине с Linux. Когда вы запускаете обычную команду сборки, Rust компилирует код для текущей платформы, в данном случае для Linux:
cargo build
Проверить, какой тип бинарного файла был создан, можно с помощью команды file:
file ./target/debug/pico-from-scratch
Вы получите примерно такой вывод. Он говорит о том, что это 64-битный ELF-файл, динамически скомпонованный и собранный для Linux.
./target/debug/pico-from-scratch: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, Build...
Кросс-компиляция для Windows
Теперь представим, что нужно собрать бинарный файл для Windows, не выходя из Linux. Здесь и пригодится кросс-компиляция.
Сначала нужно сообщить Rust о целевой платформе. Это нужно сделать только один раз:
rustup target add x86_64-pc-windows-gnu
Эта команда добавляет поддержку генерации 64-битных бинарных файлов Windows с помощью инструментария GNU (MinGW).
Теперь соберите проект снова, но на этот раз укажите цель:
cargo build --target x86_64-pc-windows-gnu
Вот и всё. Rust создаст бинарный файл .exe для Windows, хотя вы по-прежнему работаете в Linux. Выходной файл будет находиться в target/x86_64-pc-windows-gnu/debug/pico-from-scratch.exe.
Тип файла можно проверить так:
file target/x86_64-pc-windows-gnu/debug/pico-from-scratch.exe
Вывод будет примерно таким: 64-битный файл PE32+ для Windows.
target/x86_64-pc-windows-gnu/debug/pico-from-scratch.exe: PE32+ executable (console) x86-64, for MS Windows
Что такое target triple?
Что же означает эта строка x86_64-pc-windows-gnu?
Это называется target triple (тройка целевой платформы), и она точно сообщает компилятору, какой результат вам нужен. Обычно она следует такому формату:
`<architecture>-<vendor>-<os>-<abi>`
Но шаблон не всегда соблюдается. Иногда части ABI не будет. В других случаях может отсутствовать даже поставщик (vendor) или и поставщик, и ABI. Структура может быть запутанной, и исключений много. Если хотите разобраться во всех особенностях и граничных случаях, посмотрите статью «What the Hell Is a Target Triple?» (на английском языке), ссылка на которую приведена в списке литературы.
Разберём, что на самом деле означает эта тройка:
-
Архитектура (x86_64): это 64-битная x86, тип процессора, который используется в большинстве современных ПК. Её также называют AMD64 или x64.
-
Поставщик (pc): по сути, это заполнитель. В большинстве случаев он не очень важен. Если речь идёт о macOS, поставщиком будет «apple».
-
ОС (windows): сообщает Rust, что мы хотим собрать программу для Windows.
-
ABI (gnu): эта часть говорит Rust, что для сборки бинарного файла нужно использовать инструментарий GNU.
Ссылки
- Поддержка платформ (на английском языке)
- Кросс-компиляция (на английском языке)
- What the Hell Is a Target Triple? (на английском языке)
Сборка для микроконтроллера
Теперь поговорим о встраиваемых системах. Когда дело доходит до компиляции Rust-кода для микроконтроллера, всё работает немного иначе, чем в обычных настольных системах. Микроконтроллеры обычно не запускают полноценную операционную систему вроде Linux или Windows. Вместо этого они работают в минимальной среде, часто вообще без ОС. Это называется bare-metal, то есть «голое железо».
Rust поддерживает такой режим через no_std. В обычных программах на Rust стандартная библиотека (std) отвечает за файловые системы, потоки, выделение памяти в куче и ввод-вывод, но ничего из этого нет на bare-metal микроконтроллере. Поэтому вместо std мы используем гораздо меньшую библиотеку core, которая предоставляет только самые необходимые строительные блоки.
Целевая платформа (target triple) для Pico 2
Raspberry Pi Pico 2 (чип RP2350), как вы уже знаете, особенный: он содержит выбираемые ядра ARM Cortex-M33 и RISC-V Hazard3. Вы можете выбрать, какую архитектуру процессора использовать.
Цель для ARM Cortex-M33
Для режима ARM нужно использовать цель thumbv8m.main-none-eabi:
Разберём её:
- Архитектура (thumbv8m.main): Cortex-M33 использует набор инструкций ARM Thumb-2 для архитектуры ARMv8-M.
- Поставщик (none): конкретный поставщик не указан.
- ОС (none): операционной системы нет, это bare-metal.
- ABI (eabi): Embedded Application Binary Interface, стандартное соглашение о вызовах для встраиваемых систем ARM.
Чтобы установить и использовать эту цель:
rustup target add thumbv8m.main-none-eabi
cargo build --target thumbv8m.main-none-eabi
Цель для RISC-V Hazard3
Для режима RISC-V используйте цель riscv32imac-unknown-none-elf:
riscv32imac-unknown-none-elf
Разберём её:
- Архитектура (riscv32imac): 32-битный RISC-V с наборами инструкций I (целочисленные), M (умножение и деление), A (атомарные) и C (сжатые).
- Поставщик (unknown): конкретный поставщик не указан.
- ОС (none): операционной системы нет, это bare-metal.
- Формат (elf): ELF (Executable and Linkable Format), формат объектных файлов, который часто используется во встраиваемых системах.
Чтобы установить и использовать эту цель:
rustup target add riscv32imac-unknown-none-elf
cargo build --target riscv32imac-unknown-none-elf
В наших упражнениях мы в основном будем использовать режим ARM. Некоторые крейты, например panic-probe, не работают в режиме RISC-V.
Конфигурация Cargo
В быстром старте вы могли заметить, что мы ни разу вручную не передавали флаг --target при запуске команд cargo. Откуда же cargo знал, для какой цели собирать? Потому что цель уже была задана в файле .cargo/config.toml.
Этот файл позволяет хранить настройки, относящиеся к cargo, включая цель сборки по умолчанию. Чтобы настроить его для Pico 2 в режиме ARM, создайте папку .cargo в корне проекта и добавьте файл config.toml со следующим содержимым:
[build]
target = "thumbv8m.main-none-eabihf"
Теперь не нужно передавать --target каждый раз. Cargo будет использовать эту цель автоматически.
no_std
В Rust есть два основных фундаментальных крейта: std и core.
-
Крейт std — это стандартная библиотека. Она даёт такие возможности, как выделение памяти в куче, доступ к файловой системе, потоки и
println!(). -
Крейт core — минимальное подмножество. Он содержит только самые необходимые возможности Rust: базовые типы (Option, Result и т. п.), трейты и некоторые другие операции. Он не зависит от операционной системы или среды выполнения.
Если сейчас попробовать собрать проект, вы получите целую кучу ошибок. Вот как это выглядит:
error[E0463]: can't find crate for `std`
|
= note: the `thumbv8m.main-none-eabihf` target may not support the standard library
= note: `std` is required by `pico_from_scratch` because it does not declare `#![no_std]`
error: cannot resolve a prelude import
error: cannot find macro `println` in this scope
--> src/main.rs:2:5
|
2 | println!("Hello, world!");
| ^^^^^^^
error: `#[panic_handler]` function required, but not found
For more information about this error, try `rustc --explain E0463`.
error: could not compile `pico-from-scratch` (bin "pico-from-scratch") due to 4 previous errors
Ошибок здесь очень много. Давайте исправим их по одной. Первая ошибка говорит, что целевая платформа, возможно, не поддерживает стандартную библиотеку. Это верно, мы уже это знаем. Проблема в том, что мы не сообщили Rust, что не хотим использовать std. Именно для этого служит атрибут no_std.
#![no_std]
Атрибут #![no_std] отключает использование стандартной библиотеки (std). Это почти всегда необходимо при разработке встраиваемых систем, где среде обычно не хватает многих ресурсов (например, операционной системы, файловой системы или выделения памяти в куче), которые стандартная библиотека предполагает доступными.
В начало файла src/main.rs добавьте эту строку:
#![no_std]
На этом всё. Теперь Rust знает, что проект будет использовать только библиотеку core, а не std.
Println
Макрос println! приходит из крейта std. Поскольку в нашем проекте std не используется, println! мы использовать не можем. Давайте удалим его из кода.
Теперь код должен выглядеть так:
#![no_std]
fn main() {
}
С этим исправлением мы устранили три ошибки и сократили список. Осталась ещё одна проблема, которую мы исправим в следующем разделе.
Ресурсы:
- Официальная документация Rust, раздел о пределюдиях и no_std (на английском языке)
- The Embedded Rust Book (на английском языке)
- Writing an OS in Rust, раздел об атрибуте no_std (на английском языке)
Обработчик паники
На этом этапе при сборке проекта вы получите такую ошибку:
error: `#[panic_handler]` function required, but not found
Когда программа на Rust паникует, обычно паника обрабатывается встроенным обработчиком из стандартной библиотеки. Но на предыдущем шаге мы добавили #![no_std], который говорит Rust не использовать стандартную библиотеку. Поэтому по умолчанию обработчика паники теперь нет.
В окружении no_std вы сами должны определить поведение при панике, потому что при возникновении проблемы некому перехватить управление: ни операционной системы, ни среды выполнения нет.
Исправить это можно, добавив собственный обработчик паники. Просто создайте функцию с атрибутом #[panic_handler]. Функция должна принимать ссылку на PanicInfo, а её возвращаемый тип должен быть !, что означает, что функция никогда не возвращает управление.
Добавьте это в src/main.rs:
#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
loop {}
}
}
Крейты для паники
Есть готовые крейты, которые предоставляют функцию-обработчик паники для проектов no_std. Один из простых и часто используемых — “panic_halt”, который просто останавливает выполнение при панике.
#![allow(unused)]
fn main() {
use panic_halt as _;
}
Эта строка подключает обработчик паники из крейта. Теперь, если случится паника, программа просто остановится и будет крутиться в бесконечном цикле.
На самом деле код крейта panic_halt реализует простой обработчик паники, который выглядит так:
#![allow(unused)]
fn main() {
use core::panic::PanicInfo;
use core::sync::atomic::{self, Ordering};
#[inline(never)]
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {
atomic::compiler_fence(Ordering::SeqCst);
}
}
}
Можно либо использовать внешний крейт, как здесь, либо написать собственную функцию-обработчик вручную. Выбор за вами.
Ресурсы:
- Официальная документация Rust, раздел о обработчике паники (на английском языке)
- The Embedded Rust Book, раздел о панике (на английском языке)
no_main
Если на этом этапе попробовать собрать проект, вы получите ошибку о том, что функции main нужна стандартная библиотека. Что?! (Я удержался от соблазна вставить сюда мем с Мистером Бином, потому что не всем нравятся мемы.) Что же делать? Откуда вообще начинается программа?
Во встраиваемых системах мы не используем обычный fn main, который опирается на стандартную библиотеку. Вместо этого нужно сообщить Rust, что мы предоставим собственную точку входа. Для этого используется атрибут no_main.
Атрибут #![no_main] указывает, что программа не будет использовать стандартную точку входа (fn main).
В начало файла src/main.rs добавьте эту строку:
#![no_main]
Объявление точки входа
Теперь, когда мы отказались от стандартной точки входа, нужно указать Rust, с какой функции начинать. Каждый крейт HAL в экосистеме embedded Rust предоставляет специальный макрос-атрибут, который позволяет пометить точку входа. Этот макрос инициализирует и настраивает всё, что нужно для микроконтроллера.
Если бы мы использовали rp-hal, для чипа RP2350 можно было бы взять rp235x_hal::entry. Однако мы будем использовать Embassy (крейт embassy-rp). Embassy предоставляет макрос embassy_executor::main, который настраивает асинхронную среду выполнения для задач и вызывает нашу функцию main.
Исполнитель Embassy (Embassy Executor) — это исполнитель async/await, созданный для встраиваемых систем, вместе с поддержкой прерываний и таймеров. Чтобы глубже понять, как работает Embassy, можно прочитать официальную книгу Embassy (на английском языке).
Среда выполнения Cortex-M
Если заглянуть в макрос embassy_executor::main, видно, что он использует другой макрос в зависимости от архитектуры. Поскольку Pico 2 — это Cortex-M, используется cortex_m_rt::entry. Он приходит из крейта cortex_m_rt, который предоставляет стартовый код и минимальную среду выполнения для микроконтроллеров Cortex-M.
Если выполнить cargo expand в проекте быстрого старта, можно увидеть, как раскрывается макрос, и полный поток выполнения. Если идти по этой цепочке до конца, программа начинается с функции __cortex_m_rt_main_trampoline. Эта функция вызывает __cortex_m_rt_main, которая настраивает исполнитель Embassy и запускает нашу функцию main.
Чтобы всё это работало, нужно добавить в проект крейты cortex-m и cortex-m-rt. Обновим файл Cargo.toml:
cortex-m = { version = "0.7.7" }
cortex-m-rt = "0.7.5"
Теперь можно добавить крейт исполнителя embassy:
embassy-executor = { version = "0.9", features = [
"arch-cortex-m",
"executor-thread",
] }
Затем в main.rs задайте точку входа так:
use embassy_executor::Spawner;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {}
Мы изменили сигнатуру функции. Функция должна принимать Spawner как аргумент, чтобы удовлетворить требованиям embassy, а сама функция теперь помечена как async.
Уже близко?
Ура! Теперь попробуйте собрать проект — он должен скомпилироваться успешно.
Сгенерированный бинарный файл можно проверить командой file:
file target/thumbv8m.main-none-eabihf/debug/pico-from-scratch
Вы увидите что-то вроде этого:
target/thumbv8m.main-none-eabihf/debug/pico-from-scratch: ELF 32-bit LSB executable, ARM, EABI5 version 1 (GNU/Linux), statically linked, with debug_info, not stripped
Как видите, бинарный файл собран для 32-битной ARM. Значит, базовая настройка для Pico работает.
Но уже близко? Пока нет. Мы прошли половину пути: теперь у нас есть корректный бинарный файл для Pico, но перед запуском на реальном железе нужно сделать ещё кое-что.
Ресурсы:
- Официальная документация Rust (на английском языке)
- Writing an OS in Rust, раздел о точке входа (на английском языке)
Периферия
Прежде чем перейти к следующей части, быстро посмотрим, что такое периферия.
Во встраиваемых системах периферия — это аппаратные компоненты, которые расширяют возможности микроконтроллера (MCU). Они позволяют микроконтроллеру взаимодействовать с внешним миром: обрабатывать входы и выходы, обмениваться данными, работать со временем и многое другое.
Пока ЦП отвечает за выполнение логики программы, периферия берёт на себя основную работу по взаимодействию с аппаратурой, часто разгружая ЦП. Это позволяет ЦП сосредоточиться на важных задачах, а периферийные блоки самостоятельно или почти без контроля выполняют специализированные функции.
Разгрузка
Разгрузка (offloading) — это практика, при которой определённые задачи передаются аппаратной периферии вместо того, чтобы выполнять их программно на ЦП. Это повышает производительность, снижает энергопотребление и позволяет выполнять операции параллельно. Например:
- Периферия UART может отправлять и принимать данные в фоновом режиме с помощью DMA (Direct Memory Access, прямой доступ к памяти), пока ЦП продолжает обрабатывать другую логику.
- Таймер можно настроить так, чтобы он генерировал точные задержки или периодические прерывания без участия ЦП.
- Контроллер ШИМ может управлять мотором непрерывно, без того, чтобы ЦП постоянно переключал выводы.
Разгрузка — ключевой приём проектирования во встраиваемых системах, позволяющий эффективно использовать ограниченную вычислительную мощность.
Распространённые типы периферии
Вот некоторые из самых распространённых видов периферии во встраиваемых системах:
| Периферия | Описание |
|---|---|
| GPIO (General Purpose Input/Output) | Цифровые выводы, которые можно настроить как входы или выходы для взаимодействия с внешним оборудованием, например кнопками, светодиодами и датчиками. |
| UART (Universal Asynchronous Receiver/Transmitter) | Интерфейс последовательной связи для отправки и приёма данных между устройствами, часто используется для отладки. |
| SPI (Serial Peripheral Interface) | Высокоскоростной синхронный протокол связи, который используется для подключения микроконтроллеров к периферии, например SD-картам, дисплеям и датчикам, по архитектуре «ведущий — ведомый». |
| I2C (Inter-Integrated Circuit) | Последовательный протокол связи по двум проводам, который используется для подключения низкоскоростной периферии, например датчиков и микросхем памяти, к микроконтроллеру. |
| ADC (Analog-to-Digital Converter) | Преобразует аналоговые сигналы от датчиков или других источников в цифровые значения, которые микроконтроллер может обработать. |
| PWM (Pulse Width Modulation) | Генерирует сигналы, которые управляют подачей мощности; часто используется для регулировки яркости светодиодов, управления скоростью моторов и работы сервоприводов. |
| Timer | Используется для генерации задержек, измерения интервалов времени, подсчёта событий или запуска действий в определённые моменты. |
| RTC (Real-Time Clock) | Отслеживает текущее время и дату даже при выключенной системе, обычно питается от батареи. |
Периферия в Rust
В embedded Rust к периферии обращаются по модели синглтона. Одна из основных целей Rust — безопасность, и она распространяется и на доступ к аппаратуре. Чтобы никакие две части программы не могли случайно управлять одной и той же периферией одновременно, Rust обеспечивает эксклюзивное владение через этот подход.
Паттерн «Синглтон»
Паттерн синглтон гарантирует, что в программе существует только один экземпляр каждой периферии. Это позволяет избежать распространённых ошибок, которые возникают, когда несколько фрагментов кода одновременно пытаются изменить один и тот же аппаратный ресурс.
В Embassy периферия тоже предоставляется по модели синглтона. Но мы не будем вызывать Peripherals::take() напрямую. Вместо этого используем функцию embassy_rp::init(Default::default()). Она берёт на себя базовую настройку системы и внутри вызывает Peripherals::take() за нас. Так мы получаем доступ ко всей периферии в безопасном и готовом к использованию виде.
Embassy для Raspberry Pi Pico
Понятие HAL мы уже вводили в главе «Введение». Для Pico мы будем использовать Embassy RP HAL. Embassy RP HAL поддерживает микроконтроллеры Raspberry Pi RP2040, а также семейство RP235x.
HAL поддерживает как блокирующий, так и асинхронный API периферии. Асинхронные API предпочтительнее, потому что HAL сам управляет ожиданием завершения операций периферии в режиме низкого энергопотребления и обрабатывает прерывания, так что вы можете сосредоточиться на основной функциональности.
Добавим крейт embassy-rp в проект, обновив Cargo.toml:
embassy-rp = { version = "0.9.0", features = [
"rp235xa",
] }
Important
Мы указываем зависимость вручную, с точной версией, чтобы все, кто следует этой книге, использовали одну и ту же версию. Это позволяет избежать неожиданных проблем, которые могут возникнуть, если новые версии внесут изменения.
Мы включили функцию rp235xa, потому что наш чип — RP2350. Если бы мы использовали старую Pico, вместо неё включили бы функцию rp2040.
Инициализация HAL embassy-rp
Инициализируем HAL. При необходимости в функцию инициализации можно передать пользовательскую конфигурацию. Сейчас конфигурация позволяет менять настройки тактирования, но пока оставим значения по умолчанию:
#![allow(unused)]
fn main() {
let peripherals = embassy_rp::init(Default::default());
}
Это даёт нам синглтоны периферии, которые нам нужны. Помните, что вызывать эту функцию следует только один раз при запуске: повторный вызов приведёт к панике.
Таймер
Мы повторим пример быстрого старта и заставим мигать встроенный светодиод. Чтобы создать эффект мигания, нам нужен таймер, который будет добавлять задержки между включением и выключением светодиода. Без задержек мигание было бы слишком быстрым, чтобы его увидеть.
Для работы со временем мы будем использовать крейт embassy-time, который предоставляет основные функции для работы со временем:
cargo add embassy-time
#![allow(unused)]
fn main() {
embassy-time = { version = "0.5.0" }
}
Также нужно включить функцию time-driver в крейте embassy-rp. Она настраивает периферию TIMER как глобальный драйвер времени для embassy-time, работающий с частотой тактов 1 МГц:
cargo add embassy-rp --features=time-driver,critical-section-impl
embassy-rp = { version = "0.9.0", features = [
"rp235xa",
"time-driver",
"critical-section-impl",
] }
Мы почти добавили все необходимые крейты. Теперь напишем код для эффекта мигания.
Мигание встроенным светодиодом Raspberry Pi Pico 2
Когда вы начинаете работать с embedded-программированием, первая периферия, с которой вы столкнётесь, — GPIO. «General-Purpose Input/Output» (ввод-вывод общего назначения) означает именно то, что написано: вывод можно использовать и для входа, и для выхода. Как выход Pico может посылать сигналы для управления компонентами, например светодиодами. Как вход компоненты, например кнопки, могут посылать сигналы Pico.
В этом упражнении мы будем управлять встроенным светодиодом, посылая ему сигналы. Если посмотреть на 8-ю страницу даташита Pico 2, видно, что встроенный светодиод подключён к GPIO 25.
Настроим вывод GPIO 25 как выход и зададим его начальное состояние в низкий уровень (выключено):
#![allow(unused)]
fn main() {
let mut led = Output::new(peripherals.PIN_25, Level::Low);
}
Большинство редакторов кода, например VS Code, имеют горячие клавиши для автоматического добавления импортов. Если в вашем редакторе такой функции нет или возникли проблемы, импорты можно добавить вручную:
#![allow(unused)]
fn main() {
use embassy_rp::gpio::{Level, Output};
}
Логика мигания
Теперь создадим простой цикл, который заставит светодиод мигать. Сначала включаем светодиод, вызывая функцию set_high() у нашего экземпляра GPIO. Затем добавляем короткую задержку с помощью Timer. Далее выключаем светодиод с помощью set_low() и добавляем ещё одну задержку. Так и создаётся эффект мигания.
Подключим Timer в проект:
#![allow(unused)]
fn main() {
use embassy_time::Timer;
}
Вот цикл мигания:
#![allow(unused)]
fn main() {
loop {
led.set_high();
Timer::after_millis(250).await;
led.set_low();
Timer::after_millis(250).await;
}
}
Прошивка Rust-программы в Raspberry Pi Pico 2
После сборки программы у нас появится ELF-файл, готовый к прошивке.
Для отладочной сборки (cargo build) файл находится здесь:
./target/thumbv8m.main-none-eabihf/debug/pico-from-scratch
Для релизной сборки (cargo build --release) — здесь:
./target/thumbv8m.main-none-eabihf/release/pico-from-scratch
Чтобы загрузить программу на Pico, мы воспользуемся инструментом Picotool. Вот команда для прошивки нашей программы:
#![allow(unused)]
fn main() {
picotool load -u -v -x -t elf ./target/thumbv8m.main-none-eabihf/debug/pico-from-scratch
}
Вот что делает каждый флаг:
-u— режим обновления (записываются только изменившиеся данные)-v— проверить, что всё записано корректно-x— запустить программу сразу после загрузки-t elf— сообщает picotool, что мы используем ELF-файл
Команда cargo run
Каждый раз вводить эту длинную команду утомительно. Упростим её, обновив файл .cargo/config.toml. Мы можем настроить Cargo так, чтобы при запуске cargo run он автоматически использовал picotool:
[target.thumbv8m.main-none-eabihf]
runner = "picotool load -u -v -x -t elf"
Теперь достаточно ввести:
cargo run --release
# или
cargo run
и программа будет прошита и запущена на Pico.
Пока что ничего на самом деле не прошьётся. Нам не хватает одного важного шага.
Скрипт компоновщика
Программа теперь собирается успешно. Однако при попытке прошить её на Pico может появиться ошибка такого вида:
ERROR: File to load contained an invalid memory range 0x00010000-0x000100aa
Сравнение нашего проекта с проектом быстрого старта
Чтобы понять, почему прошивка не удаётся, посмотрим на скомпилированную программу с помощью инструмента arm-none-eabi-readelf. Он показывает, как компилятор и компоновщик разместили программу в памяти.
Я взял бинарный файл из проекта быстрого старта и сравнил его с бинарным файлом, который наш проект выдаёт в текущем состоянии.
Не нужно понимать каждую деталь этого вывода. Важно лишь заметить, что два бинарных файла выглядят очень по-разному, хотя наш код на Rust почти такой же.
Главное отличие в том, что в нашем проекте не хватает некоторых важных секций: .text, .rodata, .data и .bss. Обычно их создаёт компоновщик:
.text: здесь находятся сами инструкции программы (код).rodata: данные только для чтения, например константы.data: инициализированные глобальные и статические переменные.bss: неинициализированные глобальные и статические переменные
Сравнить их можно и командой cargo size из набора инструментов cargo-binutils.
Компоновщик (linker):
Обычно этим занимается компоновщик. Его задача — взять все части нашей программы, например скомпилированный код, код библиотек, стартовый код и данные, и объединить их в один исполняемый файл, который устройство может действительно запустить. Он также решает, куда помещать каждую часть программы в памяти: где будет код и где — глобальные переменные.
Однако компоновщик не знает автоматически структуру памяти RP2350. Нужно сообщить ему, как расположены флеш-память и ОЗУ. Это делается с помощью скрипта компоновщика. Если скрипт отсутствует или неверен, компоновщик не разместит наш код в правильных областях памяти, что и приводит к ошибке прошивки, которую мы видим.
Скрипт компоновщика
Скрипт компоновщика писать самим мы не будем. Крейт cortex-m-rt уже предоставляет основной скрипт (link.x), но он знает только о ядре Cortex-M. Он ничего не знает о конкретном микроконтроллере, который мы используем. У каждого микроконтроллера свой объём флеш-памяти, свой объём ОЗУ и своя структура памяти, а cortex-m-rt не может угадать эти значения.
Поэтому cortex-m-rt ожидает, что пользователь или крейт поддержки платы (board support crate) предоставит небольшой скрипт компоновщика под названием memory.x. Этот файл описывает структуру памяти целевого устройства.
В memory.x мы должны определить области памяти, которые есть у устройства. Минимум нужны две области: одна с именем FLASH и одна с именем RAM. Секции .text и .rodata программы размещаются в области FLASH. Секции .bss и .data, а также куча (heap) размещаются в области RAM.
Для RP2350 даташит (глава 2.2, Карта адресов) указывает, что флеш-память начинается с адреса 0x10000000, а SRAM — с адреса 0x20000000. Поэтому наш файл memory.x будет выглядеть примерно так:
MEMORY {
FLASH : ORIGIN = 0x10000000, LENGTH = 2048K
RAM : ORIGIN = 0x20000000, LENGTH = 512K
SRAM4 : ORIGIN = 0x20080000, LENGTH = 4K
SRAM5 : ORIGIN = 0x20081000, LENGTH = 4K
...
...
}
...
...
Для RP2350 в memory.x нужны и другие настройки. Писать их вручную не нужно. Вместо этого возьмём файл из репозитория примеров embassy-rp и поместим его в корень проекта.
Опция кодогенерации для компоновщика
Одного файла memory.x в папке проекта недостаточно. Также нужно убедиться, что компоновщик действительно использует скрипт, предоставляемый cortex-m-rt.
Для этого мы говорим Cargo передать компоновщику скрипт link.x. Передать аргумент в rustc можно несколькими способами: через .cargo/config.toml или через файл сборки build.rs. В быстром старте мы использовали build.rs, а сейчас применим подход с .cargo/config.toml. В этом файле обновите секцию target следующим образом:
[target.thumbv8m.main-none-eabihf]
runner = "picotool load -u -v -x -t elf" # Эту строку мы уже добавили
rustflags = ["-C", "link-arg=-Tlink.x"] # Это новая строка
Запускаем на Pico
Когда всё настроено, можно прошить программу на Pico:
cargo run --release
Фух… Мы взяли обычный проект на Rust и превратили его в прошивку no_std для Pico. Наконец-то можно увидеть, как мигает светодиод.
Ресурсы
- Everything You Never Wanted To Know About Linker Script (на английском языке)
Создание проекта на Rust для Raspberry Pi Pico в VS Code (с расширением)
Проект Rust для Pico мы уже создавали вручную и через шаблон. Теперь попробуем другой подход: расширение Raspberry Pi Pico для VS Code.
Использование расширения Pico
В Visual Studio Code найдите расширение «Raspberry Pi Pico» и убедитесь, что устанавливаете официальное: у него должен быть значок проверенного издателя и ссылка на официальный сайт Raspberry Pi. Установите это расширение.
Одной установки расширения может не хватить — всё зависит от того, что уже установлено в вашей системе. В Linux вам, скорее всего, понадобятся базовые зависимости:
sudo apt install build-essential libudev-dev
Создание проекта
Создадим проект на Rust с помощью расширения Pico в VS Code. Откройте панель Activity Bar слева и нажмите значок Pico. Затем выберите «New Rust Project».
Так как это первая настройка, расширение загрузит и установит необходимые инструменты: Pico SDK, picotool, OpenOCD, а также тулчейны ARM и RISC-V для отладки.
Структура проекта
Если проект создан успешно, вы увидите папки и файлы примерно такого вида:
Запуск программы
Теперь достаточно нажать «Run Project (USB)», чтобы прошить программу на Pico и запустить её. Не забудьте нажать кнопку BOOTSEL, когда подключаете Pico к компьютеру, иначе эта опция будет неактивна.
Когда прошивка завершится, программа сразу начнёт работать на вашем Pico. Вы должны увидеть, как встроенный светодиод мигает.
Широтно-импульсная модуляция (ШИМ)
В этом разделе мы разберём, что такое ШИМ и зачем он нужен.
Цифровой и аналоговый сигналы
Чтобы понять ШИМ, сначала нужно понять, что такое цифровой и аналоговый сигналы.
Цифровые сигналы
Цифровой сигнал имеет только два состояния: HIGH или LOW. В микроконтроллерах HIGH обычно означает полное напряжение (5 В или 3,3 В), а LOW — 0 В. Промежуточных значений нет. Представьте выключатель света, который может быть только полностью включён или полностью выключен.
Когда вы используете цифровой вывод микроконтроллера, он может выдавать только эти два значения. Если записать в вывод HIGH, он выдаст 3,3 В. Если записать LOW, он выдаст 0 В. Нельзя заставить цифровой вывод выдать 1,5 В, 2,7 В или любое другое значение между ними.
Аналоговые сигналы
Аналоговый сигнал может принимать любое значение напряжения в пределах диапазона. Вместо простого «вкл» или «выкл» он меняется непрерывно и плавно. Представьте регулятор яркости, который может установить яркость в любой точке от полностью выключено до полностью ярко, причём с бесконечным числом промежуточных положений.
Например, аналоговый сигнал может быть 0 В, 0,5 В, 1,5 В, 2,8 В, 3,1 В или любым другим значением в допустимом диапазоне. Такое плавное изменение позволяет точно управлять устройствами.
Проблема
Вот в чём трудность: большинство выводов микроконтроллера цифровые, и они могут выдавать только HIGH или LOW. Но что, если вы хотите:
уменьшить яркость светодиода до 50 % вместо простого полного включения или выключения (как в примере мигания из быстрого старта)? Или повернуть сервопривод в любое положение от 0° до 180°? Или плавно регулировать скорость вентилятора или температуру?
Нужно что-то, что работает как аналоговый выход, но у вас есть только цифровые выводы. Здесь на помощь приходит ШИМ.
Широтно-импульсная модуляция (ШИМ)
ШИМ — это широтно-импульсная модуляция (Pulse Width Modulation, PWM). Это метод, при котором цифровой сигнал быстро переключается между HIGH и LOW, чтобы получить выход, ведущий себя как аналоговое напряжение.
На изображении выше первый график показывает простой сигнал 3,3 В. Именно такой сигнал мы обычно выдаём на вывод GPIO, например чтобы полностью включить или полностью выключить светодиод, что создаёт эффект мигания.
Чтобы получить напряжение между 0 В и 3,3 В, мы не держим сигнал в HIGH всё время. Вместо этого мы многократно переключаем вывод между 0 В и 3,3 В.
Когда такое переключение происходит очень быстро, подключённое устройство не успевает отслеживать каждое отдельное изменение. Оно не видит чистых 0 В или чистых 3,3 В. Оно реагирует на то, как долго сигнал остаётся на уровне 3,3 В по сравнению с тем, сколько времени он находится на уровне 0 В.
В этом примере сигнал половину времени находится на уровне 3,3 В, а половину — на уровне 0 В. Поэтому устройство получает в среднем примерно половину напряжения, то есть около 1,65 В.
Ширина импульса и скважность
Ширина импульса — это просто то, как долго сигнал остаётся включённым, прежде чем выключиться. Она измеряется во времени, например в микросекундах или миллисекундах.
Например, если импульс имеет ширину 1 миллисекунду, сигнал остаётся в HIGH 1 миллисекунду, а затем переходит в LOW на оставшуюся часть цикла.
Скважность (duty cycle) описывает ту же идею, но иначе. Вместо времени она показывает, какую часть цикла сигнал остаётся включённым, в процентах.
Поэтому вместо того чтобы говорить, что сигнал включён 1 миллисекунду, можно сказать, что он включён 50 % времени.
Например:
- Скважность 0 % означает, что сигнал всегда LOW (в среднем 0 В).
- Скважность 50 % означает, что сигнал HIGH и LOW одинаковое время (в среднем 1,65 В в системе на 3,3 В).
- Скважность 75 % означает, что сигнал HIGH 75 % времени и LOW 25 % времени.
- Скважность 100 % означает, что сигнал всегда HIGH (3,3 В).
Изменение скважности меняет мощность, которая передаётся нагрузке, поэтому на изображении выше светодиод выглядит тусклым, средним по яркости или полностью ярким.
Пример использования 1: уменьшение яркости светодиода
Светодиод мигает так быстро, что глаз не видит отдельных импульсов включения и выключения, и вы воспринимаете только среднюю яркость. Низкая скважность делает свечение тусклым, более высокая — ярче, хотя светодиод всё время переключается между полным напряжением и нулём. В следующей главе мы сделаем это на практике.
Пример использования 2: управление сервоприводом
Сервопривод определяет угол поворота по ширине импульса. Он ожидает импульс каждые 20 миллисекунд, и ширина импульса — примерно 1 мс для 0°, 1,5 мс для 90° и 2 мс для 180° — указывает сервоприводу, в какую сторону двигаться.
Период и частота
К этому моменту у вас должно быть базовое представление о ширине импульса и скважности. Далее рассмотрим ещё два важных понятия, которые используются в ШИМ: период и частоту.
Эти два понятия описывают, как часто повторяется сигнал ШИМ.
Период
Период — это общее время, за которое завершается один полный цикл «включено — выключено». Иными словами, это время от одной точки сигнала до того момента, когда та же точка снова появляется в следующем цикле.
Например:
- В верхней части схемы один полный цикл занимает 1 секунду, поэтому период равен 1 секунде. Это медленно меняющийся сигнал.
- В нижней части схемы один полный цикл занимает 0,2 секунды, поэтому период равен 0,2 секунды. Это быстрее меняющийся сигнал.
Частота
Частота показывает, сколько полных циклов происходит за одну секунду. Измеряется она в герцах (Гц, Hz).
Например:
- 1 Гц = 1 цикл в секунду (как в верхней части схемы)
- 5 Гц = 5 циклов в секунду (как в нижней части схемы)
Связь
Частота сигнала и его период обратно пропорциональны друг другу.
\[ \text{Frequency (Hz)} = \frac{1}{\text{Period (s)}} \]
Это означает:
- Когда период уменьшается, частота растёт
- Когда период увеличивается, частота падает
Всё ещё непонятно? Представьте так:
Представьте, что вы с другом считаете от 0 до 99 снова и снова.
Вы считаете быстро и быстро заканчиваете один круг. Ваш друг считает медленно и тратит на тот же круг гораздо больше времени. Вы оба называете одни и те же числа. Различается только скорость.
Время, которое уходит на один круг, — это период. Насколько часто вы повторяете круги, — это частота. Счёт быстрее означает более короткий период и более высокую частоту. Счёт медленнее означает более длинный период и более низкую частоту.
Примеры
Итак, если период равен 1 секунде, то частота будет 1 Гц.
\[ 1 \text{ Hz} = \frac{1 \text{ cycle}}{1 \text{ second}} = \frac{1}{1 \text{ s}} \]
Например, если период равен 20 мс (0,02 с), частота будет 50 Гц.
\[ \text{Frequency} = \frac{1}{20 \text{ ms}} = \frac{1}{0.02 \text{ s}} = 50 \text{ Hz} \]
Симуляция
Ниже — интерактивная симуляция. Используйте ползунки, чтобы менять скважность и частоту, и наблюдайте, как меняются ширина импульса и яркость светодиода. Верхняя часть прямоугольного сигнала соответствует состоянию «включено» (HIGH), нижняя — «выключено» (LOW). Ширина высокого участка меняется вместе со скважностью.
Если в симуляции плавно менять скважность от низкой к высокой и обратно, вы заметите, что светодиод как будто меняет яркость.
Периферия ШИМ в RP2350
В RP2350 есть периферия ШИМ с 12 генераторами, которые называются слайсами (slice). Каждый слайс содержит два выходных канала (A и B), то есть всего 24 канала ШИМ. Подробные характеристики см. на странице 1077 даташита RP2350.
Давайте быстро посмотрим на ключевые понятия.
Генератор ШИМ (слайс)
Слайс — это аппаратный блок, который генерирует сигналы ШИМ. Каждый из 12 слайсов (PWM0–PWM11) — независимый блок синхронизации со своим 16-битным счётчиком, регистрами сравнения, настройками управления и делителем тактовой частоты. Эта независимость означает, что каждый слайс можно настроить на разную частоту и разрешение.
Канал
Каждый слайс содержит два выходных канала: канал A и канал B. Оба канала используют один и тот же счётчик, поэтому работают на одной частоте и синхронизированы. Однако у каждого канала свой регистр сравнения, что позволяет независимо управлять скважностью. Это даёт возможность получить от одного слайса два связанных, но разных сигнала ШИМ.
Соответствие каналов ШИМ выводам GPIO
Каждый вывод GPIO подключён к определённому слайсу и каналу. Полную таблицу соответствия вы найдёте на странице 1078 даташита RP2350. Например, GP25 (вывод встроенного светодиода) соответствует слайсу ШИМ 4, каналу B, что обозначается как 4B.
Инициализируем периферию ШИМ и получаем доступ ко всем слайсам:
#![allow(unused)]
fn main() {
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
}
Получаем ссылку на слайс ШИМ 4 для настройки:
#![allow(unused)]
fn main() {
let pwm = &mut pwm_slices.pwm4;
}
GPIO в ШИМ
Я создал небольшую форму, которая помогает понять, какой вывод GPIO соответствует какому каналу ШИМ, и генерирует пример кода.
Фазово-корректный режим
В стандартном ШИМ (быстром ШИМ) счётчик считает от 0 до TOP, а затем сразу сбрасывается в 0. Это создаёт асимметричные фронты, при которых выход меняется в разных точках цикла.
Фазово-корректный ШИМ считает от 0 до TOP, а затем обратно до 0, формируя треугольную форму сигнала. Выход переключается симметрично: один раз вверх и один раз вниз. Это даёт импульсы, центрированные относительно периода, с фронтами, которые зеркально отражают друг друга. Это снижает электромагнитные помехи и делает переходы более плавными. Компромисс в том, что фазово-корректный режим работает на половине частоты стандартного ШИМ при том же значении TOP. Подробнее об этом см. далее в разделе Фазово-корректный режим.
Настроим PWM4 на работу в фазово-корректном режиме, чтобы получить более плавные переходы выхода.
Embassy
#![allow(unused)]
fn main() {
let mut pwm_config: Config = Default::default();
pwm_config.phase_correct = true;
let mut pwm_pin = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, pwm_config);
}
rp-hal
#![allow(unused)]
fn main() {
pwm.set_ph_correct();
}
Получаем изменяемую ссылку на канал B слайса PWM4 и направляем его выход на вывод GPIO 25.
#![allow(unused)]
fn main() {
let channel = &mut pwm.channel_b;
channel.output_to(pins.gpio25);
}
Плавное изменение яркости светодиода
В этом разделе мы научимся создавать эффект плавного изменения яркости (то есть постепенного уменьшения и увеличения яркости) светодиода с помощью Raspberry Pi Pico 2. Сначала мы будем менять яркость встроенного светодиода, подключённого к выводу GPIO 25 (согласно даташиту).
Чтобы светодиод тускнел, мы используем метод ШИМ (Pulse Width Modulation, широтно-импульсная модуляция). Подробности см. во введении в разделе ШИМ.
Мы постепенно увеличиваем скважность ШИМ, чтобы повысить яркость, а затем постепенно уменьшаем её, чтобы снизить яркость светодиода. Так и создаётся эффект плавного изменения яркости.
Глаз
« Подойдите поближе… Ещё ближе…
Чем больше вам кажется, что вы видите… тем легче вас обмануть…
Ведь что такое зрение? … Вы смотрите, но на самом деле вы фильтруете, интерпретируете, ищете смысл… »
Вот в чём фокус: когда переключение происходит очень быстро, глаз не успевает за ним. Вместо мигания кажется, что яркость просто меняется! Чем дольше светодиод включён, тем он ярче, а чем меньше времени он горит, тем тусклее он выглядит. Это похоже на то, как мы обманываем мозг, заставляя его думать, что светодиод плавно гаснет или разгорается.
Основная логика
В нашей программе мы в первом цикле постепенно увеличиваем скважность от низкого значения до высокого, с небольшой задержкой между изменениями. Так создаётся эффект нарастания яркости. Затем запускаем другой цикл, который уменьшает скважность от высокой к низкой, опять же с небольшой задержкой. Так создаётся эффект затухания.
Можно использовать встроенный светодиод, а если хотите лучше увидеть изменение яркости, подключите внешний светодиод. Только не забудьте обновить слайс и канал ШИМ, чтобы они соответствовали выводу GPIO, который вы используете.
Симуляция: плавное изменение яркости светодиода с ШИМ
Ниже симуляция, которая показывает эффект плавного изменения яркости светодиода в зависимости от скважности и от высокого и низкого участков прямоугольного сигнала. Скорость по умолчанию я сделал очень низкой, чтобы её было легко и не утомительно наблюдать. Чтобы запустить анимацию, нажмите кнопку «Запустить анимацию». Скорость можно увеличить, уменьшив задержку, и понаблюдать за изменениями.
Плавное изменение яркости светодиода на Raspberry Pi Pico с Embassy
Создадим эффект плавного изменения яркости светодиода с помощью ШИМ на Raspberry Pi Pico с Embassy.
Создание проекта с помощью cargo-generate
К этому моменту вы уже должны знать эти шаги. Мы используем команду cargo-generate с нашим шаблоном и, когда программа попросит, выбираем Embassy в качестве HAL.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Обновление импортов
Добавьте импорт ниже, чтобы подключить типы ШИМ:
#![allow(unused)]
fn main() {
use embassy_rp::pwm::{Pwm, SetDutyCycle};
}
Инициализация ШИМ
Настроим ШИМ для светодиода. Используйте первую строку для встроенного светодиода или раскомментируйте вторую, если хотите использовать внешний светодиод на GPIO 16.
#![allow(unused)]
fn main() {
// Для встроенного светодиода
let mut pwm = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, Default::default());
// Для внешнего светодиода, подключённого к GPIO 16
// let mut pwm = Pwm::new_output_a(p.PWM_SLICE0, p.PIN_16, Default::default());
}
Основная логика
В главном цикле мы создаём эффект затухания и нарастания, увеличивая скважность от 0 до 100 процентов, а затем уменьшая её обратно. Небольшая задержка между шагами делает изменение яркости плавным. Вы можете изменить задержку и посмотреть, как меняется скорость изменения яркости.
#![allow(unused)]
fn main() {
loop {
for i in 0..=100 {
Timer::after_millis(8).await;
let _ = pwm.set_duty_cycle_percent(i);
}
for i in (0..=100).rev() {
Timer::after_millis(8).await;
let _ = pwm.set_duty_cycle_percent(i);
}
Timer::after_millis(500).await;
}
}
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_rp::pwm::{Pwm, SetDutyCycle};
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// Для встроенного светодиода
let mut pwm = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, Default::default());
// Для внешнего светодиода, подключённого к GPIO 16
// let mut pwm = Pwm::new_output_a(p.PWM_SLICE0, p.PIN_16, Default::default());
loop {
for i in 0..=100 {
Timer::after_millis(8).await;
let _ = pwm.set_duty_cycle_percent(i);
}
for i in (0..=100).rev() {
Timer::after_millis(8).await;
let _ = pwm.set_duty_cycle_percent(i);
}
Timer::after_millis(500).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"led-dimming"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку led-dimming:
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/led-dimming
Программа плавного изменения яркости светодиода с RP HAL
rp-hal — это реализация Embedded-HAL для микроконтроллеров серии RP и альтернатива фреймворку Embassy для Pico.
Этот пример взят из репозитория rp235x-hal (там также есть примеры, не ограничивающиеся мигающим светодиодом):
https://github.com/rp-rs/rp-hal/tree/main/rp235x-hal-examples
Основной код
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
// Трейт для ШИМ
use embedded_hal::pwm::SetDutyCycle;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// Внешний высокоскоростной кварц на плате Raspberry Pi Pico 2 работает на 12 МГц.
/// Измените значение, если на вашей плате другая частота
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
/// Минимальное значение ШИМ (то есть яркости светодиода), которое нам нужно
const LOW: u16 = 0;
/// Максимальное значение ШИМ (то есть яркости светодиода), которое нам нужно
const HIGH: u16 = 25000;
#[hal::entry]
fn main() -> ! {
// Получаем наши синглтон-объекты
let mut pac = hal::pac::Peripherals::take().unwrap();
// Настраиваем драйвер сторожевого таймера — он нужен для настройки тактирования
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Настраиваем тактирование
//
// По умолчанию генерируется системная частота 125 МГц
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// Блок SIO с однотактным доступом управляет нашими выводами GPIO
let sio = hal::Sio::new(pac.SIO);
// Настраиваем выводы в соответствии с их функциями на этой плате
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// Инициализируем ШИМ
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
// Настраиваем PWM4
let pwm = &mut pwm_slices.pwm4;
pwm.set_ph_correct();
pwm.enable();
// Выводим канал B слайса PWM4 на GPIO 25
let channel = &mut pwm.channel_b;
channel.output_to(pins.gpio25);
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
loop {
for i in LOW..=HIGH {
timer.delay_us(8);
let _ = channel.set_duty_cycle(i);
}
for i in (LOW..=HIGH).rev() {
timer.delay_us(8);
let _ = channel.set_duty_cycle(i);
}
timer.delay_ms(500);
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект с мигающим светодиодом, который я создал, и перейти в папку led-dimming, чтобы запустить эту версию программы:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/led-dimming
Кнопки
Теперь, когда мы умеем заставлять светодиод мигать, научимся считывать сигнал с кнопки. Это позволит взаимодействовать с Raspberry Pi Pico и заставит программы реагировать на наши действия.
Кнопка — это небольшой тактовый переключатель. Такие кнопки есть в большинстве наборов для начинающих. Когда вы её нажимаете, два внутренних контакта соединяются и цепь замыкается. Когда отпускаете, контакты разъединяются и цепь размыкается. Программа может считывать это состояние (разомкнуто или замкнуто) и действовать в зависимости от него.
Как работает тактовая кнопка
У тактовой кнопки четыре ножки, расположенные парами. Если смотреть на кнопку сверху, ножки образуют прямоугольник. Две ножки с каждой стороны кнопки электрически соединены внутри.
Позже я обновлю этот раздел и добавлю более понятную схему, которая явнее покажет внутренние соединения. А пока этой иллюстрации достаточно, чтобы понять принцип. Светлая линия показывает, что выводы слева соединены между собой, то же самое справедливо для выводов справа. Когда кнопка нажата, левая и правая стороны соединяются.
Подключение кнопки к Pico
Подключите одну сторону кнопки к земле, а другую — к выводу GPIO (например, GPIO 15). Когда кнопка нажата, обе стороны соединяются внутри, и вывод GPIO 15 притягивается к низкому уровню. В коде мы можем проверить, притянут ли вывод к низкому уровню, и запустить действия в зависимости от этого.
Стоп. Что происходит, когда кнопка не нажата? Какой уровень напряжения читает сейчас вывод GPIO? Чтобы логика работала, вывод должен находиться в высоком состоянии, тогда низкое состояние будет означать нажатие. Но без дополнительных элементов в схеме вывод GPIO окажется в так называемом плавающем состоянии. Это ненадёжно: вывод может случайным образом переключаться между высоким и низким уровнями, даже когда кнопка не нажата. Как это исправить? Посмотрим в следующем разделе.
Подтягивающие резисторы: pull-up и pull-down
Работая с кнопками, переключателями и другими цифровыми входами Raspberry Pi Pico, вы быстро столкнётесь с любопытной проблемой: что происходит, когда к входному выводу ничего не подключено? Ответ может удивить: вывод становится «плавающим» (floating), подхватывает электрические помехи и выдаёт случайные, непредсказуемые значения. Здесь на помощь приходят подтягивающие резисторы — pull-up и pull-down.
Проблема плавающего вывода
Представьте, что вы подключаете кнопку напрямую к выводу GPIO на Pico. Когда кнопка нажата, она соединяет вывод с землёй (0 В). Когда кнопка отпущена, можно ожидать, что вывод будет читаться как HIGH, но это не так. Вместо этого вывод оказывается отключённым от всего. Он находится в неопределённом плавающем состоянии, работая как антенна, которая собирает электрические помехи от соседних цепей, от вашей руки или даже от радиоволн в воздухе.
Из-за этого плавающего состояния код будет читать случайные значения, и кнопка может «нажиматься» сама по себе или вести себя непредсказуемо. Нам нужен способ задать выводу предсказуемое состояние по умолчанию.
Кстати, кнопку можно подключить и наоборот: одну сторону — к 3,3 В вместо земли (хотя для RP2350 я бы этого не рекомендовал, и вскоре объясню почему). Но проблема остаётся той же. Когда кнопка нажата, вывод соединяется с высоким уровнем. Когда отпущена, можно ожидать низкого уровня, но вывод снова оказывается в плавающем состоянии.
Что такое подтягивающие резисторы pull-up и pull-down?
Подтягивающие резисторы — простые решения, которые гарантируют, что вывод всегда имеет известный уровень напряжения, даже когда ничто другое его не задаёт.
Pull-up резистор: подключает вывод к положительному напряжению (3,3 В на Pico) через резистор. Это «подтягивает» вывод к HIGH по умолчанию. Когда вы нажимаете кнопку, которая соединяет вывод с землёй, вывод читается как LOW.
Pull-down резистор: подключает вывод к земле (0 В) через резистор. Это «подтягивает» вывод к LOW по умолчанию. Когда вы нажимаете кнопку, которая соединяет вывод с 3,3 В, вывод читается как HIGH.
Как работает pull-up резистор
Рассмотрим типичную схему кнопки с pull-up резистором:
Когда кнопка не нажата, ток течёт через резистор к выводу GPIO и удерживает его на уровне 3,3 В (HIGH). Когда вы нажимаете кнопку, создаётся прямой путь к земле. Поскольку электричество выбирает путь наименьшего сопротивления, ток течёт через кнопку на землю, а не к выводу, и вывод читается как LOW.
Как работает pull-down резистор
Pull-down резистор работает в противоположном направлении:
Когда кнопка не нажата, вывод GPIO соединён с землёй через резистор и читается как LOW. При нажатии кнопка напрямую соединяет вывод с 3,3 В, и вывод читается как HIGH.
Внутренние подтягивающие резисторы
На каждом выводе GPIO Raspberry Pi Pico есть встроенные pull-up и pull-down резисторы. Для простых входов с кнопками внешние резисторы добавлять не нужно: их можно включить программно.
Использование подтягивающих резисторов в embedded Rust
Посмотрим, как настроить внутренние подтягивающие резисторы при подключении кнопки к Pico.
Как видно на схеме, когда мы включаем внутренний pull-up резистор, вывод GPIO по умолчанию подтянут к 3,3 В. Резистор находится внутри самого чипа Pico, поэтому никакие внешние компоненты не нужны — только кнопка между выводом GPIO и землёй.
Вот как это настроить в коде:
#![allow(unused)]
fn main() {
let button = Input::new(p.PIN_16, Pull::Up);
// Читаем состояние кнопки
if button.is_low() {
// Кнопка нажата (подключена к земле)
// Выполнить действие
}
}
Когда pull-up резистор включён, вывод GPIO по умолчанию подтянут к высокому напряжению. Когда вы нажимаете кнопку, она соединяет вывод с землёй и переводит его в LOW. Итоговая логика такая: кнопка не нажата = HIGH, кнопка нажата = LOW.
Настройка кнопки с pull-down резистором
Вот похожий код, но на этот раз мы используем внутренний pull-down резистор. С pull-down вывод по умолчанию притянут к LOW. Когда кнопка нажата и соединяет вывод с 3,3 В, он читается как HIGH.
#![allow(unused)]
fn main() {
let button = Input::new(p.PIN_16, Pull::Down);
// Читаем состояние кнопки
if button.is_high() {
// Кнопка нажата (подключена к 3,3 В)
// Выполнить действие
}
}
Important
В исходном чипе RP2350, выпущенном в 2024 году, есть аппаратная ошибка (RP2350-E9), которая влияет на внутренние pull-down резисторы.
Из-за неё вывод GPIO может читаться как HIGH, даже когда кнопка не нажата, — то есть наоборот по отношению к ожидаемому. Подробнее об этой проблеме — в статье Саймона Монка (на английском языке).
Ошибка исправлена в более новой ревизии чипа RP2350 A3. Если у вас более старый чип, не используйте
Pull::Downв коде. Вместо этого можно установить внешний pull-down резистор и задать в кодеPull::None.
С включённым pull-down резистором кнопка должна соединять вывод с 3,3 В при нажатии. Вывод читается как LOW, когда кнопка не нажата, и как HIGH, когда нажата.
Использование плавающего входа
Можно также настроить вывод без внутреннего подтягивающего резистора:
#![allow(unused)]
fn main() {
let button = Input::new(p.PIN_16, Pull::None);
}
Однако, как мы обсуждали выше, плавающие входы ненадёжны для кнопок, потому что они подхватывают электрические помехи и читают случайные значения. Такой вариант полезен, только когда в схеме есть внешний pull-up или pull-down резистор либо когда подключённое устройство активно задаёт вывод в HIGH или LOW (как некоторые датчики).
Светодиод по нажатию кнопки
Создадим простой проект, который включает светодиод, когда нажата кнопка. Можно использовать внешний светодиод или встроенный. Просто измените номер вывода светодиода в коде, чтобы он соответствовал тому, который вы используете.
Начнём с создания нового проекта с помощью cargo generate и нашего шаблона.
В терминале введите:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Кнопка как вход
До сих пор мы использовали структуру Output, потому что Pico отправлял сигналы светодиоду. На этот раз Pico будет получать сигнал от кнопки, поэтому настроим вывод как Input.
#![allow(unused)]
fn main() {
let button = Input::new(p.PIN_15, Pull::Up);
}
Мы подключили одну сторону кнопки к GPIO 15, а другую — к земле. Это значит, что при нажатии кнопки вывод притягивается к низкому уровню (LOW). Как мы обсуждали ранее, без подтягивающего резистора вход окажется в плавающем состоянии и будет выдавать ненадёжные значения. Поэтому мы включаем внутренний подтягивающий резистор (pull-up), чтобы вывод по умолчанию оставался в высоком уровне (HIGH).
Светодиод как выход
Настраиваем вывод светодиода как выход, начиная с низкого уровня (выключено). Если вы используете внешний светодиод, раскомментируйте первую строку для GPIO 16. Если используете встроенный светодиод Pico, используйте GPIO 25, как показано. Главное — чтобы ваша схема соответствовала выбранному выводу.
#![allow(unused)]
fn main() {
// let mut led = Output::new(p.PIN_16, Level::Low);
let mut led = Output::new(p.PIN_25, Level::Low);
}
Главный цикл
Теперь в цикле мы постоянно проверяем, нажата ли кнопка, — то есть находится ли вывод в низком состоянии. Между проверками добавляем небольшую задержку 5 миллисекунд, чтобы не перегружать систему. Когда кнопка показывает LOW (нажата), мы переводим вывод светодиода в HIGH, чтобы включить его, и ждём 3 секунды, чтобы успеть его заметить. Задержку можно изменить по своему усмотрению.
#![allow(unused)]
fn main() {
loop {
if button.is_low() {
defmt::info!("Button pressed");
led.set_high();
Timer::after_secs(3).await;
} else {
led.set_low();
}
Timer::after_millis(5).await;
}
}
Note
Дребезг контактов (debounce): если уменьшить задержку, можно заметить, что одно нажатие иногда даёт несколько срабатываний. Это называется «дребезгом кнопки». Когда вы нажимаете физическую кнопку, металлические контакты внутри на мгновение бьются друг о друга, создавая несколько электрических сигналов за несколько миллисекунд. В этом примере 3-секундная задержка светодиода скрывает эффект дребезга, но в приложениях, где нужно точно считать отдельные нажатия, потребуется логика устранения дребезга (debouncing).
Мы также выводим сообщение «Button pressed» с помощью defmt. Если вы используете отладочный зонд, чтобы увидеть эти логи в терминале, выполните команду cargo embed --release.
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp::block::ImageDef;
use embassy_rp::gpio::Pull;
use embassy_rp::{
self as hal,
gpio::{Input, Level, Output},
};
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let button = Input::new(p.PIN_15, Pull::Up);
// let mut led = Output::new(p.PIN_16, Level::Low);
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
if button.is_low() {
defmt::info!("Button pressed");
led.set_high();
Timer::after_secs(3).await;
} else {
led.set_low();
}
Timer::after_millis(5).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"button"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать (или просто посмотреть) проект, который я создал, и перейти в папку button.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/button
TOP и делитель ШИМ
Иногда нужно задать точную частоту выходного сигнала ШИМ. Например, любительские сервоприводы обычно работают на 50 Гц. Однако ни embassy-rp, ни rp-hal не предоставляют простого способа задать частоту напрямую (насколько мне известно). Вместо этого нужно работать с настройками самой аппаратной части ШИМ. Крейт embassy-rp позволяет настроить ШИМ через структуру Config, в которой есть различные поля. Мы сосредоточимся на параметрах top и divider. Разберёмся, как они работают.
Как работает ШИМ внутри RP2350
Слайс ШИМ в RP2350 управляется тактовым сигналом. Этот сигнал гораздо быстрее, чем ШИМ-сигнал, который нужен вам на выводе. Аппаратная часть ШИМ использует счётчик, который многократно считает от 0 до TOP, а затем начинает снова с 0. Регистр TOP определяет, до какого значения доходит счётчик, прежде чем начать заново.
Каждый раз, когда счётчик достигает верхнего значения, он возвращается к нулю и начинает снова. Один полный счёт от нуля до верхнего значения — это один период ШИМ.
Если счётчик увеличивается очень быстро, частота ШИМ будет высокой. Если медленнее — частота будет ниже. Здесь в дело вступает делитель.
Для генерации ШИМ в RP2350 используются два основных 16-битных регистра: регистр захвата/сравнения (CC, Capture/Compare) и регистр TOP. Регистр CC определяет, сколько времени выходной импульс остаётся в высоком состоянии в каждом цикле (скважность). Регистр TOP определяет, сколько времени занимает каждый полный цикл (период).
Для простого объяснения пусть значение TOP равно 9, а значение CC равно 3. Счётчик будет считать от 0 до 9, как показано на схеме. Сигнал остаётся в высоком состоянии до тех пор, пока счётчик не достигнет значения CC. После этого в данном цикле он остаётся в низком состоянии. Не торопитесь и постарайтесь понять диаграмму выше. На диаграмме каждый отсчёт нарисован ступенькой (красным цветом). Импульс остаётся в высоком состоянии (зелёным цветом), пока счётчик не совпадёт со значением CC. Как видно, начиная с 3 и до 9 импульс переходит в низкое состояние.
Как TOP управляет частотой
Счётчик ШИМ считает от 0 до значения TOP, а затем возвращается к 0. Один полный цикл счёта (от 0 до TOP) даёт один период ШИМ. Частота — это просто количество таких полных циклов в секунду.
Системная частота тактирования RP2350 равна 150 МГц (150 миллионов циклов в секунду). Если пока не учитывать делитель и оставить его равным 1, счётчик увеличивается на единицу за каждый такт системной частоты. Важно понимать, как TOP влияет на частоту:
- Большее значение TOP => счётчику нужно больше шагов, чтобы завершить один цикл => ниже частота ШИМ
- Меньшее значение TOP => счётчику нужно меньше шагов, чтобы завершить один цикл => выше частота ШИМ
Рассмотрим несколько конкретных примеров, чтобы это стало понятнее.
Пример 1: TOP = 149
Счётчик считает: 0, 1, 2, 3, … 148, 149, а затем возвращается к 0.
Получается 150 отсчётов за цикл (от 0 до 149 включительно).
При системной частоте 150 МГц частота ШИМ равна:
Note
Пока не полагайтесь на эту упрощённую формулу. Она недостаточно точна, потому что нужно учесть ещё один множитель — делитель тактовой частоты. Мы приводим её здесь только для того, чтобы понять базовую связь между TOP и частотой.
\[ f_{PWM} = \frac{150,000,000}{150} = 1,000,000 \text{ Hz (1 MHz)} \]
Пример 2: TOP = 1,499
Счётчик проходит 1 500 значений (от 0 до 1 499).
Частота ШИМ:
\[ f_{PWM} = \frac{150,000,000}{1,500} = 100,000 \text{ Hz (100 kHz)} \]
Почему одного TOP недостаточно
Регистр TOP имеет ширину 16 бит, поэтому максимальное значение, которое он может хранить, — 65 535. Это значит, что счётчик проходит 65 536 шагов, прежде чем вернуться к нулю. Если применить это максимальное значение TOP к тому же расчёту, что и в примерах выше, получим частоту ШИМ около 2 288 Гц.
Это самая низкая частота, которой можно добиться только с помощью TOP при системной частоте 150 МГц.
Для многих реальных задач этого всё равно слишком много. Например, любительским сервоприводам нужна частота ШИМ около 50 Гц. Используя только TOP, невозможно замедлить ШИМ настолько, чтобы попасть в этот диапазон.
Чтобы решить эту задачу, RP2350 предоставляет дополнительный инструмент: делитель тактовой частоты ШИМ. Делением тактового сигнала, который подаётся на счётчик ШИМ, можно получить гораздо более низкие частоты, включая нужные сервоприводам 50 Гц.
Делитель тактовой частоты
Делитель замедляет тактовый сигнал, который управляет счётчиком ШИМ. Вместо счёта на полной системной частоте 150 МГц счётчик увеличивается медленнее, в зависимости от значения делителя.
Когда делитель увеличивается, каждый отсчёт занимает больше времени. Это значит, что счётчику требуется больше времени, чтобы пройти от 0 до TOP, и частота ШИМ становится ниже. Именно это позволяет получить низкие частоты, например 50 Гц, которые невозможны при использовании только TOP.
Прежде чем переходить к настоящей формуле из даташита RP2350, рассмотрим ещё один упрощённый пример.
Допустим, мы задаём TOP равным 1 499, так что счётчик проходит 1 500 шагов (от 0 до 1 499). Теперь, если задать делитель тактовой частоты равным 10, каждый шаг будет занимать 10 тактов системной частоты вместо одного.
\[ f_{PWM} = \frac{150{,}000{,}000}{1{,}500 \times 10} = 10{,}000\ \text{Hz (10 kHz)} \]
Без делителя для того же значения TOP мы получили бы 100 кГц. Теперь при делителе 10 получаем 10 кГц — в десять раз медленнее. Это показывает, как делитель позволяет замедлять частоту ШИМ.
Фазово-корректный режим
Потерпите немного. Прежде чем приводить настоящую формулу, нужно понять ещё одно важное понятие.
До сих пор мы считали, что счётчик ШИМ считает в одном направлении: от 0 до TOP, а затем сразу возвращается к 0. Но ШИМ может работать и иначе. В фазово-корректном режиме счётчик ведёт себя по-другому, и это напрямую влияет на частоту ШИМ.
В фазово-корректном режиме счётчик ШИМ не возвращается к нулю при достижении TOP. Вместо этого он считает от 0 до TOP, а затем обратно от TOP до 0. Получается симметричный шаблон счёта «вверх-вниз».
Из-за этого один полный цикл ШИМ включает и счёт вверх, и счёт вниз. Другими словами, счётчику требуется примерно вдвое больше времени, чтобы завершить полный цикл, по сравнению с обычным режимом счёта только вверх.
Важный вывод прост: включение фазово-корректного режима вдвое уменьшает частоту ШИМ при тех же значениях TOP и делителя.
Этот режим часто используют, когда нужны более чистые и симметричные сигналы ШИМ, например для управления моторами.
Формула частоты ШИМ
Даташит RP2350 точно определяет, как вычисляется период ШИМ. Период показывает, сколько тактов системной частоты требуется для одного полного цикла ШИМ.
Период в тактах вычисляется по формуле:
\[ \text{period} = (\text{TOP} + 1) \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right) \]
Чтобы определить выходную частоту по системной частоте тактирования, используйте формулу:
\[ f_{PWM} = \frac{f_{sys}}{\text{period}} = \frac{f_{sys}}{(\text{TOP} + 1) \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right)} \]
Где:
- \( f_{PWM} \) — выходная частота ШИМ.
- \( f_{sys} \) — системная частота тактирования. Для Pico 2 она равна 150 МГц.
Делитель и дробная часть
В формуле, которую мы рассматривали выше, есть одна важная часть, которую мы пока не объяснили: DIV_FRAC. Она задаёт дробную часть делителя тактовой частоты в RP2350.
Делитель тактовой частоты RP2350 состоит из двух частей. DIV_INT — целая часть, которая задаёт целое деление. DIV_FRAC — дробная часть, которая позволяет точнее управлять коэффициентом деления. Вместе они дают возможность замедлять счётчик ШИМ точнее, чем при использовании только целого делителя. Одно важное правило: если DIV_INT равен 0, нельзя задавать никакие биты DIV_FRAC.
Ручной расчёт TOP
В этом разделе мы вручную вычислим значение TOP для заданной частоты ШИМ. Этот метод требует перебора различных значений делителя и проверки, попадает ли получившееся значение TOP в допустимый диапазон.
В следующем разделе есть более удобный способ: с помощью кода на Rust или формы-калькулятора можно автоматически вычислить и TOP, и делитель. Однако ручной метод полезен уже сейчас, потому что помогает понять, как тактовая частота, делитель и значение TOP связаны друг с другом.
Значение TOP должно лежать в диапазоне от 0 до 65534. Хотя TOP хранится в 16-битном беззнаковом регистре, если установить его в 65535, нельзя получить настоящую скважность 100 процентов. Это происходит потому, что для скважности 100 процентов регистр сравнения скважности (CC) должен быть равен TOP + 1, а сам CC также имеет ширину 16 бит. Если держать TOP не выше 65534, значение TOP + 1 всё ещё помещается в регистр CC, и весь диапазон скважности от 0 до 100 процентов представляется корректно.
Чтобы TOP оставался в этих пределах, мы будем выбирать значения делителя, являющиеся степенями двойки, например 8, 16, 32 или 64. Такой подход работает не для каждой возможной частоты. В некоторых случаях понадобятся другие целые значения или даже дробные делители. Чтобы всё было просто, начнём с этого подхода.
В качестве примера рассчитаем значения, необходимые для генерации сигнала ШИМ 50 Гц для сервопривода.
Формула частоты ШИМ
Даташит RP2350 определяет частоту ШИМ так:
\[ f_{PWM} = \frac{f_{sys}}{\text{period}} = \frac{f_{sys}}{(\text{TOP} + 1) \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right)} \]
Вот выведенная формула для получения TOP по целевой частоте:
\[ \text{TOP} = \frac{f_{sys}} {f_{PWM} \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right)} - 1 \]
Где:
- \( f_{PWM} \) — желаемая частота ШИМ.
- \( f_{sys} \) — системная частота тактирования. Для Pico 2 она равна 150 МГц.
Мы не будем использовать фазово-корректный режим и не будем использовать дробную часть делителя, поэтому упростим формулу ещё больше:
\[ \text{TOP} = \frac{f_{sys}} {f_{PWM} \times \text{DIV_INT}} - 1 \]
TOP для 50 Гц
Нам нужна частота ШИМ 50 Гц. Чтобы её получить, подставим системную частоту тактирования, целевую частоту и выбранную целую часть делителя и получим следующее значение TOP:
\[ \text{top} = \frac{150,000,000}{50 \times 64} - 1 \]
\[ \text{top} = \frac{150,000,000}{3,200} - 1 \]
\[ \text{top} = 46,875 - 1 \]
\[ \text{top} = 46,874 \]
Вы можете поэкспериментировать с другими значениями делителя (в том числе дробными) и соответствующими значениями top.
Калькулятор TOP и делителя для целевой частоты
В MicroPython можно задать частоту ШИМ напрямую, без ручного расчёта значений TOP и делителя. Внутри MicroPython вычисляет эти значения по целевой частоте и системной частоте тактирования.
Я хотел проверить, есть ли что-то подобное в Rust. Обсуждая это в Matrix-чате rp-rs, 9names перенёс на Rust соответствующий код на C из MicroPython, который вычисляет значения TOP и делителя. Этот код принимает на вход целевую частоту и частоту источника тактирования и возвращает соответствующие значения TOP и делителя. Реализацию можно найти здесь.
Этот Rust-код можно использовать напрямую в своём проекте. Я скомпилировал тот же код в WASM и собрал вокруг него небольшую форму, чтобы вы могли попробовать его здесь.
По умолчанию частота источника тактирования равна системной частоте RP2350 — 150 МГц, а целевая частота — 50 Гц. При необходимости можно изменить оба значения.
Расчёт TOP и делителя
Результат
Note
Делитель показан в виде целой и дробной частей.
Дробная часть — это не десятичная дробь. Она представляет собой дробь с фиксированной точкой из 4 бит.
Эффективный делитель вычисляется так:
DIV = DIV_INT + (DIV_FRAC / 16)Например,
DIV_INT = 45иDIV_FRAC = 13означают, что делитель равен45 + 0.8125, а не45.13.
Код
Если вы используете rp-hal, целую и дробную части задают отдельно:
#![allow(unused)]
fn main() {
pwm.set_top(65483);
pwm.set_div_int(45);
pwm.set_div_frac(13);
}
Если вы используете embassy-rp, обе части объединяются в одном поле делителя внутри структуры Config. Это не значение с плавающей запятой: внутри используется число с фиксированной точкой, которое хранит целую и дробную части вместе. Если вы не знакомы с числами с фиксированной точкой, я написал отдельную статью, где они подробно разобраны, — её можно прочитать здесь.
Если нужен только целый делитель, можно просто передать значение типа u8:
#![allow(unused)]
fn main() {
let mut servo_config: PwmConfig = Default::default();
servo_config.top = 46_874;
servo_config.divider = 64.into();
}
Если нужна и дробная часть, необходимо добавить крейт fixed в зависимости и задать делитель с помощью типа с фиксированной точкой:
#![allow(unused)]
fn main() {
let mut servo_config: PwmConfig = Default::default();
servo_config.top = 65483;
servo_config.divider = FixedU16::<U4>::from_num(45.8125);
// или
// servo_config.divider = fixed::types::U12F4::from_num(45.8125);
}
Сервоприводы
Сервоприводы позволяют точно управлять положением. Их можно использовать, чтобы направлять камеру, двигать части небольшого робота или автоматически управлять переключателями. Они отличаются от обычных моторов постоянного тока. Вместо непрерывного вращения сервопривод поворачивается на определённый угол и остаётся в этом положении.
В этой главе мы заставим сервопривод проходить три положения: 0°, 90° и 180°.
Используемое оборудование
Для этой главы понадобятся следующие компоненты:
- Микросервопривод SG90
- Перемычки:
- Перемычки «female-to-male» (или «male-to-male», в зависимости от способа подключения) для соединения Pico 2 с выводами сервопривода (земля, питание и сигнал).
SG90 небольшой, дешёвый и легко находится. Его часто используют в учебных проектах, и он хорошо подходит для демонстраций.
Основы работы сервопривода
Обычный любительский сервопривод имеет три провода: земля, питание и сигнал. Провода питания и земли подают энергию на мотор. Сигнальный провод используется, чтобы сообщить сервоприводу, в какое положение двигаться. На этом выводе сервопривод ожидает сигнал ШИМ. Разная ширина импульсов соответствует разным углам.
Чтобы использовать сервопривод, не нужно знать его внутреннее устройство. Достаточно сформировать правильный сигнал ШИМ.
Как работает управление сервоприводом
Сервопривод использует сигналы ШИМ (широтно-импульсной модуляции), чтобы управлять своим положением. Ширина каждого импульса сообщает сервоприводу, на какой угол повернуться, а постоянное повторение этого импульса удерживает его в этом положении.
Основной принцип
Сервоприводы работают на частоте 50 Гц, то есть ожидают управляющий импульс каждые 20 миллисекунд. Внутри каждого цикла длительностью 20 мс положение сервопривода определяется тем, сколько времени сигнал остаётся в высоком состоянии.
Представьте это так: каждые 20 мс вы посылаете сервоприводу короткую команду. Длина этой команды говорит ему, куда повернуться.
Ширина импульса
Положение сервопривода задаётся тем, как долго импульс сигнала остаётся в высоком состоянии. Короткий импульс переводит его в минимальное положение (обычно 0°), средний импульс — в центральное положение (обычно 90°), а длинный импульс — в максимальное положение (обычно 180°).
Стандарт и реальность
Часто встречаются «стандартные» значения: импульс 1,0 мс для 0°, 1,5 мс для 90° и 2,0 мс для 180°. Однако дешёвые сервоприводы редко точно следуют этим цифрам. Производственные отклонения означают, что у каждого сервопривода свои характеристики.
Например, мой сервопривод требовал 0,5 мс для минимального положения, 1,5 мс для центра и 2,4 мс для максимального положения. Это совершенно нормально и ожидаемо.
Опубликованные значения ширины импульсов нужно считать отправной точкой, а не абсолютными величинами. Всегда тестируйте и калибруйте конкретный сервопривод. Логический анализатор или осциллограф помогут, но подойдёт и простой метод проб и ошибок. Примеры в этом руководстве используют значения, которые подошли для моего сервопривода, поэтому для вашего их, возможно, придётся подправить.
Расчёт скважности
Скважность — это процент времени, в течение которого сигнал остаётся в высоком состоянии в каждом 20-миллисекундном цикле. Понимание этого помогает правильно настроить ШИМ в коде.
Примеры расчётов
Для импульса 0,5 мс (положение 0°) скважность вычисляется так:
Импульс 0,5 мс означает, что сигнал находится в «высоком» состоянии 0,5 миллисекунды в каждом 20-миллисекундном цикле. Сервопривод воспринимает это как команду перейти в положение 0 градусов.
\[ \text{Duty Cycle (%)} = \frac{0.5 \text{ms}}{20 \text{ms}} \times 100 = 2.5\% \]
Это значит, что только в течение 2,5 % каждого 20-миллисекундного цикла сигнал остаётся «высоким», заставляя сервопривод повернуться в положение 0 градусов.
Для импульса 1,5 мс (положение 90°) получаем:
Импульс 1,5 мс означает, что сигнал находится в «высоком» состоянии 1,5 миллисекунды в 20-миллисекундном цикле. Сервопривод переходит в нейтральное положение, примерно 90 градусов (среднее положение).
\[ \text{Duty Cycle (%)} = \frac{1.5 \text{ms}}{20 \text{ms}} \times 100 = 7.5\% \]
Здесь сигнал остаётся «высоким» 7,5 % цикла, что устанавливает сервопривод в положение 90 градусов (нейтральное).
Для импульса 2,4 мс (положение 180°) получаем:
Импульс 2,4 мс означает, что сигнал находится в «высоком» состоянии 2,4 миллисекунды в 20-миллисекундном цикле. Сервопривод перейдёт в максимальное положение, обычно 180 градусов (полный поворот в одну сторону).
\[ \text{Duty Cycle (%)} = \frac{2.4 \text{ms}}{20 \text{ms}} \times 100 = 12\% \]
В этом случае сигнал «высокий» 12 % цикла, что заставляет сервопривод повернуться на 180 градусов.
Ссылка
- Управление сервоприводом с помощью ШИМ — в стиле Wokwi (на английском языке)
Сервопривод с Raspberry Pi Pico 2 (RP2350)
Необходимое напряжение питания и ширина импульсов могут различаться в зависимости от сервопривода, поэтому всегда лучше сверяться с даташитом или техническими характеристиками изделия. Сервопривод, который использую я, работает в диапазоне от 4,8 до 6 В, поэтому я буду питать его напряжением 5 В.
- Земля (GND): подключите вывод GND сервопривода (обычно коричневый провод, хотя может отличаться) к любому выводу земли Pico 2.
- Питание (VCC): подключите вывод VCC сервопривода (обычно красный провод) к выводу питания 5 В Pico 2 (VBUS).
- Сигнал (PWM): подключите управляющий (сигнальный) вывод сервопривода к GPIO15 Pico 2, настроенному для ШИМ. Обычно это оранжевый провод (может отличаться).
| Вывод Pico | Провод | Сервопривод | Примечания |
|---|---|---|---|
| VBUS |
|
Питание (красный провод) | Подаёт питание 5 В на сервопривод. |
| GND |
|
Земля (коричневый провод) | Подключается к земле. |
| GPIO 15 |
|
Сигнал (оранжевый/жёлтый провод) | Получает сигнал ШИМ для управления положением сервопривода. |
Положение и скважность
Положение и скважность
Чтобы управлять сервоприводом с Raspberry Pi Pico, нужно задать частоту ШИМ 50 Гц. Насколько мне известно, ни в embassy, ни в rp-hal нет простого способа задать частоту напрямую.
В embassy-rp ШИМ настраивается через структуру Config с несколькими полями. Для нашей задачи нас в основном интересуют значения top и divider. То же относится и к rp-hal, где можно задавать top, div_int и div_frac отдельно.
Подходящее значение TOP можно найти ручным методом или с помощью формы, которая автоматически вычисляет и TOP, и делитель для целевой частоты 50 Гц.
Ручным методом я получил значение TOP 46 874 при делителе 64. С помощью формы я получил делитель 45,8125 и значение TOP 65 483. Можно использовать любую из этих конфигураций.
Note
В rp-hal целую и дробную части делителя нужно задавать отдельно. Так, делитель 64 становится div_int = 64 и div_frac = 0. Делитель 45,8125 становится div_int = 45 и div_frac = 13.
Расчёт положения на основе TOP
Когда известно значение TOP для ШИМ-сигнала 50 Гц, можно рассчитать значения скважности, необходимые для установки положения сервопривода.
Сервопривод определяет своё положение, измеряя ширину импульса, то есть время, в течение которого сигнал остаётся в высоком состоянии в каждом 20-миллисекундном цикле ШИМ. Точные ширины импульсов не одинаковы для всех сервоприводов и могут немного отличаться в зависимости от конкретной модели.
В моём случае значения были следующими:
-
0° при примерно 0,5 мс, что соответствует скважности 2,5 %, поскольку 0,5 мс составляет 2,5 % периода 20 мс.
-
90° при примерно 1,5 мс, что соответствует скважности 7,5 %, поскольку 1,5 мс составляет 7,5 % периода 20 мс.
-
180° при примерно 2,4 мс, что соответствует скважности 12 %, поскольку 2,4 мс составляет 12 % периода 20 мс.
В главе о плавном изменении яркости светодиода изменять скважность было просто. Нас интересовала только яркость, а не частота, поэтому было достаточно использовать set_duty_cycle_percent. Эта функция принимает значение u8 от 0 до 100, что хорошо подходит для целых процентов.
Для управления сервоприводом такой подход не подходит, потому что нужные скважности включают дробные значения, например 2,5 %, 7,5 % и 12 %.
Поэтому у нас есть две альтернативы. Первый вариант — вычислить значение скважности напрямую из TOP и использовать set_duty_cycle, который принимает u16. Второй вариант — использовать set_duty_cycle_fraction, который позволяет задать скважность в виде числителя и знаменателя.
Вариант 1: ручной расчёт с set_duty_cycle
Сначала переводим ширину импульса в процент от периода. Затем этот процент умножаем на TOP + 1, чтобы получить значение скважности, которое настраивает выход ШИМ.
#![allow(unused)]
fn main() {
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = 46_874;
const TOP: u16 = PWM_TOP + 1;
// 0,5 мс — это 2,5 % от 20 мс; 0 градусов для сервопривода
const MIN_DUTY: u16 = (TOP as f64 * (2.5 / 100.)) as u16;
// 1,5 мс — это 7,5 % от 20 мс; 90 градусов для сервопривода
const HALF_DUTY: u16 = (TOP as f64 * (7.5 / 100.)) as u16;
// 2,4 мс — это 12 % от 20 мс; 180 градусов для сервопривода
const MAX_DUTY: u16 = (TOP as f64 * (12. / 100.)) as u16;
}
Когда значение скважности вычислено, его можно применить так:
#![allow(unused)]
fn main() {
servo.set_duty_cycle(MIN_DUTY)
.expect("invalid min duty cycle");
}
Вариант 2: использование set_duty_cycle_fraction
Другой вариант — использовать set_duty_cycle_fraction. Он поможет задавать проценты в виде дроби.
На самом деле set_duty_cycle_percent — это удобный метод из embedded-hal, который внутри вызывает set_duty_cycle_fraction. Он просто делит процент на 100 и передаёт результат как дробь.
Из embedded-hal:
#![allow(unused)]
fn main() {
/// Установить скважность в `percent / 100`
///
/// Вызывающий код отвечает за то, что `percent` не превышает 100.
#[inline]
fn set_duty_cycle_percent(&mut self, percent: u8) -> Result<(), Self::Error> {
self.set_duty_cycle_fraction(u16::from(percent), 100)
}
/// Установить скважность в `num / denom`.
///
/// Вызывающий код отвечает за то, что `num` не превышает `denom`,
/// и что `denom` не равен нулю.
fn set_duty_cycle_fraction(&mut self, num: u16, denom: u16) -> Result<(), Self::Error> {
debug_assert!(denom != 0);
debug_assert!(num <= denom);
let duty = u32::from(num) * u32::from(self.max_duty_cycle()) / u32::from(denom);
// Это безопасно, потому что мы знаем, что `num <= denom`, поэтому `duty <= self.max_duty_cycle()` (u16)
#[allow(clippy::cast_possible_truncation)]
{
self.set_duty_cycle(duty as u16)
}
}
}
Эта функция не принимает значения с плавающей запятой. Вместо этого она принимает числитель и знаменатель, оба типа u16. Чтобы представить дробные проценты, мы просто масштабируем их до целых чисел.
Помните, что 2,5 % можно записать как дробь 2,5/100. Поскольку в числителе нельзя использовать десятичные дроби, умножим и числитель, и знаменатель на 10, чтобы получить эквивалентную дробь из целых чисел:
#![allow(unused)]
fn main() {
2.5/100 = (2.5 × 10)/(100 × 10) = 25/1000
}
Теперь у нас есть эквивалентная дробь, использующая только целые числа. Ту же операцию можно применить и к остальным процентам:
Например:
- 2,5 % можно записать как 25 / 1000 (то есть 25 — это 2,5 % от 1000)
- 7,5 % можно записать как 75 / 1000 (то есть 75 — это 7,5 % от 1000)
- 12 % можно записать как 120 / 1000 (то есть 120 — это 12 % от 1000)
Поэтому в нашем коде можно применить это так:
#![allow(unused)]
fn main() {
// Переместить сервопривод в положение 0° (скважность 2,5 % = 25/1000)
servo.set_duty_cycle_fraction(25, 1000)
.expect("invalid duty cycle");
// Положение 90° (скважность 7,5 %)
servo.set_duty_cycle_fraction(75, 1000)
.expect("invalid duty cycle");
// Положение 180° (скважность 12 %)
servo.set_duty_cycle_fraction(120, 1000)
.expect("invalid duty cycle");
}
Управление сервоприводом на Raspberry Pi Pico с Embassy и Rust
В этом разделе мы создадим простую программу, которая переводит рычаг сервопривода из 0 в 90, затем в 180 и обратно в 0. Такого базового движения достаточно, чтобы понять, как ШИМ управляет сервоприводом. Когда идея станет понятна, можно экспериментировать дальше и создавать более интересные приложения.
Начнём с создания нового проекта на фреймворке Embassy. После этого мы повторим тот же проект на rp-hal. Как обычно, создайте проект из шаблона с помощью cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «servo-motor», и выберите «embassy» в качестве HAL. Включите логирование defmt, если у вас есть отладочный зонд, чтобы также видеть логи.
Дополнительные импорты
Помимо обычных импортов шаблона, добавьте в проект эти специфические импорты.
#![allow(unused)]
fn main() {
// Для ШИМ
use embassy_rp::pwm::{Config as PwmConfig, Pwm, SetDutyCycle};
}
Настройка ШИМ
В главе о плавном изменении яркости светодиода мы оставляли настройки ШИМ по умолчанию. Там этого было достаточно, потому что важна была только скважность.
На этот раз так поступить нельзя. Для управления сервоприводом нужно самим задать значения TOP и делителя, чтобы частота ШИМ получилась 50 Гц, на основе значений, которые мы вычислили ранее.
Здесь я использую вручную вычисленные значения TOP и делителя прямо в коде, а не калькулятор-форму. Делитель, который я использую, — целое число, поэтому его можно просто преобразовать методом into(). Если бы у делителя была дробная часть, пришлось бы использовать крейт fixed, который мы рассматривали ранее. Чтобы всё было просто, пока остаёмся на целой версии.
#![allow(unused)]
fn main() {
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = 46_874;
}
Note
Можно попробовать и с дробным делителем. Мы уже рассматривали фрагмент кода для этого ранее, так что его можно повторно использовать и поэкспериментировать с дробными значениями.
Когда значения готовы, применяем их к конфигурации ШИМ:
#![allow(unused)]
fn main() {
let mut servo_config: PwmConfig = Default::default();
servo_config.top = PWM_TOP;
servo_config.divider = PWM_DIV_INT.into();
}
Инициализация ШИМ
Когда конфигурация ШИМ готова, следующий шаг — создать выход ШИМ и привязать его к выводу GPIO, подключённому к сигнальному проводу сервопривода.
В нашем случае мы используем GPIO 15. Смело меняйте это, если ваша проводка отличается.
#![allow(unused)]
fn main() {
let mut servo = Pwm::new_output_b(p.PWM_SLICE7, p.PIN_15, servo_config);
}
Главный цикл
Теперь переходим к главному циклу. Здесь мы просто меняем значение скважности, ждём короткую задержку, а затем переходим к следующему положению.
#![allow(unused)]
fn main() {
loop {
// Переместить сервопривод в положение 0° (скважность 2,5 % = 25/1000)
servo
.set_duty_cycle_fraction(25, 1000)
.expect("invalid min duty cycle");
Timer::after_millis(1000).await;
// Положение 90° (скважность 7,5 %)
servo
.set_duty_cycle_fraction(75, 1000)
.expect("invalid half duty cycle");
Timer::after_millis(1000).await;
// Положение 180° (скважность 12 %)
servo
.set_duty_cycle_fraction(120, 1000)
.expect("invalid max duty cycle");
Timer::after_millis(1000).await;
}
}
Если всё работает, вы увидите, как рычаг сервопривода переходит в первое положение, ненадолго замирает, переходит в следующее, затем в последнее и потом возвращается обратно.
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку servo-motor.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/servo-motor
Отладка
Если сервопривод не двигается, начните с проверки проводки. Убедитесь, что сигнальный провод подключён к правильному выводу GPIO, сервопривод получает нормальное питание и земля общая с Pico.
Затем дважды проверьте, что код был прошит правильно и программа действительно запущена на плате. Если вы используете отладочный зонд с включённым defmt, вывод логов поможет это подтвердить.
Если всё выглядит правильно, но сервопривод всё равно не двигается как ожидается, вероятнее всего, ваш сервопривод использует немного другие ширины импульсов для каждого положения. В этом случае обратитесь к даташиту для вашей модели сервопривода или проверьте сайт производителя или продавца, если там есть данные о таймингах. Возможно, придётся подстроить значения скважности под ваш сервопривод.
Не расстраивайтесь, если с первого раза всё не получится идеально. С этим я тоже бился, когда только начинал. Я постарался понятно объяснить расчёты и логику, стоящую за ними. Надеюсь, это поможет.
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// ШИМ
use embassy_rp::pwm::{Config as PwmConfig, Pwm, SetDutyCycle};
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = 46_874;
// Альтернативный метод:
// const TOP: u16 = PWM_TOP + 1;
// const MIN_DUTY: u16 = (TOP as f64 * (2.5 / 100.)) as u16;
// const HALF_DUTY: u16 = (TOP as f64 * (7.5 / 100.)) as u16;
// const MAX_DUTY: u16 = (TOP as f64 * (12. / 100.)) as u16;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut servo_config: PwmConfig = Default::default();
servo_config.top = PWM_TOP;
servo_config.divider = PWM_DIV_INT.into();
let mut servo = Pwm::new_output_b(p.PWM_SLICE7, p.PIN_15, servo_config);
loop {
// Переместить сервопривод в положение 0° (скважность 2,5 % = 25/1000)
servo
.set_duty_cycle_fraction(25, 1000)
.expect("invalid min duty cycle");
Timer::after_millis(1000).await;
// Положение 90° (скважность 7,5 %)
servo
.set_duty_cycle_fraction(75, 1000)
.expect("invalid half duty cycle");
Timer::after_millis(1000).await;
// Положение 180° (скважность 12 %)
servo
.set_duty_cycle_fraction(120, 1000)
.expect("invalid max duty cycle");
Timer::after_millis(1000).await;
}
// Альтернативный метод
// loop {
// servo
// .set_duty_cycle(MIN_DUTY)
// .expect("invalid min duty cycle");
// Timer::after_millis(1000).await;
// servo
// .set_duty_cycle(HALF_DUTY)
// .expect("invalid half duty cycle");
// Timer::after_millis(1000).await;
// servo
// .set_duty_cycle(MAX_DUTY)
// .expect("invalid max duty cycle");
// Timer::after_millis(1000).await;
// }
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"servo-motor"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Управление сервоприводом на Raspberry Pi Pico с помощью rp-hal
В этом упражнении мы повторяем тот же пример управления сервоприводом, но на этот раз с rp-hal вместо Embassy. Общая идея остаётся точно такой же. Основное отличие — мы будем использовать дробный делитель вместо делителя-целого.
Для этого мы будем опираться на форму-калькулятор, которая сгенерирует значение TOP и целую и дробную части делителя.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «servo-motor», и выберите «rp-hal» в качестве HAL. Включите логирование defmt, если у вас есть отладочный зонд, чтобы также видеть логи.
Дополнительные импорты
Помимо обычного шаблонного кода rp-hal, нужно подключить трейт, который позволяет обновлять скважность ШИМ.
#![allow(unused)]
fn main() {
// Для ШИМ
use embedded_hal::pwm::SetDutyCycle;
}
Инициализация слайса ШИМ
Далее инициализируем периферию ШИМ и выбираем слайс, который будем использовать. В нашем случае это слайс ШИМ 7 (поскольку мы используем GPIO 15).
#![allow(unused)]
fn main() {
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
let pwm = &mut pwm_slices.pwm7;
}
Настройка делителя и TOP
Теперь применяем значение TOP и делитель, которые мы получили с помощью формы-калькулятора. На этот раз мы явно задаём и целую, и дробную части делителя.
#![allow(unused)]
fn main() {
pwm.set_div_int(45);
pwm.set_div_frac(13);
pwm.set_top(65483);
pwm.enable();
}
Подключение канала ШИМ к GPIO
#![allow(unused)]
fn main() {
let servo = &mut pwm.channel_b;
servo.output_to(pins.gpio15);
}
Главный цикл
Наконец, в главном цикле мы меняем скважность, чтобы перемещать сервопривод между разными положениями. Как и в примере на Embassy, используем set_duty_cycle_fraction.
#![allow(unused)]
fn main() {
loop {
// Переместить сервопривод в положение 0° (скважность 2,5 % = 25/1000)
servo
.set_duty_cycle_fraction(25, 1000)
.expect("invalid min duty cycle");
timer.delay_ms(1000);
// Положение 90° (скважность 7,5 %)
servo
.set_duty_cycle_fraction(75, 1000)
.expect("invalid half duty cycle");
timer.delay_ms(1000);
// Положение 180° (скважность 12 %)
servo
.set_duty_cycle_fraction(120, 1000)
.expect("invalid max duty cycle");
timer.delay_ms(1000);
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку servo-motor.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/servo-motor
Полный код
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
use embedded_hal::pwm::SetDutyCycle;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// Внешний высокоскоростной кварц на плате Raspberry Pi Pico 2 работает на 12 МГц.
/// Измените значение, если на вашей плате другая частота
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
// Получаем наши синглтон-объекты
let mut pac = hal::pac::Peripherals::take().unwrap();
// Настраиваем драйвер сторожевого таймера — он нужен для настройки тактирования
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Настраиваем тактирование
//
// По умолчанию генерируется системная частота 125 МГц
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// Блок SIO с однотактным доступом управляет нашими выводами GPIO
let sio = hal::Sio::new(pac.SIO);
// Настраиваем выводы в соответствии с их функциями на этой плате
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
let pwm = &mut pwm_slices.pwm7;
pwm.set_div_int(45);
pwm.set_div_frac(13);
pwm.set_top(65483);
pwm.enable();
let servo = &mut pwm.channel_b;
servo.output_to(pins.gpio15);
loop {
// Переместить сервопривод в положение 0° (скважность 2,5% = 25/1000)
servo
.set_duty_cycle_fraction(25, 1000)
.expect("invalid min duty cycle");
timer.delay_ms(1000);
// Положение 90° (скважность 7,5%)
servo
.set_duty_cycle_fraction(75, 1000)
.expect("invalid half duty cycle");
timer.delay_ms(1000);
// Положение 180° (скважность 12%)
servo
.set_duty_cycle_fraction(120, 1000)
.expect("invalid max duty cycle");
timer.delay_ms(1000);
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Buzzinga
В этом разделе мы рассмотрим несколько интересных занятий с использованием зуммера. Название Buzzinga я выбрал просто ради шутки (это игра слов с «Bazinga» — фразой Шелдона из сериала «Теория большого взрыва»). Это не технический термин.
Что такое зуммер?
Зуммер — небольшой электронный компонент, который издаёт звук, когда на него подают питание или управляющий электрический сигнал. Его используют для генерации сигналов, оповещений или простых мелодий, обеспечивая звуковую обратную связь в электронных устройствах.
Зуммеры часто встречаются в будильниках, таймерах, системах уведомлений, компьютерах и простых пользовательских интерфейсах, где они подтверждают действия пользователя или сигнализируют о событиях.
Распространённые типы зуммеров
В embedded-проектах вы чаще всего будете встречать два типа:
Активный зуммер
Внутри такого зуммера есть встроенный генератор. Нужно только подать питание, и он сразу начнёт издавать звук. Активные зуммеры очень просты в использовании, но позволяют мало управлять высотой тона.
Как определить
Активный зуммер обычно имеет белое покрытие сверху и гладкий чёрный корпус снизу. Проще всего его проверить, подключив напрямую к батарейке: если он издаёт звук без дополнительных схем, это активный зуммер.
Пассивный зуммер
Пассивный зуммер сам по себе звука не издаёт. Его нужно управлять сигналом ШИМ или меандром (прямоугольными импульсами). Это позволяет задавать частоту, а значит, получать разные тона или даже простые мелодии.
Как определить
У пассивного зуммера обычно нет белого покрытия сверху, и часто он выглядит как маленькая печатная плата с синим или зелёным основанием. Если подключить его напрямую к батарейке, звука не будет.
Какой выбрать?
Выбирайте активный зуммер, если нужен простой фиксированный тон или сигнал. Он подходит для базовых оповещений, будильников или подтверждения действий пользователя и требует минимальной настройки.
Выбирайте пассивный зуммер, если хотите больше контроля над звуком. Поскольку им нужно управлять сигналом ШИМ или меандром, можно получать разные тоны, мелодии и звуковые узоры.
Для наших упражнений рекомендуется пассивный зуммер, потому что он позволяет напрямую задавать частоту выходного сигнала и получить более качественный тон. Однако если у вас есть только активный зуммер, можно всё равно следовать книге. Я сам сначала использовал именно активный.
Требования к оборудованию
- Пассивный зуммер
- Перемычки
У зуммера обычно два вывода: положительный (для сигнала) и земляной. Положительный вывод часто помечен знаком «+» и, как правило, длиннее, а отрицательный — короче, как у светодиода.
Однако некоторые пассивные зуммеры не имеют полярности. В этом случае любой из выводов можно подключить к сигналу или к земле. Если не уверены, проверьте маркировку или даташит.
Ссылка
Подключение зуммера к Raspberry Pi Pico
Подключим GPIO 15 к положительному (сигнальному) выводу зуммера, а GND Pico — к земляному выводу зуммера. При необходимости можно использовать другой вывод GPIO.
| Вывод Pico | Провод | Вывод зуммера | Примечания |
|---|---|---|---|
| GPIO 15 |
|
Положительный вывод | Получает сигналы ШИМ для создания звука. |
| GND |
|
Земляной вывод | Подключается к земле. |
Сигнал зуммера с помощью ШИМ на Raspberry Pi Pico на embedded Rust
В этом упражнении мы сгенерируем звуковой сигнал с помощью зуммера. Идея похожа на мигание светодиодом, но вместо того, чтобы переключать вывод GPIO между HIGH и LOW, мы управляем скважностью ШИМ.
Будем многократно переключать скважность ШИМ между 50 процентами и 0 процентами с задержкой между переключениями. Когда скважность составляет 50 процентов, зуммер издаёт звук. Когда 0 процентов — звук прекращается. Повторение этого создаёт отчётливый сигнал.
Можно попробовать и без изменения частоты ШИМ. В этом примере мы задаём частоту ШИМ 440,0 Гц. Это соответствует музыкальной ноте a4 (ля первой октавы) и тональному сигналу телефонии (call progress tone), который используется в Северной Америке и большей части Европы. Знать что-либо о нотах для этого не нужно. Важно лишь то, что мы генерируем тон фиксированной частоты и включаем или выключаем звук, меняя скважность.
Создание проекта из шаблона
Начнём с создания нового проекта на основе фреймворка Embassy. Как обычно, создайте проект из шаблона с помощью cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «buzzer-beep», и выберите «embassy» как HAL. Включите логирование defmt, если у вас есть отладочный зонд, чтобы также видеть логи.
Дополнительные импорты
Как и раньше, импортируем трейт SetDutyCycle, а также типы Pwm и Config для настройки ШИМ.
#![allow(unused)]
fn main() {
use embassy_rp::pwm::{Config as PwmConfig, Pwm, SetDutyCycle};
}
Вычисление TOP
Значение TOP можно вычислить вручную или воспользоваться калькулятором из предыдущей главы. На этот раз пойдём другим путём.
Зафиксируем делитель на 64 и используем const fn, чтобы вычислить значение TOP. Поскольку мы не используем режим phase-correct и дробный делитель, расчёт простой.
#![allow(unused)]
fn main() {
const fn get_top(freq: f64, div_int: u8) -> u16 {
assert!(div_int != 0, "Divider must not be 0");
let result = 150_000_000. / (freq * div_int as f64);
assert!(result >= 1.0, "Frequency too high");
assert!(
result <= 65535.0,
"Frequency too low: TOP exceeds 65534 max"
);
result as u16 - 1
}
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = get_top(440., PWM_DIV_INT);
}
Основная логика
Сначала настраиваем ШИМ с вычисленным значением TOP и фиксированным делителем. Затем создаём выход ШИМ для зуммера. Внутри цикла переключаем скважность между 50 процентами и 0 процентами с задержкой между переключениями, что даёт повторяющийся звуковой сигнал.
#![allow(unused)]
fn main() {
let mut pwm_config = PwmConfig::default();
pwm_config.top = PWM_TOP;
pwm_config.divider = PWM_DIV_INT.into();
let mut buzzer = Pwm::new_output_b(p.PWM_SLICE7, p.PIN_15, pwm_config);
loop {
buzzer
.set_duty_cycle_percent(50)
.expect("50 is valid duty percentage");
Timer::after_millis(1000).await;
buzzer
.set_duty_cycle_percent(0)
.expect("0 is valid duty percentage");
Timer::after_millis(1000).await;
}
}
Если хотите, чтобы сигнал был короче или быстрее, измените значения задержек.
Клонируйте существующий проект
Можно клонировать (или посмотреть) проект, который я создал, и перейти в папку buzzer-beep.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/buzzer-beep
Версия на rp-hal
Если хотите посмотреть тот же пример, реализованный с помощью rp-hal, его можно найти здесь.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/buzzer-beep
Воспроизведение песен на пассивном зуммере с помощью Rust и Raspberry Pi Pico
В этом разделе мы будем воспроизводить песни на зуммере с помощью Raspberry Pi Pico.
Если вы не знакомы с нотами и нотным письмом, можно посмотреть краткую теорию. Эта часть необязательна и нужна лишь для того, чтобы дать базовые сведения, необходимые для понимания примера.
Для наглядности код разделён на модули Rust. Всё можно держать и в одном файле, как мы делали до сих пор, но разделение делает пример понятнее:
Для этого упражнения рекомендуется пассивный зуммер, хотя подойдёт и пассивный, и активный.
ШИМ
Мы используем ШИМ, чтобы управлять частотой сигнала, отправляемого на зуммер. Каждая частота соответствует музыкальной ноте. Частота (нота) удерживается заданное время, а затем происходит переход к следующей ноте согласно музыкальным данным.
Например, нота a4 — это 440 Гц. Чтобы её сыграть, мы настраиваем выход ШИМ на 440 Гц и держим его включённым нужное время, прежде чем перейти к следующей ноте.
Если вы не знакомы с ШИМ на Pico, рекомендую прочитать раздел о ШИМ, прежде чем продолжать.
Репозиторий песен
В этом упражнении в качестве демонстрации мы сыграем на зуммере музыкальную тему.
Также можно посмотреть репозиторий rust-embedded-songs и попробовать другие песни:
https://github.com/ImplFerris/rust-embedded-songs/
Подмодули
Обновите main.rs, объявив подмодули, а затем создайте соответствующие исходные файлы.
#![allow(unused)]
fn main() {
pub mod music;
pub mod got;
}
Музыкальные ноты
Введение в ноты и нотное письмо
Это краткое руководство по нотам и нотному письму. Оно не охватывает всего, но даёт быстрый справочник по ключевым понятиям.
Нотный лист
Ноты для мелодии записаны на следующем нотном листе. Подробности можно посмотреть по ссылке на MuseScore.
В музыке длительность нот обозначается следующими типами, которые определяют, как долго звучит каждая нота:
- Целая нота: самая длинная длительность, звучит 4 доли.
- Половинная нота: звучит 2 доли.
- Четвертная нота: звучит 1 долю.
- Восьмая нота: звучит половину доли, то есть 1/8 длительности целой ноты.
- Шестнадцатая нота: звучит четверть доли, то есть 1/16 длительности целой ноты.
Нота с точкой
Нота с точкой — это нота, рядом с которой стоит точка. Точка увеличивает длительность ноты на половину её исходного значения. Например:
- Половинная нота с точкой: звучит 3 доли (2 + 1).
- Четвертная нота с точкой: звучит 1,5 доли (1 + 0,5).
Темп и BPM (ударов в минуту)
Темп — это скорость, с которой исполняется музыкальное произведение. Обычно его измеряют в ударах в минуту (BPM, beats per minute), то есть в том, сколько долей проходит за одну минуту.
Модуль музыки (music.rs)
В модуле музыки мы определяем константы для музыкальных нот и их значений частоты.
Каждая нота хранится как значение f64, чтобы его можно было сразу использовать при настройке частоты ШИМ. Также определено специальное значение REST, которое обозначает тишину между нотами.
#![allow(unused)]
fn main() {
// Частоты нот в герцах как f64
pub const NOTE_B0: f64 = 31.0;
pub const NOTE_C1: f64 = 33.0;
pub const NOTE_CS1: f64 = 35.0;
pub const NOTE_D1: f64 = 37.0;
pub const NOTE_DS1: f64 = 39.0;
pub const NOTE_E1: f64 = 41.0;
pub const NOTE_F1: f64 = 44.0;
pub const NOTE_FS1: f64 = 46.0;
pub const NOTE_G1: f64 = 49.0;
pub const NOTE_GS1: f64 = 52.0;
pub const NOTE_A1: f64 = 55.0;
pub const NOTE_AS1: f64 = 58.0;
pub const NOTE_B1: f64 = 62.0;
pub const NOTE_C2: f64 = 65.0;
pub const NOTE_CS2: f64 = 69.0;
pub const NOTE_D2: f64 = 73.0;
pub const NOTE_DS2: f64 = 78.0;
pub const NOTE_E2: f64 = 82.0;
pub const NOTE_F2: f64 = 87.0;
pub const NOTE_FS2: f64 = 93.0;
pub const NOTE_G2: f64 = 98.0;
pub const NOTE_GS2: f64 = 104.0;
pub const NOTE_A2: f64 = 110.0;
pub const NOTE_AS2: f64 = 117.0;
pub const NOTE_B2: f64 = 123.0;
pub const NOTE_C3: f64 = 131.0;
pub const NOTE_CS3: f64 = 139.0;
pub const NOTE_D3: f64 = 147.0;
pub const NOTE_DS3: f64 = 156.0;
pub const NOTE_E3: f64 = 165.0;
pub const NOTE_F3: f64 = 175.0;
pub const NOTE_FS3: f64 = 185.0;
pub const NOTE_G3: f64 = 196.0;
pub const NOTE_GS3: f64 = 208.0;
pub const NOTE_A3: f64 = 220.0;
pub const NOTE_AS3: f64 = 233.0;
pub const NOTE_B3: f64 = 247.0;
pub const NOTE_C4: f64 = 262.0;
pub const NOTE_CS4: f64 = 277.0;
pub const NOTE_D4: f64 = 294.0;
pub const NOTE_DS4: f64 = 311.0;
pub const NOTE_E4: f64 = 330.0;
pub const NOTE_F4: f64 = 349.0;
pub const NOTE_FS4: f64 = 370.0;
pub const NOTE_G4: f64 = 392.0;
pub const NOTE_GS4: f64 = 415.0;
pub const NOTE_A4: f64 = 440.0;
pub const NOTE_AS4: f64 = 466.0;
pub const NOTE_B4: f64 = 494.0;
pub const NOTE_C5: f64 = 523.0;
pub const NOTE_CS5: f64 = 554.0;
pub const NOTE_D5: f64 = 587.0;
pub const NOTE_DS5: f64 = 622.0;
pub const NOTE_E5: f64 = 659.0;
pub const NOTE_F5: f64 = 698.0;
pub const NOTE_FS5: f64 = 740.0;
pub const NOTE_G5: f64 = 784.0;
pub const NOTE_GS5: f64 = 831.0;
pub const NOTE_A5: f64 = 880.0;
pub const NOTE_AS5: f64 = 932.0;
pub const NOTE_B5: f64 = 988.0;
pub const NOTE_C6: f64 = 1047.0;
pub const NOTE_CS6: f64 = 1109.0;
pub const NOTE_D6: f64 = 1175.0;
pub const NOTE_DS6: f64 = 1245.0;
pub const NOTE_E6: f64 = 1319.0;
pub const NOTE_F6: f64 = 1397.0;
pub const NOTE_FS6: f64 = 1480.0;
pub const NOTE_G6: f64 = 1568.0;
pub const NOTE_GS6: f64 = 1661.0;
pub const NOTE_A6: f64 = 1760.0;
pub const NOTE_AS6: f64 = 1865.0;
pub const NOTE_B6: f64 = 1976.0;
pub const NOTE_C7: f64 = 2093.0;
pub const NOTE_CS7: f64 = 2217.0;
pub const NOTE_D7: f64 = 2349.0;
pub const NOTE_DS7: f64 = 2489.0;
pub const NOTE_E7: f64 = 2637.0;
pub const NOTE_F7: f64 = 2794.0;
pub const NOTE_FS7: f64 = 2960.0;
pub const NOTE_G7: f64 = 3136.0;
pub const NOTE_GS7: f64 = 3322.0;
pub const NOTE_A7: f64 = 3520.0;
pub const NOTE_AS7: f64 = 3729.0;
pub const NOTE_B7: f64 = 3951.0;
pub const NOTE_C8: f64 = 4186.0;
pub const NOTE_CS8: f64 = 4435.0;
pub const NOTE_D8: f64 = 4699.0;
pub const NOTE_DS8: f64 = 4978.0;
pub const REST: f64 = 0.0; // Тишина, для пауз
}
Структура песни
Мы определяем небольшую вспомогательную структуру, которая представляет песню и отвечает за тайминг нот.
#![allow(unused)]
fn main() {
pub struct Song {
whole_note: u64,
}
}
Поле whole_note хранит длительность целой ноты в миллисекундах. Все остальные длительности нот вычисляются из этого значения. Использование миллисекунд упрощает задержки при воспроизведении нот на зуммере.
Создание песни
При создании Song мы вычисляем длительность целой ноты по темпу.
#![allow(unused)]
fn main() {
impl Song {
pub fn new(tempo: u16) -> Self {
let whole_note = (60_000 * 4) / tempo as u64;
Self { whole_note }
}
}
}
Темп задаётся в ударах в минуту. В одной минуте 60 000 миллисекунд, а целая нота равна четырём долям. Деление на темп даёт время в миллисекундах, которое должна длиться одна целая нота.
Например, при 120 BPM одна доля длится 500 мс, поэтому целая нота длится 2000 мс.
Расчёт длительности ноты
Этот метод преобразует значение ноты во временную длительность.
#![allow(unused)]
fn main() {
pub fn calc_note_duration(&self, divider: i16) -> u64 {
if divider > 0 {
self.whole_note / divider as u64
} else {
let duration = self.whole_note / divider.unsigned_abs() as u64;
(duration as f64 * 1.5) as u64
}
}
}
Делитель показывает, как нота соотносится с целой. Значение 1 означает целую ноту, значение 2 — половинную, значение 4 — четвертную. Длительность вычисляется делением длительности целой ноты на это значение.
Отрицательные значения используются для обозначения нот с точкой. Нота с точкой звучит в полтора раза дольше обычной ноты того же вида. Когда делитель отрицательный, код сначала вычисляет обычную длительность по модулю значения, а затем умножает её на 1,5.
Такая логика с положительными и отрицательными значениями — собственный подход (основанный на примере для Arduino, на который я опирался), чтобы отличать ноты с точкой. Это не часть стандартной музыкальной нотации.
Пример мелодии: тема Game of Thrones
Этот раздел содержит фрагменты кода для модуля Rust got.
Импорт определений музыки
Модуль got использует константы нот и вспомогательные типы, определённые в модуле music. Подключаем их следующим импортом:
#![allow(unused)]
fn main() {
use crate::music::*;
}
Это позволяет мелодии использовать константы нот, например NOTE_E4 и NOTE_A4, напрямую, без записи имени модуля каждый раз.
Темп
Задаём темп песни. Вы можете изменить это значение и посмотреть, как это влияет на скорость воспроизведения.
#![allow(unused)]
fn main() {
pub const TEMPO: u16 = 85;
}
Массив мелодии
Мелодию темы Game of Thrones задаём массивом нот и длительностей. Каждый элемент — кортеж, который содержит частоту ноты и её длительность.
Длительность задаётся целым числом. Положительные значения обозначают обычные ноты, отрицательные — ноты с точкой.
#![allow(unused)]
fn main() {
pub const MELODY: [(f64, i16); 92] = [
// Тема Game of Thrones
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_E4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_E4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_E4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_E4, 16),
(NOTE_F4, 16),
(NOTE_G4, -4),
(NOTE_C4, -4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 4),
(NOTE_C4, 4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_D4, -1),
(NOTE_F4, -4),
(NOTE_AS3, -4),
(NOTE_DS4, 16),
(NOTE_D4, 16),
(NOTE_F4, 4),
(NOTE_AS3, -4),
(NOTE_DS4, 16),
(NOTE_D4, 16),
(NOTE_C4, -1),
// Повтор
(NOTE_G4, -4),
(NOTE_C4, -4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 4),
(NOTE_C4, 4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_D4, -1),
(NOTE_F4, -4),
(NOTE_AS3, -4),
(NOTE_DS4, 16),
(NOTE_D4, 16),
(NOTE_F4, 4),
(NOTE_AS3, -4),
(NOTE_DS4, 16),
(NOTE_D4, 16),
(NOTE_C4, -1),
(NOTE_G4, -4),
(NOTE_C4, -4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 4),
(NOTE_C4, 4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_D4, -2),
(NOTE_F4, -4),
(NOTE_AS3, -4),
(NOTE_D4, -8),
(NOTE_DS4, -8),
(NOTE_D4, -8),
(NOTE_AS3, -8),
(NOTE_C4, -1),
(NOTE_C5, -2),
(NOTE_AS4, -2),
(NOTE_C4, -2),
(NOTE_G4, -2),
(NOTE_DS4, -2),
(NOTE_DS4, -4),
(NOTE_F4, -4),
(NOTE_G4, -1),
];
}
Код
Воспроизведение мелодии Game of Thrones
В этом разделе мы собираем всё вместе в файле main.rs.
К этому моменту у нас уже есть частоты нот, логика тайминга песни и данные мелодии. Здесь мы просто связываем их между собой с помощью ШИМ и таймеров.
Импорты
Добавьте необходимые импорты для ШИМ, таймеров и работы с песнями.
#![allow(unused)]
fn main() {
// Для ШИМ
use embassy_rp::pwm::{Config as PwmConfig, Pwm, SetDutyCycle};
use crate::music::Song;
}
ШИМ и зуммер
В отличие от предыдущего примера, мы клонируем PwmConfig. Конструктор Pwm забирает объект конфигурации, а внутри цикла нам нужно менять top, чтобы задавать частоту ШИМ для каждой музыкальной ноты.
#![allow(unused)]
fn main() {
let mut pwm_config = PwmConfig::default();
pwm_config.top = PWM_TOP;
pwm_config.divider = PWM_DIV_INT.into();
let mut buzzer = Pwm::new_output_b(p.PWM_SLICE7, p.PIN_15, pwm_config.clone());
}
Создание объекта Song
Создайте Song с темпом, заданным для темы Game of Thrones.
#![allow(unused)]
fn main() {
let song = Song::new(got::TEMPO);
}
Воспроизведение нот
Мелодия проигрывается циклом по массиву MELODY. Каждый элемент содержит частоту ноты и значение длительности.
#![allow(unused)]
fn main() {
// Один раз проигрываем мелодию
for (note, duration_type) in got::MELODY {
let top = get_top(note, PWM_DIV_INT);
pwm_config.top = top;
buzzer.set_config(&pwm_config);
let note_duration = song.calc_note_duration(duration_type);
let pause_duration = note_duration / 10; // 10% от note_duration
buzzer
.set_duty_cycle_percent(50)
.expect("50 is valid duty percentage"); // Ставим скважность 50%, чтобы сыграть ноту
Timer::after_millis(note_duration - pause_duration).await; // Играем 90%
buzzer
.set_duty_cycle_percent(0)
.expect("50 is valid duty percentage"); // Останавливаем тон
Timer::after_millis(pause_duration).await; // Пауза 10%
}
}
Для каждой ноты частота ШИМ обновляется установкой нового значения top. Это заставляет зуммер издавать нужную высоту тона.
Длительность ноты рассчитывается по темпу песни. Большую часть времени нота звучит, а небольшая часть остаётся тихой. Эта короткая тишина помогает отделить ноты друг от друга, благодаря чему мелодия звучит чище.
Зуммер включается установкой скважности 50 процентов и выключается установкой нуля.
Поддержание работы программы
После окончания мелодии этот цикл просто удерживает программу активной.
#![allow(unused)]
fn main() {
loop {
Timer::after_millis(100).await;
}
}
Клонируйте существующий проект
Можно клонировать (или использовать как образец) проект, который я создал, и перейти в папку buzzer-song.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/buzzer-song
Версия на rp-hal
Если хотите увидеть тот же пример, реализованный с помощью rp-hal, его можно найти здесь.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/got-buzzer
Пищание активным зуммером
Поскольку вы уже знаете, как работает активный зуммер, заставить его пищать можно просто включая и выключая вывод GPIO. В этом упражнении мы подаём питание на зуммер через вывод GPIO, ждём короткое время, выключаем и повторяем. Так получается отчётливый звуковой сигнал.
Note
Этот пример предназначен для активного зуммера. Если использовать пассивный, звук может быть странным или нестабильным. Выполняйте это упражнение только с активным зуммером.
Требования к оборудованию
- Активный зуммер
- Перемычки (female-to-male или male-to-male, в зависимости от вашей схемы)
Проект из шаблона
Создайте новый проект. Чтобы его настроить, выполните:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «active-beep», и выберите embassy в качестве HAL.
Основная логика
Убедитесь, что зуммер подключён к GPIO 15. Вывод переключается каждые 500 миллисекунд, чтобы включать и выключать зуммер.
#![allow(unused)]
fn main() {
let mut buzzer = Output::new(p.PIN_15, Level::Low);
loop {
buzzer.set_high();
Timer::after_millis(500).await;
buzzer.set_low();
Timer::after_millis(500).await;
}
}
Клонируйте существующий проект
Можно клонировать (или использовать как образец) проект, который я создал, и перейти в папку active-beep.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/active-beep
Делитель напряжения
Делитель напряжения — простая схема, которая понижает входное напряжение до меньшего выходного с помощью двух последовательно соединённых резисторов. Делитель может понадобиться, когда датчик или модуль выдаёт напряжение выше того, которое микроконтроллер безопасно выдерживает.
Резистор, подключённый к входному напряжению, называется \( R_{1} \), а резистор, подключённый к земле, — \( R_{2} \). Выходное напряжение \( V_{out} \) измеряется в точке между \( R_{1} \) и \( R_{2} \), и оно составляет долю от входного напряжения \( V_{in} \).
Схема
Выходное напряжение (Vout) рассчитывается по формуле:
\[ V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2} \]
Пример расчёта \( V_{out} \)
Дано:
- \( V_{in} = 3.3 V \)
- \( R_1 = 10 k\Omega \)
- \( R_2 = 10 k\Omega \)
Подставляем значения:
\[ V_{out} = 3.3 V \times \frac{10 k\Omega}{10 k\Omega + 10 k\Omega} = 3.3 V \times \frac{10}{20} = 3.3 V \times 0.5 = 1.65 V \]
Выходное напряжение \( V_{out} \) равно 1.65 В.
fn main() {
// Код можно редактировать
// Можно менять значения и запускать код
let vin: f64 = 3.3;
let r1: f64 = 10000.0;
let r2: f64 = 10000.0;
let vout = vin * (r2 / (r1 + r2));
println!("The output voltage Vout is: {:.2} V", vout);
}
Применение
Делители напряжения используются, например, в потенциометрах: при повороте ручки сопротивление меняется, и вместе с ним меняется выходное напряжение. Их также применяют для измерения резистивных датчиков, таких как датчики света и термисторы. К ним подаётся известное напряжение, а микроконтроллер считывает напряжение в средней точке и по нему определяет значения датчика, например температуру.
Симуляция делителя напряжения
Формула: Vout = Vin × (R2 / (R1 + R2))
Формула с подставленными значениями: Vout = 3.3 × (10000 / (10000 + 10000))
Выходное напряжение (Vout): 1.65 V
Симулятор на сайте Falstad
Для построения схемы я использовал сайт https://www.falstad.com/circuit/. Это отличный инструмент для рисования схем. Вы можете скачать созданный мной файл voltage-divider.circuitjs.txt и импортировать его, чтобы поэкспериментировать с этой схемой.
Маячок летучих мышей: проект датчика расстояния 🦇
Если вы смотрели фильм «Бэтмен: Начало», то помните сцену, где Бэтмен использует устройство, которое испускает ультразвуковые сигналы, чтобы призвать рой летучих мышей. Это один из самых крутых гаджетов в его арсенале! Хотя мы сегодня не будем строить маячок для призыва летучих мышей, мы будем работать с похожей ультразвуковой технологией.
Ультразвук
Ультразвуковые волны — это звуковые волны с частотой выше 20 000 Гц, за пределами того, что способно услышать человеческое ухо, но многие животные их слышат. Летучие мыши используют ультразвук, чтобы летать в темноте и избегать препятствий. Дельфины применяют его для общения и для обнаружения предметов под водой.
Ультразвуковые технологии вокруг нас
Люди позаимствовали этот природный принцип эхолокации для повседневных изобретений:
- Датчики парковки используют ультразвуковые датчики, чтобы обнаруживать препятствия при движении задним ходом. Чем ближе вы подъезжаете к предмету, тем чаще пищит сигнал.
- Подводные лодки используют сонар для навигации и обнаружения предметов под водой
- Медицинское ультразвуковое исследование позволяет врачам заглянуть внутрь человеческого тела
- Автоматические двери и навигация роботов опираются на ультразвуковое определение расстояния
Сегодня вы создадите собственный датчик расстояния на основе ультразвукового модуля: он будет посылать звуковые волны, измерять, сколько времени им нужно, чтобы вернуться, и вычислять расстояние.
Знакомство с железом
HC-SR04+ — простой и недорогой ультразвуковой датчик расстояния. Он может измерять расстояния примерно от 2 см до 400 см. Он работает так: посылает короткий импульс ультразвука, а затем слушает эхо. Измеряя, сколько времени занимает возврат эха, датчик вычисляет, как далеко находится объект.
Tip
HC-SR04 обычно работает от 5 В, что может быть проблемой для Raspberry Pi Pico. По возможности купите версию HC-SR04+, которая работает и от 3,3 В, и от 5 В, поэтому она больше подходит для Pico.
Почему это важно: Вывод Echo у HC-SR04 выдаёт сигнал 5 В, а выводы GPIO Pico безопасно выдерживают только 3,3 В. Прямое подключение 5 В к Pico может его повредить.
Ваши варианты:
- Купить вариант HC-SR04+ (рекомендуемое и самое простое решение)
- Использовать делитель напряжения на выводе Echo, чтобы снизить сигнал 5 В до 3,3 В
- Использовать преобразователь логических уровней, чтобы безопасно понизить напряжение
- Питать HC-SR04 от 3,3 В (не рекомендуется: он может работать ненадёжно или не работать вообще)
В этом проекте мы создадим детектор приближения, который постепенно делает светодиод ярче по мере приближения предметов. Когда датчик обнаруживает что-то в пределах 30 см, светодиод будет светиться ярче с помощью ШИМ. Если хотите попробовать другие идеи, можно изменить значение расстояния.
Предварительные требования
Перед началом познакомьтесь с этими темами:
Требования к оборудованию
Чтобы выполнить этот проект, вам понадобится:
- Ультразвуковой датчик HC-SR04+ или HC-SR04
- Макетная плата
- Перемычки
- Внешний светодиод (можно также использовать встроенный светодиод, но тогда код придётся изменить соответственно)
- Если вы используете стандартный модуль HC-SR04, который работает от 5 В, понадобятся два резистора (1 кОм и 2 кОм или 2,2 кОм), чтобы собрать делитель напряжения.
Модуль датчика HC-SR04 содержит передатчик и приёмник. У модуля есть выводы Trigger и Echo, которые можно подключить к выводам GPIO Pico. Когда приёмник обнаруживает возвращающуюся звуковую волну, вывод Echo переходит в HIGH на время, равное времени, за которое волна вернулась к датчику.
Даташит
Большинство электронных компонентов поставляется с даташитом. Это техническая документация, которая рассказывает всё, что нужно знать о работе компонента, его электрических характеристиках и правильном использовании.
Даташит на ультразвуковой датчик HC-SR04 можно найти здесь: https://cdn.sparkfun.com/datasheets/Sensors/Proximity/HCSR04.pdf
Даташиты поначалу могут выглядеть пугающе из-за технических характеристик и схем, но понимать в них всё необязательно.
Как работает ультразвуковой датчик?
Ультразвуковые датчики работают, испуская звуковые волны на частоте (40 кГц), которая слишком высока для человеческого уха. Эти звуковые волны распространяются в воздухе и отражаются, когда натыкаются на предмет. Датчик вычисляет расстояние, измеряя, сколько времени звуковым волнам нужно, чтобы вернуться.
- Передатчик: испускает ультразвуковые звуковые волны.
- Приёмник: обнаруживает звуковые волны, отражённые от предмета.
Формула для вычисления расстояния:
Distance = (Time x Speed of Sound) / 2
Скорость звука при нормальном атмосферном давлении и температуре 20 °C составляет примерно 0,0343 см/мкс (или 343 м/с).
Пример расчёта
Допустим, ультразвуковой датчик фиксирует, что звуковой волне понадобилось 2000 мкс, чтобы вернуться после удара о предмет.
Шаг 1: вычисляем общее расстояние, которое прошла звуковая волна:
Total distance = Time x Speed of Sound
Total distance = 2000 µs x 0.0343 cm/µs = 68.6 cm
Шаг 2: поскольку звуковая волна прошла до предмета и обратно, расстояние до предмета — половина общего пути:
Distance to object = 68.6 cm / 2 = 34.3 cm
Итак, предмет находится на расстоянии 34,3 см от датчика.
Распиновка HC-SR04
У модуля четыре вывода: VCC, Trig, Echo и GND.
| Вывод | Функция |
|---|---|
| VCC | Питание |
| Trig | Сигнал запуска (Trigger) |
| Echo | Сигнал эха (Echo) |
| GND | Земля |
Измерение расстояния с помощью модуля HC-SR04
В модуле HC-SR04 есть передатчик и приёмник, которые отвечают за отправку ультразвуковых волн и обнаружение отражённых. Мы будем использовать вывод Trig, чтобы посылать звуковые волны, и считывать сигнал с вывода Echo, чтобы измерять расстояние.
Как видно на схеме, мы подключаем выводы Trig и Echo к выводам GPIO микроконтроллера (также подключаем VCC и GND, но на схеме их опустили, чтобы не усложнять рисунок). Чтобы отправить ультразвуковые волны, мы переводим вывод Trig в HIGH на 10 микросекунд, а затем возвращаем его в LOW. Это заставляет модуль отправить 8 последовательных ультразвуковых волн на частоте 40 кГц. Рекомендуется делать паузу не менее 50 мс между запусками.
Когда волны датчика натыкаются на предмет, они отражаются обратно к модулю. Как видно на схеме, вывод Echo меняет сигнал, который передаётся микроконтроллеру: длительность, в течение которой сигнал остаётся в HIGH (ширина импульса), соответствует расстоянию. В микроконтроллере мы измеряем, сколько времени вывод Echo остаётся в HIGH. Затем эту длительность можно использовать для вычисления расстояния до предмета.
Ширина импульса и расстояние:
Ширина импульса (время, в течение которого сигнал остаётся в высоком состоянии), которую выдаёт вывод Echo, находится примерно в диапазоне от 150 мкс до 25 000 мкс (25 мс). Это только если сигнал отразился от предмета. Если предмета нет, ширина импульса составит около 38 мс.
Подключение HC-SR04 к Pico 2 с помощью делителя напряжения
Если вы используете обычный HC-SR04, как я, вам понадобится создать делитель напряжения для вывода Echo. В этом разделе мы разберём, как собрать схему. Однако если вам повезло и вы купили HC-SR04 Plus, можно перейти к следующей странице. Схема станет намного проще, потому что датчик можно питать от 3,3 В вместо 5 В.
Распространённые комбинации резисторов
Ниже приведены некоторые пары резисторов, которые можно использовать, чтобы снизить сигнал Echo HC-SR04 примерно до 3,3 В. R1 — резистор, подключённый к выводу Echo, а R2 — резистор, подключённый к земле.
| R1 (к Echo) | R2 (к GND) | Выходное напряжение |
|---|---|---|
| 330 Ω | 470 Ω | 2.94 V |
| 330 Ω | 680 Ω | 3.37 V |
| 470 Ω | 680 Ω | 2.96 V |
| 680 Ω | 1 kΩ | 2.98 V |
| 1 kΩ | 1.8 kΩ | 3.21 V |
| 1 kΩ | 2 kΩ | 3.33 V |
| 1 kΩ | 2.2 kΩ | 3.44 V |
| 1.5 kΩ | 2.2 kΩ | 2.97 V |
| 2.2 kΩ | 3.3 kΩ | 3.00 V |
| 3.3 kΩ | 4.7 kΩ | 2.94 V |
| 4.7 kΩ | 6.8 kΩ | 2.96 V |
| 6.8 kΩ | 10 kΩ | 2.98 V |
| 22 kΩ | 33 kΩ | 3.00 V |
| 33 kΩ | 47 kΩ | 2.94 V |
| 47 kΩ | 68 kΩ | 2.96 V |
Можно выбрать любую пару резисторов из таблицы, потому что все они снижают сигнал Echo 5 В до безопасного уровня около 3,3 В. На практике лучше использовать номиналы, которые уже есть в вашем наборе.
Подключение Raspberry Pi Pico 2 и ультразвукового датчика
| Вывод Pico 2 | Провод | Вывод HC-SR04 |
|---|---|---|
| VBUS (Pin 40) |
|
VCC |
| GPIO 17 |
|
Trig |
| GPIO 16 (через делитель напряжения) |
|
Echo (через делитель 1 кОм/2,2 кОм) |
| GND |
|
GND |
- VCC: подключите вывод VCC HC-SR04 к VBUS (Pin 40) на Pico 2. HC-SR04 требует питания 5 В, а VBUS выдаёт 5 В от USB-соединения.
- Trig: подключите к GPIO 17 на Pico 2, чтобы запускать ультразвуковые импульсы.
- Echo: подключите к GPIO 16 на Pico 2 через делитель напряжения (резистор 1 кОм от вывода Echo, резистор 2 кОм или 2,2 кОм до земли). Точка соединения резисторов подключается к GPIO 16. Этот делитель понижает сигнал Echo 5 В примерно до 3,4 В, защищая выводы GPIO Pico на 3,3 В.
- GND: подключите к любому выводу земли на Pico 2.
Подключение Pico 2 и светодиода
| Вывод Pico 2 | Провод | Компонент |
|---|---|---|
| GPIO 3 |
|
Резистор (220 Ω–330 Ω) |
| Резистор |
|
Анод (длинная ножка) светодиода |
| GND |
|
Катод (короткая ножка) светодиода |
Схема для HC-SR04+
Пропустите этот шаг, если вы используете вариант HC-SR04, рассчитанный только на 5 В.
Подключение Pico и ультразвукового датчика:
| Вывод Pico | Провод | Вывод HC-SR04+ |
|---|---|---|
| 3.3 V |
|
VCC |
| GPIO 17 |
|
Trig |
| GPIO 16 |
|
Echo |
| GND |
|
GND |
- VCC: подключите вывод VCC HC-SR04+ к выводу 3.3 V на Pico.
- Trig: подключите к GPIO 17 на Pico, чтобы запускать ультразвуковые импульсы.
- Echo: подключите к GPIO 16 на Pico; этот вывод посылает импульс, когда обнаруживает отражённый сигнал, а длина импульса показывает, сколько времени сигналу понадобилось, чтобы вернуться.
- GND: подключите к выводу земли на Pico.
- LED: подключите анод (длинную ножку) светодиода к GPIO 3.
Подключение Pico и светодиода:
| Вывод Pico | Провод | Компонент |
|---|---|---|
| GPIO 3 |
|
Резистор |
| Резистор |
|
Анод (длинная ножка) светодиода |
| GND |
|
Катод (короткая ножка) светодиода |
Учебник по Rust: использование датчика HC-SR04 с Pico 2
Мы начнём с создания нового проекта на фреймворке Embassy. После этого повторим тот же проект на rp-hal. Как обычно, создайте проект из шаблона с помощью cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например bat-beacon, и выберите embassy в качестве HAL. Включите логирование defmt, если у вас есть отладочный зонд, чтобы также видеть логи.
Дополнительные импорты
Помимо обычных импортов шаблона, добавьте в проект эти импорты. Редактор кода должен предложить автоматический импорт для большинства из них, кроме трейта SetDutyCycle, который придётся добавить вручную.
#![allow(unused)]
fn main() {
// Для GPIO
use embassy_rp::gpio::{Input, Level, Output, Pull};
// Для ШИМ
use embassy_rp::pwm::{Pwm, SetDutyCycle};
// Для вычисления времени
use embassy_time::Instant;
}
Нам нужны типы GPIO для управления выводами Trigger и Echo, ШИМ для управления яркостью светодиода и утилиты времени для измерения длительности ультразвукового импульса.
Назначение выводов GPIO
К этому моменту вы уже должны знать ШИМ из раздела о плавном изменении яркости светодиода. Здесь мы создадим похожий эффект, но есть ключевое отличие. В главе о плавном изменении яркости мы заставляли светодиод многократно плавно загораться и гаснуть с помощью условий. Здесь же яркость светодиода будет расти только тогда, когда предмет приближается к датчику.
#![allow(unused)]
fn main() {
// Для встроенного светодиода
// let mut led = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, Default::default());
// Для внешнего светодиода, подключённого к GPIO 3
let mut led = Pwm::new_output_b(p.PWM_SLICE1, p.PIN_3, Default::default());
}
Можно использовать как встроенный светодиод, так и внешний. Мне больше нравится внешний светодиод: плавные изменения яркости видны намного лучше.
Далее инициализируем светодиод выключенным и получим его максимальное значение скважности:
#![allow(unused)]
fn main() {
led.set_duty_cycle(0)
.expect("duty cycle is within valid range");
let max_duty = led.max_duty_cycle();
// defmt::info!("Max duty cycle {}", max_duty);
}
Скважность определяет яркость светодиода: 0 — полностью выключен, а max_duty — полностью включен.
Настройка выводов Trigger и Echo
Как вы знаете, Pico должен посылать сигнал на вывод trigger, поэтому вывод GPIO 17 (подключённый к выводу trigger) мы настраиваем как выход с начальным низким уровнем. Датчик сообщает расстояние импульсами на выводе echo, то есть отправляет сигналы в Pico (вход для Pico). Поэтому вывод GPIO 16 (подключённый к выводу echo) мы настраиваем как вход.
#![allow(unused)]
fn main() {
let mut trigger = Output::new(p.PIN_17, Level::Low);
let echo = Input::new(p.PIN_16, Pull::Down);
}
Перевод расстояния в яркость светодиода
Нам нужна функция, которая переводит измеренное расстояние в подходящее значение скважности. Чем ближе предмет, тем выше скважность (ярче светодиод):
#![allow(unused)]
fn main() {
const MAX_DISTANCE_CM: f64 = 30.0;
fn calculate_duty_cycle(distance: f64, max_duty: u16) -> u16 {
if distance < MAX_DISTANCE_CM && distance >= 2.0 {
let normalized = (MAX_DISTANCE_CM - distance) / MAX_DISTANCE_CM;
// defmt::info!("duty cycle :{}", (normalized * max_duty as f64) as u16);
(normalized * max_duty as f64) as u16
} else {
0
}
}
}
Функция принимает измеренное расстояние и максимальное значение скважности. Если расстояние находится между 2 см (минимальный диапазон датчика) и 30 см, мы нормируем его к диапазону от 0 до 1 и умножаем на максимальную скважность. Предметы ближе 2 см или дальше 30 см приводят к выключению светодиода (скважность 0).
Измерение расстояния датчиком
Расстояние будем измерять, посылая ультразвуковой импульс и засекая, сколько времени он идёт до возврата:
#![allow(unused)]
fn main() {
const ECHO_TIMEOUT: Duration = Duration::from_millis(100);
async fn measure_distance(trigger: &mut Output<'_>, echo: &Input<'_>) -> Option<f64> {
// Отправляем импульс trigger
trigger.set_low();
Timer::after_micros(2).await;
trigger.set_high();
Timer::after_micros(10).await;
trigger.set_low();
// Ждём, когда echo станет HIGH (датчик отвечает)
let timeout = Instant::now();
while echo.is_low() {
if timeout.elapsed() > ECHO_TIMEOUT {
defmt::warn!("Timeout waiting for HIGH");
return None; // Досрочный выход по таймауту
}
}
let start = Instant::now();
// Ждём, когда echo станет LOW (импульс завершён)
let timeout = Instant::now();
while echo.is_high() {
if timeout.elapsed() > ECHO_TIMEOUT {
defmt::warn!("Timeout waiting for LOW");
return None; // Досрочный выход по таймауту
}
}
let end = Instant::now();
// Вычисляем расстояние
let time_elapsed = end.checked_duration_since(start)?.as_micros();
let distance = time_elapsed as f64 * 0.0343 / 2.0;
Some(distance)
}
}
Мы начинаем с того, что ненадолго переводим вывод trigger в низкий уровень, а затем на 10 микросекунд переводим его в высокий. Это создаёт импульс trigger, который велит датчику испустить ультразвуковой пакет. После этого ждём, пока вывод Echo поднимется. Время, в течение которого вывод Echo остаётся в высоком состоянии, — это время прохождения звуковой волны туда и обратно. Используя эту длительность, вычисляем итоговое расстояние и возвращаем его.
Мы также добавили таймаут на ожидание изменения состояния вывода echo, чтобы код не завис навечно. Если вывод не отвечает за допустимое время, мы считаем попытку неудачным измерением и возвращаем None, что позволяет остальной программе продолжать работать как обычно.
Главный цикл
Наконец, создадим главный цикл, который постоянно считывает датчик и обновляет светодиод:
#![allow(unused)]
fn main() {
loop {
Timer::after_millis(10).await;
let distance = match measure_distance(&mut trigger, &echo).await {
Some(d) => d,
None => {
Timer::after_secs(5).await;
continue; // Переходим к следующей итерации
}
};
let duty_cycle = calculate_duty_cycle(distance, max_duty);
led.set_duty_cycle(duty_cycle)
.expect("duty cycle is within valid range");
}
}
Каждые 10 миллисекунд мы измеряем расстояние. Если измерение прошло успешно, вычисляем подходящую яркость светодиода и применяем её. Если измерение не удалось (из-за таймаута или проблем с датчиком), ждём 5 секунд перед следующей попыткой.
Полный код
Вот всё вместе:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::{Duration, Timer};
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Для GPIO
use embassy_rp::gpio::{Input, Level, Output, Pull};
// Для ШИМ
use embassy_rp::pwm::{Pwm, SetDutyCycle};
// Для вычисления времени
use embassy_time::Instant;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// Для встроенного светодиода
// let mut led = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, Default::default());
// Для внешнего светодиода, подключённого к GPIO 3
let mut led = Pwm::new_output_b(p.PWM_SLICE1, p.PIN_3, Default::default());
let mut trigger = Output::new(p.PIN_17, Level::Low);
let echo = Input::new(p.PIN_16, Pull::None);
led.set_duty_cycle(0)
.expect("duty cycle is within valid range");
let max_duty = led.max_duty_cycle();
// defmt::info!("Max duty cycle {}", max_duty);
loop {
Timer::after_millis(10).await;
let distance = match measure_distance(&mut trigger, &echo).await {
Some(d) => d,
None => {
Timer::after_secs(5).await;
continue; // Переходим к следующей итерации
}
};
let duty_cycle = calculate_duty_cycle(distance, max_duty);
led.set_duty_cycle(duty_cycle)
.expect("duty cycle is within valid range");
}
}
const ECHO_TIMEOUT: Duration = Duration::from_millis(100);
async fn measure_distance(trigger: &mut Output<'_>, echo: &Input<'_>) -> Option<f64> {
// Отправляем импульс trigger
trigger.set_low();
Timer::after_micros(2).await;
trigger.set_high();
Timer::after_micros(10).await;
trigger.set_low();
// Ждём, когда echo станет HIGH (датчик отвечает)
let timeout = Instant::now();
while echo.is_low() {
if timeout.elapsed() > ECHO_TIMEOUT {
defmt::warn!("Timeout waiting for HIGH");
return None; // Досрочный выход по таймауту
}
}
let start = Instant::now();
// Ждём, когда echo станет LOW (импульс завершён)
let timeout = Instant::now();
while echo.is_high() {
if timeout.elapsed() > ECHO_TIMEOUT {
defmt::warn!("Timeout waiting for LOW");
return None; // Досрочный выход по таймауту
}
}
let end = Instant::now();
// Вычисляем расстояние
let time_elapsed = end.checked_duration_since(start)?.as_micros();
let distance = time_elapsed as f64 * 0.0343 / 2.0;
Some(distance)
}
const MAX_DISTANCE_CM: f64 = 30.0;
fn calculate_duty_cycle(distance: f64, max_duty: u16) -> u16 {
if distance < MAX_DISTANCE_CM && distance >= 2.0 {
let normalized = (MAX_DISTANCE_CM - distance) / MAX_DISTANCE_CM;
// defmt::info!("duty cycle :{}", (normalized * max_duty as f64) as u16);
(normalized * max_duty as f64) as u16
} else {
0
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"ultrasonic"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку ultrasonic.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/ultrasonic
Написание кода на Rust для ультразвукового датчика HC-SR04 с Pico 2
Начнём с создания проекта из шаблона, а затем изменим код под требования текущего проекта.
Создание проекта из шаблона
Подробности и инструкции см. в разделе о шаблоне.
Чтобы создать проект, выполните:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, придумайте имя проекта — пусть будет «bat-beacon». Не забудьте выбрать rp-hal в качестве HAL.
Затем перейдите в папку проекта:
cd PROJECT_NAME
# Например, если вы назвали проект «bat-beacon»:
# cd bat-beacon
Настройка вывода светодиода
К этому моменту вы уже должны понимать этот код. Если нет, сначала пройдите раздел про мигание светодиодом.
Краткое напоминание: здесь мы настраиваем ШИМ для светодиода, что позволяет управлять яркостью, меняя скважность.
#![allow(unused)]
fn main() {
let pwm = &mut pwm_slices.pwm1; // Берём слайс ШИМ 1
pwm.set_ph_correct(); // Включаем фазово-корректный режим для более плавных переходов
pwm.enable(); // Включаем слайс ШИМ
let led = &mut pwm.channel_b; // Выбираем канал ШИМ B
led.output_to(pins.gpio3); // Назначаем GPIO 3 выходом ШИМ
}
Настройка вывода Trigger
Вывод Trigger ультразвукового датчика используется для запуска ультразвукового импульса. Его нужно настроить как выход, чтобы мы могли управлять им и посылать импульс.
#![allow(unused)]
fn main() {
let mut trigger = pins.gpio17.into_push_pull_output();
}
Настройка вывода Echo
Вывод Echo ультразвукового датчика принимает возвращающийся сигнал, что позволяет измерить время, за которое импульс дошёл до объекта и вернулся. Его настраивают как вход, чтобы обнаруживать возвращающийся импульс.
#![allow(unused)]
fn main() {
let mut echo = pins.gpio16.into_pull_down_input();
}
🦇 Зажигаем светодиод
Шаг 1: отправляем импульс Trigger
Сначала нужно отправить на вывод trigger короткий импульс, чтобы начать ультразвуковое измерение.
#![allow(unused)]
fn main() {
// Перед стартом убедимся, что вывод Trigger в низком уровне
trigger.set_low().ok().unwrap();
timer.delay_us(2);
// Отправляем высокий импульс длительностью 10 микросекунд
trigger.set_high().ok().unwrap();
timer.delay_us(10);
trigger.set_low().ok().unwrap();
}
Шаг 2: измеряем время эха
Далее используем два цикла. Первый цикл выполняется, пока состояние вывода echo — LOW. Как только оно станет HIGH, мы запишем текущее время в переменную. Затем запускаем второй цикл, который продолжается, пока вывод echo остаётся HIGH. Когда он вернётся в LOW, мы запишем текущее время в другую переменную. Разница между этими двумя временами даёт нам ширину импульса.
#![allow(unused)]
fn main() {
let mut time_low = 0;
let mut time_high = 0;
// Ждём, когда вывод Echo станет высоким, и запоминаем время
while echo.is_low().ok().unwrap() {
time_low = timer.get_counter().ticks();
}
// Ждём, когда вывод Echo станет низким, и запоминаем время
while echo.is_high().ok().unwrap() {
time_high = timer.get_counter().ticks();
}
// Вычисляем время, за которое сигнал вернулся
let time_passed = time_high - time_low;
}
Шаг 3: вычисляем расстояние
Чтобы вычислить расстояние, нужна ширина импульса. Она показывает, сколько времени ультразвуковым волнам понадобилось, чтобы дойти до препятствия и вернуться. Поскольку импульс представляет время туда и обратно, мы делим его на 2, чтобы учесть путь до препятствия и обратно.
Скорость звука в воздухе примерно 0,0343 см за микросекунду. Умножив время (в микросекундах) на это значение и разделив на 2, получаем расстояние до препятствия в сантиметрах.
#![allow(unused)]
fn main() {
let distance = time_passed as f64 * 0.0343 / 2.0;
}
Шаг 4: скважность ШИМ для светодиода
Наконец, регулируем яркость светодиода в зависимости от измеренного расстояния.
Процент скважности вычисляется по нашей собственной логике, вы можете изменить её под свои нужды. Когда предмет ближе 30 см, яркость светодиода будет расти. Чем ближе предмет к ультразвуковому модулю, тем выше получается вычисленное отношение, а значит, тем выше скважность. В итоге яркость светодиода постепенно увеличивается по мере приближения предмета к датчику.
#![allow(unused)]
fn main() {
let duty_cycle = if distance < 30.0 {
let step = 30.0 - distance;
(step * 1500.) as u16 + 1000
} else {
0
};
// Меняем яркость светодиода
led.set_duty_cycle(duty_cycle).unwrap();
}
Полная логика цикла
Примечание: этот фрагмент кода показывает только цикл и не включает всю программу.
#![allow(unused)]
fn main() {
loop {
timer.delay_ms(5);
trigger.set_low().ok().unwrap();
timer.delay_us(2);
trigger.set_high().ok().unwrap();
timer.delay_us(10);
trigger.set_low().ok().unwrap();
let mut time_low = 0;
let mut time_high = 0;
while echo.is_low().ok().unwrap() {
time_low = timer.get_counter().ticks();
}
while echo.is_high().ok().unwrap() {
time_high = timer.get_counter().ticks();
}
let time_passed = time_high - time_low;
let distance = time_passed as f64 * 0.0343 / 2.0;
let duty_cycle = if distance < 30.0 {
let step = 30.0 - distance;
(step * 1500.) as u16 + 1000
} else {
0
};
led.set_duty_cycle(duty_cycle).unwrap();
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку ultrasonic.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/ultrasonic
Ваше задание
- Используйте фреймворк Embassy вместо
rp-hal - Используйте вместо этого встроенный светодиод
Прерывания
Одну минуту, звонит мой партнёр.
Да, солнышко. Конечно, сделаю.
Ну вот, я вернулся. Так на чём я остановился?
Когда происходит прерывание, процессор приостанавливает текущее выполнение.
Секунду, кто-то звонит в дверь.
Отлично, Pico W наконец пришла.
Итак, вернёмся к объяснению.
Выполнение продолжается с той же инструкции, на которой оно было прервано.
Тот телефонный звонок и звонок в дверь были прерываниями.
Я (в роли процессора) приостановил объяснение, обработал эти прерывания и затем продолжил.
Это была простая попытка объяснить прерывания с помощью аналогии. Надеюсь, суть понятна. Прерывание — это сигнал, который заставляет процессор приостановить обычное выполнение, чтобы обработать событие. Идея вдохновлена отличным объяснением Патрика на YouTube. Оригинальное видео ещё веселее и очень познавательно — его стоит посмотреть.
Зачем нужны прерывания
В простой программе процессор выполняет инструкции одну за другой по прямой линии. Для простых программ это работает нормально, но встраиваемым системам часто нужно реагировать на внешние события: нажатие кнопки, данные от датчика, истечение таймера.
Без прерываний единственный способ заметить такие события — опрос (polling): постоянная проверка регистра состояния или входного вывода в цикле, чтобы узнать, произошло ли что-то. Это похоже на то, что вы снова и снова спрашиваете:
«Кнопка изменилась?»
«Таймер истёк?»
«Появились ли новые данные?»
Чаще всего ответ «нет». Процессор тратит время ЦП на бесконечные проверки, даже когда ничего не происходит. Из-за этого система работает неэффективно и медленнее реагирует.
Вместо этого периферия может вызвать прерывание, чтобы привлечь внимание процессора. Когда происходит прерывание, процессор временно приостанавливает текущий код, переходит к специальному фрагменту кода — обработчику прерывания, обрабатывает событие и затем продолжает выполнение с того самого места, где оно было прервано.
Tip
Представьте разницу между тем, чтобы стоять у стиральной машины и каждую минуту проверять, закончила ли она, и тем, чтобы заниматься своими делами, пока она работает, а она подаст сигнал по окончании программы.
С прерываниями процессор свободно выполняет основной код и останавливается, только когда что-то действительно требует внимания.
Как процессор помнит, чем занимался
Когда происходит прерывание, процессор должен уметь продолжить выполнение позже, не потеряв место, где остановился.
Для этого процессор сохраняет своё текущее состояние. Сюда входят, например, счётчик команд (program counter) и важные регистры.
На большинстве микроконтроллеров это состояние автоматически помещается в стек аппаратно. Затем выполняется обработчик прерывания. Когда обработчик завершается, сохранённое состояние восстанавливается из стека, и выполнение продолжается так, будто ничего не произошло.
Процедуры обслуживания прерываний
Код, который выполняется в ответ на прерывание, называется процедурой обслуживания прерывания (ISR, Interrupt Service Routine).
ISR должна быть короткой и быстрой. Пока выполняется ISR, обычное выполнение программы приостановлено. Долгие или блокирующие операции внутри ISR могут привести к пропуску событий и проблемам с таймингом.
Таблица векторов прерываний
Когда происходит прерывание, как процессор узнаёт, какую процедуру ISR нужно выполнить? Ответ — таблица векторов прерываний.
Таблица векторов прерываний — это таблица в памяти, которая содержит адреса функций-обработчиков прерываний. Каждому источнику прерываний назначается фиксированная позиция в этой таблице, которая называется номером вектора. Когда срабатывает прерывание, процессор использует этот номер, чтобы найти соответствующую запись в таблице, и переходит по адресу обработчика, записанному там.
Таблица векторов не ограничивается прерываниями от периферии. В ней также есть записи для системных исключений, таких как сброс, аппаратные сбои и системные таймеры. С точки зрения процессора эти события обрабатываются так же, как прерывания: переходом по адресу, указанному в таблице векторов.
На процессорах ARM Cortex-M, таких как RP2350, таблица векторов обычно расположена в начале флеш-памяти при загрузке. Первая запись содержит начальное значение указателя стека, а вторая — обработчик сброса, то есть первый код, который выполняется при запуске процессора.
Уровни приоритета прерываний
Микроконтроллеры позволяют задавать прерываниям уровни приоритета. Прерывание с более высоким приоритетом может вытеснить прерывание с более низким приоритетом. Так обеспечивается обработка критичных по времени событий в первую очередь.
NVIC: контроллер прерываний
Вложенный векторный контроллер прерываний (NVIC, Nested Vectored Interrupt Controller) — аппаратный компонент процессоров ARM Cortex-M, который управляет прерываниями.
NVIC отвечает за включение и выключение отдельных прерываний, соблюдение уровней приоритета и обработку ситуаций, когда одновременно возникает несколько прерываний. Когда приходит прерывание с более высоким приоритетом, NVIC может приостановить обработчик с более низким приоритетом, чтобы сначала разобраться с более срочным событием.
Номера приоритетов в ARM Cortex-M работают в обратном порядке: меньшее число означает более высокий приоритет. Среди настраиваемых прерываний приоритет 0 — самый срочный, а большие числа, например приоритет 15, — менее срочные. Это значит, что обработчик с приоритетом 0 может вытеснить обработчик с приоритетом 2, но не наоборот.
Критические секции
Критическая секция — небольшой фрагмент кода, который нельзя прерывать, чтобы сохранить согласованность данных или состояния аппаратуры.
Представьте ситуацию, когда основной код обновляет общую переменную или настраивает периферию в несколько шагов. Если в середине этой последовательности происходит прерывание и обработчик обращается к тем же данным или аппаратуре, система может оказаться в несогласованном состоянии.
Во встраиваемых системах это обычно решается временным отключением прерываний перед входом в критическую секцию и их немедленным включением после выхода из неё.
Цель не в том, чтобы блокировать прерывания на долгое время, а в том, чтобы защитить очень маленькие и чувствительные участки кода, где важна согласованность.
В экосистеме embedded Rust крейт critical-section предоставляет универсальный переносимый API для входа в критические секции на многих платформах и в разных окружениях. Он определяет функции acquire, release и with, которые библиотеки и приложения могут использовать, чтобы выполнять код при отключённых прерываниях или с другой защитой.
Пример:
#![allow(unused)]
fn main() {
use core::cell::Cell;
use critical_section::Mutex;
static MY_VALUE: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));
critical_section::with(|cs| {
// Этот код выполняется внутри критической секции.
// `cs` — это токен, с помощью которого можно «доказать» это некоторому API,
// например, `Mutex`:
MY_VALUE.borrow(cs).set(42);
});
}
Типы прерываний
В микроконтроллерах прерывания обычно приходят из нескольких распространённых источников.
Внешние прерывания
Они вызываются внешними сигналами, например нажатием кнопки или изменением на выводе GPIO. Их часто используют для пользовательского ввода или реакции на внешние аппаратные события.
Прерывания таймера
Таймеры могут генерировать прерывания через фиксированные промежутки времени. Они широко используются для задержек, планирования задач, мигания светодиодами или отсчёта времени. Да, мы фактически уже использовали прерывания таймера. Каждый раз, когда мы вызываем Timer::after_millis(100).await в Embassy, именно это и происходит за кулисами: периферия таймера настраивается так, чтобы сработать прерыванием через 100 миллисекунд. Наша задача засыпает, а когда срабатывает прерывание таймера, она пробуждается. Процессор не сидит и не считает — он свободен заниматься другими делами или спать, пока ждёт.
Прерывания периферии
Многие периферийные модули могут генерировать прерывания. Например:
- Периферия SPI и I2C может вызывать прерывания, чтобы сигнализировать о завершении передачи или ошибках.
- Периферия ADC может генерировать прерывание по окончании преобразования.
Системные исключения
Некоторые прерывания генерирует сам процессор, например при сбоях или от системных таймеров. Обычно они предназначены для задач системного уровня.
Прерывания в RP2350
В предыдущей главе мы рассмотрели, что такое прерывания и какую роль играет NVIC. Теперь давайте посмотрим, какие прерывания на самом деле доступны в RP2350.
Прерывания делятся на две группы: системные исключения и внешние прерывания.
Системные исключения определены самой архитектурой процессора. К ним относятся сброс, обработчики сбоев и системный таймер. На большинстве чипов Cortex-M они работают одинаково.
Внешние прерывания приходят от периферии RP2350. У каждого периферийного модуля, который может генерировать прерывание, есть номер IRQ и имя вектора. Именно эти имена вы будете видеть в коде.
Таблица ниже показывает внешние прерывания RP2350, пронумерованные от 0 до 51. Они охватывают распространённую периферию, например таймеры, GPIO, DMA и интерфейсы связи вроде I2C, SPI и UART.
Запоминать эту таблицу не нужно. Её назначение — помочь понять, откуда берутся такие имена, как I2C0_IRQ или UART0_IRQ, когда вы встречаете их в примерах или документации.
Полные подробности см. в разделе 3.2 даташита RP2350 на странице 82.
В следующей главе мы увидим, как Embassy использует эти прерывания, не требуя от вас писать обработчики прерываний вручную.
Внешние прерывания RP2350
Important
Некоторые описания прерываний здесь упрощены для удобного обзора для начинающих. Более точные и подробные сведения см. в даташите RP2350.
Таймеры
Аварийные сигналы таймеров, используемые для задержек и планирования.
| IRQ | Вектор | Описание |
|---|---|---|
| 0 | TIMER0_IRQ_0 | Прерывание аварийного сигнала таймера 0 |
| 1 | TIMER0_IRQ_1 | Прерывание аварийного сигнала таймера 0 |
| 2 | TIMER0_IRQ_2 | Прерывание аварийного сигнала таймера 0 |
| 3 | TIMER0_IRQ_3 | Прерывание аварийного сигнала таймера 0 |
| 4 | TIMER1_IRQ_0 | Прерывание аварийного сигнала таймера 1 |
| 5 | TIMER1_IRQ_1 | Прерывание аварийного сигнала таймера 1 |
| 6 | TIMER1_IRQ_2 | Прерывание аварийного сигнала таймера 1 |
| 7 | TIMER1_IRQ_3 | Прерывание аварийного сигнала таймера 1 |
ШИМ
События переполнения счётчика ШИМ.
| IRQ | Вектор | Описание |
|---|---|---|
| 8 | PWM_IRQ_WRAP_0 | Прерывание переполнения ШИМ |
| 9 | PWM_IRQ_WRAP_1 | Прерывание переполнения ШИМ |
DMA
События передачи DMA.
| IRQ | Вектор | Описание |
|---|---|---|
| 10 | DMA_IRQ_0 | Прерывание передачи DMA |
| 11 | DMA_IRQ_1 | Прерывание передачи DMA |
| 12 | DMA_IRQ_2 | Прерывание передачи DMA |
| 13 | DMA_IRQ_3 | Прерывание передачи DMA |
USB
События контроллера USB.
| IRQ | Вектор | Описание |
|---|---|---|
| 14 | USBCTRL_IRQ | Прерывание контроллера USB |
PIO
События конечных автоматов PIO.
| IRQ | Вектор | Описание |
|---|---|---|
| 15 | PIO0_IRQ_0 | Прерывание PIO 0 |
| 16 | PIO0_IRQ_1 | Прерывание PIO 0 |
| 17 | PIO1_IRQ_0 | Прерывание PIO 1 |
| 18 | PIO1_IRQ_1 | Прерывание PIO 1 |
| 19 | PIO2_IRQ_0 | Прерывание PIO 2 |
| 20 | PIO2_IRQ_1 | Прерывание PIO 2 |
GPIO и базовый ввод-вывод
События GPIO и межъядерной сигнализации.
| IRQ | Вектор | Описание |
|---|---|---|
| 21 | IO_IRQ_BANK0 | Прерывание GPIO |
| 22 | IO_IRQ_BANK0_NS | Прерывание GPIO |
| 23 | IO_IRQ_QSPI | Прерывание GPIO QSPI |
| 24 | IO_IRQ_QSPI_NS | Прерывание GPIO QSPI |
| 25 | SIO_IRQ_FIFO | Прерывание FIFO между ядрами |
| 26 | SIO_IRQ_BELL | Прерывание doorbell между ядрами |
| 27 | SIO_IRQ_FIFO_NS | Прерывание FIFO между ядрами |
| 28 | SIO_IRQ_BELL_NS | Прерывание doorbell между ядрами |
| 29 | SIO_IRQ_MTIMECMP | Прерывание системного таймера |
Периферия связи
События интерфейсов связи.
| IRQ | Вектор | Описание |
|---|---|---|
| 30 | CLOCKS_IRQ | Прерывание системы тактирования |
| 31 | SPI0_IRQ | Прерывание SPI |
| 32 | SPI1_IRQ | Прерывание SPI |
| 33 | UART0_IRQ | Прерывание UART |
| 34 | UART1_IRQ | Прерывание UART |
| 35 | ADC_IRQ_FIFO | Прерывание FIFO АЦП |
| 36 | I2C0_IRQ | Прерывание I2C |
| 37 | I2C1_IRQ | Прерывание I2C |
Система и питание
События управления системой и питанием.
| IRQ | Вектор | Описание |
|---|---|---|
| 38 | OTP_IRQ | Прерывание OTP |
| 39 | TRNG_IRQ | Прерывание генератора случайных чисел |
| 40 | Reserved | Зарезервировано |
| 41 | Reserved | Зарезервировано |
| 42 | PLL_SYS_IRQ | Прерывание системной PLL |
| 43 | PLL_USB_IRQ | Прерывание PLL USB |
| 44 | POWMAN_IRQ_POW | Прерывание менеджера питания |
| 45 | POWMAN_IRQ_TIMER | Прерывание таймера менеджера питания |
Программные прерывания
Прерывания, которые можно вызвать программно.
| IRQ | Вектор | Описание |
|---|---|---|
| 46 | SPAREIRQ_IRQ_0 | Программное прерывание |
| 47 | SPAREIRQ_IRQ_1 | Программное прерывание |
| 48 | SPAREIRQ_IRQ_2 | Программное прерывание |
| 49 | SPAREIRQ_IRQ_3 | Программное прерывание |
| 50 | SPAREIRQ_IRQ_4 | Программное прерывание |
| 51 | SPAREIRQ_IRQ_5 | Программное прерывание |
Использование прерываний с Embassy
В предыдущей главе мы рассмотрели, что такое прерывания и как в эту картину вписывается NVIC. Теперь посмотрим, как прерывания на самом деле используются в Embassy.
В Embassy обычно не нужно писать обработчики прерываний самостоятельно. Асинхронные драйверы используют прерывания внутри себя, чтобы ждать аппаратных событий и пробуждать задачи, когда эти события происходят. Ваш код просто ожидает операцию (await) и продолжается, когда она готова.
Для некоторой периферии Embassy нужна небольшая настройка, чтобы знать, какое аппаратное прерывание относится к какому драйверу. Здесь на помощь приходит bind_interrupts!.
Зачем нужен bind_interrupts!
Асинхронная периферия, такая как I2C и SPI, не завершает свою работу за один шаг. Пока операция выполняется, задача засыпает, а аппаратура генерирует прерывания по мере продвижения работы.
Обработчики прерываний для такой периферии Embassy уже предоставляет. От вас требуется только связь между аппаратным прерыванием и обработчиком, который должен его обслуживать. Именно эту связь и создаёт макрос bind_interrupts!.
Здесь вы не пишете обработчик прерывания. Вы просто подключаете всё так, чтобы асинхронный драйвер мог работать.
Привязка прерывания для I2C
Вот простой пример для I2C:
#![allow(unused)]
fn main() {
use embassy_rp::{bind_interrupts, i2c};
use embassy_rp::peripherals::I2C0;
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
Это говорит Embassy, что прерывание I2C0_IRQ должен обрабатывать драйвер I2C для I2C0. Когда это настроено, асинхронные операции I2C могут корректно засыпать и просыпаться.
Использование асинхронного I2C
После привязки прерывания использование асинхронного I2C выглядит привычно:
#![allow(unused)]
fn main() {
use embassy_rp::i2c::{I2c, Config as I2cConfig};
use embassy_rp::peripherals::I2C0;
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c = I2c::new_async(
p.I2C0,
scl,
sda,
Irqs,
I2cConfig::default(),
);
}
Когда позже вы вызываете асинхронную операцию, например:
#![allow(unused)]
fn main() {
i2c.write(0x3C, &[0x00]).await;
}
ваша задача приостанавливается и даёт выполниться другому коду. Тем временем аппаратура I2C делает свою работу. Когда аппаратура заканчивает, срабатывает прерывание, и Embassy пробуждает вашу задачу. Прерывание происходит за кулисами: вы видите только, что код продолжается после .await.
Inter-Integrated Circuit (I2C)
До сих пор мы переключали выходные выводы между уровнями High и Low, чтобы управлять светодиодом, и считывали те же два уровня с кнопки. Но для работы с такими интересными устройствами, как модули дисплеев, RFID-считыватели и считыватели SD-карт, этого мало. Простого переключения выводов здесь недостаточно: нужен полноценный механизм связи, и именно для этого существуют протоколы связи. Самые распространённые из них — I2C, SPI и UART. У каждого есть свои плюсы и минусы.
Поскольку в следующей главе мы будем использовать OLED-дисплей, который общается по I2C, именно этот протокол мы изучим первым. OLED-дисплеи — одни из моих любимых модулей. Я использовал их, чтобы делать небольшие игры и множество интересных личных проектов.
Что такое I2C?
I2C расшифровывается как Inter-Integrated Circuit, его также пишут как I²C. Это один из популярных способов связи, которые микроконтроллеры используют для обмена данными с датчиками, дисплеями (например, OLED) и другими микросхемами. Это последовательный, полудуплексный и синхронный интерфейс. Разберём, что это значит.
-
Последовательный (serial) означает, что данные передаются по одному биту, один за другим. Каждый бит отправляется отдельно, а не сразу несколько битов. Эти биты передаются по сигнальной линии данных во время обмена.
-
Полудуплексный (half duplex) означает, что данные передаются только в одном направлении в каждый момент времени. Устройство либо отправляет данные, либо принимает их, но не то и другое одновременно. Направление переключается по мере необходимости во время обмена.
-
Синхронный (synchronous) означает, что оба устройства опираются на общий сигнал тактирования. Этот тактовый сигнал точно задаёт, когда отправляется и считывается каждый бит данных, и держит оба устройства в согласии во время обмена.
Контроллер и цель
I2C использует модель «контроллер — цель». Контроллер (раньше его называли master, «ведущий») — это устройство, которое инициирует обмен и формирует тактовый сигнал. Цель (раньше её называли slave, «ведомый») отвечает на команды контроллера.
Рисунок: один контроллер и одна цель
В типичных embedded-проектах микроконтроллер (например, Pico) выступает контроллером, а подключённые устройства, такие как дисплеи (например, OLED) или датчики, — целями.
I2C позволяет легко подключить множество устройств на одни и те же два провода. К одному контроллеру можно подключить несколько целей, и это самая распространённая схема. I2C также поддерживает несколько контроллеров на одной шине, поэтому несколько контроллеров могут обмениваться данными с одной или несколькими целями.
Шина I2C
Шина I2C использует всего две линии, которые общие для всех подключённых устройств:
-
SCL (Serial Clock Line, линия тактирования): передаёт тактовый сигнал от контроллера. Иногда устройства обозначают её как SCK.
-
SDA (Serial Data Line, линия данных): передаёт данные в обоих направлениях. Иногда устройства обозначают её как SDI.
Рисунок: один контроллер и несколько целей
Все подключённые устройства используют одни и те же два провода. Контроллер выбирает, с каким устройством общаться, отправляя его уникальный адрес.
Адреса I2C
У каждой цели I2C есть 7-битный или 10-битный адрес. Чаще всего используется 7-битный, который позволяет получить до 128 возможных адресов.
Многие устройства имеют фиксированный адрес, заданный производителем, но другие позволяют настроить младшие биты адреса с помощью выводов или перемычек. Например, датчик может использовать выводы с подписями A0 и A1, чтобы изменить свой адрес, — так на одной шине можно разместить несколько одинаковых микросхем.
Когда контроллер хочет связаться с целью, он начинает с условия START, затем отправляет адрес устройства и бит чтения/записи. Устройство с подходящим адресом отвечает сигналом ACK (acknowledge, подтверждение), и обмен продолжается.
Режимы скорости
I2C поддерживает разные режимы скорости в зависимости от того, как быстро нужно передавать данные. Стандартный режим работает до 100 кбит/с, быстрый режим (fast mode) — до 400 кбит/с, а режим Fast Mode Plus — до 1 Мбит/с. Для ещё более быстрой связи есть высокоскоростной режим (High-Speed), который поддерживает до 3,4 Мбит/с. Также есть сверхбыстрый режим (Ultra-Fast) — 5 Мбит/с. Какую скорость можно использовать, зависит от того, какие режимы поддерживаются и I2C-интерфейсом микроконтроллера, и подключёнными целевыми устройствами.
Почему I2C?
I2C идеален, когда нужно подключить несколько устройств всего двумя проводами. Он хорошо подходит для задач, где скорость не критична, а простота проводки важна.
Хорошая новость в том, что в embedded Rust не нужно реализовывать протокол I2C самостоятельно. Крейт embedded-hal определяет общие трейты I2C, а HAL для вашего чипа берёт на себя низкоуровневые детали. В следующем разделе мы подробнее разберём это.
Ресурсы
- Основы протокола связи I2C: обратитесь к этой статье, если хотите глубже понять, как контроллер общается с целью.
I2C у Raspberry Pi Pico 2 (RP2350)
Теперь, когда вы понимаете основы протокола I2C, посмотрим, как он работает на Raspberry Pi Pico 2. В RP2350 есть два независимых контроллера I2C, которые называются I2C0 и I2C1. Представьте их как два независимых канала связи, которые могут работать одновременно. Это полезно, когда два устройства имеют одинаковый адрес I2C: их можно подключить к разным контроллерам.
Доступные выводы I2C
Оба контроллера I2C поддерживают несколько вариантов выводов для SDA и SCL. Для каждого контроллера нужно выбрать только одну пару.
| Контроллер I2C | Выводы GPIO |
|---|---|
| I2C0 - SDA | GP0, GP4, GP8, GP12, GP16, GP20 |
| I2C0 - SCL | GP1, GP5, GP9, GP13, GP17, GP21 |
| I2C1 - SDA | GP2, GP6, GP10, GP14, GP18, GP26 |
| I2C1 - SCL | GP3, GP7, GP11, GP15, GP19, GP27 |
На схеме платы Pico 2 выводы, которые поддерживают I2C, помечены надписями SDA и SCL, а также выделены синим цветом, чтобы их было легко найти.
Варианты скорости
Контроллеры I2C в RP2350 поддерживают три режима скорости, что позволяет подобрать их под возможности подключаемых устройств:
- Стандартный режим: до 100 кбит/с (килобит в секунду) — самый медленный, но самый совместимый
- Быстрый режим: до 400 кбит/с — хороший баланс для большинства датчиков и дисплеев
- Режим Fast Mode Plus: до 1000 кбит/с — когда нужна более быстрая передача данных
Стоит отметить, что RP2350 не поддерживает сверхскоростные режимы (высокоскоростной на 3,4 Мбит/с или сверхбыстрый на 5 Мбит/с), которые используются некоторыми специализированными устройствами. Однако большинство распространённых датчиков, дисплеев и периферийных устройств прекрасно работают в поддерживаемом диапазоне скоростей.
Режим контроллера или цели
RP2350 в любой момент может работать либо как контроллер (master), либо как цель (slave), но не оба одновременно на одном контроллере. В типичных проектах, где Pico 2 управляет датчиками и дисплеями, вы всегда будете использовать режим контроллера.
Полные технические характеристики см. на странице 983 даташита RP2350.
Использование I2C в экосистеме embedded Rust
В предыдущем разделе мы изучили основы связи по I2C и модель «контроллер — цель» (раньше «ведущий — ведомый»). Теперь посмотрим, как эти понятия применяются в экосистеме embedded Rust, где ключевой принцип — модульность и повторное использование кода.
Роль embedded-hal
Крейт embedded-hal определяет стандартный набор трейтов для абстракции embedded-оборудования, включая I2C. Эти трейты позволяют писать код драйверов (например, для дисплеев или датчиков) в обобщённом виде, чтобы он работал на многих микроконтроллерах без платформенно-зависимых изменений.
Основной трейт I2C выглядит так:
#![allow(unused)]
fn main() {
pub trait I2c<A: AddressMode = SevenBitAddress>: ErrorType {
// Этот метод обязаны реализовать авторы HAL
fn transaction(...);
// Это методы по умолчанию, построенные поверх `transaction`
fn read(...);
fn write(...);
fn write_read(...);
}
}
Единственный метод, который HAL обязан реализовать, — transaction. Трейт предоставляет реализации по умолчанию для read, write и write_read, которые используют этот метод.
Обобщённый параметр типа A задаёт режим адресации и имеет тип по умолчанию SevenBitAddress. Поэтому в большинстве случаев его не нужно указывать вручную. Для 10-битной адресации можно использовать TenBitAddress.
Крейты HAL конкретных микроконтроллеров (например, esp-hal, stm32-hal или nrf-hal) реализуют этот трейт для своей периферии I2C. Например, крейт esp-hal реализует I2C. Если вам интересно, можно посмотреть реализацию.
Помимо обычного крейта
embedded-hal, есть асинхронная версия под названиемembedded-hal-async. Она определяет похожие трейты, но они рассчитаны на работу с асинхронным кодом, что полезно при написании неблокирующих драйверов или задач во встраиваемых системах.
Драйверы, не зависящие от платформы
Представьте, что вы пишете драйвер для датчика или дисплея, который общается по I2C. Вы не хотите привязывать свой код к конкретному микроконтроллеру, например Raspberry Pi Pico или ESP32. Вместо этого можно написать драйвер обобщённо, используя трейт embedded-hal.
Пока ваш драйвер зависит только от трейта I2C, он может работать на любой платформе, которая предоставляет реализацию этого трейта, — например, на STM32, nRF или ESP32.
Совместное использование шины I2C
Во многих embedded-проектах к одним и тем же линиям SDA и SCL подключают несколько устройств I2C (например, OLED-дисплей, LCD и различные датчики). Однако в каждый момент времени шиной может управлять только одно устройство.
Рисунок: микроконтроллер (Pico) и несколько устройств
Если отдать эксклюзивный доступ одному драйверу, другие устройства не смогут обмениваться данными. Здесь помогает крейт embedded-hal-bus.
Он предоставляет типы-обёртки, такие как AtomicDevice, CriticalSectionDevice и RefCellDevice, которые позволяют нескольким драйверам безопасно разделять доступ к одной шине I2C. Сами эти обёртки реализуют трейт I2c, поэтому драйверы могут использовать их так, будто это исходная шина.
Использовать I2C можно двумя способами:
-
Без совместного использования: если ваше приложение общается только с одним устройством I2C, можно передать драйверу экземпляр шины I2C, который предоставляет HAL (реализующий трейт
I2c), напрямую. -
С совместным использованием: если приложению нужно общаться с несколькими устройствами I2C на одной шине, можно обернуть экземпляр шины, предоставленный HAL, одним из типов совместного доступа из крейта
embedded-hal-bus, напримерAtomicDeviceилиCriticalSectionDevice. Это обеспечивает безопасный и согласованный доступ для нескольких драйверов.
Ресурсы
- Документация embedded-hal по I2C: подробное описание того, как устроены трейты I2C и как их предполагается использовать на разных платформах.
I2C в Embassy RP
Посмотрим, как инициализировать и использовать I2C с Embassy на Raspberry Pi Pico 2.
Блокирующий режим
Embassy предоставляет простой способ настроить I2C в блокирующем режиме:
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
info!("set up i2c");
let mut i2c = i2c::I2c::new_blocking(p.I2C0, scl, sda, Config::default());
}
Мы используем метод new_blocking, чтобы создать экземпляр I2C, который ждёт завершения каждой операции, прежде чем продолжить. Сначала выбираем периферию I2C, с которой будем работать, — I2C0 или I2C1. Выбрав периферию, нужно сопоставить её с правильными выводами GPIO для SCL и SDA.
Для конфигурации реализация по умолчанию даёт стандартную скорость обмена 100 кГц и включает внутренние подтягивающие резисторы (pull-up).
Настройка Config
Структура Config позволяет управлять поведением шины I2C. Можно изменить скорость обмена и включить или выключить внутренние подтягивающие резисторы на линиях SDA и SCL.
Чтобы увеличить скорость шины, можно изменить поле frequency:
#![allow(unused)]
fn main() {
let mut config = Config::default();
config.frequency = 400_000;
}
Если в схеме уже есть внешние подтягивающие резисторы, можно отключить внутренние:
#![allow(unused)]
fn main() {
let mut config = Config::default();
config.sda_pullup = false;
config.scl_pullup = false;
}
Отправка данных
Многим устройствам I2C нужно отправлять команды или байты конфигурации. Например, представьте, что мы настраиваем датчик и нужно записать в него два байта:
#![allow(unused)]
fn main() {
const SENSOR_ADDR: u8 = 0x68;
let config_data = [0x6B, 0x00];
i2c.write(SENSOR_ADDR, &config_data)?;
}
Здесь мы отправляем два байта устройству с адресом 0x68. Первый байт 0x6B обычно указывает устройству, в какой регистр мы пишем, а 0x00 — значение, которое нужно записать. Разные устройства используют этот шаблон по-разному, поэтому, чтобы узнать, какие байты отправлять, нужно смотреть даташит вашего устройства.
Чтение из регистра
Большинство устройств I2C хранят данные в регистрах. Чтобы прочитать конкретный регистр, используем write_read. Допустим, мы хотим считать температуру с датчика:
#![allow(unused)]
fn main() {
const TEMP_REGISTER: u8 = 0x41;
let mut buffer = [0u8; 2];
i2c.write_read(SENSOR_ADDR, &[TEMP_REGISTER], &mut buffer)?;
}
Сначала мы сообщаем устройству: «я хочу читать регистр 0x41» (это часть записи), а затем устройство отправляет нам обратно 2 байта данных о температуре (это часть чтения). Метод write_read выполняет обе операции в одной транзакции I2C. После этого в buffer будут лежать сырые байты температуры, которые затем можно преобразовать в реальное значение температуры.
Непрерывное чтение
Некоторые устройства автоматически сдвигают свой внутренний указатель и продолжают выдавать данные. В таких случаях можно использовать простой read:
#![allow(unused)]
fn main() {
let mut buffer = [0u8; 5];
i2c.read(SENSOR_ADDR, &mut buffer)?;
}
Это чтение байтов, начиная с текущей внутренней позиции устройства. Оно используется реже, чем write_read, но полезно для датчиков, которые непрерывно передают данные.
Асинхронный режим
Если мы создаём более сложное приложение, которому нужно выполнять несколько задач одновременно, можно использовать асинхронный режим. Он позволяет программе заниматься другой работой, пока операции I2C завершаются:
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
let mut i2c = I2c::new_async(
p.I2C0,
scl,
sda,
Irqs,
I2cConfig::default(),
);
let mut buffer = [0u8; 2];
i2c.write_read(SENSOR_ADDR, &[TEMP_REGISTER], &mut buffer).await?;
}
Режим цели (Slave)
Pico также может работать как устройство-цель I2C (его ещё называют ведомым устройством, slave), которое отвечает на запросы другого контроллера. Однако в большинстве проектов этой книги Pico используется как контроллер, который общается с датчиками и другой периферией, поэтому режим цели мы здесь не рассматриваем.
OLED-дисплей
В этом разделе мы научимся подключать OLED-дисплей к Raspberry Pi Pico 2. OLED-дисплеи — один из самых интересных компонентов для работы, потому что они открывают много творческих возможностей. Можно делать игры, создавать панели мониторинга или наглядно выводить показания датчиков.
Чтобы вы представляли, что возможно, я сделал несколько игр с OLED-дисплеем. Одна из них — Pico Rex, маленькая игра с прыгающим динозавриком, вдохновлённая офлайн-динозавром из Chrome. Вы можете посмотреть её проект на GitHub.
Также я сделал небольшую игру в стиле flappy и игру-шутер (а также другие примеры): смотрите здесь.
Изучая работу с дисплеем, смело экспериментируйте и воплощайте собственные идеи. Даже простые анимации или обновления текста может быть удивительно интересно создавать.
В следующих нескольких главах мы создадим простые проекты, например выведем текст и изображение (картинку Ferris 🦀) на OLED-дисплей. Чтобы подключить OLED к Pico, мы будем использовать протокол I2C.
Знакомство с железом
OLED, сокращение от Organic Light-Emitting Diode (органический светодиод), — популярный модуль дисплея. Такие дисплеи бывают разных размеров и могут поддерживать разные цвета. Они обмениваются данными по протоколу I²C или SPI.
В этом упражнении мы используем монохромный OLED-модуль 0,96 дюйма с разрешением 128 × 64. Он работает от 3,3 В. Для связи мы используем протокол I2C.
Tip
Чаще всего OLED-дисплеи поставляются с штыревыми разъёмами, но не припаянными. Пайка — полезный навык, но для неё нужна аккуратность и подготовка. Прежде чем приступать, посмотрите побольше обучающих видео и почитайте материалы. Сначала это может казаться сложным, но с практикой становится легче. Если пайка вам пока не по силам, стоит поискать уже припаянную версию дисплея, хотя она может стоить немного дороже.
SSD1306
SSD1306 — интегрированная микросхема контроллера, которая управляет многими небольшими OLED-дисплеями, включая модуль, который мы будем использовать (модуль 0,96 дюйма 128x64). Этот контроллер обеспечивает обмен данными между Pico и OLED-панелью, позволяя дисплею показывать текст, графику и многое другое.
Даташит: даташит SSD1306.
Как работает OLED-модуль?
Мы не будем углубляться в детали технологии OLED. Вместо этого сосредоточимся на том, что важно для наших упражнений. Модуль имеет разрешение 128 × 64, то есть всего 128 × 64 = 8192 пикселя. Каждый пиксель можно включать и выключать независимо.
Не переживайте, если эти понятия пока не очень понятны, — вы всегда можете изучить их позже. Эти детали нужнее, если вы планируете написать собственный драйвер для SSD1306 или заниматься более сложными задачами. Сейчас же для этого уже есть хороший крейт, который берёт эти аспекты на себя и упрощает процесс.
В даташите 128 столбцов называются сегментами (segments), а 64 строки — общими линиями (commons). Будьте внимательны и не путайте «commons» с «columns» (столбцами), потому что слова похожи.
Память
Пиксели OLED-дисплея организованы в страничную структуру внутри GDDRAM (Graphics Display DRAM, графическая DRAM дисплея). GDDRAM разделена на 8 страниц (от страницы 0 до страницы 7), каждая из которых состоит из 128 столбцов (сегментов) и 8 строк (общих линий).
(Изображение взято из даташита)
Сегмент — это 8 бит данных (один байт), где каждый бит отвечает за один пиксель. При записи данных вы записываете целый сегмент, то есть весь байт сразу.
(Изображение взято из даташита)
Сегменты и общие линии можно переназначить программно, что даёт механическую гибкость. Подробности см. на странице 25 даташита SSD1306.
Страницы и сегменты
Я создал изображение, которое показывает, как 128 × 64 пикселя делятся на 8 страниц. Затем я сосредоточился на одной странице, которая содержит 128 сегментов (столбцов) и 8 строк. Наконец, я приблизил один сегмент, чтобы показать, что он представляет 8 пикселей, расположенных друг над другом, где каждый пиксель соответствует одному биту.
Схема подключения
Для OLED-дисплея нужно четыре соединения с Raspberry Pi Pico. В этом примере используется I2C0 с GPIO 16 и 17, но можно выбрать любую допустимую пару выводов I2C на вашем Pico.
| Вывод Pico | Провод | Вывод OLED |
|---|---|---|
| GPIO 16 |
|
SDA |
| GPIO 17 |
|
SCL |
| 3.3 V |
|
VCC |
| GND |
|
GND |
Крейты, которые вы будете использовать
Теперь, когда вы понимаете, что такое SSD1306 и как работает связь по I2C, давайте посмотрим, как на самом деле рисовать графику на дисплее. Возможно, вы думаете, что для каждого пикселя нужно отправлять сырые команды. Хорошая новость в том, что этого делать не нужно. Экосистема Rust предоставляет отличные инструменты, которые сильно упрощают эту задачу.
Рисование на дисплее
При работе с дисплеем SSD1306 на Rust вы будете использовать два основных крейта, которые работают вместе:
embedded-graphics— библиотека рисования, которая позволяет создавать фигуры, текст и изображенияssd1306— драйвер, который управляет самим оборудованием
Что такое embedded-graphics?
embedded-graphics — лёгкая библиотека двумерной графики, созданная для встраиваемых систем с ограниченной памятью. Вместо того чтобы самому устанавливать отдельные пиксели, вы используете команды рисования высокого уровня. Например:
#![allow(unused)]
fn main() {
Circle::new(Point::new(20, 20), 30)
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
.draw(&mut display)?; // display реализует DrawTarget
}
Этот код рисует окружность, и вам не нужно вычислять позиции пикселей или разбираться, как дисплей хранит свои данные внутри.
Что предоставляет embedded-graphics:
- Графические примитивы, такие как окружности, прямоугольники, линии и треугольники
- Вывод текста — отображение текста разными шрифтами
- Поддержку изображений и иконок через совместимые крейты для изображений
- Параметры стиля для цвета, толщины линии и заливки
Ключевой принцип дизайна
embedded-graphics полностью не зависит от дисплея. Он ничего не знает о вашем конкретном оборудовании (будь то SSD1306, ST7789 или любой другой дисплей). Он просто умеет описывать, что нужно нарисовать. А драйвер дисплея берёт на себя детали конкретного оборудования и превращает эти инструкции в реальные пиксели.
Такой дизайн означает, что код рисования можно написать один раз и использовать с разными дисплеями, просто поменяв драйвер.
Подробнее можно узнать в официальной документации: https://docs.rs/embedded-graphics/latest/embedded_graphics/
Что такое крейт ssd1306?
Крейт ssd1306 — аппаратный драйвер для дисплеев, которые используют микросхему контроллера SSD1306. Он выполняет всю низкоуровневую работу, необходимую для связи с дисплеем. Это инициализация экрана, отправка правильных команд I2C или SPI, управление внутренним буфером при необходимости и обновление пикселей на аппаратуре.
Графический режим
Драйвер ssd1306 поддерживает несколько режимов, но для рисования графики вы будете использовать BufferedGraphicsMode. Чтобы войти в этот режим, вызовите:
#![allow(unused)]
fn main() {
let display = ssd1306::Ssd1306::new(i2c, size, rotation)
.into_buffered_graphics_mode();
}
В этом режиме драйвер хранит внутренний буфер в RAM и реализует трейт DrawTarget из embedded-graphics-core. Именно это позволяет двум крейтам работать вместе.
Как они работают вместе: трейт DrawTarget
Если вы дошли до этого места, то, вероятно, уже понимаете трейты Rust. Трейт в общих чертах описывает, что что-то умеет делать, а реализация решает, как именно это делается.
DrawTarget — трейт, который позволяет embedded-graphics отправлять команды рисования драйверу дисплея. Когда драйвер ssd1306 реализует этот трейт, по сути он говорит:
«Я могу принимать пиксели, которые создаёт embedded-graphics, и я знаю, как вывести эти пиксели на экран SSD1306».
Вот что на самом деле происходит, когда вы что-то рисуете:
- Вы создаёте фигуры, текст или изображения с помощью примитивов embedded-graphics.
- Вы вызываете
.draw(&mut display), чтобы их отрисовать. embedded-graphicsгенерирует пиксели, которые нужно нарисовать.- Драйвер
ssd1306берёт эти пиксели и сохраняет их во внутреннем буфере. - Когда вы вызываете
display.flush(), драйвер отправляет обновлённые пиксели на OLED-аппаратуру.
Вот простой пример:
#![allow(unused)]
fn main() {
// Создаём окружность с помощью embedded-graphics
let circle = Circle::new(Point::new(64, 32), 20)
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On));
// Рисуем её на драйвере дисплея
circle.draw(&mut display)?; // Записываем данные пикселей в буфер драйвера
display.flush()?; // Отправляем буфер на настоящий экран
}
Асинхронный режим
Крейт ssd1306 также поддерживает асинхронную работу, если включить функцию async. Это полезно, если вы используете Embassy.
Чтобы использовать асинхронный режим, добавьте функцию в Cargo.toml:
ssd1306 = { version = "0.10.0", features = ["async"] }
Когда функция async включена, драйвер использует трейты embedded-hal-async вместо обычных блокирующих трейтов embedded-hal. Это позволяет выполнять обмен по I2C/SPI асинхронно, что удобно, когда вы хотите заниматься другими задачами, пока обновляется дисплей.
Главное отличие в том, что такие методы, как .init() и .flush(), становятся асинхронными и их нужно вызывать с .await:
#![allow(unused)]
fn main() {
// Асинхронная версия
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display.init().await?; // Обратите внимание на .await
// Рисование работает так же
circle.draw(&mut display)?;
display.flush().await?; // Теперь это тоже асинхронно
}
Hello OLED
Будем делать всё просто: выведем «Hello, Rust!» на OLED-дисплей. Сначала сделаем это с Embassy, а затем повторим то же самое с помощью rp-hal.
Создание проекта
Как обычно, создайте проект из шаблона с помощью cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «hello-oled», и выберите «embassy» в качестве HAL. Включите логирование defmt, если у вас есть отладочный зонд, чтобы также видеть логи.
Обновление зависимостей
Добавьте следующие строки в Cargo.toml в раздел dependencies:
embedded-graphics = "0.8.1"
ssd1306 = { version = "0.10.0", features = ["async"] }
Мы включаем функцию async, чтобы драйвер ssd1306 работал с асинхронным I2C из Embassy. Можно использовать его и без этой функции, тогда I2C из Embassy будет работать в блокирующем режиме.
Дополнительные импорты
Добавьте эти импорты в начало main.rs:
#![allow(unused)]
fn main() {
// Привязка прерываний
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
text::{Baseline, Text},
};
}
Привязка прерывания I2C
Мы подробно обсуждали это в разделе о прерываниях, так что вы уже должны знать, что это делает. Здесь прерывание I2C0_IRQ привязывается к обработчику прерываний I2C из Embassy для I2C0.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
Инициализация I2C
Сначала нужно настроить шину I2C для связи с дисплеем.
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; //400 кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
}
Мы подключили линию SDA OLED к выводу 16, а линию SCL — к выводу 17. В этой главе мы будем использовать эти же выводы. Если вы подключили дисплей к другой допустимой паре I2C, измените код, чтобы он соответствовал вашей проводке.
Мы используем метод new_async, чтобы создать экземпляр I2C в асинхронном режиме. Это позволяет передачам I2C ожидать завершения (await), вместо того чтобы блокировать процессор. Скорость шины 400 кГц — распространённое значение, которое поддерживают дисплеи SSD1306.
Инициализация дисплея
Теперь создаём интерфейс дисплея и инициализируем его:
#![allow(unused)]
fn main() {
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
}
I2CDisplayInterface::new(i2c_bus) оборачивает асинхронную шину I2C, чтобы её мог использовать драйвер SSD1306. Он использует адрес I2C по умолчанию 0x3C, который стандартен для большинства модулей SSD1306.
Мы создаём экземпляр дисплея, указав дисплей 128x64 и ориентацию по умолчанию. Также включаем режим буферизованной графики, чтобы рисовать в буфер RAM с помощью embedded-graphics.
#![allow(unused)]
fn main() {
display
.init()
.await
.expect("failed to initialize the display");
}
Наконец, display.init() отправляет команды инициализации аппаратуре дисплея. Это пробуждает дисплей и правильно его настраивает.
Вывод текста
Прежде чем рисовать текст, нужно определить, как он будет выглядеть:
#![allow(unused)]
fn main() {
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
}
Здесь создаётся стиль текста с использованием FONT_6X10 — встроенного моноширинного шрифта шириной 6 пикселей и высотой 10 пикселей. Мы задаём BinaryColor::On, чтобы выводить белые пиксели на чёрном фоне, поскольку OLED монохромный.
Теперь выведем текст в буфер дисплея:
#![allow(unused)]
fn main() {
defmt::info!("sending text to display");
Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top)
.draw(&mut display)
.expect("failed to draw text to display");
}
Мы выводим «Hello, Rust!» в позицию (0, 16), то есть на 16 пикселей ниже верхнего края экрана. Используем стиль текста, который определили ранее, и выравниваем текст по верхнему краю с помощью Baseline::Top.
Вызов .draw(&mut display) отрисовывает текст во внутренний буфер дисплея. На этом этапе текст существует в RAM, но ещё не виден на физическом экране.
Отображение текста
Наконец, отправляем содержимое буфера на настоящую OLED-аппаратуру:
#![allow(unused)]
fn main() {
display
.flush()
.await
.expect("failed to flush data to display");
}
Вот когда происходит обмен по I2C. Драйвер отправляет байты из RAM контроллеру дисплея, и вы увидите, как на OLED-экране появляется «Hello, Rust!»!
Полный код
Вот всё вместе:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Привязка прерываний
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
text::{Baseline, Text},
};
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; //400 кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
defmt::info!("sending text to display");
Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top)
.draw(&mut display)
.expect("failed to draw text to display");
display
.flush()
.await
.expect("failed to flush data to display");
loop {
Timer::after_millis(100).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"hello-oled"),
embassy_rp::binary_info::rp_program_description!(c"Hello OLED"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку hello-oled.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/oled/hello-oled
Hello Rust на OLED
То же самое приветствие мы сделаем и с rp-hal.
Создание проекта из шаблона
Чтобы создать проект, выполните:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, придумайте имя проекта — пусть будет «oh-led». Не забудьте выбрать rp-hal в качестве HAL.
Затем перейдите в папку проекта:
cd PROJECT_NAME
# Например, если вы назвали проект «oh-led»:
# cd oh-led
Добавление дополнительных зависимостей
Так как мы используем OLED-дисплей на SSD1306, нужно подключить драйвер SSD1306. Чтобы добавить эту зависимость, используйте команду cargo:
cargo add ssd1306@0.10.0
Для вывода графики на OLED-дисплей, рисования изображений, фигур и текста мы будем использовать крейт embedded_graphics.
cargo add embedded-graphics@0.8.1
Дополнительные импорты
Помимо импортов из шаблона, для этой задачи понадобятся следующие зависимости.
#![allow(unused)]
fn main() {
// Embedded Graphics
use embedded_graphics::mono_font::MonoTextStyleBuilder;
use embedded_graphics::mono_font::ascii::FONT_6X10;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::text::{Baseline, Text};
// Для установки частоты
use hal::fugit::RateExtU32;
use hal::gpio::{FunctionI2C, Pin};
// Дисплей SSD1306
use ssd1306::{I2CDisplayInterface, Ssd1306, prelude::*};
}
Настройка выводов
Начнём с настройки выводов GPIO для связи по I2C. В этом случае GPIO16 назначен как вывод SDA, а GPIO17 — как вывод SCL. Затем настраиваем периферию I2C в режиме контроллера.
#![allow(unused)]
fn main() {
// Переназначаем два вывода как I²C, а не GPIO
let sda_pin: Pin<_, FunctionI2C, _> = pins.gpio16.reconfigure();
let scl_pin: Pin<_, FunctionI2C, _> = pins.gpio17.reconfigure();
// Создаём шину I²C, используя два заранее настроенных вывода. Это вызовет
// ошибку на этапе компиляции, если выводы в неправильном режиме или если эта
// периферия I²C недоступна на этих выводах!
let i2c = hal::I2C::i2c0(
pac.I2C0,
sda_pin,
scl_pin,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
}
Подготовка дисплея
Создаём интерфейс для OLED-дисплея через I2C.
#![allow(unused)]
fn main() {
// Вспомогательную структуру предоставляет крейт ssd1306
let interface = I2CDisplayInterface::new(i2c);
// Инициализируем дисплей
let mut display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display.init().expect("failed to initialize the display");
}
Задаём стиль текста и рисуем
Далее определяем стиль текста и используем его, чтобы вывести «Hello Rust» на экран:
#![allow(unused)]
fn main() {
// Embedded graphics
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
Text::with_baseline(
"Hello, Rusty!",
Point::new(0, 16),
text_style,
Baseline::Top,
)
.draw(&mut display)
.expect("failed to draw text to display");
}
Здесь мы пишем сообщение с координатами (x=0, y=16).
Выгрузка данных на дисплей
#![allow(unused)]
fn main() {
display.flush().expect("failed to flush data to display");
}
Полный код
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Embedded Graphics
use embedded_graphics::mono_font::MonoTextStyleBuilder;
use embedded_graphics::mono_font::ascii::FONT_6X10;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::text::{Baseline, Text};
// Для установки частоты
use hal::fugit::RateExtU32;
use hal::gpio::{FunctionI2C, Pin};
// Дисплей SSD1306
use ssd1306::{I2CDisplayInterface, Ssd1306, prelude::*};
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// Внешний высокоскоростной кварц на плате Raspberry Pi Pico 2 работает на 12 МГц.
/// Измените значение, если на вашей плате другая частота
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
// Получаем наши синглтон-объекты
let mut pac = hal::pac::Peripherals::take().unwrap();
// Настраиваем драйвер сторожевого таймера — он нужен для настройки тактирования
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Настраиваем тактирование
//
// По умолчанию генерируется системная частота 125 МГц
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// Блок SIO с однотактным доступом управляет нашими выводами GPIO
let sio = hal::Sio::new(pac.SIO);
// Настраиваем выводы в соответствии с их функциями на этой плате
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
// Переназначаем два вывода как I²C, а не GPIO
let sda_pin: Pin<_, FunctionI2C, _> = pins.gpio16.reconfigure();
let scl_pin: Pin<_, FunctionI2C, _> = pins.gpio17.reconfigure();
// Создаём шину I²C, используя два заранее настроенных вывода. Это вызовет
// ошибку на этапе компиляции, если выводы в неправильном режиме или если эта
// периферия I²C недоступна на этих выводах!
let i2c = hal::I2C::i2c0(
pac.I2C0,
sda_pin,
scl_pin,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
// Вспомогательную структуру предоставляет крейт ssd1306
let interface = I2CDisplayInterface::new(i2c);
// Инициализируем дисплей
let mut display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display.init().expect("failed to initialize the display");
// Embedded graphics
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
Text::with_baseline(
"Hello, Rusty!",
Point::new(0, 16),
text_style,
Baseline::Top,
)
.draw(&mut display)
.expect("failed to draw text to display");
display.flush().expect("failed to flush data to display");
loop {
timer.delay_ms(100);
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку hello-oled.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/oled/hello-oled
Вывод растрового изображения на OLED-дисплей на Raspberry Pi Pico
В этом упражнении мы выведем растровое изображение, используя только массивы байтов. Мы создадим символ Ом (Ω) в формате 1BPP (1 бит на пиксель).
Изображение 1BPP
Формат 1BPP (1 бит на пиксель) использует один бит для каждого пикселя. Он может представлять только два цвета, обычно чёрный и белый. Если значение бита равно 0, обычно это полностью чёрный пиксель. Если значение бита равно 1, обычно это полностью белый пиксель.
Мы создадим символ Ом на сетке 8 × 5 пикселей в формате 1bpp. Я выделил единицы в массиве байтов, чтобы показать, как они включают пиксели и формируют символ Ом.
Я выбрал ширину 8, чтобы сделать пример простым. Это позволяет представить ширину 8 пикселей одним байтом (8 битов). Но если увеличить ширину, она уже не поместится в один байт, и изображение придётся разнести по нескольким элементам массива байтов. Я объясню это в следующих главах. А пока давайте держать всё простым.
Символ Ом на OLED-дисплее (128x64)
Покажу, как выглядит символ Ом, выведенный на OLED-дисплей (разрешение 128 × 64) в позиции ноль (x = 0 и y = 0).
Это увеличенная иллюстрация. Когда вы увидите символ на реальном модуле дисплея, он будет маленьким.
Ссылки
- Документация ImageRaw в embedded-graphics
- Image2Bytes: преобразование изображения в шестнадцатеричный массив байтов
Вывод изображения из одного байта в embedded Rust с embedded-graphics
К этому моменту, надеюсь, вы поняли, как изображение представлено массивом байтов. Теперь перейдём к части с кодом.
Создание проекта
Как обычно, создайте проект из шаблона с помощью cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «byte-oled», и выберите «embassy» в качестве HAL. Включите логирование defmt, если у вас есть отладочный зонд, чтобы также видеть логи.
Обновление зависимостей
Добавьте следующие строки в раздел dependencies вашего Cargo.toml:
embedded-graphics = "0.8.1"
ssd1306 = { version = "0.10.0", features = ["async"] }
Дополнительные импорты
Добавьте эти импорты в начало main.rs:
#![allow(unused)]
fn main() {
// Привязка прерываний
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
image::{Image, ImageRaw},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
};
}
Шаблонный код
Эту часть мы уже разобрали в предыдущей главе.
Привязка прерывания I2C
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
Инициализация I2C и экземпляра дисплея
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; // 400 кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
}
Создаём изображение
Изображение мы храним как массив байтов. Каждый байт представляет одну строку пикселей.
#![allow(unused)]
fn main() {
// 8x5 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
0b00111000,
0b01000100,
0b01000100,
0b00101000,
0b11101110,
];
}
Это символ Ом (Ω) шириной 8 пикселей и высотой 5 пикселей. Каждая запись 0b означает, что число записано в двоичном виде с помощью единиц и нулей. Единица означает, что пиксель ВКЛЮЧЁН (белый), а ноль — что пиксель ВЫКЛЮЧЕН (чёрный). Каждая строка представляет одну строку изображения сверху вниз.
Выводим изображение
Теперь выведем изображение на дисплей:
#![allow(unused)]
fn main() {
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 8);
let image = Image::new(&raw_image, Point::zero());
}
Первая строка создаёт сырое изображение из наших байтов. Мы говорим, что ширина изображения 8 пикселей, а высоту оно определяет само. Вторая строка размещает изображение в позиции (0, 0), то есть в левом верхнем углу экрана.
Отображаем изображение
Как и в предыдущей главе, нужно нарисовать изображение в буфер дисплея и затем отправить его на экран:
#![allow(unused)]
fn main() {
image.draw(&mut display).expect("failed to draw text to display");
display.flush().await.expect("failed to flush data to display");
}
Клонируйте существующий проект
Также можно клонировать проект, который я создал, и перейти в папку byte-oled.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/oled/byte-oled
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Привязка прерываний
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
image::{Image, ImageRaw},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
};
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
// 8x5 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
0b00111000,
0b01000100,
0b01000100,
0b00101000,
0b11101110,
];
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; // 400 кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 8);
let image = Image::new(&raw_image, Point::zero());
image
.draw(&mut display)
.expect("failed to draw text to display");
display
.flush()
.await
.expect("failed to flush data to display");
loop {
Timer::after_millis(100).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"byte-oled"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Использование нескольких байтов для представления более широких строк пикселей
В предыдущем примере мы оставались простыми и использовали изображение шириной 8 пикселей. Это было удобно, потому что каждая строка идеально помещалась в один байт. Однако настоящим изображениям часто нужно больше пикселей. Как же их представить, если одного байта недостаточно? Ответ простой: используем несколько байтов. Но тогда возникает проблема. Если используется несколько байтов, как система поймёт, где заканчивается одна строка и начинается следующая?
Именно поэтому нужно точно сообщить крейту embedded-graphics ширину нашего изображения. Когда мы указываем ширину, система знает, сколько байтов использовать для каждой строки. Зная ширину и формат изображения, она может автоматически вычислить высоту.
Разбираем математику
Рассмотрим пример с изображением шириной 31 пиксель и высотой 7 пикселей. Ширина составляет 31 пиксель, и каждый пиксель занимает 1 бит. Чтобы узнать, сколько байтов нужно для каждой строки, выполним простой расчёт. Поскольку в байте 8 битов, делим 31 на 8. Получаем 3 полных байта, которые покрывают 24 пикселя. Но у нас остаётся ещё 7 пикселей, поэтому нужен ещё один байт, чтобы их разместить. Итого на каждую строку из 31 пикселя нужно 4 байта. Если изображение содержит 7 строк, то общая длина данных составит 4 × 7 = 28 байтов.
Как система вычисляет высоту
Крейт embedded-graphics внутри использует примерно такой код, чтобы вычислить высоту. Вам не нужно добавлять это в свой код. Я показываю его только для того, чтобы вы видели, как это работает за кулисами:
#![allow(unused)]
fn main() {
let height = data.len() / bytes_per_row(width, C::Raw::BITS_PER_PIXEL);
//...
//...
const fn bytes_per_row(width: u32, bits_per_pixel: usize) -> usize {
(width as usize * bits_per_pixel + 7) / 8
}
}
В нашем примере массив данных содержит 28 элементов, каждый пиксель занимает 1 бит, а ширина изображения равна 31. Если выполнить этот расчёт, получится 4 байта на строку и высота 7 пикселей.
Попробуйте сами
Можно запустить этот код прямо здесь или в Rust Playground, чтобы увидеть, как работает расчёт:
// 31x7 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
// 1-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 2-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 3-я строка
0b00000001,0b10000000,0b00000011,0b00000000,
// 4-я строка
0b11111111,0b10000000,0b00000011,0b11111110,
// 5-я строка
0b00000001,0b10000000,0b00000011,0b00000000,
// 6-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 7-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
];
const fn bytes_per_row(width: u32, bits_per_pixel: usize) -> usize {
(width as usize * bits_per_pixel + 7) / 8
}
fn main(){
const BITS_PER_PIXEL: usize = 1;
let width = 31;
let data = IMG_DATA;
println!("Bytes Per Row:{}", bytes_per_row(width,BITS_PER_PIXEL));
let height = data.len() / bytes_per_row(width, BITS_PER_PIXEL);
println!("Height: {}", height);
}
Эти массивы байтов не обязательно создавать вручную: можно воспользоваться онлайн-инструментом, например image2bytes, который сгенерирует массив байтов за вас.
Код для многобайтового изображения
Рисуем многобайтовое изображение в embedded Rust с embedded-graphics
Теперь напишем код, который выводит более широкое изображение на OLED-экран. Основные отличия от предыдущего примера — данные изображения и значение ширины. На этот раз мы выведем резистор в стиле IEC-60617.
Основа проекта
Мы скопируем проект byte-oled и будем работать поверх него.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cp -r pico2-embassy-projects/oled/byte-oled ~/YOUR_PROJECT_FOLDER/oled-rawimg
Или можно просто создать новый проект из шаблона и повторить те же шаги, что и раньше.
Данные изображения
Вот массив байтов для символа резистора. Обратите внимание, что каждой строке нужно несколько байтов, потому что изображение шириной 31 пиксель.
#![allow(unused)]
fn main() {
// 31x7 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
// 1-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 2-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 3-я строка
0b00000001,0b10000000,0b00000011,0b00000000,
// 4-я строка
0b11111111,0b10000000,0b00000011,0b11111110,
// 5-я строка
0b00000001,0b10000000,0b00000011,0b00000000,
// 6-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 7-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
];
}
Создание и размещение изображения
Нужно задать ширину 31 пиксель. Изображение мы выведем в позиции (x=35, y=35). Для этих координат нет особой причины: я просто хотел показать, что изображение можно разместить в любом месте экрана, а не только в точке ноль. Не стесняйтесь пробовать другие значения положения и смотреть, что получится.
#![allow(unused)]
fn main() {
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 31);
let image = Image::new(&raw_image, Point::new(35, 35));
}
Клонируйте существующий проект
Также можно клонировать проект, который я создал, и перейти в папку oled-rawimg.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/oled/oled-rawimg
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Привязка прерываний
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
image::{Image, ImageRaw},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
};
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
// 31x7 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
// 1-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 2-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 3-я строка
0b00000001,0b10000000,0b00000011,0b00000000,
// 4-я строка
0b11111111,0b10000000,0b00000011,0b11111110,
// 5-я строка
0b00000001,0b10000000,0b00000011,0b00000000,
// 6-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
// 7-я строка
0b00000001,0b11111111,0b11111111,0b00000000,
];
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; // 400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 31);
let image = Image::new(&raw_image, Point::new(35, 35));
image
.draw(&mut display)
.expect("failed to draw text to display");
display
.flush()
.await
.expect("failed to flush data to display");
loop {
Timer::after_millis(100).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"oled-rawimg"),
embassy_rp::binary_info::rp_program_description!(c"Multi Byte Image on OLED"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Использование файла растрового изображения
Вместо сырых данных изображения можно использовать файлы BMP (.bmp) напрямую, подключив крейт tinybmp. tinybmp — лёгкий парсер BMP, созданный для встраиваемых окружений. В основном он предназначен для вывода BMP-изображений в DrawTarget из embedded_graphics, но его также можно использовать для разбора файлов BMP в других задачах.
Файл BMP
Крейт требует, чтобы изображение было в формате BMP. Если ваше изображение в другом формате, его нужно преобразовать в BMP. Например, в Linux можно использовать такую команду, чтобы преобразовать PNG в монохромный BMP:
convert ferris.png -monochrome ferris.bmp
Я подготовил файл BMP с Ferris, который вы можете скачать и использовать в этом упражнении.
Основа проекта
Мы скопируем проект oled-rawimg и будем работать поверх него.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cp -r pico2-embassy-projects/oled/oled-rawimg ~/YOUR_PROJECT_FOLDER/oled-bmp
Или можно просто создать новый проект из шаблона и повторить те же шаги, что и раньше.
Обновление Cargo.toml
Нужен ещё один крейт — «tinybmp», чтобы загружать BMP-изображение.
tinybmp = "0.6.0"
Использование файла BMP
Поместите файл «ferris.bmp» в папку src. Код довольно простой: загружаем изображение как байты и передаём их в функцию from_slice структуры Bmp. Затем можно использовать результат с Image.
#![allow(unused)]
fn main() {
// Здесь стандартный шаблонный код...
// Подключаем данные файла BMP.
let bmp_data = include_bytes!("../ferris.bmp");
// Разбираем файл BMP.
let bmp = Bmp::from_slice(bmp_data).unwrap();
// Обычный код:
let image = Image::new(&bmp, Point::new(32, 0));
image
.draw(&mut display)
.expect("failed to draw text to display");
defmt::info!("Displaying image");
display.flush().await.expect("failed to flush data to display");
}
Клонируйте существующий проект
Также можно клонировать проект, который я создал, и перейти в папку oled-bmp.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/oled/oled-bmp
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Привязка прерываний
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{image::Image, prelude::Point, prelude::*};
use tinybmp::Bmp;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; // 400 кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
// Подключаем данные файла BMP.
let bmp_data = include_bytes!("../ferris.bmp");
// Разбираем файл BMP.
let bmp = Bmp::from_slice(bmp_data).unwrap();
// Обычный код:
let image = Image::new(&bmp, Point::new(32, 0));
image
.draw(&mut display)
.expect("failed to draw text to display");
defmt::info!("Displaying image");
display
.flush()
.await
.expect("failed to flush data to display");
loop {
Timer::after_millis(100).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"oled-rawimg"),
embassy_rp::binary_info::rp_program_description!(c"Multi Byte Image on OLED"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
ЖК-дисплей
В этом разделе мы будем работать с ЖК-модулями (LCD, Liquid Crystal Display), совместимыми с Hitachi HD44780. Такие символьные ЖК-дисплеи очень распространены и уже десятилетия используются в повседневных устройствах: принтерах, цифровых часах, микроволновках, стиральных машинах, кондиционерах и других бытовых приборах. Их также можно встретить в офисной технике, например в копировальных аппаратах, факсах и сетевых маршрутизаторах.
Эти дисплеи предназначены для вывода символов ASCII, а также поддерживают до 8 пользовательских символов, которые вы можете определить сами.
Варианты исполнения
ЖК-дисплеи, совместимые с HD44780, бывают разных физических форматов. Самые распространённые — дисплеи 16 × 2, с 16 столбцами и 2 строками, и 20 × 4, с 20 столбцами и 4 строками. Они также отличаются цветом подсветки, например синим, жёлтым или зелёным.
Интерфейсы ЖК-дисплея
Дисплеи на базе HD44780 обычно используются одним из двух способов. Различие здесь не в самом дисплее, а в том, как управляющие сигналы доходят до контроллера.
По своей сути контроллер HD44780 работает через параллельный интерфейс. Это родной и самый прямой способ обмена с ЖК-дисплеем. Многие модули выводят этот интерфейс напрямую через свой 16-контактный разъём.
Чтобы упростить подключение, некоторые модули оснащаются платой-адаптером I2C. Эта плата стоит между микроконтроллером и ЖК-дисплеем и преобразует команды I2C в те же параллельные сигналы, которые ожидает контроллер HD44780.
Параллельный интерфейс
При использовании параллельного интерфейса несколько выводов GPIO микроконтроллера подключаются напрямую к дисплею. Эти выводы передают управляющие сигналы и линии данных, а также питание и управление контрастом.
Такой подход требует больше проводов и задействует много выводов GPIO, но он точно отражает работу контроллера HD44780 изнутри. Он полезен для понимания временных диаграмм, команд и низкоуровневого поведения дисплея.
Интерфейс I2C
Существуют варианты с адаптером I2C, установленным на обратной стороне, а также адаптер можно добавить позже, если понадобится.
С адаптером I2C обмен идёт всего по двум сигнальным линиям. Это уменьшает количество нужных соединений и делает проводку намного проще. В большинстве адаптеров I2C есть встроенный подстроечный потенциометр для регулировки контраста. Поэтому при использовании варианта с I2C нам не нужны внешний потенциометр или резисторы.
Вариант с I2C, конечно, немного дороже параллельного, но остаётся недорогим и широко доступным.
В этой книге
В этой книге мы будем использовать версию с I2C. Изначально я использовал параллельный интерфейс, потому что не знал, что покупать. Однако проводка быстро стала сложной: мне пришлось подключить около 12 проводов, тогда как версии с I2C требуется всего 4.
Кроме того, параллельный интерфейс требует дополнительных действий, например настройки потенциометра или делителя напряжения для управления контрастом. С версией I2C эта дополнительная настройка не нужна.
Требования к оборудованию
Нам понадобится ЖК-дисплей LCD1602. Рекомендуется модуль 16 × 2 с адаптером I2C, чтобы вы могли повторять примеры без изменений, хотя и другие размеры работают так же.
Преобразователь уровней
Выводы GPIO Pico рассчитаны на 3,3 В, то есть они не предназначены для сигналов 5 В. Подача на них более высокого напряжения, например 5 В, может повредить плату. Многие LCD1602 с адаптером I2C рассчитаны на работу от 5 В, и при прямом подключении к Pico возникает несоответствие уровней напряжения.
Чтобы безопасно подключить Pico и дисплей, нужно учесть эту разницу напряжений. Для этого используется преобразователь уровней. Двунаправленный преобразователь логических уровней I2C позволяет устройствам на 3,3 В и 5 В безопасно обмениваться данными и защищает выводы GPIO Pico. Такие модули недороги и часто продаются как «4-Channel (I2C) 3.3 V-5 V Bi-Directional Logic Level Converter».
Также можно питать ЖК-дисплей от 3,3 В. Это избавляет от проблемы напряжения, но подсветка и контраст будут заметно слабее.
Даташиты
- Даташит HD44780 можно найти на Sparkfun или на сайте MIT
- LCD Driver Data Book
- Даташит LCD-модуля 1602A
Как это работает?
Жидкокристаллический дисплей (LCD, Liquid Crystal Display) работает, используя жидкие кристаллы, чтобы управлять прохождением света через экран. Когда мы подаём напряжение, жидкие кристаллы меняют свою ориентацию. Это изменение либо пропускает свет, либо блокирует его. Управляя тем, какие участки пропускают свет, а какие его блокируют, ЖК-дисплей может отображать символы и знаки.
Сам экран не излучает свет. Вместо этого подсветка за дисплеем обеспечивает освещение. Жидкие кристаллы выборочно перекрывают эту подсветку, создавая тёмные участки, которые образуют видимые на экране символы.
ЖК-дисплей 16 × 2 и матрица пикселей 5 × 8
ЖК-дисплей 16 × 2 имеет 2 строки и 16 столбцов, поэтому он может одновременно отображать 32 символа. Каждый символ на экране построен из матрицы пикселей 5 × 8. Это значит, что каждый символ формируется из 5 вертикальных столбцов и 8 горизонтальных строк крошечных точек.
Эти точки включаются и выключаются, образуя буквы, цифры и символы.
Вывод текста и пользовательских символов на ЖК-дисплее 16 × 2
При выводе обычного текста рисовать отдельные пиксели не нужно: это делает контроллер HD44780 автоматически. Когда мы отправляем символ ASCII, контроллер находит соответствующий шаблон 5 × 8 и выводит его на экран.
Если нужно вывести собственные символы, например иконки или специальные знаки, можно определить свои шаблоны пикселей 5 × 8. Эти шаблоны хранятся в памяти ЖК-дисплея, и после определения их можно выводить так же, как обычные символы. Одно важное ограничение: в дисплее одновременно может храниться только 8 пользовательских символов.
Режим передачи данных
Контроллер HD44780 поддерживает два режима передачи данных: 8-битный и 4-битный.
При использовании параллельного интерфейса 8-битный режим передаёт целый байт за один раз, задействуя все линии данных. Это быстрее, но требует много выводов GPIO. В 4-битном режиме те же данные передаются в два приёма, используя только четыре линии данных. Это упрощает проводку ценой небольшого снижения быстродействия.
При использовании адаптера I2C плата адаптера внутри себя управляет ЖК-дисплеем через 4-битный параллельный интерфейс. Настраивать это самостоятельно не нужно: адаптер делает всё автоматически.
Чтобы проводка оставалась простой и практичной, мы будем использовать 4-битный режим.
Регулировка контраста
Когда мы включаем ЖК-дисплей, на нём должна быть видна матрица точек. Если после запуска программы текст плохо виден, контраст нужно подрегулировать.
При использовании ЖК-модуля с I2C можно подстроить небольшой потенциометр на плате адаптера I2C, чтобы задать контраст.
Медленно поворачивая этот потенциометр, вы сделаете символы чётче или темнее, пока их не станет легко читать.
Распиновка
Распиновка
При использовании параллельного интерфейса ЖК-дисплей имеет в общей сложности 16 выводов. Эти выводы обеспечивают питание, управление контрастом, управляющие сигналы, линии данных и подключение подсветки.
В интерфейсе I2C эти сигналы упрощены и выведены на меньшее число контактов. Сначала рассмотрим вариант с I2C, а затем параллельный интерфейс.
Распиновка I2C
Адаптер I2C упрощает подключение, внутри себя преобразуя команды I2C в параллельные сигналы. Со стороны микроконтроллера нужны только питание и две линии I2C.
| Вывод | Обозначение | Описание |
|---|---|---|
| 1 | VCC | Питание (обычно 5 В) |
| 2 | GND | Земля |
| 3 | SDA | Линия данных для связи по I2C |
| 4 | SCL | Линия тактирования для связи по I2C |
Распиновка параллельного интерфейса
В параллельном интерфейсе микроконтроллер общается непосредственно с контроллером HD44780. Это даёт больше контроля, но требует больше проводов и аккуратной работы с временными диаграммами.
| Номер вывода | Вывод LCD | Описание |
|---|---|---|
| 1 | VSS | Земля (GND). |
| 2 | VDD | Питание логики ЖК-дисплея, обычно 5 В. |
| 3 | Vo |
Вывод регулировки контраста. - Этот вывод принимает аналоговое напряжение между GND и VDD. - Рекомендуется: использовать потенциометр 10 кОм как делитель напряжения, с движком, подключённым к Vo, и двумя другими выводами — к VDD и GND. - Альтернатива: использовать постоянные резисторы как делитель напряжения между VDD и GND, а среднюю точку подключить к Vo. |
| 4 | RS |
Выбор регистра: - LOW (RS = 0): регистр инструкций или команд. - HIGH (RS = 1): регистр данных. |
| 5 | RW |
Управление чтением или записью: - LOW (RW = 0): запись в ЖК-дисплей. - HIGH (RW = 1): чтение из ЖК-дисплея. - Обычно подключается к GND для работы только на запись. |
| 6 | E | Вывод разрешения (Enable). Данные или команды защёлкиваются при переходе этого вывода с HIGH на LOW. |
| 7–10 | D0–D3 | Младшие биты данных. Используются только в 8-битном режиме. В 4-битном режиме оставляйте их неподключёнными. |
| 11–14 | D4–D7 | Старшие биты данных. Используются для передачи данных и в 4-битном, и в 8-битном режимах. В 4-битном режиме все данные передаются только через эти выводы. |
| 15 | A | Анод подсветки. Часто подключается к 5 В. Некоторые модули содержат встроенный резистор ограничения тока. |
| 16 | K | Катод подсветки. Подключается к GND. |
Регулировка контраста
Вывод Vo управляет контрастом ЖК-дисплея, задавая разность напряжений между VDD и Vo. Более низкие значения Vo повышают контраст, а более высокие — уменьшают его.
Рекомендуемый способ — использовать потенциометр, подключённый между VDD и GND, с движком, соединённым с Vo. Так регулировка легко выполняется, пока дисплей включён.
Если потенциометра нет, можно использовать постоянные резисторы как делитель напряжения между VDD и GND, с средней точкой, соединённой с Vo.
Вывод выбора регистра (RS)
Вывод RS определяет, интерпретирует ли ЖК-дисплей поступающие значения как команды или как данные символов.
- RS = LOW: режим команд
- RS = HIGH: режим данных
Вывод разрешения (E)
Вывод Enable управляет моментом, когда данные защёлкиваются в ЖК-дисплее.
Чтобы отправить данные или команду, выставьте значение на линиях данных, установите RS нужным образом, затем на короткое время переведите E в HIGH и верните обратно в LOW. ЖК-дисплей считывает данные при переходе с HIGH на LOW.
Вывод E обычно держат в низком уровне (E=0), но на короткое время переводят в высокий уровень (E=1), чтобы начать передачу данных, а затем возвращают в низкий. Данные защёлкиваются в ЖК-дисплее при переходе с высокого уровня на низкий.
Подключение ЖК-дисплея (LCD1602) к Raspberry Pi Pico
Мы подключим символьный ЖК-дисплей LCD1602 с адаптером I2C к Raspberry Pi Pico. С точки зрения проводки схема выглядит простой, потому что нужны всего четыре соединения: питание, земля, SDA и SCL. Однако, несмотря на небольшое количество проводов, есть важная деталь по напряжению, которую нужно правильно учесть до подключения.
Проблема совместимости по напряжению
Выводы GPIO Raspberry Pi Pico рассчитаны на 3,3 В. Всё, что существенно выше 3,3 В на SDA или SCL, может повредить Pico — сразу или постепенно, со временем.
Большинство модулей LCD1602 с адаптером I2C рассчитаны на работу от 5 В. Адаптер I2C обычно имеет подтягивающие резисторы, подключённые к его напряжению питания. При питании от 5 В это означает, что в состоянии покоя SDA и SCL находятся на уровне 5 В.
Если подключить SDA и SCL такого модуля напрямую к Pico, выводы GPIO Pico окажутся под напряжением 5 В. Именно эту основную проблему нам и нужно решить.
Распространённый обходной путь, который вы увидите в интернете
Многие онлайн-руководства предлагают питать LCD1602 и его адаптер I2C от 5 В и подключать SDA и SCL напрямую к Pico. Я сам проверял эту схему, и она работает. Однако с точки зрения электрики это небезопасно, и долговременное использование может повредить выводы GPIO Pico.
По этой причине, хотя такая схема работает, её нельзя считать безопасной или рекомендованной.
Простой, но достаточно безопасный способ: питание всего от 3,3 В
Для демонстраций, экспериментов и учебных проектов самый простой и безопасный подход — питать модуль LCD1602 с I2C от шины 3,3 В Pico, а не от 5 В.
Когда адаптер питается от 3,3 В, его подтягивающие резисторы I2C подтягивают SDA и SCL к 3,3 В вместо 5 В. Это сразу убирает проблему совместимости по напряжению, и SDA и SCL можно подключить напрямую к Pico.
Компромисс в том, что контраст и яркость подсветки ЖК-дисплея будут ниже. В большинстве помещений дисплей остаётся читаемым и обычно вполне подходит для демонстраций и тестирования.
Такой подход не требует дополнительных компонентов и не нагружает выводы GPIO Pico.
| Вывод LCD | Провод | Вывод Pico | Примечания |
|---|---|---|---|
| GND |
|
GND | Общая земля |
| VCC |
|
3.3 V | Питание ЖК-дисплея 3,3 В |
| SCL |
|
GPIO 17 | Линия тактирования I2C (SCL I2C0) |
| SDA |
|
GPIO 16 | Линия данных I2C (SDA I2C0) |
Лучший вариант: использование преобразователя уровней
Если нужна полная яркость подсветки или если ваш ЖК-модуль не работает надёжно при 3,3 В, его придётся питать от 5 В. Но тогда нужно защитить Pico от сигналов 5 В.
Решение — двунаправленный преобразователь логических уровней (также называемый level shifter). Этот небольшой модуль преобразует сигналы между 3,3 В и 5 В в обоих направлениях. Такие модули продаются как «2-Channel» или «4-Channel I2C/SPI Logic Level Converter».
Pico подключается к стороне 3,3 В преобразователя, а ЖК-дисплей — к стороне 5 В. Преобразователь гарантирует, что Pico видит только сигналы 3,3 В, а ЖК-дисплей получает сигналы 5 В, которые ему нужны.
Это электрически правильный и безопасный способ, но он добавляет несколько проводов и требует дополнительного модуля.
На первый взгляд схема может показаться немного запутанной, но идея проста, если разобрать её по частям.
Подключение питания (сначала подключите эти провода)
Сначала подключите выход 3,3 В Pico к выводу, помеченному LV (Low Voltage), на преобразователе. Подключите VBUS Pico (5 В) к выводу, помеченному HV (High Voltage), на преобразователе. Вывод VCC ЖК-дисплея также питается напрямую от VBUS Pico. Земля должна быть общей, поэтому соедините вместе GND Pico, GND преобразователя и GND ЖК-дисплея.
| От | Вывод | Провод | К | Вывод | Назначение |
|---|---|---|---|---|---|
| Raspberry Pi Pico | GND |
|
Преобразователь уровней | GND | Общая земля |
| Преобразователь уровней | GND |
|
ЖК-дисплей | GND | Общая земля |
| Raspberry Pi Pico | 3.3 V |
|
Преобразователь уровней | LV | Питание низкого уровня (сторона 3,3 В) |
| Raspberry Pi Pico | VBUS (5 V) |
|
Преобразователь уровней | HV | Питание высокого уровня (сторона 5 В) |
| Raspberry Pi Pico | VBUS (5 V) |
|
ЖК-дисплей | VCC | Питание ЖК-дисплея 5 В |
Линии данных (обмен по I2C)
Теперь о линиях I2C. Выводы Pico всегда должны подключаться к выводам преобразователя с маркировкой LVx, а выводы ЖК-дисплея — к соответствующим выводам с маркировкой HVx. Вывод GPIO 16 Pico (SDA) подключается к LV1, а вывод SDA ЖК-дисплея — к HV1. Вывод GPIO 17 Pico (SCL) подключается к LV2, а вывод SCL ЖК-дисплея — к HV2.
| От | Вывод | Провод | К | Вывод | Назначение |
|---|---|---|---|---|---|
| Raspberry Pi Pico | GPIO 16 |
|
Преобразователь уровней | LV1 | SDA (данные) — сторона Pico |
| Преобразователь уровней | HV1 |
|
ЖК-дисплей | SDA | SDA (данные) — сторона ЖК-дисплея |
| Raspberry Pi Pico | GPIO 17 |
|
Преобразователь уровней | LV2 | SCL (тактирование) — сторона Pico |
| Преобразователь уровней | HV2 |
|
ЖК-дисплей | SCL | SCL (тактирование) — сторона ЖК-дисплея |
Как это работает?
Если говорить просто: когда Pico обменивается данными по линии I2C, сторона LVx работает на 3,3 В, а преобразователь выдаёт сигнал 5 В на соответствующей стороне HVx для ЖК-дисплея. Когда ЖК-дисплей обменивается данными на 5 В, преобразователь переводит этот сигнал обратно, так что Pico видит на стороне LVx только 3,3 В. Таким образом, оба устройства работают на нужном им напряжении, и выводы GPIO Pico не перегружаются.
«Hello, Rust!» на ЖК-дисплее
Мы создадим простую программу, которая выводит «Hello, Rust!» на ЖК-экран. Это поможет быстро проверить, что проводка, настройка I2C и конфигурация дисплея верны, прежде чем переходить к следующему упражнению.
Драйверы HD44780
Драйверы можно найти, выполнив поиск по названию аппаратного контроллера HD44780. Иногда помогает поиск по названию модуля дисплея, например lcd1602.
Пока я искал, я наткнулся на несколько крейтов на Rust, которые могут управлять этим ЖК-дисплеем. Некоторые из них даже поддерживают асинхронный режим. Также можно написать собственный драйвер по даташиту, но это выходит за рамки этой главы.
Tip
Если вы хотите научиться писать собственные драйверы для embedded Rust, можно обратиться к книге Rust Embedded Drivers (RED)
Сейчас мы воспользуемся одним из готовых крейтов. Позже вы можете попробовать и другие. Просто прочитайте документацию крейта и при необходимости адаптируйте код.
В этом упражнении мы будем использовать крейт hd44780-driver
Проект из шаблона
Начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «hello-lcd», и выберите embassy в качестве HAL.
Дополнительные крейты
Добавьте в Cargo.toml следующую зависимость вместе с уже существующими:
#![allow(unused)]
fn main() {
hd44780-driver = "0.4.0"
}
Дополнительные импорты
Добавьте импорты, необходимые для I2C и драйвера ЖК-дисплея.
#![allow(unused)]
fn main() {
// I2C
use embassy_rp::i2c::Config as I2cConfig;
use embassy_rp::i2c::{self}; // для удобства импортируем с псевдонимом
// Драйвер ЖК-дисплея
use hd44780_driver::HD44780;
use embassy_time::Delay;
}
Адрес I2C
Адаптеры I2C для LCD1602 обычно используют адрес 0x27, хотя некоторые модули используют 0x3F в зависимости от адаптера. Проверьте даташит своего модуля или попробуйте оба адреса, если не уверены.
#![allow(unused)]
fn main() {
const LCD_I2C_ADDRESS: u8 = 0x27;
}
Настройка I2C
Настроим интерфейс I2C, используя GPIO 16 для SDA и GPIO 17 для SCL, с частотой 100 кГц.
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 100_000; //100 кГц
let i2c = i2c::I2c::new_blocking(p.I2C0, scl, sda, i2c_config);
}
Инициализация ЖК-дисплея
Теперь создадим экземпляр драйвера ЖК-дисплея с нашим интерфейсом I2C:
#![allow(unused)]
fn main() {
// Инициализация ЖК-дисплея
let mut lcd =
HD44780::new_i2c(i2c, LCD_I2C_ADDRESS, &mut Delay).expect("failed to initialize lcd");
}
Очистка дисплея
Перед тем как что-либо вывести, сбросим и очистим экран:
#![allow(unused)]
fn main() {
// Очищаем экран
lcd.reset(&mut Delay).expect("failed to reset lcd screen");
lcd.clear(&mut Delay).expect("failed to clear the screen");
}
Вывод текста на ЖК-дисплей
Наконец, выведем наше сообщение на ЖК-дисплей:
#![allow(unused)]
fn main() {
// Выводим в верхней строке
lcd.write_str("Hello, Rust!", &mut Delay)
.expect("failed to write text to LCD");
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку hello-lcd.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/lcd/hello-lcd/
Версия на rp-hal
Можно клонировать проект, который я создал, и перейти в папку hello-lcd (версия на rp-hal).
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/lcd/hello-lcd/
Поддерживаемые символы
В даташите HD44780 вы найдёте две таблицы символов, которые соответствуют двум версиям ПЗУ (ROM) — A00 и A02. Чтобы определить, какое ПЗУ использует ваш дисплей, попробуйте вывести уникальные символы из обеих таблиц. Тот, который отобразится правильно, и укажет на версию ПЗУ. После этого нужно смотреть только на соответствующую таблицу.
В моём случае ЖК-модуль основан на ПЗУ версии A00. Я приведу таблицу A00 и объясню, как её читать, хотя логика интерпретации одинакова для обеих версий.
Это 8-битный символ, в котором сначала идут старшие 4 бита, а затем младшие 4 бита — они вместе образуют полный байт символа. В справочной таблице старшие 4 бита соответствуют столбцам, а младшие 4 бита — строкам.
Например, чтобы получить двоичное представление символа «#», старшие 4 бита равны 0010, а младшие 4 бита — 0011. Объединив их, получаем полное двоичное значение 00100011. В Rust это значение можно записать либо в двоичном виде (0b00100011), либо в шестнадцатеричном (0x23).
Крейт hd44780-driver
В используемом нами крейте hd44780-driver символы можно выводить напрямую: как один байт или как последовательность байтов.
Запись одного байта
#![allow(unused)]
fn main() {
lcd.write_byte(0x23, &mut timer).unwrap();
lcd.write_byte(0b00100011, &mut timer).unwrap();
}
Запись нескольких байтов
#![allow(unused)]
fn main() {
lcd.write_bytes(&[0x23, 0x24], &mut timer).unwrap();
}
Пользовательские символы
Помимо поддерживаемых символов, можно создавать собственные символы (глифы), например смайлики или значки сердца. В модуле есть 64 байта памяти для генератора символов (CGRAM), что позволяет хранить до 8 пользовательских глифов.
Контроллер предоставляет 64 байта CGRAM (Character Generator RAM). Каждый пользовательский глиф занимает 8 байтов, поэтому одновременно можно сохранить до 8 пользовательских глифов.
Каждый глиф — это сетка 8x8, где каждая строка представлена одним 8-битным значением (u8). Получается 8 байтов на глиф (8 строк × 1 байт на строку). Именно поэтому при общем объёме 64 байта можно сохранить только до 8 пользовательских глифов (8 глифов × 8 байтов = 64 байта).
Note
Если помните, в нашем ЖК-модуле каждый символ представлен сеткой 5 × 8. Но подождите, разве мы не говорили, что для символов нужна сетка 8 × 8? Да, всё верно: нужна память 8 × 8 (8 байтов), но в каждой строке мы используем только 5 битов. Три старших бита в каждой строке остаются нулями.
Генератор
Генератор пользовательских символов ЖК-дисплея (сетка 5x8)
Выбирайте ячейки сетки, чтобы создать символ или знак. Когда вы выбираете ячейку, соответствующие биты в массиве байтов обновляются.
Сгенерированный массив
Ferris на ЖК-дисплее
В этом разделе мы нарисуем Ferris на символьном ЖК-дисплее. Это моя попытка сделать так, чтобы он был похож на краба. Если придумаете более удачный рисунок, присылайте pull request.
Хотя один пользовательский символ ограничен одной ячейкой 5x8, мы не обязаны ограничиваться одной ячейкой. Объединив 4 или даже 6 соседних сеток, можно вывести символ побольше. Насколько далеко вы зайдёте, зависит только от вашей фантазии.
Для создания Ferris мы будем использовать генератор пользовательских глифов со предыдущей страницы. Генератор выдаёт массив байтов, который можно сразу использовать в коде.
Крейт liquid_crystal
Крейт hd44780-driver, который мы использовали раньше, не поддерживает определение пользовательских глифов. Для работы с глифами, которые хранятся в CGRAM, мы будем использовать крейт liquid_crystal.
Этот крейт поддерживает пользовательские глифы и также предоставляет асинхронный API, который мы используем в этой главе.
Обновление Cargo.toml
При добавлении зависимости включите функцию async:
liquid_crystal = { version = "0.2.0", features = ["async"] }
Дополнительные импорты
Добавьте эти импорты в начало main.rs:
#![allow(unused)]
fn main() {
// Привязка прерываний
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// Драйвер ЖК-дисплея
use liquid_crystal::I2C;
use liquid_crystal::LiquidCrystal;
use liquid_crystal::prelude::*;
use embassy_time::Delay;
}
Привязка прерывания I2C
Привяжите прерывание I2C0_IRQ к обработчику прерываний I2C из Embassy для I2C0:
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
Инициализация I2C
Сначала настроим шину I2C для связи с дисплеем:
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 100_000; // 100кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
}
Инициализация интерфейса ЖК-дисплея
Когда интерфейс I2C настроен, инициализируем ЖК-дисплей.
#![allow(unused)]
fn main() {
// Инициализация ЖК-дисплея
let mut i2c_interface = I2C::new(i2c_bus, 0x27);
let mut lcd = LiquidCrystal::new(&mut i2c_interface, Bus4Bits, LCD16X2);
lcd.begin(&mut Delay);
}
Сгенерированный массив байтов для пользовательского глифа
#![allow(unused)]
fn main() {
const FERRIS: [u8; 8] = [
0b01010, 0b10001, 0b10001, 0b01110, 0b01110, 0b01110, 0b11111, 0b10001,
];
// Определяем символ
lcd.custom_char(&mut Delay, &FERRIS, 0);
}
Вывод
Вывести символ очень просто. Нужно использовать перечисление CustomChar и передать индекс пользовательского символа. Мы определили только один пользовательский символ, он находится в позиции 0.
#![allow(unused)]
fn main() {
lcd.write(&mut Delay, CustomChar(0));
lcd.write(&mut Delay, Text(" implRust!"));
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку custom-glyph.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/lcd/custom-glyph/
Версия на rp-hal
Можно клонировать проект, который я создал, и перейти в папку custom-glyph (версия на rp-hal).
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/lcd/custom-glyph/
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Привязка прерываний
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// Драйвер ЖК-дисплея
use liquid_crystal::I2C;
use liquid_crystal::LiquidCrystal;
use liquid_crystal::prelude::*;
use embassy_time::Delay;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
// const LCD_I2C_ADDRESS: u8 = 0x27;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 100_000; //100кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
// Инициализация ЖК-дисплея
let mut i2c_interface = I2C::new(i2c_bus, 0x27);
let mut lcd = LiquidCrystal::new(&mut i2c_interface, Bus4Bits, LCD16X2);
lcd.begin(&mut Delay);
const FERRIS: [u8; 8] = [
0b01010, 0b10001, 0b10001, 0b01110, 0b01110, 0b01110, 0b11111, 0b10001,
];
// Определяем символ
lcd.custom_char(&mut Delay, &FERRIS, 0);
lcd.write(&mut Delay, CustomChar(0));
// Обычный текст
lcd.write(&mut Delay, Text(" implRust!"));
loop {
Timer::after_millis(100).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"custom-chars"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Генератор нескольких символов
Генератор многоячеистых пользовательских символов
Он нужен, когда хочется объединить несколько сеток, чтобы получить один символ. На ЖК-дисплее 16 × 2 можно использовать соседние сетки, чтобы создать пользовательский символ или знак. Пример символа, созданного этим генератором, и то, как использовать его в Rust, — на следующей странице.
Сгенерированный массив
Многоячеистый пользовательский символ
В этом разделе мы создадим Ferris из шести соседних сеток с помощью генератора с предыдущей страницы. Ниже приведён код на Rust, который использует сгенерированные массивы байтов, чтобы вывести глиф на ЖК-дисплей.
Здесь мы сосредоточимся только на составлении глифа. Настройка проекта и инициализация ЖК-дисплея остаются такими же, как раньше, и в этом разделе повторяться не будут.
Сгенерированные массивы байтов для символов
#![allow(unused)]
fn main() {
const SYMBOL1: [u8; 8] = [
0b00110, 0b01000, 0b01110, 0b01000, 0b00100, 0b00011, 0b00100, 0b01000,
];
const SYMBOL2: [u8; 8] = [
0b00000, 0b00000, 0b00000, 0b10001, 0b10001, 0b11111, 0b00000, 0b00000,
];
const SYMBOL3: [u8; 8] = [
0b01100, 0b00010, 0b01110, 0b00010, 0b00100, 0b11000, 0b00100, 0b00010,
];
const SYMBOL4: [u8; 8] = [
0b01000, 0b01000, 0b00100, 0b00011, 0b00001, 0b00010, 0b00101, 0b01000,
];
const SYMBOL5: [u8; 8] = [
0b00000, 0b00000, 0b00000, 0b11111, 0b01010, 0b10001, 0b00000, 0b00000,
];
const SYMBOL6: [u8; 8] = [
0b00010, 0b00010, 0b00100, 0b11000, 0b10000, 0b01000, 0b10100, 0b00010,
];
}
Объявляем их как символы
Каждый глиф хранится в отдельной ячейке CGRAM. В этом примере мы используем ячейки с 0 по 5.
#![allow(unused)]
fn main() {
lcd.custom_char(&mut Delay, &SYMBOL1, 0);
lcd.custom_char(&mut Delay, &SYMBOL2, 1);
lcd.custom_char(&mut Delay, &SYMBOL3, 2);
lcd.custom_char(&mut Delay, &SYMBOL4, 3);
lcd.custom_char(&mut Delay, &SYMBOL5, 4);
lcd.custom_char(&mut Delay, &SYMBOL6, 5);
}
Вывод
Первые три глифа выводим в первой строке, а оставшиеся три — во второй строке, выравнивая их так, чтобы получился один составной символ.
#![allow(unused)]
fn main() {
lcd.set_cursor(&mut Delay, 0, 4)
.write(&mut Delay, CustomChar(0))
.write(&mut Delay, CustomChar(1))
.write(&mut Delay, CustomChar(2));
lcd.set_cursor(&mut Delay, 1, 4)
.write(&mut Delay, CustomChar(3))
.write(&mut Delay, CustomChar(4))
.write(&mut Delay, CustomChar(5));
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку mutli-glyph.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/lcd/mutli-glyph/
Версия на rp-hal
Версия с использованием rp-hal также доступна:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/lcd/mutli-glyph/
Индекс символов
Ниже приведён список пользовательских символов с соответствующими массивами байтов. Если вы придумали интересный символ и хотите добавить его в этот список, отправляйте pull request. Пожалуйста, используйте генератор пользовательских символов, чтобы результаты были единообразными.
| Название | Превью | Массив байтов |
|---|---|---|
| Сердце | ![]() |
[ 0b00000, 0b01010, 0b11111, 0b11111, 0b01110, 0b00100, 0b00000, 0b00000,] |
| Замок | ![]() |
[ 0b01110, 0b10001, 0b10001, 0b11111, 0b11011, 0b11011, 0b11011, 0b11111, ] |
| Контурное сердце | ![]() |
[ 0b00000, 0b01010, 0b10101, 0b10001, 0b10001, 0b01010, 0b00100, 0b00000, ] |
| Батарея | ![]() |
[ 0b01110, 0b11011, 0b10001, 0b10001, 0b10001, 0b11111, 0b11111, 0b11111, ] |
| Автобус | ![]() |
[ 0b01110, 0b11111, 0b10001, 0b10001, 0b11111, 0b10101, 0b11111, 0b01010, ] |
| Колокольчик | ![]() |
[ 0b00100, 0b01110, 0b01110, 0b01110, 0b11111, 0b00000, 0b00100, 0b00000, ] |
| Песочные часы | ![]() |
[ 0b00000, 0b11111, 0b10001, 0b01010, 0b00100, 0b01010, 0b10101, 0b11111, ] |
| Зарядка | ![]() |
[ 0b01010, 0b01010, 0b11111, 0b10001, 0b10001, 0b01110, 0b00100, 0b00100, ] |
| Галочка | ![]() |
[ 0b00000, 0b00000, 0b00001, 0b00011, 0b10110, 0b11100, 0b01000, 0b00000, ] |
| Нота | ![]() |
[ 0b00011, 0b00010, 0b00010, 0b00010, 0b00010, 0b01110, 0b11110, 0b01110, ] |
АЦП (Analog to Digital Converter)
Надеюсь, вы уже знакомы с понятием аналоговых и цифровых сигналов. Окружающий мир в основном аналоговый: температура плавно растёт и падает, сила света меняется постепенно, звук распространяется в воздухе волнами, а датчики выдают напряжения, которые меняются непрерывно.
Однако микроконтроллеры понимают только дискретные значения. Внутри всё представлено двоичными числами — единицами и нулями, HIGH и LOW. Так возникает базовый разрыв между реальным миром и цифровой логикой. Как цифровая система может понять то, что меняется плавно?
Здесь на помощь приходит аналого-цифровой преобразователь (АЦП, ADC — Analog to Digital Converter). АЦП измеряет аналоговое напряжение и превращает его в цифровое число, которое микроконтроллер может обработать, сохранить и использовать для принятия решений.
Возможно, вы помните из главы о ШИМ, что цифровая система может создать аналогоподобный выход, быстро включая и выключая вывод. АЦП делает обратное: он позволяет микроконтроллеру считывать аналоговые входные сигналы из реального мира и понимать их. Вместе такие периферийные блоки, как ШИМ и АЦП, позволяют встраиваемым системам и ощущать окружающую среду, и управлять ею.
Рассмотрим простой пример — систему контроля температуры. Датчик температуры выдаёт напряжение, которое зависит от температуры, например 0 В при 0 °C и 3,3 В при 100 °C. Без АЦП этот плавный сигнал ничего не значил бы для микроконтроллера. АЦП через равные промежутки времени измеряет напряжение и превращает каждое измерение в цифровое значение. Ваш код может считать это значение, интерпретировать его как температуру и соответствующим образом отреагировать.
Разрешение АЦП
Одна из важнейших характеристик АЦП — его разрешение, которое определяет, насколько мелко он может разбить диапазон аналогового входа на дискретные цифровые значения. Разрешение обычно выражают в битах: например, 8-битный, 10-битный или 12-битный. Количество битов определяет, сколько различных значений может выдать АЦП.
8-битный АЦП делит диапазон входного напряжения на 256 уровней (28). 10-битный даёт 1 024 уровня (210), а 12-битный — 4 096 уровней (212). Как видите, каждый дополнительный бит удваивает число уровней и заметно повышает точность. Чем больше битов у АЦП, тем меньшую разницу напряжений он способен различить.
Пример
Разберём это на примере. Допустим, вы используете 10-битный АЦП с опорным напряжением 3,3 В. АЦП будет представлять напряжения от 0 В до 3,3 В цифровыми значениями от 0 до 1 023.
Наименьшее изменение напряжения, которое может обнаружить АЦП (его называют шагом квантования), вычисляется так:
\[ \text{Step Size} = \frac{\text{Vref}}{2^{\text{bits}}} \]
Шаг = 3,3 В / 1 024 = 3,22 мВ
Это значит, что АЦП не может отличить два напряжения, которые ближе друг к другу, чем примерно на 3,22 мВ. Например, если выход датчика меняется с 1,500 В до 1,502 В, АЦП, скорее всего, выдаст одно и то же цифровое значение для обоих.
Сравните с 12-битным АЦП с тем же опорным напряжением 3,3 В:
Шаг = 3,3 В / 4 096 = 0,81 мВ
12-битный преобразователь даёт заметно более тонкую точность, что может быть критично в приложениях, где нужны точные измерения.
Формула АЦП
Концептуально АЦП превращает входное напряжение в цифровое число по следующей зависимости:
\[ ADC = \frac{V_{in}}{V_{ref}} \times 2^{\text{bits}} \]
Где:
- \(V_{in}\) — входное напряжение
- \(V_{ref}\) — опорное напряжение
Преобразование значений АЦП в напряжение
Когда вы читаете АЦП в коде, он возвращает сырое цифровое значение от 0 до 4095 (для 12-битного АЦП). Это число показывает, где измеренное напряжение находится в диапазоне от 0 В до 3,3 В. Чтобы превратить сырое значение обратно в реальное напряжение, нужен простой расчёт.
Формула преобразования:
\[ \text{Voltage} = \frac{\text{ADC Reading} \times \text{Reference Voltage}}{2^{\text{bits}}} \]
Для RP2350 с его 12-битным АЦП и опорным напряжением 3,3 В:
\[ \text{Voltage} = \frac{\text{ADC Reading} \times 3.3}{4096} \]
Пример
Допустим, мы получили показание АЦП, равное 1000.
Voltage = (1000 × 3.3) / 4096 = 0.806 V
Ссылки
В RP2350
Микроконтроллер RP2350 содержит встроенный 12-битный АЦП с несколькими входными каналами. Это даёт 4 096 различимых уровней для представления напряжений от 0 В до 3,3 В. При опорном напряжении 3,3 В (ADC_AVDD) шаг квантования составляет около 0,806 мВ. Подробнее см. на странице 1066 даташита: даташит RP2350
В чипе RP2350 для АЦП используется вывод ADC_AVDD. Он питает АЦП и одновременно служит опорным напряжением для преобразований. Значение на этом выводе задаёт максимальное напряжение, которое АЦП может измерить. На практике ADC_AVDD обычно подключают к 3,3 В. Поскольку АЦП чувствителен к малым изменениям напряжения, это питание должно быть чистым и стабильным, чтобы показания были надёжными.
На плате Raspberry Pi Pico 2 это уже сделано за вас. Плата подводит питание 3,3 В к выводу ADC_AVDD RP2350 через небольшой фильтр из резистора и конденсатора. Это помогает снизить шумы и улучшает работу АЦП.
Pico 2 также выводит этот отфильтрованный опорный сигнал на контакт с подписью ADC_VREF (физический контакт 35). По умолчанию трогать его не нужно: АЦП можно использовать как есть. Для более сложных задач этот вывод позволяет подать внешнее прецизионное опорное напряжение, если нужна большая точность. Это необязательно и не требуется для проектов для начинающих.
Пока достаточно запомнить: АЦП RP2350 — 12-битный, измеряет напряжения до своего опорного напряжения, а плата Pico 2 уже обеспечивает безопасную и стабильную настройку по умолчанию.
Каналы АЦП и соответствие GPIO на Raspberry Pi Pico 2
У АЦП RP2350 несколько внутренних каналов. На Raspberry Pi Pico 2 четыре из них подключены к выводам GPIO, которые можно использовать как аналоговые входы.
| Вывод GPIO | Канал ADC | Назначение |
|---|---|---|
| GPIO26 | ADC0 | Можно использовать для чтения напряжения с периферии. |
| GPIO27 | ADC1 | Можно использовать для чтения напряжения с периферии. |
| GPIO28 | ADC2 | Можно использовать для чтения напряжения с периферии. |
| GPIO29 | ADC3 | Измеряет напряжение питания VSYS на плате. |
Делитель напряжения с АЦП
Иногда нужно измерить напряжение выше опорного напряжения АЦП (3,3 В): например, питание 5 В, батарею 9 В или систему на 12 В. Поскольку АЦП RP2350 безопасно измеряет напряжения только до 3,3 В, напряжение нужно уменьшить с помощью делителя напряжения.
Мы уже разбирали, как работает делитель напряжения и как два резистора могут уменьшать напряжение. Если нужно освежить память, вернитесь к разделу о делителе напряжения. Здесь мы сосредоточимся на том, как делитель взаимодействует с АЦП и как выход делителя превращается в показание АЦП.
Tip
Делители напряжения нужны не только для уменьшения напряжения. Такие датчики, как LDR и термисторы, — это резисторы, сопротивление которых меняется в зависимости от света или температуры. Когда они используются в делителе напряжения, эти изменения сопротивления превращаются в изменения напряжения, которые может считать АЦП.
Использование делителей напряжения с АЦП
При использовании делителя напряжения с АЦП обычно цель — уменьшить напряжение так, чтобы оно оставалось в диапазоне входа АЦП. Проектируйте делитель так, чтобы максимально ожидаемое входное напряжение давало выходное напряжение ниже опорного напряжения АЦП.
Для системы на 3,3 В хорошей практикой считается стремиться к 3,0 В или меньше, чтобы оставить запас на колебания питания и шумы.
Пример: измерение питания 3,3 В
Рассмотрим простой пример: входное напряжение 3,3 В, а номиналы резисторов — R1 = 10 кОм и R2 = 1 кОм.
Вы уже должны быть знакомы с формулой делителя напряжения:
\[ V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2} \]
Подставляем значения в формулу делителя напряжения:
\[ V_{out} = 3.3 V \times \frac{1k}{1k + 10k} = 3.3 V \times \frac{1k}{11k} = 0.3 V \]
Это выходное напряжение находится в пределах входного диапазона АЦП. Выход делителя V_out подключается к входному выводу АЦП.
Фоторезисторы (LDR)
Фоторезисторы (LDR, Light Dependent Resistors), также известные как светочувствительные резисторы, — одни из самых простых датчиков, которые можно использовать с микроконтроллером. Их сопротивление меняется в зависимости от количества света, падающего на них. Больше света — ниже сопротивление, меньше света — выше сопротивление.
Благодаря такому поведению LDR часто используют в проектах вроде автоматических уличных фонарей, регулировки яркости экрана, люксметров и простого определения дня и ночи.
Как работает LDR
LDR изготавливается из фоточувствительного полупроводникового материала. Когда свет попадает на его поверхность, фотоны передают энергию материалу и высвобождают носители заряда. В результате сопротивление LDR уменьшается.
Tip
Представьте, что LDR — это Дракула. На солнце он слабеет, так же как сопротивление уменьшается. В темноте он становится сильнее, так же как сопротивление растёт.
Изменение сопротивления нелинейно, и точные значения различаются у разных LDR. Для большинства embedded-задач это не проблема. Обычно нас интересуют относительные уровни освещённости, а не точные измерения.
Мы не будем углубляться в детали полупроводниковых материалов, из которых делают LDR, и физику процессов. Если вам интересно и хочется разобраться глубже, можно прочитать эту статью и провести дополнительное исследование: https://www.elprocus.com/ldr-light-dependent-resistor-circuit-and-working/
Необходимые компоненты
Вам понадобятся следующие компоненты:
- LDR (фоторезистор)
- Резистор (обычно 10 кОм), нужен для создания делителя напряжения
- Перемычки (как обычно)
Предварительные знания
Прежде чем работать с LDR, вы должны понимать, что такое делитель напряжения и как он работает, а также что такое АЦП и как он превращает аналоговое напряжение в цифровое значение.
Если нужно быстро освежить знания, можно обратиться к разделам:
Симуляция LDR в делителе напряжения
Чтобы понять, как ведёт себя LDR в делителе напряжения, я создал простую симуляцию в Falstad. На схеме LDR показан символом резистора со стрелками, направленными на него, что обозначает падающий свет.
Файл с созданной мной схемой, voltage-divider-ldr.circuitjs.txt, можно импортировать на сайт Falstad и поэкспериментировать. Также внизу страницы есть встроенный симулятор, так что можно использовать любой вариант.
В этой конфигурации LDR расположен в верхней части делителя напряжения (то есть как R1), а внизу — постоянный резистор (как R2). Такая схема широко используется, потому что при увеличении освещённости выходное напряжение растёт, что интуитивно понятно при чтении значения через АЦП.
Когда схема загружена, двигайте ползунок яркости и наблюдайте, как меняется сопротивление LDR. Одновременно видно, как выходное напряжение \( V_{out} \) реагирует на эти изменения.
Important
Если поменять местами LDR и постоянный резистор, поведение выходного напряжения инвертируется. Если поставить LDR как R2 (нижний резистор) в делитель напряжения, связь между уровнем освещённости и \( V_{out} \) становится обратной, потому что меняется уравнение.
Пример выхода при полной яркости
Когда LDR освещён сильным светом, его сопротивление низкое. Из-за этого большая часть напряжения питания оказывается на выходе, и выходное напряжение высокое.
Пример выхода при слабом свете
По мере уменьшения освещённости сопротивление LDR растёт. Это снижает выходное напряжение.
Пример выхода в полной темноте
В темноте сопротивление LDR становится очень высоким, из-за чего выходное напряжение падает почти до нуля.
Симулятор делителя напряжения с LDR
Включаем светодиод при слабом освещении с Raspberry Pi Pico
В этом упражнении мы соберём небольшой, но практичный проект с LDR. Pico будет автоматически включать светодиод, когда уровень освещённости опускается ниже определённого значения, то есть светодиод загорается по мере того, как темнеет. Эту идею можно расширить, чтобы управлять настоящей лампой, но это выходит за рамки упражнения и требует соблюдения правил безопасности.
Попробовать можно в закрытой комнате, выключая и включая верхний свет. Когда свет выключен и в комнате становится достаточно темно, светодиод должен включиться, а при включении верхнего света — снова погаснуть. Также можно изменить порог чувствительности или закрыть LDR рукой или другим предметом, чтобы имитировать разные уровни освещённости.
Tip
Возможно, придётся подстроить порог АЦП в зависимости от освещения вашей комнаты и конкретного используемого LDR.
Требования к оборудованию
Для этого упражнения понадобятся следующие компоненты:
- Светодиод
- LDR
- Резисторы
- 330 Ом: используется со светодиодом, чтобы ограничить ток и защитить его. Возможно, понадобится другой номинал в зависимости от светодиода.
- 10 кОм: используется с LDR для создания делителя напряжения. Возможно, понадобится изменить этот номинал в зависимости от характеристик LDR.
- Перемычки: нужны, чтобы соединить всё на макетной плате или напрямую с микроконтроллером.
Схема подключения светодиода и LDR к Pico
Подключение LDR (АЦП)
Мы подключим LDR как делитель напряжения и подадим выход делителя на вывод АЦП Pico. Здесь LDR выступает в роли R1 и подключён к питанию 3,3 В, поэтому значение АЦП уменьшается по мере падения уровня освещённости.
| Вывод Pico | Провод | Компонент |
|---|---|---|
| 3.3 V |
|
Один вывод LDR |
| GPIO 28 (ADC2) |
|
Точка соединения LDR и резистора 10 кОм |
| Резистор 10 кОм |
|
Другой вывод LDR |
| GND |
|
Другой вывод резистора 10 кОм |
Подключение светодиода
Светодиод будет подключён к выводу GPIO 15 и автоматически включаться, когда уровень освещённости падает.
| Вывод Pico | Провод | Компонент |
|---|---|---|
| GPIO 15 |
|
Резистор 330 Ом |
| Резистор 330 Ом |
|
Анод (длинная ножка) светодиода |
| GND |
|
Катод (короткая ножка) светодиода |
Код на embedded Rust для управления светодиодом по уровню освещённости
Когда схема собрана на макетной плате, напишем код.
Проект из шаблона
Как обычно, начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «ldr-dracula», и выберите embassy в качестве HAL.
Дополнительные импорты
#![allow(unused)]
fn main() {
// Для привязки прерываний
use embassy_rp::adc::InterruptHandler;
use embassy_rp::bind_interrupts;
// Для АЦП
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
// Для светодиода
use embassy_rp::gpio::{Level, Output, Pull};
}
Обработчик прерывания для АЦП
В АЦП RP2350 есть буфер FIFO (First-In-First-Out), который хранит результаты преобразований (то есть измеренное напряжение, преобразованное в дискретное значение АЦП). Когда в этот FIFO поступают данные, аппаратура генерирует сигнал прерывания ADC_IRQ_FIFO.
В библиотеке Embassy логика обработки прерываний АЦП уже реализована. Нам нужно лишь привязать это аппаратное прерывание, чтобы драйвер АЦП мог его использовать.
Перед тем как начать читать значения АЦП, нужно настроить эту привязку прерывания.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => InterruptHandler;
});
}
Порог АЦП
Прежде чем писать основную логику, нужно решить, что считать слабым освещением. Для этого зададим пороговое значение показаний АЦП.
#![allow(unused)]
fn main() {
const LDR_THRESHOLD: u16 = 200;
}
Этот порог задаёт уровень освещённости, при котором должен включаться светодиод. Когда показание АЦП опускается ниже этого значения, мы считаем, что в помещении темно. Когда показание выше — светло.
Возможно, порог придётся подстроить в зависимости от освещения в комнате, используемого LDR и номиналов резисторов в делителе напряжения.
Создание экземпляра АЦП
Создадим экземпляр АЦП.
#![allow(unused)]
fn main() {
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
}
Здесь мы передаём конструктору АЦП три вещи: саму периферию АЦП, привязку прерываний, которую мы определили ранее, и конфигурацию по умолчанию.
Note
Интересный факт: HAL на самом деле не использует
Irqsво время выполнения, когда вы передаёте его в конструктор АЦП. Он нужен только на этапе компиляции, чтобы убедиться, что вы объявили привязку прерывания АЦП. Если вы посмотрите на новый метод, то увидите, что параметр называется_irq, — это явно показывает, что он не используется.
Настройка вывода АЦП
Далее выбираем, какой вывод GPIO будет использоваться как вход АЦП.
#![allow(unused)]
fn main() {
let mut adc_pin = Channel::new_pin(p.PIN_28, Pull::None);
}
Настройка выхода для светодиода
Теперь настраиваем вывод GPIO, который управляет светодиодом.
#![allow(unused)]
fn main() {
let mut led = Output::new(p.PIN_15, Level::Low);
}
Главный цикл
Логика простая: читаем значение АЦП, и если оно меньше заданного порога, включаем светодиод, иначе выключаем.
#![allow(unused)]
fn main() {
loop {
let adc_reading = adc
.read(&mut adc_pin)
.await
.expect("Unable to read the adc value");
defmt::info!("ADC value: {}", adc_reading);
if adc_reading < LDR_THRESHOLD {
led.set_high();
} else {
led.set_low();
}
Timer::after_secs(1).await;
}
}
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
// Привязка прерываний
use embassy_rp::adc::InterruptHandler;
use embassy_rp::bind_interrupts;
// АЦП
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
// Для светодиода
use embassy_rp::gpio::{Level, Output, Pull};
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => InterruptHandler;
});
const LDR_THRESHOLD: u16 = 200;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut adc_pin = Channel::new_pin(p.PIN_28, Pull::None);
let mut led = Output::new(p.PIN_15, Level::Low);
loop {
let adc_reading = adc
.read(&mut adc_pin)
.await
.expect("Unable to read the adc value");
defmt::info!("ADC value: {}", adc_reading);
if adc_reading < LDR_THRESHOLD {
led.set_high();
} else {
led.set_low();
}
Timer::after_secs(1).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"ldr-dracula"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку ldr-dracula.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/ldr-dracula/
Термистор
Термистор — это резистор, сопротивление которого меняется с температурой. Когда температура растёт или падает, сопротивление меняется предсказуемым образом. Название «термистор» образовано от слов «thermal» (тепловой) и «resistor» (резистор).
С точки зрения микроконтроллера термистор — это просто ещё один резистор, подключённый к выводу АЦП. АЦП ничего не знает о температуре. Он измеряет только напряжение. Всё остальное мы делаем в коде.
NTC и PTC
Есть два распространённых типа термисторов.
У термистора NTC сопротивление ниже при более высокой температуре. Это самый распространённый тип для измерения температуры во встраиваемых системах.
У термистора PTC сопротивление выше при более высокой температуре. Такие термисторы обычно используют для защиты или ограничения тока, а не для точного измерения.
В этой главе мы будем работать с термистором NTC.
Ссылки
- Основы термисторов (на английском языке)
- Thermistors (на английском языке)
NTC и делитель напряжения
Чтобы понять, как ведёт себя термистор NTC в делителе напряжения, я создал простую симуляцию в Falstad. На схеме термистор изображён как резистор, сопротивление которого меняется с температурой.
Файл с созданной мной схемой, voltage-divider-thermistor.circuitjs.txt, можно импортировать на сайт Falstad и поэкспериментировать самостоятельно. Внизу страницы также есть встроенный симулятор, так что можно воспользоваться любым вариантом.
В этой конфигурации термистор NTC расположен в верхней части делителя напряжения (R1), а внизу — постоянный резистор (R2). Когда температура меняется, сопротивление термистора меняется, и это напрямую влияет на выходное напряжение делителя.
Important
Если поменять местами термистор и постоянный резистор, поведение выходного напряжения инвертируется. Если поставить термистор как R2 вместо R1, рост температуры приведёт к уменьшению \( V_{out} \).
Термистор при 25 °C
При 25 °C сопротивление термистора составляет 10 кОм, и выходное напряжение (\( V_{out} \)) равно 1,65 В.
Термистор при 10 °C
Сопротивление термистора возрастает, и выходное напряжение (\( V_{out} \)) становится ниже.
Термистор при 100 °C
Сопротивление термистора уменьшается из-за его отрицательного температурного коэффициента. Это приводит к росту выходного напряжения.
Симулятор делителя напряжения с NTC-термистором
Из показаний АЦП в сопротивление
Когда мы подключаем термистор к Pico, мы не читаем напряжение напрямую. То, что мы получаем от АЦП, — это лишь цифровое значение, которое представляет это напряжение (см. главу АЦП).
Но для вычисления температуры нам на самом деле нужно сопротивление термистора в данный момент, а не само значение АЦП.
В следующей главе мы рассмотрим уравнения для температуры. Прежде чем использовать эти формулы, нужно сначала преобразовать показание АЦП обратно в сопротивление термистора. Именно на этом и сосредоточена текущая глава.
Преобразование показаний АЦП в сопротивление термистора
Теперь мы выведем сопротивление термистора непосредственно из показания АЦП, используя уравнение делителя напряжения и уравнение преобразования АЦП.
Шаг 1: напряжение в средней точке делителя
Это стандартная формула делителя напряжения, которую мы уже видели. Поскольку термистор подключён как R1, мы явно обозначаем его как сопротивление термистора.
\[ V_{in} = \frac{R_2}{R_{thermistor} + R_2} \times V_{ref} \]
Шаг 2: уравнение преобразования АЦП
АЦП преобразует входное напряжение в цифровое значение на основе отношения входного напряжения к опорному.
\[ ADC = \frac{V_{in}}{V_{ref}} \times 2^{bits} \]
Шаг 3: подставляем уравнение делителя в уравнение АЦП
Подставляем выражение для \( V_{in} \) в уравнение АЦП:
\[ ADC = \frac{\frac{R_2}{R_{thermistor} + R_2} \times V_{ref}}{V_{ref}} \times 2^{bits} \]
Члены \( V_{ref} \) сокращаются, и остаётся:
\[ ADC = \frac{R_2}{R_{thermistor} + R_2} \times 2^{bits} \]
Шаг 4: выражаем сопротивление термистора
Преобразуем и решим уравнение относительно \( R_{thermistor} \):
\[ R_{thermistor} = \left( \frac{2^{bits}}{ADC} - 1 \right) \times R_2 \]
Для Pico, поскольку он использует 12-битный АЦП, уравнение упрощается до:
\[ R_{thermistor} = \left( \frac{4096}{ADC} - 1 \right) \times R_2 \]
Теперь уравнение содержит только результат АЦП, который измеряется, и \( R_2 \), то есть известное значение резистора. Поскольку \( R_2 \) напрямую влияет на вычисленное сопротивление термистора, он должен быть резистором со стабильным сопротивлением при изменении температуры. Любое изменение \( R_2 \) с температурой напрямую скажется на точности измерения.
В следующей главе мы используем это значение сопротивления для вычисления температуры.
Функция на Rust
Простая вспомогательная функция, которая превращает показание АЦП в соответствующее сопротивление термистора.
const ADC_LEVELS: f64 = 4096.0;
const R2_RES: f64 = 10_000.0; // R2
fn adc_to_resistance(adc_value: u16, r2_res:f64) -> f64 {
let adc = adc_value as f64;
((ADC_LEVELS / adc) - 1.0) * r2_res
}
fn main() {
let adc_value = 2000; // Наш пример значения АЦП;
let r2 = adc_to_resistance(adc_value, R2_RES);
println!("Calculated Resistance (R2): {} Ω", r2);
}
Нелинейность термистора
Мы научились использовать термистор в делителе напряжения и вычислять сопротивление термистора в данный момент. Следующий шаг — определить температуру, соответствующую этому сопротивлению.
Этот шаг непростой. Термисторы — нелинейные устройства, то есть сопротивление меняется с температурой не по прямой. Поэтому пересчитать сопротивление в температуру с помощью одной линейной формулы нельзя.
Проблема нелинейной кривой
Термистор меняет сопротивление неравномерно при изменении температуры. Зависимость между температурой и сопротивлением описывается кривой.
При низких температурах небольшое изменение температуры даёт большое изменение сопротивления. При высоких температурах то же изменение температуры даёт гораздо меньшее изменение сопротивления. Такое поведение — фундаментальное свойство термисторов.
Из-за этой кривой не существует простого правила вида:
фиксированное число омов соответствует одному градусу
Использование известных опорных точек
Производители термисторов знают об этом поведении и публикуют подробные даташиты. В этих даташитах перечислены значения сопротивления при конкретных температурах.
Каждая запись в даташите представляет собой известную, измеренную точку на кривой термистора. Вместе эти точки описывают, как меняется сопротивление во всём диапазоне температур.
Распространённые подходы на практике
Есть три широко используемых метода для перевода сопротивления термистора в температуру:
Уравнение Бета (метод параметра B):
Этот метод использует одну константу материала, которая называется значением Бета. Значение приводится в даташите термистора. Метод предполагает, что кривую можно приблизить логарифмической зависимостью между сопротивлением и температурой.
Уравнение Штайнхарта — Харта:
Уравнение Штайнхарта — Харта — более точная модель кривой термистора. Вместо одной константы оно использует три коэффициента, которые лучше соответствуют нелинейному поведению.
Эти коэффициенты либо предоставляет производитель, либо получают из данных калибровки. Уравнение Штайнхарта — Харта обеспечивает высокую точность в широком диапазоне температур. Цена — больше вычислений и более сложная реализация.
Таблицы поиска (lookup tables):
Таблица поиска хранит известные пары «температура — сопротивление». Эти значения берутся напрямую из даташита термистора или из калибровочных измерений. Когда измерено сопротивление, его сравнивают с таблицей, чтобы найти ближайшую подходящую температуру. Если сопротивление попадает между двумя записями, для оценки температуры между ними можно использовать интерполяцию.
В следующих главах мы подробно рассмотрим, как использовать уравнение Бета и уравнение Штайнхарта — Харта для определения температуры.
Ссылки
- The B parameter vs. Steinhart-Hart equation (на английском языке)
- Characterising Thermistors – A Quick Primer, Beta Value & Steinhart-Hart Coefficients (на английском языке)
Уравнение B
Уравнение B, также называемое методом бета-параметра (Beta parameter method), — самый простой способ перевести сопротивление термистора в температуру. Оно использует одну константу материала, которую предоставляет производитель, чтобы приблизительно описать поведение термистора.
Формула уравнения B
\[ \frac{1}{T} = \frac{1}{T_0} + \frac{1}{B} \ln \left( \frac{R}{R_0} \right) \]
В этом уравнении T — искомая температура в кельвинах. R — измеренное сопротивление при неизвестной температуре T.
\( T_0 \) — опорная температура, обычно 298,15 К (25 °C), при которой известно сопротивление термистора. \( R_0 \) — сопротивление при опорной температуре \( T_0 \), для распространённых термисторов часто 10 кОм.
B — бета-значение термистора, константа материала. А ln — натуральный логарифм.
Понимание параметров
R₀ (опорное сопротивление):
Это сопротивление термистора при известной температуре, обычно 25 °C. Например, «термистор на 10 кОм» имеет R₀ = 10 000 Ом при 25 °C. Это значение должно быть указано в даташите.
B (бета-значение):
Значение B — константа, которую обычно предоставляют производители. Оно описывает, насколько быстро сопротивление меняется с температурой. Распространённые значения лежат в диапазоне от 3000 до 4000 К.
В даташите B часто задаётся для диапазона температур, например B₂₅/₈₅ = 3950, что означает, что бета-значение равно 3950 между 25 °C и 85 °C.
Температура в кельвинах:
Температура в уравнении B должна быть выражена в кельвинах (Кельвин = Цельсий + 273,15), а не в градусах Цельсия. Перед использованием уравнения переведите температуру в кельвины, а результат переведите обратно в градусы Цельсия, вычтя 273,15.
Пример расчёта
Допустим, измеренное сопротивление термистора составляет 10 475 Ом — это и есть R в уравнении. Чтобы вычислить температуру, подставим это значение вместе с опорной температурой \( T_0 = 298{,}15 K \) (25 °C), опорным сопротивлением \( R_0 = 10 k\Omega \) и бета-значением термистора 3950 в уравнение B.
Шаг 1: вычисляем отношение сопротивлений \[ \frac{R}{R_0} = \frac{10475}{10000} = 1.0475 \]
Шаг 2: вычисляем натуральный логарифм отношения \[ \ln(1.0475) = 0.04641 \]
Шаг 3: применяем уравнение B \[ \frac{1}{T} = \frac{1}{298.15} + \frac{1}{3950} \times 0.04641 \]
\[ \frac{1}{T} = 0.003354 + 0.00001175 = 0.003366 \]
Шаг 4: вычисляем T, взяв обратное значение \[ T = \frac{1}{0.003366} = 297.1 K \]
Шаг 5: переводим в градусы Цельсия \[ T = 297.1 - 273.15 = 23.95 °C \]
Измеренное сопротивление 10 475 Ом соответствует температуре примерно 24 °C.
Функция на Rust
fn calculate_temperature(current_res: f64, ref_res: f64, ref_temp: f64, b_val: f64) -> f64 {
let ln_value = (current_res/ref_res).ln();
// let ln_value = libm::log(current_res / ref_res); // для no_std используйте этот крейт
let inv_t = (1.0 / ref_temp) + ((1.0 / b_val) * ln_value);
1.0 / inv_t
}
fn kelvin_to_celsius(kelvin: f64) -> f64 {
kelvin - 273.15
}
fn celsius_to_kelvin(celsius: f64) -> f64 {
celsius + 273.15
}
const B_VALUE: f64 = 3950.0;
const V_IN: f64 = 3.3; // Входное напряжение
const REF_RES: f64 = 10_000.0; // Опорное сопротивление в омах (10 кОм)
const REF_TEMP: f64 = 25.0; // Опорная температура 25 °C
fn main() {
let t0 = celsius_to_kelvin(REF_TEMP);
let r = 10475.0; // Измеренное сопротивление в омах
let temperature_kelvin = calculate_temperature(r, REF_RES, t0, B_VALUE);
let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
println!("Temperature: {:.2} °C", temperature_celsius);
}
Уравнение Штайнхарта — Харта
Уравнение Штайнхарта — Харта — более точный метод перевода сопротивления термистора в температуру по сравнению с простым уравнением B. Оно использует три коэффициента, чтобы моделировать нелинейное поведение термистора в более широком диапазоне температур с большей точностью.
Tip
В нашей программе мы не будем использовать эту формулу. Для простоты будем использовать уравнение B.
Формула Штайнхарта — Харта
\[ \frac{1}{T} = A + B \ln R + C (\ln R)^3 \]
В этом уравнении T — искомая температура в кельвинах. R — измеренное сопротивление в омах. A, B и C — коэффициенты Штайнхарта — Харта, которые индивидуальны для каждого термистора. ln — натуральный логарифм.
Калибровка
Чтобы определить точные значения A, B и C, поместите термистор в три известных температурных условия, обычно в ледяную воду (холод), при комнатной температуре и в горячую или кипящую воду.
Для каждого условия измерьте сопротивление термистора, используя значение АЦП, и запишите реальную температуру с помощью надёжного термометра. Перед дальнейшими расчётами переведите все температуры в кельвины.
Используя эти три пары «сопротивление — температура» вместе, коэффициенты A, B и C вычисляются путём решения системы уравнений. Коэффициенты не привязаны по отдельности к холодной, комнатной или горячей точке. Вместо этого все три калибровочные точки вместе задают кривую, которая описывает нелинейное поведение термистора в измеренном диапазоне.
Получив коэффициенты, те же значения A, B и C можно использовать для вычисления температуры при любом значении сопротивления в этом диапазоне.
Вычисление коэффициентов Штайнхарта — Харта
Имея три пары данных «сопротивление — температура», можно вычислить коэффициенты Штайнхарта — Харта A, B и C.
$$ \begin{bmatrix} 1 & \ln R_1 & \ln^3 R_1 \\ 1 & \ln R_2 & \ln^3 R_2 \\ 1 & \ln R_3 & \ln^3 R_3 \end{bmatrix}\begin{bmatrix} A \\ B \\ C \end{bmatrix} = \begin{bmatrix} \frac{1}{T_1} \\ \frac{1}{T_2} \\ \frac{1}{T_3} \end{bmatrix} $$
Где:
- \( R_1, R_2, R_3 \) — значения сопротивления при температурах \( T_1, T_2, T_3 \).
Посчитаем коэффициенты
Вычисляем натуральные логарифмы сопротивлений: $$ L_1 = \ln R_1, \quad L_2 = \ln R_2, \quad L_3 = \ln R_3 $$
Промежуточные вычисления: $$ Y_1 = \frac{1}{T_1}, \quad Y_2 = \frac{1}{T_2}, \quad Y_3 = \frac{1}{T_3} $$
$$ \gamma_2 = \frac{Y_2 - Y_1}{L_2 - L_1}, \quad \gamma_3 = \frac{Y_3 - Y_1}{L_3 - L_1} $$
И, наконец: $$ C = \left( \frac{ \gamma_3 - \gamma_2 }{ L_3 - L_2} \right) \left(L_1 + L_2 + L_3\right)^{-1} \ $$ $$ B = \gamma_2 - C \left(L_1^2 + L_1 L_2 + L_2^2\right) \ $$ $$ A = Y_1 - \left(B + L_1^2 C\right) L_1 $$
Хорошая новость, всем! Коэффициенты вычислять вручную не нужно. Достаточно указать значения сопротивления и температуры для холодной, комнатной и горячей среды, а затем с помощью формы ниже определить A, B и C.
Расчёт значения АЦП и сопротивления
Примечание: если у вас уже есть температура и соответствующее сопротивление, можно сразу ввести эти значения во вторую таблицу.
Если у вас есть значение АЦП и вы хотите вычислить сопротивление, используйте эту таблицу, чтобы найти соответствующее сопротивление при разных температурах. Когда вы вводите значение АЦП для каждой температуры, рассчитанное сопротивление автоматически обновляется во второй таблице.
Для этого расчёта понадобится базовое сопротивление термистора, которое необходимо для определения сопротивления при заданной температуре по значению АЦП.
Учтите, что количество бит АЦП может потребоваться изменить, если вы используете другой микроконтроллер. В нашем случае, для Raspberry Pi Pico, разрешение АЦП составляет 12 бит.
Поиск коэффициентов
Измените температуру, введя значение в градусах Фаренгейта или Цельсия: форма автоматически переведёт его в другой формат. Укажите сопротивление для каждой температуры, а затем нажмите кнопку «Рассчитать коэффициенты».
Расчёт температуры по сопротивлению
Теперь с этими коэффициентами можно вычислить температуру для любого сопротивления:
Функция на Rust
fn steinhart_temp_calc(
resistance: f64, // Сопротивление в омах
a: f64, // Коэффициент A
b: f64, // Коэффициент B
c: f64, // Коэффициент C
) -> Result<(f64, f64), String> {
if resistance <= 0.0 {
return Err("Resistance must be a positive number.".to_string());
}
// Вычисляем температуру в кельвинах по уравнению Штайнхарта — Харта:
// 1/T = A + B*ln(R) + C*(ln(R))^3
let ln_r = resistance.ln();
let inverse_temperature = a + b * ln_r + c * ln_r.powi(3);
if inverse_temperature == 0.0 {
return Err("Invalid coefficients or resistance leading to division by zero.".to_string());
}
let temperature_kelvin = 1.0 / inverse_temperature;
let temperature_celsius = temperature_kelvin - 273.15;
let temperature_fahrenheit = (temperature_celsius * 9.0 / 5.0) + 32.0;
Ok((temperature_celsius, temperature_fahrenheit))
}
fn main() {
// Примеры входных данных
let a = 2.10850817e-3;
let b = 7.97920473e-5;
let c = 6.53507631e-7;
let resistance = 10000.0;
match steinhart_temp_calc(resistance, a, b, c) {
Ok((celsius, fahrenheit)) => {
println!("Temperature in Celsius: {:.2}", celsius);
println!("Temperature in Fahrenheit: {:.2}", fahrenheit);
}
Err(e) => println!("Error: {}", e),
}
}
Ссылки
- Калькулятор термисторов (на английском языке)
- Коэффициенты Штайнхарта — Харта для расчёта температуры двигателя (на английском языке)
- Калибровка коэффициентов Штайнхарта — Харта для термисторов (на английском языке)
- Кулинарный термометр с коррекцией Штайнхарта — Харта (на английском языке)
Вывод температуры на OLED с помощью embedded Rust на Raspberry Pi Pico
В этой главе мы будем считывать температуру с термистора и выводить её на OLED-экран.
К этому моменту вы уже должны знать, как использовать OLED-дисплей с Raspberry Pi Pico. Вместо того чтобы выводить значения в консоль, сделаем всё интереснее и покажем их на аппаратном дисплее.
Требования к оборудованию
- Термистор NTC 103: 10 кОм, эпоксидный диск 5 мм
- OLED-дисплей (0,96 дюйма, I2C/IIC, 4 вывода, разрешение 128x64, микросхема SSD1306)
- Резистор 10 кОм: вместе с термистором образует делитель напряжения
- Перемычки
Схема подключения OLED и термистора к Raspberry Pi Pico
Подключение термистора
Мы подключим термистор как делитель напряжения и подадим выход делителя на вывод АЦП Pico. Здесь термистор выступает в роли R1 и подключён к питанию 3,3 В, поэтому значение АЦП уменьшается при росте температуры (поведение NTC).
| Вывод Pico | Провод | Компонент |
|---|---|---|
| 3.3 V |
|
Один вывод термистора |
| GPIO 28 (ADC2) |
|
Точка соединения термистора и резистора 10 кОм |
| Резистор 10 кОм |
|
Другой вывод термистора |
| GND |
|
Другой вывод резистора 10 кОм |
Подключение OLED (I2C)
OLED-дисплей подключается по I2C. SDA подключён к GPIO 16, а SCL — к GPIO 17.
| Вывод Pico | Провод | Вывод OLED |
|---|---|---|
| GND |
|
GND |
| 3.3 V |
|
VCC |
| GPIO 16 |
|
SDA |
| GPIO 17 |
|
SCL |
Пишем код на embedded Rust для вывода температуры на OLED-дисплей
В этом разделе мы переходим к части с кодом. Мы напишем программу, которая считывает значение термистора через АЦП, переводит его в температуру по уравнению B и выводит результат на OLED-дисплей по I2C.
Проект из шаблона
Как обычно, начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «temperature-oled», и выберите embassy в качестве HAL.
Требуемые дополнительные крейты
Нам понадобится несколько дополнительных крейтов для поддержки OLED-дисплея, форматирования текста и математических операций. Добавьте следующие записи в Cargo.toml вместе с существующими зависимостями.
#![allow(unused)]
fn main() {
ssd1306 = { version = "0.10.0", features = ["async"] }
heapless = "0.9.2"
libm = "0.2.15"
embedded-graphics = "0.8"
}
-
ssd1306: драйвер для управления OLED-дисплеями на основе SSD1306. -
heapless: в окружении no_std стандартный тип String недоступен, потому что он требует выделения памяти в куче. Этот крейт предоставляет структуры фиксированного размера, размещаемые на стеке. Мы используем его, чтобы хранить отформатированный текст, например значения АЦП, сопротивление и температуру, перед отправкой на OLED. -
libm: предоставляет математические функции для окружения no_std. Он нужен, чтобы вычислять натуральный логарифм при использовании уравнения B. -
embedded-graphics: Драйвер SSD1306 поддерживает разные способы вывода содержимого на дисплей.При использовании
into_buffered_graphics_modeдисплей рассматривается как буфер пикселей. Текст и фигуры сначала рисуются во внутренний кадровый буфер в памяти с помощью API embedded-graphics, а затем весь буфер отправляется на OLED. Этот режим требует крейт embedded-graphics.При использовании
into_terminal_modeдрайвер предоставляет простой текстовый интерфейс. Вы выводите символы прямо на дисплей, не рисуя пиксели или фигуры самостоятельно. В этом случае embedded-graphics не нужен.
Дополнительные импорты
#![allow(unused)]
fn main() {
// Форматирование текста без выделения памяти в куче
use core::fmt::Write;
use heapless::String;
// Для OLED-дисплея
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Для АЦП
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::gpio::Pull;
// Привязка прерываний
use embassy_rp::bind_interrupts;
use embassy_rp::peripherals::I2C0;
use embassy_rp::{adc, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// Embedded Graphics
use embedded_graphics::{
mono_font::{MonoTextStyle, iso_8859_13::FONT_7X13_BOLD},
pixelcolor::BinaryColor,
prelude::*,
text::Text,
};
}
Обработчик прерываний
В этом проекте мы используем периферию и АЦП, и I2C. Каждая из них генерирует прерывания, и Embassy требует, чтобы эти прерывания были явно привязаны на этапе компиляции.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
ADC_IRQ_FIFO — прерывание, которое генерирует АЦП, когда в его FIFO появляются данные. Оно необходимо для работы АЦП в Embassy. I2C0_IRQ — прерывание периферии I2C0. Оно нужно для асинхронного обмена по I2C с OLED-дисплеем.
Константы термистора
Определим несколько констант, которые описывают термистор и работу АЦП.
#![allow(unused)]
fn main() {
const ADC_LEVELS: f64 = 4096.0;
const B_VALUE: f64 = 3950.0;
const REF_RES: f64 = 10_000.0; // Опорное сопротивление в омах (10 кОм)
const REF_TEMP: f64 = 25.0; // Опорная температура 25 °C
}
Термистор, который мы использовали, имеет сопротивление 10 кОм при 25 °C и бета-значение 3950. У Pico 12-битный АЦП, поэтому есть 4096 возможных уровней АЦП.
Вспомогательные функции
Определим несколько вспомогательных функций.
Эта функция, которая переводит значения АЦП в сопротивление, использует уравнение делителя напряжения. Мы уже рассмотрели эту формулу раньше, поэтому здесь просто переиспользуем её.
#![allow(unused)]
fn main() {
// Эту формулу мы уже разбирали в главе об АЦП
fn adc_to_resistance(adc_value: u16, r2_res: f64) -> f64 {
let adc = adc_value as f64;
((ADC_LEVELS / adc) - 1.0) * r2_res
}
}
Эта функция, которая переводит сопротивление в температуру, применяет уравнение B. Поскольку мы работаем в окружении no_std, для вычисления натурального логарифма используем крейт libm.
#![allow(unused)]
fn main() {
// Уравнение B для перевода сопротивления в температуру
fn calculate_temperature(current_res: f64, ref_res: f64, ref_temp: f64, b_val: f64) -> f64 {
let ln_value = libm::log(current_res / ref_res); // Используем libm для `no_std`
let inv_t = (1.0 / ref_temp) + ((1.0 / b_val) * ln_value);
1.0 / inv_t
}
}
Также определим небольшие вспомогательные функции для перевода между кельвинами и градусами Цельсия.
#![allow(unused)]
fn main() {
fn kelvin_to_celsius(kelvin: f64) -> f64 {
kelvin - 273.15
}
fn celsius_to_kelvin(celsius: f64) -> f64 {
celsius + 273.15
}
}
Настройка дисплея
Настроим OLED на работу по I2C: SDA — на GPIO 16, SCL — на GPIO 17. Частоту I2C установим 400 кГц.
Инициализируем дисплей SSD1306 в режиме буферизованной графики. В этом режиме все операции рисования сначала выполняются в памяти. Содержимое отправляется на OLED только тогда, когда мы выгружаем буфер. Также задаём стиль текста: размер шрифта можно изменить.
#![allow(unused)]
fn main() {
// Настройка дисплея
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; //400 кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let text_style = MonoTextStyle::new(&FONT_7X13_BOLD, BinaryColor::On);
}
Настройка АЦП
Настроим канал АЦП, подключённый к выводу термистора. Затем инициализируем периферию АЦП, используя привязки прерываний, определённые ранее.
#![allow(unused)]
fn main() {
// Настройка АЦП для термистора
let mut adc_pin = Channel::new_pin(p.PIN_28, Pull::None);
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
}
Строка heapless
Создадим строку heapless фиксированной ёмкостью 64 символа. Эта строка живёт на стеке и переиспользуется на каждой итерации цикла. Мы используем её, чтобы хранить отформатированные значения температуры, показания АЦП и сопротивления перед тем, как выводить их на OLED.
#![allow(unused)]
fn main() {
let mut buff: String<64> = String::new();
}
Переводим опорную температуру в кельвины
Опорную температуру термистора задаём 25 °C. Поскольку уравнение B требует температуру в кельвинах, мы переводим это значение один раз при инициализации. Так мы не повторяем преобразование внутри цикла.
#![allow(unused)]
fn main() {
let ref_temp = celsius_to_kelvin(REF_TEMP);
}
Главный цикл
На каждой итерации цикла мы считываем значение термистора через АЦП, переводим показание в температуру, форматируем результат как текст и обновляем OLED.
Сначала очищаем и строковый буфер, и буфер дисплея.
#![allow(unused)]
fn main() {
buff.clear();
display
.clear(BinaryColor::Off)
.expect("failed to clear the display");
}
Читаем АЦП
Затем считываем значение АЦП с вывода термистора.
#![allow(unused)]
fn main() {
let adc_value = adc
.read(&mut adc_pin)
.await
.expect("failed to read adc value");
}
Переводим значение АЦП в температуру
Переводим показание АЦП в сопротивление по уравнению делителя напряжения. Затем переводим сопротивление в температуру по уравнению B, которое даёт температуру в кельвинах. Наконец, переводим значение в градусы Цельсия.
#![allow(unused)]
fn main() {
let current_res = adc_to_resistance(adc_value, REF_RES);
let temperature_kelvin = calculate_temperature(current_res, REF_RES, ref_temp, B_VALUE);
let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
}
Форматируем текст вывода
Форматируем температуру, значение АЦП и сопротивление в строку heapless.
#![allow(unused)]
fn main() {
writeln!(buff, "Temp: {:.2} °C", temperature_celsius)
.expect("failed to format temperature");
writeln!(buff, "ADC: {}", adc_value).expect("failed to format ADC value");
writeln!(buff, "R: {:.2}", current_res).expect("failed to format Resistance");
}
Обновляем OLED
Выводим отформатированный текст в буфер дисплея, выгружаем буфер, чтобы обновить OLED, и ждём перед следующей итерацией.
#![allow(unused)]
fn main() {
Text::new(&buff, Point::new(5, 20), text_style)
.draw(&mut display)
.expect("Failed to write the text");
display.flush().await.expect("failed to send to display");
Timer::after_secs(2).await;
}
Запуск
Когда вы прошьёте прошивку, вы должны увидеть температуру вместе со значениями сопротивления и АЦП. Можно переместить установку в другую комнату, понаблюдать, как меняются показания днём и ночью, или вынести её на улицу с внешним источником питания, чтобы увидеть, как температура реагирует в разных условиях.
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp::block::ImageDef;
use embassy_rp::{self as hal};
use embassy_time::Timer;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
// Форматирование текста без выделения памяти в куче
use core::fmt::Write;
use heapless::String;
// Для OLED-дисплея
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Для АЦП
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::gpio::Pull;
// Привязка прерываний
use embassy_rp::bind_interrupts;
use embassy_rp::peripherals::I2C0;
use embassy_rp::{adc, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// Embedded Graphics
use embedded_graphics::{
mono_font::{MonoTextStyle, iso_8859_13::FONT_7X13_BOLD},
pixelcolor::BinaryColor,
prelude::*,
text::Text,
};
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
const ADC_LEVELS: f64 = 4096.0;
const B_VALUE: f64 = 3950.0;
const REF_RES: f64 = 10_000.0; // Опорное сопротивление в омах (10 кОм)
const REF_TEMP: f64 = 25.0; // Опорная температура 25 °C
// Эту формулу мы уже разбирали в главе об АЦП
fn adc_to_resistance(adc_value: u16, r2_res: f64) -> f64 {
let adc = adc_value as f64;
((ADC_LEVELS / adc) - 1.0) * r2_res
}
// Уравнение B для перевода сопротивления в температуру
fn calculate_temperature(current_res: f64, ref_res: f64, ref_temp: f64, b_val: f64) -> f64 {
let ln_value = libm::log(current_res / ref_res); // Используем libm для `no_std`
let inv_t = (1.0 / ref_temp) + ((1.0 / b_val) * ln_value);
1.0 / inv_t
}
fn kelvin_to_celsius(kelvin: f64) -> f64 {
kelvin - 273.15
}
fn celsius_to_kelvin(celsius: f64) -> f64 {
celsius + 273.15
}
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// Настройка дисплея
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; //400 кГц
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let text_style = MonoTextStyle::new(&FONT_7X13_BOLD, BinaryColor::On);
// Настройка АЦП для термистора
let mut adc_pin = Channel::new_pin(p.PIN_28, Pull::None);
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut buff: String<64> = String::new();
let ref_temp = celsius_to_kelvin(REF_TEMP);
loop {
buff.clear();
display
.clear(BinaryColor::Off)
.expect("failed to clear the display");
let adc_value = adc
.read(&mut adc_pin)
.await
.expect("failed to read adc value");
let current_res = adc_to_resistance(adc_value, REF_RES);
let temperature_kelvin = calculate_temperature(current_res, REF_RES, ref_temp, B_VALUE);
let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
writeln!(buff, "Temp: {:.2} °C", temperature_celsius)
.expect("failed to format temperature");
writeln!(buff, "ADC: {}", adc_value).expect("failed to format ADC value");
writeln!(buff, "R: {:.2}", current_res).expect("failed to format Resistance");
Text::new(&buff, Point::new(5, 20), text_style)
.draw(&mut display)
.expect("Failed to write the text");
display.flush().await.expect("failed to send to display");
Timer::after_secs(2).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"temperature-oled"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку temperature-oled.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/temperature-oled/
Связь по USB Serial
В этом разделе мы настроим обмен данными между нашим устройством (Pico) и компьютером под управлением Linux. Мы отправим простую строку с Pico на компьютер, а также передадим ввод с компьютера обратно на Pico.
Это особенно полезно, когда у вас нет отладочного зонда и вы хотите выводить показания датчиков или сообщения о состоянии в системную консоль, чтобы быстро понять, что происходит. Я сам много раз пользовался этим подходом, ещё до того как купил отладочный зонд.
Tip
Если у вас есть отладочный зонд, эту главу можно пока пропустить. К ней всегда можно вернуться позже, когда понадобится обмениваться данными с компьютером по USB, например отправлять сообщения, читать ввод или строить простые интерфейсы на командах.
CDC ACM
Когда вы подключаете USB-устройство к компьютеру, компьютеру нужно знать, что это за устройство и как с ним общаться. USB решает эту задачу, определяя стандартные типы устройств, которые называются классами.
CDC расшифровывается как Communication Device Class (класс коммуникационных устройств). Это класс USB для устройств, которые обмениваются данными, отправляя и принимая их, похоже на последовательную связь. Один из очень распространённых типов CDC называется ACM, что означает Abstract Control Model (абстрактная модель управления).
CDC ACM делает USB-устройство похожим на простой последовательный порт для компьютера.
Поэтому, хотя данные передаются по USB, операционная система воспринимает их как обычное последовательное соединение. В Linux именно поэтому устройство появляется как что-то вроде /dev/ttyACM0.
Если вы когда-либо использовали UART через USB-адаптер для последовательного порта, со стороны программного обеспечения это ощущается почти так же.
Инструменты для Linux
Когда вы прошьёте код из этого упражнения, устройство появится на компьютере как /dev/ttyACM0. Для работы с последовательным портом USB в Linux можно использовать такие инструменты, как minicom, tio (или cat), чтобы читать и отправлять данные на устройство и с него
- minicom: текстовая программа для связи через последовательный порт. Она используется для общения с внешними устройствами RS-232, например мобильными телефонами, маршрутизаторами и консольными портами.
- tio: инструмент для работы с последовательными устройствами, с простым интерфейсом командной строки и файлом конфигурации, который позволяет легко подключаться к последовательным TTY-устройствам для базового ввода-вывода.
Крейты Rust
Мы будем использовать пример из репозитория RP-HAL. Он использует два крейта: usb-device, стек USB для встраиваемых устройств на Rust, и usbd-serial, который реализует класс последовательного порта USB CDC-ACM. Класс SerialPort в usbd-serial реализует буферизованный последовательный порт с потоковым интерфейсом и может использоваться почти так же, как UART.
Ссылки
- CDC: Communication Device Class (ACM) (на английском языке)
- USB Device CDC ACM Class (на английском языке)
- В чём разница между /dev/ttyUSB и /dev/ttyACM? (на английском языке)
- Defined Class Codes (на английском языке)
Логирование по USB Serial с Embassy на Raspberry Pi Pico
В этой главе мы рассмотрим простой способ выводить сообщения с Raspberry Pi Pico в системную консоль через USB Serial. Мы будем использовать интерфейс USB вместе с крейтом embassy-usb-logger, чтобы отправлять логи на компьютер.
Пример намеренно будет небольшим. Pico ведёт счётчик, который увеличивается раз в секунду. Каждый раз, когда значение меняется, мы выводим его в системную консоль по USB. Когда счётчик достигает предела, он сбрасывается в ноль и продолжает работу. Цель здесь не сам счётчик, а демонстрация того, как работает логирование, когда в качестве вывода используется USB Serial.
Этот подход полезен, когда вы хотите, чтобы вывод логов появлялся на компьютере как обычное последовательное устройство. Настроив это один раз, вы сможете тем же способом выводить показания датчиков, изменения состояния или отладочные сообщения вашего приложения.
Проект из шаблона
Начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «cdc-logger», и выберите embassy в качестве HAL. Включать defmt для этой схемы не нужно.
Требуемые дополнительные крейты
Обновите Cargo.toml, добавив крейт логгера USB.
embassy-usb-logger = "0.5.1"
log = "0.4"
Крейт embassy-usb-logger предоставляет готовую реализацию логгера, который отправляет сообщения по USB. Крейт log предоставляет макросы логирования, которые используются во всей программе.
Дополнительные импорты
Добавьте следующие импорты в файл main.rs.
#![allow(unused)]
fn main() {
// Для USB
use embassy_rp::{peripherals::USB, usb};
}
Привязка прерывания USB
Периферии USB нужен обработчик прерываний. Embassy предоставляет этот обработчик, нам остаётся только привязать его.
#![allow(unused)]
fn main() {
embassy_rp::bind_interrupts!(struct Irqs {
USBCTRL_IRQ => usb::InterruptHandler<USB>;
});
}
Задача логгера USB
Работа с USB выполняется в собственной задаче. Эта задача создаёт драйвер USB и запускает логгер USB.
#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn logger_task(usb: embassy_rp::Peri<'static, embassy_rp::peripherals::USB>) {
let driver = embassy_rp::usb::Driver::new(usb, Irqs);
embassy_usb_logger::run!(1024, log::LevelFilter::Info, driver);
}
}
Задача логгера работает непрерывно и в фоне обрабатывает всю связь по USB.
Функция main
В функции main мы запускаем задачу логгера USB.
#![allow(unused)]
fn main() {
spawner.must_spawn(logger_task(p.USB));
}
Главный цикл
Мы ведём простой счётчик и увеличиваем его раз в секунду. Счётчик использует арифметику с переполнением (wrapping), поэтому после достижения максимального значения он возвращается к нулю. На каждой итерации мы логируем текущее значение с помощью log::info!, что отправляет сообщение по USB Serial.
#![allow(unused)]
fn main() {
let mut i: u8 = 0;
loop {
i = i.wrapping_add(1);
log::info!("USB says: {}", i);
Timer::after_secs(1).await;
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку cdc-logger.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/usb-serial/cdc-logger/
Как запустить?
Прошивка (запуск кода) на Pico выполняется как обычно. Однако нужно настроить tio для взаимодействия с Pico через последовательный порт (/dev/ttyACM0). Это позволит читать данные с Pico или отправлять их на него.
tio
Убедитесь, что в вашей системе установлен tio. Если нет, его можно установить командой:
apt install tio
Подключение к последовательному порту
Выполните следующую команду, чтобы подключиться к последовательному порту Pico:
tio /dev/ttyACM0
Откроется сеанс терминала для связи с Pico.
Прошивка и запуск кода
Откройте другой терминал, перейдите в папку проекта и прошейте код на Pico как обычно:
cargo run --release
Когда программа начнёт работать, в терминале tio вы увидите значение счётчика, которое выводится раз в секунду.
Ссылка
rp-usb-console— крейт для логирования по USB, который также поддерживает команды, например для настройки уровней логирования (на английском языке)
Пример USB Serial с rp-hal
Мы возьмём пример из репозитория RP HAL и немного изменим его, чтобы наглядно увидеть двустороннюю связь.
Исходный пример отправляет простое сообщение «Hello, World!» с Pico на компьютер, как только внутренний таймер достигает определённого значения. Также он опрашивает входящие данные с компьютера и отправляет данные обратно.
Мы немного усложним его и сделаем интереснее. Вместо «Hello, World!» мы отправим на компьютер «Hello, Rust!». Понимаю, это не самая интересная часть. Вот что будет интереснее: если вы введёте символ ‘r’ в терминале, подключённом по USB Serial, встроенный светодиод загорится. Введёте любой другой символ — светодиод погаснет.
Проект из шаблона
Чтобы настроить проект, создайте его из предоставленного шаблона:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например «usb-serial-led», и выберите RP-HAL в качестве HAL.
Требуемые дополнительные крейты
Обновите Cargo.toml, добавив следующие крейты вместе с существующими зависимостями:
#![allow(unused)]
fn main() {
usbd-serial = "0.2.2"
usb-device = "0.3.2"
}
Дополнительные импорты
#![allow(unused)]
fn main() {
// Поддержка USB Device
use usb_device::{class_prelude::*, prelude::*};
// Поддержка класса USB Communications Class Device
use usbd_serial::SerialPort;
// Для светодиода
use embedded_hal::digital::OutputPin;
}
Настройка светодиода
Настроим встроенный светодиод.
#![allow(unused)]
fn main() {
let mut led = pins.gpio25.into_push_pull_output();
}
Настройка драйвера USB
Сначала создаём шину USB, используя периферию USB RP2350 и её DPRAM:
#![allow(unused)]
fn main() {
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USB,
pac.USB_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
}
Создание последовательного интерфейса CDC ACM
Далее создаём последовательный порт CDC ACM поверх шины USB:
#![allow(unused)]
fn main() {
let mut serial = SerialPort::new(&usb_bus);
}
Этот объект serial ведёт себя очень похоже на последовательный порт в стиле UART.
Создание USB-устройства
Теперь описываем само USB-устройство, чтобы хост знал, как его перечислить:
#![allow(unused)]
fn main() {
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.strings(&[StringDescriptors::default()
.manufacturer("implRust")
.product("Ferris")
.serial_number("TEST")])
.unwrap()
.device_class(2) // 2 для CDC, см.: https://www.usb.org/defined-class-codes
.build();
}
Для разработки мы используем поддельные VID и PID, задаём базовые строковые дескрипторы и устанавливаем класс устройства CDC, чтобы Linux воспринимал его как последовательное устройство.
Отслеживание приветственного сообщения
Объявляем флаг, который отслеживает, отправлено ли уже приветственное сообщение. Этот флаг проверяется внутри главного цикла, чтобы сообщение отправилось только один раз.
#![allow(unused)]
fn main() {
let mut said_hello = false;
}
Главный цикл
Отправка сообщения на компьютер
Как только программа начинает работать, мы проверяем таймер. Когда счётчик достигает 2 000 000 тактов, мы отправляем компьютеру сообщение «Hello, Rust!» и следим, чтобы оно было отправлено только один раз:
#![allow(unused)]
fn main() {
if !said_hello && timer.get_counter().ticks() >= 2_000_000 {
said_hello = true;
// Записывает байты из `data` в порт и возвращает количество записанных байтов.
let _ = serial.write(b"Hello, Rust!\r\n");
}
}
Опрос входящих данных
Одновременно мы непрерывно опрашиваем USB-устройство на предмет входящих данных. Когда данные приходят, мы считываем их и проверяем каждый полученный байт. Если символ — ‘r’, встроенный светодиод включается. Любой другой символ выключает светодиод:
#![allow(unused)]
fn main() {
if usb_dev.poll(&mut [&mut serial]) {
let mut buf = [0u8; 64];
if let Ok(count) = serial.read(&mut buf) {
for &byte in &buf[..count] {
if byte == b'r' {
led.set_high().expect("unable to turn on LED");
} else {
led.set_low().expect("unable to turn off LED");
}
}
}
}
}
Полный код
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
//Обработчик паники
use panic_probe as _;
// Поддержка USB Device
use usb_device::{class_prelude::*, prelude::*};
// Поддержка класса USB Communications Class Device
use usbd_serial::SerialPort;
// Для светодиода
use embedded_hal::digital::OutputPin;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// Внешний высокоскоростной кварц на плате Raspberry Pi Pico 2 работает на 12 МГц.
/// Измените значение, если на вашей плате другая частота
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
// Получаем наши синглтон-объекты
let mut pac = hal::pac::Peripherals::take().unwrap();
// Настраиваем драйвер сторожевого таймера — он нужен для настройки тактирования
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Настраиваем тактирование
//
// По умолчанию генерируется системная частота 125 МГц
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// Блок SIO с однотактным доступом управляет нашими выводами GPIO
let sio = hal::Sio::new(pac.SIO);
// Настраиваем выводы в соответствии с их функциями на этой плате
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
let mut led = pins.gpio25.into_push_pull_output();
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USB,
pac.USB_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut serial = SerialPort::new(&usb_bus);
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.strings(&[StringDescriptors::default()
.manufacturer("implRust")
.product("Ferris")
.serial_number("TEST")])
.unwrap()
.device_class(2) // 2 для CDC, см.: https://www.usb.org/defined-class-codes
.build();
let mut said_hello = false;
loop {
if !said_hello && timer.get_counter().ticks() >= 2_000_000 {
said_hello = true;
// Записывает байты из `data` в порт и возвращает количество записанных байтов.
let _ = serial.write(b"Hello, Rust!\r\n");
}
if usb_dev.poll(&mut [&mut serial]) {
let mut buf = [0u8; 64];
if let Ok(count) = serial.read(&mut buf) {
for &byte in &buf[..count] {
if byte == b'r' {
led.set_high().expect("unable to turn on LED");
} else {
led.set_low().expect("unable to turn off LED");
}
}
}
}
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку usb-serial-led.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/usb-serial-led/
Как запустить?
Прошивка (запуск кода) на Pico выполняется как обычно. Однако нужно настроить tio для взаимодействия с Pico через последовательный порт (/dev/ttyACM0). Это позволит читать данные с Pico или отправлять их на него.
tio
Убедитесь, что в вашей системе установлен tio. Если нет, его можно установить командой:
apt install tio
Подключение к последовательному порту
Выполните следующую команду, чтобы подключиться к последовательному порту Pico:
tio /dev/ttyACM0
Откроется сеанс терминала для связи с Pico.
Прошивка и запуск кода
Откройте другой терминал, перейдите в папку проекта и прошейте код на Pico как обычно:
cargo run
Если всё настроено правильно, в терминале tio вы увидите сообщение «Connected», за которым последует сообщение «Hello, Rust!», отправленное с Pico.
Отправка данных на Pico
В терминале, где запущен tio, всё, что вы печатаете, будет отправлено на Pico. Вы не увидите того, что печатаете (потому что мы не возвращаем введённые символы обратно в терминал).
Если вы нажмёте букву ‘r’, встроенный светодиод включится. Если нажмёте любой другой символ, светодиод выключится.
Версия на Embassy
Также можно посмотреть этот проект, который демонстрирует использование USB Serial с фреймворком Embassy.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/usb-serial/
Последовательный периферийный интерфейс (SPI)
В этом разделе мы узнаем, что такое SPI и как использовать шины связи SPI на Raspberry Pi Pico.
Что такое SPI?
SPI расшифровывается как Serial Peripheral Interface (последовательный периферийный интерфейс). Это один из самых распространённых способов, которым микроконтроллеры обмениваются данными с такими устройствами, как дисплеи, датчики и SD-карты. Технически это последовательный, полнодуплексный и синхронный интерфейс. Но что означают эти термины?
-
Последовательный (serial) означает, что данные передаются по одному биту, один за другим, по одной линии данных. Это отличается от параллельной связи, при которой несколько битов передаются одновременно по нескольким линиям.
-
Полнодуплексный (full duplex) означает, что два устройства могут отправлять и принимать данные одновременно. Оба устройства могут передавать данные друг другу, не дожидаясь, пока другое закончит. Это отличается от полудуплексного режима, где устройства должны работать по очереди: одно отправляет, пока другое принимает.
-
Синхронный (synchronous) означает, что оба устройства используют общий тактовый сигнал. Этот тактовый сигнал точно задаёт, когда отправляется и считывается каждый бит данных, поэтому обе стороны остаются согласованными во время обмена.
Контроллер и периферия
SPI использует модель «контроллер — периферия» (раньше её называли «ведущий — ведомый», master-slave). Контроллер — это устройство, которое начинает обмен и формирует тактовый сигнал. Периферия — это устройство, которое отвечает контроллеру.
В нашем случае Raspberry Pi Pico выступает в роли контроллера, а такие устройства, как дисплеи, датчики или SD-карты, — в роли периферии.
Рисунок: одна шина SPI с контроллером и одной периферией
Обычно соединение использует четыре линии:
-
Линия SCK (Serial Clock) несёт тактовый сигнал, который формирует контроллер. Этот сигнал синхронизирует контроллер и устройство во время передачи данных.
-
Линия MOSI (Master Out, Slave In) используется для отправки данных от контроллера к устройству. В некоторых даташитах эта линия может обозначаться как SDO (Serial Data Out).
-
Линия MISO (Master In, Slave Out) передаёт данные от устройства к контроллеру. В зависимости от устройства её также могут называть SDI (Serial Data In).
-
Наконец, линия CS (Chip Select) используется контроллером, чтобы выбрать, с каким устройством он хочет обмениваться данными. У каждого подключённого устройства обычно есть собственная линия CS. Когда контроллер переводит линию CS устройства в низкий уровень (активный), устройство становится активным и готовым к обмену. В некоторой старой документации эту линию могут называть SS (Slave Select).
Рисунок: одна шина SPI с контроллером и несколькими периферийными устройствами
Режимы SPI
SPI поддерживает четыре режима, которые нумеруются от 0 до 3. Они определяют, когда данные считываются и записываются, в зависимости от уровня простоя тактового сигнала и фронта.
Пока не стоит слишком углубляться в детали. Режим 0 — самый распространённый и подходит для большинства устройств.
Почему SPI?
SPI — отличный выбор, когда нужна быстрая и надёжная связь между микроконтроллером и периферийным устройством. Он заметно быстрее I²C или UART, прост в использовании и позволяет одновременно передавать и принимать данные (полный дуплекс). Поэтому он идеально подходит для высокоскоростных устройств, таких как дисплеи или SD-карты.
Пока есть достаточно выводов GPIO и не нужно подключать большое количество устройств, SPI обычно лучший инструмент для такой задачи.
Ресурсы
Для более подробных технических сведений обратитесь к следующим материалам:
- Serial Peripheral Interface (SPI) от Sparkfun (на английском языке)
- Основы протокола связи SPI (на английском языке)
Использование SPI в экосистеме embedded Rust
В предыдущем разделе мы узнали, что такое SPI и как работает модель «контроллер — периферия». Теперь посмотрим, как эти понятия применяются в экосистеме embedded Rust.
Экосистема embedded Rust построена на модульности и повторном использовании. Это значит, что код, написанный для одного микроконтроллера, можно перенести на другой с минимальными изменениями. Одна из ключевых причин такой гибкости — трейты, которые определяет крейт embedded-hal.
SPI в embedded-hal
Крейт embedded-hal определяет стандартные трейты для работы с SPI, чтобы драйверы и библиотеки можно было писать в обобщённом виде. Для SPI важны два трейта:
-
SpiBus: представляет полный контроль над шиной SPI, включая линии SCK, MOSI и MISO. Его должен реализовать крейт HAL микроконтроллера. Например, крейт esp-hal реализует SpiBus. Если вам интересно, можно посмотреть реализацию здесь.
-
SpiDevice: представляет доступ к одному устройству SPI, которое может делить шину с другими. Он берёт на себя управление линией выбора микросхемы (CS) и гарантирует, что устройство выбрано перед обменом и освобождено после него.
Драйверы, не зависящие от платформы
Представьте, что вы пишете драйвер для датчика или дисплея, который общается по SPI. Вы не хотите привязывать свой код к конкретному микроконтроллеру, например Raspberry Pi Pico или ESP32. Вместо этого можно написать драйвер в обобщённом виде, используя трейты embedded-hal.
Пока ваш драйвер зависит только от трейтов SpiDevice или SpiBus, он может работать на любой платформе, которая предоставляет реализацию этих трейтов, например на STM32, nRF или ESP32.
Совместное использование шины SPI
Во многих проектах несколько устройств SPI используют одну и ту же шину. Например, к одним и тем же линиям MOSI, MISO и SCK могут быть подключены дисплей, SD-карта и датчик температуры. Разделяет их только линия выбора микросхемы (CS).
Рисунок: одна шина SPI с контроллером и несколькими периферийными устройствами
Если отдать полный контроль над шиной SPI одному драйверу (через SpiBus), остальные не смогут её использовать. Поэтому нам нужен способ безопасно разделять шину SPI между несколькими устройствами.
Именно для этого существует SpiDevice: он позволяет каждому драйверу использовать только свою линию CS, оставаясь при этом на общей шине.
На практике это означает, что каждому драйверу нужно передавать структуру, которая реализует SpiDevice, а не всю шину целиком.
Как получить SpiDevice?
Мы сказали, что каждому драйверу следует передавать реализацию SpiDevice, а не полную шину SpiBus. Но нужно ли самим писать эту структуру SpiDevice?
На самом деле нет. Хотя написать собственную реализацию можно, это обычно не нужно, а правильно сделать её непросто, особенно когда нескольким устройствам нужно согласовывать доступ к шине.
Здесь пригодится крейт embedded-hal-bus. Он предоставляет готовые обёртки, которые реализуют трейт SpiDevice за вас. Эти обёртки берут на себя доступ к шине, управление выбором микросхемы и при необходимости синхронизацию между устройствами.
-
Если ваш проект использует только одно устройство SPI и не нужно совместное использование, можно взять структуру
ExclusiveDevice— она даёт одному устройству эксклюзивный доступ к шине. -
Если же в проекте несколько устройств SPI на одной шине, можно выбрать одну из реализаций совместного доступа, например
AtomicDeviceилиCriticalSectionDevice. Они управляют доступом к шине так, что каждое устройство получает свою очередь, не мешая остальным.
Эти структуры позволяют сосредоточиться на использовании или написании драйверов, не беспокоясь о низкоуровневой координации и шаблонном коде.
Ресурсы
- Документация embedded-hal по SPI: подробное описание того, как устроены трейты SPI и как их предполагается использовать на разных платформах.
Периферия SPI в RP2350
В RP2350 есть два контроллера SPI, которые называются SPI0 и SPI1. Оба — периферия общего назначения, доступная для использования в приложениях. Эти контроллеры обеспечивают гибкие возможности последовательной связи для подключения внешних устройств, таких как датчики, дисплеи, SD-карты и другие периферийные устройства SPI.
Каждый модуль SPI поддерживает стандартную работу по SPI, включая режимы мастера и ведомого, полнодуплексные передачи и обычные сигналы SPI: SCLK, MOSI, MISO и выбор микросхемы (chip select).
Более подробные и точные сведения можно найти на 1046-й странице даташита RP2350: https://pip-assets.raspberrypi.com/categories/1214-rp2350/documents/RP-008373-DS-2-rp2350-datasheet.pdf?disposition=inline#page=1047
Выводы GPIO
Raspberry Pi Pico 2 выводит на свой разъём GPIO две шины SPI: SPI0 и SPI1. Каждая шина SPI использует четыре сигнала: тактирование (SCK), передачу (MOSI), приём (MISO) и выбор микросхемы (CS).
На приведённой схеме выводы SPI выделены розовым цветом.
Варианты выводов SPI0
| MISO (RX) | CS | SCK | MOSI (TX) |
|---|---|---|---|
| GPIO 0 | GPIO 1 | GPIO 2 | GPIO 3 |
| GPIO 4 | GPIO 5 | GPIO 6 | GPIO 7 |
| GPIO 16 | GPIO 17 | GPIO 18 | GPIO 19 |
Варианты выводов SPI1
| MISO (RX) | CS | SCK | MOSI (TX) |
|---|---|---|---|
| GPIO 8 | GPIO 9 | GPIO 10 | GPIO 11 |
| GPIO 12 | GPIO 13 | GPIO 14 | GPIO 15 |
Использование SPI в Embassy
Embassy предоставляет два способа использовать SPI. Первый — асинхронный, при котором передачи можно ожидать (await) без блокировки исполнителя. Второй — блокирующий, который ведёт себя ближе к традиционным HAL и часто проще для небольших примеров или разовых передач.
Асинхронный режим
В асинхронном режиме SPI под капотом использует DMA. Нужно предоставить каналы DMA, а все передачи выполняются с помощью .await. Это полезно, когда передачи SPI — часть большого асинхронного приложения, и вы не хотите блокировать другие задачи.
#![allow(unused)]
fn main() {
let miso = p.PIN_12;
let mosi = p.PIN_11;
let clk = p.PIN_10;
let mut spi_bus = Spi::new(p.SPI1, clk, mosi, miso, p.DMA_CH0, p.DMA_CH1, spi::Config::default());
let tx_buf = [1_u8, 2, 3, 4, 5, 6];
let mut rx_buf = [0_u8; 6];
spi_bus.transfer(&mut rx_buf, &tx_buf).await.unwrap();
info!("{:?}", rx_buf);
}
Здесь данные записываются из tx_buf, а данные, полученные от периферии, сохраняются в rx_buf. Вызов transfer возвращает управление только после завершения транзакции SPI.
Блокирующий режим
Блокирующий SPI проще: вызовы возвращаются только после завершения передачи, и не требуется ни DMA, ни асинхронный контекст.
#![allow(unused)]
fn main() {
let miso = p.PIN_12;
let mosi = p.PIN_11;
let clk = p.PIN_10;
let mut config = spi::Config::default();
let mut spi_bus = Spi::new_blocking(p.SPI1, clk, mosi, miso, config);
spi_bus.blocking_transfer_in_place(&mut buf).unwrap();
}
Использование SPI в rp-hal
В rp-hal сначала нужно настроить выводы в режим функции SPI, затем создаётся периферия SPI и инициализируется с нужной тактовой частотой и режимом SPI.
#![allow(unused)]
fn main() {
// Эти выводы неявно используются драйвером SPI, если они в правильном режиме
let spi_mosi = pins.gpio7.into_function::<hal::gpio::FunctionSpi>();
let spi_miso = pins.gpio4.into_function::<hal::gpio::FunctionSpi>();
let spi_sclk = pins.gpio6.into_function::<hal::gpio::FunctionSpi>();
let spi_bus = hal::spi::Spi::<_, _, _, 8>::new(pac.SPI0, (spi_mosi, spi_miso, spi_sclk));
// Заменяем неинициализированный драйвер SPI на инициализированный
let mut spi_bus = spi_bus.init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
16.MHz(),
embedded_hal::spi::MODE_0,
);
// Записываем 0, возвращаемое значение игнорируем
if spi_bus.write(&[0]).is_ok() {
// Запись по SPI прошла успешно
};
}
Пример использования драйвера
Обычно вы не будете работать с SPI напрямую. Вместо этого вы передаёте устройство SPI драйверу. Обычно это означает, что сначала создаётся шина SPI, а затем она оборачивается в ExclusiveDevice из крейта embedded-hal-bus.
#![allow(unused)]
fn main() {
let clk = p.PIN_14;
let mosi = p.PIN_15;
let spi_bus = Spi::new_blocking_txonly(p.SPI1, clk, mosi, SpiConfig::default());
let spi_dev = ExclusiveDevice::new_no_delay(spi_bus, cs_pin).expect("Failed to get exclusive device");
// Создаём экземпляр дисплея для одной светодиодной матрицы 8x8 (без последовательного соединения)
let mut display = SingleMatrix::from_spi(spi_dev).expect("display count 1 should not panic");
}
Светодиодная матрица 8x8 на MAX7219
В этой главе мы будем работать с одним из интересных модулей дисплеев. Это светодиодная точечная матрица 8×8. Светодиодная точечная матрица — это по сути сетка светодиодов, расположенных строками и столбцами, которая позволяет выводить узоры, цифры, текст и простую графику. Модуль, с которым мы работаем, — матрица 8×8, то есть у нас 64 отдельных светодиода для управления.
Точечные матрицы часто встречаются в повседневной жизни. Их можно увидеть в автобусах и поездах, где они показывают номера маршрутов и сообщения о прибытии, в лифтах — с номерами этажей и стрелками направления, в цифровых часах — с временем и простыми символами. Их также используют на промышленном оборудовании и панелях управления, чтобы показывать состояние работы, предупреждения или сообщения об ошибках, а также в торговых центрах и аркадных играх.
Знакомство с железом
Теперь посмотрим на само оборудование. Модуль построен вокруг микросхемы MAX7219. Эта микросхема отвечает за управление всеми 64 светодиодами матрицы 8x8.
Без этой микросхемы понадобилось бы много выводов GPIO, потому что каждую строку и каждый столбец светодиодной матрицы пришлось бы подключать и управлять ими напрямую с микроконтроллера. MAX7219 берёт это на себя внутри, поэтому микроконтроллеру нужно лишь отправлять команды высокого уровня, а не управлять каждым светодиодом.
Tip
В этой книге мы не будем разбирать внутреннее устройство MAX7219. Мы воспользуемся готовым драйвером и сосредоточимся на управлении дисплеем. Если вы хотите научиться писать драйвер MAX7219 с нуля, включая реализацию трейтов embedded-graphics, это можно найти в книге RED (Rust Embedded Drivers).
Последовательное соединение (daisy chaining)
Одна полезная особенность MAX7219 — модули можно соединять последовательно (daisy chain). Это значит, что можно соединить несколько одинаковых модулей вместе и получить более крупный дисплей. Например, можно соединить 4 или 8 модулей рядом, чтобы получить более широкую точечную матрицу.
Некоторые модули поставляются с уже припаянными разъёмами, что упрощает последовательное соединение. При использовании нескольких модулей нужно внимательно относиться к проводке и питанию, потому что больше светодиодов означает больший ток потребления. Мы начнём с одного модуля и будем держать всё простым.
7-сегментные дисплеи
Та же микросхема MAX7219 часто используется и с 7-сегментными светодиодными дисплеями. Внутреннее устройство и способ обмена данными те же. В этой главе мы сосредоточимся только на светодиодной матрице 8x8. Когда вы поймёте это, работа с 7-сегментными дисплеями покажется очень похожей.
Обмен данными
MAX7219 использует последовательный интерфейс, похожий на SPI. Ему нужны три управляющих сигнала. DIN — линия данных, по которой информация отправляется в микросхему. CLK — тактовый сигнал, который сообщает микросхеме, когда считывать каждый бит. CS, иногда обозначаемый как LOAD, используется для защёлкивания данных в микросхеме.
На стороне микроконтроллера мы используем периферию SPI только на запись, чтобы отправлять данные в MAX7219. Микросхема ничего не отправляет обратно. Она только принимает команды и обновляет дисплей в соответствии с тем, что мы ей передаём.
Схема подключения
Для дисплея MAX7219 нужно пять соединений с Raspberry Pi Pico 2. В этом примере используется SPI1 с GPIO 13, 14 и 15, но можно взять любую допустимую пару выводов SPI на вашем Pico.
| Вывод Pico | Провод | Вывод MAX7219 |
|---|---|---|
| GPIO 13 (SPI1 CS) |
|
CS / LOAD |
| GPIO 14 (SPI1 SCK) |
|
CLK |
| GPIO 15 (SPI1 MOSI) |
|
DIN |
| VBUS (5 V) |
|
VCC |
| GND |
|
GND |
Код на embedded Rust для управления светодиодной матрицей MAX7219 на Raspberry Pi Pico 2
Перейдём к части с кодом. В этой программе мы нарисуем квадрат на светодиодной матрице 8x8 с помощью embedded-graphics.
Tip
Рисование квадрата — лишь пример, но, освоив основы, можно делать гораздо больше. Можно прокручивать текст, выводить иконки или смайлики и даже анимировать объекты. Примеры можно посмотреть здесь: https://github.com/ImplFerris/max7219-examples. Также можно попробовать собрать простые часы из последовательно соединённых матриц.
Как обычно, создайте новый проект с помощью предоставленного шаблона.
#![allow(unused)]
fn main() {
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
}
Когда появится запрос, задайте проекту имя, например «hello-max7219», и выберите embassy в качестве HAL.
Дополнительные крейты
Добавим следующие крейты вместе с уже существующими зависимостями:
max7219-display = { version = "0.1.5", features = ["led-matrix", "graphics"] }
embedded-graphics = "0.8.0"
embedded-hal-bus = "0.3.0"
Крейт max7219-display предоставляет драйвер, который используется в этой программе. Драйвер поддерживает разные виды модулей дисплеев, например 7-сегментные дисплеи и светодиодные матрицы. В нашем случае используется светодиодная матрица 8x8, поэтому мы включаем функцию led-matrix. Поскольку мы хотим рисовать фигуры, а не управлять отдельными светодиодами вручную, мы также включаем функцию graphics, которая реализует для драйвера трейты embedded-graphics.
Крейт embedded-graphics используется для создания фигур и текста в структурированном виде. Он предоставляет примитивы вроде прямоугольников, точек и стилей, что значительно упрощает рисование на светодиодной матрице.
embedded-hal-bus
Крейт embedded-hal определяет общие трейты для периферии вроде SPI, чтобы драйверы могли работать на разных микроконтроллерах. Чтобы понять, зачем нужен embedded-hal-bus, полезно посмотреть, как организованы эти трейты.
Embedded HAL разделяет SPI на два отдельных понятия: шину SPI (представляет общие линии тактирования и данных) и устройство SPI (представляет отдельную периферию со своей линией выбора микросхемы, chip select). HAL микроконтроллеров обычно предоставляют реализацию шины SPI, но драйверы устройств пишутся для работы с устройствами SPI.
Крейт embedded-hal-bus закрывает этот разрыв. Он предоставляет адаптеры, которые оборачивают шину SPI и управляют выводом chip select, создавая интерфейс устройства SPI, который ожидают драйверы. В этой главе мы используем ExclusiveDevice — самый простой адаптер. Он рассчитан на сценарии, когда шиной SPI пользуется только одно устройство: он автоматически активирует chip select в начале каждой транзакции и деактивирует его по завершении.
Дополнительные импорты
Теперь добавим импорты, необходимые для SPI, драйвера дисплея MAX7219 и рисования с помощью embedded-graphics.
#![allow(unused)]
fn main() {
// Для MAX7219
use embedded_hal_bus::spi::ExclusiveDevice;
use max7219_display::led_matrix::display::SingleMatrix;
// Для рисования фигур
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{PrimitiveStyleBuilder, Rectangle};
// Для SPI
use embassy_rp::spi::{Config as SpiConfig, Spi};
// Для вывода CS
use embassy_rp::gpio::{Level, Output};
}
Настройка SPI и chip select
Начнём с создания вывода chip select. MAX7219 использует активно-низкий сигнал chip select, поэтому инициализируем вывод в высоком состоянии. Так устройство остаётся неактивным, пока не начнётся передача по SPI.
#![allow(unused)]
fn main() {
let cs_pin = Output::new(p.PIN_13, Level::High);
}
Далее назначаем выводы тактирования SPI и MOSI. Поскольку MAX7219 работает только на запись, нужны только эти два сигнала. В этой схеме вывод MISO не используется.
#![allow(unused)]
fn main() {
let clk = p.PIN_14;
let mosi = p.PIN_15;
}
Выбрав выводы, создаём периферию SPI. Мы используем блокирующий экземпляр SPI только на передачу, так как вся связь идёт от Pico к дисплею.
#![allow(unused)]
fn main() {
let spi_bus = Spi::new_blocking_txonly(p.SPI1, clk, mosi, SpiConfig::default());
}
На этом этапе у нас есть шина SPI, но она ещё не управляет chip select. Чтобы это обеспечить, оборачиваем шину в ExclusiveDevice. Это превращает шину SPI в устройство SPI, автоматически управляя выводом chip select для каждой передачи.
#![allow(unused)]
fn main() {
let spi_dev =
ExclusiveDevice::new_no_delay(spi_bus, cs_pin).expect("Failed to get exclusive device");
}
После этого шага устройство SPI готово к использованию драйвером дисплея MAX7219.
Создание экземпляра дисплея
Когда устройство SPI готово, создаём экземпляр светодиодной матрицы. Здесь мы работаем с одной матрицей 8x8, а не с последовательным соединением. Драйвер дисплея берёт устройство SPI во владение и использует его для отправки команд и данных о пикселях в MAX7219.
#![allow(unused)]
fn main() {
// Создаём экземпляр дисплея для одной светодиодной матрицы 8x8 (без последовательного соединения)
let mut display = SingleMatrix::from_spi(spi_dev).expect("display count 1 should not panic");
}
Настройка яркости дисплея
После создания экземпляра дисплея задаём уровень яркости. MAX7219 поддерживает несколько уровней интенсивности, и здесь мы настраиваем единственное устройство с индексом 0. Низкого значения интенсивности обычно достаточно, и оно не даёт излишней яркости.
#![allow(unused)]
fn main() {
// Задаём яркость (уровень интенсивности) единственного устройства с индексом 0
display
.driver()
.set_intensity(0, 1)
.expect("failed to set intensity");
}
Рисуем прямоугольник
Теперь нарисуем простую фигуру с помощью embedded-graphics. Вместо заливки всей области мы рисуем полый прямоугольник, так что включаются только пиксели по границе. Сначала я попробовал залитый прямоугольник, но он выглядел плохо, поэтому эту версию я закомментировал.
Сначала определяем стиль рисования. Этот стиль включает пиксели и задаёт толщину линии в один пиксель, что хорошо подходит для дисплея 8x8.
#![allow(unused)]
fn main() {
// ---- Рисуем прямоугольник ----
// let rect = Rectangle::new(Point::new(1, 1), Size::new(6, 6)).into_styled(
// embedded_graphics::primitives::PrimitiveStyle::with_fill(BinaryColor::On),
// );
let hollow_rect_style = PrimitiveStyleBuilder::new()
.stroke_color(BinaryColor::On) // Рисуем только границу
.stroke_width(1) // Толщина границы — 1 пиксель
.build();
}
Далее определяем сам прямоугольник. Левый верхний угол находится в точке x = 1 и y = 1, а прямоугольник занимает 6 пикселей по ширине и высоте. Это оставляет отступ в один пиксель от краёв, поэтому граница не касается крайних светодиодов.
#![allow(unused)]
fn main() {
let rect = Rectangle::new(Point::new(1, 1), Size::new(6, 6)).into_styled(hollow_rect_style);
}
Когда мы рисуем прямоугольник, он записывается в буфер дисплея. На этом этапе на аппаратуру ещё ничего не отправлено.
#![allow(unused)]
fn main() {
rect.draw(&mut display).expect("failed to draw the shape");
}
Чтобы фактически обновить светодиодную матрицу, выгружаем кадровый буфер (framebuffer). Это отправляет данные пикселей по SPI в MAX7219.
#![allow(unused)]
fn main() {
display.flush().expect("failed to send to the device");
}
Полный код
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
// Для MAX7219
use embedded_hal_bus::spi::ExclusiveDevice;
use max7219_display::led_matrix::display::SingleMatrix;
// Для рисования фигур
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{PrimitiveStyleBuilder, Rectangle};
// Для SPI
use embassy_rp::spi::{Config as SpiConfig, Spi};
// Для вывода CS
use embassy_rp::gpio::{Level, Output};
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let cs_pin = Output::new(p.PIN_13, Level::High);
let clk = p.PIN_14;
let mosi = p.PIN_15;
let spi_bus = Spi::new_blocking_txonly(p.SPI1, clk, mosi, SpiConfig::default());
let spi_dev =
ExclusiveDevice::new_no_delay(spi_bus, cs_pin).expect("Failed to get exclusive device");
// Создаём экземпляр дисплея для одной светодиодной матрицы 8x8 (без последовательного соединения)
let mut display = SingleMatrix::from_spi(spi_dev).expect("display count 1 should not panic");
// Задаём яркость (уровень интенсивности) единственного устройства с индексом 0
display
.driver()
.set_intensity(0, 1)
.expect("failed to set intensity");
// ---- Рисуем прямоугольник ----
// let rect = Rectangle::new(Point::new(1, 1), Size::new(6, 6)).into_styled(
// embedded_graphics::primitives::PrimitiveStyle::with_fill(BinaryColor::On),
// );
let hollow_rect_style = PrimitiveStyleBuilder::new()
.stroke_color(BinaryColor::On) // Рисуем только границу
.stroke_width(1) // Толщина границы — 1 пиксель
.build();
let rect = Rectangle::new(Point::new(1, 1), Size::new(6, 6)).into_styled(hollow_rect_style);
rect.draw(&mut display).expect("failed to draw the shape");
display.flush().expect("failed to send to the device");
loop {
Timer::after_millis(100).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"hello-max7219"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку hello-max7219.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/max7219/hello-max7219
RFID
Мы будем использовать RFID-считыватель RC522, чтобы читать данные с RFID-меток и брелоков.
RFID широко используется в системах вроде ключей от подъезда, пропусков в офис, умных парковок, ключ-карт в отелях, проездных на платные дороги и бесконтактных банковских карт. Все эти системы работают похожим образом. Вы подносите карту или метку к считывателю, и тот беспроводным способом её распознаёт.
Мы сделаем нечто похожее с помощью Raspberry Pi Pico, но помимо чтения данных научимся также записывать данные на RFID-метки и разберёмся, как устроена система.
Что такое RFID?
RFID расшифровывается как Radio Frequency Identification (радиочастотная идентификация). Это беспроводная технология, которая используется для идентификации объектов с помощью радиоволн.
Система RFID состоит из двух основных частей. Одна — считыватель (reader), другая — метка (tag). Считыватель создаёт радиополе. Когда метка оказывается рядом с этим полем, она получает питание от него и отправляет данные обратно считывателю. У большинства простых RFID-меток нет батареи.
Данные на метке могут быть простым уникальным идентификатором или включать небольшие блоки памяти, из которых можно читать и в которые можно писать. Всю радиосвязь берёт на себя считыватель, а микроконтроллер общается со считывателем по стандартному интерфейсу, например SPI.
В этой книге мы сосредоточимся на RFID малого радиуса действия, когда метка должна находиться очень близко к считывателю. Именно такой тип обычно используется в пропусках, брелоках и подобных системах.
Категории по дальности
Системы RFID можно классифицировать по рабочей частоте, которая напрямую влияет на дальность связи, скорость передачи данных и типичные сценарии использования. Три основные категории — низкочастотная (LF, Low Frequency), высокочастотная (HF, High Frequency) и сверхвысокочастотная (UHF, Ultra High Frequency).
-
Низкая частота (LF) LF RFID работает на частоте около 125 кГц. Такие системы имеют очень малую дальность считывания — обычно до 10 см. Передача данных медленная, но LF RFID относительно устойчив к помехам от металла и жидкостей. Поэтому его часто применяют в простых системах контроля доступа и для учёта животных.
-
Высокая частота (HF) HF RFID работает на частоте 13,56 МГц и обычно обеспечивает дальность считывания от 10 см до 1 м в зависимости от размера антенны и мощности. Системы HF обеспечивают умеренную скорость передачи данных и поддерживают более сложные протоколы, позволяя выполнять как чтение, так и запись. Эта категория широко используется в системах контроля доступа в офисах, квартирах и отелях, а также в билетных системах, бесконтактных платежах и передаче данных на небольшие расстояния.
В этой книге мы будем использовать именно эту категорию, а конкретно модуль RC522, который работает на частоте 13,56 МГц.
-
Сверхвысокая частота (UHF) UHF RFID работает в диапазоне 860–960 МГц и поддерживает гораздо большие расстояния считывания — часто до 12 м и более. Такие системы обеспечивают более высокую скорость передачи данных и могут одновременно считывать несколько меток. UHF RFID широко применяется в управлении складскими запасами в розничной торговле, логистике, отслеживании цепочек поставок и борьбе с контрафактом.
Категории по источнику питания
RFID-метки можно классифицировать по способу питания. Два основных типа — активные и пассивные метки.
-
Активные RFID-метки Активные метки содержат встроенную батарею, которая позволяет им самостоятельно передавать сигналы. Это обеспечивает гораздо большую дальность связи по сравнению с пассивными метками. Активные RFID-метки обычно используются для отслеживания крупных активов, например железнодорожных вагонов, морских контейнеров и оборудования, которое нужно контролировать на больших расстояниях.
-
Пассивные RFID-метки У пассивных меток нет батареи. Вместо этого они получают питание от электромагнитного поля, которое создаёт RFID-считыватель. Получив питание, метка отправляет свои данные обратно считывателю с помощью радиоволн. Пассивные RFID-метки — самый распространённый тип, их можно найти в пропусках, брелоках и системах бесконтактной оплаты. Модуль RC522, который используется в этой книге, работает с пассивными RFID-метками.
Компоненты RFID-системы
RFID-считыватель технически называется PCD (Proximity Coupling Device, устройство близкого связывания). В пассивных RFID-системах считыватель создаёт электромагнитное поле, которое питает метку и обеспечивает связь.
Сама метка называется RFID-меткой, или, в технических терминах, PICC (Proximity Integrated Circuit Card, карта с интегральной схемой близкого действия). Полезно знать и эти технические термины: они пригодятся, если вы будете обращаться к даташитам и другим документам.
RFID-считыватели обычно включают внутренние компоненты, такие как буферы FIFO и энергонезависимую память, например EEPROM. Многие считыватели также содержат криптографические функции для защищённой связи с метками, позволяя взаимодействовать с защищёнными данными только аутентифицированным считывателям. Например, RFID-считыватели от NXP Semiconductors используют шифр Crypto-1 для аутентификации.
У каждой RFID-метки есть жёстко заданный UID (Unique Identifier, уникальный идентификатор). В зависимости от типа метки этот UID может иметь размер 4, 7 или 10 байтов и используется для однозначной идентификации метки.
Ссылки
Знакомство с модулем
Мы будем использовать RFID-модуль RC522, который построен на микросхеме MFRC522 (разработки NXP) и работает на частоте 13,56 МГц. Этот модуль широко доступен по невысокой цене и обычно поставляется с RFID-меткой (MIFARE Classic 1K) и брелоком, каждый из которых содержит 1 КБ памяти. Подробности см. в даташите MFRC522.
Микроконтроллер может обмениваться данными со считывателем по SPI, UART и I2C. Также у него есть вывод IRQ (Interrupt Request, запрос прерывания), который может генерировать прерывания, так что микроконтроллер (Pico) узнаёт о появлении метки рядом, вместо того чтобы постоянно спрашивать у считывателя (что-то вроде «Мы уже приехали?»).
К сожалению, библиотека, которую мы будем использовать, пока не поддерживает эту функцию, поэтому сейчас мы её не используем. Этот раздел мы обновим, когда поддержка появится. Так что — мы уже приехали?
Дополнительная информация о модуле:
- Поддерживаемые стандарты: ISO/IEC 14443 A / MIFARE
- Дальность считывания карт: 0–50 мм
- Ток в режиме ожидания: 10–13 мА
- Рабочий ток: 13–26 мА
- Рабочее напряжение: 3,3 В постоянного тока (⚠️ не используйте 5 В и выше, это приведёт к поломке).
MIFARE
MIFARE — семейство интегральных микросхем (IC), которые используются в бесконтактных смарт-картах и карточках близкого действия. Их разработала компания NXP Semiconductors. Карты MIFARE соответствуют стандарту ISO/IEC 14443A и используют методы шифрования, например алгоритм Crypto-1. Самое распространённое семейство — MIFARE Classic, у которого есть подтип MIFARE Classic EV1.
Структура памяти
Карта MIFARE Classic 1K разделена на 16 секторов, и каждый сектор содержит 4 блока. Каждый блок может хранить до 16 байтов, что даёт общий объём памяти 1 КБ.
16 секторов × 4 блока/сектор × 16 байтов/блок = 1024 байта = 1 КБ
Эта схема основана на структуре памяти из даташита. Я добавил цветовое кодирование и разделительные линии, чтобы структуру было легче читать. Каждый сектор отделён жёлтой горизонтальной линией. Служебный блок сектора (trailer) выделен тёмно-красным. Все обычные блоки данных показаны зелёным. Блок производителя выделен отдельно, чтобы отличать его от данных, доступных пользователю для записи.
Служебный блок сектора (Sector Trailer)
Последний блок каждого сектора, известный как «trailer», содержит два секретных ключа и программируемые условия доступа для блоков этого сектора. У каждого сектора есть собственная пара ключей (KeyA и KeyB), что позволяет поддерживать несколько приложений с иерархией ключей.
Note
Ключи по умолчанию: карта MIFARE Classic 1K предварительно настроена с ключом по умолчанию FF FF FF FF FF FF для обоих ключей, KeyA и KeyB. При чтении служебного блока значения KeyA возвращаются как нули (00 00 00 00 00 00), а KeyB возвращается как есть.
По умолчанию байты управления доступом (6, 7 и 8 служебного блока) установлены в FF 07 80h. Подробнее см. на 10-й странице даташита. Девятый байт можно использовать для хранения данных.
| Номер байта | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | |
| Описание | KEY A | Биты доступа | Данные пользователя | KEY B | ||||||||||||
| Данные по умолчанию | FF | FF | FF | FF | FF | FF | FF | 07 | 80 | 69 | FF | FF | FF | FF | FF | FF |
Блок производителя
Первый блок (блок 0) первого сектора (сектора 0) содержит данные производителя микросхемы, включая UID. Этот блок защищён от записи.
Блок данных
Поскольку у каждого сектора есть служебный блок, для хранения данных в каждом секторе можно использовать только 3 блока. Однако в первом секторе доступно только 2 блока, потому что первый блок хранит данные производителя.
Чтобы читать или писать данные, сначала нужно пройти аутентификацию с помощью KeyA или KeyB этого сектора.
Блоки данных можно дополнительно разделить на две категории в зависимости от битов доступа (мы объясним это позже).
- Блок чтения/записи: обычные блоки данных, которые позволяют выполнять базовые операции — чтение и запись данных.
- Блок значения (value block): такие блоки идеально подходят для приложений вроде электронных кошельков, где их обычно используют для хранения числовых значений, например остатка на счёте. Поэтому с ними можно выполнять инкремент (например, прибавить 10 к остатку) или декремент (например, списать 5 за покупку).
Ссылки
Процесс работы
Когда вы подносите метку к считывателю, метка получает питание от радиополя считывателя и готова отвечать на команды опроса (polling).
Чтобы проверить, есть ли поблизости метка, мы отправляем команду опроса. Обычно это REQA. Если метка ранее была переведена в состояние HALT, используем WUPA. Если метка присутствует и может отвечать, она отправляет ATQA (Answer To Request, ответ на запрос).
Important
Обратите внимание: как только карта переходит в состояние HALT, разбудить её может только команда WUPA (Wake Up), после чего можно выполнять дальнейшие операции. Мне всегда вспоминается Чендлер из «Друзей», который кричит «WOOPAAH». Команда REQA работает только с картами в состоянии покоя (idle).
После получения ATQA выполняем процедуру антиколлизии и выбора (anticollision and select). На этом шаге обрабатываются коллизии, если присутствует несколько меток, и получается UID одной из них. Когда процедура завершена, карта выбрана и активна.
После выбора карты мы проходим аутентификацию для конкретного сектора, из которого хотим читать или в который хотим писать. Аутентификацию нужно повторять при обращении к другому сектору.
Когда аутентификация прошла успешно, можно выполнять операции: чтение, запись, инкремент, декремент или восстановление (restore).
Когда все операции выполнены, отправляем команду HLTA, чтобы перевести карту в состояние HALT. Пока радиополе включено, остановленная карта не отвечает на REQA и может быть активирована только командой WUPA. Если карта покидает радиополе и возвращается, она начинает работу заново с самого начала.
Подключение RC522 к Raspberry Pi Pico
Сейчас мы посмотрим, как подключить RFID-считыватель к Pico 2. Но сначала быстро взглянем на распиновку модуля RC522.
Схема выводов RC522
RFID-модуль RC522 имеет 8 выводов. Некоторые выводы выполняют разные функции в зависимости от того, используется ли модуль с SPI, I2C или UART. На схеме ниже показаны все доступные функции каждого вывода. В нашей схеме мы будем использовать SPI, но сначала полезно понять полную распиновку.
| Вывод | Функция SPI | Функция I²C | Функция UART | Описание |
|---|---|---|---|---|
| 3.3 V | Питание | Питание | Питание | Питание (3,3 В). |
| GND | Земля | Земля | Земля | Подключение к земле. |
| RST | Сброс | Сброс | Сброс | Сброс модуля. |
| IRQ | Прерывание (необязательно) | Прерывание (необязательно) | Прерывание (необязательно) | Запрос прерывания (IRQ) сообщает микроконтроллеру, когда обнаружена RFID-метка. Без использования IRQ микроконтроллеру пришлось бы постоянно опрашивать модуль. |
| MISO | Master-In-Slave-Out | SCL | TX | В режиме SPI работает как Master-In-Slave-Out (MISO). В режиме I²C выполняет роль линии тактирования (SCL). В режиме UART — вывод передачи (TX). |
| MOSI | Master-Out-Slave-In | - | - | В режиме SPI работает как Master-Out-Slave-In (MOSI). |
| SCK | Последовательное тактирование | - | - | В режиме SPI — линия тактирования, которая синхронизирует передачу данных. |
| SDA | Выбор микросхемы (CS) | SDA | RX | В режиме SPI работает как выбор микросхемы (CS/SS, также называется Slave Select). В режиме I²C — линия данных (SDA). В режиме UART — вывод приёма (RX). |
Подключение RFID-считывателя к Raspberry Pi Pico
Чтобы установить связь между Raspberry Pi Pico и RFID-считывателем, мы будем использовать протокол SPI. В этой схеме на Pico используется SPI0. Пока выводы RST и IRQ мы не используем.
В таблице ниже показано, как подключить модуль RC522 к Pico по SPI.
| Вывод Pico | Провод | Вывод RFID-считывателя |
|---|---|---|
| 3.3 V |
|
3.3 V |
| GND |
|
GND |
| GPIO 0 |
|
MISO |
| GPIO 1 |
|
SDA (CS) |
| GPIO 2 |
|
SCK |
| GPIO 3 |
|
MOSI |
Как прочитать UID RFID-карты с Raspberry Pi Pico во встраиваемом Rust
Теперь, когда проводка готова, перейдём к части с кодом. Будем делать просто: сначала обнаружим RFID-карту и прочитаем её UID. Аутентификацию и доступ к данным пока не рассматриваем.
В этом разделе мы выведем UID с помощью отладочного зонда. Логирование выполняется через defmt, а вывод появляется в консоли компьютера через RTT. Если у вас есть отладочный зонд, это самый простой способ вывести и изучить результат.
Important
В следующем разделе мы прочитаем тот же UID, но выведем его через USB Serial. Это полезно, если у вас нет отладочного зонда и вы хотите выводить данные в системную консоль. Вы не ограничены этими двумя способами. Можно также вывести UID на OLED-экран.
Драйвер mfrc522
Для связи с RFID-считывателем RC522 мы будем использовать крейт mfrc522. Он предоставляет основные функции, необходимые для обнаружения карт и чтения их UID. Крейт ещё находится в разработке, но для возможностей, которые нам нужны в этой главе, его достаточно.
Проект из шаблона
Начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, введите имя проекта, например «print-uid», и выберите «Embassy» в качестве HAL. Обязательно включите «defmt» для логирования.
Требуемые дополнительные крейты
Добавьте в Cargo.toml необходимые крейты вместе с существующими зависимостями. Набор крейтов зависит от того, как выводятся данные.
mfrc522 = "0.8.0"
embedded-hal-bus = "0.3.0"
Крейт mfrc522 предоставляет драйвер для связи с RFID-считывателем RC522 и отвечает за обнаружение карт и чтение UID. Крейт embedded-hal-bus позволяет совместно использовать шину SPI через обёртку ExclusiveDevice, которая нужна драйверу mfrc522.
Дополнительные импорты
Добавьте эти импорты в файл main.rs:
#![allow(unused)]
fn main() {
// Для SPI
use embassy_rp::spi::Spi;
use embassy_rp::spi;
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
// Для вывода CS
use embassy_rp::gpio::{Level, Output};
// Драйвер для MFRC522
use mfrc522::{Mfrc522, comm::blocking::spi::SpiInterface};
}
Настройка SPI для RFID-считывателя
Настроим шину SPI и выводы, подключённые к считывателю. В этом примере используется SPI0:
#![allow(unused)]
fn main() {
let miso = p.PIN_0;
let cs_pin = Output::new(p.PIN_1, Level::High);
let clk = p.PIN_2;
let mosi = p.PIN_3;
let mut config = spi::Config::default();
config.frequency = 1000_000;
let spi_bus = Spi::new_blocking(p.SPI0, clk, mosi, miso, config);
}
Мы настраиваем шину SPI на работу с частотой 1 МГц, что обеспечивает надёжную связь с RFID-считывателем. Вывод выбора микросхемы (chip select) стартует в высоком уровне — это состояние покоя для SPI-устройств.
Получение SpiDevice из шины SPI
Драйвер mfrc522 ожидает SpiDevice, а не «голую» шину SPI. Чтобы создать такое устройство, используем крейт embedded-hal-bus:
#![allow(unused)]
fn main() {
let spi = ExclusiveDevice::new(spi_bus, cs_pin, Delay).expect("Failed to get exclusive device");
}
Мы используем ExclusiveDevice, поскольку в нашей схеме на шине SPI только одно устройство. Эта обёртка объединяет шину SPI и вывод CS, автоматически управляя линией chip select при каждой операции чтения или записи. Параметр задержки обеспечивает контроль времени.
Инициализация mfrc522
Когда устройство SPI готово, можно инициализировать RFID-считыватель.
#![allow(unused)]
fn main() {
let itf = SpiInterface::new(spi);
let mut rfid = Mfrc522::new(itf)
.init()
.expect("failed to initialize the RFID reader");
}
Чтение UID и вывод
Главный цикл постоянно проверяет наличие RFID-карт поблизости. Когда карта обнаружена, мы читаем её UID и выводим его:
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
defmt::info!("UID: {:02x}", uid.as_bytes());
Timer::after_millis(500).await;
}
}
}
}
Метод reqa отправляет команду Request, чтобы обнаружить карты поблизости. Когда карта отвечает своим ATQA (Answer To Request), мы вызываем select, чтобы выполнить протокол антиколлизии и получить UID. Байты UID форматируются в шестнадцатеричном виде и отправляются через defmt в консоль RTT.
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку print-uid.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/print-uid/
Как запустить?
Не забудьте использовать команду cargo embed --release для прошивки вместо cargo flash --release, если вы используете отладочный зонд. В шаблоне уже настроено открытие RTT при прошивке, поэтому вы должны увидеть вывод defmt.
Теперь поднесите RFID-метку к считывателю. В консоли RTT вы должны увидеть байты UID в шестнадцатеричном формате.
Чтение UID RFID-карты через USB Serial
Прежде чем начать, обязательно посмотрите руководство по USB Serial для настройки USB Serial на Pico. Здесь мы не будем заново рассказывать о настройке USB, чтобы не усложнять.
В этом разделе мы прочитаем UID RFID-карты и выведем его через USB Serial. Такой подход полезен, когда у вас нет отладочного зонда и вы хотите, чтобы Pico отображался на компьютере как последовательное устройство.
Логика работы с RFID остаётся такой же, как в предыдущем разделе. Единственное отличие — то, как мы выводим UID.
Драйвер mfrc522
Для связи с RFID-считывателем RC522 мы продолжаем использовать крейт mfrc522. Драйвер отвечает за обнаружение карт и чтение UID. Со стороны RFID никаких изменений не требуется.
Проект из шаблона
Начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, введите имя проекта, например «uid-over-usb», и выберите «Embassy» в качестве HAL. Включать defmt для этой схемы не нужно.
Требуемые дополнительные крейты
Обновите Cargo.toml и добавьте следующие крейты вместе с существующими зависимостями:
mfrc522 = "0.8.0"
embedded-hal-bus = "0.3.0"
embassy-usb-logger = "0.5.1"
log = "0.4"
heapless = "0.9.2"
Крейт embassy-usb-logger обеспечивает поддержку логирования через USB. Крейт log используется для макросов логирования, например log::info!. Крейт heapless нужен, чтобы форматировать UID без выделения памяти в куче (heap).
Дополнительные импорты
#![allow(unused)]
fn main() {
// Для USB
use embassy_rp::{peripherals::USB, usb};
// Для SPI
use embassy_rp::spi;
use embassy_rp::spi::Spi;
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
// Для вывода CS
use embassy_rp::gpio::{Level, Output};
// Драйвер для MFRC522
use mfrc522::{Mfrc522, comm::blocking::spi::SpiInterface};
// Для подготовки буфера с данными перед записью в USB Serial
use core::fmt::Write;
use heapless::String;
}
Настройка USB-логгера
Сначала нужно привязать обработчик прерывания USB. Это говорит Embassy, какой обработчик прерываний использовать для связи по USB:
#![allow(unused)]
fn main() {
embassy_rp::bind_interrupts!(struct Irqs {
USBCTRL_IRQ => usb::InterruptHandler<USB>;
});
}
Далее создаём асинхронную задачу, которая будет обрабатывать логирование через USB. Эта задача работает в фоне и управляет всей связью по USB Serial:
#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn logger_task(usb: embassy_rp::Peri<'static, embassy_rp::peripherals::USB>) {
let driver = embassy_rp::usb::Driver::new(usb, Irqs);
embassy_usb_logger::run!(1024, log::LevelFilter::Info, driver);
}
}
Атрибут #[embassy_executor::task] помечает эту функцию как асинхронную задачу, которую может запустить исполнитель Embassy. Задача принимает периферию USB как параметр и создаёт драйвер USB с нашей конфигурацией прерываний. Макрос embassy_usb_logger::run! настраивает USB-логгер с буфером 1024 байта и фильтрацией по уровню Info.
Вспомогательная функция для вывода UID в hex
Эта вспомогательная функция преобразует массив байтов u8 (в нашем случае UID) в печатаемую шестнадцатеричную строку. Можно было бы передавать сырые байты и включить режим hex в tio (нужна последняя версия) или minicom, но мне этот способ кажется проще. В режиме hex всё выводится в шестнадцатеричном виде, включая обычный текст.
#![allow(unused)]
fn main() {
fn print_hex_to_serial(data: &[u8]) {
let mut buff: String<64> = String::new();
for &d in data.iter() {
write!(buff, "{:02x} ", d).expect("failed to write byte into buffer");
}
log::info!("UID: {}", buff);
}
}
Эта функция создаёт буфер строки фиксированного размера на стеке (без выделения памяти в куче) и форматирует каждый байт как двузначное шестнадцатеричное число. Отформатированная строка затем отправляется через систему логирования.
Запуск задачи логгера
В функции main нужно запустить задачу логгера и дать ей время на инициализацию:
#![allow(unused)]
fn main() {
spawner.must_spawn(logger_task(p.USB));
Timer::after_secs(3).await;
}
Метод spawner.must_spawn() запускает задачу логгера внутри исполнителя Embassy. Эта задача работает параллельно с нашим основным кодом и обеспечивает логирование через USB Serial. Затем ждём 3 секунды, чтобы компьютер успел распознать Pico как USB-устройство Serial, а логгер был готов. Эта задержка важна, потому что USB-соединение устанавливается постепенно, и сообщения, отправленные слишком рано, могут не появиться на компьютере.
Настройка SPI для RFID-считывателя
Настройка SPI такая же, как раньше.
#![allow(unused)]
fn main() {
let miso = p.PIN_0;
let cs_pin = Output::new(p.PIN_1, Level::High);
let clk = p.PIN_2;
let mosi = p.PIN_3;
let mut config = spi::Config::default();
config.frequency = 1000_000;
let spi_bus = Spi::new_blocking(p.SPI0, clk, mosi, miso, config);
}
Это настраивает шину SPI на частоту 1 МГц, которая подходит для модуля RC522. Вывод выбора микросхемы (CS) инициализируется в высоком уровне (неактивное состояние), так как SPI chip select активен по низкому уровню.
Получение SpiDevice из шины SPI
Драйвер mfrc522 ожидает SpiDevice, а не «голую» шину SPI. Чтобы создать такое устройство, используем крейт embedded-hal-bus:
#![allow(unused)]
fn main() {
let spi = ExclusiveDevice::new(spi_bus, cs_pin, Delay).expect("Failed to get exclusive device");
}
Мы используем ExclusiveDevice, поскольку в нашей схеме на шине SPI только одно устройство. Эта обёртка объединяет шину SPI и вывод CS, автоматически управляя линией chip select при каждой операции чтения или записи. Параметр задержки обеспечивает контроль времени между операциями.
Инициализация mfrc522
Когда устройство SPI готово, можно инициализировать RFID-считыватель.
#![allow(unused)]
fn main() {
let itf = SpiInterface::new(spi);
log::info!("Initializing MFRC522...");
let mut rfid = match Mfrc522::new(itf).init() {
Ok(rfid) => {
log::info!("MFRC522 initialized successfully");
rfid
}
Err(e) => {
log::error!("Failed to initialize MFRC522: {:?}", e);
loop {
Timer::after_secs(1).await;
}
}
};
}
SpiInterface оборачивает наше устройство SPI для использования с драйвером mfrc522. Затем мы пытаемся инициализировать RFID-считыватель. Если инициализация прошла успешно, выводим сообщение об успехе и продолжаем работу. Если нет — выводим ошибку и входим в бесконечный цикл, так как без работающего RFID-считывателя продолжать нельзя.
Чтение UID и вывод
Главный цикл постоянно проверяет наличие RFID-карт поблизости. Когда карта обнаружена, мы читаем её UID и выводим его:
#![allow(unused)]
fn main() {
log::info!("Waiting for RFID");
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
print_hex_to_serial(uid.as_bytes());
Timer::after_millis(500).await;
}
}
Timer::after_millis(100).await;
}
}
Метод reqa отправляет команду Request, чтобы обнаружить карты поблизости. Когда карта отвечает своим ATQA (Answer To Request), мы вызываем select, чтобы выполнить протокол антиколлизии и получить UID. Байты UID форматируются в шестнадцатеричном виде и через систему логирования отправляются в консоль USB Serial.
Задержка 500 мс после успешного чтения не даёт заваливать вывод повторными чтениями одной и той же карты. Задержка 100 мс в главном цикле обеспечивает разумный интервал опроса без излишней нагрузки на процессор.
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку uid-over-usb.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/uid-over-usb/
Как запустить?
Прошивка (запуск кода) на Pico выполняется как обычно. Однако нужно настроить tio для взаимодействия с Pico через последовательный порт (/dev/ttyACM0). Это позволит читать данные с Pico или отправлять их на него.
tio
Убедитесь, что в вашей системе установлен tio. Если нет, его можно установить командой:
apt install tio
Подключение к последовательному порту
Выполните следующую команду, чтобы подключиться к последовательному порту Pico:
tio /dev/ttyACM0
Откроется сеанс терминала для связи с Pico. Устройство /dev/ttyACM0 появляется, когда компьютер распознаёт Pico как USB-устройство CDC ACM.
Прошивка и запуск кода
Откройте другой терминал, перейдите в папку проекта и прошейте код на Pico как обычно:
cargo run --release
Если всё настроено правильно, в терминале tio вы увидите сообщение «Connected».
Чтение UID
Теперь поднесите RFID-метку к считывателю. В терминале USB Serial вы должны увидеть байты UID в шестнадцатеричном формате. Каждый байт выводится как двузначное шестнадцатеричное число.
Версия на rp-hal
Если вас интересует версия этого примера на rp-hal, её можно найти в репозитории ниже. Клонируйте репозиторий и перейдите в проект print-uid:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/rfid/print-uid/
Включаем светодиод при совпадении UID RFID-метки
В этом разделе мы возьмём UID, который получили в предыдущей главе, и вобьём его прямо в программу. Светодиод будет включаться только тогда, когда рядом находится подходящая RFID-метка; в остальных случаях он остаётся выключенным. Если поднести RFID-метку, светодиод загорится. Если поднести другую метку, например брелок или любую другую RFID-карту, светодиод погаснет.
Так вы получите базовое ощущение «авторизован — не авторизован». Простой проверки UID недостаточно для настоящей безопасности, и в следующей главе мы подробнее разберём чтение и аутентификацию. А пока это простой и весёлый способ начать. Этот пример можно также расширить, добавив OLED-дисплей и выводя на нём сообщения «Authorized» или «Unauthorized» вместо светодиода.
Получаем UID вашей карты
Прежде чем писать логику управления светодиодом, нужно узнать UID вашей RFID-карты.
Запустите код из предыдущей главы, который выводит UID. Когда вы поднесёте карту к считывателю, вы увидите примерно такой вывод:
UID: 13 37 73 31
Это четыре байта, которые вы вобьёте в программу как [0x13, 0x37, 0x73, 0x31].
Логика
Логика здесь простая. По умолчанию светодиод выключен. Программа постоянно проверяет наличие RFID-метки. Когда метка обнаружена, её UID считывается и сравнивается с заданным в коде UID. Если они совпадают, светодиод включается. Если не совпадают или метки нет, светодиод остаётся выключенным.
#![allow(unused)]
fn main() {
// Замените байты UID на UID вашей метки
const TAG_UID: [u8; 4] = [0x13, 0x37, 0x73, 0x31];
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
led.set_low();
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if *uid.as_bytes() == TAG_UID {
led.set_high();
Timer::after_millis(500).await;
}
}
}
Timer::after_millis(100).await;
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку rfid-led.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/rfid-led/
Зажигаем светодиод
Прошейте программу на Pico, как обычно:
# если вы используете отладочный зонд
# cargo flash --release
cargo embed --release
# если вы используете подход с BOOTSEL
cargo run --release
Когда программа запущена, поднесите подходящую RFID-метку к считывателю и посмотрите, как включается встроенный светодиод. Уберите метку — светодиод погаснет. Попробуйте другую карту или брелок, и вы увидите, что светодиод не загорается для UID, которые не совпадают.
Версия на rp-hal
Если вас интересует версия этого примера на rp-hal, её можно найти в репозитории ниже. Клонируйте репозиторий и перейдите в проект rfid-led:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/rfid/rfid-led/
Чтение данных RFID-метки на Raspberry Pi Pico с embedded Rust
В этой главе мы выходим за рамки чтения UID и начинаем читать данные, которые хранятся на RFID-метке. Мы прочитаем все блоки первого сектора, то есть сектора 0.
Как уже говорилось, читать или записывать данные на RFID-метку напрямую нельзя. Прежде чем обращаться к блоку, нужно пройти аутентификацию с сектором, в котором находится этот блок. Когда аутентификация прошла успешно, считывателю разрешается читать или записывать блоки этого сектора.
Выберите способ вывода
В этой главе инструкции и код немного отличаются в зависимости от того, как вы выводите результаты. Выберите подходящую вкладку в зависимости от того, используете ли вы отладочный зонд или USB Serial.
В этой схеме для вывода данных блоков мы используем defmt через RTT.
Проект из шаблона
Начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, введите имя проекта, например «read-blocks», и выберите «Embassy» в качестве HAL. Обязательно включите «defmt» для логирования.
Требуемые дополнительные крейты
Обновите Cargo.toml и добавьте необходимые крейты вместе с существующими зависимостями.
mfrc522 = "0.8.0"
embedded-hal-bus = "0.3.0"
heapless = { version = "0.9.2", features = ["defmt"] }
Дополнительные импорты
#![allow(unused)]
fn main() {
// Для SPI
use embassy_rp::spi::Spi;
use embassy_rp::{self as hal, spi};
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
// Для вывода CS
use embassy_rp::gpio::{Level, Output};
// Драйвер для MFRC522
use mfrc522::{Mfrc522, comm::blocking::spi::SpiInterface};
// Для подготовки буфера с данными перед логированием
use core::fmt::Write;
use heapless::String;
}
Повторное использование существующей настройки
Оставшаяся инициализация SPI и RFID-считывателя такая же, как в предыдущей главе. Её можно повторно использовать здесь без изменений. В этой главе мы только добавляем несколько вспомогательных функций и изменяем главный цикл, чтобы читать блоки и выводить их содержимое.
Аутентификация
Большинство меток MIFARE Classic поставляются с ключом сектора по умолчанию. Самый распространённый ключ по умолчанию — шесть байтов 0xFF. Если аутентификация не проходит, возможно, стоит посмотреть документацию метки или попробовать другой ключ.
Для аутентификации нужны три вещи:
- UID метки, который мы уже получаем с помощью шагов REQA и select
- Абсолютный номер блока, для которого проводится аутентификация
- Ключ, которым выполняется аутентификация; в этом примере он задан прямо в коде
Вспомогательная функция для вывода байтов в виде hex-дампа
Эта функция преобразует сырые байты в шестнадцатеричный формат и выводит их.
#![allow(unused)]
fn main() {
fn print_hex(data: &[u8]) {
let mut buff: String<64> = String::new();
for &d in data.iter() {
write!(buff, "{:02x} ", d).expect("failed to write byte into buffer");
}
defmt::println!("{}", buff);
}
}
Чтение блока
Когда аутентификация с сектором прошла успешно, считывателю разрешается обращаться к блокам, принадлежащим этому сектору. С этого момента считыватель может отправлять команды чтения для этих блоков.
Каждый блок метки MIFARE Classic хранит ровно 16 байтов данных. Функция mf_read из крейта mfrc522 читает один блок за раз и при успехе возвращает эти 16 байтов. Если чтение не удалось, возвращается ошибка.
В этой главе мы сосредоточимся на секторе 0. В секторе 0 четыре блока. Аутентификация выполняется один раз для сектора, а затем все четыре блока читаются по порядку. Пока мы остаёмся в одном секторе, аутентификацию повторять не нужно.
#![allow(unused)]
fn main() {
fn read_sector<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
const AUTH_KEY: [u8; 6] = [0xFF; 6];
let block_offset = sector * 4;
rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
.map_err(|_| "Auth failed")?;
for abs_block in block_offset..block_offset + 4 {
let data = rfid.mf_read(abs_block).map_err(|_| "Read failed")?;
print_hex(&data);
}
Ok(())
}
}
При обращении к памяти метки нужно указывать абсолютные номера блоков. Метка рассматривает все блоки как часть одной непрерывной последовательности, а не использует нумерацию внутри сектора. Например, сектор 0 использует блоки 0–3, сектор 1 — блоки 4–7, а сектор 2 — блоки 8–11.
Чтобы превратить номер сектора в правильный номер блока, вычисляем смещение. Поскольку в каждом секторе четыре блока, умножение номера сектора на 4 даёт первый блок этого сектора. Это значение сохраняется в block_offset и затем используется как начальная точка для чтения всех блоков выбранного сектора.
Для первого сектора, то есть сектора 0, вычисление простое: смещение равно 0 × 4, что даёт блок 0. Это значит, что сектор 0 начинается с блока 0 и включает блоки 0, 1, 2 и 3. Именно эти блоки мы читаем, когда проходим аутентификацию и обращаемся к первому сектору.
Главный цикл
Главный цикл работает похоже на то, что мы рассматривали в предыдущей главе. После выбора метки переходим к чтению её блоков. Когда данные блоков прочитаны, цикл отправляет команды HLTA и stop_crypto1, чтобы перевести карту в состояние HALT.
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if let Err(e) = read_sector(&uid, 0, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
let _ = rfid.hlta();
let _ = rfid.stop_crypto1();
Timer::after_millis(100).await;
}
}
Timer::after_millis(100).await;
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку read-blocks.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/read-blocks/
Как запустить?
Используйте cargo embed, чтобы прошить и запустить программу: шаблон уже настроен на RTT и defmt, и вывод появляется в терминале после завершения прошивки.
cargo embed --release
Чтение данных блока
Поднесите RFID-метку к считывателю, и системная консоль покажет байты данных, прочитанные со всех блоков первого сектора (сектора 0).
Версия на rp-hal
Если вас интересует версия этого примера на rp-hal, её можно найти в репозитории ниже. Клонируйте репозиторий и перейдите в проект read-blocks:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/rfid/read-blocks/
Вывод всей памяти
До сих пор вы научились проходить аутентификацию с сектором и читать блоки внутри него. В предыдущей главе мы ограничивались сектором 0. В этой главе мы расширим этот подход и выведем всю память метки.
Мы пройдём по всем секторам метки, аутентифицируем каждый сектор и прочитаем каждый блок внутри него. Поскольку аутентификация действительна только для одного сектора, при переходе к следующему сектору её нужно будет пройти заново.
Для каждого сектора мы прочитаем все четыре блока и выведем их содержимое в виде 16 байтов. Чтобы вывод было проще понять, добавим подписи, которые показывают:
- Номер сектора
- Абсолютный номер блока
- Номер блока относительно сектора
- Является ли блок данными производителя, служебным блоком сектора (trailer) или обычными данными
Чтобы сохранить фокус этой главы, мы не будем повторять шаги создания проекта и настройки зависимостей. К этому моменту вы уже должны уверенно работать с нужными крейтами и структурой проекта.
Выберите способ вывода
Как и раньше, основная логика одинакова для обеих схем. Отличается только вид логирования.
Выберите вкладку, которая соответствует тому, как вы выводите результаты.
В этой схеме для вывода данных блоков мы используем defmt через RTT.
Перебор секторов
Начнём с создания вспомогательной функции, которая перебирает все секторы метки. Для метки MIFARE Classic 1K это секторы с 0 по 15.
Для каждого сектора мы выводим заголовок, а затем вызываем read_sector, чтобы пройти аутентификацию и прочитать его блоки.
#![allow(unused)]
fn main() {
fn dump_memory<E, COMM>(
uid: &mfrc522::Uid,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
let mut buff: String<64> = String::new();
for sector in 0..16 {
// Выводим номер сектора
write!(buff, "-----------SECTOR {}-----------", sector)
.expect("failed to write into heapless buff");
defmt::println!("{}", buff);
buff.clear();
read_sector(uid, sector, rfid)?;
}
Ok(())
}
}
Увеличиваем буфер логов
Вывод всей памяти даёт много данных. Увеличьте размер буфера логов, чтобы вывод не обрезался.
#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn logger_task(usb: embassy_rp::Peri<'static, embassy_rp::peripherals::USB>) {
let driver = embassy_rp::usb::Driver::new(usb, Irqs);
embassy_usb_logger::run!(8192, log::LevelFilter::Info, driver);
}
}
Определяем тип блока
Далее добавим небольшую вспомогательную функцию, которая определяет тип блока по его положению внутри сектора.
#![allow(unused)]
fn main() {
fn get_block_type(sector: u8, rel_block: u8) -> &'static str {
match rel_block {
0 if sector == 0 => "MFD",
3 => "TRAILER",
_ => "DATA",
}
}
}
Чтение сектора с подписями
Функция read_sector похожа на ту, что вы использовали раньше, но теперь она добавляет к выводу больше контекста.
#![allow(unused)]
fn main() {
fn read_sector<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
const AUTH_KEY: [u8; 6] = [0xFF; 6];
let mut buff: String<64> = String::new();
let block_offset = sector * 4;
rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
.map_err(|_| "Auth failed")?;
for abs_block in block_offset..block_offset + 4 {
let rel_block = abs_block - block_offset;
let data = rfid.mf_read(abs_block).map_err(|_| "Read failed")?;
// Выводим данные блока
for &d in data.iter() {
write!(buff, "{:02x} ", d).expect("failed to write byte into buffer");
}
// Выводим тип блока
let block_type = get_block_type(sector, rel_block);
defmt::println!(
"BLOCK {} (REL: {}) | {} | {}",
abs_block,
rel_block,
buff,
block_type
);
buff.clear();
}
defmt::println!("");
Ok(())
}
}
Главный цикл
Главный цикл почти не меняется. Единственное отличие в том, что теперь мы вызываем dump_memory вместо чтения одного сектора.
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if let Err(e) = dump_memory(&uid, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
let _ = rfid.hlta();
let _ = rfid.stop_crypto1();
Timer::after_millis(500).await;
}
}
Timer::after_millis(200).await;
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку memory-dump.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/memory-dump/
Дамп
Когда вы запустите программу и поднесёте метку или брелок, вы увидите примерно такой вывод. Если вы заметили байты 0x40..0x43 в блоке 18 (это блок 2 сектора 4) и задались вопросом, откуда они там, — отличное наблюдение! Это пользовательские данные, которые я записал на метку.
Запись данных
В предыдущих главах мы сосредоточились на чтении данных с метки. В этой главе мы делаем шаг дальше и записываем данные в память метки.
Мы запишем данные в блок 2 сектора 4. Чтобы хорошо видеть результат операции записи, сначала считаем и выведем содержимое блока до записи, а после завершения записи снова прочитаем тот же сектор.
Запись выполняется функцией mf_write из крейта mfrc522. Эта функция записывает ровно 16 байтов, что соответствует размеру одного блока метки MIFARE Classic.
Caution
Запись в неправильный блок может необратимо изменить ключи аутентификации или биты доступа. Не записывайте в служебные блоки секторов, если не понимаете их структуру полностью.
Запись блока
Начнём с определения вспомогательной функции, которая выполняет операцию записи. Функция получает UID метки, номер сектора, номер блока относительно сектора, буфер данных на 16 байтов и изменяемую ссылку на RFID-считыватель. Внутри функции вычисляется абсолютный номер блока, и перед записью сектор проходит аутентификацию.
#![allow(unused)]
fn main() {
fn write_block<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
rel_block: u8,
data: [u8; 16],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
const AUTH_KEY: [u8; 6] = [0xFF; 6];
let block_offset = sector * 4;
let abs_block = block_offset + rel_block;
rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
.map_err(|_| "Auth failed")?;
rfid.mf_write(abs_block, data).map_err(|_| "Write failed")?;
Ok(())
}
}
Подготовка данных
Каждый блок хранит ровно 16 байтов. Когда мы пишем текст или меньшие значения, оставшиеся байты всё равно нужно указать. В этом примере мы записываем строку implRust и дополняем оставшиеся байты значением 0x00.
#![allow(unused)]
fn main() {
let target_sector = 4;
let rel_block = 2;
const DATA: [u8; 16] = [
b'i', b'm', b'p', b'l', b'R', b'u', b's', b't', // "implRust"
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Оставшиеся байты — 0x00
];
}
Главный цикл
Главный цикл следует простой последовательности. После обнаружения и выбора метки считываем и выводим текущее содержимое целевого сектора. Затем записываем новые данные в выбранный блок и снова читаем сектор, чтобы подтвердить изменение. Наконец, переводим метку в состояние HALT и останавливаем шифрование.
Если вы используете USB Serial вместо отладочного зонда, замените defmt::info! и defmt::error! на log::info! и log::error!.
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
defmt::info!("\r\n----Before Write----\r\n");
if let Err(e) = read_sector(&uid, target_sector, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
if let Err(e) = write_block(&uid, target_sector, rel_block, DATA, &mut rfid) {
defmt::error!("Error writing data: {:?}", e);
}
defmt::info!("\r\n----After Write----\r\n");
if let Err(e) = read_sector(&uid, target_sector, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
let _ = rfid.hlta();
let _ = rfid.stop_crypto1();
Timer::after_millis(500).await;
}
}
Timer::after_millis(100).await;
}
}
Клонируйте существующий проект
Можно клонировать пример проекта и перейти в папку с примером записи.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/write-data/
Результат
Когда программа успешно работает, вывод показывает содержимое блока до и после записи. Вы должны увидеть ASCII-байты implRust в блоке 2, что подтверждает, что операция записи прошла как ожидалось.
Версия на rp-hal
Если вас интересует версия этого примера на rp-hal, её можно найти в репозитории ниже. Клонируйте репозиторий и перейдите в проект write-data:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/rfid/write-data/
Смена ключа аутентификации
В этой главе мы меняем ключ аутентификации для сектора. Точнее, мы обновим ключ A сектора 1. По умолчанию и ключ A, и ключ B на карте MIFARE Classic 1K установлены в FF FF FF FF FF FF. Мы заменим ключ A значением 52 75 73 74 65 64 — это шестнадцатеричное представление ASCII-строки «Rusted».
Чтобы поменять ключ, нужно записать данные в служебный блок сектора (trailer), то есть в блок 3 сектора. Остальные данные сектора должны остаться нетронутыми. Запись в служебный блок — операция чувствительная, поэтому нужно соблюдать осторожность.
Перед любыми изменениями настоятельно рекомендуется посмотреть текущее содержимое служебного блока сектора. Это можно сделать, запустив программу Вывод всей памяти или Чтение данных из предыдущих глав.
Note
Ключи по умолчанию: на карте MIFARE Classic 1K и ключ A, и ключ B изначально установлены в FF FF FF FF FF FF. При чтении служебного блока ключ A не возвращается и отображается как 00 00 00 00 00 00, а ключ B возвращается так, как хранится.
В этом примере мы также обновим ключ B, чтобы было легко убедиться, что запись прошла успешно. Ключ B устанавливается в шестнадцатеричные байты ASCII-строки «Ferris», то есть 46 65 72 72 69 73.
Программа выводит содержимое сектора до и после записи. После смены ключа попытка аутентификации со старым ключом не пройдёт. Это ожидаемое поведение, которое подтверждает, что новый ключ теперь активен.
Ключи и данные служебного блока
Данные, которые записываются в служебный блок, состоят из трёх частей. Первые шесть байтов — новое значение ключа A, затем идут биты доступа и байт служебного блока, а в конце — шесть байтов ключа B.
Текущий ключ аутентификации установлен в значение по умолчанию. Новый ключ извлекается из первых шести байтов массива данных, чтобы его можно было использовать повторно при чтении сектора после обновления.
#![allow(unused)]
fn main() {
let target_sector = 1;
let rel_block = 3;
const DATA: [u8; 16] = [
0x52, 0x75, 0x73, 0x74, 0x65, 0x64, // Ключ A: "Rusted"
0xFF, 0x07, 0x80, 0x69, // Биты доступа и байт служебного блока
0x46, 0x65, 0x72, 0x72, 0x69, 0x73, // Ключ B: "Ferris"
];
let current_key = &[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
let new_key: &[u8; 6] = &DATA[..6].try_into().expect("have enough data");
}
Запись служебного блока
Функция записи немного изменена, чтобы принимать ключ аутентификации в качестве аргумента. Это позволяет пройти аутентификацию со старым ключом перед записью, а после записи — с новым ключом.
#![allow(unused)]
fn main() {
fn write_block<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
rel_block: u8,
data: [u8; 16],
key: &[u8; 6],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
let block_offset = sector * 4;
let abs_block = block_offset + rel_block;
rfid.mf_authenticate(uid, block_offset, key)
.map_err(|_| "Auth failed")?;
rfid.mf_write(abs_block, data).map_err(|_| "Write failed")?;
Ok(())
}
}
Чтение сектора с пользовательским ключом
Функция чтения обновлена аналогичным образом. Теперь она принимает ключ как аргумент и использует его при аутентификации.
#![allow(unused)]
fn main() {
fn read_sector<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
key: &[u8; 6],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
let block_offset = sector * 4;
rfid.mf_authenticate(uid, block_offset, key)
.map_err(|_| "Auth failed")?;
for abs_block in block_offset..block_offset + 4 {
let data = rfid.mf_read(abs_block).map_err(|_| "Read failed")?;
print_hex(&data);
}
Ok(())
}
}
Главный цикл
Главный цикл имеет ту же структуру, что и в предыдущих главах. Сначала сектор читается с текущим ключом. Затем служебный блок записывается с новым ключом. Наконец, сектор читается снова, уже с новым ключом, чтобы подтвердить изменение.
Если вы используете USB Serial вместо отладочного зонда, замените defmt::println! и defmt::error! на log::info! и log::error!.
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
defmt::println!("\r\n----Before Write----\r\n");
if let Err(e) = read_sector(&uid, target_sector, current_key, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
Timer::after_millis(200).await;
if let Err(e) =
write_block(&uid, target_sector, rel_block, DATA, current_key, &mut rfid)
{
defmt::error!("Error writing block: {:?}", e);
}
Timer::after_millis(200).await;
defmt::println!("\r\n----After Write----\r\n");
if let Err(e) = read_sector(&uid, target_sector, new_key, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
let _ = rfid.hlta();
let _ = rfid.stop_crypto1();
Timer::after_millis(500).await;
}
}
Timer::after_millis(200).await;
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку change-key.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/change-key/
Результат
Когда программа работает, содержимое сектора выводится до и после записи. После смены ключа, если поднести метку к считывателю снова, аутентификация со старым ключом не пройдёт. Это подтверждает, что новый ключ 52 75 73 74 65 64 («Rusted») теперь активен.
Note
После обновления ключа, если снова поднести метку к считывателю, аутентификация не пройдёт, потому что программа по-прежнему использует старый ключ, жёстко заданный в
current_key.
Проверить это можно и дополнительно: обновите программу read-data, чтобы она использовала новый ключ, и запустите её снова.
Контроль доступа
Метка содержит биты доступа, которые управляют доступом к данным, хранящимся на метке. В этой главе мы разберём, как работают эти биты. Этот раздел может показаться немного сложным, поэтому я постараюсь объяснить его как можно проще.
Caution
Изменение битов доступа: будьте осторожны при записи битов доступа, потому что неверные значения могут сделать сектор непригодным для использования.
Разрешения
Это базовые разрешения, на основе которых задаются условия доступа. В таблице описана каждая операция с разрешением и указаны блоки, к которым она применима: обычные блоки данных (чтение/запись), блоки значений или служебные блоки секторов.
| Операция | Описание | Применимо к типу блока |
|---|---|---|
| Read | Чтение одного блока памяти | Чтение/запись, значение, служебный блок |
| Write | Запись одного блока памяти | Чтение/запись, значение, служебный блок |
| Increment | Увеличение содержимого блока и сохранение результата во внутреннем буфере передачи | Значение |
| Decrement | Уменьшение содержимого блока и сохранение результата во внутреннем буфере передачи | Значение |
| Restore | Чтение содержимого блока во внутренний буфер передачи | Значение |
| Transfer | Запись содержимого внутреннего буфера передачи в блок | Значение, чтение/запись |
Условия доступа
Начнём с главного вопроса: условия доступа. В ходе своих исследований я заметил, что многие люди с трудом разбираются в разделе об условиях доступа в даташите. Вот моя попытка объяснить его понятно 🤞.
Для управления разрешениями каждого блока используются всего 3 битовые комбинации. В официальном даташите это обозначается с помощью записи вида CXY (C1₀, C1₂… C3₃) для битов доступа. Первое число (X) в этой записи — номер бита доступа, от 1 до 3, и каждый соответствует определённому типу разрешения. Однако смысл этих разрешений различается в зависимости от того, является ли блок блоком данных или служебным блоком. Второе число (Y) в нижнем индексе обозначает относительный номер блока, от 0 до 3.
Таблица 1: Условия доступа для служебного блока сектора
В исходном даташите номер в нижнем индексе в таблице не указан. Я добавил индекс «3», так как служебный блок сектора находится в блоке 3.
Important
Читаемый ключ: если ключ можно прочитать, его нельзя использовать для аутентификации. Поэтому в этой таблице, когда ключ B читаем, он не может выполнять роль ключа аутентификации. Как вы могли заметить, да, ключ A никогда нельзя прочитать.
| Биты доступа | Условие доступа для | Примечание | |||||||
|---|---|---|---|---|---|---|---|---|---|
| Ключ A | Биты доступа | Ключ B | |||||||
| C13 | C23 | C33 | Чтение | Запись | Чтение | Запись | Чтение | Запись | |
| 0 | 0 | 0 | никогда | ключ A | ключ A | никогда | ключ A | ключ A | Ключ B может быть прочитан |
| 0 | 1 | 0 | никогда | никогда | ключ A | никогда | ключ A | никогда | Ключ B может быть прочитан |
| 1 | 0 | 0 | никогда | ключ B | ключ A|B | никогда | никогда | ключ B | |
| 1 | 1 | 0 | никогда | никогда | ключ A|B | никогда | никогда | никогда | |
| 0 | 0 | 1 | никогда | ключ A | ключ A | ключ A | ключ A | ключ A | Ключ B может быть прочитан; конфигурация по умолчанию |
| 0 | 1 | 1 | никогда | ключ B | ключ A|B | ключ B | никогда | ключ B | |
| 1 | 0 | 1 | никогда | никогда | ключ A|B | ключ B | никогда | никогда | |
| 1 | 1 | 1 | никогда | никогда | ключ A|B | никогда | никогда | никогда | |
Как разобраться в этой таблице?
Это простая таблица, которая показывает связь между битовыми комбинациями и разрешениями.
Например, пусть вы выбрали «1 0 0» (третья строка таблицы). Тогда ключ A и ключ B нельзя прочитать. Однако ключом B можно изменить и ключ A, и ключ B. Биты доступа можно прочитать и ключом A, и ключом B. Но сами биты доступа изменить нельзя никогда.
Теперь вопрос: где хранить эти биты? Мы разместим их в 6-м, 7-м и 8-м байтах в определённом месте, которое будет объяснено чуть позже.
Таблица 2: Условия доступа для блоков данных
Это относится ко всем блокам данных. В исходном даташите нет индекса «Y», я добавил его для ясности. Здесь «Y» обозначает номер блока (от 0 до 2).
Конфигурация по умолчанию означает, что и ключ A, и ключ B могут выполнять все операции. Однако, как видно из предыдущей таблицы, ключ B читаем (в конфигурации по умолчанию), поэтому он не может использоваться для аутентификации. Следовательно, можно использовать только ключ A.
| Биты доступа | Условие доступа для | Применение | |||||
|---|---|---|---|---|---|---|---|
| C1Y | C2Y | C3Y | Чтение | Запись | Инкремент | Декремент, перенос/восстановление | |
| 0 | 0 | 0 | ключ A|B | ключ A|B | ключ A|B | ключ A|B | Конфигурация по умолчанию |
| 0 | 1 | 0 | ключ A|B | никогда | никогда | никогда | блок чтения/записи |
| 1 | 0 | 0 | ключ A|B | ключ B | никогда | никогда | блок чтения/записи |
| 1 | 1 | 0 | ключ A|B | ключ B | ключ B | ключ A|B | блок значения |
| 0 | 0 | 1 | ключ A|B | никогда | никогда | ключ A|B | блок значения |
| 0 | 1 | 1 | ключ B | ключ B | никогда | никогда | блок чтения/записи |
| 1 | 0 | 1 | ключ B | никогда | никогда | никогда | блок чтения/записи |
| 1 | 1 | 1 | никогда | никогда | никогда | никогда | блок чтения/записи |
Как разобраться в этой таблице?
Она похожа на предыдущую: показывает связь между битовыми комбинациями и разрешениями.
Например, если выбрать «0 1 0» (вторая строка таблицы) и применить это разрешение к блоку 1, то для чтения блока 1 можно использовать и ключ A, и ключ B. Однако никакие другие операции с блоком 1 выполнить нельзя.
Обозначение такое: номер блока записывается как нижний индекс к меткам битов (например, C11, C21, C31). Здесь индекс «1» обозначает блок 1. Для выбранной комбинации «0 1 0» это означает:
- C11 = 0
- C21 = 1
- C31 = 0
Эти биты также будут размещены в 6-м, 7-м и 8-м байтах в определённом месте, которое будет объяснено чуть позже.
Таблица 3: Таблица условий доступа
Раскрасим исходную таблицу, чтобы лучше увидеть, что означает каждый бит. Седьмой и третий биты в каждом байте относятся к служебному блоку сектора. Шестой и второй биты соответствуют блоку 2. Пятый и первый биты связаны с блоком 1. Четвёртый и нулевой биты относятся к блоку 0.
Надчёркивание в обозначении означает инвертированное значение. Это значит, что если значение CXy равно 0, то CXy становится равным 1.
| Байт | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| Байт 6 | C23 | C22 | C21 | C20 | C13 | C12 | C11 | C10 |
| Байт 7 | C13 | C12 | C11 | C10 | C33 | C32 | C31 | C30 |
| Байт 8 | C33 | C32 | C31 | C30 | C23 | C22 | C21 | C20 |
Биты доступа по умолчанию — «FF 07 80». Попробуем понять, что это значит.
| Байт | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| Байт 6 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| Байт 7 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 |
| Байт 8 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
Из таблицы выше можно вывести значения CXY. Обратите внимание: только C33 равно 1, а все остальные значения равны 0. Теперь посмотрите Таблицу 1 и Таблицу 2, чтобы понять, какому разрешению это соответствует.
| Блок | C1Y | C2Y | C3Y | Доступ |
|---|---|---|---|---|
| Блок 0 | 0 | 0 | 0 | Все разрешения с ключом A |
| Блок 1 | 0 | 0 | 0 | Все разрешения с ключом A |
| Блок 2 | 0 | 0 | 0 | Все разрешения с ключом A |
| Блок 3 (служебный) | 0 | 0 | 1 | Ключ A можно записать с помощью ключа A. Биты доступа и ключ B можно читать и записывать только с помощью ключа A. |
Поскольку ключ B читаем, его нельзя использовать для аутентификации.
Калькулятор на следующей странице
Всё ещё сложно? Используйте калькулятор на следующей странице, чтобы поэкспериментировать с различными комбинациями. Меняйте разрешения для каждого блока и смотрите, как соответственно меняются значения битов доступа.
Ссылка
Калькулятор битов доступа MIFARE Classic 1K
Декодирование: Измените «Access bits», и таблицы блока данных и служебного блока сектора обновятся автоматически.
Кодирование: Нажмите кнопку «Edit» в каждой строке таблицы, чтобы выбрать нужные условия доступа. Это обновит биты доступа.
Caution
Запись неверного значения в биты условий доступа может сделать сектор недоступным.
Биты доступа
Условия доступа блока данных:
| Блок | C1Y | C2Y | C3Y | Чтение | Запись | Инкремент | Декремент/Перенос/Восстановление | Примечания | Действие |
|---|---|---|---|---|---|---|---|---|---|
| Блок 0 | 0 | 0 | 0 | ключ A|B | ключ A|B | ключ A|B | ключ A|B | Конфигурация по умолчанию | |
| Блок 1 | 0 | 0 | 0 | ключ A|B | ключ A|B | ключ A|B | ключ A|B | Конфигурация по умолчанию | |
| Блок 2 | 0 | 0 | 0 | ключ A|B | ключ A|B | ключ A|B | ключ A|B | Конфигурация по умолчанию |
| C1Y | C2Y | C3Y | Чтение | Запись | Инкремент | Декремент/Перенос/Восстановление | Примечания |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | ключ A|B | ключ A|B | ключ A|B | ключ A|B | Конфигурация по умолчанию |
| 0 | 1 | 0 | ключ A|B | никогда | никогда | никогда | блок чтения/записи |
| 1 | 0 | 0 | ключ A|B | ключ B | никогда | никогда | блок чтения/записи |
| 1 | 1 | 0 | ключ A|B | ключ B | ключ B | ключ A|B | блок значения |
| 0 | 0 | 1 | ключ A|B | никогда | никогда | ключ A|B | блок значения |
| 0 | 1 | 1 | ключ B | ключ B | никогда | никогда | блок чтения/записи |
| 1 | 0 | 1 | ключ B | никогда | никогда | никогда | блок чтения/записи |
| 1 | 1 | 1 | никогда | никогда | никогда | никогда | блок чтения/записи |
Условия доступа служебного блока сектора (блок 3):
| C13 | C23 | C33 | Чтение ключа A | Запись ключа A | Чтение битов доступа | Запись битов доступа | Чтение ключа B | Запись ключа B | Примечания | Действие |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 1 | никогда | ключ A | ключ A | ключ A | ключ A | ключ A | Ключ B может быть прочитан; конфигурация по умолчанию |
| C13 | C23 | C33 | Чтение ключа A | Запись ключа A | Чтение битов доступа | Запись битов доступа | Чтение ключа B | Запись ключа B | Примечания |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | никогда | ключ A | ключ A | никогда | ключ A | ключ A | Ключ B может быть прочитан |
| 0 | 1 | 0 | никогда | никогда | ключ A | никогда | ключ A | никогда | Ключ B может быть прочитан |
| 1 | 0 | 0 | никогда | ключ B | ключ A|B | никогда | никогда | ключ B | |
| 1 | 1 | 0 | никогда | никогда | ключ A|B | никогда | никогда | никогда | |
| 0 | 0 | 1 | никогда | ключ A | ключ A | ключ A | ключ A | ключ A | Ключ B может быть прочитан; конфигурация по умолчанию |
| 0 | 1 | 1 | никогда | ключ B | ключ A|B | ключ B | никогда | ключ B | |
| 1 | 0 | 1 | никогда | никогда | ключ A|B | ключ B | никогда | никогда | |
| 1 | 1 | 1 | никогда | никогда | ключ A|B | никогда | никогда | никогда |
Ссылки
- Интерфейс вдохновлён этим калькулятором: Mifarecalc
- MIFARE-Classic-1K-Access-Bits-Calculator
SD-карта (SDC/MMC)
Рано или поздно вам может понадобиться хранить данные, которые не помещаются во флеш-памяти микроконтроллера. Это могут быть журналы датчиков, конфигурационные файлы, игровые ресурсы или что-то другое, что должно сохраняться после отключения питания. Один из самых практичных способов — использовать SD-карту. В этом разделе мы научимся работать с модулем SD-карты на Raspberry Pi Pico.
MMC (MultiMediaCard)
MultiMediaCard (MMC) появилась как один из ранних видов флеш-памяти, предшественник SD-карты. Её часто использовали в видеокамерах, цифровых фотоаппаратах и портативных музыкальных плеерах.
Как и современная флеш-память, MMC хранит данные в виде электрического заряда в ячейках флеш-памяти. Это сильно отличается от оптических носителей, таких как CD или DVD, которые хранят данные в виде физических меток, считываемых лазером.
Хотя карты MMC сегодня почти вышли из употребления, они по-прежнему важны, потому что SD-карты унаследовали многие идеи от MMC.
SD-карта (Secure Digital)
Карта Secure Digital, обычно называемая SD-картой, построена на идеях MMC и развила их. SD-карты стали чрезвычайно популярны, и сейчас их используют в фотоаппаратах, встраиваемых системах и одноплатных компьютерах. Существует более компактный вариант — microSD, который обычно применяют в проектах на микроконтроллерах.
Источник изображения: на основе SD card от Tkgd2007, распространяется по лицензиям GFDL и CC BY-SA 3.0, 2.5, 2.0, 1.0.
Внутри SD-карты читают и записывают данные блоками фиксированного размера, обычно 512 байтов. Поэтому доступ к данным осуществляется блоками, а не отдельными байтами. Над этими блоками работают файловые системы, например FAT, которые управляют тем, как файлы сохраняются и извлекаются.
Требования к оборудованию
Мы будем использовать модуль адаптера micro SD-карты. Найти его можно, введя в поиске «Micro SD Card Reader Module» или «Micro SD Card Adapter».
И, конечно, вам понадобится microSD-карта. Карта должна быть отформатирована в FAT32. В зависимости от вашего компьютера может понадобиться отдельный адаптер SD или USB для форматирования карты, так как не у всех ноутбуков есть слот для microSD.
Ссылки
- Я настоятельно рекомендую посмотреть доклад Джонатана Палланта на Euro Rust 2024 о написании драйвера SD-карты на Rust. Драйвер, который мы будем использовать, написал именно он (изначально он создавал его, чтобы запускать MS-DOS на ARM). Он не предназначен для производственных систем.
- Если хотите понять, как всё устроено внутри в режиме SPI, можно обратиться к статье: How to Use MMC/SDC (на английском языке).
Подключение считывателя micro SD-карт к Raspberry Pi Pico
В этом разделе мы подключим модуль считывателя microSD-карт к Raspberry Pi Pico в режиме SPI.
Распиновка microSD-карты в режиме SPI
Мы рассмотрим только саму microSD-карту, так как именно её мы используем. У microSD-карты 8 физических выводов, но в режиме SPI нужны только 6 из них.
Вы могли заметить, что большинство модулей считывателей microSD тоже выводят только 6 контактов. Это потому, что такие модули уже внутри подключены для работы по SPI, а неиспользуемые выводы просто не выведены наружу.
В таблице ниже показано, как выводы microSD-карты соответствуют сигналам SPI.
| Вывод microSD-карты | Функция SPI |
|---|---|
| 1 | - |
| 2 | Выбор микросхемы (CS); также называется Card Select |
| 3 | Вход данных (DI) — соответствует MOSI. Для приёма данных от микроконтроллера. |
| 4 | VDD — питание (3,3 В) |
| 5 | Последовательное тактирование (SCK) |
| 6 | Земля (GND) |
| 7 | Выход данных (DO) — соответствует MISO. Для передачи данных от microSD-карты к микроконтроллеру. |
| 8 | - |
Подключение Raspberry Pi Pico к считывателю SD-карт
Выводы GPIO Raspberry Pi Pico рассчитаны на 3,3 В и могут быть необратимо повреждены сигналами 5 В. microSD-карты тоже работают при 3,3 В и могут выйти из строя при подаче более высокого напряжения.
Модули считывателей SD-карт бывают разных конфигураций:
Модули только на 3,3 В: такие модули — простые платы-переходники для microSD, рассчитанные на работу непосредственно при 3,3 В. Поскольку и Raspberry Pi Pico, и SD-карта используют одно и то же напряжение, модуль можно подключить без дополнительных схем. Питайте его от вывода 3V3(OUT) Pico и подключите линии данных SPI напрямую к выводам GPIO Pico. Это самый простой и безопасный вариант при работе с Raspberry Pi Pico.
Модули на 5 В со стабилизацией напряжения: некоторые модули SD-карт рассчитаны на питание от 5 В. Такие модули содержат дополнительные компоненты, благодаря которым сама SD-карта всё равно работает при 3,3 В. Некоторые модули более высокого качества также следят за тем, чтобы все сигнальные линии оставались на безопасном уровне 3,3 В.
Если модуль на 5 В спроектирован правильно, его можно использовать с Raspberry Pi Pico. Однако не все модули на 5 В корректно работают с уровнями сигналов. Поэтому перед подключением к Pico всегда проверяйте описание модуля и документацию.
Модуль, используемый в этом руководстве
Модуль SD-карты, который используется в этом руководстве, рассчитан на питание от 5 В и выдаёт сигналы 3,3 В, поэтому его безопасно использовать с Raspberry Pi Pico.
Питайте модуль от вывода VBUS Pico, который выдаёт 5 В, когда Pico питается по USB. Не питайте такой модуль от вывода 3,3 В Pico, так как он может работать ненадёжно.
Caution
Всегда проверяйте, что ваш модуль SD-карты выдаёт сигналы 3,3 В, прежде чем подключать его к Pico. Если не уверены, используйте модуль только на 3,3 В, чтобы не повредить Pico.
Схема подключения
| Вывод Pico | Провод | Вывод SD-карты |
|---|---|---|
| GPIO 4 (RX) |
|
MISO |
| GPIO 5 |
|
CS |
| GPIO 6 |
|
SCK |
| GPIO 7 (TX) |
|
MOSI |
| VBUS |
|
VCC |
| GND |
|
GND |
Чтение SD-карты с Raspberry Pi Pico
В этой главе мы прочитаем файл с microSD-карты и выведем его содержимое. Прежде чем продолжить, нужна SD-карта, уже отформатированная в FAT32, и на ней должен быть файл с именем RUST.TXT. Для этого примера можно записать в него текст Ferris.
Выберите способ вывода
В этом разделе приведены два способа вывода. Какой из них использовать, зависит от того, есть ли у вас отладочный зонд.
С точки зрения логики и поведения разницы нет. Программа работает одинаково в обоих случаях. Основное отличие — в том, как выводятся данные.
Если вы используете отладочный зонд, мы выводим сообщения через defmt по RTT. Если используете USB Serial, мы выводим сообщения через крейт log.
Настройка USB Serial такая же, как в главе о USB Serial. Возможно, потребуется выполнить те же шаги для настройки USB-логгера. Здесь я их повторять не буду.
С точки зрения кода различие в основном сводится к следующему:
- Поскольку с отладочным зондом мы используем defmt, логирование выполняется через
defmt::info! - А при USB Serial мы используем крейт log, поэтому логирование выполняется через
log::info!
Выберите вкладку ниже в зависимости от вашей настройки:
В этой схеме содержимое файла выводится через defmt по RTT.
Проект из шаблона
Начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, введите имя проекта, например «read-sdcard», и выберите «Embassy» в качестве HAL. Обязательно включите «defmt» для логирования.
Требуемые дополнительные крейты
Обновите Cargo.toml и добавьте эти дополнительные крейты вместе с существующими зависимостями.
# Для преобразования шины SPI в SpiDevice
embedded-hal-bus = "0.3.0"
# Драйвер SD-карты
embedded-sdmmc = "0.9.0"
Крейт embedded-sdmmc — драйвер для чтения и записи файлов на SD-картах с файловой системой FAT.
Дополнительные импорты
#![allow(unused)]
fn main() {
// Для SPI
use embassy_rp::spi;
use embassy_rp::spi::Spi;
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
// Для вывода CS
use embassy_rp::gpio::{Level, Output};
// Для SdCard
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};
}
Фиктивный TimeSource
Код файловой системы SD-карты требует реализации TimeSource, которая предоставляет отметки времени для метаданных файлов. Хотя в этом примере мы не создаём и не изменяем файлы, трейт всё равно нужно реализовать.
Поскольку временные метки здесь не важны, мы используем фиктивную реализацию, которая всегда возвращает нулевые значения.
#![allow(unused)]
fn main() {
/// Код взят из https://github.com/rp-rs/rp-hal-boards/blob/main/boards/rp-pico/examples/pico_spi_sd_card.rs
/// Фиктивный источник времени, который важен главным образом при создании файлов.
#[derive(Default)]
pub struct DummyTimesource();
impl TimeSource for DummyTimesource {
// Теоретически здесь можно использовать RTC rp2040, если есть
// какое-либо внешнее устройство синхронизации времени.
fn get_timestamp(&self) -> Timestamp {
Timestamp {
year_since_1970: 0,
zero_indexed_month: 0,
zero_indexed_day: 0,
hours: 0,
minutes: 0,
seconds: 0,
}
}
}
}
Настройка SPI для считывателя SD-карт
Теперь настроим периферию SPI и выводы GPIO, которые используются для связи со считывателем SD-карт.
#![allow(unused)]
fn main() {
let miso = p.PIN_4;
let cs_pin = Output::new(p.PIN_5, Level::High);
let clk = p.PIN_6;
let mosi = p.PIN_7;
let mut config = spi::Config::default();
config.frequency = 400_000;
let spi_bus = Spi::new_blocking(p.SPI0, clk, mosi, miso, config);
}
Создание SpiDevice из шины SPI
Драйвер SD-карты ожидает SpiDevice, а не «голую» шину SPI.
#![allow(unused)]
fn main() {
let spi_device =
ExclusiveDevice::new(spi_bus, cs_pin, Delay).expect("Failed to get exclusive device");
}
Настройка драйвера SD-карты
Когда устройство SPI готово, можно создать экземпляр драйвера SD-карты.
#![allow(unused)]
fn main() {
let sdcard = SdCard::new(spi_device, Delay);
}
Чтение размера SD-карты
Прежде чем открывать какие-либо файлы, мы читаем общий размер карты, чтобы убедиться, что инициализация прошла успешно.
#![allow(unused)]
fn main() {
defmt::info!("Init SD card controller and retrieve card size...");
let sd_size = sdcard.num_bytes().expect("failed to get sdcard size");
defmt::info!("card size is {} bytes", sd_size);
}
Открываем том и корневой каталог
Далее создаём менеджер томов и открываем первый том на карте.
#![allow(unused)]
fn main() {
let volume_mgr = VolumeManager::new(sdcard, DummyTimesource::default());
let volume0 = volume_mgr
.open_volume(VolumeIdx(0))
.expect("failed to open volume");
let root_dir = volume0.open_root_dir().expect("failed to open root dir");
}
Открываем файл
Когда корневой каталог доступен, открываем файл, который хотим прочитать.
#![allow(unused)]
fn main() {
let my_file = root_dir
.open_file_in_dir("RUST.TXT", embedded_sdmmc::Mode::ReadOnly)
.expect("failed to open RUST.TXT file");
}
Чтение содержимого файла
Наконец, читаем файл небольшими порциями и выводим его содержимое.
#![allow(unused)]
fn main() {
while !my_file.is_eof() {
let mut buffer = [0u8; 32];
if let Ok(n) = my_file.read(&mut buffer) {
if let Ok(s) = core::str::from_utf8(&buffer[..n]) {
defmt::info!("{}", s);
} else {
defmt::info!("{:02x}", &buffer[..n]);
}
}
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку read-sdcard.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/sdcard/read-sdcard/
Когда программа запущена, в выводе будет показано содержимое файла RUST.TXT.
Запись данных на SD-карту в embedded Rust на Raspberry Pi Pico
В этой главе мы создадим файл на microSD-карте и запишем в него данные. Подключение SD-карты, настройка SPI, логирование и инициализация карты остаются прежними. Меняются только открытие файла и запись в него данных.
Если файл уже существует на карте, он будет перезаписан. После работы программы можно извлечь карту и проверить содержимое на компьютере.
Настройка проекта
Можно взять проект из примера чтения и изменить его либо создать новый проект из шаблона и повторить шаги настройки до создания экземпляра sdcard.
Дальше отличается только код работы с файлами.
Открытие файла для записи
Чтобы писать данные, файл нужно открыть в режиме, который допускает создание и изменение. Здесь мы используем ReadWriteCreateOrTruncate: он создаёт файл, если его нет, и обрезает его, если он уже существует. Так файл каждый раз начинается пустым.
#![allow(unused)]
fn main() {
let my_file = root_dir
.open_file_in_dir(
"FERRIS.TXT",
embedded_sdmmc::Mode::ReadWriteCreateOrTruncate,
)
.expect("failed to create FERRIS.TXT file");
}
Запись данных в файл
Чтобы пример оставался простым, мы записываем в файл одну строку текста. Если запись прошла успешно, файл сбрасывается (flush), чтобы данные гарантированно попали на SD-карту.
#![allow(unused)]
fn main() {
let line = "Hello, Ferris!";
if let Ok(()) = my_file.write(line.as_bytes()) {
info!("Written Data");
if let Err(_) = my_file.flush() {
info!("Failed to flush");
}
} else {
error!("Unable to write the data");
}
}
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку write-sdcard.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/sdcard/write-sdcard/
Проверка результата
Когда программа завершит работу, выключите плату и извлеките microSD-карту. Если вставить карту в компьютер, в корневом каталоге должен быть файл FERRIS.TXT.
Tip
В этом примере мы используем фиктивный источник времени, поэтому дата файла будет около 1980 года. Если нужны правильные временные метки, потребуется настроить RTC и предоставить собственную реализацию TimeSource, которая возвращает текущее время.
Открыв файл, вы увидите:
Hello, Ferris!
Джойстик
В этом разделе мы разберём, как использовать модуль джойстика. Он похож на джойстики контроллеров PS2 (PlayStation 2). Их часто применяют в играх, а также для управления дронами, радиоуправляемыми машинками, роботами и другими устройствами, чтобы менять положение или направление.
Знакомство с железом — модуль джойстика
Ручку джойстика можно перемещать по вертикали и по горизонтали, а её положение (оси X и Y) передаётся в микроконтроллер (например, Pico). Кроме того, ручку можно нажать вниз, как кнопку. Джойстик обычно работает от 5 В, но его также можно подключить к 3,3 В.
Как это работает?
Модуль джойстика содержит два потенциометра на 10 кОм: один для оси X и другой для оси Y. Также в нём есть кнопка, которая видна сверху.
Когда вы перемещаете джойстик слева направо или справа налево (ось X), можно увидеть, как один из потенциометров двигается соответственно. Аналогично, когда вы двигаете его вверх и вниз (ось Y), движется другой потенциометр.
Также можно увидеть, как срабатывает кнопка, когда вы нажимаете на ручку.
Движение и АЦП
Движение джойстика и соответствующие значения АЦП
Когда вы двигаете джойстик по оси X или Y, он выдаёт аналоговый сигнал, напряжение которого меняется от 0 до 3,3 В (или до 5 В, если подключить его к питанию 5 В). Когда джойстик находится в центральном (нейтральном) положении, выходное напряжение составляет примерно 1,65 В, то есть половину VCC (в нашем случае VCC равно 3,3 В).
Note
Причина, по которой в центральном положении напряжение равно 1,65 В, в том, что потенциометр работает как делитель напряжения. Когда потенциометр перемещается, его сопротивление меняется, и делитель выдаёт другое напряжение. См. раздел о делителе напряжения.
У джойстика всего 5 выводов, и мы вскоре разберём, что означает каждый из них. Из них два вывода передают положение осей X и Y, и их нужно подключить к выводам АЦП микроконтроллера.
Как вы, возможно, уже знаете, у Raspberry Pi Pico есть 12-битный АЦП с последовательным приближением (SAR), который преобразует аналоговые сигналы (разницы напряжений) в цифровые значения. Так как это 12-битный АЦП, аналоговые значения будут представлены цифровыми значениями от 0 до 4095. Если вы не знакомы с АЦП, см. раздел об АЦП, который мы рассматривали ранее.
Примечание:
Значения АЦП на изображении — лишь приблизительные, чтобы дать общее представление, и не будут точными. Например, в центральном положении я получил около 1850 для X и Y. Когда я двигал ручку в сторону выводов, X уходил к 0, а при движении в противоположную сторону — к 4095. То же самое относится и к оси Y. Поэтому, возможно, вашему джойстику понадобится калибровка.
Распиновка
У джойстика всего 5 выводов: питание, земля, выход оси X, выход оси Y и выход переключателя.
| Вывод джойстика | Описание |
|---|---|
| GND | Вывод земли. Должен быть подключён к земле схемы. |
| VCC | Вывод питания (обычно 5 В или 3,3 В). |
| VRX | Аналоговый выход оси X: его напряжение меняется в зависимости от горизонтального положения джойстика и изменяется от 0 В до VCC при движении влево и вправо. |
| VRY | Аналоговый выход оси Y: его напряжение меняется в зависимости от вертикального положения джойстика и изменяется от 0 В до VCC при движении вверх и вниз. |
| SW | Вывод переключателя. Когда ручку джойстика нажимают, этот вывод обычно притягивается к низкому уровню (к GND). |
Подключение джойстика к Raspberry Pi Pico
Подключим джойстик к Raspberry Pi Pico. Выводы VRX и VRY нужно подключить к выводам АЦП Pico. Джойстик будем питать от 3,3 В, а не от 5 В, потому что выводы GPIO Pico рассчитаны только на 3,3 В. Подключение к 5 В может повредить выводы Pico. К счастью, джойстик работает и от 3,3 В.
| Вывод Pico | Провод | Вывод джойстика |
|---|---|---|
| GND |
|
GND |
| 3.3 V |
|
VCC |
| GPIO 27 (ADC1) |
|
VRX |
| GPIO 26 (ADC0) |
|
VRY |
| GPIO 15 |
|
SW |
Определяем положение джойстика по значениям АЦП Raspberry Pi Pico
В этой главе мы будем выводить значения АЦП джойстика каждый раз, когда они меняются. Мы считаем два аналоговых сигнала, vrx и vry, которые подключены к осям джойстика, а также выведем значения при нажатии кнопки джойстика.
Цель проста: нужно увидеть, как физическое движение джойстика превращается в числовые значения АЦП. Когда вы поймёте, как значения меняются при движении ручки, эту идею можно использовать в проектах: управление роботами, радиоуправляемыми машинками, дронами, поворотными механизмами камеры и многое другое.
Проект из шаблона
Начнём с создания нового проекта из шаблона.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
Когда появится запрос, задайте проекту имя, например joystick-adc, и выберите embassy в качестве HAL.
Если у вас есть отладочный зонд, можно включить defmt, и логирование будет простым. Если отладочного зонда нет, отключите defmt и используйте вывод через USB Serial, как в предыдущей главе. Здесь эти шаги повторять не будем, чтобы сосредоточиться на настройке джойстика и АЦП.
Дополнительные импорты
Добавьте следующие импорты в файл main.rs. Они нужны для доступа к АЦП, входу GPIO и обработке прерываний.
#![allow(unused)]
fn main() {
// Для АЦП
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::gpio::{Input, Pull};
// Привязка прерываний
use embassy_rp::adc;
use embassy_rp::bind_interrupts;
}
Привязка прерывания АЦП
Далее привязываем прерывание FIFO АЦП.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
});
}
Настройка АЦП и джойстика
Теперь инициализируем периферию АЦП и настроим выводы джойстика. Сигналы vrx и vry подключены к выводам GPIO, которые поддерживают АЦП. Кнопку джойстика мы настраиваем как обычный вход.
#![allow(unused)]
fn main() {
// Настройка АЦП
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut vrx_pin = Channel::new_pin(p.PIN_27, Pull::None);
let mut vry_pin = Channel::new_pin(p.PIN_26, Pull::None);
let button = Input::new(p.PIN_15, Pull::Up);
}
Хранение предыдущих значений
Перед входом в главный цикл сохраняем несколько переменных с предыдущими показаниями. Это позволяет замечать, что значение действительно изменилось, а не выводить значения непрерывно.
#![allow(unused)]
fn main() {
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut print_vals = true;
let mut prev_btn_state = false;
}
Мы храним предыдущие показания по осям X и Y, держим флаг, который управляет тем, когда нужно выводить значения, а также запоминаем предыдущее состояние кнопки, чтобы определить событие нажатия.
Главный цикл
Внутри главного цикла мы непрерывно считываем значения АЦП с двух выводов джойстика. Если чтение АЦП завершилось ошибкой, мы пропускаем итерацию и пробуем снова.
#![allow(unused)]
fn main() {
loop {
let Ok(vry) = adc.read(&mut vry_pin).await else {
continue;
};
let Ok(vrx) = adc.read(&mut vrx_pin).await else {
continue;
};
}
Определение значимых изменений
Аналоговые джойстики не бывают идеально стабильными. Даже без прикосновения показания могут немного колебаться. Чтобы вывод оставался читаемым, мы реагируем только тогда, когда изменение достаточно большое.
#![allow(unused)]
fn main() {
if vrx.abs_diff(prev_vrx) > 100 {
prev_vrx = vrx;
print_vals = true;
}
if vry.abs_diff(prev_vry) > 100 {
prev_vry = vry;
print_vals = true;
}
}
Когда любая из осей меняется больше порога, мы обновляем сохранённое значение и отмечаем, что показания нужно вывести.
Вывод значений АЦП
Если установлен флаг print_vals, мы выводим текущее положение джойстика и сбрасываем флаг.
#![allow(unused)]
fn main() {
if print_vals {
print_vals = false;
info!("X: {} Y: {}", vrx, vry);
}
}
Обработка кнопки
Затем считываем кнопку джойстика. Поскольку она использует подтягивающий резистор, нажатая кнопка читается как низкий уровень.
#![allow(unused)]
fn main() {
let btn_state = button.is_low();
if btn_state && !prev_btn_state {
info!("Button Pressed");
print_vals = true;
}
prev_btn_state = btn_state;
}
Когда кнопка нажата, мы помечаем значения АЦП для вывода. Так можно увидеть точное положение джойстика в момент нажатия.
В конце цикла добавим короткую задержку.
#![allow(unused)]
fn main() {
Timer::after_millis(100).await;
}
}
Полный код
#![no_std]
#![no_main]
use defmt::info;
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
// Для АЦП
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::gpio::{Input, Pull};
// Привязка прерываний
use embassy_rp::adc;
use embassy_rp::bind_interrupts;
//Обработчик паники
use panic_probe as _;
// Логирование через defmt
use defmt_rtt as _;
/// Сообщаем Boot ROM о нашем приложении
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
});
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// Настройка АЦП
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut vrx_pin = Channel::new_pin(p.PIN_27, Pull::None);
let mut vry_pin = Channel::new_pin(p.PIN_26, Pull::None);
let button = Input::new(p.PIN_15, Pull::Up);
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut print_vals = true;
let mut prev_btn_state = false;
loop {
let Ok(vry) = adc.read(&mut vry_pin).await else {
continue;
};
let Ok(vrx) = adc.read(&mut vrx_pin).await else {
continue;
};
if vrx.abs_diff(prev_vrx) > 100 {
prev_vrx = vrx;
print_vals = true;
}
if vry.abs_diff(prev_vry) > 100 {
prev_vry = vry;
print_vals = true;
}
if print_vals {
print_vals = false;
info!("X: {} Y: {}", vrx, vry);
}
let btn_state = button.is_low();
if btn_state && !prev_btn_state {
info!("Button Pressed");
print_vals = true;
}
prev_btn_state = btn_state;
Timer::after_millis(100).await;
}
}
// Метаданные программы для `picotool info`.
// Это не обязательно, но рекомендуется добавить эти минимальные записи.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"joystick-adc"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// Конец файла
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку joystick-adc.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/joystick/joystick-adc
Отправка значений АЦП джойстика в USB Serial (RP-HAL)
В предыдущей главе мы использовали Embassy, чтобы читать значения АЦП джойстика и выводить их через логирование. В первой версии этой книги я работал с RP-HAL, когда изучал экосистему. Я не хочу выбрасывать этот материал, потому что он может пригодиться для понимания старого кода или других подходов.
В этой главе показан тот же пример работы с джойстиком и АЦП, но реализованный с помощью RP-HAL и USB Serial. Если версия на RP-HAL вам не интересна, эту главу можно пропустить.
Проект из шаблона
Чтобы настроить проект, выполните:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.1.0
Когда появится запрос, задайте проекту имя, например «joystick-usb», и выберите RP-HAL в качестве HAL.
Затем перейдите в папку проекта:
cd PROJECT_NAME
# Например, если вы назвали проект «joystick-usb»:
# cd joystick-usb
Дополнительные крейты
Добавьте в Cargo.toml эти дополнительные крейты вместе с уже существующими зависимостями.
#![allow(unused)]
fn main() {
usb-device = "0.3.2"
usbd-serial = "0.2.2"
heapless = "0.8.0"
embedded_hal_0_2 = { package = "embedded-hal", version = "0.2.5", features = [
"unproven",
] }
}
Первые три должны быть уже знакомы: они настраивают связь через USB Serial, чтобы передавать данные между Pico и компьютером. heapless — вспомогательная библиотека для буферов.
embedded_hal_0_2 — новый крейт. В вашем Cargo.toml, возможно, уже есть embedded-hal версии «1.0.0», поэтому можно задаться вопросом, зачем нужна эта версия. Причина в том, что Embedded HAL 1.0.0 не содержит трейта АЦП для чтения значений, а RP-HAL использует трейт из версии 0.2. (Не удаляйте существующий embedded-hal 1.0.0, просто добавьте эту версию рядом с ним.)
Дополнительные импорты
#![allow(unused)]
fn main() {
/// Этот трейт — интерфейс АЦП, который настроен на чтение конкретного канала
/// в момент запроса (в отличие от непрерывной асинхронной выборки).
use embedded_hal_0_2::adc::OneShot;
// Для USB Serial
use usb_device::{class_prelude::*, prelude::*};
use usbd_serial::SerialPort;
use heapless::String;
}
USB Serial
Убедитесь, что вы выполнили раздел про USB Serial и добавили в проект шаблонный код из него.
#![allow(unused)]
fn main() {
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USB,
pac.USB_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut serial = SerialPort::new(&usb_bus);
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.strings(&[StringDescriptors::default()
.manufacturer("implRust")
.product("Ferris")
.serial_number("12345678")])
.unwrap()
.device_class(2) // 2 для CDC, см.: https://www.usb.org/defined-class-codes
.build();
let mut buff: String<64> = String::new();
}
Настройка АЦП и выводов
Подключение джойстика такое же, как и раньше. GPIO 27 и GPIO 26 подключены к выводам VRX и VRY джойстика и настроены как входы АЦП. GPIO 15 используется для кнопки джойстика с внутренним подтягивающим резистором.
#![allow(unused)]
fn main() {
let mut adc = hal::Adc::new(pac.ADC, &mut pac.RESETS);
//Вывод VRX
let mut adc_pin_1 = hal::adc::AdcPin::new(pins.gpio27).unwrap();
// Вывод VRY
let mut adc_pin_0 = hal::adc::AdcPin::new(pins.gpio26).unwrap();
let mut btn = pins.gpio15.into_pull_up_input();
}
Вывод координат
Как и в версии на Embassy, мы не выводим значения АЦП непрерывно. Мы запоминаем предыдущие показания и выводим их, только когда изменение пересекает порог. Тот же подход применяется и к кнопке: нажатие определяем по переходу состояния, поэтому сообщение выводится один раз за нажатие.
#![allow(unused)]
fn main() {
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut print_vals = true;
}
Чтение значений АЦП:
Сначала считываем значения АЦП для vrx и vry. Если при чтении возникает ошибка, мы её игнорируем и продолжаем цикл:
#![allow(unused)]
fn main() {
let Ok(vry): Result<u16, _> = adc.read(&mut adc_pin_0) else {
continue;
};
let Ok(vrx): Result<u16, _> = adc.read(&mut adc_pin_1) else {
continue;
};
}
Проверка изменений относительно порога:
Далее проверяем, превышает ли абсолютная разница между текущим и предыдущим значением vrx или vry порог (например, 100). Если да, обновляем предыдущее значение и выставляем флаг print_vals в true:
#![allow(unused)]
fn main() {
if vrx.abs_diff(prev_vrx) > 100 {
prev_vrx = vrx;
print_vals = true;
}
if vry.abs_diff(prev_vry) > 100 {
prev_vry = vry;
print_vals = true;
}
}
Использование порога отфильтровывает небольшие колебания АЦП, исключает ненужный вывод и гарантирует обновления только при значимых изменениях.
Вывод координат
Если print_vals равен true, сбрасываем его в false и выводим координаты X и Y через USB Serial:
#![allow(unused)]
fn main() {
if print_vals {
print_vals = false;
buff.clear();
write!(buff, "X: {} Y: {}\r\n", vrx, vry).unwrap();
let _ = serial.write(buff.as_bytes());
}
}
Определение нажатия кнопки по переходу состояния
Обычно кнопка находится в высоком состоянии. Когда вы нажимаете на ручку, она переключается из высокого состояния в низкое. Однако, поскольку программа работает в цикле, простая проверка, что кнопка находится в низком состоянии, может приводить к множественным срабатываниям на одно нажатие. Чтобы этого избежать, мы регистрируем нажатие один раз, определяя переход из высокого состояния в низкое, которое означает, что кнопку нажали.
Для этого мы сохраняем предыдущее состояние кнопки и сравниваем его с текущим перед выводом сообщения «Button pressed». Если кнопка сейчас в низком состоянии (нажата), а предыдущее состояние было высоким (не нажата), мы считаем это новым нажатием и выводим сообщение. Затем обновляем предыдущее состояние, что обеспечивает корректное определение будущих переходов.
#![allow(unused)]
fn main() {
let btn_state = btn.is_low().unwrap();
if btn_state && !prev_btn_state {
let _ = serial.write("Button Pressed\r\n".as_bytes());
print_vals = true;
}
prev_btn_state = btn_state;
}
Полный код
#![no_std]
#![no_main]
use core::fmt::Write;
use embedded_hal::{delay::DelayNs, digital::InputPin};
use embedded_hal_0_2::adc::OneShot;
use hal::block::ImageDef;
use heapless::String;
use panic_halt as _;
use rp235x_hal as hal;
use usb_device::{class_prelude::*, prelude::*};
use usbd_serial::SerialPort;
#[link_section = ".start_block"]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
let mut pac = hal::pac::Peripherals::take().unwrap();
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
let sio = hal::Sio::new(pac.SIO);
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// let mut led = pins.gpio25.into_push_pull_output();
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USB,
pac.USB_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut serial = SerialPort::new(&usb_bus);
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.strings(&[StringDescriptors::default()
.manufacturer("implRust")
.product("Ferris")
.serial_number("12345678")])
.unwrap()
.device_class(2) // 2 для CDC, см.: https://www.usb.org/defined-class-codes
.build();
let mut btn = pins.gpio15.into_pull_up_input();
let mut adc = hal::Adc::new(pac.ADC, &mut pac.RESETS);
//Вывод VRX
let mut adc_pin_1 = hal::adc::AdcPin::new(pins.gpio27).unwrap();
// Вывод VRY
let mut adc_pin_0 = hal::adc::AdcPin::new(pins.gpio26).unwrap();
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut prev_btn_state = false;
let mut buff: String<64> = String::new();
let mut print_vals = true;
loop {
let _ = usb_dev.poll(&mut [&mut serial]);
let Ok(vry): Result<u16, _> = adc.read(&mut adc_pin_0) else {
continue;
};
let Ok(vrx): Result<u16, _> = adc.read(&mut adc_pin_1) else {
continue;
};
if vrx.abs_diff(prev_vrx) > 100 {
prev_vrx = vrx;
print_vals = true;
}
if vry.abs_diff(prev_vry) > 100 {
prev_vry = vry;
print_vals = true;
}
let btn_state = btn.is_low().unwrap();
if btn_state && !prev_btn_state {
let _ = serial.write("Button Pressed\r\n".as_bytes());
print_vals = true;
}
prev_btn_state = btn_state;
if print_vals {
print_vals = false;
buff.clear();
write!(buff, "X: {} Y: {}\r\n", vrx, vry).unwrap();
let _ = serial.write(buff.as_bytes());
}
timer.delay_ms(50);
}
}
#[link_section = ".bi_entries"]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"JoyStick USB"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
Клонируйте существующий проект
Можно клонировать проект, который я создал, и перейти в папку joystick-usb.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/joystick-usb/
Как запустить?
Прошивка (запуск кода) на Pico выполняется как обычно. Однако нужно настроить tio для взаимодействия с Pico через последовательный порт (/dev/ttyACM0). Это позволит читать данные с Pico и отправлять их на него.
tio
Убедитесь, что в вашей системе установлен tio. Если нет, его можно установить командой:
apt install tio
Подключение к последовательному порту
Выполните следующую команду, чтобы подключиться к последовательному порту Pico:
tio /dev/ttyACM0
Откроется сеанс терминала для общения с Pico.
Прошивка и запуск кода
Откройте другой терминал, перейдите в папку проекта и прошейте код на Pico как обычно:
cargo run
Если всё настроено правильно, в терминале tio вы увидите сообщение «Connected». Когда вы двигаете джойстик, будут выводиться координаты. Нажатие ручки вниз также покажет сообщение «Button pressed».
Отладка
Отладка embedded Rust на Raspberry Pi Pico 2 с помощью GDB
В этой главе мы рассмотрим, как отлаживать программы на embedded Rust для Raspberry Pi Pico 2 (RP2350) с помощью GDB. Понадобится отладочный зонд, который нужно подключить к Raspberry Pi Pico 2. Прежде чем продолжить, прочитайте главу «Отладочный зонд для Raspberry Pi Pico 2».
Что даёт отладочный зонд
В главе о зонде мы видели, что он избавляет от необходимости каждый раз нажимать кнопку BOOTSEL при прошивке. Но возможности зонда намного шире. Он позволяет использовать GDB прямо с компьютера, так что вы можете отлаживать программу во время её работы на Pico 2.
Что такое GDB?
Если вы никогда не пользовались GDB, вот простое объяснение: GDB — это отладчик командной строки, который позволяет приостанавливать программу, смотреть, что происходит внутри неё, читать память и выполнять код пошагово, чтобы находить проблемы.
Для отладки Pico 2 нужна версия GDB с поддержкой архитектуры ARM. Установить её можно командой:
sudo apt install gdb-multiarch
Включаем GDB в Embed.toml
Раньше мы использовали probe-rs через команду cargo embed. Тот же инструмент может запускать и GDB-сервер, который позволяет подключить GDB к Pico 2 через отладочный зонд.
Для этого нужно отредактировать файл Embed.toml в корне проекта. Это файл конфигурации, который использует команда cargo embed. Добавьте в него следующий раздел, чтобы включить GDB-сервер:
[default.gdb]
# Открывать ли GDB-сервер после прошивки.
enabled = true
Пример проекта
Для этого упражнения я создал простую программу, которая мигает светодиодом, с использованием rp-hal. Embassy здесь не используется, чтобы всё оставалось простым. Файл Embed.toml уже настроен, поэтому можно клонировать проект и сразу начать работать:
git clone https://github.com/ImplFerris/pico-debug
cd pico-debug
Если сейчас выполнить команду cargo embed, GDB-сервер запустится автоматически и будет слушать порт 1337 (порт по умолчанию для probe-rs).
Подключение GDB к удалённому серверу
Чтобы подключить GDB к запущенному GDB-серверу probe-rs, откройте новый терминал и запустите GDB с бинарным файлом нашего проекта:
Примечание: в версии probe-rs 0.30 есть проблема: при попытке подключиться к GDB-серверу соединение сразу закрывается. Я откатился к версии 0.28, как рекомендовано в этом обсуждении проблемы. После отката снова запустите
cargo embed.
gdb-multiarch ./target/thumbv8m.main-none-eabihf/debug/pico-debug
Затем подключитесь к серверу на порту 1337:
(gdb) target remote :1337
На этом этапе GDB подключён к Pico 2 через отладочный зонд, и вы можете использовать точки останова, пошаговое выполнение, просмотр памяти и другие команды отладки.
Возврат к началу программы
Когда вы подключаете GDB к запущенному GDB-серверу, процессор может быть не остановлен в начале вашей программы. Он может находиться где-то глубоко внутри кода.
Чтобы начать отладку с чистого состояния, выполните:
(gdb) monitor reset halt
Эта команда говорит отладочному зонду сбросить Pico 2 и сразу остановить процессор. Программа возвращается в самое начало, туда, где процессор начинает работу после сброса.
Поиск Reset Handler и трассировка вызова main
Когда Pico 2 перезагружается, процессор начинает выполнение с Reset Handler. Чтобы понять, как запускается наша программа, найдём Reset Handler, дизассемблируем его и пройдём по цепочке вызовов до нашей настоящей функции main на Rust.
Когда Pico 2 запускается, процессор не переходит сразу к нашей функции main на Rust. Вместо этого он проходит короткую цепочку функций, которую предоставляет среда выполнения Cortex-M.
В этом разделе мы:
- Найдём адрес, с которого чип начинает выполнение после сброса
- Посмотрим, какую функцию вызывает Reset Handler
- Пройдём по цепочке до нашей настоящей функции
mainна Rust
Чтение записи вектора сброса
Процессор Cortex-M начинает выполнение с чтения таблицы в начале флеш-памяти, которая называется таблицей векторов.
Первые две записи:
- Слово 0 (смещение 0x00): начальное значение указателя стека
- Слово 1 (смещение 0x04): адрес обработчика сброса
На Pico 2 флеш-память начинается с адреса 0x10000000, поэтому:
- Начальное значение указателя стека хранится по адресу 0x10000000
- Адрес обработчика сброса находится по адресу 0x10000004
Что такое Reset Handler?
Reset Handler — это первая функция, которая выполняется при включении или сбросе процессора. Она выполняет инициализацию и в конечном итоге вызывает нашу функцию main.
Прочитаем её в GDB:
(gdb) x/wx 0x10000004
Пример вывода — реальные адреса в ваших сборках могут отличаться, используйте свои, следуя этой главе:
0x10000004 <__RESET_VECTOR>: 0x1000010d
Это значение — адрес, на который процессор переходит после сброса. Младший бит (так называемый «бит Thumb») всегда равен 1, поэтому фактический адрес — 0x1000010c.
Но можно использовать любое из двух значений (0x1000010d или 0x1000010c), GDB с обоими справится.
Также можно использовать программу readelf, чтобы найти адрес точки входа:
arm-none-eabi-readelf -h ./target/thumbv8m.main-none-eabihf/debug/pico-debug
Дизассемблирование Reset Handler
Теперь попросим GDB показать инструкции по этому адресу:
(gdb) disas 0x1000010d
# или
(gdb) disas 0x1000010c
Вы увидите ассемблерные инструкции обработчика сброса. Найдите инструкцию bl (Branch with Link), которая вызывает другую функцию:
...
0x10000140 <+52>: isb sy
0x10000144 <+56>: bl 0x1000031c <main>
0x10000148 <+60>: udf #0
Reset Handler вызывает функцию по адресу 0x1000031c, которую GDB показывает как main, — но это ещё не наша функция main на Rust.
Что это за «main»?
main по адресу 0x1000031c — не функция main нашей программы. Это небольшая обёртка, созданная крейтом cortex-m-rt. Такую обёртку часто называют трамплином (trampoline), потому что позже она передаёт управление настоящей точке входа.
Её демангленное имя обычно такое:
#![allow(unused)]
fn main() {
// NOTE: здесь префикс pico_debug — это имя нашего проекта
pico_debug::__cortex_m_rt_main_trampoline
}
Давайте дизассемблируем её.
Дизассемблирование трамплина
(gdb) disas 0x1000031c
Вывод:
Dump of assembler code for function main:
0x1000031c <+0>: push {r7, lr}
0x1000031e <+2>: mov r7, sp
0x10000320 <+4>: bl 0x10000164 <_ZN10pico_debug18__cortex_m_rt_main17he0b4d19700c84ad2E>
End of assembler dump.
Это очень маленький кусок кода. Всё, что он делает, — вызывает настоящую точку входа на Rust, которая называется:
#![allow(unused)]
fn main() {
pico_debug::__cortex_m_rt_main
}
Включение демангленных имён
Имена функций Rust по умолчанию искажены (mangled) и выглядят нечитаемо.
Включим деманглинг (demangling):
set print asm-demangle on
Теперь попробуйте:
(gdb) disas 0x1000031c
# или
(gdb) disas pico_debug::__cortex_m_rt_main_trampoline
Теперь должны отображаться читаемые имена Rust.
Dump of assembler code for function pico_debug::__cortex_m_rt_main_trampoline:
0x1000031c <+0>: push {r7, lr}
0x1000031e <+2>: mov r7, sp
0x10000320 <+4>: bl 0x10000164 <pico_debug::__cortex_m_rt_main>
End of assembler dump.
Дизассемблирование настоящей функции main на Rust
Теперь посмотрим на нашу функцию main:
disas pico_debug::__cortex_m_rt_main
Вы увидите логику программы: сначала код начальной настройки, затем цикл, который переключает вывод светодиода.
#![allow(unused)]
fn main() {
...
0x100002dc <+376>: bl 0x100079a4 <rp235x_hal::timer::Timer<rp235x_hal::timer::CopyableTimer0>::new_timer0>
0x100002e0 <+380>: bl 0x10000b30 <rp235x_hal::gpio::Pin<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::func::FunctionNull, rp235x_hal::gpio::pull::PullDown>::into_push_pull_output<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::func::FunctionNull, rp235x_hal::gpio::pull::PullDown>>
...
0x100002f8 <+404>: bl 0x10000c48 <rp235x_hal::gpio::eh1::{impl#1}::set_high<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown>>
...
0x10000306 <+418>: bl 0x100006b8 <rp235x_hal::timer::{impl#7}::delay_ms<rp235x_hal::timer::CopyableTimer0>>
...
0x1000030c <+424>: bl 0x10000c38 <rp235x_hal::gpio::eh1::{impl#1}::set_low<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown>>
...
}
Точки останова
Теперь, когда мы проследили путь выполнения от сброса до нашей функции main, поставим точки останова в цикле мигания светодиодом и посмотрим, как меняются регистры GPIO при переключении светодиода.
Разбираем цикл светодиода
Вернёмся к дизассемблированному коду функции __cortex_m_rt_main. Нам нужны инструкции bl. bl расшифровывается как «branch and link» — это инструкции, которые вызывают другие функции. Нас интересуют вызовы функций set_high и set_low.
#![allow(unused)]
fn main() {
...
// Это вызов set_high()
0x100002f8 <+404>: bl 0x10000c48 <rp235x_hal::gpio::eh1::{impl#1}::set_high<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown>>
...
// Это вызов delay_ms()
0x10000306 <+418>: bl 0x100006b8 <rp235x_hal::timer::{impl#7}::delay_ms<rp235x_hal::timer::CopyableTimer0>>
...
// Это вызов set_low()
0x1000030c <+424>: bl 0x10000c38 <rp235x_hal::gpio::eh1::{impl#1}::set_low<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown>>
...
// Это вызов delay_ms()
0x10000314 <+432>: bl 0x100006b8 <rp235x_hal::timer::{impl#7}::delay_ms<rp235x_hal::timer::CopyableTimer0>>
...
}
Посмотрите на адреса слева: 0x100002f8 и 0x1000030c. Это адреса в памяти, где происходит управление светодиодом. По первому адресу вызывается set_high, по второму — set_low. Мы поставим точки останова на эти адреса, чтобы программа приостанавливалась прямо перед выполнением этих инструкций.
Установка точек останова в цикле
Установим первую точку останова. Введите в GDB:
(gdb) break *0x100002f8
Вы увидите: Breakpoint 1 at 0x100002f8: file src/main.rs, line 63.
Это значит, что GDB создал точку останова по этому адресу, и она соответствует строке 63 в нашем файле main.rs.
(gdb) break *0x1000030c
Вы увидите: Breakpoint 2 at 0x1000030c: file src/main.rs, line 65.
Теперь сбросим всё, чтобы начать заново:
monitor reset halt
Эта команда сбрасывает микроконтроллер и останавливает его в начале, так что у нас есть чистая отправная точка.
Обзор регистров GPIO
Прежде чем продолжить, нужно объяснить, на что мы будем смотреть. Когда вы вызываете set_high или set_low в коде на Rust, на самом деле меняются определённые ячейки памяти. Эти ячейки называются регистрами, и они напрямую управляют оборудованием.
В чипе RP2350 есть регистр GPIO_OUT, который управляет всеми выводами GPIO. Его можно найти в даташите RP2350 (глава 3.1.11, страница 55) в разделе SIO (Single-cycle IO).
Вот где этот регистр находится в памяти:
- Периферия SIO начинается с базового адреса
0xd0000000 - Регистр GPIO_OUT находится по смещению
0x010от этого базового адреса - Поэтому полный адрес:
0xd0000000+0x010=0xd0000010
Представьте GPIO_OUT как 32-битное число, где каждый бит управляет одним выводом GPIO. Бит 0 управляет GPIO0, бит 1 — GPIO1 и так далее. Бит 25 управляет GPIO25 — именно к нему подключён встроенный светодиод. Когда бит 25 равен 0, светодиод выключен. Когда бит 25 равен 1, светодиод включён.
Запуск до первой точки останова
Запустим программу, пока она не дойдёт до первой точки останова:
(gdb) continue
Когда точка останова сработает, GDB покажет примерно следующее:
Continuing.
Thread 1 hit Breakpoint 1, 0x100002f8 in pico_debug::__cortex_m_rt_main () at src/main.rs:63
63 led_pin.set_high().unwrap();
Программа остановилась прямо перед вызовом set_high. Это идеальный момент, чтобы посмотреть, что находится в регистре, до того как мы включим светодиод.
Проверка регистров GPIO перед set_high
Посмотрим, что сейчас находится в регистре GPIO_OUT:
(gdb) x/x 0xd0000010
Команда x/x означает «исследовать эту ячейку памяти и показать значение в шестнадцатеричном формате».
Скорее всего, вы получите сообщение об ошибке «Cannot access memory at address 0xd0000010». Это происходит потому, что GDB не знает о регистрах периферии по умолчанию. Нужно сказать GDB, что чтение из этой области памяти разрешено.
Делаем периферию SIO доступной в GDB
Чтобы это исправить, нужно сообщить GDB об области памяти периферии. Согласно даташиту RP2350, область SIO на самом деле простирается от 0xd0000000 до 0xdfffffff. Однако нам не нужно отображать всю область SIO — достаточно той части, которая покрывает регистры, с которыми мы будем работать.
Поэтому можно ввести:
(gdb) mem 0xD0000000 0xD0001000 rw nocache
Здесь мы отображаем примерно 4 КБ области SIO (от 0xD0000000 до 0xD0001000), что более чем достаточно, чтобы покрыть GPIO_OUT и другие регистры SIO, которые мы будем рассматривать при отладке.
Если хотите отобразить меньше и быть точнее, можно использовать:
(gdb) mem 0xD0000000 0xD0000100 rw nocache
Это дает всего 256 байт, которых хватает для всех основных регистров SIO, которые нам нужны, включая GPIO_OUT по адресу 0xD0000010. Главное — отобразить ровно столько памяти, сколько нужно для чтения нужных регистров, без отображения всей области SIO.
Теперь попробуйте снова прочитать GPIO_OUT:
(gdb) x/x 0xd0000010
0xd0000010: 0x00000000
Мы получили значение 0x00000000. Это значит, что все 32 бита равны нулю, то есть все выводы GPIO сейчас выключены. Наш светодиод выключен.
Продолжение до второй точки останова
Теперь продолжим выполнение и посмотрим, что происходит после выполнения set_high:
(gdb) continue
Continuing.
Thread 1 received signal SIGINT, Interrupt.
rp235x_hal::gpio::eh1::{impl#1}::set_high<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown> (self=0x2007ffbd)
at /home/implrust/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rp235x-hal-0.3.1/src/gpio/mod.rs:1549
1549 fn set_high(&mut self) -> Result<(), Self::Error> {
Мы были прерваны внутри функции set_high. Продолжим снова:
(gdb) continue
Continuing.
Thread 1 hit Breakpoint 2, pico_debug::__cortex_m_rt_main () at src/main.rs:65
65 led_pin.set_low().unwrap();
Теперь программа прошла через set_high и задержку и остановилась на второй точке останова, в строке 65, прямо перед вызовом set_low. Проверим GPIO_OUT ещё раз:
(gdb) x/x 0xd0000010
0xd0000010: 0x02000000
Значение изменилось с 0x00000000 на 0x02000000. К этому моменту вы также должны увидеть, что светодиод включился.
Разберём, что означает 0x02000000. В двоичном виде это 00000010 00000000 00000000 00000000. Если считать справа, начиная с 0, бит 25 теперь равен 1. Именно это сделал set_high: он установил бит 25 регистра GPIO_OUT, что включило GPIO25 и зажгло светодиод.
Продолжаем, чтобы увидеть set_low в действии
Теперь продолжим ещё раз, чтобы увидеть, что происходит при выполнении set_low. Но сначала отметим, что светодиод сейчас включён, а GPIO_OUT показывает 0x02000000 с битом 25, установленным в 1.
Продолжим:
(gdb) continue
Continuing.
Thread 1 received signal SIGINT, Interrupt.
rp235x_hal::gpio::eh1::{impl#1}::set_low<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown> (self=0x2007ffbd)
at /home/implrust/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rp235x-hal-0.3.1/src/gpio/mod.rs:1544
1544 fn set_low(&mut self) -> Result<(), Self::Error> {
Мы были прерваны внутри функции set_low. Продолжим снова:
(gdb) continue
Continuing.
Thread 1 hit Breakpoint 1, 0x100002f8 in pico_debug::__cortex_m_rt_main () at src/main.rs:63
63 led_pin.set_high().unwrap();
Программа прошла через set_low и задержку и вернулась к первой точке останова в строке 63. Проверим GPIO_OUT ещё раз:
(gdb) x/x 0xd0000010
0xd0000010: 0x00000000
Значение вернулось к 0x00000000. Бит 25 теперь равен 0, а значит, GPIO25 выключен и светодиод не горит. На вашей плате светодиод должен погаснуть.
Что мы узнали
Из наблюдений:
- Когда мы вызываем
led_pin.set_high(), бит 25 регистра GPIO_OUT меняется с 0 на 1 (0x00000000→0x02000000) - Когда мы вызываем
led_pin.set_low(), бит 25 меняется с 1 на 0 (0x02000000→0x00000000)
Атомарные регистры GPIO
Ранее мы смотрели только на регистр GPIO_OUT. В нём хранится полное 32-битное выходное значение для всех выводов GPIO. Но на практике библиотека rp-hal не пишет в GPIO_OUT напрямую. Вместо этого она использует атомарные вспомогательные регистры: GPIO_OUT_SET, GPIO_OUT_CLR и GPIO_OUT_XOR.
Эти атомарные регистры — регистры только для записи внутри блока SIO, которые сами по себе не хранят значений. Когда вы записываете в них, биты, которые вы установили, используются для изменения базового регистра GPIO_OUT:
- GPIO_OUT_SET устанавливает указанные биты в 1. Этот регистр находится по адресу
0xd0000018согласно даташиту. - GPIO_OUT_CLR сбрасывает указанные биты в 0. Этот регистр находится по адресу
0xd0000020согласно даташиту. - GPIO_OUT_XOR инвертирует указанные биты.
Изменяются только те биты, которые вы записали как 1. Все остальные биты остаются нетронутыми. Это делает код безопаснее и предотвращает случайные изменения других выводов.
Например, чтобы управлять GPIO25:
-
Чтобы установить GPIO25 в высокий уровень, нужно записать 1 в бит 25 регистра GPIO_OUT_SET. Значение GPIO_OUT_SET будет равно
0b00000010_00000000_00000000_00000000(или в шестнадцатеричном виде0x02000000). -
Чтобы установить GPIO25 в низкий уровень, нужно записать 1 в бит 25 регистра GPIO_OUT_CLR. Значение GPIO_OUT_CLR будет равно
0b00000010_00000000_00000000_00000000(или в шестнадцатеричном виде0x02000000).
Эти операции меняют только бит 25 в GPIO_OUT, оставляя все остальные биты без изменений.
Внутри rp-hal: установка вывода в высокий или низкий уровень
Если проследить, что делают set_high() и set_low() внутри rp-hal, видно, что они никогда не пишут в GPIO_OUT напрямую. Вместо этого они пишут в атомарные регистры GPIO_OUT_SET и GPIO_OUT_CLR.
Код внутри rp-hal выглядит так:
#![allow(unused)]
fn main() {
#[inline]
pub(crate) fn _set_low(&mut self) {
let mask = self.id.mask();
self.id.sio_out_clr().write(|w| unsafe { w.bits(mask) });
}
#[inline]
pub(crate) fn _set_high(&mut self) {
let mask = self.id.mask();
self.id.sio_out_set().write(|w| unsafe { w.bits(mask) });
}
}
Когда выполняются эти функции write(), в итоге вызывается core::ptr::write_volatile(). write_volatile выполняет некоторые предварительные проверки, а затем внутренняя функция компилятора intrinsics::volatile_store выполняет финальную запись по адресу MMIO. Именно эта volatile-запись и меняет реальный аппаратный регистр.
Теперь посмотрим, как это выглядит при пошаговом выполнении в GDB.
Точка останова на write_volatile
Есть много способов добраться до write_volatile. Один из них — пройти по set_low() или set_high() пошагово командами stepi и nexti в GDB. Но мы пойдём более коротким путём: поставим точку останова прямо на core::ptr::write_volatile.
Нужно учесть одну вещь. Если поставить эту точку останова сразу после сброса (например, сразу после monitor reset halt), GDB остановится много раз. Это происходит потому, что write_volatile используется во многих местах при запуске. Поэтому мы не будем её ставить в самом начале.
Вместо этого выполните шаги из предыдущей главы. Когда программа остановится на первой точке останова в вашем коде, например так:
Continuing.
Thread 1 hit Breakpoint 1, 0x100002f8 in pico_debug::__cortex_m_rt_main () at src/main.rs:63
63 led_pin.set_high().unwrap();
Tip
Проверить точки останова можно командой
info break. Удалить точку останова можно командойdelete <номер>.
Теперь, когда мы прошли стартовый код, поставим точку останова на write_volatile:
(gdb) break core::ptr::write_volatile
Затем продолжим выполнение:
(gdb) continue
Вы должны увидеть примерно такой вывод:
Thread 1 received signal SIGINT, Interrupt.
rp235x_hal::gpio::eh1::{impl#1}::set_high<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown> (self=0x2007ffbd)
at /home/implrust/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rp235x-hal-0.3.1/src/gpio/mod.rs:1549
1549 fn set_high(&mut self) -> Result<(), Self::Error> {
Продолжаем снова:
(gdb) continue
Теперь мы остановились внутри функции write_volatile:
Thread 1 hit Breakpoint 3, core::ptr::write_volatile<u32> (dst=0xd0000018, src=33554432)
at /home/implrust/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ub_checks.rs:76
76 if ::core::ub_checks::$kind() {
Обратили внимание на аргументы функции? Адрес назначения dst равен 0xd0000018 — это адрес регистра GPIO_OUT_SET. Значение src равно 33554432. В шестнадцатеричном виде это 0x02000000. В двоичном виде это 0b00000010_00000000_00000000_00000000. Это точная битовая маска для GPIO25.
Посмотрим на дизассемблирование функции, чтобы увидеть, что происходит на уровне ассемблера:
(gdb) disas
Dump of assembler code for function _ZN4core3ptr14write_volatile17hc4948e781ca030f6E:
0x10008084 <+0>: push {r7, lr}
0x10008086 <+2>: mov r7, sp
0x10008088 <+4>: sub sp, #24
0x1000808a <+6>: str r2, [sp, #4]
0x1000808c <+8>: str r1, [sp, #8]
0x1000808e <+10>: str r0, [sp, #12]
0x10008090 <+12>: str r0, [sp, #16]
0x10008092 <+14>: str r1, [sp, #20]
=> 0x10008094 <+16>: b.n 0x10008096 <_ZN4core3ptr14write_volatile17hc4948e781ca030f6E+18>
0x10008096 <+18>: ldr r2, [sp, #4]
0x10008098 <+20>: ldr r0, [sp, #12]
0x1000809a <+22>: movs r1, #4
0x1000809c <+24>: bl 0x100080ac <_ZN4core3ptr14write_volatile18precondition_check17h8beabfccc7ba3236E>
0x100080a0 <+28>: b.n 0x100080a2 <_ZN4core3ptr14write_volatile17hc4948e781ca030f6E+30>
0x100080a2 <+30>: ldr r0, [sp, #8]
0x100080a4 <+32>: ldr r1, [sp, #12]
0x100080a6 <+34>: str r0, [r1, #0]
0x100080a8 <+36>: add sp, #24
0x100080aa <+38>: pop {r7, pc}
End of assembler dump.
Ключевая инструкция находится по адресу 0x100080a6. Это строка, которая фактически записывает в аппаратный регистр. В этот момент r1 содержит адрес GPIO_OUT_SET, а r0 — значение, которое будет записано.
Посмотрим поближе. Поставим ещё одну точку останова прямо на эту инструкцию:
(gdb) break *0x100080a6
Затем продолжим:
(gdb) continue
Если вас прервали, продолжите снова:
Thread 1 received signal SIGINT, Interrupt.
core::ptr::write_volatile<u32> (dst=0xd0000018, src=33554432)
at /home/implrust/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ub_checks.rs:77
77 precondition_check($($arg,)*);
Продолжаем снова:
(gdb) c
Continuing.
Thread 1 hit Breakpoint 4, 0x100080a6 in core::ptr::write_volatile<u32> (dst=0xd0000018, src=33554432)
at /home/implrust/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:2201
2201 intrinsics::volatile_store(dst, src);
GDB остановится прямо на инструкции записи. Если снова выполнить disas, вы увидите стрелку, указывающую на эту строку:
...
0x100080a4 <+32>: ldr r1, [sp, #12]
=> 0x100080a6 <+34>: str r0, [r1, #0]
0x100080a8 <+36>: add sp, #24
Перед выполнением этой инструкции записи проверим значения в регистрах r0 и r1:
(gdb) i r $r0
r0 0x2000000 33554432
(gdb) i r $r1
r1 0xd0000018 3489660952
Проверим также текущее значение регистра GPIO_OUT:
(gdb) x/x 0xd0000010
0xd0000010: 0x00000000
Сейчас там одни нули. На этом этапе светодиод ещё выключен, потому что мы ещё не выполнили инструкцию записи.
Теперь сделаем один шаг вперёд:
(gdb) nexti
# или
(gdb) ni
После выполнения этой команды вы должны увидеть, как загорелся светодиод. Теперь снова проверим регистр GPIO_OUT:
(gdb) x/x 0xd0000010
0xd0000010: 0x02000000
Регистр теперь показывает 0x02000000 — это в точности битовая маска для GPIO25. Это подтверждает, что наша операция записи успешно установила вывод светодиода в высокий уровень.
Попробуйте сами
Теперь пришло время применить полученные знания на практике. Дайте программе работать, пока она не дойдёт до точки останова set_low. Затем продолжите выполнение, пока не дойдёте до функции write_volatile.
На этот раз всё будет немного иначе. Адрес назначения будет 0xd0000020 — это регистр GPIO_OUT_CLR. Как следует из названия, этот регистр используется для сброса выводов GPIO, а не для их установки.
Выполните пошаговое выполнение, как и раньше. Когда вы выполните инструкцию str, светодиод погаснет. Если после этого посмотреть регистр GPIO_OUT, он снова будет содержать одни нули. Это подтверждает, что бит для GPIO25 сброшен, и светодиод выключен.
Сторожевой таймер
Эта книга изначально была написана с использованием rp-hal. Позже я переработал её так, чтобы в основном использовать Embassy. При работе с rp-hal есть шаг, на котором мы явно настраиваем сторожевой таймер (watchdog). Чтобы объяснить, зачем нужна эта строка и что она на самом деле делает, в этой главе вводится понятие сторожевого таймера.
В январе 1994 года космический аппарат Clementine успешно картографировал Луну. Во время полёта к астероиду Geographos 7 мая 1994 года в процессоре Honeywell 1750 произошло исключение из-за операции с плавающей запятой. Этот процессор обрабатывал телеметрию и несколько других критически важных функций космического аппарата.
В Honeywell 1750 был встроенный сторожевой таймер, но им не воспользовались. После этого отказа команда разработчиков публично пожалела о таком решении. Также отмечалось, что даже стандартный сторожевой таймер, возможно, не был бы достаточно надёжен, чтобы обнаружить именно этот режим отказа.
Итак, что же такое сторожевой таймер и зачем он нужен?
Возможно, у вас уже есть общее представление.
Что такое сторожевой таймер?
Сторожевой таймер (WDT, watchdog timer) — аппаратный компонент, который часто встречается во встраиваемых системах. Его главная задача — обнаружить сбой программного обеспечения и автоматически перезагрузить процессор, если что-то пошло не так. Так система может восстановиться без вмешательства человека.
Сторожевые таймеры особенно важны в системах, которые должны работать без присмотра в течение длительного времени.
Как это работает?
Сторожевой таймер работает как таймер обратного отсчёта. Он начинает отсчёт от заданного значения к нулю. Программа должна периодически сбрасывать этот таймер, пока он не дошёл до нуля.
Это действие обычно называют «кормлением сторожевого таймера» (feeding the watchdog). Также можно встретить выражение «пнуть собаку» (kicking the dog), но этот термин широко используется, а я лично его избегаю.
Если программа не сбросила таймер вовремя, например из-за бесконечного цикла, взаимной блокировки (deadlock) или зависания системы, сторожевой таймер решает, что система работает некорректно, и инициирует перезагрузку процессора. После перезагрузки система может запуститься заново из известного хорошего состояния.
Кормление собаки:
Сторожевой таймер можно представить как собаку, которую нужно кормить через равные промежутки времени. Со временем собака голодает всё сильнее. Если её не покормить вовремя, она отреагирует. Во встраиваемых системах эта реакция — аппаратная перезагрузка.
Чтобы система работала нормально, программа должна регулярно «кормить» сторожевой таймер, сбрасывая его счётчик.
Код
В следующем фрагменте мы настраиваем драйвер сторожевого таймера. Это необходимо, потому что код инициализации тактирования зависит от доступности сторожевого таймера.
#![allow(unused)]
fn main() {
// Настраиваем драйвер сторожевого таймера — он нужен для настройки тактирования
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
}
Ссылки
- Great Watchdog Timers For Embedded Systems, Jack Ganssle
- Born to fail
- A Guide to Watchdog Timers for Embedded Systems
- Proper Watchdog Timer Use
Подборка проектов на Rust для Raspberry Pi Pico 2
Здесь собраны интересные проекты, которые я нашёл в интернете и которые связаны с Pico 2 и Rust. Если у вас есть интересный проект, которым хотите поделиться, пришлите PR :)
- Pico Rex: игра «Динозаврик» на Rust для Raspberry Pi Pico 2 (RP2350) с OLED-дисплеем, написанная на фреймворке Embassy.
- GB-RP2350 — эмулятор Game Boy для Pi Pico 2 на Rust: также можно найти пост автора на Reddit.
- simple-robot: очень простой робот с ультразвуковым датчиком расстояния HC-SR04, который умеет двигаться как автономно, так и под дистанционным управлением (Rust, Embassy).
Полезные ресурсы
В этом разделе собраны ресурсы, которые я нахожу полезными.
Статьи в блогах
Учебные материалы
Другие ресурсы
- Подборка ресурсов по Embedded Rust
- Writing an OS in Rust: здесь объясняется много полезных концепций (на английском языке)
- Embassy Book
















