3 lecciones que debes entender para desarrollar un bootloader

El desarrollo de un bootloader me ha llevado a preguntarme qué sucede en un microcontrolador desde que encendemos el sistema hasta que arranca la aplicación principal.

3 lecciones que debes entender para desarrollar un bootloader

Una de las cosas que más me fascina y al mismo tiempo me inquieta cuando programo sistemas embebidos es el bajo nivel de abstracción con el que se trabaja y lo que ello conlleva. Hace unos meses tuve que desarrollar un método de actualización remota para el sistema de sonido Kien. La primera palabra que se me vino a la mente fue bootloader.

Sabía que era lo que tenía que desarrollar y aunque anteriormente había desarrollado alguno muy simple, nunca me había detenido a entender todo lo que sucedía por detrás. A pesar de ser una meta relativamente humilde y aunque tenía claro qué era lo que buscaba conseguir, a la hora de implementarlo aprendí mucho sobre el funcionamiento interno de un microcontrolador gracias a esa fina capa de abstracción que proporcionan los sistemas embebidos.

¿Qué es un bootloader?

Llamamos bootloader a un pequeño programa que se ejecuta inmediatamente antes de la aplicación principal cada vez que se inicia un microcontrolador. El principal motivo por el que normalmente se utiliza es para actualizar el sistema sin necesidad de una herramienta específica o incluso para hacerlo sin acceder físicamente al dispositivo, algo muy común en el mundillo IoT. A esta característica mucha gente la conocerá como actualizaciones Over The Air. Además, los bootloaders pueden existir por otras razones, como podría ser la ejecución de algún tipo de verificación del sistema (seleccionar entre distintas imágenes de distintos orígenes, verificar su integridad y realizar alguna acción en caso de detectar anomalías) o inicialización del entorno.

A nivel de programación un bootloader no tiene nada particular. Es un programa que realiza una serie de acciones, como podría hacer la aplicación principal. Puede acceder a todos los periféricos que necesite (temporizadores, watchdog, UART, SPI, I2C…) y por supuesto hacer uso de ellos para acceder a dispositivos externos (comunicaciones a través de WiFi, BLE, acceso a una flash externa…). Vamos, que no existen instrucciones específicas, ni lenguajes particulares, ni nada que lo diferencie a primera vista de la aplicación. Hasta aquí nada especial.

Sin embargo, lo que encuentro interesante, no es cómo programar un bootloader sino cómo hacer que conviva e interactúe con la aplicación principal, ya que por norma general ambos han de hacer uso de los mismos recursos. Este es el verdadero motivo por el que he escrito este artículo.

Tras darle varias vueltas, he llegado a la conclusión de que las principales lecciones que he aprendido, y que a mi parecer son de impresdincible compresión a la hora de desarrollar un bootloader han sido:

  • Cómo está organizada la memoria de un microcontrolador (y cómo adaptarla a nuestras necesidades)
  • Cómo es el procedimiento de arranque de un microcontrolador
  • Cómo comunicar el bootloader y la aplicación

NOTA: En mi caso, el micro que estaba usando era de la familia PIC32 (sí, un PIC, estás leyendo bien). Aunque el mercado está dominado actualmente por arquitecturas ARM, los fundamentos son en gran medida extrapolables y es lo que he intentado transmitir aquí. Es por ello que me he tomado alguna libertad a la hora de hablar por ejemplo del mapa de memoria (no distingo entre memoria virtual y física). También asumo que se está usando C/C++ como lenguaje de programación por ser lo más habitual en este campo. Si no es vuestro caso, tenedlo en cuenta donde hablo del crt0.

Mapa de memoria

Una de las características que definen a los cargadores de arranque es que son programas independientes a la aplicación principal. Esto es así porque como acabo de mencionar, los bootloaders suelen existir para preparar (actualizar, validar, seleccionar…) la aplicación. Por ello, lo primero que debemos saber es qué posiciones de memoria ocupará cada uno y de qué recursos harán uso. Para ello hemos de recurrir al mapa de memoria d. microcontrolador, que seguramente se encuentre en la hoja de características correspondiente.

La mayoría de los procesadores modernos tienen casi todos los recursos internos mapeados a varias direcciones de memoria. Desde la memoria no volátil (flash), pasando por la memoria volátil (RAM), hasta la mayoría de los registros y periféricos (SFRs). Es decir, la manera de acceder a una dirección específica de la memoria flash y la de acceder al registro de interrupciones es la misma. En la imagen inferior podéis ver como está organizada la memoria en un PIC32MX.

Mapa de memoria de un PIC32MX

Una particularidad de los PIC32 son los llamados Resgistros de Configuración del Dispositivo (Device Configuration Registers) que contienen parámetros como la fuente del oscilador, los divisores de algún PLL, la protección del código… Son registros no volátiles cuyo valor se recomienda sea compartido por el cargador de arranque y la aplicación, ya que cualquier modificación podría impedir que el microcontrolador funcionase correctamente.

Otra peculiaridad es la existencia de una Memoria de Arranque (Boot Flash). Una pequeña memoria independiente de la memoria flash principal que puede ser usada para contener el bootloader y los vectores de interrupción correspondientes si el tamaño es suficiente. Si no lo es, cualquier otra zona de la flash es válida para contener el código de arranque (como haremos en la sección posterior).

Procedimiento de arranque

La segunda lección tiene que ver con lo que sucede despues de encender en micro (y tras la estabilización de voltajes, osciladores y el resto del hardware necesario). En primer lugar es necesario conocer es la instrucción que se ejecutará tras el arranque. En el caso de los PIC32, la dirección de reinicio es la 0x1FC00000, que como se puede ver en la tabla superior corresponde con la primera dirección de la memoria de arranque (boot flash). Este valor está fijado por el hardware y no puede ser cambiado. Siempre que arranque, la instrucción que se encuentre en dicha dirección será la primera en ejecutarse. En otras arquitecturas esta dirección puede ser configurable.

Tras ello se ejecutará el conocido como crt0 (C Run-Time startup), que se encarga de inicializar el stack (la sección de memoria que contendrá, entre otras cosas, variables locales, parámetros de funciones y direcciones de retorno de las mismas), las variables globales (las que no tengan valor inicial serán puestas a cero) y por último de copiar las variables las de solo lectura (en C aquellos definidos con const) a una memoria no volátil.

El último paso que da el crt0 es ejecutar el main() correspondiente, que en este caso sería la función principal del bootloader. A partir de ahí, nuestro programa se ejecutaría normalmente.

Una vez entendido esto necesitamos entender como se pueden reorganizar las diferentes secciones de la memoria. Para ello disponemos de los llamados linker scripts. A continuación podéis ver una pequeña sección de uno de estos archivos. Dado que son archivos largos y engorrosos solamente muestro la sección que nos incumbe.

Bootloader linker script

Como se puede ver en el linker, lo primero que se ejecutará será el crt0, ya que se encuentra en la dirección 0x1FC00000. Posteriormente, como acabamos de ver, este será el encargado de saltar al main() del bootloader que se encontrará entre la dirección 0x1D001000 y la 0x1D001FFF. En la imagen que acompaña al linker se puede ver como quedaría repartida la memoria en este caso.

Lo único que falta ahora es saber dónde colocar el código de aplicación y cómo ejecutarlo cuando se den las condiciones necesarias.

/* Bootloader linker script */
MEMORY
{
  kseg0_program_mem  (rx) : ORIGIN = 0x1D001000, LENGTH = 0x1000 /* All C Files will be located here */
  exception_mem           : ORIGIN = 0x1D000000, LENGTH = 0x1000 /* Interrupt vector table */
  kseg1_boot_mem          : ORIGIN = 0x1FC00000, LENGTH = 0x490 /* C Startup code */
  config3                 : ORIGIN = 0x1FC00BF0, LENGTH = 0x4
  config2                 : ORIGIN = 0x1FC00BF4, LENGTH = 0x4
  config1                 : ORIGIN = 0x1FC00BF8, LENGTH = 0x4
  config0                 : ORIGIN = 0x1FC00BFC, LENGTH = 0x4
  kseg1_data_mem    (w!x) : ORIGIN = 0x00000000, LENGTH = 0x10000 /* RAM */
  sfrs                    : ORIGIN = 0x1F800000, LENGTH = 0x100000
}

Mapa de memoria del bootloader

App linker script

Al igual que el bootloader, en el linker script de la aplicación que se muestra a continuación indica la localización del crt0 correspondiente y del código de aplicación. Como véis, no hay rastro de la dirección de reset (0x1FC00000), y es que la aplicación no será ejecutada sin antes pasar por el bootloader.

/* App linker script */
MEMORY
{
  kseg0_program_mem  (rx) : ORIGIN = 0x1D003490, LENGTH = 0x20000 /* All C Files will be located here */
  exception_mem           : ORIGIN = 0x1D002000, LENGTH = 0x1000 /* Interrupt vector table */
  kseg1_boot_mem          : ORIGIN = 0x1D003000, LENGTH = 0x490 /* C Startup code */
}

El resultado tras unir ambos ejecutables usando los archivos de enlace anteriores es el que muestra la siguiente imagen. Este también sería el resultado esperado después de una actualización remota.

Mapa de memoria del bootloader y la app

Cuando sea necesario saltar del bootloader a la aplicación, el primero ha de lanzar manualmente el crt0 de la aplicación, que como hemos visto en el script se encuentra en la dirección 0x1D002000. Para ello tenemos que hacer uso de una de las características más potentes y peligrosas de C, los punteros a funciones. El código que necesitaríamos sería el siguiente:

void (*fptr)(void);
fptr = (void (*)(void)) 0x1D002000; // C startup code for app
fptr();

Comunicaciones entre app y bootloader

Para acabar, tuve que lidiar con una de las particularidades que tenía mi bootloader. En mi caso era necesario que el programa de arranque intercambiase cierta información con la aplicación principal. Pueden existir varias razones para ello. Por ejemplo, si el microcontrolador se reinicia cuando estaba ejecutando la aplicación, normalmente existe un registro que indica la causa del reinicio. Puesto que tras el reinicio se ejecutará el bootloader, es este quien puede acceder a dicha información antes de que sea borrada. Para hacérselo saber a la app es necesario una forma de pasar información entre ambos.

Ahora que sabemos como está organizada la memoria y qué podemos hacer con ella, estaréis de acuerdo conmigo en que una de las primeras opciones que se nos viene a la cabeza sería reservar algunas posiciones en forma de buffer compartido entre ambos.

Conclusiones

Hasta aquí lo que he aprendido sobre este tema. Como véis, el mundo de los sistemas embebidos es muy interesante y muy complejo una vez necesitas hacer algo que se sale de lo habitual. Muchas gracias a los que os habéis molestado en llegar hasta el final. Como dije al principio, este artículo no pretende ser una guía para crear un bootloader, sino más bien un ejercicio para asentar mis conocimientos y darme cuenta de las partes que necesito reforzar.

Los principales temas que me he dado cuenta que necesito entender son la diferencia entre memoria virtual y física, y los detalles de los linker script. Me gustaría hacerlo, eso sí, basándome en una familia diferente, ya sea ARM, debido a su gran cuota de mercado, o RISC-V por el potencial que le otorga su naturaleza abierta.

Vuelvo a recalcar también que he intentado ser lo más agnóstico posible en cuanto a la arquitectura para que pueda serle útil a la mayor parte de gente posible, por lo que me he tenido que tomar alguna que otra libertad. A pesar de ello espero que haya sido de ayuda para alguien y os animo también a vosotros a compartir conocimiento con la Comunidad.

Más recursos

Crédito de la imagen principal a yellowcloud. Se trata de unos microcontroladores con memoria EPROM, muy comunes antes de los años 90. Para borrar la memoria no volátil había que aplicarles luz UV.


Esta obra tiene una licencia Creative Commons Atribución-Compartir igual 4.0 Internacional
Licencia Creative Commons