Otimização de código em Linguagem C – Parte 1
- por Sergio Prado
Olá Pessoal!
Sistemas embarcados têm como uma de suas principais características a limitação de recursos computacionais. Como estes dispositivos possuem aplicações bem específicas, são projetados na medida para cumprir a função para o qual foram designados.
Seja para diminuir custos do projeto, tempo de desenvolvimento, tamanho do dispositivo ou consumo elétrico, normalmente são limitados pela quantidade de memória, capacidade de processamento e dispositivos de I/O. Portanto, escrever código para estes dispositivos requer atenção especial quanto a utilização e gerenciamento eficiente dos recursos disponíveis.
Nosso foco aqui é software desenvolvido em linguagem C, uma vez que grande parte do software desenvolvido para dispositivos embarcados ainda é feita nesta linguagem. Entretanto, grande parte dos conceitos podem ser aplicados igualmente em qualquer outra linguagem estruturada.
A otimização do código está intimamente relacionada à maneira como é realizada a “transformação”, ou compilação, do código-fonte no arquivo binário que será carregado para a memória do dispositivo. O compilador é o software responsável por esta transformação, e é por ele que iniciaremos nosso trabalho.
O compilador C
O compilador C é a ferramenta que irá transformar suas idéias e algoritmos (código-fonte C), na sua aplicação. O compilador realiza uma série de transformações no código-fonte para gerar o melhor código possível, como por exemplo salvar variáveis em registradores ao invés de usar a memória para melhorar o tempo de acesso (otimização de processamento) ou remover código inútil do programa (otimização de espaço).
É necessário conhecer o funcionamento interno do compilador para que o código possa ser compilado da forma mais otimizada possível. Em um compilador C moderno, o processo de compilação envolve basicamente os seis passos abaixo, na ordem apresentada:
1. Pré-processamento: Conversão do código-fonte em uma linguagem intermediária. É aqui que as diretivas de compilação são processadas e a sintaxe do código é verificada.
2. Otimização de alto nível: É nesta etapa que o compilador realiza a primeira otimização do código, em cima do código-fonte da aplicação. Veremos mais adiante algumas das técnicas para auxiliar o compilador neste trabalho de otimização.
3. Geração do código: Geração do código de máquina para a arquitetura-alvo. Nesta fase o compilador faz todas as conversões necessárias para a arquitetura em questão. Expressões aritméticas de 32 bits em processadores de 8 bits são convertidas em expressões aritméticas de 8 bits, por exemplo.
4. Otimização de baixo nível: Nesta fase a otimização é realizada em cima do código-objeto gerado na fase anterior, como por exemplo removendo instruções não usadas ou redundantes.
5. Assembler: Nesta etapa o compilador gera o arquivo-objeto correspondente ao arquivo-fonte C compilado.
6. Linkagem: Por último, o compilador faz a linkagem de todos os arquivos-objeto em um único arquivo binário para ser carregado na memória do dispositivo.
Podemos perceber que, dentre as seis etapas no processo de compilação descritas, quando pensamos em otimização de código, devemos focar nas etapas 2 e 4. Veremos a seguir algumas técnicas que podem auxiliar e instruir o compilador na tarefa de otimizar o código gerado.
Variáveis
O tempo de acesso aos registradores da CPU é normalmente muito mais rápido do que o tempo de acesso à memória de dados, e o processador possui melhor performance quando realiza cálculos utilizando registradores ao invés de memória. Por este motivo, o compilador procura alocar variáveis locais e parâmetros para funções em registradores para melhorar a performance do sistema. Mas como a quantidade de registradores é limitada, nem sempre é possível alocar todas as variáveis em registradores.
O compilador precisa decidir quais variáveis irão ser acessadas através de registradores e quais deverão ser mantidas em memória. Podemos instruir o compilador através da palavra-chave register, conforme mostra a listagem abaixo:
1 2 3 4 5 6 7 8 9 |
int exp(register int base, register int expoente) { register int resultado = 1; for (; expoente; expoente--) resultado *= base; return(resultado); } |
Tanto os parâmetros da função (base, expoente) quanto a variável local (resultado) foram definidas como register , instruindo o compilador para alocá-las em registradores. Devemos ressaltar que nem sempre será possível alocar todas as variáveis em registradores. A palavra-chave register é apenas uma orientação para auxiliar o compilador a decidir quais variáveis são mais prioritárias para a alocação em registradores. Por este motivo é importante ter um código modular, com funções pequenas e específicas, para que a quantidade de variáveis locais também seja pequena, aumentando as chances de que estas sejam otimizadas através do armazenamento em registradores.
Funções
Uma chamada de função é um processo complexo e deve ser levado em consideração quando pensamos em performance. Basicamente, antes de uma chamada à função o compilador salva em memória (ou registradores) os argumentos da função e o endereço de retorno. Essa região de memória é chamada de stack. Dentro da função chamada, os registradores são salvos, parâmetros são lidos do stack ou de registradores, e variáveis locais são alocadas em memória ou em registradores (se disponíveis). Podemos perceber que, para funções com uma quantidade grande de parâmetros, o custo de uma chamada pode ser enorme para a performance do sistema. Uma das técnicas para melhorar a performance é diminuir a quantidade de parâmetros passados para funções. Podemos perceber comparando as duas funções abaixo, que o custo de chamada da função atualiza_dados(), com 5 parâmetros, é muito maior que o custo de chamada da função atualiza_dados_otimizada(), que recebe apenas um ponteiro para uma estrutura de dados como parâmetro.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
void atualiza_dados(char *nome, char *endereco, char *cidade, char *telefone, double salario) { ... } struct Dados { char *nome; char *endereco; char *cidade; char *telefone; double salario; }; void atualiza_dados_otimizada(struct *Dados) { ... } |
Uma outra técnica de otimização é o uso do recurso de inlining de funções. É uma boa prática de programação quebrar o código em pequenas funções, porém o custo de chamada das funções pode diminuir a performance do sistema. Para minimizar este problema de performance, podemos utilizar o recurso de inlining de função, onde a chamada à função é substituída por uma cópia do seu código. Em código C, podemos empregar este recurso através da definição de macros, conforme ilustra o código abaixo. No primeiro caso, a função soma() será chamada 100 vezes, diminuindo a performance do sistema. No segundo caso, usamos a macro SOMA(), eliminando a chamada à função e conseqüentemente aumentando a performance do sistema.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
#define SOMA(a, b) (a + b) int soma(int a, int b) { return(a + b); } int main() { int i, res, a = 1; /* primeiro caso */ for (i = 0; i < 100; i++) res = soma(a, res); /* segundo caso */ for (res = 0, i = 0; i < 100; i++) res = SOMA(a, res); ... } |
Na parte 2 deste artigo, vamos falar sobre diretivas de otimização do compilador e algumas técnicas de desenvolvimento visando a otimização de tamanho e performance do código gerado. Até lá!
Um abraço,
Sergio Prado