Title: Rust en Arduino - La manera difícil

Date: 2022-12-22
Content:

Vamos a realizar el ejemplo mas sencillo que puede existir en Arduino, como el “hola mundo” para el área de embebidos, un led que parpadea.

Antes de empezar necesitamos adecuar nuestro ambiente de trabajo y para ello se necesitarán 3 cosas:

Para este texto yo estaré utilizando mi distro de Arch con los paquetes avr-gcc avr-binutils avr-libc avrdude instalados, ustedes pueden ver como instalarlos en su distro o en el sistema operativo de su elección... Desconozco cual es el procedimiento para hacerlo en Windows, pero siempre se puede utilizar el WSL y yo digo que debería ir bien, pero no lo he probado.

Notación numérica

Antes de comenzar voy a explicar la notación numérica que estaré utilizando.
Sí un número comienza con 0x Significa que ese número está siendo expresado en hexadecimal.
De otra forma, si comienza con 0b indica que ese numero está siendo escrito en binario.
Sí de lo contrario, no contiene ningún prefijo, es que ese número está en decimal.

Prefijo Sistema Ejemplo
0x Hexadecimal 0x25 = 37
0xFF = 255
0b Binario 0b101 = 5
0b1010101 = 85 = 0x55
0b11111111 = 255 = 0xFF
«Nada» Decimal 10, 5, 240

Si quieres convertir entre los distintos sistemas puedes utilizar a Python con las funciones bin y hex, que reciben un número y lo convierten a binario o hexadecimal respectivamente.

Registros

Lo primero que deben de hacer es… olvidar todo lo que saben hasta ahorita de Arduino, por lo menos sí solo haz programado Arduinos con Processing, que es el lenguaje por defecto del Arduino IDE.

Recuerdan esos numeritos a los lados de los pines en tu Arduino?

                               +-----+
  +----[PWR]-------------------| USB |--+
  |                            +-----+  |
  |         GND/RST2  [ ][ ]            |
  |       MOSI2/SCK2  [ ][ ]  A5/SCL[ ] |
  |          5V/MISO2 [ ][ ]  A4/SDA[ ] |
  |                             AREF[ ] |
  |                              GND[ ] |
  | [ ]N/C                    SCK/13[ ] |
  | [ ]IOREF                 MISO/12[ ] |
  | [ ]RST                   MOSI/11[ ]~|
  | [ ]3V3    +---+               10[ ]~|
  | [ ]5v    -| A |-               9[ ]~|
  | [ ]GND   -| R |-               8[ ] |
  | [ ]GND   -| D |-                    |
  | [ ]Vin   -| U |-               7[ ] |
  |          -| I |-               6[ ]~|
  | [ ]A0    -| N |-               5[ ]~|
  | [ ]A1    -| O |-               4[ ] |
  | [ ]A2     +---+           INT1/3[ ]~|
  | [ ]A3                     INT0/2[ ] |
  | [ ]A4/SDA  RST SCK MISO     TX►1[ ] |
  | [ ]A5/SCL  [ ] [ ] [ ]      RX◄0[ ] |
  |            [ ] [ ] [ ]              |
  |  UNO_R3    GND MOSI 5V  ____________/
   \_______________________/

Pues olvídense de ellos, ya que ahora les presentare los registros.

Los registros son áreas de la memoria de un procesador y su función es guardar o leer información y cada registro tiene un identificador numérico único, comúnmente expresado en hexadecimal.

Lo curioso es que la información no solo puede ser escrita por el procesador, sino que, sensores y otros chips externos al circuito principal también pueden leer y modificar esos registros. En términos prácticos, es lo que modificamos cuando escribimos un digitalWrite y es de donde obtenemos la información cuando ejecutamos un digitalRead en processing.

Dicho esto, en el Arduino UNO podemos identificar físicamente, por así decirlo, 4 secciones distintas de pines:

                                     +-----+
        +----[PWR]-------------------| USB |--+
        |                            +-----+  |
        |         GND/RST2  [ ][ ]            |
        |       MOSI2/SCK2  [ ][ ]  A5/SCL[ ] | ═══╗
        |          5V/MISO2 [ ][ ]  A4/SDA[ ] |    ║
        |                             AREF[ ] |    ║
        |                              GND[ ] |    ║
   ╔═══ | [ ]N/C                    SCK/13[ ] | Sección
   ║    | [ ]IOREF                 MISO/12[ ] |    4
   ║    | [ ]RST                   MOSI/11[ ]~|    ║
Sección | [ ]3V3    +---+               10[ ]~|    ║
   1    | [ ]5v    -| A |-               9[ ]~|    ║
   ║    | [ ]GND   -| R |-               8[ ] | ═══╝
   ║    | [ ]GND   -| D |-                    |
   ╚═══ | [ ]Vin   -| U |-               7[ ] | ═══╗
        |          -| I |-               6[ ]~|    ║
   ╔═══ | [ ]A0    -| N |-               5[ ]~|    ║
   ║    | [ ]A1    -| O |-               4[ ] | Sección
Sección | [ ]A2     +---+           INT1/3[ ]~|    3
   2    | [ ]A3                     INT0/2[ ] |    ║
   ║    | [ ]A4/SDA  RST SCK MISO     TX►1[ ] |    ║
   ╚═══ | [ ]A5/SCL  [ ] [ ] [ ]      RX◄0[ ] | ═══╝
        |            [ ] [ ] [ ]              |
        |  UNO_R3    GND MOSI 5V  ____________/
         \_______________________/

Las secciones en donde nos vamos a centrar aquí serán las 3 ultimas.

Cada sección de pines tiene asignado 3 registros, uno que contendrá el modo en el que se encuentra cada Pin, otro contiene los valores de salida y otro con los valores de entrada.

Cada registro en el Arduino UNO es de 8 bits y cada bit en ese entero u8 va a ser capaz de manipular independientemente un Pin. Creo que con un ejemplo lo vamos a entender mejor.

Para mayor facilidad en este ejemplo voy a hacer este ejercicio con el 3er grupo de pines, los marcados desde el 0 hasta el 7.

Como había dicho anteriormente, cada grupo de pines tiene 3 registros asignados, uno que indica si el pin es de lectura o escritura y otro que trae la info. Bien, entonces digamos que el grupo 3 tiene asignados los siguientes registros:
AA - para controlar el modo
AB - para escribir valores de salida
AC - para leer los valores de entrada

Cada bit del u8 corresponde a un Pin físico de nuestro Arduino.
Comencemos con el registro AA, que es el que controla los modos.

      8   7   6   5   4   3   2   1
AA = [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
--------------------------------------
     [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]  \
      7   6   5   4   3   2   1   0   |
                              TX  RX  |

Digamos que si le escribimos un 1 en algún bit del registro AA (el de modos), pondremos ese pin en modo de OUTPUT y por lo contrario, si le colocamos un 0, se pondrá en modo INPUT.
entonces haciendo un:

      8   7   6   5   4   3   2   1
AA = [0] [0] [0] [0] [1] [1] [1] [1] = 0x0F = 15
--------------------------------------
     [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]  \
      7   6   5   4   3   2   1   0   |
                              TX  RX  |

Eso colocaría al PIN 1,2,3,4 en modo OUTPUT y el 5,6,7,8 en modo INPUT.

Ya que controlamos mas o menos esta información, ahora sigue el siguiente registro.
Nuestro registro hipotético AB sería el encargado de almacenar la información de salida de los pines.
Sí el Pin está en modo OUTPUT y de manera interna colocas un 1, el Pin mandaría voltaje alto (5v).

      8   7   6   5   4   3   2   1
AA = [0] [0] [0] [0] [0] [0] [0] [1] = 0x01 = 1
--------------------------------------
     [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]  \
      7   6   5   4   3   2   1   0   |
                              TX  RX  |

Si colocamos el valor 0b00000001 = 0x01 = 1, esto haría que el actuador que tengamos conectado al Pin marcado como 0 en el PCB, se active.

Y el último registro, el AC, se puede intuir mas o menos como se va a comportar, sí el pin está en modo INPUT, y con algún sensor se le mande voltaje alto, este colocaría el bit correspondiente al Pin en 1 (Al final del post hay una nota con respecto a esto).

Ok, perfecto, ya sabemos como gestionar el modo y la info de los pines desde los registros... pero como se manipulan o leen esos registros desde Rust?

Esqueleto del main.rs

Para hacer que Rust pueda compilar a un Arduino se necesita un archivo main.rs algo parecido a esto:

#![no_std]
#![no_main]

#[no_mangle]
pub extern "C" fn main() {}

#[panic_handler]
fn panic(_info: &::core::panic::PanicInfo) -> ! {
    loop {}
}

Y lo iré explicando linea a linea.

#![no_std]
#![no_main]

Estas primeras lineas contienen unos macros que le indican al compilar de Rust 2 cosas:

  1. No incluyan la biblioteca estándar a este proyecto
  2. Este proyecto no tendrá una función main "rustificada".
    Este ultimo punto significa que vas a deslindar al compilador de asignar un punto de entrada a tu programa, esa chamba te la estás delegando a ti y sí tu programa no sabe por donde arrancar, el compilador se lavará las manos.
#[no_mangle]
pub fn extern "C" main() {
} 

El macro encima de la función main con muchas cositas extra, le está indicando al compilador que no haga optimizaciones de nombres. Y es que Rust (y muchos otros lenguajes como C++, D, etc...), al momento de compilar, les da igual el nombre que tu le hayas colocado a tu función, internamente se llamará de otra forma.
En programas clásicos eso da igual, mientras todo funcione como tenga que funcionar, a tu realmente te da igual que la función se llame "main" o punto_de_entrada_asbdauhsd123, mientras el programa comience por ahí, está todo bien.

Rust Ensamblador
pub fn square(num: i32) -> i32 {
num * num
}
_ZN7example6square17h6dfd88b0be9f1c7eE:
push rax
imul edi, edi
mov dword ptr [rsp + 4], edi
...
Rust Ensamblador
#[no_mangle]
pub fn square(num: i32) -> i32 {
num * num
}
square:
push rax
imul edi, edi
mov dword ptr [rsp + 4], edi
...

(En estos ejemplos se está utilizando el "pub" para que jale en godbolt, pueden ignorarlos sin problemas)

Peeeeero, recuerden que el trabajo de asignar un punto de entrada al programa se la quitamos al compilador, así que la persona que está programando es la que se debe aventar esa chamba y es justo lo que haremos a continuación.
La segunda linea, esa que tiene un montón de cositas extras en la función main significan:

#[panic_handler]
fn panic(_info: &::core::panic::PanicInfo) -> ! {
    loop {}
}

Visto lo anterior, esto es muuucho mas sencillo de entender. Vamos línea por línea:

  1. El macro panic_handler le indica al compilar que deberá llamar a la función que se encuentra debajo cada que ocurra un panic.
  2. fn panic
    1. core::panic::PanicInfo: Contiene la información del panic, la linea que lo causó, el mensaje de error, el traza del error, etc...
    2. -> !: Esto le está indicando al compilador que esa función nunca va a terminar ya que tiene un loop infinito sin condición de salida. Se puede prescindir de ello, pero ya que existe la función de indicar algo tan especifico al compilador, pues se lo ponemos.
  3. loop {}: Es un loop infinito sin condición de salida.

Y a grandes rasgos significa qué cuando nuestro programa tenga un panic!, lo que hará será ejecutar un loop infinito y tendremos que reiniciar a mano nuestro Arduino.
Ya existen crates que tienen implementadas varias entradas en pánico (Consultar las referencias la final del documento), pero quería enseñarles que también se pueden implementar las propias.

También necesitaremos tunear un poco el Cargo.toml agregando esto al final:

[profile.release]
lto = true
panic = "abort"

Y es básicamente un workaround para que todo el conjunto compile, ya que la arquitectura AVR no está soportada oficialmente por el equipo de Rust, puede que algunos cambios rompan la compatibilidad.

  1. El primero es la optimización del linker... esto no sería necesario, pero actualmente existe un bug con ciertas versiones de LLVM que impiden que el programa compile, y se puede evitar habilitando esa optimización.
  2. panic = "abort": Aquí vamos con un concepto algo complicado... Cuando tu programa en Rust tiene un panic, por defecto comienza a desalojar la información que existía en el stack de atrás hacía adelante, el "cómo" suele venir en la biblioteca estándar, ya que puede variar entre arquitecturas y entre OS's. Pero aquí como no tenemos std, pues nos tocaría hacer a mano esa liberación de memoria, peeero como pues nuestro panic es un maldito loop vacío, pues nos da igual sí el stack se queda con cosas o no. el "abort" indica que deje todo como está y vaya al panic_handler directamente (ver el lang_item = "eh_personality" para mas info al respecto).

Y ¡¡Listo!!, ya con ese main.rs y Cargo.toml está todo listo para compilar tu hermoso primer ejemplo con la siguiente linea:

cargo build -Z build-std=core --target avr-atmega328p.json --release

Cada parámetro significa lo siguiente:

  1. -Z build-std=core: Sin esta linea, Cargo no agregará el crate "core". Este crate es necesario para el panic_handler y también incluye varias funciones que utilizaremos mas adelante.
  2. --target avr-atmega328p.json: Le indica al compilador cual es el target custom que utilizaremos, en este caso es un json que ya está tuneado para el micro del Arduino.
  3. --release: Para que se tomen en cuenta los workarounds que agregamos al Cargo.toml es necesario compilar en modo release.

Una acotación mas, y es que para poder compilar todo nuestro desmadrito, será necesario hacerlo en la versión nocturna del compilador, ya que utilizamos características como el build-std=core, que no es posible utilizarlo en la rama estable.

>> cargo build -Z build-std=core --target avr-atmega328p.json --release
 Compiling compiler_builtins v0.1.85
 Compiling core v0.0.0 (~/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core)
 Compiling rustc-std-workspace-core v1.99.0 (~/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/rustc-std-workspace-core)
 Compiling arduino_blinkrs v0.1.0 (~/Proyectos/arduino_blinkrs)
  Finished release [optimized] target(s) in 16.59s

La compilación se realizó con éxito :) oficialmente hemos programado un firmware para el Arduino con Rust.
Pero aún faltan un montón de cosas, ya que de momento solo tenemos un firmware que no hace absolutamente nada.
Lo siguiente que toca es aprender a manipular los registros que vimos anteriormente.

Hola hoja de especificaciones

Pero antes tenemos que saber qué registros modificar y para ello no hay nada como consultar la propia hoja de especificaciones del ATmega328p, pero como leer eso puede ser un poco abrumador, aquí la voy a resumir un poco:
Los grupos de pines tienen asignada una letra que los identifica:

                                    +-----+
       +----[PWR]-------------------| USB |--+
       |                            +-----+  |
       |         GND/RST2  [ ][ ]            |
       |       MOSI2/SCK2  [ ][ ]  A5/SCL[ ] | ══╗
       |          5V/MISO2 [ ][ ]  A4/SDA[ ] |   ║
       |                             AREF[ ] |   ║
       |                              GND[ ] |   ║
       | [ ]N/C                    SCK/13[ ] | Grupo
       | [ ]IOREF                 MISO/12[ ] |   B
       | [ ]RST                   MOSI/11[ ]~|   ║
       | [ ]3V3    +---+               10[ ]~|   ║
       | [ ]5v    -| A |-               9[ ]~|   ║
       | [ ]GND   -| R |-               8[ ] | ══╝
       | [ ]GND   -| D |-                    |
       | [ ]Vin   -| U |-               7[ ] | ══╗
       |          -| I |-               6[ ]~|   ║
   ╔══ | [ ]A0    -| N |-               5[ ]~|   ║
   ║   | [ ]A1    -| O |-               4[ ] | Grupo
 Grupo | [ ]A2     +---+           INT1/3[ ]~|   D
   C   | [ ]A3                     INT0/2[ ] |   ║
   ║   | [ ]A4/SDA  RST SCK MISO     TX►1[ ] |   ║
   ╚══ | [ ]A5/SCL  [ ] [ ] [ ]      RX◄0[ ] | ══╝
       |            [ ] [ ] [ ]              |
       |  UNO_R3    GND MOSI 5V  ____________/
        \_______________________/

Y como había dicho al inicio de la presentación, existen 3 registros para cada grupo de pines:

  1. El registro para controlar en que modo van a estar los pines lo llamaremos "DDR", que significa "Data Direction Register".
  2. El registro que contiene la información de salida, lo llamaremos "PORT", que significa... "PORT".
  3. El registro que contiene la información de entrada, y su nombre es "PIN".

Y cada Pin tiene un bit asignado y se enumeran desde el 0 hasta el 7.
Así que para construir una dirección a un Pin en concreto lo haremos de la siguiente manera:
{Nombre del registro}{Letra identificativa}{número del bit}

Ejemplo Descripción
PORTC5 6to Pin del registro "PORT" grupo "C"
PINB Todos los pines del registro "PIN" del grupo B
D2 3er Pin del grupo "D" Sin determinar ningún registro en específico
DDRB6 7mo Pin del registro "DDR" del grupo "B"
PINC9 No existe, recuerda que sólo hay 8 pines

Como dato relevante, el LED incrustado en la placa es el B5, solo para que lo sepan, ya que ese justamente será el que haremos parpadear.

Sabiendo eso, nos vamos a la tabla de especificamos, en el apartado en donde están los números de registros para saber cual es el DDR, PORT y PIN de la sección B.

Address Name Bit 7 Bit 6 Bit 5 . . .
. . .
0x06(0x26) PINC - PINC6 PINC5 . . .
0x05(0x25) PORTB PORTB7 PORTB6 PORTB5 . . .
0x04(0x24) DDRB DDRB7 DDRB6 DDRB5 . . .
0x03(0x23) PINB PINB7 PINB6 PINB5 . . .
0x02(0x22) Reserved - - - -
. . .

(Ver sección "Register summary" de la Hoja de especificaciones)
Solo vamos a tomar en cuenta el número que se encuentre entre paréntesis.

Y ¡bingo!, finalmente dimos con que la dirección de los registros Siendo 0x25 para el "PORTB", 0x24 para el "DDRB" y finalmente el 0x23 para el "PIN", recordando que están expresados en hexadecimal.
Una vez teniendo esto, ya tenemos todo para ir a nuestro editor.
Para interactuar con los registros vamos a necesitar un par de funciones que se encuentran dentro del crate core y se llaman read_volatile y write_volatile.
Y para mayor comodidad, pondremos en constantes las direcciones de los registros como punteros.

...
use core::ptr::{read_volatile, write_volatile};

const PORTB: *mut u8 = 0x25 as *mut u8;
const DDRB: *mut u8 = 0x24 as *mut u8;
const PINB: *mut u8 = 0x23 as *mut u8;

#[no_mangle]
pub extern "C" fn main() {
    unsafe { write_volatile(DDRB, 0b11111111) };
    unsafe { write_volatile(PORTB, 0b00100000) };
}
...

Con la primer linea de write_volatile estamos colocando todo el grupo de pines de la sección "B" en modo OUTPUT (recuerden, 1 es OUTPUT y 0 es INPUT).
Y con la segunda estamos colocando un valor de 1 (Voltaje alto) en PORTB5, que es justamente el que controla el Pin marcado como 13 en la placa, que a su vez es el que controla el LED naranja incluido en el Arduino.
Con este código podemos apreciar 2 cosas:

  1. write_volatile (y read_volatile) necesitan estar enjauladas en un bloque unsafe, ya que modificarán directamente una dirección de memoria evadiendo por completo el verificador de seguridad de Rust.
  2. write_volatile recibe 2 parámetros, el registro que queremos modificar y el valor que vamos a asignar.

Compilamos para ver que todo esté funcionando correctamente y parece ser que si:

>> cargo build -Z build-std=core --target avr-atmega328p.json --release
   Compiling arduino_blinkrs v0.1.0 (~/Proyectos/arduino_blinkrs)
warning: unused import: `read_volatile`
 --> src/main.rs:4:17
  |
4 | use core::ptr::{read_volatile, write_volatile};
  |                 ^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `arduino_blinkrs` (bin "arduino_blinkrs") generated 1 warning (run `cargo fix --bin "arduino_blinkrs"` to apply 1 suggestion)
    Finished release [optimized] target(s) in 0.20s

Nuestro pequeño monstruo parece no tener ningún error (solo un pequeño warning, pero se sabe que esos se ignoran), así que en principio ya podríamos subir este resultado a la placa y ver que, efectivamente, el led de nuestra plaquita se prende, pero... como subimos el código al Arduino?.

Preparando el binario

Lo primero es conectar por USB el Arduino a nuestra computadora, evidentemente.
Despues, identificar cual puerto le asignó la PC a la placa. Esto lo podemos hacer entrando al Arduino IDE > Herramientas > Puerto y ver cual nos marca como "Arduino UNO".
Captura de pantalla del Arduino IDE mostrando el puerto serie de un Arduino UNO conectado

En mi caso me asignó el /dev/ttyACM0. Ya identificado, podemos pasar a lo que sigue que es, la conversión del .elf a un .hex.
Para hacer esa conversión vamos a necesitar una de las utilidades que instalamos al inicio llamada "avr-objcopy" y se utiliza de la siguiente manera:

avr-objcopy -O ihex -R .eeprom path/al/archivo.elf <output>.hex

Que en este caso sería:

avr-objcopy -O ihex -R .eeprom target/avr-atmega328p/release/arduino_blinkrs.elf output.hex

Siendo /target/avr-atmega328p/release/arduino_blinkrs.elf nuestro binario de entrada y output.hex, el archivo en donde volcará el resultado de la conversión.
Lo ejecutamos y sí vemos que no arroja ningún error, eso es buena señal.

Lo siguiente ahora si es subirlo a nuestra placa, y para ello se hará uso de otra herramienta llamada avrdude, y se utiliza de la siguiente manera:

avrdude -p atmega328p -c arduino -P <puerto del arduino> -U flash:w:</path/al/archivo.hex>:i

que en este caso sería:

avrdude -p atmega328p -c arduino -P /dev/ttyACM0 -U flash:w:output.hex:i

Lo ejecutamos y nos arroja un output parecido a este:

>> avrdude -p atmega328p -c arduino -P /dev/ttyACM0 -U flash:w:output.hex:i

avrdude: AVR device initialized and ready to accept instructions

Reading | ################################################## | 100% 0.00s

avrdude: Device signature = 0xle950f (probably m328p)
avrdude: NOTE: "flash" memory has been specified, an erase cycle will be performed
         To disable this feature, specify the -D option.
avrdude: erasing chip
avrdude: reading input file "output.hex"
avrdude: writing flash (178 bytes):

Writing | ################################################## | 100% 0.04s

avrdude: 178 bytes of flash written
avrdude: verifying flash memory against output.hex:

Reading | ################################################## | 100% 0.03s
avrdude: 178 bytes of flash verified
avrdude done. Thank you.

El comando nos dice que todo el archivo output.hex se volcó en el Arduino exitosamente.
Y como podemos apreciar, el Led incorporado, se prende:
Arduino UNO con el led embebido encendido

Y colocando un 0 en PORTB5:

pub extern "C" fn main() {
    unsafe { write_volatile(DDRB, 0b11111111) };
    unsafe { write_volatile(PORTB, 0b00000000) };
}

Volvemos, a compilar, convertir el .elf a un .hex y sublo al Arduibo, veremos como el led se apaga.
Arduino UNO con el led embebido apagado

Pero yo les prometí al inicio un led parpadeante, no un led fijo, así que hay que hacer que parpadeé.

Como podrías intuirlo algunos, podemos simplemente meter el write_volatile de PORTB que hace prenda y se apague en un loop infinito y ya lo tendríamos, pero esto haría que se prenda y apague a una velocidad demasiada alta como para que fuera apreciable, así que tenemos que colocar un sleep de por medio.

std::thread\::sleep?

Por desgracia, el thread::sleep solo se encuentra en la biblioteca estándar, y recordemos que estamos en un proyecto sin eso, así que tendremos que hacerlo de otra forma, como por ejemplo, hacer que el microcontrolador esté ejecutando operaciones vacías durante X ciclos y para hacerlo tendremos que adentrarnos a un nivel muy bajo.
Muchos de los procesadores, incluyendo los AVR, tienen en su set de instrucciones una que no hace nada, y normalmente se llama "nop" o "no operation". Esto lo podemos consultar de nueva en la hoja de especificaciones del microcontrolador, en el apartado de "Instruction Set Summary".

Mnemonics Operands Description Operation Flags #Clocks
. . .
MCU CONTROL INSTRUCTIONS
NOP No Operation None 1
SLEEP Sleep (see specific descr. for Sleep function) None 1
. . .

Como se puede apreciar, efectivamente, el procesador AVR tiene una instrucción llamada "nop", que no hace nada, no recibe operadores y que gasta un único ciclo del procesador. También hay otra llamada "sleep" que hace lo mismo, pero "nop" puede ser mas extrapolable a otras arquitecturas, así que es el que utilizaremos aquí.
Así que solo tendremos que hacer una llamada con ensamblador a esa instrucción tantas veces como necesitemos y listo, nuestra simulación de thread::sleep quedaría lista.
Ahora, el procesador del Arduino UNO va a 16 MHz o 16'000'000 Hz, eso significa que realiza 16 millones de instrucciones por segundo, así que sí queremos dormir al procesador durante un segundo, hay que ejecutar el "nop" 16'000'000 de veces, ya que, como vimos en la tabla anterior, solo gasta un ciclo.
En Rust puedes hacer llamadas a ensamblador de manera cruda utilizando el macro asm! que se encuentra dentro de core::arch y recibe como parámetro el nombre de la instrucción.
Para que sea mas sencillo de utilizar, haremos una función que reciba como parámetro el número de segundo que queramos que se "duerma" nuestro CPU y también pondremos en una constante la velocidad del CPU expresado en Hz, que en este caso es 16_000_000.

#![feature(asm_experimental_arch)]

use core::arch::asm;

const CPU_SPEED: u32 = 16_000_000;

fn sleep(time: u32) {
    for _ in 0..(CPU_SPEED / 10 * time) {
        unsafe { asm!("nop") }
    }
}

Dejando de lado la feature asm_experimental_arch, que es para habilitar el macro en las arquitectura donde aún es experimental, solo nos tocaría hacer un for que itere desde 0 hasta la velocidad del CPU multiplicado por el número de segundos y dentro del for llamar al "nop" y listo, el problema de la pausa está solucionado.
Pero como pueden ver en el código, existe una división entre 10 en el número total de ciclos del procesador y es que realmente cada iteración del for se lleva mas de un ciclo.
Esto ya es un poco mas bajo nivel, pero haré mi mejor esfuerzo para explicarlo y que se entienda.
Si pegamos ese mismo código en godbolt.

Rust Ensamblador
use std::arch::asm;

#[no_mangle]
pub fn sleep(time: u32) {
let cycles = 10_000_000 * time;
for _ in 0..cycles {
unsafe { asm!("nop") };
}
}
sleep:
sub rsp, 40
. . .
jne .LBB6_2

.LBB6_2:
. . .
.LBB6_3:
mov rax, . . . ═════════╗
lea rdi, [rsp + 24] ║
call rax ║
mov dword ptr [rsp + 36], edx ║
mov dword ptr [rsp + 32], eax ║
mov eax, dword ptr [rsp + 32] ║
cmp rax, 0 ║
jne .LBB6_5 ║
add rsp, 40 ║
ret ║
.LBB6_5: ║
nop
jmp .LBB6_3 ═════════╝

Cada instrucción que ven a la derecha ("mov", "lea", "call", etc...) digamos que se lleva un ciclo del procesador (esto no es del todo cierto, ya que algunas instrucciones puede gastar varios ciclos, pero para que la explicación sea sencilla podemos decir que cada una de ellas es un ciclo gastado del procesador).
Y efectivamente, en la sección LBB6_5 podemos ver como nuestro "nop" está ahí, gastando su respectivo ciclo, pero el iterador dentro del for se está llevamos como 10 mas o menos (todas las instrucciones de la sección LBB6_3 son las que hacen que el iterador funcione), así que cada ciclo realmente nos está costando ~10+1.
Esa es la razón por la que todo lo dividimos entre 10, no es lo mas elegante pero tampoco estamos programando un reloj suizo, con llegar a un tiempo estimado creo que podemos quedar conformes en este experimento.
Utilizar un while con una variable mutable como contador utiliza mas o menos la misma cantidad de instrucciones, así que támbien utilizo el for por que, a mi parecer, es mas sencillo de leer.
Ahora si, mezclamos todo lo aprendido hemos aprendido y el resultado final es esta belleza:

#![feature(asm_experimental_arch)]
#![no_std]
#![no_main]

use core::arch::asm;
use core::ptr::{read_volatile, write_volatile};

const DDRB: *mut u8 = 0x24 as *mut u8;
const PORTB: *mut u8 = 0x25 as *mut u8;

const CPU_SPEED: u32 = 16_000_000;

#[no_mangle]
pub extern "C" fn main() -> ! {
    unsafe { write_volatile(DDRB, 0b11111111) };

    loop {
	    unsafe { write_volatile(PORTB, 0b00100000) };
        sleep(1);
	    unsafe { write_volatile(PORTB, 0b00000000) };
        sleep(1);
    }
}

fn sleep(time: u32) {
    for _ in 0..(CPU_SPEED / 10 * time) {
        unsafe { asm!("nop") }
    }
}

#[panic_handler]
fn panic(_info: &::core::panic::PanicInfo) -> ! {
    loop {}
}

Compilamos, convertimos y subimos y vemos como nuestro led, efectivamente, está parpadeando (mas o menos) cada segundo ✨\._.\ \._./ /._./✨.

Mejorando un poco el código

Peeeero, tener hardcodeado el valor de PORTB en escritura puede que no sea lo mejor. Sí solo vamos a hacer que el led parpadeé pues no habría ningún problema, pero sí esto lo queremos extrapolar a una situación un poquito mas dinámica, creo que lo mejor sería leer el valor de PORTB, sea cual sea, y modificarlo para prender o apagar SOLO el bit que nos interesa.
Para ello haremos uso de un par de operadores binario, resultando en este código:

... 
loop {
    let mut portb_value = unsafe { read_volatile(PORTB) };
    portb_value = portb_value ^ ( 1 << 5 );
    unsafe { write_volatile(PORTB, portb_value) };
    sleep(1);
}
...

Operador XOR (^)

Comenzaré explicando el operador "^", támbien llamado "XOR".
XOR es un operador booleano que compara 2 valores binarios y devuelve 1 sí solo uno de ellos es 1, de lo contrario, devuelve 0.
Otra forma de verlo es que XOR es un "detector de diferencias", ya que devuelve 0 si los 2 valores que estás comparando son iguales y 1 si son diferentes. Creo que consultando su tabla de verdades es mas sencillo de ver:

Entrada A Entrada B Salida
0 0 0
0 1 1
1 0 1
1 1 0

Aquí se puede apreciar mejor lo que comentaba, tanto en el primero como en el ultimo caso, los valores son iguales, por que XOR regresa 0, pero en el 2do y en el 3ro son distintos, por lo cual va a regresar 1.
Ahora, sí se aplica el operador XOR a un valor entero, la operación se haría una vez por cada bit del número. Pongamos de ejemplo 2 valores u8:

7 6 5 4 3 2 1 0 Valor
1 0 1 1 1 0 0 1 185
^
1 0 0 1 1 1 0 1 157
=
0 0 1 0 0 1 0 0 36

Gracias a esta cualidad, resulta muy útil el operador XOR para invertir un bit en específico de un número sin modificar alterar nada mas.
Supongamos que tenemos el número (0b11110000 = 140) y queremos invertir su 6to bit, lo que podemos es aplicarle una operación XOR contra otro que tenga todos sus bits en 0, excepto el que queremos invertir (0b00100000 = 32).
La primera vez que lo hagamos veremos como, efectivamente, el bit se invierte:

0b11110000 ^ 0b00100000 = 0b11010000 = 208

Lo interesante, viene ahora. Ya que, al volver a hacer la misma operación XOR, pero ahora aplicandoselo al resultado que nos arrojó veremos como el bit vuelve a ser 1:

0b11010000 ^ 0b00100000 = 0b11110000 = 240

Operadores de desplazamiento (<< y >>)

Ahora que ya sabemos invertir un bit en un valor numérico, no les parece como muy de hueva estar escribiendo 0b00100000 en cada operación que hagamos?
Pues no se preocupen, ya que existe otro operador binario que nos puede hacer la vida un poquito mas sencilla, funciona de la siguiente manera:
Le damos un número X y una cantidad de 0's que queramos que se agreguen a ese X a la izquierda y por arte de magia, lo recorre. Con un ejemplo creo que se entiende mejor.
Supongamos que tenemos el valor 0b101 y queremos desplazarlo 4 bits a la izquierda para que convierta en 0b1010000, pues simplemente tendríamos que escribir:

0b101 << 4 = 0b1010000 = 80

Sí queremos que se desplace a la derecha es igual unicamente intercambiando el operador << por >>, y en lugar de agregar 0's, va descartando bits:

0b1011111 >> 3 = 0b1011 = 11

Viendo de vuelta el código ahora si podemos ver que ocurre:

... 
loop {
    let mut portb_value = unsafe { read_volatile(PORTB) };
    portb_value = portb_value ^ ( 1 << 5 );
    unsafe { write_volatile(PORTB, portb_value) };
    sleep(1);
}
...

Cuando comienza el programa PORTB está en 0's, así que leemos el valor utilizando la función read_volatile, a ese número le aplicamos una operación XOR para prender unicamente el bit 5 y lo escribimos de nuevo en el registro haciendo uso de write_volatile y dormimos 1 segundo.
En el siguiente ciclo, ya con PORTB5 encendido hacemos lo mismo, volvemos a leer su valor, aplicamos de vuelta XOR para ahora apagar el bit 5, escribimos dicho valor en PORTB y volvemos a dormir por un segundo y así hasta el infinito.

YYYYY LISTO!!!, logramos hacer que un led parpadeara escribiendo y leyendo código ensamblador, midiendo tiempos en el CPU, modificando registros, etc... pues saben que, nada de eso es útil.
Y es que el ecosistema para sistema embebidos en Rust nos permite hacer todo esto de una manera mas sofisticada.
No me malentiendan, lo que aprendimos aquí es bastante útil, sobretodo para entender como funcionan las tripas de nuestro querido Arduino, pero programar para embebidos en Rust no suele ser así.
Imagina que quieres hacer una biblioteca para, por ejemplo, controlar un display de 7 segmentos. Puedes programarla de la forma que acabamos de ver, pero esto es poco portable para otras placas o arquitecturas, ya que no todas tienen los registros en los mismo lugares o no todos tienen un CPU con la misma velocidad, así que nuestra pobre biblioteca solo serviría para las Arduino UNO.
Pero que pasa si les digo que la comunidad de Rust hace uso de una técnica para que todas las bibliotecas sirvan para todas las placas que existen y las que están por existir, practicamente sin que la persona que mantenga la biblioteca haga ningún cambio.
Estimado público, dejenme presentarle a las ✨HAL✨.

Presentando a las HAL

Las HAL (por sus siglas en inglés: Capa de abstracción del Hardware) es una técnica para hacer bibliotecas de manera genérica y completamente portables a otras plataformas.
Funciona así:
Existe un crate llamado embedded_hal🤖, que contiene muchísimos Traits para todo lo que una placa suele tener (Pines digitales, analogícos, SPI's, UART's, I2C, etc...), pero no contiene ninguna implementación de ellos, solo son Traits que indican un estándar de como deben funcionar, por ejemplo:
Los pines digitales deben tener un método llamado set_high que seteé ese pin en voltaje alto, y otro llamado set_low para lo contrario. el cómo se implemente eso en cada placa a ese crate no le importa, solo marca un éstandar.
La ventaja de tener eso es que tu, como mantenedor de una biblioteca, puedes hacer tus funciones utilizando los métodos genéricos de embedded_hal.

Si estás escribiendo una biblioteca📜 para controlar un display de 7 segmentos, necesitas 7 Pines digitales, uno para cada led individual.
Entonces puedes crear un Struct que reciba esos Pines que implementen el Trait OutputPin de embedded_hal y utilizar los métodos set_low, set_high o toggle dependiendo de que valor quieres que tome, todo eso sin saber siquiera en que arquitectura o placa va a ejecutarse, tu solo recibes Pines y los pones en high o low según lo requiera tu biblioteca.

Otra de las partes es el arduino_hal⚡, la implementación como tal de los Traits de embedded_hal para el Arduino UNO, ahí si es donde se podría aplicar lo aprendido en aquí. Así, la persona que implemente el arduino_hal es la encargada de que cuando se mande llamar al método set_high o set_low del Pin, este realice las asignaciones en los registros PORTXY y DDRXY para prender o apagar ese Pin.

Así por último están las persona que mezclará todo para hacer su firmware💻, solo tiene que importar la biblioteca que quiera utilizar junto con el HAL específico para su placa y comenzar a utilizarla de una forma mucho mas sencilla.

La interacción entre las distintas partes se vería algo así:

embedded_hal <═════════════ Biblioteca
     🤖                         📜
     ^                          ^
     ║                          ║
     ║                          ║
     ║                          ║
     ║                          ║
     ║                          ║
 arduino_hal <══════════════ Firmware
     ⚡                         💻

Una vez explicado esto, el mísmo código para hacer que el led parpadeante quedaría algo así utilizando un HAL llamado avr-hal:

#![no_std]
#![no_main]

use arduino_hal::delay_ms;
use panic_halt as _;

#[arduino_hal::entry]
fn main() -> ! {
    let peripherals = arduino_hal::Peripherals::take().unwrap();
    let pins = arduino_hal::pins!(peripherals);

    let mut led = pins.d13.into_output();
    led.set_high();

    loop {
        led.toggle();
        delay_ms(1000);
    }
}

Así ya se ve mucho mas sencillo, no? solo importamos el Pin asociado al led que queremos modificar, en este caso el B5 (este HAL no nombra los pines siguiendo la nomenclatura de la hoja de especificaciones, en su lugar hace uso de la nomenclatura impresa en la PCB, que para el led embebido sería el digital 13 o d13), támbien el HAL ya tiene funciones para hacer los delays, así que solo seteamos el pin como output y en el loop colocamos un toggle y un delay y listo :), sin ensamblador, sin saber de registros, sin nosotros utilizar unsafe, desde luego un API mucho mas amigable.

FIN

Al final todo el código de este escrito lo puedes encontra en mi GitLab, en el cual habrá 2 ramas, una llamada "low_level" que contiene el ejemplo manipulando los registros directamente y otra llamada "using_hal" con el ejemplo utilizando la HAL.
Támbien pueden encontrar revisar la información de este mismo texto pero en formato charla en el canal de YouTube Software Guru

Espero les haya gustado y hayan aprendido lo básico de como es que funciona un microcontrolador a un nivel un poco mas bajo y como implementar ese conocimiento en Rust :), hasta la próxima.

Fuentes y enlaces de interés