
Hola Habr! En este artículo quiero hablar sobre el trabajo de punto flotante para procesadores con arquitectura ARM. Creo que este artículo será útil principalmente para aquellos que portan su sistema operativo a la arquitectura ARM y al mismo tiempo necesitan soporte para punto flotante de hardware (lo que hicimos para
Embox , que anteriormente usaba una implementación de software de operaciones de punto flotante).
Entonces comencemos.
Banderas del compilador
Para admitir coma flotante, debe pasar los indicadores correctos al compilador. Una
búsqueda rápida en
Google lleva a la idea de que dos opciones son especialmente importantes: -mfloat-abi y -mfpu. La opción -mfloat-abi establece el
ABI para operaciones de punto flotante y puede tener uno de tres valores: 'soft', 'softfp' y 'hard'. La opción 'soft', como su nombre lo indica, le dice al compilador que use las llamadas de función incorporadas para programar el punto flotante (esta opción se usó antes). Los dos 'softfp' y 'hard' restantes se considerarán un poco más tarde, después de considerar la opción -mfpu.
-Mfpu flag y versión VFP
La opción -mfpu, tal como está escrita en la
documentación en línea de gcc , le permite especificar el tipo de hardware y puede tomar las siguientes opciones:
'auto', 'vfpv2', 'vfpv3', 'vfpv3-fp16', 'vfpv3-d16', 'vfpv3-d16-fp16', 'vfpv3xd', 'vfpv3xd-fp16', 'neon-vfpv3', 'neon -fp16 ',' vfpv4 ',' vfpv4-d16 ',' fpv4-sp-d16 ',' neon-vfpv4 ',' fpv5-d16 ',' fpv5-sp-d16 ',' fp-armv8 ',' neon -fp-armv8 'y' crypto-neon-fp-armv8 '. Y 'neon' es lo mismo que 'neon-vfpv3', y 'vfp' es 'vfpv2'.
Mi compilador (arm-none-eabi-gcc (15: 5.4.1 + svn241155-1) 5.4.1 20160919) produce una lista ligeramente diferente, pero esto no cambia la esencia del asunto. En cualquier caso, debemos entender cómo esta o aquella bandera afecta al compilador y, por supuesto, qué bandera se debe usar cuando.
Comencé a entender la plataforma basada en el procesador imx6, pero la pospondremos por un tiempo, ya que el coprocesador de neón tiene características que analizaré más adelante, y comenzaremos con un caso más simple: desde la plataforma
integrador / cp ,
No tengo la placa en sí, por lo que la depuración se realizó en el emulador qemu. En qemu, la plataforma Interator / cp se basa en el procesador
ARM926EJ-S , que a su vez admite el coprocesador
VFP9-S . Este coprocesador cumple con la versión 2 de Vector Floating-point Architecture (VFPv2). En consecuencia, debe establecer -mfpu = vfpv2, pero esta opción no estaba en la lista de opciones de mi compilador. En
Internet, encontré una opción de compilación con los indicadores -mcpu = arm926ej-s -mfpu = vfpv3-d16, lo instalé y todo lo compilé para mí. Cuando comencé, recibí una
excepción de instrucción indefinida , que era predecible, porque el coprocesador estaba
apagado .
Para permitir que el coprocesador funcione, debe establecer el bit EN [30] en el registro
FPEXC . Esto se hace usando el comando VMSR.
asm volatile ("VMSR FPEXC, %0" : : "r" (1 << 30);
De hecho, el comando VMSR es procesado por el coprocesador y genera una excepción si el coprocesador no está encendido, pero el acceso a este registro
no lo causa . Es cierto que, a diferencia de los demás, el acceso a este registro solo es posible en
modo privilegiado .
Después de que se permitió que el coprocesador funcionara, nuestras pruebas de funciones matemáticas comenzaron a pasar. Pero cuando activé la optimización (-O2), surgió la excepción de instrucción indefinida mencionada anteriormente. Y surgió en la instrucción
vmov que se llamó en el código anteriormente, pero se ejecutó con éxito (sin una excepción). Finalmente, encontré al final de la página la frase "Las instrucciones que copian las constantes inmediatas están disponibles en VFPv3" (es decir, las operaciones con constantes son compatibles a partir de VFPv3). Y decidí verificar qué versión se lanza en mi emulador. La versión se registra en el registro
FPSID . De la documentación se deduce que el valor del registro debe ser 0x41011090. Esto corresponde a 1 en el campo de la arquitectura [19..16], es decir, VFPv2. En realidad, después de haber hecho una copia impresa al inicio, obtuve esto
unit: initializing embox.arch.arm.fpu.vfp9_s: VPF info: Hardware FP support Implementer = 0x41 (ARM) Subarch: VFPv2 Part number = 0x10 Variant = 0x09 Revision = 0x00
Después de leer cuidadosamente que 'vfp' es alias 'vfpv2', configuré el indicador correcto, funcionó. Volviendo a la
página donde vi la combinación de banderas -mcpu = arm926ej-s -mfpu = vfpv3-d16, noto que no tuve el cuidado suficiente, porque -mfloat-abi = soft aparece en la lista de banderas. Es decir, no hay soporte de hardware en este caso. Más precisamente, -mfpu solo importa si un valor que no sea 'soft' se establece en -mfloat-abi.
Ensamblador
Es hora de hablar sobre ensamblador. Después de todo, necesitaba hacer soporte en tiempo de ejecución y, por ejemplo, el compilador, por supuesto, no sabe sobre el cambio de contexto.
Registros
Comencemos con la descripción de los registros. VFP le permite realizar operaciones con números de coma flotante de 32 bits (s0..s31) y 64 bits (d0..d15). La correspondencia entre estos registros se muestra en la imagen a continuación.

Q0-Q15 son registros de 128 bits de versiones anteriores para trabajar con SIMD, más sobre ellos más adelante.
Sistema de mando
Por supuesto, la mayoría de las veces el trabajo con los registros de VFP debe entregarse al compilador, pero al menos debe escribir el cambio de contexto manualmente. Si ya tiene una comprensión aproximada de la sintaxis de las instrucciones del ensamblador para trabajar con registros de uso general, tratar con nuevas instrucciones no debería ser difícil. Muy a menudo, el prefijo "v" simplemente se agrega.
vmov d0, r0, r1 /* r0 r1, .. d0 64 , r0-1 32 */ vmov r0, r1, d0 vadd d0, d1, d2 vldr d0, r0 vstm r0!, {d0-d15} vldm r0!, {d0-d15}
Y así sucesivamente. Se puede encontrar una lista completa de comandos en
el sitio web de ARM .
Y, por supuesto, no te olvides de la versión VFP para que no haya situaciones como la descrita anteriormente.
Marcar -mfloat-abi 'softfp' y 'hard'
Volver a -mfloat-abi. Si lee la
documentación , veremos:
'softfp' permite la generación de código usando instrucciones de coma flotante de hardware, pero aún usa las convenciones de llamada de flotación suave. 'hard' permite la generación de instrucciones de punto flotante y utiliza convenciones de llamadas específicas de FPU.
Es decir, estamos hablando de pasar argumentos a una función. Pero al menos no estaba muy claro para mí cuál es la diferencia entre las convenciones de llamadas de "flotación suave" y "específicas de FPU". Suponiendo que el estuche rígido usa registros de coma flotante y el caso softfp usa registros enteros, encontré confirmación en el
wiki de Debian . Y aunque esto es para coprocesadores NEON, pero no importa. Otro punto interesante es que con la opción softfp, el compilador puede, pero no está obligado a usar soporte de hardware:
"El compilador puede tomar decisiones inteligentes sobre cuándo y si genera instrucciones de FPU emuladas o reales dependiendo del tipo de FPU elegido (-mfpu =)"
Para mayor claridad, decidí experimentar, y me sorprendió mucho, porque con la optimización de -O0 desactivada, la diferencia era muy leve y no se aplicaba a los lugares donde realmente se usaba el punto flotante. Adivinando que el compilador simplemente empuja todo en la pila, en lugar de usar registros, encendí la optimización -O2 y nuevamente me sorprendí, porque con la optimización el compilador comenzó a usar registros de coma flotante de hardware para las opciones hard y sotffp, y la diferencia es cómo y en el caso de -O0 fue muy insignificante. Como resultado, para mí mismo, expliqué esto por el hecho de que el compilador resuelve el
problema asociado con el hecho de que si copia datos entre registros de punto flotante y enteros, el rendimiento disminuirá significativamente. Y el compilador, al optimizar, comienza a usar todos los recursos a su disposición.
Cuando me preguntaron qué bandera usar 'softfp' o 'hard', respondí por mí mismo de la siguiente manera: siempre que haya partes compiladas con la bandera 'softfp', debe usar 'hard'. Si hay alguno, entonces necesita usar 'softfp'.
Cambio de contexto
Como Embox admite la multitarea preventiva, para que funcione correctamente en tiempo de ejecución, naturalmente, se necesitaba una implementación de cambio de contexto. Para esto, es necesario guardar los registros del coprocesador. Hay un par de matices. Primero: resultó que
los comandos de operación de pila para puntos flotantes (vstm / vldm) no son compatibles con todos los modos . Segundo: estas operaciones no admiten el trabajo con más de dieciséis registros de 64 bits. Si necesita cargar / guardar más registros a la vez, debe usar dos instrucciones.
Daré una pequeña optimización más. De hecho, no es necesario guardar y restaurar 256 bytes de registros VFP cada vez (los registros de propósito general ocupan solo 64 bytes, por lo que la diferencia es significativa). La optimización obvia realizará estas operaciones solo si el proceso utiliza estos registros en principio.
Como ya mencioné, cuando el coprocesador VFP está apagado, un intento de ejecutar la instrucción correspondiente dará como resultado una excepción de "Instrucción no definida". En el controlador de esta excepción, debe verificar cuál es la causa de la excepción, y si se trata de usar un coprocesador VPF, el proceso se marca como el uso del coprocesador VFP.
Como resultado, el contexto de guardar / restaurar ya escrito se complementó con macros
#define ARM_FPU_CONTEXT_SAVE_INC(tmp, stack) \ vmrs tmp, FPEXC ; \ stmia stack!, {tmp}; \ ands tmp, tmp, #1<<30; \ beq fpu_out_save_inc; \ vstmia stack!, {d0-d15}; \ fpu_out_save_inc: #define ARM_FPU_CONTEXT_LOAD_INC(tmp, stack) \ ldmia stack!, {tmp}; \ vmsr FPEXC, tmp; \ ands tmp, tmp, #1<<30; \ beq fpu_out_load_inc; \ vldmia stack!, {d0-d15}; \ fpu_out_load_inc:
Para verificar la corrección de la operación de cambio de contexto en condiciones de punto flotante, escribimos una prueba en la que multiplicamos en un hilo en un bucle y dividimos en otro, luego comparamos los resultados.
EMBOX_TEST_SUITE("FPU context consistency test. Must be compiled with -02"); #define TICK_COUNT 10 static float res_out[2][TICK_COUNT]; static void *fpu_context_thr1_hnd(void *arg) { float res = 1.0f; int i; for (i = 0; i < TICK_COUNT; ) { res_out[0][i] = res; if (i == 0 || res_out[1][i - 1] > 0) { i++; } if (res > 0.000001f) { res /= 1.01f; } sleep(0); } return NULL; } static void *fpu_context_thr2_hnd(void *arg) { float res = 1.0f; int i = 0; for (i = 0; i < TICK_COUNT; ) { res_out[1][i] = res; if (res_out[0][i] != 0) { i++; } if (res < 1000000.f) { res *= 1.01f; } sleep(0); } return NULL; } TEST_CASE("Test FPU context consistency") { pthread_t threads[2]; pthread_t tid = 0; int status; status = pthread_create(&threads[0], NULL, fpu_context_thr1_hnd, &tid); if (status != 0) { test_assert(0); } status = pthread_create(&threads[1], NULL, fpu_context_thr2_hnd, &tid); if (status != 0) { test_assert(0); } pthread_join(threads[0], (void**)&status); pthread_join(threads[1], (void**)&status); test_assert(res_out[0][0] != 0 && res_out[1][0] != 0); for (int i = 1; i < TICK_COUNT; i++) { test_assert(res_out[0][i] < res_out[0][i - 1]); test_assert(res_out[1][i] > res_out[1][i - 1]); } }
La prueba pasó con éxito cuando se desactivó la optimización, por lo que indicamos en la descripción de la prueba que debe compilarse con la optimización, EMBOX_TEST_SUITE ("Prueba de coherencia de contexto FPU. Debe compilarse con -02"); aunque sabemos que las pruebas no deberían basarse en esto.
Coprocesador NEON y SIMD
Es hora de decir por qué pospuse la historia sobre imx6. El hecho es que se basa en el núcleo Cortex-A9 y contiene el coprocesador NEON más avanzado (https://developer.arm.com/technologies/neon). NEON no solo es VFPv3, sino que también es un coprocesador SIMD. VFP y NEON usan los mismos registros. VFP usa registros de 32 bits y 64 bits para la operación, y NEON usa registros de 64 bits y 128 bits, estos últimos fueron designados Q0-Q16. Además de los valores enteros y los números de coma flotante, NEON también puede trabajar con un anillo polinomial de módulo 2 de 16 ° u 8 ° grado.
El modo vfp para NEON casi no es diferente del coprocesador vfp9-s desmontado. Por supuesto, es mejor especificar las opciones vfpv3 o vfpv3-d32 para -mfpu para una mejor optimización, ya que tiene 32 registros de 64 bits. Y para habilitar el coprocesador, debe dar acceso a los coprocesadores c10 y c11. esto se hace usando comandos
asm volatile ("mrc p15, 0, %0, c1, c0, 2" : "=r" (val) :); val |= 0xf << 20; asm volatile ("mcr p15, 0, %0, c1, c0, 2" : : "r" (val));
pero no hay otras diferencias fundamentales.
Otra cosa si especifica -mfpu = neon, en este caso el compilador puede usar instrucciones SIMD.
Usando SIMD en C
Para << registrar >> valores manualmente por registro, puede incluir "arm_neon.h" y usar los tipos de datos correspondientes:
float32x4_t para cuatro flotantes de 32 bits en un registro, uint8x8_t para ocho enteros de 8 bits y así sucesivamente. Para acceder a un solo valor, nos referimos a él como una matriz, suma, multiplicación, asignación, etc. En cuanto a las variables ordinarias, por ejemplo:
uint32x4_t a = {1, 2, 3, 4}, b = {5, 6, 7, 8}; uint32x4_t c = a * b; printf(“Result=[%d, %d, %d, %d]\n”, c[0], c[1], c[2], c[3]);
Por supuesto, usar la vectorización automática es más fácil. Para la vectorización automática, agregue el indicador -ftree-vectorize a GCC.
void simd_test() { int a[LEN], b[LEN], c[LEN]; for (int i = 0; i < LEN; i++) { a[i] = i; b[i] = LEN - i; } for (int i = 0; i < LEN; i++) { c[i] = a[i] + b[i]; } for (int i = 0; i < LEN; i++) { printf("c[i] = %d\n", c[i]); } }
El ciclo de adición genera el siguiente código:
600059a0: f4610adf vld1.64 , [r1 :64] 600059a4: e2833010 add r3, r3, #16 600059a8: e28d0a03 add r0, sp, #12288 ; 0x3000 600059ac: e2811010 add r1, r1, #16 600059b0: f4622adf vld1.64 , [r2 :64] 600059b4: e2822010 add r2, r2, #16 600059b8: f26008e2 vadd.i32 q8, q8, q9 600059bc: ed430b04 vstr d16, [r3, #-16] 600059c0: ed431b02 vstr d17, [r3, #-8] 600059c4: e1530000 cmp r3, r0 600059c8: 1afffff4 bne 600059a0 <foo+0x58> 600059cc: e28d5dbf add r5, sp, #12224 ; 0x2fc0 600059d0: e2444004 sub r4, r4, #4 600059d4: e285503c add r5, r5, #60 ; 0x3c
Después de realizar pruebas para código paralelo, descubrimos que la simple adición en un bucle, siempre que las variables sean independientes, da una aceleración de hasta 7 veces. Además, decidimos ver cuánto paralelismo afecta las tareas reales, tomamos MESA3d con su emulación de software y medimos el número de fps con diferentes indicadores, obtuvimos una ganancia de 2 cuadros por segundo (15 frente a 13), es decir, la aceleración es de aproximadamente 15-20% .
Daré otro
ejemplo de aceleración usando los comandos NEON , no los nuestros, sino de ARM.
La copia de memoria es hasta un 50 por ciento más rápida de lo normal. Los verdaderos ejemplos están ahí en ensamblador.
Ciclo de copia normal:
WordCopy LDR r3, [r1], #4 STR r3, [r0], #4 SUBS r2, r2, #4 BGE WordCopy
bucle con comandos y registros de neón:
NEONCopyPLD PLD [r1, #0xC0] VLDM r1!,{d0-d7} VSTM r0!,{d0-d7} SUBS r2,r2,#0x40 BGE NEONCopyPLD
Está claro que la copia de 64 bytes es más rápida que 4 bytes, y dicha copia dará un aumento del 10%, pero el 40% restante parece dar el trabajo del coprocesador.
Cortex-m
Trabajar con FPU en Cortex-M no es muy diferente del descrito anteriormente. Por ejemplo, así es como se ve la macro anterior para guardar el contexto fpu-shny
#define ARM_FPU_CONTEXT_SAVE_INC(tmp, stack) \ ldr tmp, =CPACR; \ ldr tmp, [tmp]; \ tst tmp, #0xF00000; \ beq fpu_out_save_inc; \ vstmia stack!, {s0-s31}; fpu_out_save_inc:
Además, el comando vstmia usa solo los registros s0-s31 y se accede a los registros de control de manera diferente. Por lo tanto, no entraré en demasiados detalles, solo explicaré diff. Entonces, hicimos soporte para STM32F7discovery con
cortex-m7 para ello, respectivamente, necesitamos establecer el indicador -mfpu = fpv5-sp-d16. Tenga en cuenta que en las versiones móviles, debe mirar más de cerca la versión del coprocesador, ya que el mismo cortex-m puede tener diferentes opciones. Entonces, si su opción no es con doble precisión, sino con una sola, entonces puede que no haya registros D0-D16, como lo hemos hecho en
stm32f4discovery , por lo que se utiliza la variante con registros S0-S31. Para este controlador usamos -mfpu = fpv4-sp-d16.
La principal diferencia es el acceso a los registros de control del controlador, están ubicados directamente en el espacio de direcciones del núcleo principal, y para diferentes tipos son diferentes
cortex-m4 para
cortex-m7 .
Conclusión
Con esto terminaré mi cuento sobre punto flotante para ARM. Observo que los microcontroladores modernos son muy potentes y adecuados no solo para el control, sino también para procesar señales o diversos tipos de información multimedia. Para utilizar eficazmente todo este poder, debe comprender cómo funciona. Espero que este artículo haya ayudado a resolver esto un poco mejor.