Obtener un rendimiento rápido de una MCU STM32

Estoy trabajando con el kit de descubrimiento STM32F303VC y estoy un poco desconcertado por su rendimiento. Para familiarizarme con el sistema, he escrito un programa muy simple simplemente para probar la velocidad de transferencia de bits de esta MCU. El código se puede dividir de la siguiente manera:

  1. El reloj HSI (8 MHz) está encendido;
  2. PLL se inicia con el prescaler de 16 para lograr HSI / 2 * 16 = 64 MHz;
  3. PLL se designa como SYSCLK;
  4. SYSCLK se monitorea en el pin MCO (PA8), y uno de los pines (PE10) se alterna constantemente en el ciclo infinito.

El código fuente de este programa se presenta a continuación:

#include "stm32f3xx.h"

int main(void)
{
      // Initialize the HSI:
      RCC->CR |= RCC_CR_HSION;
      while(!(RCC->CR&RCC_CR_HSIRDY));

      // Initialize the LSI:
      // RCC->CSR |= RCC_CSR_LSION;
      // while(!(RCC->CSR & RCC_CSR_LSIRDY));

      // PLL configuration:
      RCC->CFGR &= ~RCC_CFGR_PLLSRC;     // HSI / 2 selected as the PLL input clock.
      RCC->CFGR |= RCC_CFGR_PLLMUL16;   // HSI / 2 * 16 = 64 MHz
      RCC->CR |= RCC_CR_PLLON;          // Enable PLL
      while(!(RCC->CR&RCC_CR_PLLRDY));  // Wait until PLL is ready

      // Flash configuration:
      FLASH->ACR |= FLASH_ACR_PRFTBE;
      FLASH->ACR |= FLASH_ACR_LATENCY_1;

      // Main clock output (MCO):
      RCC->AHBENR |= RCC_AHBENR_GPIOAEN;
      GPIOA->MODER |= GPIO_MODER_MODER8_1;
      GPIOA->OTYPER &= ~GPIO_OTYPER_OT_8;
      GPIOA->PUPDR &= ~GPIO_PUPDR_PUPDR8;
      GPIOA->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR8;
      GPIOA->AFR[0] &= ~GPIO_AFRL_AFRL0;

      // Output on the MCO pin:
      //RCC->CFGR |= RCC_CFGR_MCO_HSI;
      //RCC->CFGR |= RCC_CFGR_MCO_LSI;
      //RCC->CFGR |= RCC_CFGR_MCO_PLL;
      RCC->CFGR |= RCC_CFGR_MCO_SYSCLK;

      // PLL as the system clock
      RCC->CFGR &= ~RCC_CFGR_SW;    // Clear the SW bits
      RCC->CFGR |= RCC_CFGR_SW_PLL; //Select PLL as the system clock
      while ((RCC->CFGR & RCC_CFGR_SWS_PLL) != RCC_CFGR_SWS_PLL); //Wait until PLL is used

      // Bit-bang monitoring:
      RCC->AHBENR |= RCC_AHBENR_GPIOEEN;
      GPIOE->MODER |= GPIO_MODER_MODER10_0;
      GPIOE->OTYPER &= ~GPIO_OTYPER_OT_10;
      GPIOE->PUPDR &= ~GPIO_PUPDR_PUPDR10;
      GPIOE->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR10;

      while(1)
      {
          GPIOE->BSRRL |= GPIO_BSRR_BS_10;
          GPIOE->BRR |= GPIO_BRR_BR_10;

      }
}

El código se compiló con CoIDE V2 con GNU ARM Embedded Toolchain utilizando la optimización -O1. Las señales en los pines PA8 (MCO) y PE10, examinadas con un osciloscopio, se ven así:ingrese la descripción de la imagen aquí

El SYSCLK parece estar configurado correctamente, ya que la MCO (curva naranja) exhibe una oscilación de casi 64 MHz (considerando el margen de error del reloj interno). La parte extraña para mí es el comportamiento en PE10 (curva azul). En el bucle while(1) infinito, se necesitan 4 + 4 + 5 = 13 ciclos de reloj para realizar una operación elemental de 3 pasos (es decir, establecimiento de bit/reinicio de bit/retorno). Empeora aún más en otros niveles de optimización (por ejemplo, -O2, -O3, ar -Os): se agregan varios ciclos de reloj adicionales a la parte BAJA de la señal, es decir, entre los flancos descendente y ascendente de PE10 (habilitar el LSI de alguna manera parece para remediar esta situación).

¿Se espera este comportamiento de este MCU? Me imagino que una tarea tan simple como configurar y restablecer un bit debería ser de 2 a 4 veces más rápida. ¿Hay alguna manera de acelerar las cosas?

¿Has probado con algún otro MCU para comparar?
¿Qué estás tratando de lograr? Si desea una salida oscilante rápida, debe usar temporizadores. Si desea interactuar con protocolos seriales rápidos, debe usar el periférico de hardware correspondiente.
Gran comienzo con el kit!!
No debe |= registros BSRR o BRR ya que son solo de escritura.

Respuestas (4)

La pregunta aquí realmente es: ¿cuál es el código de máquina que está generando a partir del programa C y en qué se diferencia de lo que esperaría?

Si no tuvieras acceso al código original, esto habría sido un ejercicio de ingeniería inversa (básicamente algo que comenzara con: radare2 -A arm image.bin; aaa; VV), pero tienes el código, así que esto lo hace todo más fácil.

Primero, compílelo con la -gbandera agregada a CFLAGS(mismo lugar donde también especifica -O1). Luego, mire el ensamblado generado:

arm-none-eabi-objdump -S yourprog.elf

Tenga en cuenta que, por supuesto, tanto el nombre del objdumpbinario como el archivo ELF intermedio pueden ser diferentes.

Por lo general, también puede omitir la parte en la que GCC invoca al ensamblador y simplemente mirar el archivo de ensamblaje. Simplemente agregue -Sa la línea de comandos de GCC, pero eso normalmente romperá su compilación, por lo que probablemente lo haga fuera de su IDE.

Hice el ensamblaje de una versión ligeramente parcheada de su código :

arm-none-eabi-gcc 
    -O1 ## your optimization level
    -S  ## stop after generating assembly, i.e. don't run `as`
    -I/path/to/CMSIS/ST/STM32F3xx/ -I/path/to/CMSIS/include
     test.c

y obtuve lo siguiente (extracto, código completo en el enlace de arriba):

.L5:
    ldr r2, [r3, #24]
    orr r2, r2, #1024
    str r2, [r3, #24]
    ldr r2, [r3, #40]
    orr r2, r2, #1024
    str r2, [r3, #40]
    b   .L5

Que es un bucle (observe el salto incondicional a .L5 al final y la etiqueta .L5 al principio).

Lo que vemos aquí es que nosotros

  • primero ldr(registro de carga) el registro r2con el valor en la ubicación de memoria almacenada en r3+ 24 Bytes. Ser demasiado perezoso para buscar eso: muy probablemente la ubicación de BSRR.
  • Luego, ORel r2registro con la constante 1024 == (1<<10), que correspondería a establecer el bit 10 en ese registro, y escribir el resultado en r2sí mismo.
  • Luego str(almacene) el resultado en la ubicación de memoria que hemos leído en el primer paso
  • y luego repita lo mismo para una ubicación de memoria diferente, por pereza: muy probablemente BRRla dirección de .
  • Finalmente b(rama) de vuelta al primer paso.

Así que tenemos 7 instrucciones, no tres, para empezar. Solo bsucede una vez y, por lo tanto, es muy probable que esté tomando un número impar de ciclos (tenemos 13 en total, por lo que en algún lugar debe provenir un recuento impar de ciclos). Dado que todos los números impares por debajo de 13 son 1, 3, 5, 7, 9, 11, y podemos descartar cualquier número mayor que 13-6 (suponiendo que la CPU no puede ejecutar una instrucción en menos de un ciclo), sabemos que btoma 1, 3, 5 o 7 ciclos de CPU.

Siendo quienes somos, miré la documentación de instrucciones de ARM y cuántos ciclos toman para el M3:

  • ldrtoma 2 ciclos (en la mayoría de los casos)
  • orrtoma 1 ciclo
  • strtoma 2 ciclos
  • blleva de 2 a 4 ciclos. Sabemos que debe ser un número impar, por lo que debe tomar 3, aquí.

Todo eso se alinea con tu observación:

13 = 2 ( C yo d r + C o r r + C s t r ) + C b = 2 ( 2 + 1 + 2 ) + 3 = 2 5 + 3


Como muestra el cálculo anterior, difícilmente habrá una manera de hacer que su ciclo sea más rápido: los pines de salida en los procesadores ARM generalmente están asignados a la memoria , no a los registros del núcleo de la CPU, por lo que debe pasar por la rutina habitual de carga, modificación y almacenamiento si quieres hacer cualquier cosa con esos.

Por supuesto, lo que podría hacer es no leer ( |=implícitamente tiene que leer) el valor del pin en cada iteración del bucle, sino simplemente escribirle el valor de una variable local, que simplemente cambia en cada iteración del bucle.

Tenga en cuenta que creo que podría estar familiarizado con los micros de 8 bits y que intentaría leer solo valores de 8 bits, almacenarlos en variables locales de 8 bits y escribirlos en fragmentos de 8 bits. No. ARM es una arquitectura de 32 bits, y extraer 8 bits de una palabra de 32 bits puede requerir instrucciones adicionales. Si puede, simplemente lea la palabra completa de 32 bits, modifique lo que necesite y vuelva a escribirlo completo. Si eso es posible, por supuesto, depende de lo que esté escribiendo, es decir, el diseño y la funcionalidad de su GPIO mapeado en memoria. Consulte la hoja de datos/guía del usuario de STM32F3 para obtener información sobre lo que se almacena en los 32 bits que contienen el bit que desea alternar.


Ahora, traté de reproducir su problema con el período "bajo" cada vez más largo, pero simplemente no pude: el bucle se ve exactamente igual -O3que -O1con mi versión del compilador. ¡Tendrás que hacerlo tú mismo! Tal vez esté utilizando alguna versión antigua de GCC con soporte ARM subóptimo.

¿No sería simplemente almacenar ( =en lugar de |=), como usted dice, exactamente la aceleración que está buscando el OP? La razón por la que los ARM tienen los registros BRR y BSRR por separado es para no requerir lectura-modificación-escritura. En este caso, las constantes podrían almacenarse en registros fuera del ciclo, por lo que el ciclo interno sería solo 2 str y una rama, ¿entonces 2 + 2 +3 = 7 ciclos para toda la ronda?
Gracias. Eso realmente aclaró bastante las cosas. Fue un pensamiento un poco precipitado insistir en que solo se necesitarían 3 ciclos de reloj; de 6 a 7 ciclos era algo que en realidad esperaba. El -O3error parece haber desaparecido después de limpiar y reconstruir la solución. No obstante, mi código ensamblador parece tener una instrucción UTXH adicional dentro:.L5: ldrh r3, [r2, #24] uxth r3, r3 orr r3, r3, #1024 strh r3, [r2, #24] @ movhi ldr r3, [r2, #40] orr r3, r3, #1024 str r3, [r2, #40] b .L5
uxthestá allí porque GPIO->BSRRLestá (incorrectamente) definido como un registro de 16 bits en sus encabezados. Utilice una versión reciente de los encabezados, de las bibliotecas STM32CubeF3 , donde no hay BSRRL ni BSRRH, sino un solo registro de 32 bits BSRR. @Marcus aparentemente tiene los encabezados correctos, por lo que su código realiza accesos completos de 32 bits en lugar de cargar una media palabra y extenderla.
¿Por qué cargar un solo byte requeriría instrucciones adicionales? La arquitectura ARM tiene LDRBy STRBque realizan lecturas/escrituras de bytes en una sola instrucción, ¿no?
@psmears: porque LDRBy LDRHcargue los bits inferiores del registro de destino, y deje los bits de orden superior solos. El valor debe promoverse a 32 bits sin signo para el operador OR bit a bit, porque GPIO_BSRR_BS_10así está definido. UXTBo UXTHestablece esos bits no cargados en 0.
@berendi Exactamente. Usar el encabezado correcto y BSRRen lugar de BSRRLes la solución que tuve que hacer para construirlo en mi máquina (por lo tanto, publiqué el código completo)
@berendi: Tal vez me estoy perdiendo algo, pero según la memoria (y los documentos ), LDRBel cero se extiende a 32 bits. ¿Estamos hablando de diferentes variantes de la arquitectura ARM o algo así?
@psmears el hecho de que la LDRBinstrucción exista no significa que el encabezado esté escrito para que pueda usarse, supongo que aquí.
@psmears: tienes razón, uxtbo uxthes innecesario, es un error conocido de gcc
@MarcusMüller: Claro, solo estaba cuestionando la afirmación de que hacer operaciones de bytes en lugar de operaciones de palabras requeriría más instrucciones, ya que esto era diferente de mi memoria de ARM ISA :)
@berendi: Ah, gracias, es bueno saber que no me estoy volviendo loco :)
El núcleo M3 puede admitir bandas de bits (no estoy seguro si esta implementación en particular lo hace), donde una región de 1 MB de espacio de memoria periférica se asocia a una región de 32 MB. Cada bit tiene una dirección de palabra discreta (solo se usa el bit 0). Presumiblemente aún más lento que solo una carga/almacenamiento.
El problema principal es: BSRR y BRR y solo escritura. No hay motivo para hacerlos OR: te has perdido este punto muy importante.

Los registros BSRRy BRRson para establecer y restablecer bits de puerto individuales:

Registro de establecimiento/reinicio de bit de puerto GPIO (GPIOx_BSRR)

...

(x = A..H) Bits 15:0

BSy: Puerto x establece bit y (y= 0..15)

Estos bits son de solo escritura. Una lectura de estos bits devuelve el valor 0x0000.

0: ninguna acción en el bit ODRx correspondiente

1: establece el bit ODRx correspondiente

Como puede ver, la lectura de estos registros siempre da 0, por lo que su código

GPIOE->BSRRL |= GPIO_BSRR_BS_10;
GPIOE->BRR |= GPIO_BRR_BR_10;

efectivamente es GPIOE->BRR = 0 | GPIO_BRR_BR_10, pero el optimizador no lo sabe, por lo que genera una secuencia de LDRinstrucciones en lugar ORRde STRuna sola tienda.

Puede evitar la costosa operación de lectura, modificación y escritura simplemente escribiendo

GPIOE->BSRRL = GPIO_BSRR_BS_10;
GPIOE->BRR = GPIO_BRR_BR_10;

Puede obtener alguna mejora adicional al alinear el bucle con una dirección divisible por 8. Intente poner asm("nop");las instrucciones de uno o modo antes del while(1)bucle.

Para agregar a lo que se ha dicho aquí: ciertamente con Cortex-M, pero prácticamente con cualquier procesador (con canalización, caché, predicción de bifurcación u otras características), es trivial tomar incluso el ciclo más simple:

top:
   subs r0,#1
   bne top

Ejecútalo tantos millones de veces como quieras, pero haz que el rendimiento de ese ciclo varíe ampliamente, solo esas dos instrucciones, agrega algunos nops en el medio si lo deseas; no importa.

Cambiar la alineación del ciclo puede variar drásticamente el rendimiento, especialmente con un ciclo pequeño como ese, si toma dos líneas de búsqueda en lugar de una, se come ese costo adicional, en un microcontrolador como este, donde el flash es 2 más lento que la CPU. o 3 y luego, al aumentar el reloj, la relación se vuelve aún peor 3 o 4 o 5 que agregar búsqueda adicional.

Es probable que no tenga un caché, pero si lo tuviera, ayuda en algunos casos, pero duele en otros y/o no hace la diferencia. La predicción de bifurcación que puede o no tener aquí (probablemente no) solo puede ver hasta donde está diseñada en la tubería, por lo que incluso si cambió el ciclo para ramificarse y tuvo una bifurcación incondicional al final (más fácil para un predictor de bifurcación use) todo lo que hace es ahorrarle muchos relojes (el tamaño de la tubería desde donde normalmente buscaría hasta qué tan profundo puede ver el predictor) en la próxima búsqueda y/o no hace una búsqueda previa por si acaso.

Al cambiar la alineación con respecto a las líneas de búsqueda y caché, puede afectar si el predictor de bifurcación lo está ayudando o no, y eso se puede ver en el rendimiento general, incluso si solo está probando dos instrucciones o esos dos con algunos nops .

Es algo trivial hacer esto, y una vez que comprenda eso, luego tomando el código compilado, o incluso el ensamblado escrito a mano, puede ver que su rendimiento puede variar ampliamente debido a estos factores, agregando o ahorrando algunos hasta un par de cientos por ciento, una línea de código C, un nop mal colocado.

Después de aprender a usar el registro BSRR, intente ejecutar su código desde RAM (copiar y saltar) en lugar de flash, lo que debería brindarle un aumento instantáneo de rendimiento de 2 a 3 veces en la ejecución sin hacer nada más.

¿Se espera este comportamiento de este MCU?

Es un comportamiento de su código.

  1. Debe escribir en los registros BRR/BSRR, no leer-modificar-escribir como lo hace ahora.

  2. También incurre en una sobrecarga de bucle. Para obtener el máximo rendimiento, replique las operaciones de BRR/BSRR una y otra vez → cópielas y péguelas en el ciclo varias veces para pasar por muchos ciclos de configuración/reinicio antes de que un ciclo se sobrecargue.

editar: algunas pruebas rápidas bajo IAR.

un salto a través de la escritura a BRR/BSRR requiere 6 instrucciones con una optimización moderada y 3 instrucciones con el nivel más alto de optimización; hojear RMW'ng toma 10 instrucciones / 6 instrucciones.

extra de sobrecarga de bucle.

Al cambiar |=a =una fase de establecimiento/reinicio de un solo bit, se consumen 9 ciclos de reloj ( enlace ). El código ensamblador tiene 3 instrucciones de largo:.L5 strh r1, [r3, #24] @ movhi str r2, [r3, #40] b .L5
No desenrolle bucles manualmente. Eso prácticamente nunca es una buena idea. En este caso particular, es especialmente desastroso: hace que la forma de onda no sea periódica. Además, tener el mismo código muchas veces en flash no es necesariamente más rápido. Es posible que esto no se aplique aquí (¡podría!), pero el desenrollado de bucles es algo que mucha gente cree que ayuda, que los compiladores ( gcc -funroll-loops) pueden hacer muy bien y que cuando se abusa (como aquí) tiene el efecto inverso de lo que desea.
Un bucle infinito nunca se puede desenrollar de manera efectiva para mantener un comportamiento de tiempo constante.
@MarcusMüller: los bucles infinitos a veces se pueden desenrollar de manera útil mientras se mantiene una sincronización constante si hay puntos en algunas repeticiones del bucle donde una instrucción no tendría un efecto visible. Por ejemplo, si somePortLatchcontrola un puerto cuyos 4 bits inferiores están configurados para la salida, es posible que se desarrolle while(1) { SomePortLatch ^= (ctr++); }en un código que genera 15 valores y luego regresa para comenzar en el momento en que, de lo contrario, generaría el mismo valor dos veces seguidas.
Supergato, cierto. Además, los efectos como la sincronización de la interfaz de memoria, etc., pueden hacer que sea sensato desenrollar "parcialmente". Mi declaración fue demasiado general, pero creo que el consejo de Danny es aún más generalizador, e incluso peligroso.