Experimentando con C++ en sistemas embebidos

C++ es un lenguaje que está evolucionando bastante en los últimos años. En este post analizo que hace el compilador cuando usamos 'std::array' y las diferencias con un clásico array de C.

Experimentando con C++ en sistemas embebidos

Últimamente he estado empleando C++ de manera habitual. C está bien, se puede optimizar al máximo, es muy versátil y dentro de lo que cabe es un lenguaje sencillo. C++ sin embargo abre un nuevo abanico de posibilidades: es un lenguaje orientado a objetos, es más seguro, más completo... ¡Ah, y es compatible con C!

Para los que no os creáis esto, os dejo un vídeo muy interesante en el que se discute este tema:


Personalmente nunca había usado C++ a nivel profesional. Siempre había escuchado que C++ en sistemas embebidos no era recomendable; que si el genera mucho código, que si no es eficiente, que si el soporte de los compiladores... Es más, podría decir que lo que yo empleaba era una especie de C con objetos. No hacía uso de prácticamente ninguna característica especial. Pero C++ no es eso. Hay que verlo como un lenguaje diferente, que necesita habilidades y conocimientos diferentes.

Algo que he llegado a comprender es que se puede usar sin ningún objeto y sacarle provecho igualmente. Hoy por ejemplo, voy a hablar de una funcionalidad que ha sido introducida en C++11: el std::array.

¿Que es un std::array?

C++11 se añadió a la biblioteca estándar un nuevo tipo contenedor llamado  array sobre el que leía cada vez que buscaba algo en StackOverflow. Todo el mundo lo recomendaba para sustituir los clásicos arrays de C pero yo no entendía muy bien el motivo, así que esto es lo que voy a destripar.

Si verdaderamente queremos entender cuáles son las ventajas y cómo funciona el lenguaje, tenemos que comprender el código que genera el compilador. Para ello hay una herramienta open source magnífica: Compiler Explorer. Podéis interactuar con el ejemplo que voy a desgranar en este post haciendo click aquí. El compilador que usaré sera ARM gcc 7.2.1.

El código completo del ejemplo es sencillo. Lo que busco es emplear arrays de C y de C++ de diferentes maneras para entender qué está sucediendo. El caso más típico es pasarlos como parámetros a una función:

#include <array>

void print_carray(char arr[]) {
    printf("%c", arr[0]);
}

void print_cpparray(std::array<char, 5> arr) {
    printf("%c", arr[0]);
}

void print_cpparray_ref(std::array<char, 5>& arr) {
    printf("%c", arr[0]);
}

int main() {
    char arrc[] = {'a','b', 'c', 'd', '\0'};
    std::array<char, 5> arrcpp = {'e','f', 'g', 'h', '\0'};

    print_carray(arrc);
    print_cpparray(arrcpp);
    print_cpparray_ref(arrcpp);
}

La parte más dura consiste en entender el código ensamblado. Compiler Explorer ayuda a seguir lo que está sucediendo, pero hay que entender que hace cada instrucción y para que vale cada registro. Al final del artículo he dejado unos enlaces a unos archivos donde hay algo de información al respecto. En este caso he añadido personalmente pseudocódigo como comentarios.

Vamos a comenzar por la declaración de las variables:

char arrc[] = {'a','b', 'c', 'd', '\0'};
std::array<char, 5> arrcpp = {'e','f', 'g', 'h', '\0'};

El resultado de estas dos líneas es el siguiente:

; arrc
        ldr     r2, .L8         ; r2 = puntero a arrc[0]
        sub     r3, fp, #12     ; r2 = fp - 12
        ldm     r2, {r0, r1}    ; r0 = arrc[0-3] (1 palabra)
                                ; r1 = arrc[4-7] (1 palabra)
        str     r0, [r3]        ; escribe arrc[0-3] en (fp - 12)
        add     r3, r3, #4      ; r3 = fp - 16
        strb    r1, [r3]        ; escribe arrcpp[4] to (fp - 16)

; arrcpp
        ldr     r2, .L8+4       ; r2 = puntero a arrcpp[0]
        sub     r3, fp, #20     ; r2 = fp - 20
        ldm     r2, {r0, r1}    ; r0 = arrcpp[0-3] (1 palabra)
                                ; r1 = arrcpp[4-7] (1 palabra)
        str     r0, [r3]        ; escribe arrcpp[0-3] en fp - 20
        add     r3, r3, #4      ; r3 = fp - 24
        strb    r1, [r3]        ; escribe arrcpp[4] en fp - 24



.LC0:
        .byte   97       ; arrc[0]
        .byte   98
        .byte   99
        .byte   100
        .byte   0
        .align  2
.LC1:
        .byte   101      ; arrcpp[0]
        .byte   102
        .byte   103
        .byte   104
        .byte   0
        .text
        .align  2
        .global main     ; aquí comienza el main()
.L8:
        .word   .LC0
        .word   .LC1
        .cfi_endproc

Como podéis observar, en la memoria es imposible distinguir un array de C de uno std::array. ¡Son idénticos!

Vamos ahora a ver que pasa cuando los usamos, ¿qué diferencia hay a la hora de pasar un array de C y uno de C++ como parámetro a una función?

; print_carray(arrc)
        sub     r3, fp, #12             ; r3 = fp - 12 (apunta a arrc[0])
        mov     r0, r3                  ; r0 = r3
        bl      print_carray(char*)     ; ejecuta print_carray()

; print_cpparray_ref(arrcpp)
        sub     r3, fp, #20             ; similar a lo anterior
        mov     r0, r3
        bl      print_cpparray_ref(std::array<char, 5u>&)

Como cabía esperar, si a la función print_carray le pasamos arrc, lo que hace es poner el puntero a arrc[0] en R0 para que acceda la función. Lo mismo sucede si lo pasamos por referencia (print_cpparray_ref).

Algo curioso con lo que no contaba era con que usase registros en vez de una región de memoria reservada (la pila o stack) para pasar los valores. Resulta que hay cuatro registros R0-R3 (16 bytes en una arquitectura de 32 bits) disponibles para este uso: compartir información con las subrutinas.

Prosigamos entonces. ¿Qué pasa si pasamos std::array por valor?

; print_cpparray(arrcpp)
        sub     r3, fp, #20
        ldm     r3, {r0, r1}    ; r0 = arrcpp[0-3] (1 palabra)
                                ; r1 = arrcpp[4-7] (1 palabra)

        bl      print_cpparray(std::array<char, 5u>)

En este caso la diferencia está clara, lo que se le pasa a la función es una copia del array que en este caso cabe en dos registros de 4 bytes (2 palabras). En argot técnico se dice que std::array no decae (decay) en un puntero. Es decir, la conversión ha de ser explícita: arrc es un puntero, mientras que arrcpp no.

Arrays de más de 16 bytes

Sabiendo esto, ¿qué pasa si el tamaño de array excede el tamaño de los registros reservados? ¿Sigue usando registros para llamar a la función en vez del stack? ¿Hay mucha diferencia entre pasar el array usando un puntero o una referencia?

Vayamos con ello (aquí está el proyecto de Compiler Explorer):

int main() {
    std::array<char, 21> arrcpp = {'e','f', 'g', 'h', 'e','f', 'g', 'h',
                                   'e','f', 'g', 'h', 'e','f', 'g', 'h',
                                   'e','f', 'g', 'h', '\0'};

    print_cpparray(arrcpp);
    print_cpparray_ref(arrcpp);
}

No vamos a ver que sucede al declarar la variable. Es muy parecido a lo que pasaba anteriormente. Vayamos con la parte que nos interesa, las llamadas a la función:

; print_cpparray(arrcpp);
        mov     r3, sp                    ; r3 = sp (stack pointer)
        sub     r2, fp, #12               ; r2 = fp - 12
        ldm     r2, {r0, r1}              ; r0 = arrcpp[16-19]
                                          ; r1 = arrcpp[20-23] (3 bytes que no pertenecen al array)
        str     r0, [r3]                  ; escribe arrcpp[16-19] en el stack
        add     r3, r3, #4                ; incrementa sp en 1 palabra
        strb    r1, [r3]                  ; escribe arrcpp[20] en el stack
        sub     r3, fp, #28               ; r3 = fp - 28
        ldm     r3, {r0, r1, r2, r3}      ; r0 = arrcpp[0-3]
                                          ; r1 = arrcpp[4-7]
                                          ; r2 = arrcpp[8-11]
                                          ; r3 = arrcpp[12-15]
        bl      print_cpparray(std::array<char, 21u>)

Llama la atención lo que está sucediendo. En este caso el compilador hace uso tanto de la pila como de los registros disponibles. Parte del array se lo pasa a la función a través de la memoria (arrcpp[16-20]) y otra parte usando registros hardware.

¿Y si ahora lo pasamos por referencia?

; print_cpparray_ref(arrcpp);
        sub     r3, fp, #28       ; r3 = fp - 28 (puntero a arrcpp[0])
        mov     r0, r3            ; r0 = r3
        bl      print_cpparray_ref(std::array<char, 21u>&)

Como cabía esperar, esto no ha cambiado. Independientemente del tamaño del objeto (en este caso del array), lo único que la función necesita es el puntero al objeto en el registro r0.

Conclusiones

Hay varias cosas que podemos aprender de este ejemplo:

  • El ensamblador asusta, pero yendo paso a paso es fácil de comprender. Nos ayuda a entender un lenguaje y también el hardware sobre el que corre.
  • std::array no tiene overhead con respecto al array[]. Por cierto, esto se conoce muchas veces como PLD o Plain Old Data.
  • El número de argumentos y tamaño de los mismo afecta a la eficiencia del programa. En un micro ARM disponemos de 4 registros (16 bytes) para comunicarnos con la subrutina si hacer uso de la memoria (más lento).

Además, hay otras diferencias importantes que no he tratado en este ejemplo:

  • El compilador conoce en todo momento el tamaño de un std::array. Por ejemplo, si print_carray necesitase iterar sobre todo el array, necesitaríamos pasarle el dicho tamaño como un parámetro extra. Sin embargo en print_cpparray_ref nos bastaría con usar arrcpp.size(). Por supuesto esto tiene truco, y es que cuando definimos std::array<char, 5> arrcpp estamos definiendo un nuevo contenedor que es de tamaño fijo, en este caso 5 bytes. Ni más ni menos. Si quisiésemos un contenedor variable tendríamos que usar std::vector, algo que mostraré en el futuro si este post genera interés.
  • Sobre un std::array podemos iterar. Algo que hace nuestro código más entendible y seguro.

No hay por tanto motivos para, disponiendo de ambas opciones, elegir Plain Old Data.

Mientras no haya otro lenguaje orientado a objetos estable para microcontroladores (la comunidad de Rust viene empujando fuerte), yo seguiré aprendiendo y usando C++. Y recuerda, si dices que usas C++ pero no aprovechas funcionalidades como std::array o enum Class , lo más probable es que estés usando C con esteroides. No hay nada malo en ello, simplemente no le estás sacando partido al lenguaje.

Si os interesa este tipo de post, contactad conmigo en @crespum y preparo más hablando de otras características interesantes de C++.

Más recursos


Crédito de la imagen principal a Lucas Vasques.

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