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.
Ú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 alarray[]
. 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, siprint_carray
necesitase iterar sobre todo el array, necesitaríamos pasarle el dicho tamaño como un parámetro extra. Sin embargo enprint_cpparray_ref
nos bastaría con usararrcpp.size()
. Por supuesto esto tiene truco, y es que cuando definimosstd::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 usarstd::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.