33- Optimización del código Arduino

Con el crecimiento de habilidades y la creación de proyectos cada vez más globales, se enfrentará al hecho de que “Arduino” ya no podrá hacer frente a la cantidad de cálculos que desea obtener de él. Es posible que simplemente no sea lo suficientemente rápido en los cálculos, la actualización de la información en las pantallas, el envío de datos y otras acciones que consumen muchos recursos, o simplemente arduino se puede quedar sin memoria. Lo peor es cuando se agota la RAM: puede pasar de forma absolutamente imperceptible, y el dispositivo empieza a comportarse de forma inapropiada, se reinicia o simplemente se congela. ¿Cómo se puede evitar esto? ¡Necesitas optimizar tu código! Hay muy poca información sobre esto en Internet, por lo que describiré todo lo que encontré personalmente.

Este capítulo analiza la mayoría de las formas existentes para optimizar la velocidad de ejecución del código, en algunas de ellas pueden ahorrarse unos pocos microsegundos (0,000001 segundos). También hablaremos de optimizar Flash y RAM, algunos métodos pueden reducir literalmente unos pocos bytes. Siempre evalúe la conveniencia de la optimización: si hay suficiente espacio, optimice su tiempo, ¡no lo desperdicie en reescrituras sin sentido! Pero será correcto desarrollar el hábito de escribir de manera óptima de inmediato =)


Lo que el compilador de Arduino puede manejar.

Consideramos muchas formas diferentes de optimizar el código, pero no tomamos en cuenta lo principal: ¡el compilador en sí hace un buen trabajo optimizándolo! Algunos de los pasos anteriores no tienen sentido, porque el propio compilador hará lo mismo. Pero todavía los desarmamos para el desarrollo general y la comprensión del proceso. Ahora veamos algunas de las acciones que realiza el compilador.

Modificador volátile

El compilador optimiza acciones sobre variables que no están marcadas como volátile ya que este es un comando directo que dice «no me optimices». Este es un punto importante, porque las acciones con tales variables ( si es necesario ) deben optimizarse manualmente. El compilador no optimizará los cálculos, eliminará las variables no utilizadas y las construcciones que las utilicen.

Eliminando variables y funciones no utilizadas

El compilador elimina las variables del código, así como la implementación de funciones y métodos de la clase, si no se utilizan en el código arduino. Por lo tanto, incluso si incluimos una biblioteca enorme, pero usamos solo un par de métodos de ella, el tamaño de la memoria no aumentará en el tamaño de la biblioteca completa. El compilador solo tomará lo que se use directa o indirectamente (por ejemplo, una función llama a otra función).

Optimización del código

El propio compilador intenta optimizar los cálculos tanto como sea posible:

  • Reemplaza los tipos de datos por otros más óptimos siempre que sea posible y no afecte el resultado. por ejemplo val / = 2.8345 tarda 4 veces más que val / = 2.0, porque 2. 0 fue reemplazado por 2.
  • Reemplaza las operaciones de multiplicación de enteros con potencias de dos ( 2 ^ n ) cambio de bits. Por ejemplo, val * 16 corre dos veces más rápido que val * 12 porque será reemplazado por val << 4;
    • Nota: para operaciones de división de enteros, dicha optimización no se realiza y se puede hacer manualmente: val >> 4 corre 15 veces más rápido que val / 16.
  • Reemplaza las operaciones de módulo % por potencias de dos con una máscara de bits (el resto de la división por 2 ^ n se puede calcular mediante una máscara de bits: val & n). Así por ejemplo (100 % 10) tarda 17 veces más que (100% 8), considera esto.
  • Precalcula todo lo que se puede calcular (constantes), por ejemplo val / = 7.8125 corre igual como val / = ( 2.5 * 10.0 / 3.2 + 12.28 * 3.2 ), porque el compilador ha calculado y sustituido el resultado de todas las acciones con constantes de antemano;
  • Utiliza una celda de dos bytes (con signo) para multiplicar y dividir números enteros. Esto es muy peligroso, porque el resultado puede ser mayor, pero al compilador no le importa. Para expresiones cuyo resultado exceda 32’768, debe forzar al compilador a asignar más memoria usando( long) o de otras formas, cubrimos esto en la lección sobre operaciones matemáticas.

Condiciones, opciones y selectores

El compilador cortará una rama completa de condiciones o conmutadores si está seguro de antemano sobre el resultado de la comparación o selección. ¿Cómo convencerlo de esto? Así es, consideremos un ejemplo elemental: una condición o un interruptor (no importa) con tres opciones:

switch (num) {
  case 0: Serial.println("Hello 0"); break;
  case 1: Serial.println("Hello 1"); break;
  case 2: Serial.println("Hello 2"); break;
}
// o este otro
if (num == 0)
  Serial.println("Hello 0");
else if (num == 1)
  Serial.println("Hello 1");
else if (num == 2)
  Serial.println("Hello 2");

Si declaras num como una variable ordinaria: toda la construcción, las tres condiciones o el conmutador completo se incluirán en el código compilado. Si num es una constante o definida con #define– el compilador cortará todo el bloque de condiciones o un conmutador y dejará solo el contenido que se obtiene con un determinado num. Es muy fácil verificar esto compilando el código y mirando la cantidad de memoria ocupada en el registro del compilador. Con este truco, puedes acelerar algunas funciones y reducir el espacio de memoria que ocupan. Consideremos un ejemplo muy útil: la función de lectura rápida del estado de un pin digital para el ATmega328:

bool fastRead(uint8_t pin) {
  if (pin < 8) {
    return bitRead(PIND, pin);
  } else if (pin < 14) {
    return bitRead(PINB, pin - 8);
  } else if (pin < 20) {
    return bitRead(PINC, pin - 14);
  }
}

La llamada fastRead ( variable ) toma 6 ciclos de procesador (0.37 μs), la llamada fastRead ( constante )– 1 ciclo de reloj (0,0625 μs)! Para comparar, llamar al estándar digitalRead ( variable ) toma 58 ciclos, y digitalRead ( constante )– 52 ciclos. Es decir, con la ayuda de un código óptimo y la comprensión de la lógica del compilador, puede hacer que “digitalRead ()” sea 58 veces más rápido que lo que ofrece la biblioteca Arduino.h, ¡sin perder nada de su usabilidad!

Si está escribiendo su propia biblioteca o clase, entonces todo será un poco más difícil: las constantes dentro de una clase no son una buena razón para que el compilador corte condiciones y conmutadores, incluso si es const y está declarado en la inicialización de la clase. Para que el compilador corte una condición o cambie dentro de la implementación de métodos de clase, necesita una constante / diseño o plantilla externa modelo. Permítame recordarle que la plantilla también le permite crear una matriz de un tamaño determinado dentro de una clase, hablé de esto en la lección sobre bibliotecas. En general, aquí abajo en el ejemplo hay una clase de prueba con lecturas digitales de diferentes opciones y los resultados del benchmark (banco de pruebas):

#define MY_PIN 3
const byte _pinCM = 3;
template <byte PIN>
class fast {
  public:
    fast(byte pin) : _pinC(pin) {_pin = _pinV = pin;}
    bool dreadVol() {return digitalRead(_pinV);}
    bool dreadVar() {return digitalRead(_pin);}
    bool dreadConst() {return digitalRead(_pinC);}
    bool dreadDefine() {return digitalRead(MY_PIN);}
    bool dreadExtConst() {return digitalRead(_pinCM);}
    bool dreadTempConst() {return digitalRead(PIN);}
    bool fastReadVol() {return fastRead(_pinV);}
    bool fastReadVar() {return fastRead(_pin);}
    bool fastReadConst() {return fastRead(_pinC);}
    bool fastReadDefine() {return fastRead(MY_PIN);}
    bool fastReadExtConst() {return fastRead(_pinCM);}
    bool fastReadTempConst() {return fastRead(PIN);}
    bool fastReadShortVol() {return fastReadShort(_pinV);}
    bool fastReadShortVar() {return fastReadShort(_pin);}
    bool fastReadShortConst() {return fastReadShort(_pinC);}
    bool fastReadShortDefine() {return fastReadShort(MY_PIN);}
    bool fastReadShortExtConst() {return fastReadShort(_pinCM);}
    bool fastReadShortTempConst() {return fastReadShort(PIN);}
    bool fastRead(uint8_t pin) {
      if (pin < 8) {
        return bitRead(PIND, pin);
      } else if (pin < 14) {
        return bitRead(PINB, pin - 8);
      } else if (pin < 20) {
        return bitRead(PINC, pin - 14);    // Return pin state
      }
    }
    bool fastReadShort(uint8_t pin) {
      return bitRead(PIND, pin); // <8
    }
  private:
    byte _pin;
    volatile byte _pinV;
    const byte _pinC=3;
};
 volatilevariableconstantdefineexternal consttemplate const
digitalRead585858525252
pinRead666111
bitRead(PIND, pin);311111
Resultados de referencia (en ciclos de CPU).

Optimización de la velocidad.

Antes de comenzar a optimizar los cálculos, debe comprender por qué se ralentiza: existe el tiempo de procesador, es decir, el tiempo que el procesador de computación dedica a diversas actividades. Por ejemplo, desea hacer algunos cálculos y mostrarlos inmediatamente. Si hay demasiados cálculos, tardarán más de lo deseado y los datos se enviarán lentamente. De hecho, tal situación es muy difícil de lograr, de todos modos, nuestro procesador realiza operaciones a una frecuencia de 16 MHz, pero una vez encontré este “umbral”. El proyecto se denominó cubo LED, en el que se calculó el comportamiento de varias decenas de partículas en un plano. No se calculó de todos modos, pero de acuerdo con un modelo matemático, con fricción, ángulos de inclinación, rebotes desde el borde del avión, etc., el resultado se mostró en una matriz de LED. Con un aumento en la cantidad de partículas, me enfrenté al hecho de que comienzan a disminuir abiertamente, es decir, la frecuencia de actualización de la matriz se redujo drásticamente. Pasemos a optimizar los cálculos.

Utilice variables de tipos apropiados

El tipo de variable / constante no solo afecta la cantidad de memoria que ocupa, sino también la velocidad de los cálculos. Aquí hay una tabla para los cálculos más simples no optimizados por el compilador. En código real, el tiempo puede ser menor. Nota: los tiempos se dan para cristal de 16 MHz.

Tiempos de ejecución según tipo en Arduino
Tiempos de ejecución según tipo en Arduino

Como puede ver, el tiempo de cálculo es diferente a veces incluso para tipos de datos enteros, por lo que siempre es necesario averiguar cuál será el valor máximo que se almacenará en una variable y seleccionar el tipo de datos apropiado. Trate de no utilizar números de 32 bits donde no sean necesarios y, si es posible, no utilice float.

Al mismo tiempo, multiplicar long por float será más rentable que dividir long por un número entero. Esto puede considerarse de antemano como 1 / número y multiplique en lugar de dividir en los puntos críticos del código en tiempo de ejecución. También lea sobre esto a continuación.

Renunciar a «float»

También puede averiguar en la tabla anterior que el microcontrolador dedica varias veces más tiempo a operaciones con números de punto flotante en comparación con los tipos enteros. El hecho es que la mayoría de los microcontroladores AVR (que están en Arduino) no tienen soporte de «hardware» para los cálculos de números float, y estos cálculos se realizan mediante métodos de software no muy óptimos. Por cierto, existe ese soporte en los microcontroladores ARM. ¿Qué hacer? Solo evita usar float donde el problema se puede resolver con tipos enteros.

Si necesitas multiplicar, redistribuya varios float, luego puede convertirlos a un tipo entero multiplicándolos por 10-100-1000, dependiendo de la precisión que se necesite, calcular y luego convertir el resultado a float. En la mayoría de los casos, esto es más rápido que calcular float directamente:

// digamos que necesitamos manejar el valor float del sensor
// o almacenar una matriz de dichos valores sin desperdiciar memoria extra.
// sensorRead () devuelve la temperatura en float con una precisión decimal.
// Conviértelo en un número entero multiplicándolo por 10:
int val = sensorRead () * 10 ;  
// ahora puede trabajar con valor entero sin perder precisión de medición y
// puede almacenarlo en 2 bytes en lugar de 4.
// Para volver a convertirlo en flotante, simplemente divida por 10
float val_f = val / 10.0 ;

También existe el punto fijo: números de punto fijo. Desde el punto de vista del usuario, son fracciones decimales ordinarias, pero de hecho son tipos enteros y se calculan en consecuencia más rápido. No hay soporte nativo de punto fijo en Arduino, pero puede trabajar con ellos usando funciones, macros o bibliotecas escritas por usted mismo, aquí debajo puede encontrar mi ejemplo de trabajo que se puede usar en la práctica:

Ejemplo de trabajo en punto fijo

// macros para trabajar con punto fijo
#define FIX_BITS 8
#define FLOAT2FIX (a) (int32_t) ((a * (1 << FIX_BITS))) // transferencia de flotante a fijo
#define INT2FIX (a) (int32_t) ((a) << FIX_BITS) // transferir de int a fijo
#define FIX2FLOAT (a) (((float) (a) / (1 << FIX_BITS))) // transferencia de fijo a flotante
#define FIX2INT (a) ((a) >> FIX_BITS) // transferencia de fijo a int
#define FIX_MUL (a, b) (((int32_t) (a) * (b)) >> FIX_BITS) // multiplicación de dos fijos
void setup() {
  Serial.begin(9600);
  float x = 8.3;
  float y = 2.34;
  float z = 0;
  // primero traducir a fijo
  int32_t a = FLOAT2FIX ( x ) ;
  int32_t b = FLOAT2FIX ( y ) ;
  int32_t c = 0;
  z = x + y;   // agregar float
  c = a + b;   // agregar fijo
  // traducir fijo de nuevo a float
  float cFloat = FIX2FLOAT ( c ) ;
  // imprime el resultado para comparar
  Serial.println(z);
  Serial.println(cFloat);
}
/ *
   Pruebas de velocidad de ejecución:
   x = 8,3; // 0,75 us - asignación a flotante
   a = FLOAT2FIX (8.3); // 0.75 us - convierte el número flotante a fijo
   a = FLOAT2FIX (x); // 14.9 us - convertir variable flotante a fija
   z = x + y; // 8.25 us - adición de flotante
   c = a + b; // 2.0 us - adición fija
   z = x * y; // 10.3 us - multiplicar flotante
   c = FIX_MUL (a, b); // 6.68 us - multiplicación fija
   z = FIX2FLOAT (c); // 13.37 us - conversión de fijo a flotante
* /
void loop() {} 

Elija multiplicadores en potencias de dos

Como se explicó en el primer capítulo, el compilador reemplaza las operaciones de multiplicación de enteros con ( 2 ^ n ) cambios de bits, que son mucho más rápidos. Cómo usarlo: si es posible, escribe tus algoritmos para que se obtengan potencias de dos en operaciones matemáticas (2 4 8 16 32 64 128 …). Por ejemplo, multiplicar un número por 16 es dos veces más rápido que multiplicar por 15. Estamos hablando de unos pocos microsegundos, pero a veces esto también es importante.

Nota: Debe ser entero aquí, porque para float ¡el truco no funciona!

Reemplazar división con desplazamiento de bits

En cuanto a la división de enteros por potencias de dos, el compilador no la reemplaza con un desplazamiento, y esto puede y debe hacerse manualmente. Por ejemplo, división de números long por 16 (val / 16) tarda 15 veces más que una operación de rotación con el mismo resultado: val >> 4 (desplaza 4 bits, 16 == 2 a la potencia de 4). Para largos obtenemos 40 μs por división y 2.5 μs por rotación. ¡Ahorro!

Nota: Debe ser entero aquí, porque para float ¡el truco no funciona!

Reemplazar división por multiplicación por flotante

Nuevamente, en la tabla anterior, puede ver que la división para todos los tipos de datos lleva mucho más tiempo que la multiplicación, por lo que a veces es más rentable reemplazar la división por un número entero con la multiplicación por float… En lugar de dividir …keka / 10; // tarda 14,54 μs usaremos keka * 0,1 ; // que tarda 10,58 μs.

keka / 10; // ejecutado a  14,54 μs
keka * 0,1 ; //  ejecutado a 10,58 μs

Reemplazar exponenciación con multiplicación

Para la exponenciación, tenemos una función útil pow ( a, b ), pero en los cálculos de números enteros es mejor no usarlo: lleva mucho más tiempo que la multiplicación manual, por lo que funciona con float: keka = pow ( keka, 5 ) ; // corre a 20,33 us, mientras que keka = ( long) keka * keka * keka * keka * keka; //se ejecuta en 4.47 us.

keka = pow ( keka, 5 ) ;                            // corre a 20,33 us
keka = ( long ) keka * keka * keka * keka * keka;  // se ejecuta en 4.47 us.

Optimizar operación modulo %

Operación resto de división %, toma un tiempo relativamente largo, como la división misma (ver tabla arriba). Debe recordarse que el compilador optimiza el resto de la división por 2 ^ n, reemplazándolo con una máscara de bits, que se toma en un par de ciclos de procesador, ¡que es varias decenas de veces más rápido! por ejemplo val % 8 se optimizará automáticamente en val & 0b111. Si es posible, debe escribir su algoritmo de modo que el resto de la división se busque exactamente de 2 ^ n. Por ejemplo, cuando se trabaja con un búfer circular, puede hacer que su tamaño sea igual a 16, 32, 64, 128 … y acelerar la operación de saltar al principio del búfer, como se suele hacer en buffer_pos % y buffer_size.

Calcule previamente lo que se puede calcular

Algunos cálculos complejos requieren realizar los mismos pasos varias veces. Será mucho más rápido crear una variable local, «count» y usarla en cálculos posteriores. 

Nota: el compilador optimiza la mayoría de los cálculos en sí, por ejemplo, acciones con constantes y números específicos.

Otro buen ejemplo: calcular cantidades que se comportan de manera predecible, como funciones armónicas sin() y cos(). Se necesita bastante tiempo para calcularlos: ¡¡¡119,46 μs !!! En la práctica, los senos / cosenos casi nunca se calculan mediante un microcontrolador, se calculan de antemano y se almacenan como una matriz. Sí, de nuevo dos dilemas: perder el tiempo en cálculos o ocupar memoria con datos ya calculados.

Además, no olvide que el propio compilador optimiza los cálculos. y lo hace bastante bien.

No use delay() y retrasos similares

Un consejo bastante obvio: no uses delay() donde puedes prescindir de él. Y esto es el 99,99% del tiempo. Use un temporizador en millis() como estudiamos en la lección de manejo de temporizaciones.

Reemplace las funciones de Arduino con sus contrapartes rápidas

Si el proyecto utiliza con mucha frecuencia periféricos del microcontrolador (ADC, entradas / salidas digitales, generación de PWM …), entonces necesita saber una cosa: las funciones de Arduino (en especial Wiring) están escritas para proteger al usuario de posibles errores, dentro de estas funciones hay un montón de controles y protecciones «contra dumis», por lo que tardan mucho más tiempo del que podrían. Además, algunos periféricos del microcontrolador están configurados para que funcionen muy lentamente. Ejemplo: digitalWrite () y digitalRead () se realizan aproximadamente en 3,5 μs, cuando el trabajo directo con el puerto del microcontrolador tarda 0,5 μs, que es casi un orden de magnitud más rápido. analogRead () tarda 112 microsegundos, aunque si lo modifica de forma un poco diferente, funcionará casi 10 veces más rápido sin perder mucha precisión.

Use switch en lugar de else if

En construcciones de ramificación con opción múltiple por el valor de una variable entera, debe dar preferencia a la construcción switchcase, funciona más rápido que otra cosa (revise las lecciones sobre condiciones y opciones). Pero recuerda eso, switch solo funciona con enteros! Aquí debajo encontrará los resultados de una prueba (no optimizada para el compilador).

// switch de prueba
// keka tiene 10
// tiempo de ejecución: 0,3 μs (5 ciclos de reloj)
switch (keka) {
  case 10: break;  //selecciona esto
  case 20: break;
  case 30: break;
  case 40: break;
  case 50: break;
  case 60: break;
  case 70: break;
  case 80: break;
  case 90: break;
  case 100: break;
}
// keka es igual a 100
// tiempo de ejecución: 0,3 μs (5 ciclos de reloj)
switch (keka) {
  case 10: break;
  case 20: break;
  case 30: break;
  case 40: break;
  case 50: break;
  case 60: break;
  case 70: break;
  case 80: break;
  case 90: break;
  case 100: break;  //selecciona esto
}
// prueba ELSE IF
// keka tiene 10
// tiempo de ejecución: 0,50 μs (8 ciclos de reloj)
if ( keka == 10 ) { // seleccione esto    
} else if (keka == 20) {
} else if (keka == 30) {
} else if (keka == 40) {
} else if (keka == 50) {
} else if (keka == 60) {
} else if (keka == 70) {
} else if (keka == 80) {
} else if (keka == 90) {
} else if (keka == 100) {
}
// keka es igual a 100
// tiempo de ejecución: 2,56 μs (41 ciclos de reloj)
if (keka == 10) {
} else if (keka == 20) {
} else if (keka == 30) {
} else if (keka == 40) {
} else if (keka == 50) {
} else if (keka == 60) {
} else if (keka == 70) {
} else if (keka == 80) {
} else if (keka == 90) {
} else if (keka == 100) {   //  seleccione esto    
}

Recuerda el orden de las condiciones

Si se verifican varias expresiones lógicas simultáneamente, cuando se produce el primer resultado, en el que toda la condición recibirá un valor conocido de forma única, el resto de las expresiones ni siquiera se verifican. Por ejemplo:

if ( flag && getSensorState () ) {    
  // algún código
}

Si flag tiene el significado falso, la función getSensorState() ¡Ni siquiera se llamará! el If será inmediatamente omitido. Esto debe usarse colocando las condiciones en orden ascendente de tiempo de procesador que se requiere para llamarlas / ejecutarlas, si se trata de funciones. Por ejemplo, si nuestro getSensorState () tarda algún tiempo en ejecutarse, lo colocamos después de la bandera, que es solo una variable. Esto ahorrará tiempo a la CPU cuando la bandera sea false.

Usar operaciones bit a bit

Utilice trucos y operaciones de bits en general, ya que a menudo ayudan a acelerar su código. Lea más en esta lección.

Utilice punteros y enlaces

En lugar de pasar un «objeto» como argumento a una función, páselo por referencia o por un puntero: el procesador no asignará memoria para una copia del argumento (y creará esta copia como una variable formal) – esto ahorrará tiempo ! Lea más sobre punteros y enlaces en un tutorial separado.

Usar funciones macro e integradas

Cada función creada tiene su propia dirección de memoria y, para llamarla, el procesador se dirige a esta dirección, lo que lleva tiempo. El tiempo es muy corto, pero a veces incluso es crítico, por lo que tales llamadas de tiempo crítico pueden reemplazarse con funciones macro o funciones integradas, lea más en la lección sobre funciones.

Usa constantes

Constantes (cons o #define) «Trabajan» mucho más rápido que las variables cuando se pasan como argumentos a una función. ¡Haga todo constantes que no cambien mientras el programa se está ejecutando! Ejemplo:

byte pin = 3;   // la frecuencia será de 128 kHz 
// byte const pin = 3; // la frecuencia será de 994 kHz 
void setup() {
  pinMode(pin, OUTPUT);
}
void loop() {
  for (;;) {
    digitalWrite(pin, 1);
    digitalWrite(pin, 0);
  }
}

¿Por qué está pasando esto? El compilador optimiza el código y, con argumentos constantes, puede eliminar casi todo el código innecesario de la función (si hay, por ejemplo, bloques if – if else) y se ejecutará más rápido.

Optimiza el bucle void loop()

Función loop() está anidado en un ciclo externo con algunas comprobaciones adicionales, por lo que si realmente te importa el tiempo mínimo entre iteraciones loop() – solo introduce en tu bucle for( ;; ), por ejemplo así:

void loop() {
  for (;;) {
  // tu codigo
  }
}

Código en ensamblador (es broma)

Arduino IDE admite inserciones de ensamblador, en las que puede dar comandos directos al procesador en el idioma del mismo nombre, lo que proporciona el código más rápido y claro posible. Pero en nuestra familia no bromean sobre esto =)


Optimización de la memoria.

La mayoría de las veces nos enfrentamos a una falta de memoria: Flash, RAM o SRAM. Después de compilar el código, recibimos un mensaje sobre la huella Flash / SRAM, que es información valiosa. La memoria flash se puede llenar hasta en un 99%, su volumen no cambia durante el funcionamiento del dispositivo, lo que no se puede decir de SRAM. Digamos que al momento de lanzar el programa tenemos el 80% de la RAM ocupada, pero en el proceso de trabajo pueden aparecer y desaparecer variables locales, lo que acabará con el volumen ocupado hasta en un 100% y lo más probable es que el dispositivo se reinicie o congele. El peligro es que la «sección» de RAM comience a fragmentarse, es decir aparecen pequeños espacios vacíos que el microcontrolador no puede llenar con los nuevos datos que aparecen. Sí, todo es como en una computadora, solo que no tenemos un botón de «desfragmentar». Por lo tanto, debe aprender a manejar manualmente la administración de memoria para intentar dejar más SRAM libre.

También adjunto un ejemplo de boceto con una función que muestra la cantidad de SRAM libre. 

/ *
   Función que devuelve la cantidad de memoria de acceso aleatorio libre (SRAM)
   Nota:  método para comprobar la RAM libre 
   no funciona correctamente en caso de fragmentación de la memoria.
* /
void setup() {
  Serial.begin(9600);
}
void loop() {
  Serial.println(memoryFree()); // imprime la cantidad de SRAM libre
  delay(1000);
}
extern int __bss_end;
extern void *__brkval;
// Función que devuelve la cantidad de RAM libre
int memoryFree() {
  int freeValue;
  if ((int)__brkval == 0)
    freeValue = ((int)&freeValue) - ((int)&__bss_end);
  else
    freeValue = ((int)&freeValue) - ((int)__brkval);
  return freeValue;
}

Utilice variables de tipos apropiados

Como recordará de la lección sobre tipos de datos, cada tipo tiene un límite en el valor máximo almacenado, que determina directamente el peso de este tipo en la memoria. Aquí están todos:

NombrePesoRango
boolean1 byte0 o 1,  verdadero  o  falso
char (int8_t)1 byte-128 … 127
byte (uint8_t)1 byte0 … 255
int (int16_t)2 bytes-32 768 … 32 767
unsigned int (uint16_t)2 bytes0 … 65 535
long (int32_t)4 bytes-2147 483 648 … 2147483647
unsigned long (uint32_t)4 bytes0 … 4 294967 295
float (doble)4 bytes-3.4028235E + 38 … 3.4028235E + 38

Simplemente no use variables de tipos más pesados ​​donde no sea necesario.

Utilice #define

Para almacenar constantes al estilo de los números de pin, algunas configuraciones y valores constantes, no use variables globales, sino #define. Así, la constante quedará almacenada en el código, en la memoria Flash, que es mucho mejor.

#define MOTOR_PIN 10
#define MOTOR_SPEED 120

Usar directivas del preprocesador

Si tiene un proyecto complejo en el que algunos fragmentos de código o bibliotecas se activan o desactivan antes que el firmware, utilice la compilación condicional mediante directivas #if, #elif, #ifdef y otras, de los que hablamos en la lección sobre compilación condicional

Usar progmem

Para almacenar grandes cantidades de datos persistentes (matriz de mapas de bits para mostrar, líneas con texto, «tablas» sinusoidales u otros valores de corrección) utilice PROGMEM- y la capacidad de almacenar y leer datos en la memoria Flash del microcontrolador, que es mucho más grande que la RAM operativa. La peculiaridad es que los datos en Flash se escriben durante el firmware, y luego no será posible cambiarlos, solo podrás leerlos y usarlos.

Breve guia de progmem:

// almacena varios enteros
const uint16_t ints[] PROGMEM = {65000, 32796, 16843, 10, 11234};
 // almacena algunos decimales
const float floats[] PROGMEM = {0.5, 120.25, 0.9214};
// guarda cadenas
const char message[] PROGMEM = {"Hello! Lolkek"};
void setup() {
  Serial.begin(9600);
  Serial.println(pgm_read_word(&(ints[2])));      // imprime 16843
  Serial.println(pgm_read_float(&(floats[1])));   // imprime 120.25
  for (byte i = 0; i < 13; i++)
    Serial.print((char)pgm_read_byte(&(message[i]))); 
    // imprime ¡Hola! Lolkek
}

La función principal para leer desde progmem es pgm_read_TYPE. Podemos usar estos 4:

pgm_read_byte ( datos ) ; - para 1 byte (char, byte, int8_t, uint8_t)
pgm_read_word ( datos ) ; - para 2 bytes (int, word, unsigned int, int16_t, int16_t)
pgm_read_dword ( datos ) ; - para 4 bytes (largo, largo sin firmar, int32_t, int32_t)
pgm_read_float ( datos ) ; - para números de coma flotante

¡Atención! Al leer números negativos (signed), debe convertir el tipo de datos. Ejemplo:

// guarda varios enteros con diferentes signos
const int16_t ints [] PROGMEM = { 65000, 32796, -16843 } ; 
// preparar
serial.println (( int ) pgm_read_word ( & ( ints [ 2 ]))) ;  // imprimirá -16843

También existe una forma más conveniente de escribir y leer datos, implementada en bibliotecas. Mira las bibliotecas aquí.

Utilice la macro F ()

Si el proyecto usa salida de datos de texto al puerto COM, entonces cada carácter ocupará un byte de RAM, esto también se aplica a los datos de cadena y salidas de pantalla. Tenemos una herramienta incorporada que te permite almacenar cadenas en la memoria Flash, es muy fácil de usar, es mucho más conveniente que la misma PROGMEM.

Las llamadas «F () macro ” permite almacenar líneas en la memoria Flash sin ocupar espacio en SRAM. Funciona de manera muy simple y eficiente, lo que le permite crear un dispositivo con comunicación / depuración extendida a través del puerto serie y no pensar en RAM:

// esta salida (línea, texto) ocupa 18 bytes en RAM
serial.println ( "¡Hola <nombre de usuario>!" ) ;
// esta salida no ocupa nada en RAM, gracias a F ()
serial.println ( F ( "Escriba / ayuda para ayudar" )) ;

Limita el uso de bibliotecas

Digamos que está utilizando una biblioteca que tiene un «montón de cosas» de las que necesita un par de funciones. Al compilar el código, las funciones y variables no utilizadas se cortan, pero a veces esto no es suficiente, depende de la biblioteca. Tiene sentido «extraer» el código que desea de la biblioteca y simplemente incrustarlo en su código, ahorrando así un poco de espacio.

No uses float

Como comentamos en la lección de tipos de datos, la compatibilidad con el cálculo con datos float, el software (para AVR), es decir, en términos generales, para los cálculos, la biblioteca está integrada. Habiendo usado una vez todas las operaciones aritméticas en el código con float, la biblioteca inserta aproximadamente 1000 bytes de código en Flash para respaldar estos cálculos.

También duplicaré el ejemplo del capítulo anterior: si necesita almacenar muchos valores float en la memoria RAM o EEPROM, es decir, tiene sentido reemplazarlos con números enteros. Cómo hacerlo sin perder precisión:

// digamos que necesitamos almacenar una matriz de tales valores sin desperdiciar memoria extra.
// deja que sensorRead () devuelva la temperatura en float con una precisión decimal.
// Conviértelo a un número entero multiplicándolo por 10 (o 100, según la precisión que sea necesaria):
vals [ 30 ] = sensorRead () * 10;
// ¡Los valores enteros int usan la mitad de la memoria!
// Para volver a convertirlos en flotantes, simplemente divídalos por 10.0
float val_f = vals [ 30 ] / 10.0 ;

No use objetos de las clases Serial y String

Quizás las bibliotecas «más gordas» en términos de consumo de memoria son los objetos estándar Serial y String. Si aparece Serial en el código, toma inmediatamente al menos 998 bytes de Flash ( 3% para ATmega328) y 175 bytes de SRAM ( 8% para ATmega328). Tan pronto como comenzamos a usar Strings , nos despedimos de Flash de 1178 bytes ( 4% para ATmega328).

Si aún se necesita Serial, intente utilizar un análogo muy ligero de la biblioteca estándar: G_ppUART. La biblioteca ofrece casi todas las características del Serial estándar, pero ocupa mucha menos memoria.

Utilice indicadores de un solo bit

Debe tener en cuenta que el tipo de datos lógicos que toma el booleano en la memoria Arduino es de 1 bit, como debería ser, y hasta 8, es decir, 1 byte. Esta es una injusticia universal, porque de hecho podemos guardar 8 banderas en un byte cierto/falso, pero de hecho solo almacenamos uno. Pero hay una salida: empaquetar los bits manualmente en bytes, para lo cual es necesario agregar varias macros. Usar esto no es muy conveniente, pero en una situación crítica, cuando cada byte es importante, puede ser rentable. Ver ejemplo:

// opción para empaquetar banderas en una matriz. 
#define NUM_FLAGS 30 // número de banderas
byte flags[NUM_FLAGS / 8 + 1];      // matriz de banderas comprimidas
// ============== MACROS PARA TRABAJAR CON UN PAQUETE DE BANDERAS ==============
// levanta la bandera (paquete, número)
#define setFlag (flag, num) bitSet (flag [(num) >> 3], (num) & 0b111)
// omitir bandera (paquete, número)
#define clearFlag (flag, num) bitClear (flag [(num) >> 3], (num) & 0b111)
// escribe la bandera (paquete, número, valor)
#define writeFlag (flag, num, state) ((state)? setFlag (flag, num): clearFlag (flag, num))
// lee la bandera (paquete, número)
#define readFlag (flag, num) bitRead (flag [(num) >> 3], (num) & 0b111)
// omitir todas las banderas (paquete)
#define clearAllFlags (flag) memset (flag, 0, sizeof (flag))
// levantar todas las banderas (paquete)
#define setAllFlags (flag) memset (flag, 255, sizeof (flag))
// ============== MACROS PARA TRABAJAR CON UN PAQUETE DE BANDERAS ==============
void setup () {  
  serial.beguin ( 9600 ) ;
  clearAllFlags ( banderas ) ;
  writeFlag ( banderas, 0, 1 ) ;
  writeFlag ( banderas, 10, 1 ) ;
  writeFlag ( banderas, 12, 1 ) ;
  writeFlag ( banderas, 15, 1 ) ;
  writeFlag ( banderas, 15, 0 ) ;
  writeFlag ( banderas, 29, 1 ) ;
  // mostrar todo
  for ( byte i = 0; i < NUM_FLAGS; i ++ ) 
    serial.print ( readFlag ( banderas, i )) ;
}
void loop () {  
}

Utilice compresión y empaquetado de bytes

En el párrafo anterior, analizamos cómo empaquetar indicadores de un bit en bytes. De la misma manera, puede empaquetar cualquier otro dato de diferentes tamaños para un almacenamiento o compresión conveniente (pero primero, aprenda la lección sobre operaciones de bits ). Como ejemplo: inicialmente necesita almacenar tres colores en la memoria para cada LED, cada color tiene una profundidad de 8 bits, es decir, se gastan un total de 3 bytes por LED RRRRRRRR GGGGGGGG BBBBBBBB. Para ahorrar espacio y comodidad de almacenamiento, puede comprimir estos tres bytes en dos (tipo de datos int), perdiendo varios matices del color resultante. Por ejemplo como este: RRRRRGGG GGGBBBBB. Exprimir y empaquetar: hay tres variables de cada color, red, green, blue:

int rgb = (( r & 0b11111000 ) << 8 ) | (( g & 0b11111100 ) << 3 ) | (( b & 0b11111000 ) >> 3 ) ;   

Por lo tanto, hemos descartado los bits menos significativos (a la derecha) del rojo y el azul, esta es la compresión. Cuantos más bits se descarten, con menor precisión será posible «descomprimir» el número nuevamente. Por ejemplo, el número 0b10101010 (170 en decimal) se comprimió en tres bits, cuando se comprimió, obtuvimos 0b10101 000 , es decir, perdió los tres bits menos significativos, y el decimal ya resulta ser 168. Para empaquetar, se usa un cambio de bit y una máscara, por lo que tomamos los primeros cinco bits de rojo, seis verdes y cinco azules, y los empujamos al lugares correctos en la variable de 16 bits resultante. Eso es todo, el color se comprime y se puede almacenar.

Para desempaquetar, se usa la operación inversa: seleccione los bits necesarios usando una máscara y vuelva a cambiarlos a un byte:

byte r = ( datos & 0b1111100000000000 ) >> 8; 
byte g = ( datos & 0b0000011111100000 ) >> 3; 
byte b = ( datos & 0b0000000000011111 ) << 3; 

Por lo tanto, puede comprimir, descomprimir y simplemente almacenar datos pequeños en tipos de datos estándar. Tomemos otro ejemplo: necesita almacenar varios números en el rango de 0 a 3 de la manera más compacta posible, es decir, en representación binaria esto es 0b00, 0b01, 0b10 y 0b11. Vemos que 4 de esos números se pueden agrupar en un byte (el máximo toma dos bits):

// números de ejemplo
byte val_0 = 2; // 0b10
byte val_1 = 0; // 0b00
byte val_2 = 1; // 0b01
byte val_3 = 3; // 0b11
byte val_pack = (( val_0 & 0b11 ) << 6 ) | (( val_1 & 0b11 ) << 4 ) | (( val_2 & 0b11 ) << 2 ) | ( val_3 & 0b11 ) ;   
// obtuve 0b10000111

Como en el ejemplo con LED, solo tomamos los bits necesarios (en este caso, los dos inferiores, 0b11) y los movió a la distancia deseada. Para desembalar, haga en orden inverso:

byte unpack_1 = ( val_pack & 0b11000000 ) >> 6; 
byte unpack_2 = ( val_pack & 0b00110000 ) >> 4; 
byte unpack_3 = ( val_pack & 0b00001100 ) >> 2; 
byte unpack_4 = ( val_pack & 0b00000011 ) >> 0; 

Y recuperamos nuestros bytes. Además, la máscara se puede reemplazar con una grabación más conveniente deslizando 0b11 a la distancia deseada:

byte unpack_1 = ( val_pack & 0b11 << 6 ) >> 6;  
byte unpack_2 = ( val_pack & 0b11 << 4 ) >> 4;  
byte unpack_3 = ( val_pack & 0b11 << 2 ) >> 2;  
byte unpack_4 = ( val_pack & 0b11 << 0 ) >> 0;  

Bueno, ahora, siguiendo el patrón, puedes crear una función o macro para leer el paquete por ti mismo:

#define UNPACK (x, y) (((x) & 0b11 << ((y) * 2)) >> ((y) * 2))

Donde x es el paquete e y es el número de secuencia del valor empaquetado. Veamos:

Serial.println(UNPACK(val_pack, 3));
Serial.println(UNPACK(val_pack, 2));
Serial.println(UNPACK(val_pack, 1));
Serial.println(UNPACK(val_pack, 0));

Selección de cargador de arranque

En una de las primeras lecciones, les dije que un cargador de arranque vive en la memoria Flash del microcontrolador, un cargador de arranque que carga el firmware a través de UART. El cargador de arranque no tiene tres líneas de código, sino mucho más: ¡el cargador de arranque estándar ocupa casi 2 KB de memoria Flash ! Para Nano / Uno, esto es un enorme 6%. Hay dos opciones: flashear un cargador de arranque más moderno, que ocupa 4 veces menos espacio (512 bytes). El cargador se llama optiBoot, la información básica que contiene está en su propio GitHub. Analizaremos el proceso de flashear el gestor de arranque más adelante, en una lección separada. Por ahora, puede comprarse un programador AVR-ISP, aunque el cargador de arranque puede actualizarse con otro Arduino.

La segunda opción es aún mejor: cargue el firmware directamente en el microcontrolador, sin un cargador de arranque , utilizando el programador. Entonces tendrá a su disposición la cantidad total declarada de memoria Flash.

Abandonar la inicialización estándar

Funciones estándar void setup () y void loop () son obligatorios por una razón: están incluidos en la función más importante de todo el programa: int main (). La implementación de esta función se encuentra en el núcleo del archivo main.cpp y tiene este aspecto:

int main(void)
{
 init();
 initVariant();
#if defined(USBCON)
 USBDevice.attach();
#endif
 
 setup();
    
 for (;;) {
  loop();
  if (serialEventRun) serialEventRun();
 }
        
 return 0;
}

¡Es aquí, en las inicializaciones, donde se cubren un par de cientos de bytes de memoria Flash! Y entonces viene loop() y hay una verificación del estado (es precisamente evitarlo lo que da un aumento de velocidad, escribí sobre esto al final de la sección “optimización de velocidad”). En las funciones de inicialización se configura la periferia del microcontrolador: ADC, interfaces, temporizador 0 (que nos da la función millis ()) y algunas otras cosas. Si puede inicializar de forma independiente solo los periféricos necesarios, esto ahorrará varios cientos de bytes de flash, todo lo que necesita hacer es ingresar descaradamente su función en el boceto main() y escriba la inicialización solo de lo que se necesita. A modo de comparación: el conjunto estándar de inicialización (funciones setup() y loop () en el boceto) dan 444 bytes de Flash (Arduino IDE v. 1.8.9). Si abandonamos este código y tomamos el control en main()– un boceto vacío ocupará 134 bytes, ¡que es casi 300 bytes menos! Esto, por supuesto, es una pequeña cosa, pero ayuda. Cómo hacerlo:

#include <Arduino.h>
int main () {  
  // nuestra "configuración" personal
  for ( ;; ) {  
    // nuestro "bucle" personal
  }
  return 0;
}

Las funciones setup() y loop() en este boceto ya no son necesarios, porque no se utilizan en nuestro personal main().

Prueba G_PPCore

También preste atención al núcleo estándar que reescribí para las placas basadas en ATmega328: G_PPCore. Este núcleo es análogo al estándar, pero las funciones principales están completamente reescritas y se ejecutan muchas veces más rápido y ocupan mucho menos espacio en la memoria del microcontrolador.

Comprar Arduino Mega

Un consejo un poco cómico, pero práctico: si comenzó a extrañar Uno / Nano y ya se han realizado todas las optimizaciones posibles, solo le queda una opción: comprar Arduino Mega, tiene 8 veces más memoria Flash y 4 veces más SRAM.


Deja un comentario