Discovery
Descubra o mundo dos microcontroladores através do Rust!
Este livro é um curso introdutório sobre sistemas embarcados baseados em microcontroladores que usa Rust como linguagem de ensino ao invés do C/C++ usual.
Escopo
Os seguintes tópicos serão abordados (eventualmente, espero):
- Como escrever, construir, gravar e depurar um programa (Rust) "embarcado".
- Funcionalidades ("periféricos") comumente encontradas em microcontroladores: entrada e saída digital, Modulação por Largura de Pulso (PWM), Conversores Analógicos-Digitais (ADC), protocolos de comunicação comuns como Serial, I2C e SPI, etc.
- Conceitos de multitarefa: multitarefa cooperativa vs preemptiva, interrupções, escalonadores, etc.
- Conceitos de sistemas de controle: sensores, calibração, filtros digitais, atuadores, controle de malha aberta, controle de malha fechada, etc.
Abordagem
- Amigável para iniciantes. Nenhuma experiência anterior com microcontroladores ou sistemas embarcados é necessária.
- Mão na massa. Muitos exercícios para colocar a teoria em prática. Você fará a maior parte do trabalho aqui.
- Centrado na ferramenta. Faremos bastante uso de ferramentas para facilitar o desenvolvimento. A depuração "real", com GDB, e o log serão introduzidos logo no início. Usar LEDs como um mecanismo de depuração não tem lugar aqui.
Fora do escopo
O que está fora do escopo deste livro:
- Ensinar Rust. Já existe bastante material sobre esse assunto. Vamos nos concentrar em microcontroladores e sistemas embarcados.
- Ser um texto abrangente sobre teoria de circuitos elétricos ou eletrônica. Abordaremos apenas o mínimo necessário para entender como alguns dispositivos funcionam.
- Cobrir detalhes como linker scripts e o processo de boot. Por exemplo, usaremos as ferramentas existentes para ajudar a inserir seu código em sua placa, mas não entraremos em detalhes sobre como essas ferramentas funcionam.
Também não pretendo portar este material para outras placas de desenvolvimento; este livro fará uso exclusivo da placa de desenvolvimento micro:bit.
Relatando problemas
A fonte original deste livro está neste repositório. Se você encontrar algum problema com o código, informe-o no rastreador de issues.
Já o repositório da tradução para português está aqui. Caso você encontre algum erro de digitação ou qualquer outro problema relacionado a ela, informe-o na seção de issues.
Outros materiais sobre Rust para sistemas embarcados:
Este livro de Discovery é apenas um de vários materiais sobre Rust para sistemas embarcados fornecidos pelo Embedded Working Group. A seleção completa pode ser encontrada em The Embedded Rust Bookshelf. Isso inclui a lista de Perguntas Frequentes.
Contexto
O que é um microcontrolador?
Um microcontrolador é um sistema em um chip. Enquanto um computador é composto por vários componentes discretos, como um processador, RAM, armazenamento, uma porta Ethernet, etc., um microcontrolador tem todos esses tipos de componentes incorporados em um único "chip" ou pacote. Isso torna possível construir sistemas com menos peças.
O que você pode fazer com um microcontrolador?
Muitas coisas! Os microcontroladores são a parte central do que são conhecidos como "sistemas embarcados". Os sistemas embarcados estão em toda parte, mas geralmente você não os percebe. Eles controlam as máquinas que lavam suas roupas, imprimem seus documentos e preparam sua comida. Os sistemas embarcados mantêm os prédios onde você vive e trabalha em uma temperatura confortável e controlam os componentes que fazem os veículos nos quais você viaja pararem e seguirem em frente.
A maioria dos sistemas embarcados opera sem intervenção do usuário. Mesmo que eles tenham uma interface de usuário, como uma máquina de lavar roupa, a maior parte de sua operação é feita por conta própria.
Os sistemas embarcados são frequentemente usados para controlar um processo físico. Para que isso seja possível, eles possuem um ou mais dispositivos que informam sobre o estado do mundo ("sensores") e um ou mais dispositivos que permitem fazer alterações ("atuadores"). Por exemplo, um sistema de controle de clima de um prédio pode ter:
- Sensores que medem temperatura e umidade em vários locais.
- Atuadores que controlam a velocidade dos ventiladores.
- Atuadores que fazem com que o calor seja adicionado ou removido do prédio.
Quando devo usar um microcontrolador?
Muitos dos sistemas embarcados listados acima poderiam ser implementados com um computador executando Linux (por exemplo, um "Raspberry Pi"). Por que usar um microcontrolador em vez disso? Parece mais difícil para desenvolver um programa.
Alguns motivos podem incluir:
Custo. Um microcontrolador é muito mais barato do que um computador de propósito geral. Além de ser mais barato, o microcontrolador também requer menos componentes elétricos externos para funcionar. Isso torna as Placas de Circuito Impresso (PCB) menores e mais baratas de serem projetadas e fabricadas.
Consumo de energia. A maioria dos microcontroladores consome uma fração da energia de um processador completo. Para aplicativos que funcionam com baterias, isso faz uma enorme diferença.
Responsividade. Para cumprir seu propósito, alguns sistemas embarcados devem sempre reagir dentro de um intervalo de tempo limitado (por exemplo, o sistema de freio "antibloqueio" de um carro). Se o sistema perder esse tipo de prazo, uma falha catastrófica pode ocorrer. Esse tipo de prazo é chamado de requisito de "tempo real rígido". Um sistema embarcado que está vinculado a esse prazo é chamado de "sistema de tempo real rígido". Um computador de propósito geral e um sistema operacional geralmente possuem muitos componentes de software que compartilham os recursos de processamento do computador. Isso torna mais difícil garantir a execução de um programa dentro de restrições de tempo rigorosas.
Confiabilidade. Em sistemas com menos componentes (tanto de hardware quanto de software), há menos coisas que podem dar errado!
Quando não devo usar um microcontrolador?
Onde computações pesadas estão envolvidas. Para manter seu consumo de energia baixo, os microcontroladores têm recursos computacionais muito limitados. Por exemplo, alguns microcontroladores nem mesmo possuem suporte de hardware para operações de ponto flutuante. Nesses dispositivos, realizar uma simples adição de números de precisão única pode levar centenas de ciclos de CPU.
Por que usar Rust e não C?
Espero não precisar convencê-lo aqui, pois você provavelmente está familiarizado com as diferenças entre as linguagens Rust e C. Um ponto que eu gostaria de mencionar é o gerenciamento de pacotes. C carece de uma solução oficial e amplamente aceita de gerenciamento de pacotes, enquanto Rust possui o Cargo. Isso facilita muito o desenvolvimento. Além disso, na minha opinião, o gerenciamento fácil de pacotes incentiva a reutilização de código porque as bibliotecas podem ser facilmente integradas a um aplicativo, o que também é algo positivo, pois as bibliotecas recebem mais "testes de batalha".
Por que não devo usar Rust?
Ou por que eu deveria preferir C em vez de Rust?
O ecossistema do C é muito mais maduro. Já existem soluções prontas para vários problemas. Se você precisa controlar um processo sensível ao tempo, pode adquirir um dos Sistemas Operacionais de Tempo Real (RTOS) comerciais existentes e resolver seu problema. Ainda não existem Sistemas Operacionais de Tempo Real em Rust que sejam comerciais e estejam em nível de produção, então você teria que criar um por conta própria ou experimentar um dos que estão em desenvolvimento. Você pode encontrar uma lista deles no repositório Awesome Embedded Rust.
Requisitos de hardware/conhecimento
O principal requisito de conhecimento para ler este livro é saber um pouco de
Rust. É difícil para mim quantificar um pouco, mas pelo menos posso dizer que
você não precisa entender totalmente os genéricos, mas precisa saber como usar
closures. Você também precisa estar familiarizado com os idiomas da
edição de 2018, em particular com o fato de que extern crate
não é
necessário nela.
Além disso, para acompanhar este material, você precisará do seguinte hardware:
- Uma placa micro:bit v2, alternativamente uma placa micro:bit v1.5. O livro se referirá à v1.5 apenas como v1.
(Você pode comprar essa placa em diversos fornecedores de eletrônicos)
NOTA Esta é uma imagem de uma placa micro:bit v2, a parte da frente da v1 parece um pouco diferente.
- Um cabo micro-B USB, necessário para fazer a placa micro:bit funcionar. Certifique-se de que o cabo suporta transferência de dados, pois alguns cabos suportam apenas o carregamento de dispositivos.
NOTA Talvez você já tenha um cabo como este, pois alguns kits de micro:bit são enviados com cabos desse tipo. Alguns cabos USB usados para carregar dispositivos móveis também podem funcionar, se forem micro-B e tiverem a capacidade de transmitir dados.
Perguntas frequentes: Espere, por que preciso desse hardware específico?
Isso torna minha vida e a sua muito mais fácil.
O material é muito, muito mais acessível se não tivermos que nos preocupar com diferenças de hardware. Confie em mim neste caso.
Perguntas frequentes: Posso seguir este material com uma placa de desenvolvimento diferente?
Talvez? Isso depende principalmente de duas coisas: sua experiência anterior com
microcontroladores e/ou se já existe um crate de alto nível, como o
nrf52-hal
, para sua placa de desenvolvimento em algum lugar. Você pode
consultar a lista Awesome Embedded Rust HAL para o seu microcontrolador, caso
pretenda usar um diferente.
Com uma placa de desenvolvimento diferente, este texto perderia grande parte, senão toda, de sua facilidade para iniciantes e sua capacidade de ser "fácil de seguir", em minha opinião.
Se você tiver uma placa de desenvolvimento diferente e não se considerar um iniciante total, é melhor começar com o template de projeto para início rápido.
Configurando o ambiente de desenvolvimento
Lidar com microcontroladores envolve o uso de várias ferramentas, pois estaremos lidando com uma arquitetura diferente da arquitetura do seu computador e teremos que executar e depurar programas em um dispositivo "remoto".
Documentação
No entanto, as ferramentas não são tudo. Sem documentação, é praticamente impossível trabalhar com microcontroladores.
Vamos fazer referência a todos esses documentos ao longo deste livro:
Ferramentas
Vamos utilizar todas as ferramentas listadas abaixo. Onde uma versão mínima não for especificada, qualquer versão recente deve funcionar, mas listamos a versão que testamos.
- Rust 1.57.0 ou um toolchain mais recente.
gdb-multiarch
. Versão testada: 10.2. Outras versões provavelmente também funcionarão bem, mas se a sua distribuição/plataforma não tiver ogdb-multiarch
disponível,arm-none-eabi-gdb
também servirá. Além disso, alguns binários normais dogdb
são construídos com recursos multiarquitetura, você pode encontrar mais informações sobre isso nos subcapítulos.
cargo-binutils
. Versão 0.3.3 ou mais recente.
cargo-embed
. Version 0.18.0 ou mais recente.
minicom
para Linux e macOS. Versão testada: 2.7.1. Outras versões provavelmente também funcionarão.
PuTTY
para Windows.
Em seguida, siga as instruções de instalação independentes do sistema operacional para algumas das ferramentas.
rustc
& Cargo
Instale o rustup seguindo as instruções em https://rustup.rs.
Se você já tiver o rustup instalado, verifique se você está no canal estável e
se sua toolchain estável está atualizada. rustc -V
deve retornar uma data mais
recente do que a mostrada abaixo:
$ rustc -V
rustc 1.53.0 (53cb7b09b 2021-06-17)
cargo-binutils
$ rustup component add llvm-tools-preview
$ cargo install cargo-binutils --vers 0.3.3
$ cargo size --version
cargo-size 0.3.3
cargo-embed
Para instalar o cargo-embed, primeiro instale seus pré-requisitos. Em seguida, instale-o com cargo:
$ cargo install cargo-embed --vers 0.11.0
$ cargo embed --version
cargo-embed 0.11.0
git commit: crates.io
Este repositório
Como este livro também contém algumas pequenas bases de código Rust usadas em vários capítulos, você também terá que baixar seu código-fonte. Você pode fazer isso de uma das seguintes maneiras:
- Visite o repositório, clique no botão verde "Code" e depois no botão "Download Zip"
- Clone-o usando git (se você conhece o git, provavelmente já o tem instalado) do mesmo repositório mencionado na abordagem do zip
Instruções específicas do sistema operacional
Agora siga as instruções específicas para o sistema operacional que você está usando:
Linux
Aqui estão os comandos de instalação para algumas distribuições Linux.
Ubuntu 20.04 ou mais recente / Debian 10 ou mais recente
NOTA
gdb-multiarch
é o comando GDB que você usará para depurar seus programas ARM Cortex-M.
$ sudo apt-get install \
gdb-multiarch \
minicom
Fedora 32 ou mais recente
NOTA
gdb
é o comando GDB que você usará para depurar seus programas ARM Cortex-M.
$ sudo dnf install \
gdb \
minicom
Arch Linux
NOTA
arm-none-eabi-gdb
é o comando GDB que você usará para depurar seus programas ARM Cortex-M.
$ sudo pacman -S \
arm-none-eabi-gdb \
minicom
Outras distribuições
NOTA
arm-none-eabi-gdb
é o comando GDB que você usará para depurar seus programas ARM Cortex-M.
Para distribuições que não possuem pacotes para o
toolchain pré-compilado do ARM,
faça o download do arquivo "Linux 64 bits" e coloque o diretório bin
no seu
caminho. Aqui está uma maneira de fazer isso:
$ mkdir -p ~/local && cd ~/local
$ tar xjf /path/to/downloaded/file/gcc-arm-none-eabi-9-2020-q2-update-x86_64-linux.tar.bz2
Em seguida, use o editor de sua escolha para adicionar ao seu PATH
no arquivo
de inicialização do shell apropriado (por exemplo, ~/.zshrc
ou ~/.bashrc
):
PATH=$PATH:$HOME/local/gcc-arm-none-eabi-9-2020-q2-update/bin
Regras udev
Essas regras permitem que você use dispositivos USB como o micro:bit sem
privilégios de root, ou seja, sem usar o comando sudo
.
Crie este arquivo em /etc/udev/rules.d
com o conteúdo mostrado abaixo.
$ cat /etc/udev/rules.d/99-microbit.rules
# CMSIS-DAP for microbit
SUBSYSTEM=="usb", ATTR{idVendor}=="0d28", ATTR{idProduct}=="0204", MODE:="666"
Então, recarregue as regras do udev com o seguinte comando:
$ sudo udevadm control --reload-rules
Se você tiver alguma placa conectada ao seu computador, desconecte-as e, em seguida, conecte-as novamente.
Agora, vá para a próxima seção.
Windows
arm-none-eabi-gdb
A ARM fornece instaladores .exe
para o Windows. Baixe um daqui e siga
as instruções. Logo antes de finalizar o processo de instalação,
marque/selecione a opção "Add path to environment variable". Em seguida,
verifique se as ferramentas estão no seu %PATH%
:
$ arm-none-eabi-gcc -v
(..)
gcc version 5.4.1 20160919 (release) (..)
PuTTY
Faça o download do putty.exe
mais recente deste site e coloque-o em algum
lugar no seu %PATH%
.
Agora, vá para a próxima seção.
macOS
Todas as ferramentas podem ser instaladas usando o Homebrew:
$ # ARM GCC toolchain
$ brew install --cask gcc-arm-embedded
$ # Minicom
$ brew install minicom
Isso é tudo! Vá para a próxima seção.
Verifique a instalação
Vamos verificar se todas as ferramentas foram instaladas corretamente.
Somente Linux
Verifique as permissões
Conecte o micro:bit ao seu computador usando um cabo USB.
O micro:bit agora deve aparecer como um dispositivo USB (arquivo) em
/dev/bus/usb
. Vamos descobrir como ele foi enumerado:
$ lsusb | grep -i "NXP ARM mbed"
Bus 001 Device 065: ID 0d28:0204 NXP ARM mbed
$ # ^^^ ^^^
No meu caso, o micro:bit foi conectado ao barramento #1 e foi enumerado como o
dispositivo #65. Isso significa que o arquivo /dev/bus/usb/001/065
é o
micro:bit. Vamos verificar suas permissões:
$ ls -l /dev/bus/usb/001/065
crw-rw-rw-. 1 root root 189, 64 Sep 5 14:27 /dev/bus/usb/001/065
As permissões devem ser crw-rw-rw-
. Se não forem... verifique suas
regras do udev e tente recarregá-las com:
$ sudo udevadm control --reload-rules
Todos
Verificando o cargo-embed
Primeiro, conecte o micro:bit ao seu computador usando um cabo USB.
Pelo menos um LED laranja ao lado da porta USB do micro:bit deve acender. Além disso, se você nunca tiver gravado outro programa no seu micro:bit, o programa padrão que vem com o micro:bit deve começar a piscar os LEDs vermelhos em sua parte traseira, você pode ignorá-los.
Em seguida, você terá que modificar o arquivo Embed.toml
no diretório
src/03-setup
do código-fonte do livro. Na seção default.general
, você
encontrará duas variantes de chip comentadas:
[default.general]
# chip = "nrf52833_xxAA" # para micro:bit V2, descomente esta linha
# chip = "nrf51822_xxAA" # para micro:bit V1, descomente esta linha
Se você estiver trabalhando com a placa micro:bit v2, descomente a primeira linha, para a v1 descomente a segunda.
Em seguida, execute um destes comandos:
$ # certifique-se de estar no diretório src/03-setup do código-fonte do livro
$ # Se você estiver trabalhando com micro:bit v2
$ rustup target add thumbv7em-none-eabihf
$ cargo embed --target thumbv7em-none-eabihf
$ # Se você estiver trabalhando com micro:bit v1
$ rustup target add thumbv6m-none-eabi
$ cargo embed --target thumbv6m-none-eabi
Se tudo funcionar corretamente, o cargo-embed deverá primeiro compilar o pequeno programa de exemplo neste diretório, então gravá-lo e, finalmente, abrirá uma interface de usuário baseada em texto que imprime "Hello World".
(Se não funcionar, consulte as instruções em Solucionando problemas gerais.)
Essa saída está vindo do pequeno programa Rust que você acabou de gravar no seu micro:bit. Tudo está funcionando corretamente e você pode continuar com os próximos capítulos!
Conheça seu hardware
Vamos nos familiarizar com o hardware com o qual estaremos trabalhando.
micro:bit
Aqui estão alguns dos muitos componentes na placa:
- Um microcontrolador.
- Vários LEDs, principalmente a matriz de LEDs na parte de trás.
- Dois botões de usuário, além de um botão de reset (aquele ao lado da porta USB).
- Uma porta USB.
- Um sensor que é tanto magnetômetro quanto acelerômetro.
Dentre esses componentes, o mais importante é o microcontrolador (às vezes abreviado como "MCU" para "unidade de microcontrolador"), que é o maior dos dois quadrados pretos na lateral da placa, próximo à porta USB. O MCU é o que executa o seu código. Às vezes você pode ler sobre "programar uma placa", quando na verdade estamos programando o MCU que está instalado na placa.
Se você estiver interessado em uma descrição mais detalhada da placa, pode visitar o site do micro:bit.
Como o MCU é tão importante, vamos dar uma olhada mais de perto no que está presente em nossa placa. Observe que apenas uma das duas seções a seguir se aplica à sua placa, dependendo se você está trabalhando com uma micro:bit v2 ou v1.
Nordic nRF52833 (o "nRF52", micro:bit v2)
Nosso MCU possui 73 pequenos pinos metálicos situados logo abaixo dele (é um chip chamado aQFN73). Esses pinos estão conectados a trilhas, as pequenas "estradas" que funcionam como fios conectando os componentes na placa. O MCU pode alterar dinamicamente as propriedades elétricas dos pinos. Isso funciona de forma semelhante a um interruptor de luz, alterando como a corrente elétrica flui por um circuito. Ao habilitar ou desabilitar o fluxo de corrente elétrica por um pino específico, um LED conectado a esse pino (por meio das trilhas) pode ser ligado e desligado.
Cada fabricante utiliza um esquema de numeração de peças diferente, mas muitos
permitem que você determine as informações sobre um componente simplesmente
olhando para o número da peça. Analisando o número da peça do nosso MCU
(N52833 QIAAA0 2024AL
, você provavelmente não consegue vê-lo a olho nu, mas
está no chip), o n
na frente nos indica que essa é uma peça fabricada pela
Nordic Semiconductor. Pesquisando o número da peça em seu site, encontramos
rapidamente a página do produto. Lá descobrimos que o principal destaque do
nosso chip é que ele é um "Bluetooth Low Energy and 2.4 GHz SoC" (SoC sendo a
sigla para "System on a Chip"), o que explica o "RF" no nome do produto, já que
RF é a sigla para radiofrequência. Se pesquisarmos um pouco na documentação do
chip vinculada na página do produto, encontraremos a
especificação do produto que contém o capítulo 10 "Informações de Pedido"
dedicado a explicar a estranha nomenclatura do chip. Aqui aprendemos que:
- O
N52
é a série do MCU, indicando que existem outros MCUsnRF52
. - O
833
é o código da peça. - O
QI
é o código do pacote, abreviação deaQFN73
. - O
AA
é o código da variante, indicando quanta RAM e memória flash o MCU possui, no nosso caso, 512 kilobytes de flash e 128 kilobytes de RAM. - O
A0
é o código de construção, indicando a versão do hardware (A
) e a configuração do produto (0
). - O
2024AL
é um código de rastreamento, portanto pode ser diferente no seu chip.
A especificação do produto, é claro, contém muitas outras informações úteis sobre o chip, por exemplo, que ele é baseado em um processador ARM® Cortex™-M4 de 32 bits.
Arm? Cortex-M4?
Se nosso chip for fabricado pela Nordic, então quem é a Arm? E se nosso chip é o nRF52833, o que é o Cortex-M4?
Você pode se surpreender ao saber que, embora os chips "baseados na Arm" sejam bastante populares, a empresa por trás da marca registrada "Arm" (Arm Holdings) na verdade não fabrica chips para venda. Em vez disso, seu principal modelo de negócios é apenas projetar partes dos chips. Eles licenciam esses designs para fabricantes, que, por sua vez, os implementam (talvez com algumas modificações próprias) na forma de hardware físico que pode ser vendido. A estratégia da Arm aqui é diferente de empresas como a Intel, que projetam e fabricam seus próprios chips.
A Arm licencia diversos designs diferentes. Sua família de designs "Cortex-M" é principalmente usada como núcleo em microcontroladores. Por exemplo, o Cortex-M4 (o núcleo em que nosso chip é baseado) é projetado para baixo custo e baixo consumo de energia. O Cortex-M7 tem custo mais alto, mas com mais recursos e desempenho.
Felizmente, você não precisa saber muito sobre diferentes tipos de processadores ou designs Cortex para fins deste livro. No entanto, agora você tem um pouco mais de conhecimento sobre a terminologia do seu dispositivo. Embora você esteja trabalhando especificamente com um nRF52833, pode encontrar-se lendo documentação e usando ferramentas para chips baseados em Cortex-M, pois o nRF52833 é baseado em um design Cortex-M.
Nordic nRF51822 (o "nRF51", micro:bit v1)
Nosso MCU possui 48 pequenos pinos metálicos situados logo abaixo dele (é um chip chamado QFN48). Esses pinos estão conectados a trilhas, as pequenas "estradas" que funcionam como fios conectando os componentes na placa. O MCU pode alterar dinamicamente as propriedades elétricas dos pinos. Isso funciona de forma semelhante a um interruptor de luz, alterando como a corrente elétrica flui por um circuito. Ao habilitar ou desabilitar o fluxo de corrente elétrica por um pino específico, um LED conectado a esse pino (por meio das trilhas) pode ser ligado e desligado.
Cada fabricante utiliza um esquema de numeração de peças diferente, mas muitos
permitem que você determine as informações sobre um componente simplesmente
olhando para o número da peça. Analisando o número da peça do nosso MCU
(N51822 QFAAH3 1951LN
, você provavelmente não consegue vê-lo a olho nu, mas
está no chip), o n
na frente nos indica que essa é uma peça fabricada pela
Nordic Semiconductor. Pesquisando o número da peça em seu site, encontramos
rapidamente a página do produto. Lá descobrimos que o principal destaque do
nosso chip é que ele é um "Bluetooth Low Energy and 2.4 GHz SoC" (SoC sendo a
sigla para "System on a Chip"), o que explica o "RF" no nome do produto, já que
RF é a sigla para radiofrequência. Se pesquisarmos um pouco na documentação do
chip vinculada na página do produto, encontraremos a
especificação do produto que contém o capítulo 10 "Informações de Pedido"
dedicado a explicar a estranha nomenclatura do chip. Aqui aprendemos que:
- A
N51
é a série do MCU, indicando que existem outros MCUsnRF51
.
- O
822
é o código da peça.
- O
QF
é o código do pacote, neste caso, abreviado paraQFN48
.
- O
AA
é o código de variante, indicando a quantidade de RAM e memória flash que o MCU possui, no nosso caso, 256 kilobytes de flash e 16 kilobytes de RAM.
- O
H3
é o código de construção, indicando a versão do hardware (H
) e a configuração do produto (3
).
- O
1951LN
é um código de rastreamento, por isso pode ser diferente no seu chip.
A especificação do produto, é claro, contém muitas outras informações úteis sobre o chip, por exemplo, que ele é baseado em um processador ARM® Cortex™-M0 de 32 bits.
Arm? Cortex-M0?
Se nosso chip for fabricado pela Nordic, então quem é a Arm? E se nosso chip é o nRF51822, o que é o Cortex-M0?
Você pode se surpreender ao saber que, embora os chips "baseados na Arm" sejam bastante populares, a empresa por trás da marca registrada "Arm" (Arm Holdings) na verdade não fabrica chips para venda. Em vez disso, seu principal modelo de negócios é apenas projetar partes dos chips. Eles licenciam esses designs para fabricantes, que, por sua vez, os implementam (talvez com algumas modificações próprias) na forma de hardware físico que pode ser vendido. A estratégia da Arm aqui é diferente de empresas como a Intel, que projetam e fabricam seus próprios chips.
A Arm licencia diversos designs diferentes. Sua família de designs "Cortex-M" é principalmente usada como núcleo em microcontroladores. Por exemplo, o Cortex-M0 (o núcleo em que nosso chip é baseado) é projetado para baixo custo e baixo consumo de energia. O Cortex-M7 tem custo mais alto, mas com mais recursos e desempenho.
Felizmente, você não precisa saber muito sobre diferentes tipos de processadores ou designs Cortex para fins deste livro. No entanto, agora você tem um pouco mais de conhecimento sobre a terminologia do seu dispositivo. Embora você esteja trabalhando especificamente com um nRF51822, pode encontrar-se lendo documentação e usando ferramentas para chips baseados em Cortex-M, pois o nRF51822 é baseado em um design Cortex-M.
Terminologia de Rust para Sistemas Embarcados
Antes de mergulharmos na programação do micro:bit, vamos dar uma rápida olhada nas bibliotecas e terminologia que serão importantes para todos os capítulos futuros.
Camadas de Abstração
Para qualquer microcontrolador/placa totalmente suportado com um microcontrolador, geralmente se ouvirá os seguintes termos sendo usados para seus níveis de abstração:
Peripheral Access Crate (PAC)
O trabalho do PAC é fornecer uma interface direta (mais ou menos segura) aos periféricos do chip, permitindo configurar cada último bit da forma desejada (é claro, também de maneiras incorretas). Normalmente, você só precisa lidar com o PAC se as camadas acima não atenderem às suas necessidades ou quando você as está desenvolvendo. O PAC que estamos (implicitamente) usando é o do nRF52 ou do nRF51.
Camada de Abstração de Hardware (HAL)
O trabalho do HAL é construir em cima do PAC do chip e fornecer uma abstração que seja realmente utilizável para alguém que não conhece todo o comportamento especial desse chip. Geralmente, eles abstraem periféricos inteiros em estruturas únicas que podem, por exemplo, ser usadas para enviar dados através do periférico. Vamos usar o nRF52-hal ou o nRF51-hal, respectivamente.
Board Support Crate (historicamente chamado de Board Support Package, ou BSP)
O trabalho do BSP é abstrair completamente uma placa (como o micro:bit) de uma vez. Isso significa que ele precisa fornecer abstrações para usar tanto o microcontrolador quanto os sensores, LEDs, etc. que podem estar presentes na placa. Com frequência (especialmente com placas feitas sob medida), você estará trabalhando apenas com um HAL para o chip e construirá os drivers para os sensores por conta própria ou os procurará no crates.io. Felizmente para nós, o micro:bit realmente tem um BSP, então vamos usá-lo em cima do nosso HAL também.
Unificando as Camadas
A seguir, vamos dar uma olhada em uma peça de software muito central no mundo do
Rust para sistemas embarcados: o embedded-hal
. Como o nome sugere, ele se
relaciona com o segundo nível de abstração que conhecemos: os HALs. A ideia por
trás do embedded-hal
é fornecer um conjunto de traits que descrevem
comportamentos que geralmente são compartilhados em todas as implementações de
um periférico específico em todos os HALs. Por exemplo, sempre se espera ter
funções capazes de ligar ou desligar a alimentação de um pino para, por exemplo,
acender e apagar um LED na placa. Isso nos permite escrever um driver para,
digamos, um sensor de temperatura, que pode ser usado em qualquer chip para o
qual exista uma implementação dos traits do embedded-hal
, simplesmente
escrevendo o driver de tal forma que ele dependa apenas dos traits do
embedded-hal
. Drivers escritos dessa forma são chamados de agnósticos de
plataforma e, felizmente para nós, a maioria dos drivers no crates.io são
realmente agnósticos de plataforma.
Leitura adicional
Se você quiser aprender mais sobre esses níveis de abstração, Franz Skarman, também conhecido como TheZoq2, ministrou uma palestra sobre esse assunto durante o Oxidize 2020, chamada Uma Visão Geral do Ecossistema de Rust para Sistemas Embarcados.
Roleta LED
Certo, vamos começar construindo a seguinte aplicação:
Vou fornecer a você uma API de alto nível para implementar este aplicativo, mas não se preocupe, faremos coisas de baixo nível posteriormente. O objetivo principal deste capítulo é familiarizar-se com o processo de flashing e depuração.
O código inicial está no diretório src
do repositório do livro. Dentro desse
diretório, existem mais diretórios com nomes de cada capítulo deste livro. A
maioria desses diretórios são projetos iniciais de Cargo.
Agora, vá para o diretório src/05-led-roulette
. Verifique o arquivo
src/main.rs
:
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use microbit as _; use panic_halt as _; #[entry] fn main() -> ! { let _y; let x = 42; _y = x; // loop infinito; apenas para não sairmos deste stack frame loop {} }
Programas para microcontroladores são diferentes de programas convencionais em
dois aspectos: #![no_std]
e #![no_main]
.
O atributo no_std
indica que este programa não usará o crate std
, que
pressupõe a existência de um sistema operacional subjacente. Em vez disso, o
programa usará o crate core
, que é um subconjunto do std
que pode ser
executado em sistemas bare metal (ou seja, sistemas sem abstrações de sistema
operacional, como arquivos e sockets).
O atributo no_main
indica que este programa não usará a interface main
padrão, que é adequada para aplicativos de linha de comando que recebem
argumentos. Em vez do main
padrão, utilizaremos o atributo entry
do crate
[cortex-m-rt] para definir um ponto de entrada customizado. Neste programa,
nomeamos o ponto de entrada como "main", mas poderíamos ter usado qualquer outro
nome. A função de ponto de entrada deve ter a assinatura fn() -> !
, indicando
que a função não pode retornar -- isso significa que o programa nunca termina.
Se você for um observador cuidadoso, também notará que existe um diretório
.cargo
no projeto Cargo. Esse diretório contém um arquivo de configuração do
Cargo (.cargo/config
) que ajusta o processo de linking para adaptar o layout
de memória do programa aos requisitos do dispositivo alvo. Esse processo de
linking modificado é um requisito do crate cortex-m-rt
.
Além disso, há também um arquivo Embed.toml
.
[default.general]
# chip = "nrf52833_xxAA" # para micro:bit V2, descomente essa linha
# chip = "nrf51822_xxAA" # para micro:bit V1, descomente essa linha
[default.reset]
halt_afterwards = true
[default.rtt]
enabled = false
[default.gdb]
enabled = true
Este arquivo informa ao cargo-embed
que:
- Estamos trabalhando com um nrf52833 ou nrf51822. Você terá que remover novamente os comentários do chip que estiver usando, assim como fez no capítulo 3.
- Desejamos interromper o chip depois de gravá-lo para que nosso programa não salte instantaneamente para o loop.
- Queremos desabilitar o RTT, que é um protocolo que permite ao chip enviar texto para um depurador. Na verdade, você já viu o RTT em ação: ele foi o protocolo que enviou "Hello World" no capítulo 3.
- Desejamos habilitar o GDB, pois isso será necessário para o procedimento de depuração.
Certo, vamos começar construindo este programa.
Construindo
O primeiro passo é construir o nosso crate "binário". Como o microcontrolador
possui uma arquitetura diferente do seu computador, precisaremos fazer uma
compilação cruzada. A compilação cruzada no mundo do Rust é tão simples quanto
passar uma flag --target
extra para o rustc
ou Cargo. A parte complicada é
descobrir o argumento dessa flag: o nome do alvo.
Como já sabemos, o microcontrolador do micro:bit v2 possui um processador
Cortex-M4F, enquanto o do v1 possui um Cortex-M0. O rustc
sabe como fazer a
compilação cruzada para a arquitetura Cortex-M e fornece vários alvos diferentes
que abrangem as diferentes famílias de processadores dentro dessa arquitetura:
thumbv6m-none-eabi
, para os processadores Cortex-M0 e Cortex-M1
thumbv7m-none-eabi
, para o processador Cortex-M3
thumbv7em-none-eabi
, para os processadores Cortex-M4 e Cortex-M7
thumbv7em-none-eabihf
, para os processadores Cortex-M4F e Cortex-M7F
thumbv8m.main-none-eabi
, para os processadores Cortex-M33 e Cortex-M35P
thumbv8m.main-none-eabihf
, para os processadores Cortex-M33F e Cortex-M35PF
Para o micro:bit v2, utilizaremos o alvo thumbv7em-none-eabihf
, enquanto para
o v1, utilizaremos thumbv6m-none-eabi
. Antes de fazer a compilação cruzada,
você precisa baixar uma versão pré-compilada da biblioteca padrão (na verdade,
uma versão reduzida) para o seu alvo. Isso é feito usando o rustup
:
# Para micro:bit v2
$ rustup target add thumbv7em-none-eabihf
# Para micro:bit v1
$ rustup target add thumbv6m-none-eabi
Você só precisa realizar o passo acima uma vez; o rustup
irá reinstalar uma
nova biblioteca padrão (componente rust-std
) sempre que você atualizar seu
toolchain. Portanto, você pode pular esta etapa se já tiver adicionado o alvo
necessário ao verificar sua configuração.
Com o componente rust-std
instalado, agora você pode fazer a compilação
cruzada do programa usando o Cargo:
# Certifique-se de que você está no diretório `src/05-led-roulette`
# Para micro:bit v2
$ cargo build --features v2 --target thumbv7em-none-eabihf
Compiling semver-parser v0.7.0
Compiling typenum v1.12.0
Compiling cortex-m v0.6.3
(...)
Compiling microbit-v2 v0.10.1
Finished dev [unoptimized + debuginfo] target(s) in 33.67s
# Para micro:bit v1
$ cargo build --features v1 --target thumbv6m-none-eabi
Compiling fixed v1.2.0
Compiling syn v1.0.39
Compiling cortex-m v0.6.3
(...)
Compiling microbit v0.10.1
Finished dev [unoptimized + debuginfo] target(s) in 22.73s
NOTA Certifique-se de compilar este crate sem otimizações. O arquivo Cargo.toml fornecido e o comando de compilação acima garantirão que as otimizações estejam desativadas.
OK, agora produzimos um executável. Este executável não piscará nenhum LED, é apenas uma versão simplificada na qual iremos trabalhar mais adiante neste capítulo. Como uma verificação de integridade, vamos verificar se o executável produzido é realmente um binário ARM:
# Para micro:bit v2
# Equivalente a `readelf -h target/thumbv7em-none-eabihf/debug/led-roulette`
$ cargo readobj --features v2 --target thumbv7em-none-eabihf --bin led-roulette -- --file-headers
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: ARM
Version: 0x1
Entry point address: 0x117
Start of program headers: 52 (bytes into file)
Start of section headers: 793112 (bytes into file)
Flags: 0x5000400
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 4
Size of section headers: 40 (bytes)
Number of section headers: 21
Section header string table index: 19
# Para micro:bit v1
# Equivalente a `readelf -h target/thumbv6m-none-eabi/debug/led-roulette`
$ cargo readobj --features v1 --target thumbv6m-none-eabi --bin led-roulette -- --file-headers
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: ARM
Version: 0x1
Entry point address: 0xC1
Start of program headers: 52 (bytes into file)
Start of section headers: 693196 (bytes into file)
Flags: 0x5000200
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 4
Size of section headers: 40 (bytes)
Number of section headers: 22
Section header string table index: 20
Em seguida, vamos gravar o programa em nosso microcontrolador.
Gravando
Flashing é o processo de transferir nosso programa para a memória (persistente) do microcontrolador. Uma vez programado, o microcontrolador executará o programa gravado sempre que for ligado.
Neste caso, nosso programa led-roulette
será o único programa na memória do
microcontrolador. Com isso, quero dizer que não há mais nada sendo executado no
microcontrolador: nenhum sistema operacional, nenhum "daemon", nada. O
led-roulette
tem controle total sobre o dispositivo.
Gravar o binário em si é bastante simples graças ao cargo embed
.
Antes de executar esse comando, vamos ver o que ele realmente faz. Se você olhar para o lado do seu micro:bit com o conector USB virado para cima, você perceberá que há na verdade 2 quadrados pretos lá (no micro:bit v2 há um terceiro e maior, que é um alto-falante), um é o nosso MCU que já discutimos, mas para que serve o outro? O outro chip tem 3 propósitos principais:
- Fornecer energia do conector USB para o nosso MCU.
- Fornecer uma ponte serial para USB para o nosso MCU (vamos falar sobre isso em um capítulo posterior).
- Ser um programador/depurador (esse é o propósito relevante para agora).
Basicamente, esse chip atua como uma ponte entre nosso computador (ao qual está conectado via USB) e o MCU (ao qual está conectado por trilhas e comunica-se usando o protocolo SWD). Essa ponte nos permite programar novos binários no MCU, inspecionar seu estado por meio de um depurador e realizar outras tarefas.
Então vamos gravar!
# Para micro:bit v2
$ cargo embed --features v2 --target thumbv7em-none-eabihf
(...)
Erasing sectors ✔ [00:00:00] [####################################################################################################################################################] 2.00KiB/ 2.00KiB @ 4.21KiB/s (eta 0s )
Programming pages ✔ [00:00:00] [####################################################################################################################################################] 2.00KiB/ 2.00KiB @ 2.71KiB/s (eta 0s )
Finished flashing in 0.608s
# Para micro:bit v1
$ cargo embed --features v1 --target thumbv6m-none-eabi
(...)
Erasing sectors ✔ [00:00:00] [####################################################################################################################################################] 2.00KiB/ 2.00KiB @ 4.14KiB/s (eta 0s )
Programming pages ✔ [00:00:00] [####################################################################################################################################################] 2.00KiB/ 2.00KiB @ 2.69KiB/s (eta 0s )
Finished flashing in 0.614s
Você perceberá que o cargo-embed
bloqueia após exibir a última linha, isso é
intencional e você não deve fechá-lo, pois precisamos dele nesse estado para a
próxima etapa: depurar! Além disso, você terá percebido que o cargo build
e o
cargo embed
na verdade recebem as mesmas flags, isso ocorre porque o
cargo embed
executa o build e, em seguida, grava o binário resultante no chip,
portanto, você pode pular a etapa de cargo build
no futuro se quiser gravar
seu código imediatamente.
Depurando
Como isso funciona?
Antes de depurar nosso pequeno programa, vamos dedicar um momento para entender rapidamente o que realmente está acontecendo aqui. No capítulo anterior, já discutimos o propósito do segundo chip na placa e também como ele se comunica com o nosso computador, mas como podemos realmente usá-lo?
A pequena opção default.gdb.enabled = true
no arquivo Embed.toml
fez com que
o cargo-embed
abrisse um chamado "GDB stub" após o flashing, que é um servidor
ao qual nosso GDB pode se conectar e enviar comandos como "definir um breakpoint
no endereço X". O servidor pode então decidir por conta própria como lidar com
esse comando. No caso do cargo-embed
GDB stub, ele encaminhará o comando para
a sonda de depuração na placa via USB, que por sua vez faz o trabalho de
realmente se comunicar com a MCU para nós.
Vamos depurar!
Como o cargo-embed
está bloqueando nosso shell atual, podemos simplesmente
abrir um novo e voltar ao diretório do nosso projeto. Uma vez lá, primeiro temos
que abrir o binário no gdb assim:
# Para micro:bit v2
$ gdb target/thumbv7em-none-eabihf/debug/led-roulette
# Para micro:bit v1
$ gdb target/thumbv6m-none-eabi/debug/led-roulette
NOTA: Dependendo de qual GDB você instalou, você terá que usar um comando diferente para iniciá-lo. Verifique o Capítulo 3 se você esqueceu qual era.
NOTA: Se você estiver recebendo o erro
target/thumbv7em-none-eabihf/debug/led-roulette: No such file or directory
, tente adicionar../../
ao caminho do arquivo, por exemplo:$ gdb ../../target/thumbv7em-none-eabihf/debug/led-roulette
Isso ocorre porque cada projeto de exemplo está em um
workspace
que contém todo o livro, e os workspaces possuem um único diretório dedestino
. Confira o capítulo de Workspaces no Livro de Rust para mais informações.
NOTA: Se o
cargo-embed
imprimir muitos avisos aqui, não se preocupe com isso. Até o momento, ele não implementa totalmente o protocolo GDB e, portanto, pode não reconhecer todos os comandos que seu GDB está enviando para ele. Contanto que não ocorra uma falha, está tudo bem.
Em seguida, teremos que nos conectar ao GDB stub. Ele é executado em
localhost:1337
por padrão, portanto, para se conectar a ele, execute o
seguinte comando:
(gdb) target remote :1337
Remote debugging using :1337
0x00000116 in nrf52833_pac::{{impl}}::fmt (self=0xd472e165, f=0x3c195ff7) at /home/nix/.cargo/registry/src/github.com-1ecc6299db9ec823/nrf52833-pac-0.9.0/src/lib.rs:157
157 #[derive(Copy, Clone, Debug)]
A seguir, o que queremos fazer é chegar à função principal do nosso programa. Faremos isso primeiro configurando um breakpoint lá e continuando a execução do programa até atingirmos ele:
(gdb) break main
Breakpoint 1 at 0x104: file src/05-led-roulette/src/main.rs, line 9.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) continue
Continuing.
Breakpoint 1, led_roulette::__cortex_m_rt_main_trampoline () at src/05-led-roulette/src/main.rs:9
9 #[entry]
Breakpoints podem ser usados para interromper o fluxo normal de um programa. O
comando continue
permitirá que o programa seja executado livremente até que
ele atinja um breakpoint. Nesse caso, ele continuará a execução até chegar à
função main
, pois há um breakpoint lá.
Observe que a saída do GDB diz "Breakpoint 1". Lembre-se de que nosso
processador só pode usar uma quantidade limitada desses breakpoints, então é uma
boa ideia prestar atenção nessas mensagens. Se você acabar ficando sem
breakpoints, pode listar todos os atuais com o comando info break
e excluir os
desejados com delete <número-do-breakpoint>
.
Para uma experiência de depuração mais agradável, estaremos usando a Interface de Texto do Usuário (TUI) do GDB. Para entrar neste modo, no shell do GDB, digite o seguinte comando:
(gdb) layout src
NOTA: Pedimos desculpas aos usuários do Windows. O GDB fornecido com a GNU ARM Embedded Toolchain não suporta o modo TUI
:(
.
O comando "break" do GDB não funciona apenas para nomes de funções, ele também pode interromper em determinados números de linha. Se quisermos interromper na linha 13, podemos simplesmente fazer o seguinte:
(gdb) break 13
Breakpoint 2 at 0x110: file src/05-led-roulette/src/main.rs, line 13.
(gdb) continue
Continuing.
Breakpoint 2, led_roulette::__cortex_m_rt_main () at src/05-led-roulette/src/main.rs:13
(gdb)
A qualquer momento, você pode sair do modo TUI usando o seguinte comando:
(gdb) tui disable
Agora estamos "na" instrução _y = x
; essa instrução ainda não foi executada.
Isso significa que x
está inicializado, mas _y
não está. Vamos inspecionar
essas variáveis da pilha/locais usando o comando print
:
(gdb) print x
$1 = 42
(gdb) print &x
$2 = (*mut i32) 0x20003fe8
(gdb)
Conforme esperado, x
contém o valor 42
. O comando print &x
imprime o
endereço da variável x
. A parte interessante aqui é que a saída do GDB mostra
o tipo da referência: i32*
, um ponteiro para um valor i32
.
Se quisermos continuar a execução do programa linha por linha, podemos fazer
isso usando o comando next
, então vamos prosseguir para a instrução loop {}
:
(gdb) next
16 loop {}
E _y
agora deve estar inicializado.
(gdb) print _y
$5 = 42
Em vez de imprimir as variáveis locais uma por uma, você também pode usar o
comando info locals
:
(gdb) info locals
x = 42
_y = 42
(gdb)
Se usarmos next
novamente em cima da instrução loop {}
, ficaremos presos,
pois o programa nunca passará por essa instrução. Em vez disso, vamos mudar para
a visualização de desmontagem com o comando layout asm
e avançar uma instrução
de cada vez usando stepi
. Você sempre pode voltar para a visualização do
código-fonte em Rust posteriormente emitindo o comando layout src
novamente.
NOTA: Se você usou o comando
next
oucontinue
por engano e o GDB ficou preso, você pode desbloqueá-lo pressionandoCtrl+C
.
(gdb) layout asm
Se você não estiver usando o modo TUI, pode usar o comando disassemble /m
para
desmontar o programa em torno da linha em que você está atualmente.
(gdb) disassemble /m
Dump of assembler code for function _ZN12led_roulette18__cortex_m_rt_main17h3e25e3afbec4e196E:
10 fn main() -> ! {
0x0000010a <+0>: sub sp, #8
0x0000010c <+2>: movs r0, #42 ; 0x2a
11 let _y;
12 let x = 42;
0x0000010e <+4>: str r0, [sp, #0]
13 _y = x;
0x00000110 <+6>: str r0, [sp, #4]
14
15 // infinite loop; just so we don't leave this stack frame
16 loop {}
=> 0x00000112 <+8>: b.n 0x114 <_ZN12led_roulette18__cortex_m_rt_main17h3e25e3afbec4e196E+10>
0x00000114 <+10>: b.n 0x114 <_ZN12led_roulette18__cortex_m_rt_main17h3e25e3afbec4e196E+10>
End of assembler dump.
Vê a seta gorda =>
no lado esquerdo? Ela mostra a instrução que o processador
executará em seguida.
Se você não estiver no modo TUI, em cada comando stepi
, o GDB imprimirá a
declaração e o número da linha da instrução que o processador executará em
seguida.
(gdb) stepi
16 loop {}
(gdb) stepi
16 loop {}
Um último truque antes de passarmos para algo mais interessante. Digite os seguintes comandos no GDB:
(gdb) monitor reset
(gdb) c
Continuing.
Breakpoint 1, led_roulette::__cortex_m_rt_main_trampoline () at src/05-led-roulette/src/main.rs:9
9 #[entry]
(gdb)
Agora estamos de volta ao início de main
!
monitor reset
irá reiniciar o microcontrolador e pará-lo exatamente no ponto
de entrada do programa. O comando continue
seguinte permitirá que o programa
seja executado livremente até chegar à função main
, que possui um breakpoint.
Essa combinação é útil quando você, por engano, pulou uma parte do programa que estava interessado em inspecionar. Você pode facilmente reverter o estado do seu programa de volta ao seu início.
Detalhe: O comando
reset
não limpa ou altera a RAM. Essa memória reterá seus valores da execução anterior. Isso não deve ser um problema, a menos que o comportamento do seu programa dependa do valor de variáveis não inicializadas, mas essa é a definição de Comportamento Indefinido (Undefined Behavior - UB).
Concluímos esta sessão de depuração. Você pode encerrá-la com o comando quit
.
(gdb) quit
A debugging session is active.
Inferior 1 [Remote target] will be detached.
Quit anyway? (y or n) y
Detaching from program: $PWD/target/thumbv7em-none-eabihf/debug/led-roulette, Remote target
Ending remote debugging.
[Inferior 1 (Remote target) detached]
NOTA: Se a CLI padrão do GDB não for do seu agrado, confira o gdb-dashboard. Ele usa Python para transformar a interface padrão do GDB em um painel que mostra os registros, a visualização do código-fonte, a visualização em assembly e outras coisas.
Se você deseja aprender mais sobre o que o GDB pode fazer, confira a seção Como usar o GDB.
O que vem a seguir? A API de alto nível que eu prometi.
Acendendo
embedded-hal
Neste capítulo, vamos fazer com que um dos muitos LEDs na parte de trás do
micro:bit acenda, já que isso é basicamente o "Hello World" da programação
embarcada. Para realizar essa tarefa, vamos usar um dos recursos fornecidos pelo
embedded-hal
, especificamente o trait OutputPin
, que nos permite ligar ou
desligar um pino.
Os LEDs do micro:bit
Na parte de trás do micro:bit, você pode ver um quadrado de LEDs de 5x5, geralmente chamado de matriz de LEDs. Essa disposição em matriz é usada para que, em vez de precisarmos usar 25 pinos separados para controlar cada um dos LEDs, podemos usar apenas 10 (5+5) pinos para controlar qual coluna e qual linha de nossa matriz se acende.
NOTE que a equipe do micro:bit v1 implementou isso de forma um pouco diferente. A página de esquema deles indica que na verdade é implementado como uma matriz de 3x9, mas algumas colunas simplesmente permanecem não utilizadas.
Normalmente, para determinar quais pinos específicos devemos controlar para acender um LED específico, agora teríamos que ler o esquema do micro:bit v2 ou o esquema do micro:bit v1, respectivamente. Felizmente para nós, no entanto, podemos usar o BSP do micro:bit mencionado anteriormente, que abstrai todas essas informações de forma conveniente para nós.
Realmente acendendo!
O código necessário para acender um LED na matriz é, na verdade, bastante simples, mas requer um pouco de configuração. Primeiro, dê uma olhada e depois podemos passar por ele passo a passo:
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use panic_halt as _; use microbit::board::Board; use microbit::hal::prelude::*; #[entry] fn main() -> ! { let mut board = Board::take().unwrap(); board.display_pins.col1.set_low().unwrap(); board.display_pins.row1.set_high().unwrap(); loop {} }
As primeiras linhas até a função principal apenas realizam algumas importações básicas e configurações que já analisamos anteriormente. No entanto, a função principal parece bastante diferente do que vimos até agora.
A primeira linha está relacionada à forma como a maioria das HALs escritas em
Rust funcionam internamente. Como discutido anteriormente, elas são construídas
em cima de crates PAC que possuem (no sentido Rust) todos os periféricos de um
chip. let mut board = Board::take().unwrap();
basicamente pega todos esses
periféricos do PAC e os vincula a uma variável. Neste caso específico, não
estamos apenas trabalhando com uma HAL, mas com todo um BSP, então isso também
assume a propriedade da representação Rust de outros chips na placa.
NOTA: Se você está se perguntando por que tivemos que chamar
unwrap()
aqui, teoricamente é possível quetake()
seja chamado mais de uma vez. Isso levaria aos periféricos sendo representados por duas variáveis separadas e, portanto, muitos comportamentos possivelmente confusos, porque duas variáveis modificam o mesmo recurso. Para evitar isso, as PACs são implementadas de tal forma que causariam um pânico se você tentasse pegar os periféricos duas vezes.
Agora podemos acender o LED conectado a row1
, col1
definindo o pino row1
como alto (ou seja, ligando-o). A razão pela qual podemos deixar col1
definido
como baixo é por causa do funcionamento do circuito da matriz de LEDs. Além
disso, embedded-hal
é projetado de forma que cada operação no hardware possa
retornar possivelmente um erro, mesmo apenas alternando um pino entre ligado e
desligado. Como isso é altamente improvável em nosso caso, podemos apenas
unwrap()
o resultado.
Testando
Testar nosso pequeno programa é bastante simples. Primeiro, coloque-o em
src/main.rs
. Em seguida, basta executar o comando cargo embed
da última
seção novamente, deixe-o gravar e apenas como antes. Em seguida, abra o GDB e
conecte-se ao GDB stub:
$ # Seu comando de depuração GDB da última seção
(gdb) target remote :1337
Remote debugging using :1337
cortex_m_rt::Reset () at /home/nix/.cargo/registry/src/github.com-1ecc6299db9ec823/cortex-m-rt-0.6.12/src/lib.rs:489
489 pub unsafe extern "C" fn Reset() -> ! {
(gdb)
Se agora deixarmos o programa rodar usando o comando continue
do GDB, um dos
LEDs na parte de trás do micro:bit deve acender.
Está piscando
Delaying
Agora vamos dar uma breve olhada nas abstrações de delay fornecidas pelo embedded-hal
antes de combiná-las com as abstrações de GPIO do capítulo anterior para
finalmente fazer um LED piscar.
O embedded-hal
nos fornece duas abstrações para atrasar a execução do nosso programa:
DelayUs
e DelayMs
. Ambas funcionam exatamente da mesma forma, exceto pelo fato de aceitarem unidades diferentes para sua função de delay.
Dentro de nossa MCU, existem vários dos chamados "timers". Eles podem fazer várias coisas relacionadas ao tempo para nós, inclusive simplesmente pausar a execução do nosso programa por um período fixo de tempo. Um programa muito simples baseado em delay que imprime algo a cada segundo pode, por exemplo, ser semelhante a isso:
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use rtt_target::{rtt_init_print, rprintln};
use panic_rtt_target as _;
use microbit::board::Board;
use microbit::hal::timer::Timer;
use microbit::hal::prelude::*;
#[entry]
fn main() -> ! {
rtt_init_print!();
let mut board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
loop {
timer.delay_ms(1000u16);
rprintln!("1000 ms passed");
}
}
Observe que alteramos nossa implementação de pânico de panic_halt
para panic_rtt_target
aqui. Isso exigirá que você descomente as duas linhas RTT do arquivo Cargo.toml
e comentar a linha panic-halt
, já que o Rust permite apenas uma implementação de pânico por vez.
Para realmente ver o que foi imprimido, temos que alterar o Embed.toml
da seguinte forma:
[default.general]
# chip = "nrf52833_xxAA" # uncomment this line for micro:bit V2
# chip = "nrf51822_xxAA" # uncomment this line for micro:bit V1
[default.reset]
halt_afterwards = false
[default.rtt]
enabled = true
[default.gdb]
enabled = false
E agora, após colocar o código em src/main.rs
e executar outro rápido cargo embed
(novamente com os mesmos sinalizadores que você usou antes) você deverá ver "1000 ms passed
" sendo enviado ao seu console a cada segundo a partir da MCU.
Piscando
Agora chegamos ao ponto em que podemos combinar nosso novo conhecimento sobre GPIO e abstrações de delay para realmente fazer um LED na parte traseira do micro:bit piscar. O programa resultante é, na verdade, apenas uma mistura do programa acima com o programa que acendeu o LED na última seção e se parece com isso:
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use rtt_target::{rtt_init_print, rprintln};
use panic_rtt_target as _;
use microbit::board::Board;
use microbit::hal::timer::Timer;
use microbit::hal::prelude::*;
#[entry]
fn main() -> ! {
rtt_init_print!();
let mut board = Board::take().unwrap();
let mut timer = Timer::new(board.TIMER0);
board.display_pins.col1.set_low().unwrap();
let mut row1 = board.display_pins.row1;
loop {
row1.set_low().unwrap();
rprintln!("Dark!");
timer.delay_ms(1_000_u16);
row1.set_high().unwrap();
rprintln!("Light!");
timer.delay_ms(1_000_u16);
}
}
Depois de colocar o código em src/main.rs
e executar um cargo embed
final (com os sinalizadores adequados), você deverá ver o LED acender antes de piscar, bem como um print, toda vez que o LED mudar de desligado para ligado e vice-versa.
Desafio
Agora você está bem armado para enfrentar um desafio! Sua tarefa será implementar a aplicação que mostrei a você no início deste capítulo.
Se você não consegue ver exatamente o que está acontecendo, aqui está em uma versão muito mais lenta:
Como trabalhar com os pinos de LED separadamente é bastante incômodo (especialmente se você tiver que usar basicamente todos eles, como aqui) você pode usar a API de exibição fornecida pelo BSP. Ela funciona da seguinte forma:
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use rtt_target::rtt_init_print; use panic_rtt_target as _; use microbit::{ board::Board, display::blocking::Display, hal::{prelude::*, Timer}, }; #[entry] fn main() -> ! { rtt_init_print!(); let board = Board::take().unwrap(); let mut timer = Timer::new(board.TIMER0); let mut display = Display::new(board.display_pins); let light_it_all = [ [1, 1, 1, 1, 1], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1], ]; loop { // Exibe light_it_all por 1000ms display.show(&mut timer, light_it_all, 1000); // Limpa a exibição novamente display.clear(); timer.delay_ms(1000_u32); } }
Equipado com essa API, sua tarefa basicamente se resume a calcular a matriz de imagem adequada e passá-la para o BSP.
Minha solução
Qual foi a solução que você encontrou?
Aqui está a minha, que provavelmente é uma das maneiras mais simples (mas não a mais bonita, é claro) de gerar a matriz requerida:
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use rtt_target::rtt_init_print; use panic_rtt_target as _; use microbit::{ board::Board, display::blocking::Display, hal::Timer, }; const PIXELS: [(usize, usize); 16] = [ (0,0), (0,1), (0,2), (0,3), (0,4), (1,4), (2,4), (3,4), (4,4), (4,3), (4,2), (4,1), (4,0), (3,0), (2,0), (1,0) ]; #[entry] fn main() -> ! { rtt_init_print!(); let board = Board::take().unwrap(); let mut timer = Timer::new(board.TIMER0); let mut display = Display::new(board.display_pins); let mut leds = [ [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 0, 0, 0], ]; let mut last_led = (0,0); loop { for current_led in PIXELS.iter() { leds[last_led.0][last_led.1] = 0; leds[current_led.0][current_led.1] = 1; display.show(&mut timer, leds, 30); last_led = *current_led; } } }
Mais uma coisa! Verifique se sua solução também funciona quando compilada no modo "release":
# Para micro:bit v2
$ cargo embed --features v2 --target thumbv7em-none-eabihf --release
(...)
# Para micro:bit v1
$ cargo embed --features v1 --target thumbv6m-none-eabi --release
(...)
Se quiser depurar o binário no modo "release", você terá de usar um comando GDB diferente:
# Para micro:bit v2
$ gdb target/thumbv7em-none-eabihf/release/led-roulette
# Para micro:bit v1
$ gdb target/thumbv6m-none-eabi/release/led-roulette
O tamanho do binário é algo em que devemos estar sempre atentos! Qual é o tamanho de sua solução? Você pode verificar isso usando o comando size
no binário da versão release:
# Para micro:bit v2
$ cargo size --features v2 --target thumbv7em-none-eabihf -- -A
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
led-roulette :
section size addr
.vector_table 256 0x0
.text 26984 0x100
.rodata 2732 0x6a68
.data 0 0x20000000
.bss 1092 0x20000000
.uninit 0 0x20000444
.debug_abbrev 33941 0x0
.debug_info 494113 0x0
.debug_aranges 23528 0x0
.debug_ranges 130824 0x0
.debug_str 498781 0x0
.debug_pubnames 143351 0x0
.debug_pubtypes 124464 0x0
.ARM.attributes 58 0x0
.debug_frame 69128 0x0
.debug_line 290580 0x0
.debug_loc 1449 0x0
.comment 109 0x0
Total 1841390
$ cargo size --features v2 --target thumbv7em-none-eabihf --release -- -A
Finished release [optimized + debuginfo] target(s) in 0.02s
led-roulette :
section size addr
.vector_table 256 0x0
.text 6332 0x100
.rodata 648 0x19bc
.data 0 0x20000000
.bss 1076 0x20000000
.uninit 0 0x20000434
.debug_loc 9036 0x0
.debug_abbrev 2754 0x0
.debug_info 96460 0x0
.debug_aranges 1120 0x0
.debug_ranges 11520 0x0
.debug_str 71325 0x0
.debug_pubnames 32316 0x0
.debug_pubtypes 29294 0x0
.ARM.attributes 58 0x0
.debug_frame 2108 0x0
.debug_line 19303 0x0
.comment 109 0x0
Total 283715
# Para micro:bit v1
$ cargo size --features v1 --target thumbv6m-none-eabi -- -A
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
led-roulette :
section size addr
.vector_table 168 0x0
.text 28584 0xa8
.rodata 2948 0x7050
.data 0 0x20000000
.bss 1092 0x20000000
.uninit 0 0x20000444
.debug_abbrev 30020 0x0
.debug_info 373392 0x0
.debug_aranges 18344 0x0
.debug_ranges 89656 0x0
.debug_str 375887 0x0
.debug_pubnames 115633 0x0
.debug_pubtypes 86658 0x0
.ARM.attributes 50 0x0
.debug_frame 54144 0x0
.debug_line 237714 0x0
.debug_loc 1499 0x0
.comment 109 0x0
Total 1415898
$ cargo size --features v1 --target thumbv6m-none-eabi --release -- -A
Finished release [optimized + debuginfo] target(s) in 0.02s
led-roulette :
section size addr
.vector_table 168 0x0
.text 4848 0xa8
.rodata 648 0x1398
.data 0 0x20000000
.bss 1076 0x20000000
.uninit 0 0x20000434
.debug_loc 9705 0x0
.debug_abbrev 3235 0x0
.debug_info 61908 0x0
.debug_aranges 1208 0x0
.debug_ranges 5784 0x0
.debug_str 57358 0x0
.debug_pubnames 22959 0x0
.debug_pubtypes 18891 0x0
.ARM.attributes 50 0x0
.debug_frame 2316 0x0
.debug_line 18444 0x0
.comment 19 0x0
Total 208617
NOTA O projeto Cargo já está configurado para criar o binário de release usando o LTO.
Sabe como ler essa saída? A seção text
contém as instruções do programa. Por outro lado, as seções data
e bss
contêm variáveis alocadas estaticamente na RAM (variáveis static
). Se você se lembrar da especificação do microcontrolador do seu micro:bit, deve ter notado que a memória flash dele é realmente muito pequena para conter esse binário, então como isso é possível? Como podemos ver nas estatísticas de tamanho, a maior parte do binário é, na verdade, composta de seções relacionadas à depuração, que, no entanto, não são gravadas no microcontrolador em nenhum momento, afinal, elas não são relevantes para a execução.
Serial communication
This is what we'll be using. I hope your computer has one!
Nah, don't worry. This connector, the DE-9, went out of fashion on PCs quite some time ago; it got replaced by the Universal Serial Bus (USB). We won't be dealing with the DE-9 connector itself but with the communication protocol that this cable is/was usually used for.
So what's this serial communication? It's an asynchronous communication protocol where two devices exchange data serially, as in one bit at a time, using two data lines (plus a common ground). The protocol is asynchronous in the sense that neither of the shared lines carries a clock signal. Instead, both parties must agree on how fast data will be sent along the wire before the communication occurs. This protocol allows duplex communication as data can be sent from A to B and from B to A simultaneously.
We'll be using this protocol to exchange data between the microcontroller and your computer. Now you might be asking yourself why exactly we aren't using RTT for this like we did before. RTT is a protocol that is meant to be used solely for debugging. You will most definitely not be able to find a device that actually uses RTT to communicate with some other device in production. However, serial communication is used quite often. For example some GPS receivers send the positioning information they receive via serial communication.
The next practical question you probably want to ask is: How fast can we send data through this protocol?
This protocol works with frames. Each frame has one start bit, 5 to 9 bits of payload (data) and 1 to 2 stop bits. The speed of the protocol is known as baud rate and it's quoted in bits per second (bps). Common baud rates are: 9600, 19200, 38400, 57600 and 115200 bps.
To actually answer the question: With a common configuration of 1 start bit, 8 bits of data, 1 stop bit and a baud rate of 115200 bps one can, in theory, send 11,520 frames per second. Since each one frame carries a byte of data that results in a data rate of 11.52 KB/s. In practice, the data rate will probably be lower because of processing times on the slower side of the communication (the microcontroller).
Today's computers don't support the serial communication protocol. So you can't directly connect your computer to the microcontroller. Luckily for us though, the debug probe on the micro:bit has a so-called USB-to-serial converter. This means that the converter will sit between the two and expose a serial interface to the microcontroller and a USB interface to your computer. The microcontroller will see your computer as another serial device and your computer will see the microcontroller as a virtual serial device.
Now, let's get familiar with the serial module and the serial communication tools that your OS offers. Pick a route:
*nix tooling
Connecting the micro:bit board
If you connect the micro:bit board to your computer you
should see a new TTY device appear in /dev
.
$ # Linux
$ dmesg | tail | grep -i tty
[63712.446286] cdc_acm 1-1.7:1.1: ttyACM0: USB ACM device
This is the USB <-> Serial device. On Linux, it's named tty*
(usually
ttyACM*
or ttyUSB*
).
On Mac OS ls /dev/cu.usbmodem*
will show the serial device.
But what exactly is ttyACM0
? It's a file of course!
Everything is a file in *nix:
$ ls -l /dev/ttyACM0
crw-rw----. 1 root plugdev 166, 0 Jan 21 11:56 /dev/ttyACM0
You can send out data by simply writing to this file:
$ echo 'Hello, world!' > /dev/ttyACM0
You should see the orange LED on the micro:bit, right next to the USB port, blink for a moment, whenever you enter this command.
minicom
We'll use the program minicom
to interact with the serial device using the keyboard.
We must configure minicom
before we use it. There are quite a few ways to do that but we'll use a
.minirc.dfl
file in the home directory. Create a file in ~/.minirc.dfl
with the following
contents:
$ cat ~/.minirc.dfl
pu baudrate 115200
pu bits 8
pu parity N
pu stopbits 1
pu rtscts No
pu xonxoff No
NOTE Make sure this file ends in a newline! Otherwise,
minicom
will fail to read it.
That file should be straightforward to read (except for the last two lines), but nonetheless let's go over it line by line:
pu baudrate 115200
. Sets baud rate to 115200 bps.pu bits 8
. 8 bits per frame.pu parity N
. No parity check.pu stopbits 1
. 1 stop bit.pu rtscts No
. No hardware control flow.pu xonxoff No
. No software control flow.
Once that's in place, we can launch minicom
.
$ # NOTE you may need to use a different device here
$ minicom -D /dev/ttyACM0 -b 115200
This tells minicom
to open the serial device at /dev/ttyACM0
and set its
baud rate to 115200. A text-based user interface (TUI) will pop out.
You can now send data using the keyboard! Go ahead and type something. Note that the text UI will not echo back what you type. If you pay attention to the yellow LED on top of the micro:bit though, you will notice that it blinks whenever you type something.
minicom
commands
minicom
exposes commands via keyboard shortcuts. On Linux, the shortcuts start with Ctrl+A
. On
Mac, the shortcuts start with the Meta
key. Some useful commands below:
Ctrl+A
+Z
. Minicom Command SummaryCtrl+A
+C
. Clear the screenCtrl+A
+X
. Exit and resetCtrl+A
+Q
. Quit with no reset
NOTE Mac users: In the above commands, replace
Ctrl+A
withMeta
.
Windows tooling
Start by unplugging your micro:bit.
Before plugging the micro:bit, run the following command on the terminal:
$ mode
It will print a list of devices that are connected to your computer. The ones that start with COM
in
their names are serial devices. This is the kind of device we'll be working with. Take note of all
the COM
ports mode
outputs before plugging the serial module.
Now, plug in the micro:bit and run the mode
command again. If you see a new
COM
port appear on the list, then that's the COM port assigned to the
serial functionality on the micro:bit.
Now launch putty
. A GUI will pop out.
On the starter screen, which should have the "Session" category open, pick "Serial" as the
"Connection type". On the "Serial line" field enter the COM
device you got on the previous step,
for example COM3
.
Next, pick the "Connection/Serial" category from the menu on the left. On this new view, make sure that the serial port is configured as follows:
- "Speed (baud)": 115200
- "Data bits": 8
- "Stop bits": 1
- "Parity": None
- "Flow control": None
Finally, click the Open button. A console will show up now:
If you type on this console, the yellow LED on top of the micro:bit will blink. Each keystroke should make the LED blink once. Note that the console won't echo back what you type so the screen will remain blank.
UART
The microcontroller has a peripheral called UART, which stands for Universal Asynchronous Receiver/Transmitter. This peripheral can be configured to work with several communication protocols like the serial communication protocol.
Throughout this chapter, we'll use serial communication to exchange information between the microcontroller and your computer.
NOTE that on the micro:bit v2 we will use the so called UARTE peripheral which behaves just like a regular UART, except that the HAL has to talk to it differently. However, this will of course not be our concern.
Setup
As always from now on you will have to modify the Embed.toml
to match your micro:bit version:
[default.general]
# chip = "nrf52833_xxAA" # uncomment this line for micro:bit V2
# chip = "nrf51822_xxAA" # uncomment this line for micro:bit V1
[default.reset]
halt_afterwards = false
[default.rtt]
enabled = true
[default.gdb]
enabled = false
Send a single byte
Our first task will be to send a single byte from the microcontroller to the computer over the serial connection.
In order to do that we will use the following snippet (this one is already in 07-uart/src/main.rs
):
#![no_main] #![no_std] use cortex_m_rt::entry; use rtt_target::rtt_init_print; use panic_rtt_target as _; #[cfg(feature = "v1")] use microbit::{ hal::prelude::*, hal::uart, hal::uart::{Baudrate, Parity}, }; #[cfg(feature = "v2")] use microbit::{ hal::prelude::*, hal::uarte, hal::uarte::{Baudrate, Parity}, }; #[cfg(feature = "v2")] mod serial_setup; #[cfg(feature = "v2")] use serial_setup::UartePort; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let mut serial = { uart::Uart::new( board.UART0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ) }; #[cfg(feature = "v2")] let mut serial = { let serial = uarte::Uarte::new( board.UARTE0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ); UartePort::new(serial) }; nb::block!(serial.write(b'X')).unwrap(); nb::block!(serial.flush()).unwrap(); loop {} }
The most prevalent new thing here is obviously the cfg
directives to conditionally include/exclude
parts of the code. This is mostly just because we want to work with a regular UART for the micro:bit v1
and with the UARTE for micro:bit v2.
You will also have noticed that this is the first time we are including some code that is not from a library,
namely the serial_setup
module. Its only purpose is to provide a nice wrapper around the UARTE
so we can use it the exact same way as the UART via the embedded_hal::serial
traits. If you want, you can
check out what exactly the module does, but it is not required to understand this chapter in general.
Apart from those differences, the initialization procedures for the UART and the UARTE are quite similar so we'll discuss the initialization of just UARTE. The UARTE is initialized with this piece of code:
uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
This function takes ownership of the UARTE peripheral representation in Rust (board.UARTE0
) and the TX/RX pins
on the board (board.uart.into()
) so nobody else can mess with either the UARTE peripheral or our pins while
we are using them. After that we pass two configuration options to the constructor: the baudrate (that one should be
familiar) as well as an option called "parity". Parity is a way to allow serial communication lines to check whether
the data they received was corrupted during transmission. We don't want to use that here so we simply exclude it.
Then we wrap it up in the UartePort
type so we can use it the same way as the micro:bit v1's serial
.
After the initialization, we send our X
via the newly created uart instance. The block!
macro here is the nb::block!
macro. nb
is a (quoting from its description) "Minimal and reusable non-blocking I/O layer". It allows us to write
code that can conduct hardware operations in the background while we go and do other work (non-blocking). However,
in this and many other cases we have no interest in doing some other work so we just call block!
which will wait until
the I/O operation is done and has either succeeded or failed and then continue execution normally.
Last but not least, we flush()
the serial port. This is because an implementor of the embedded-hal::serial
traits may
decide to buffer output until it has received a certain number of bytes to send (this is the case with the UARTE implementation).
Calling flush()
forces it to write the bytes it currently has right now instead of waiting for more.
Testing it
Before flashing this you should make sure to start your minicom/PuTTY as the data we receive via our serial communication is not backed up or anything, we have to view it live. Once your serial monitor is up you can flash the program just like in chapter 5:
# For micro:bit v2
$ cargo embed --features v2 --target thumbv7em-none-eabihf
(...)
# For micro:bit v1
$ cargo embed --features v1 --target thumbv6m-none-eabi
And after the flashing is finished, you should see the character X
show up on your minicom/PuTTY terminal, congrats!
Send a string
The next task will be to send a whole string from the microcontroller to your computer.
I want you to send the string "The quick brown fox jumps over the lazy dog."
from the microcontroller to
your computer.
It's your turn to write the program.
Naive approach and write!
Naive approach
You probably came up with a program similar to the following:
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use rtt_target::rtt_init_print;
use panic_rtt_target as _;
#[cfg(feature = "v1")]
use microbit::{
hal::prelude::*,
hal::uart,
hal::uart::{Baudrate, Parity},
};
#[cfg(feature = "v2")]
use microbit::{
hal::prelude::*,
hal::uarte,
hal::uarte::{Baudrate, Parity},
};
#[cfg(feature = "v2")]
mod serial_setup;
#[cfg(feature = "v2")]
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
#[cfg(feature = "v1")]
let mut serial = {
uart::Uart::new(
board.UART0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
)
};
#[cfg(feature = "v2")]
let mut serial = {
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
UartePort::new(serial)
};
for byte in b"The quick brown fox jumps over the lazy dog.\r\n".iter() {
nb::block!(serial.write(*byte)).unwrap();
}
nb::block!(serial.flush()).unwrap();
loop {}
}
While this is a perfectly valid implementation, at some point
you might want to have all the nice perks of print!
such
as argument formatting and so on. If you are wondering how to do that, read on.
write!
and core::fmt::Write
The core::fmt::Write
trait allows us to use any struct that implements
it in basically the same way as we use print!
in the std
world.
In this case, the Uart
struct from the nrf
HAL does implement core::fmt::Write
so we can refactor our previous program into this:
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use rtt_target::rtt_init_print;
use panic_rtt_target as _;
use core::fmt::Write;
#[cfg(feature = "v1")]
use microbit::{
hal::prelude::*,
hal::uart,
hal::uart::{Baudrate, Parity},
};
#[cfg(feature = "v2")]
use microbit::{
hal::prelude::*,
hal::uarte,
hal::uarte::{Baudrate, Parity},
};
#[cfg(feature = "v2")]
mod serial_setup;
#[cfg(feature = "v2")]
use serial_setup::UartePort;
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
#[cfg(feature = "v1")]
let mut serial = {
uart::Uart::new(
board.UART0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
)
};
#[cfg(feature = "v2")]
let mut serial = {
let serial = uarte::Uarte::new(
board.UARTE0,
board.uart.into(),
Parity::EXCLUDED,
Baudrate::BAUD115200,
);
UartePort::new(serial)
};
write!(serial, "The quick brown fox jumps over the lazy dog.\r\n").unwrap();
nb::block!(serial.flush()).unwrap();
loop {}
}
If you were to flash this program onto your micro:bit, you'll see that it is functionally equivalent to the iterator-based program you came up with.
Receive a single byte
So far we can send data from the microcontroller to your computer. It's time to try the opposite: receiving
data from your computer. Luckily embedded-hal
has again got us covered with this one:
#![no_main] #![no_std] use cortex_m_rt::entry; use rtt_target::{rtt_init_print, rprintln}; use panic_rtt_target as _; #[cfg(feature = "v1")] use microbit::{ hal::prelude::*, hal::uart, hal::uart::{Baudrate, Parity}, }; #[cfg(feature = "v2")] use microbit::{ hal::prelude::*, hal::uarte, hal::uarte::{Baudrate, Parity}, }; #[cfg(feature = "v2")] mod serial_setup; #[cfg(feature = "v2")] use serial_setup::UartePort; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let mut serial = { uart::Uart::new( board.UART0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ) }; #[cfg(feature = "v2")] let mut serial = { let serial = uarte::Uarte::new( board.UARTE0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ); UartePort::new(serial) }; loop { let byte = nb::block!(serial.read()).unwrap(); rprintln!("{}", byte); } }
The only part that changed, compared to our send byte program, is the loop
at the end of main()
. Here we use the read()
function, provided by embedded-hal
,
in order to wait until a byte is available and read it. Then we print that byte
into our RTT debugging console to see whether stuff is actually arriving.
Note that if you flash this program and start typing characters inside minicom
to
send them to your microcontroller you'll only be able to see numbers inside your
RTT console since we are not converting the u8
we received into an actual char
.
Since the conversion from u8
to char
is quite simple, I'll leave this task to
you if you really do want to see the characters inside the RTT console.
Echo server
Let's merge transmission and reception into a single program and write an echo server. An echo server sends back to the client the same text it receives. For this application, the microcontroller will be the server and you and your computer will be the client.
This should be straightforward to implement. (hint: do it byte by byte)
Reverse a string
Alright, next let's make the server more interesting by having it respond to the client with the reverse of the text that they sent. The server will respond to the client every time they press the ENTER key. Each server response will be in a new line.
This time you'll need a buffer; you can use heapless::Vec
. Here's the starter code:
#![no_main] #![no_std] use cortex_m_rt::entry; use core::fmt::Write; use heapless::Vec; use rtt_target::rtt_init_print; use panic_rtt_target as _; #[cfg(feature = "v1")] use microbit::{ hal::prelude::*, hal::uart, hal::uart::{Baudrate, Parity}, }; #[cfg(feature = "v2")] use microbit::{ hal::prelude::*, hal::uarte, hal::uarte::{Baudrate, Parity}, }; #[cfg(feature = "v2")] mod serial_setup; #[cfg(feature = "v2")] use serial_setup::UartePort; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let mut serial = { uart::Uart::new( board.UART0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ) }; #[cfg(feature = "v2")] let mut serial = { let serial = uarte::Uarte::new( board.UARTE0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ); UartePort::new(serial) }; // A buffer with 32 bytes of capacity let mut buffer: Vec<u8, 32> = Vec::new(); loop { buffer.clear(); // TODO Receive a user request. Each user request ends with ENTER // NOTE `buffer.push` returns a `Result`. Handle the error by responding // with an error message. // TODO Send back the reversed string } }
My solution
#![no_main] #![no_std] use cortex_m_rt::entry; use core::fmt::Write; use heapless::Vec; use rtt_target::rtt_init_print; use panic_rtt_target as _; #[cfg(feature = "v1")] use microbit::{ hal::prelude::*, hal::uart, hal::uart::{Baudrate, Parity}, }; #[cfg(feature = "v2")] use microbit::{ hal::prelude::*, hal::uarte, hal::uarte::{Baudrate, Parity}, }; #[cfg(feature = "v2")] mod serial_setup; #[cfg(feature = "v2")] use serial_setup::UartePort; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let mut serial = { uart::Uart::new( board.UART0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ) }; #[cfg(feature = "v2")] let mut serial = { let serial = uarte::Uarte::new( board.UARTE0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ); UartePort::new(serial) }; // A buffer with 32 bytes of capacity let mut buffer: Vec<u8, 32> = Vec::new(); loop { buffer.clear(); loop { // We assume that the receiving cannot fail let byte = nb::block!(serial.read()).unwrap(); if buffer.push(byte).is_err() { write!(serial, "error: buffer full\r\n").unwrap(); break; } if byte == 13 { for byte in buffer.iter().rev().chain(&[b'\n', b'\r']) { nb::block!(serial.write(*byte)).unwrap(); } break; } } nb::block!(serial.flush()).unwrap() } }
I2C
We just saw the serial communication protocol. It's a widely used protocol because it's very simple and this simplicity makes it easy to implement on top of other protocols like Bluetooth and USB.
However, its simplicity is also a downside. More elaborated data exchanges, like reading a digital sensor, would require the sensor vendor to come up with another protocol on top of it.
(Un)Luckily for us, there are plenty of other communication protocols in the embedded space. Some of them are widely used in digital sensors.
The micro:bit board we are using has two motion sensors in it: an accelerometer and a magnetometer. Both of these sensors are packaged into a single component and can be accessed via an I2C bus.
I2C stands for Inter-Integrated Circuit and is a synchronous serial communication protocol. It uses two lines to exchange data: a data line (SDA) and a clock line (SCL). Because a clock line is used to synchronize the communication, this is a synchronous protocol.
This protocol uses a controller target model where the controller is the device that starts and drives the communication with a target device. Several devices, both controllers and targets, can be connected to the same bus at the same time. A controller device can communicate with a specific target device by first broadcasting its address to the bus. This address can be 7 bits or 10 bits long. Once a controller has started a communication with a target, no other device can make use of the bus until the controller stops the communication.
The clock line determines how fast data can be exchanged and it usually operates at a frequency of 100 kHz (standard mode) or 400 kHz (fast mode).
General protocol
The I2C protocol is more elaborate than the serial communication protocol because it has to support communication between several devices. Let's see how it works using examples:
Controller -> Target
If the Controller wants to send data to the Target:
- Controller: Broadcast START
- C: Broadcast target address (7 bits) + the R/W (8th) bit set to WRITE
- Target: Responds ACK (ACKnowledgement)
- C: Send one byte
- T: Responds ACK
- Repeat steps 4 and 5 zero or more times
- C: Broadcast STOP OR (broadcast RESTART and go back to (2))
NOTE The target address could have been 10 bits instead of 7 bits long. Nothing else would have changed.
Controller <- Target
If the controller wants to read data from the target:
- C: Broadcast START
- C: Broadcast target address (7 bits) + the R/W (8th) bit set to READ
- T: Responds with ACK
- T: Send byte
- C: Responds with ACK
- Repeat steps 4 and 5 zero or more times
- C: Broadcast STOP OR (broadcast RESTART and go back to (2))
NOTE The target address could have been 10 bits instead of 7 bits long. Nothing else would have changed.
LSM303AGR
Both of the motion sensors on the micro:bit, the magnetometer and the accelerometer, are packaged in a single component: the LSM303AGR integrated circuit. These two sensors can be accessed via an I2C bus. Each sensor behaves like an I2C target and has a different address.
Each sensor has its own memory where it stores the results of sensing its environment. Our interaction with these sensors will mainly involve reading their memory.
The memory of these sensors is modeled as byte addressable registers. These sensors can be configured too; that's done by writing to their registers. So, in a sense, these sensors are very similar to the peripherals inside the microcontroller. The difference is that their registers are not mapped into the microcontrollers' memory. Instead, their registers have to be accessed via the I2C bus.
The main source of information about the LSM303AGR is its Data Sheet. Read through it to see how one can read the sensors' registers. That part is in:
Section 6.1.1 I2C Operation - Page 38 - LSM303AGR Data Sheet
The other part of the documentation relevant to this book is the description of the registers. That part is in:
Section 8 Register description - Page 46 - LSM303AGR Data Sheet
Read a single register
Let's put all that theory into practice!
First things first we need to know the target addresses of both the accelerometer and the magnetometer inside the chip, these can be found in the LSM303AGR's datasheet on page 39 and are:
- 0011001 for the accelerometer
- 0011110 for the magnetometer
NOTE Remember that these are only the 7 leading bits of the address, the 8th bit is going to be the bit that determines whether we are performing a read or write.
Next up we'll need a register to read from. Lots of I2C chips out there will
provide some sort of device identification register for their controllers to read.
This is done since considering the thousands (or even millions) of I2C chips
out there it is highly likely that at some point two chips with the same address
will end up being built (after all the address is "only" 7 bit wide). With
this device ID register a driver could then make sure that it is indeed talking
to a LSM303AGR and not some other chip that just happens to have the same address.
As you can read in the LSM303AGR's datasheet (specifically on page 46 and 61)
it does provide two registers called WHO_AM_I_A
at address 0x0f
and WHO_AM_I_M
at address 0x4f
which contain some bit patterns that are unique to the device
(The A is as in accelerometer and the M is as in magnetometer).
The only thing missing now is the software part, i.e. which API of the microbit
/the HAL
crates we should use for this. However, if you read through the datasheet of the nRF chip
you are using you will soon find out that they don't actually have an I2C peripheral.
Luckily for us though, they have I2C-compatible ones called TWI (Two Wire Interface)
and TWIM (depending on which chip you use, just like UART and UARTE).
Now if we put the documentation of the twi(m)
module from the microbit
crate
together with all the other information we have gathered so far we'll end up with this
piece of code to read out and print the two device IDs:
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use rtt_target::{rtt_init_print, rprintln}; use panic_rtt_target as _; use microbit::hal::prelude::*; #[cfg(feature = "v1")] use microbit::{ hal::twi, pac::twi0::frequency::FREQUENCY_A, }; #[cfg(feature = "v2")] use microbit::{ hal::twim, pac::twim0::frequency::FREQUENCY_A, }; const ACCELEROMETER_ADDR: u8 = 0b0011001; const MAGNETOMETER_ADDR: u8 = 0b0011110; const ACCELEROMETER_ID_REG: u8 = 0x0f; const MAGNETOMETER_ID_REG: u8 = 0x4f; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let mut i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) }; #[cfg(feature = "v2")] let mut i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) }; let mut acc = [0]; let mut mag = [0]; // First write the address + register onto the bus, then read the chip's responses i2c.write_read(ACCELEROMETER_ADDR, &[ACCELEROMETER_ID_REG], &mut acc).unwrap(); i2c.write_read(MAGNETOMETER_ADDR, &[MAGNETOMETER_ID_REG], &mut mag).unwrap(); rprintln!("The accelerometer chip's id is: {:#b}", acc[0]); rprintln!("The magnetometer chip's id is: {:#b}", mag[0]); loop {} }
Apart from the initialization, this piece of code should be straight forward if you
understood the I2C protocol as described before. The initialization here works similarly
to the one from the UART chapter.
We pass the peripheral as well as the pins that are used to communicate with the chip to the constructor; and then the frequency we wish the bus to operate on, in this case 100 kHz (K100
).
Testing it
As always you have to modify Embed.toml
to fit your MCU and can then use:
# For micro:bit v2
$ cargo embed --features v2 --target thumbv7em-none-eabihf
# For micro:bit v1
$ cargo embed --features v1 --target thumbv6m-none-eabi
in order to test our little example program.
Using a driver
As we already discussed in chapter 5 embedded-hal
provides abstractions
which can be used to write platform independent code that can interact with
hardware. In fact all the methods we have used to interact with hardware
in chapter 7 and up until now in chapter 8 were from traits, defined by embedded-hal
.
Now we'll make actual use of the traits embedded-hal
provides for the first time.
It would be pointless to implement a driver for our LSM303AGR for every platform
embedded Rust supports (and new ones that might eventually pop up). To avoid this a driver
can be written that consumes generic types that implement embedded-hal
traits in order to provide
a platform agnostic version of a driver. Luckily for us this has already been done in the
lsm303agr
crate. Hence reading the actual accelerometer and magnetometer values will now
be basically a plug and play experience (plus reading a bit of documentation). In fact the crates.io
page already provides us with everything we need to know in order to read accelerometer data but using a Raspberry Pi. We'll
just have to adapt it to our chip:
use linux_embedded_hal::I2cdev; use lsm303agr::{AccelOutputDataRate, Lsm303agr}; fn main() { let dev = I2cdev::new("/dev/i2c-1").unwrap(); let mut sensor = Lsm303agr::new_with_i2c(dev); sensor.init().unwrap(); sensor.set_accel_odr(AccelOutputDataRate::Hz50).unwrap(); loop { if sensor.accel_status().unwrap().xyz_new_data { let data = sensor.accel_data().unwrap(); println!("Acceleration: x {} y {} z {}", data.x, data.y, data.z); } } }
Because we already know how to create an instance of an object that implements
the embedded_hal::blocking::i2c
traits from the previous page, this is quite trivial:
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use rtt_target::{rtt_init_print, rprintln}; use panic_rtt_target as _; #[cfg(feature = "v1")] use microbit::{ hal::twi, pac::twi0::frequency::FREQUENCY_A, }; #[cfg(feature = "v2")] use microbit::{ hal::twim, pac::twim0::frequency::FREQUENCY_A, }; use lsm303agr::{ AccelOutputDataRate, Lsm303agr, }; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) }; #[cfg(feature = "v2")] let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) }; // Code from documentation let mut sensor = Lsm303agr::new_with_i2c(i2c); sensor.init().unwrap(); sensor.set_accel_odr(AccelOutputDataRate::Hz50).unwrap(); loop { if sensor.accel_status().unwrap().xyz_new_data { let data = sensor.accel_data().unwrap(); // RTT instead of normal print rprintln!("Acceleration: x {} y {} z {}", data.x, data.y, data.z); } } }
Just like the last snippet you should just be able to try this out like this:
# For micro:bit v2
$ cargo embed --features v2 --target thumbv7em-none-eabihf
# For micro:bit v1
$ cargo embed --features v1 --target thumbv6m-none-eabi
Furthermore if you (physically) move around your micro:bit a little you should see the acceleration numbers that are being printed change.
The challenge
The challenge for this chapter is, to build a small application that communicates with the outside world via the serial interface introduced in the last chapter. It should be able to receive the commands "magnetometer" as well as "accelerometer" and then print the corresponding sensor data in response. This time no template code will be provided since all you need is already provided in the UART and this chapter. However, here are a few clues:
- You might be interested in
core::str::from_utf8
to convert the bytes in the buffer to a&str
, since we need to compare with"magnetometer"
and"accelerometer"
. - You will (obviously) have to read the documentation of the magnetometer API, however it's more or less equivalent to the accelerometer one
My solution
#![no_main] #![no_std] use core::str; use cortex_m_rt::entry; use rtt_target::rtt_init_print; use panic_rtt_target as _; #[cfg(feature = "v1")] use microbit::{ hal::twi, pac::twi0::frequency::FREQUENCY_A, hal::uart, hal::uart::{Baudrate, Parity}, }; #[cfg(feature = "v2")] use microbit::{ hal::twim, pac::twim0::frequency::FREQUENCY_A, hal::uarte, hal::uarte::{Baudrate, Parity}, }; use microbit::hal::prelude::*; use lsm303agr::{AccelOutputDataRate, MagOutputDataRate, Lsm303agr}; use heapless::Vec; use nb::block; use core::fmt::Write; #[cfg(feature = "v2")] mod serial_setup; #[cfg(feature = "v2")] use serial_setup::UartePort; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let mut serial = { uart::Uart::new( board.UART0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ) }; #[cfg(feature = "v2")] let mut serial = { let serial = uarte::Uarte::new( board.UARTE0, board.uart.into(), Parity::EXCLUDED, Baudrate::BAUD115200, ); UartePort::new(serial) }; #[cfg(feature = "v1")] let i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) }; #[cfg(feature = "v2")] let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) }; let mut sensor = Lsm303agr::new_with_i2c(i2c); sensor.init().unwrap(); sensor.set_accel_odr(AccelOutputDataRate::Hz50).unwrap(); sensor.set_mag_odr(MagOutputDataRate::Hz50).unwrap(); let mut sensor = sensor.into_mag_continuous().ok().unwrap(); loop { let mut buffer: Vec<u8, 32> = Vec::new(); loop { let byte = block!(serial.read()).unwrap(); if byte == 13 { break; } if buffer.push(byte).is_err() { write!(serial, "error: buffer full\r\n").unwrap(); break; } } if str::from_utf8(&buffer).unwrap().trim() == "accelerometer" { while !sensor.accel_status().unwrap().xyz_new_data { } let data = sensor.accel_data().unwrap(); write!(serial, "Accelerometer: x {} y {} z {}\r\n", data.x, data.y, data.z).unwrap(); } else if str::from_utf8(&buffer).unwrap().trim() == "magnetometer" { while !sensor.mag_status().unwrap().xyz_new_data { } let data = sensor.mag_data().unwrap(); write!(serial, "Magnetometer: x {} y {} z {}\r\n", data.x, data.y, data.z).unwrap(); } else { write!(serial, "error: command not detected\r\n").unwrap(); } } }
LED compass
In this section, we'll implement a compass using the LEDs on the micro:bit. Like proper compasses, our LED compass must point north somehow. It will do that by turning on one of its outer LEDs; the LED turned on should point towards north.
Magnetic fields have both a magnitude, measured in Gauss or Teslas, and a direction. The magnetometer on the micro:bit measures both the magnitude and the direction of an external magnetic field but it reports back the decomposition of said field along its axes.
The magnetometer has three axes associated to it. The X and Y axes basically span the plane that is the floor. The Z axis is pointing "out" of the floor, so upwards.
You should already be able to write a program that continuously prints the magnetometer data on the RTT console from the I2C chapter. After you wrote that program, locate where north is at your current location. Then line up your micro:bit with that direction and observe how the sensor's measurements look.
Now rotate the board 90 degrees while keeping it parallel to the ground. What X, Y and Z values do you see this time? Then rotate it 90 degrees again. What values do you see?
Calibration
One very important thing to do before using a sensor and trying to develop an application using it is verifying that it's output is actually correct. If this does not happen to be the case we need to calibrate the sensor (alternatively it could also be broken but that's rather unlikely in this case).
In my case on two different micro:bit's the magnetometer, without calibration, was quite a bit off of what it is supposed to measure. Hence for the purposes of this chapter we will just assume that the sensor has to be calibrated.
The calibration involves quite a bit of math (matrices) so we won't cover it here but this Design Note describes the procedure if you are interested.
Luckily for us though the group that built the original software for the micro:bit already implemented a calibration mechanism in C++ over here.
You can find a translation of it to Rust in src/calibration.rs
. The usage
is demonstrated in the default src/main.rs
file. The way the calibration
works is illustrated in this video:
You have to basically tilt the micro:bit until all the LEDs on the LED matrix light up.
If you do not want to play the game every time you restart your application during development
feel free to modify the src/main.rs
template to just use the same static calibration
once you got the first one.
Now where we got the sensor calibration out of the way let's look into actually building this application!
Take 1
What's the simplest way in which we can implement the LED compass, even if it's not perfect?
For starters, we'd only care about the X and Y components of the magnetic field because when you look at a compass you always hold it in horizontal position and thus the compass is in the XY plane.
If we only looked at the signs of the X and Y components we could determine to which quadrant the magnetic field belongs to. Now the question of course is which direction (north, north-east, etc.) do the 4 quadrants represent. In order to figure this out we can just rotate the micro:bit and observe how the quadrant changes whenever we point in another direction.
After experimenting a bit we can find out that if we point the micro:bit in e.g. north-east direction, both the X and the Y component are always positive. Based on this information you should be able to figure out which direction the other quadrants represent.
Once you figured out the relation between quadrant and direction you should be able to complete the template from below.
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use panic_rtt_target as _; use rtt_target::{rprintln, rtt_init_print}; mod calibration; use crate::calibration::calc_calibration; use crate::calibration::calibrated_measurement; mod led; use led::Direction; use microbit::{display::blocking::Display, hal::Timer}; #[cfg(feature = "v1")] use microbit::{hal::twi, pac::twi0::frequency::FREQUENCY_A}; #[cfg(feature = "v2")] use microbit::{hal::twim, pac::twim0::frequency::FREQUENCY_A}; use lsm303agr::{AccelOutputDataRate, Lsm303agr, MagOutputDataRate}; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) }; #[cfg(feature = "v2")] let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) }; let mut timer = Timer::new(board.TIMER0); let mut display = Display::new(board.display_pins); let mut sensor = Lsm303agr::new_with_i2c(i2c); sensor.init().unwrap(); sensor.set_mag_odr(MagOutputDataRate::Hz10).unwrap(); sensor.set_accel_odr(AccelOutputDataRate::Hz10).unwrap(); let mut sensor = sensor.into_mag_continuous().ok().unwrap(); let calibration = calc_calibration(&mut sensor, &mut display, &mut timer); rprintln!("Calibration: {:?}", calibration); rprintln!("Calibration done, entering busy loop"); loop { while !sensor.mag_status().unwrap().xyz_new_data {} let mut data = sensor.mag_data().unwrap(); data = calibrated_measurement(data, &calibration); let dir = match (data.x > 0, data.y > 0) { // Quadrant ??? (true, true) => Direction::NorthEast, // Quadrant ??? (false, true) => panic!("TODO"), // Quadrant ??? (false, false) => panic!("TODO"), // Quadrant ??? (true, false) => panic!("TODO"), }; // use the led module to turn the direction into an LED arrow // and the led display functions from chapter 5 to display the // arrow } }
Solution 1
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use panic_rtt_target as _; use rtt_target::{rprintln, rtt_init_print}; mod calibration; use crate::calibration::calc_calibration; use crate::calibration::calibrated_measurement; mod led; use crate::led::Direction; use crate::led::direction_to_led; use microbit::{display::blocking::Display, hal::Timer}; #[cfg(feature = "v1")] use microbit::{hal::twi, pac::twi0::frequency::FREQUENCY_A}; #[cfg(feature = "v2")] use microbit::{hal::twim, pac::twim0::frequency::FREQUENCY_A}; use lsm303agr::{AccelOutputDataRate, Lsm303agr, MagOutputDataRate}; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) }; #[cfg(feature = "v2")] let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) }; let mut timer = Timer::new(board.TIMER0); let mut display = Display::new(board.display_pins); let mut sensor = Lsm303agr::new_with_i2c(i2c); sensor.init().unwrap(); sensor.set_mag_odr(MagOutputDataRate::Hz10).unwrap(); sensor.set_accel_odr(AccelOutputDataRate::Hz10).unwrap(); let mut sensor = sensor.into_mag_continuous().ok().unwrap(); let calibration = calc_calibration(&mut sensor, &mut display, &mut timer); rprintln!("Calibration: {:?}", calibration); rprintln!("Calibration done, entering busy loop"); loop { while !sensor.mag_status().unwrap().xyz_new_data {} let mut data = sensor.mag_data().unwrap(); data = calibrated_measurement(data, &calibration); let dir = match (data.x > 0, data.y > 0) { // Quadrant I (true, true) => Direction::NorthEast, // Quadrant II (false, true) => Direction::NorthWest, // Quadrant III (false, false) => Direction::SouthWest, // Quadrant IV (true, false) => Direction::SouthEast, }; // use the led module to turn the direction into an LED arrow // and the led display functions from chapter 5 to display the // arrow display.show(&mut timer, direction_to_led(dir), 100); } }
Take 2
This time, we'll use math to get the precise angle that the magnetic field forms with the X and Y axes of the magnetometer.
We'll use the atan2
function. This function returns an angle in the -PI
to PI
range. The
graphic below shows how this angle is measured:
Although not explicitly shown in this graph the X axis points to the right and the Y axis points up.
Here's the starter code. theta
, in radians, has already been computed. You need to pick which LED
to turn on based on the value of theta
.
#![deny(unsafe_code)]
#![no_main]
#![no_std]
use cortex_m_rt::entry;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
mod calibration;
use crate::calibration::calc_calibration;
use crate::calibration::calibrated_measurement;
mod led;
use crate::led::Direction;
use crate::led::direction_to_led;
// You'll find this useful ;-)
use core::f32::consts::PI;
use libm::atan2f;
use microbit::{display::blocking::Display, hal::Timer};
#[cfg(feature = "v1")]
use microbit::{hal::twi, pac::twi0::frequency::FREQUENCY_A};
#[cfg(feature = "v2")]
use microbit::{hal::twim, pac::twim0::frequency::FREQUENCY_A};
use lsm303agr::{AccelOutputDataRate, Lsm303agr, MagOutputDataRate};
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = microbit::Board::take().unwrap();
#[cfg(feature = "v1")]
let i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) };
#[cfg(feature = "v2")]
let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) };
let mut timer = Timer::new(board.TIMER0);
let mut display = Display::new(board.display_pins);
let mut sensor = Lsm303agr::new_with_i2c(i2c);
sensor.init().unwrap();
sensor.set_mag_odr(MagOutputDataRate::Hz10).unwrap();
sensor.set_accel_odr(AccelOutputDataRate::Hz10).unwrap();
let mut sensor = sensor.into_mag_continuous().ok().unwrap();
let calibration = calc_calibration(&mut sensor, &mut display, &mut timer);
rprintln!("Calibration: {:?}", calibration);
rprintln!("Calibration done, entering busy loop");
loop {
while !sensor.mag_status().unwrap().xyz_new_data {}
let mut data = sensor.mag_data().unwrap();
data = calibrated_measurement(data, &calibration);
// use libm's atan2f since this isn't in core yet
let theta = atan2f(data.y as f32, data.x as f32);
// Figure out the direction based on theta
let dir = Direction::NorthEast;
display.show(&mut timer, direction_to_led(dir), 100);
}
}
Suggestions/tips:
- A whole circle rotation equals 360 degrees.
PI
radians is equivalent to 180 degrees.- If
theta
was zero, which direction are you pointing at? - If
theta
was, instead, very close to zero, which direction are you pointing at? - If
theta
kept increasing, at what value would you change the direction
Solution 2
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use panic_rtt_target as _; use rtt_target::{rprintln, rtt_init_print}; mod calibration; use crate::calibration::calc_calibration; use crate::calibration::calibrated_measurement; mod led; use crate::led::Direction; use crate::led::direction_to_led; // You'll find this useful ;-) use core::f32::consts::PI; use libm::atan2f; use microbit::{display::blocking::Display, hal::Timer}; #[cfg(feature = "v1")] use microbit::{hal::twi, pac::twi0::frequency::FREQUENCY_A}; #[cfg(feature = "v2")] use microbit::{hal::twim, pac::twim0::frequency::FREQUENCY_A}; use lsm303agr::{AccelOutputDataRate, Lsm303agr, MagOutputDataRate}; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) }; #[cfg(feature = "v2")] let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) }; let mut timer = Timer::new(board.TIMER0); let mut display = Display::new(board.display_pins); let mut sensor = Lsm303agr::new_with_i2c(i2c); sensor.init().unwrap(); sensor.set_mag_odr(MagOutputDataRate::Hz10).unwrap(); sensor.set_accel_odr(AccelOutputDataRate::Hz10).unwrap(); let mut sensor = sensor.into_mag_continuous().ok().unwrap(); let calibration = calc_calibration(&mut sensor, &mut display, &mut timer); rprintln!("Calibration: {:?}", calibration); rprintln!("Calibration done, entering busy loop"); loop { while !sensor.mag_status().unwrap().xyz_new_data {} let mut data = sensor.mag_data().unwrap(); data = calibrated_measurement(data, &calibration); // use libm's atan2f since this isn't in core yet let theta = atan2f(data.y as f32, data.x as f32); // Figure out the direction based on theta let dir = if theta < -7. * PI / 8. { Direction::West } else if theta < -5. * PI / 8. { Direction::SouthWest } else if theta < -3. * PI / 8. { Direction::South } else if theta < -PI / 8. { Direction::SouthEast } else if theta < PI / 8. { Direction::East } else if theta < 3. * PI / 8. { Direction::NorthEast } else if theta < 5. * PI / 8. { Direction::North } else if theta < 7. * PI / 8. { Direction::NorthWest } else { Direction::West }; display.show(&mut timer, direction_to_led(dir), 100); } }
Magnitude
We have been working with the direction of the magnetic field but what is its real magnitude?
According to the documentation about the mag_data()
function the x
y
z
values we are
getting are in nanotesla. That means the only thing we have to compute in order to get the
magnitude of the magnetic field in nanotesla is the magnitude of the 3D vector that our x
y
z
values describe. As you might remember from school this is simply:
#![allow(unused)] fn main() { // core doesn't have this function yet so we use libm, just like with // atan2f from before. use libm::sqrtf; let magnitude = sqrtf(x * x + y * y + z * z); }
Putting all this together in a program:
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use panic_rtt_target as _; use rtt_target::{rprintln, rtt_init_print}; mod calibration; use crate::calibration::calc_calibration; use crate::calibration::calibrated_measurement; use libm::sqrtf; use microbit::{display::blocking::Display, hal::Timer}; #[cfg(feature = "v1")] use microbit::{hal::twi, pac::twi0::frequency::FREQUENCY_A}; #[cfg(feature = "v2")] use microbit::{hal::twim, pac::twim0::frequency::FREQUENCY_A}; use lsm303agr::{AccelOutputDataRate, Lsm303agr, MagOutputDataRate}; #[entry] fn main() -> ! { rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) }; #[cfg(feature = "v2")] let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) }; let mut timer = Timer::new(board.TIMER0); let mut display = Display::new(board.display_pins); let mut sensor = Lsm303agr::new_with_i2c(i2c); sensor.init().unwrap(); sensor.set_mag_odr(MagOutputDataRate::Hz10).unwrap(); sensor.set_accel_odr(AccelOutputDataRate::Hz10).unwrap(); let mut sensor = sensor.into_mag_continuous().ok().unwrap(); let calibration = calc_calibration(&mut sensor, &mut display, &mut timer); rprintln!("Calibration: {:?}", calibration); rprintln!("Calibration done, entering busy loop"); loop { while !sensor.mag_status().unwrap().xyz_new_data {} let mut data = sensor.mag_data().unwrap(); data = calibrated_measurement(data, &calibration); let x = data.x as f32; let y = data.y as f32; let z = data.z as f32; let magnitude = sqrtf(x * x + y * y + z * z); rprintln!("{} nT, {} mG", magnitude, magnitude/100.0); } }
This program will report the magnitude (strength) of the magnetic field in nanotesla (nT
) and milligauss (mG
). The
magnitude of the Earth's magnetic field is in the range of 250 mG
to 650 mG
(the magnitude
varies depending on your geographical location) so you should see a value in that range or close to
that range -- I see a magnitude of around 340 mG
.
Some questions:
Without moving the board, what value do you see? Do you always see the same value?
If you rotate the board, does the magnitude change? Should it change?
Punch-o-meter
In this section we'll be playing with the accelerometer that's in the board.
What are we building this time? A punch-o-meter! We'll be measuring the power of your jabs. Well, actually the maximum acceleration that you can reach because acceleration is what accelerometers measure. Strength and acceleration are proportional though so it's a good approximation.
As we already know from previous chapters the accelerometer is built inside the LSM303AGR package. And just like the magnetometer, it is accessible using the I2C bus. It also has the same coordinate system as the magnetometer.
Gravity is up?
What's the first thing we'll do?
Perform a sanity check!
You should already be able to write a program that continuously prints the accelerometer data on the RTT console from the I2C chapter. Do you observe something interesting even when holding the board parallel to the floor with the LED side facing down?
What you should see like this is that both the X and Y values are rather close to 0, while the
Z value is at around 1000. Which is weird because the board is not moving yet its acceleration is
non-zero. What's going on? This must be related to the gravity, right? Because the acceleration of
gravity is 1 g
(aha, 1 g
= 1000 from the accelerometer). But the gravity pulls objects downwards
so the acceleration along the Z axis should be negative not positive
Did the program get the Z axis backwards? Nope, you can test rotating the board to align the gravity to the X or Y axis but the acceleration measured by the accelerometer is always pointing up.
What happens here is that the accelerometer is measuring the proper acceleration of the board not
the acceleration you are observing. This proper acceleration is the acceleration of the board as
seen from an observer that's in free fall. An observer that's in free fall is moving toward the
center of the Earth with an acceleration of 1g
; from its point of view the board is actually
moving upwards (away from the center of the Earth) with an acceleration of 1g
. And that's why the
proper acceleration is pointing up. This also means that if the board was in free fall, the
accelerometer would report a proper acceleration of zero. Please, don't try that at home.
Yes, physics is hard. Let's move on.
The challenge
To keep things simple, we'll measure the acceleration only in the X axis while the board remains
horizontal. That way we won't have to deal with subtracting that fictitious 1g
we observed
before which would be hard because that 1g
could have X Y Z components depending on how the board
is oriented.
Here's what the punch-o-meter must do:
- By default, the app is not "observing" the acceleration of the board.
- When a significant X acceleration is detected (i.e. the acceleration goes above some threshold), the app should start a new measurement.
- During that measurement interval, the app should keep track of the maximum acceleration observed
- After the measurement interval ends, the app must report the maximum acceleration observed. You
can report the value using the
rprintln!
macro.
Give it a try and let me know how hard you can punch ;-)
.
NOTE There are two additional APIs that should be useful for this task we haven't discussed yet. First the
set_accel_scale
one which you need to measure high g values. Secondly theCountdown
trait fromembedded_hal
. If you decide to use this to keep your measurement intervals you will have to pattern match on thenb::Result
type instead of using theblock!
macro we have seen in previous chapters.
My solution
#![deny(unsafe_code)] #![no_main] #![no_std] use cortex_m_rt::entry; use rtt_target::{rtt_init_print, rprintln}; use panic_rtt_target as _; #[cfg(feature = "v1")] use microbit::{ hal::twi, pac::twi0::frequency::FREQUENCY_A, }; #[cfg(feature = "v2")] use microbit::{ hal::twim, pac::twim0::frequency::FREQUENCY_A, }; use lsm303agr::{ AccelScale, AccelOutputDataRate, Lsm303agr, }; use microbit::hal::timer::Timer; use microbit::hal::prelude::*; use nb::Error; #[entry] fn main() -> ! { const THRESHOLD: f32 = 0.5; rtt_init_print!(); let board = microbit::Board::take().unwrap(); #[cfg(feature = "v1")] let i2c = { twi::Twi::new(board.TWI0, board.i2c.into(), FREQUENCY_A::K100) }; #[cfg(feature = "v2")] let i2c = { twim::Twim::new(board.TWIM0, board.i2c_internal.into(), FREQUENCY_A::K100) }; let mut countdown = Timer::new(board.TIMER0); let mut delay = Timer::new(board.TIMER1); let mut sensor = Lsm303agr::new_with_i2c(i2c); sensor.init().unwrap(); sensor.set_accel_odr(AccelOutputDataRate::Hz50).unwrap(); // Allow the sensor to measure up to 16 G since human punches // can actually be quite fast sensor.set_accel_scale(AccelScale::G16).unwrap(); let mut max_g = 0.; let mut measuring = false; loop { while !sensor.accel_status().unwrap().xyz_new_data {} // x acceleration in g let g_x = sensor.accel_data().unwrap().x as f32 / 1000.0; if measuring { // Check the status of our contdown match countdown.wait() { // countdown isn't done yet Err(Error::WouldBlock) => { if g_x > max_g { max_g = g_x; } }, // Countdown is done Ok(_) => { // Report max value rprintln!("Max acceleration: {}g", max_g); // Reset max_g = 0.; measuring = false; }, // Since the nrf52 and nrf51 HAL have Void as an error type // this path cannot occur, as Void is an empty type Err(Error::Other(_)) => { unreachable!() } } } else { // If acceleration goes above a threshold, we start measuring if g_x > THRESHOLD { rprintln!("START!"); measuring = true; max_g = g_x; // The documentation notes that the timer works at a frequency // of 1 Mhz, so in order to wait for 1 second we have to // set it to 1_000_000 ticks. countdown.start(1_000_000_u32); } } delay.delay_ms(20_u8); } }
What's left for you to explore
We have barely scratched the surface! There's lots of stuff left for you to explore.
NOTE: If you're reading this, and you'd like to help add examples or exercises to the Discovery book for any of the items below, or any other relevant embedded topics, we'd love to have your help!
Please open an issue if you would like to help, but need assistance or mentoring for how to contribute this to the book, or open a Pull Request adding the information!
Topics about embedded software
These topics discuss strategies for writing embedded software. Although many problems can be solved in different ways, these sections talk about some strategies, and when they make sense (or don't make sense) to use.
Multitasking
All our programs executed a single task. How could we achieve multitasking in a system with no OS, and thus no threads. There are two main approaches to multitasking: preemptive multitasking and cooperative multitasking.
In preemptive multitasking a task that's currently being executed can, at any point in time, be preempted (interrupted) by another task. On preemption, the first task will be suspended and the processor will instead execute the second task. At some point the first task will be resumed. Microcontrollers provide hardware support for preemption in the form of interrupts.
In cooperative multitasking a task that's being executed will run until it reaches a suspension point. When the processor reaches that suspension point it will stop executing the current task and instead go and execute a different task. At some point the first task will be resumed. The main difference between these two approaches to multitasking is that in cooperative multitasking yields execution control at known suspension points instead of being forcefully preempted at any point of its execution.
Sleeping
All our programs have been continuously polling peripherals to see if there's anything that needs to be done. However, sometimes there's nothing to be done! At those times, the microcontroller should "sleep".
When the processor sleeps, it stops executing instructions and this saves power.
It's almost always a good idea to save power so your microcontroller should be
sleeping as much as possible. But, how does it know when it has to wake up to
perform some action? "Interrupts" (see below for what exactly those are)
are one of the events that wake up the microcontroller but there are others
and the wfi
and wfe
are the instructions that make the processor "sleep".
Topics related to microcontroller capabilities
Microcontrollers (like our nRF52/nRF51) have many capabilities. However, many share similar capabilities that can be used to solve all sorts of different problems.
These topics discuss some of those capabilities, and how they can be used effectively in embedded development.
Direct Memory Access (DMA).
This peripheral is a kind of asynchronous memcpy
. If you are working with
a micro:bit v2 you have actually already used this, the HAL does this for you
with the UARTE and TWIM peripherals. A DMA peripheral can be used to perform bulk
transfers of data. Either from RAM to RAM, from a peripheral, like a UARTE, to RAM
or from RAM to a peripheral. You can schedule a DMA transfer, like read 256 bytes
from UARTE into this buffer, leave it running in the background and then poll some
register to see if it has completed so you can do other stuff while the transfer
is ongoing. For more information as to how this is implemented you can checkout the
serial_setup
module from the UART chapter. If that isn't enough yet you could even
try and dive into the code of the nrf52-hal
.
Interrupts
In order to interact with the real world, it is often necessary for the microcontroller to respond immediately when some kind of event occurs.
Microcontrollers have the ability to be interrupted, meaning when a certain event occurs, it will stop whatever it is doing at the moment, to instead respond to that event. This can be very useful when we want to stop a motor when a button is pressed, or measure a sensor when a timer finishes counting down.
Although these interrupts can be very useful, they can also be a bit difficult to work with properly. We want to make sure that we respond to events quickly, but also allow other work to continue as well.
In Rust, we model interrupts similar to the concept of threading on desktop Rust
programs. This means we also must think about the Rust concepts of Send
and Sync
when sharing data between our main application, and code that executes as part of
handling an interrupt event.
Pulse Width Modulation (PWM)
In a nutshell, PWM is turning on something and then turning it off periodically while keeping some proportion ("duty cycle") between the "on time" and the "off time". When used on a LED with a sufficiently high frequency, this can be used to dim the LED. A low duty cycle, say 10% on time and 90% off time, will make the LED very dim wheres a high duty cycle, say 90% on time and 10% off time, will make the LED much brighter (almost as if it were fully powered).
In general, PWM can be used to control how much power is given to some electric device. With proper (power) electronics between a microcontroller and an electrical motor, PWM can be used to control how much power is given to the motor thus it can be used to control its torque and speed. Then you can add an angular position sensor and you got yourself a closed loop controller that can control the position of the motor at different loads.
PWM is already abstracted within the embedded-hal
Pwm
trait and you will
again find implementations of this in the nrf52-hal
.
Digital inputs
We have used the microcontroller pins as digital outputs, to drive LEDs. But these pins can also be configured as digital inputs. As digital inputs, these pins can read the binary state of switches (on/off) or buttons (pressed/not pressed).
Again digital inputs are abstracted within the embedded-hal
InputPin
trait
and of course the nrf52-hal
does have an implementation for them.
(spoilers reading the binary state of switches / buttons is not as straightforward as it sounds ;-) )
Analog-to-Digital Converters (ADC)
There are a lot of digital sensors out there. You can use a protocol like I2C and SPI to read them. But analog sensors also exist! These sensors just output a voltage level that's proportional to the magnitude they are sensing.
The ADC peripheral can be used to convert that "analog" voltage level, say 1.25
Volts, into a "digital" number, say in the [0, 65535]
range, that the processor
can use in its calculations.
Again the embedded-hal
adc
module as well as the nrf52-hal
got you covered.
Digital-to-Analog Converters (DAC)
As you might expect a DAC is exactly the opposite of ADC. You can write some
digital value into a register to produce a voltage in the [0, 3.3V]
range
(assuming a 3.3V
power supply) on some "analog" pin. When this analog pin is
connected to some appropriate electronics and the register is written to at some
constant, fast rate (frequency) with the right values you can produce sounds or
even music!
Real Time Clock (RTC)
This peripheral can be used to track time in "human format". Seconds, minutes, hours, days, months and years. This peripheral handles the translation from "ticks" to these human friendly units of time. It even handles leap years and Daylight Save Time for you!
Other communication protocols
- SPI, abstracted within the
embedded-hal
spi
module and implemented by thenrf52-hal
- I2S, currently not abstracted within the
embedded-hal
but implemented by thenrf52-hal
- Ethernet, there does exist a small TCP/IP stack named
smoltcp
which is implemented for some chips but the ones on the micro:bit don't feature an Ethernet peripheral - USB, there is some experimental work on this, for example with the
usb-device
crate - Bluetooth, there does exist an incomplete BLE stack named
rubble
which does support nrf chips. - SMBUS, neither abstracted in
embedded-hal
nor implemented by thenrf52-hal
at the moment. - CAN, neither abstracted in
embedded-hal
nor implemented by thenrf52-hal
at the moment - IrDA, neither abstracted in
embedded-hal
nor implemented by thenrf52-hal
at the moment
Different applications use different communication protocols. User facing applications usually have a USB connector because USB is a ubiquitous protocol in PCs and smartphones. Whereas inside cars you'll find plenty of CAN "buses". Some digital sensors use SPI, others use I2C and others, SMBUS.
If you happen to be interested in developing abstractions in the embedded-hal
or
implementations of peripherals in general, don't be shy to open an issue in the HAL
repositories. Alternatively you could also join the Rust Embedded matrix channel
and get into contact with most of the people who built the stuff from above.
General Embedded-Relevant Topics
These topics cover items that are not specific to our device, or the hardware on it. Instead, they discuss useful techniques that could be used on embedded systems.
Gyroscopes
As part of our Punch-o-meter exercise, we used the Accelerometer to measure changes in acceleration in three dimensions. But there are other motion sensors such as gyroscopes, which allows us to measure changes in "spin" in three dimensions.
This can be very useful when trying to build certain systems, such as a robot that wants to avoid tipping over. Additionally, the data from a sensor like a gyroscope can also be combined with data from accelerometer using a technique called Sensor Fusion (see below for more information).
Servo and Stepper Motors
While some motors are used primarily just to spin in one direction or the other, for example driving a remote control car forwards or backwards, it is sometimes useful to measure more precisely how a motor rotates.
Our microcontroller can be used to drive Servo or Stepper motors, which allow for more precise control of how many turns are being made by the motor, or can even position the motor in one specific place, for example if we wanted to move the arms of a clock to a particular direction.
Sensor fusion
The micro:bit contains two motion sensors: an accelerometer and a magnetometer. On their own these measure: (proper) acceleration and (the Earth's) magnetic field. But these magnitudes can be "fused" into something more useful: a "robust" measurement of the orientation of the board. Where robust means with less measurement error than a single sensor would be capable of.
This idea of deriving more reliable data from different sources is known as sensor fusion.
So where to next? There are several options:
- You could check out the examples in the
microbit
board support crate. All those examples work for the micro:bit board you have.
- You could join the Rust Embedded matrix channel, lots of people who contribute or work on embedded software
hang out there. Including for example the people who wrote the
microbit
BSP, thenrf52-hal
,embedded-hal
etc.
- If you are looking for a general overview of what is available in Rust Embedded right now check out the Awesome Rust Embedded list
- You could check out Real-Time Interrupt-driven Concurrency. A very efficient preemptive multitasking framework that supports task prioritization and dead lock free execution.
- You could check out more abstractions of the
embedded-hal
project and maybe even try and write your own platform agnostic driver based on it.
- You could try running Rust on a different development board. The easiest way to get started is to
use the
cortex-m-quickstart
Cargo project template.
- You could try out this motion sensors demo. Details about the implementation and source code are available in this blog post.
- You could check out this blog post which describes how Rust type system can prevent bugs in I/O configuration.
- You could check out japaric's blog for miscellaneous topics about embedded development with Rust.
- You could join the Weekly driver initiative and help us write generic drivers on top of the
embedded-hal
traits and that work for all sorts of platforms (ARM Cortex-M, AVR, MSP430, RISCV, etc.)
General troubleshooting
cargo-embed
problems
Most cargo-embed
problems are either related to not having installed the udev
rules properly (on Linux) or having selected the wrong chip configuration in Embed.toml
so
make sure you got both of those right.
If the above does not work out for you, you can open an issue in the discovery
issue tracker.
Alternatively you can also visit the Rust Embedded matrix channel or the probe-rs matrix channel
and ask for help there.
Cargo problems
"can't find crate for core
"
Symptoms
Compiling volatile-register v0.1.2
Compiling rlibc v1.0.0
Compiling r0 v0.1.0
error[E0463]: can't find crate for `core`
error: aborting due to previous error
error[E0463]: can't find crate for `core`
error: aborting due to previous error
error[E0463]: can't find crate for `core`
error: aborting due to previous error
Build failed, waiting for other jobs to finish...
Build failed, waiting for other jobs to finish...
error: Could not compile `r0`.
To learn more, run the command again with --verbose.
Cause
You forgot to install the proper target for your microcontroller (thumbv7em-none-eabihf
for v2
and thumbv6m-none-eabi
for v1).
Fix
Install the proper target.
# micro:bit v2
$ rustup target add thumbv7em-none-eabihf
# micro:bit v1
$ rustup target add thumbv6m-none-eabi
How to use GDB
Below are some useful GDB commands that can help us debug our programs. This assumes you have flashed a program onto your microcontroller and attached GDB to a cargo-embed
session.
General Debugging
NOTE: Many of the commands you see below can be executed using a short form. For example,
continue
can simply be used asc
, orbreak $location
can be used asb $location
. Once you have experience with the commands below, try to see how short you can get the commands to go before GDB doesn't recognize them!
Dealing with Breakpoints
break $location
: Set a breakpoint at a place in your code. The value of$location
can include:break *main
- Break on the exact address of the functionmain
break *0x080012f2
- Break on the exact memory location0x080012f2
break 123
- Break on line 123 of the currently displayed filebreak main.rs:123
- Break on line 123 of the filemain.rs
info break
: Display current breakpointsdelete
: Delete all breakpointsdelete $n
: Delete breakpoint$n
(n
being a number. For example:delete $2
)
clear
: Delete breakpoint at next instructionclear main.rs:$function
: Delete breakpoint at entry of$function
inmain.rs
clear main.rs:123
: Delete breakpoint on line 123 ofmain.rs
enable
: Enable all set breakpointsenable $n
: Enable breakpoint$n
disable
: Disable all set breakpointsdisable $n
: Disable breakpoint$n
Controlling Execution
continue
: Begin or continue execution of your programnext
: Execute the next line of your programnext $n
: Repeatnext
$n
number times
nexti
: Same asnext
but with machine instructions insteadstep
: Execute the next line, if the next line includes a call to another function, step into that codestep $n
: Repeatstep
$n
number times
stepi
: Same asstep
but with machine instructions insteadjump $location
: Resume execution at specified location:jump 123
: Resume execution at line 123jump 0x080012f2
: Resume execution at address 0x080012f2
Printing Information
print /$f $data
- Print the value contained by the variable$data
. Optionally format the output with$f
, which can include:x: hexadecimal d: signed decimal u: unsigned decimal o: octal t: binary a: address c: character f: floating point
print /t 0xA
: Prints the hexadecimal value0xA
as binary (0b1010)
x /$n$u$f $address
: Examine memory at$address
. Optionally,$n
define the number of units to display,$u
unit size (bytes, halfwords, words, etc.),$f
anyprint
format defined abovex /5i 0x080012c4
: Print 5 machine instructions staring at address0x080012c4
x/4xb $pc
: Print 4 bytes of memory starting where$pc
currently is pointing
disassemble $location
disassemble /r main
: Disassemble the functionmain
, using/r
to show the bytes that make up each instruction
Looking at the Symbol Table
info functions $regex
: Print the names and data types of functions matched by$regex
, omit$regex
to print all functionsinfo functions main
: Print names and types of defined functions that contain the wordmain
info address $symbol
: Print where$symbol
is stored in memoryinfo address GPIOC
: Print the memory address of the variableGPIOC
info variables $regex
: Print names and types of global variables matched by$regex
, omit$regex
to print all global variablesptype $data
: Print more detailed information about$data
ptype cp
: Print detailed type information about the variablecp
Poking around the Program Stack
backtrace $n
: Print trace of$n
frames, or omit$n
to print all framesbacktrace 2
: Print trace of first 2 frames
frame $n
: Select frame with number or address$n
, omit$n
to display current frameup $n
: Select frame$n
frames updown $n
: Select frame$n
frames downinfo frame $address
: Describe frame at$address
, omit$address
for currently selected frameinfo args
: Print arguments of selected frameinfo registers $r
: Print the value of register$r
in selected frame, omit$r
for all registersinfo registers $sp
: Print the value of the stack pointer register$sp
in the current frame
Controlling cargo-embed
Remotely
monitor reset
: Reset the CPU, starting execution over again