Un vistazo a la arquitectura de ArduPilot

ArduPilot es un gran ejemplo de un proyecto potente y flexible. Soporta diferentes tipos de vehículos y corre en varias plataformas. En esta entrada intento profundizar en su arquitectura para reaprovechar sus fortalezas.

Un vistazo a la arquitectura de ArduPilot

El repositorio de ArduPilot puede resultar algo intimidatorio a primera vista debido a que ha crecido rápidamente para soportar tanto diferentes tipos de vehículos como diferentes plataformas hardware. Es decir, el mismo código se utiliza para pilotar drones, submarinos, rovers y aviones. Además, puede ser compilado para Linux, ChibiOS o PX4 así como diferentes controladoras de vuelo.

Por supuesto, cada una de estas plataformas usa unos drivers diferentes, lo que viene a significar que cada una de ellas tiene una forma diferente de acceder a los recursos del sistema (desde los periféricos como la UART, I2C o SPI hasta ciertas funcionalidades como el scheduler o los semáforos y mutexes). En un proyecto de este calibre y con esta versatilidad, es clave disponer de una arquitectura flexible.

Si queremos dar soporte a todas estas plataformas, la capa de aplicación no puede depender de ninguna instrucción específica del hardware. Por ello necesitamos crear una capa de abstracción intermedia que se conoce comúnmente como HAL (Hardware Abstraction Layer). La siguiente imagen está sacada de la documentación de ArduPilot y en ella se puede ver perfectamente lo que quiero decir:

Arquitectura software de Ardupilot

Mi curiosidad por el proyecto Ardupilot surgió hace meses, gracias a un proyecto de un dron. Tras leer parte del código, me pareció que podría ser un buen ejemplo de partida para un futuro proyecto. No solo la arquitectura me resultó interesante, sino que además está escrito en C++, un lenguaje que ya forma parte de mi día a día.

Esta es para mí la mayor contribución del código libre. Aunque no usemos un proyecto, podemos leer el código y aprender de él. Detrás hay muchas horas de trabajo que nos pueden ayudar a construir algo similar e incluso algo radicalmente diferente. No es lo mismo obtener una respuesta en Stackoverflow a una pregunta específica que ver y entender un caso de uso real.

Dicho esto, vamos al lío. Destripemos ArduPilot, más concretamente me centraré en ArduCopter, el autopiloto para multicópteros.

La capa de abstracción del hardware

Disponemos de varias opciones a la hora de diseñar una capa de abstracción. Podéis encontrar en esta respuesta de Stack Overflow las más habituales. En el caso que nos atañe, la HAL de ArduPilot, hace uso del polimorfismo. A continuación intentaré explicar que quiere decir esto mediante un ejemplo.

En el directorio libraries/AP_HAL/ podemos encontrar las interfaces que cada plataforma ha de definir para cada periférico. Observad que en dicha carpeta solo hay archivos .h que definen clases con métodos pure virtual, es decir son clases abstractas, que es como se conoce a aquellas que carecen de implementación alguna.

Esta es por ejemplo parte de la interfaz correspondiente a los GPIOs (os dejo también el link al archivo completo):

// libraries/AP_HAL/GPIO.h (section)
class AP_HAL::GPIO {
public:
    GPIO() {}
    virtual void    init() = 0;
    virtual void    pinMode(uint8_t pin, uint8_t output) = 0;

    virtual uint8_t read(uint8_t pin) = 0;
    virtual void    write(uint8_t pin, uint8_t value) = 0;
    virtual void    toggle(uint8_t pin) = 0;

    /* Interrupt interface: */
    virtual bool    attach_interrupt(uint8_t interrupt_num, AP_HAL::Proc p, uint8_t mode) = 0;
...
};

Al ser una clase abstracta, cada plataforma habrá de definir su implementación. El siguiente código corresponde por ejemplo a parte del método AP_HAL::GPIO::read() para la PX4 (link):

// libraries/AP_HAL_PX4/GPIO.cpp (section)
uint8_t PX4GPIO::read(uint8_t pin) {
    switch (pin) {

#ifdef GPIO_EXT_1
        case PX4_GPIO_EXT_FMU_RELAY1_PIN: {
            uint32_t relays = 0;
            ioctl(_gpio_fmu_fd, GPIO_GET, (unsigned long)&relays);
            return (relays & GPIO_EXT_1)?HIGH:LOW;
        }
#endif

#ifdef GPIO_EXT_2
        case PX4_GPIO_EXT_FMU_RELAY2_PIN: {
            uint32_t relays = 0;
            ioctl(_gpio_fmu_fd, GPIO_GET, (unsigned long)&relays);
            return (relays & GPIO_EXT_2)?HIGH:LOW;
        }
#endif
...
    }
    return LOW;
}

Y esta es la declaración de dicho método (link):

// libraries/AP_HAL_PX4/GPIO.h (section)
class PX4::PX4GPIO : public AP_HAL::GPIO {
public:
...
    uint8_t read(uint8_t pin) override;
...
}

Lo que os acabo de mostrar es solo la HAL de los GPIOs, pero el mismo esquema se repite para el resto de los recursos del sistema (e.g. I2C, SPI, UART, GPIOs, scheduler, semáforos...). Se define una interfaz y cada plataforma ha de implementarla.

Usando la HAL

Admito que es un proceso algo engorroso, pero tiene sus ventajas. Lo que me ha resultado más complicado de entender e implementar es el uso de esta abstracción. Particularmente, hay dos cuestiones que merecen ser analizadas con calma: ¿cómo y quién se encarga de inicializar los drivers?, ¿cómo accedemos a ellos?

Para entender por que esto es problemático hemos de conocer primero las restricciones:

  • Los drivers han de ser inicializados (configurar el hardware) una única vez.
  • En un RTOS, como es el caso de ArduPilot, este proceso ha de ser thread-safe. Esto en mi opinión no es un gran problema en los sistemas embebidos, y se puede solucionar inicializando el sistema antes de comenzar a ejecutar tarea alguna. ¿Qué sentido tiene iniciarlo si no podemos usar el hardware?

Para entender como resuelven en ArduPilot estas cuestiones vamos a analizar el archivo AP_HAL/AP_HAL.h (link), que si nos fijamos, está incluido en todos los ficheros correspondientes al código de aplicación. En él se agrupan las diferentes cabeceras de la HAL, de las que caben destacar las siguientes:

  • Las interfaces de los drivers (UARTDriver.h , GPIO.h, etc.). Todas ellas son clases puramente virtuales.
  • AP_HAL_Main.h es una interfaz para definir el main. Algo enrevesado a primera vista pero no tiene más.
  • HAL.h es quizás el elemento más importante, ya que en él se define la clase AP_HAL::HAL que contiene punteros a las instancias de cada driver. Recalco que son punteros, y es que para hacer uso del polimorfismo, cada plataforma ha de inicializar los correspondientes drivers e inyectarlos en esta clase, que sería por así decirlo, el genérico:
// HAL.h (section)
class AP_HAL::HAL {
    public:
        HAL(AP_HAL::UARTDriver* _uartA, // console
            AP_HAL::UARTDriver* _uartB, // 1st GPS
            AP_HAL::UARTDriver* _uartC, // telem1
            AP_HAL::I2CDeviceManager* _i2c_mgr,
            AP_HAL::SPIDeviceManager* _spi,
            AP_HAL::UARTDriver* _console,
            AP_HAL::GPIO*       _gpio,
            AP_HAL::Scheduler*  _scheduler,
            :
            uartA(_uartA),
            uartB(_uartB),
            uartC(_uartC),
            i2c_mgr(_i2c_mgr),
            spi(_spi),
            console(_console),
            gpio(_gpio),
        {
            AP_HAL::init();
        }
...
}

Lo que acabamos de ver es lo que le permite a la aplicación no necesitar saber nada sobre el hardware, sino conocer únicamente una serie de interfaces que poco tienen que ver con el metal sobre la que se está ejecutando.

De la misma manera que antes hemos visto como era la implementación de los GPIOs, vamos a ver ahora la implementación de AP_HAL::HAL para la PX4 (link). Lo que hace ArudPilot en este caso es instanciar objectos estáticos de las clases correspondientes a cada driver y pasar sus punteros al constructor correspondiente:

// libraries/AP_HAL_PX4/HAL_PX4_Class.cpp (section)
static PX4::I2CDeviceManager i2c_mgr_instance;
static PX4::SPIDeviceManager spi_mgr_instance;
...
static PX4UARTDriver uartADriver(UARTA_DEFAULT_DEVICE, "APM_uartA");
static PX4UARTDriver uartBDriver(UARTB_DEFAULT_DEVICE, "APM_uartB");

...

HAL_PX4::HAL_PX4() :
    AP_HAL::HAL(
        &uartADriver,  /* uartA */
        &uartBDriver,  /* uartB */
        &uartCDriver,  /* uartC */
        &i2c_mgr_instance,
        &spi_mgr_instance,
        &analogIn, /* analogin */
        &storageDriver, /* storage */
        &uartADriver, /* console */
        &gpioDriver, /* gpio */
        &rcinDriver,  /* rcinput */
        &rcoutDriver, /* rcoutput */
        &schedulerInstance, /* scheduler */
        &utilInstance, /* util */
        nullptr,    /* no onboard optical flow */
        nullptr)   /* CAN */
{}

Para asegurar que la inicialización solo sucede una vez y que toda la capa de aplicación puede acceder a ella, este proyecto hace uso de un singleton, más concretamente de una variante habitualmente conocida como Meyers' Singleton:

const AP_HAL::HAL& AP_HAL::get_HAL() {
    static const HAL_PX4 hal_px4;
    return hal_px4;
}

De esta manera, el esquema para acceder a la HAL sería algo así (link):

// libraries/AP_Notify/AP_BoardLED.cpp (section)
extern const AP_HAL::HAL& hal;

bool AP_BoardLED::init(void)
{
    // setup the main LEDs as outputs
    hal.gpio->pinMode(HAL_GPIO_A_LED_PIN, HAL_GPIO_OUTPUT);
    hal.gpio->pinMode(HAL_GPIO_B_LED_PIN, HAL_GPIO_OUTPUT);
    hal.gpio->pinMode(HAL_GPIO_C_LED_PIN, HAL_GPIO_OUTPUT);

    // turn all lights off
    hal.gpio->write(HAL_GPIO_A_LED_PIN, HAL_GPIO_LED_OFF);
    hal.gpio->write(HAL_GPIO_B_LED_PIN, HAL_GPIO_LED_OFF);
    hal.gpio->write(HAL_GPIO_C_LED_PIN, HAL_GPIO_LED_OFF);
    return true;
}

Este mecanismo que acabamos de describir no es más que un objeto global. Personalmente no es una solución que me apasione, pero es cierto que en sistemas embebidos hay que hacer algunos sacrificios y este podría ser uno de ellos. Yo he probado varias alternativas y al final siempre acabo volviendo a esta solución por su simplicidad.

Conclusiones

Han quedado detalles interesantes atrás, pero al final lo que pretendía transmitir es que si queremos tener un software modular y reutilizable hemos de poner el foco en las interfaces y abstracciones que comunican las diferentes capas. Dependiendo de nuestras necesidades, esta capa puede estar más cerca o más lejos del hardware. En este caso en ArduPilot necesitaban ocultar los drivers, pero podría darse el caso de que necesitemos únicamente abstraer el sistema operativo, o por ejemplo alguna librería específica que dependa de un tercero.  

Este nivel de abstracción, además de hacer el código extremadamente portable, tiene otra consecuencia muy interesante, y es que facilita la integración de tests automáticos. Por ejemplo, gracias a la HAL, si deseamos simular cambios en un GPIO, no tenemos más que añadir una plataforma nueva en la que los drivers son sustituidos por fakes o mocks.

Es más, ArduPilot dispone de un simulador en el que las lecturas de los sensores vienen de un simulador de vuelo. Es decir, podemos probar el autopiloto sin depender por ejemplo de las condiciones meteorológicas. Podemos también forzar la aparición de cualquier evento anómalo tipo derivas de la brújula o glitches GPS, que en condiciones reales son imposibles de reproducir de manera fiable, por lo que el SITL es imprescindible.

Tenía un borrador de este post escrito desde hace bastante tiempo pero nunca me había animado a acabarlo porque no estaba contento con el resultado (de hecho me sigue pareciendo que no ha quedado tan claro como me gustaría). Personalmente, para lo que más me ha servido este análisis es para mejorar la integración de herramientas de tests unitarios e integración continua en mi flujo de trabajo. Este es un tema que da para otro post, así que si os interesa permaneced atentos al blog.


Crédito de la imagen principal a Dose Media.

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