Ottimizzazione del codice firmware

Le skills dello sviluppatore non sono solo le abilità nello scrivere codice sorgente, ma un bravo sviluppatore deve possedere anche capacità di analisi critica su ciò che sta progettando, realizzando. La tecnica di ottimizzazione più efficace, infatti, è l’individuazione a priori dei colli di bottiglia del proprio software/firmware. A maggior ragione quando stiamo lavorando ad un firmware per una applicazione embedded o real-time, dove le risorse a disposizione sono limitate e bisogna sempre tener d’occhio i costi del prodotto.

Introduzione

Capire quale porzione di codice consuma maggiormente le risorse a disposizione non è un compito facile, anzi è molto arduo e per affrontarlo è necessario avere una buona conoscenza dell’architettura dell’unità di elaborazione con cui si sta lavorando (i microcontrollori dei diversi produttori hanno architetture anche molto diverse tra loro in termini di ALU e coprocessori matematici). In alcuni casi si adoperano tecniche o tool ad hoc per profilare il codice e valutare effettivamente le porzioni di codice più lente. Tralasciando tali tool, in questo articolo saranno illustrate le tecniche maggiormente diffuse per l’ottimizzazione del codice ai fini di  incrementare la velocità o ridurre l’utilizzo di memoria per firmware scritti in C.

La scelta dell’algoritmo

Prima di iniziare a scrivere un codice la cui esecuzione implementi la nostra idea di funzionalità, è importante dedicare qualche momento alla “progettazione” della propria applicazione. Io consiglio sempre il vecchio metodo carta e penna, anzi meglio la matita! Fermarsi a ragionare sul diagramma di flusso della propria applicazione, sulle informazioni necessarie nei vari stadi e sugli algoritmi da utilizzare è il primo passo per avere tutto sotto controllo e fare delle scelte fondamentali. Il software/firmware deve fare ciò che realmente vogliamo e non dobbiamo essere superflui sul "funziona così non so il perché". Questa fase non è tempo perso, è tempo guadagnato sia in fase di sviluppo, che diviene più veloce e pulito, sia in fase di debug perché si ha chiara ogni parte del codice, cosa realmente faccia e si può risalire più velocemente all'origine di eventuali errori e bugs. Questa è la fase giusta per la scelta dell’algoritmo e non deve essere sottovalutata. Alcune volte esistono algoritmi indubbiamente più veloci, altre la scelta può essere semplicemente un trade-off che dipende dalle condizioni della propria applicazione. Inoltre, ragionare prima di scrivere codice consente di scrivere un codice pulito, con una fase di debug semplice e lineare e con pochi bugs (che non fa mai male). Entrando nel dettaglio di quali algoritmi preferire, mi viene da  pensare alla scelta tra il giusto algoritmo di ricerca (preferire le ricerche binarie a quelle sequenziali) o di ordinamento.

Ottimizzazione delle funzioni matematiche

Quando si lavora con molte funzioni matematiche, e con molti calcoli, bisogna prestare molta attenzione sia per questioni di performance che per non incorrere in trappole (ad esempio la traps della divisione per 0). Spesso, quando si devono implementare equazioni molto complesse o lunghe è preferibile fattorizzare le equazioni. Tale tecnica consente di mantenere sotto controllo il flusso dell’elaborazione ed eventualmente riutilizzare calcoli già eseguiti in precedenti equazioni. Di contro, si incrementa l’utilizzo di memoria, anche se spesso sono solo informazioni locali alla funzione. Di seguito sono riportati due esempi simili per il calcolo delle equazioni f1 e f2. Come si può notare, nel secondo esempio, a discapito di una variabile in più, sono state ottimizzate il numero di operazioni aritmetiche che passano da 6 a 5.

//Esempio #1.1 funzioni matematiche
int f1, f2, a, b, c, d;

f1 = a / b * c;
f2 = a * d / b;
//Esempio #1.2 funzioni matematiche
int f1, f2, fraz, a, b, c, d;
fraz = a / b;
f1 = fraz * c;
f2 = fraz * d;

Ottimizzazione delle variabili e degli array

Sembra strano ma la scelta del tipo di variabili è molto importante sia per incrementare le performance che per la riduzione dell’utilizzo della memoria. In particolare, questo risulta ancora più importante quando non si parla di semplici variabili ma di array e variabili strutturate. Infatti, gli array sono gli scogli principali in termini di occupazione di memoria, mentre le variabili strutturate, potendo avere al proprio interno variabili di diverso tipo (e di diverse dimensioni), possono portare ad un mal utilizzo della memoria.
In funzione dell’architettura dell'ALU adoperata nel processore (o eventualmente del coprocessore matematico per le operazioni in virgola mobile) ci sarà un’ottimizzazione delle operazioni su determinate dimensioni delle variabili. Ad esempio in una architettura 16-bit, le operazioni saranno ottimizzate su questa dimensione e dunque ricorrere a variabili a 32-bit potrà portare ad una drastica riduzione delle prestazioni. Allo stesso tempo, su una architettura 32 bit, ricorrere alle variabili a 16-bit non porta evidenti benefici per due motivi: l’utilizzo della memoria sarà ottimizzato per le variabili a 32-bit e quindi in presenza di paddling si avranno locazioni di memoria non sfruttate, le operazioni saranno comunque effettuate con istruzioni a 32-bit e non ci sarà alcun incremento delle prestazioni.
Gli array oltre ad essere gli scogli principali dell’occupazione di memoria, sono anche delle strutture dati che necessitano di maggior onere computazionale. Una possibile ottimizzazione dal punto di vista delle prestazioni in fase di assegnazione può essere quella di considerare l’essenza dell'array, ossia il fatto che il suo nome è un puntatore al primo elemento. Da questo dato di fatto, è possibile utilizzare le funzioni associate ai puntatori per trasformare l’esempio #2.1 nel codice riportato nell'esempio #2.2. In quest’ultimo notiamo come utilizziamo l’indirizzo dell’array per inizializzare il puntatore e successivamente incrementiamo tale indirizzo per accedere a tutti gli elementi dell’array. La forza di tale codice sta nel fatto che è indipendente dal tipo dell’array, infatti se questo cambia ad esempio in una variabile a 64-bit (int64 oppure un double) sarà il compilatore stesso a gestire in maniera semplice e trasparente l’incremento del puntatore.

// Esempio #2.1 Ottimizzazione degli Array
int myArray[n];
for(int i=0; i<n; i++)
myArray[i]=init_value;
// Esempio #2.2 Ottimizzazione degli Array
int myArray[n];
for(int *pointer = myArray; pointer < myArray +n; pointer++)
*pointer = init_value;

Ridurre il numero di chiamate a funzioni

Sviluppare codice modulare suddividendolo in funzioni incrementa di sicuro il riuso del codice ma crea allo stesso tempo un possibile collo di bottiglia soprattutto nel caso di funzioni chiamate innumerevoli volte. Tale effetto è dovuto al fatto che ogni chiamata a funzione implica ulteriori [...]

ATTENZIONE: quello che hai appena letto è solo un estratto, l'Articolo Tecnico completo è composto da ben 2335 parole ed è riservato agli ABBONATI. Con l'Abbonamento avrai anche accesso a tutti gli altri Articoli Tecnici che potrai leggere in formato PDF per un anno. ABBONATI ORA, è semplice e sicuro.

Scarica subito una copia gratis

Scrivi un commento

Seguici anche sul tuo Social Network preferito!

Send this to a friend