free() automático no GCC

Uma das coisas que os amantes de C++ e de linguagens gerenciadas mais gostam é a “limpeza automática” que códigos escritos nessas linguagens parecem fazer. No caso de C++, objetos alocados na pilha são “destruídos” quando há perda do escopo da função. No caso de C# e Java, por exemplo, quando o objeto não é referenciado por nenhum outro, o Garbage Collector tende a livrar-se dele. Em C, aparentemente, não temos esse benefício:

void f( void )
{
  char *bufferp;
  ...

  bufferp = malloc( SIZE );
  ...
  // MEMORY LEAK: O buffer alocado não foi liberado!
}

Nada tema! GCC tem a solução!

Existe um atributo para variáveis que pode ser usado para fazer esse tipo de liberação automática. Tudo o que temos que fazer é criar a função de liberação a atrelá-la à variável:

#define AUTOFREE \
  __attribute__(( cleanup( cleanup_func ) ))

void cleanup_func( void **p ) { free( *p ) }

void f( void )
{
  AUTOFREE char *bufferp;
  ...

  bufferp = malloc( SIZE );
  ...
  // O memory leak sumiu!
}

Graças a esse atributo, a função cleanup_func é chamada, com o argumento correto, sempre que a variável sai do escopo. Isso significa que não só quando a função termina, mas em casos como esse:

void f( void )
{
  ...
  {
    AUTOFREE char *bufferp;

    bufferp = malloc( SIZE );
    ...
    // bufferp é liberado aqui, AUTOMATICAMENTE!
  }
  ...
}

Para não ter que ficar codificando uma função de cleanup (que sempre recebe um ponteiro para um ponteiro), podemos usar a glib e a macro g_autofree:

g_autofree char *bufferp;

É claro, é prudente inicializar o ponteiro com NULL para o caso de nenhuma alocação ter sido feita… A especificação da linguagem C, ISO 9989, nos diz que se passarmos NULL para free, este simplesmente não faz nada (não causa segmentation fault).

ATENÇÃO! O atributo exige que o tipo informado seja um ponteiro para o ponteiro do mesmo tipo. No entanto, (void **) apontará para qualquer tipo e não haverá problema algum, mas o compilador emitirá avisos…

Usando NTP…

Um dos métodos muito comuns em desenvolvimento de aplicações usando bancos de dados é o uso do relógio contido no host onde o banco está hospedado como se fosse “confiável”, como um método de sincronização de data/hora. No caso de sistemas que usam o Oracle é muito comum ver uma query assim:

SELECT SYSDATE FROM DUAL;

Algo semelhante é feito para o MS SQL Server e outros bancos de dados famosos:

SELECT GETDATE();  -- MS SQL Server
SELECT NOW();      -- MySQL e PostgreSQL

O problema é que todo momento em que seu código precise obter data e hora, terá que fazer uma consulta ao banco de dados. Lembre-se que uma consulta precisa ser decodificada pelo servidor antes de ser processada e há tratamento de conexão e acessos concorrentes. Sua data/hora obtida pode não ser precisa.

Felizmente existe uma solução, que já existe tem mais de duas décadas! NTP. O Network Time Protocol, além de fornecer a data/hora de fontes confiáveis, permite corrigir os atrasos na transmissão… A ideia do protocolo surgiu como ferramente de medição de performance (quanto tempo dura um roundtrip, ou seja, mandei um pacote e em quanto tempo ele chega?), bem como a obtenção de data e hora de fontes extremamente precisas e padronizadas.

A ideia não é nova. Durante as grandes guerras mundiais a necessidade do uso de relógios precisos é fator essencial para comunicações e até ações militares. Hoje em dia, em “tempo de paz”, a manutenção de horários precisos é essencial não apenas para o comércio, mas também para sistemas de posicionamento globais (GPS)… Não é incomum que as fontes de tempo sejam baseadas em “relógios atômicos”, por exemplo.

O protocolo, diferente de outros mais comuns como HTTP, não é do tipo stream ou baseado em caracter. É um padrão binário, onde o requisitante envia um pacote e recebe um pacote. Ainda, tudo trafega via UDP, ou seja, não exige a manutençao de uma conexão TCP — o que torna a transação bem rápida, em teoria… Basta enviar 48 bytes para o ntp server e ler 48 bytes que ele te enviar.

Isso pode parecer pior que enviar uma string de 25 bytes (no caso do Oracle) e obter uns 20 bytes de volta (se o retorno for do tipo “DD/MM/YYYY HH:MM:SS”), mas note, abaixo, que NTP leva em conta o atraso na rede e ainda temos a questão da confiabilidade da fonte…

Abaixo temos o código-fonte de um pequeno cliente NTP, escrito em C:

// ntp4.c
//
// Compilar com:
//  cc -O2 -o ntp4 ntp4.c
//

// _GNU_SOURCE necessário para usar a função basename()
// definida pela GNU, não a POSIX.
#define _GNU_SOURCE
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <time.h>
#include <signal.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netdb.h>

// Códigos ANSI CSI para "colorir" a saída...
#define RED    "\033[1;31m"
#define YELLOW "\033[1;33m"
#define DFL    "\033[0m"

// NTP usa "1º de janeiro de 1900, 0:00:00" como base de
// tempo. UNIX usa "1º de janeiro de 1970, 0:00:00".
// NTP_TIMESTEMP_DELTA é a diferença, em segundos.
// O macro NTP2UNIX_TIMESTAMP faz o que o nome diz.
#define NTP_TIMESTAMP_DELTA 2208988800U
#define NTP2UNIX_TIMESTAMP( x ) \
  ( time_t ) ( ( x ) - NTP_TIMESTAMP_DELTA )

// O protocolo NTP v3 usa essa estrutura.
// RFC-1305: https://tools.ietf.org/html/rfc1305
struct ntp_packet_s
{
  // +-+-+-+-+-+-+-+-+
  // |0|0|0|1|1|0|1|1|
  // +-+-+-+-+-+-+-+-+
  //  --- ----- -----
  //   |    |     |
  //   |    |     +------ Modo (3 para cliente)
  //   |    +------------ Versão (3)
  //   +----------------- Indicador de "leap second"
  //                      (do último minuto) (0)
  uint8_t li_vn_mode;

  uint8_t stratum;
  uint8_t poll;
  uint8_t precision;

  uint32_t rootDelay;
  uint32_t rootDispersion;

  uint32_t refId;
  uint32_t refTm_s;
  uint32_t refTm_f;
  uint32_t origTm_s;
  uint32_t origTm_f;

  uint32_t rxTm_s;
  uint32_t rxTm_f;
  // Os timestamps transmitidos pelo NTP estão nesses
  // dois valores.
  uint32_t txTm_s;
  uint32_t txTm_f;
};

// Usado como tempo de timeout para escrita/leitura.
// Em segundos.
#define TIMEOUT 3

// Nosso sinal usa essa variável para determinar
// para quem estamos atendendo o sinal SIGALRM.
static int sigop;         // 0 = write, 1 = read

// Protótipo local do tratador de SIGALRM.
static void timeout_handler ( int );

int main ( int argc, char *argv[] )
{
  // Restringi para IPv4.
  struct addrinfo ai_hint = { .ai_family = AF_INET };
  struct ntp_packet_s ntppkt = { .li_vn_mode = 033 };
  struct sigaction sa = {};

  struct addrinfo *resai;
  struct servent *se;
  struct sockaddr_in *sinp;
  time_t t;
  int fd;

  // Se o segundo argumento não foi informado, erro.
  if ( ! *++argv )
  {
    fprintf ( stderr, 
              YELLOW "Usage" DFL ": %s <\"addr\">\n", 
              basename( *(argv - 1) ) );
    return EXIT_FAILURE;
  }

  // Resolve o "nome"...
  if ( getaddrinfo ( *argv, NULL, &ai_hint, &resai ) )
  {
    perror ( "getaddrinfo" );
    return EXIT_FAILURE;
  }

  if ( ! resai )
  {
    fprintf ( stderr, 
              RED "ERROR" DFL ": Cannot resolve '%s'.\n", 
              *argv );
    return EXIT_SUCCESS;
  }

  // Cria o file descriptor.
  fd = socket ( AF_INET, SOCK_DGRAM, IPPROTO_UDP );
  if ( fd == -1 )
  {
    perror ( "socket" );
    freeaddrinfo ( resai );
    return EXIT_FAILURE;
  }

  // Pegamos a porta do serviço NTP de /etc/services.
  sinp = ( ( struct sockaddr_in * )resai->ai_addr;
  if ( se = getservbyname ( "ntp", NULL ) )
    sinp->sin_port = se->s_port;
  else
    // Se não achou, assume porta 123.
    sinp->sin_port = htons ( 123 );

  // read()/write() exigem que o file descriptor esteja
  // "conectado". Poderíamos usar sendto() e recvfrom()
  // e evitar isso... Para UDP connect() não "conecta",
  // mas apenas ajusta o descritor.
  if ( connect ( fd, 
                 resai->ai_addr, 
                 resai->ai_addrlen ) == -1 )
  {
    perror ( "connect" );
    freeaddrinfo ( resai );
    goto fail;
  }

  // Não preciso mais da lista
  // devolvida por getaddrinfo().
  freeaddrinfo ( resai );

  // Ajusta o tratador de sinal SIGALRM.
  sigfillset ( & ( sa.sa_mask ) );
  sa.sa_handler = timeout_handler;
  sigaction ( SIGALRM, &sa, NULL );

  // Tenta enviar o pacote...
  sigop = 0;
  alarm ( TIMEOUT );
  if ( write ( fd, &ntppkt, sizeof ntppkt ) == -1 )
  {
    perror ( "write" );
    goto fail;
  }
  alarm ( 0 );

  // Tenta obter uma resposta.
  sigop = 1;
  alarm ( TIMEOUT );
  if ( read ( fd, &ntppkt, sizeof ntppkt ) == -1 )
  {
    perror ( "write" );
    goto fail;
  }
  alarm ( 0 );

  // Calcula e mostra a data/hora.
  t = NTP2UNIX_TIMESTAMP( ntohl ( ntppkt.txTm_s ) );
  printf ( "%s\n", ctime ( &t ) );

  // SUCESSO!
  close ( fd );
  return EXIT_SUCCESS;

fail:
  // Não precisamos chamar alarm(0) aqui!
  close ( fd );
  return EXIT_FAILURE;
}

// Tratador de SIGALRM
void timeout_handler ( int signum )
{
  static const char *const msg[] =
  { RED "TIMEOUT" DFL ": ", 
    "sending request\n", 
    "receiving respospnse\n" };

  // Uso write() aqui porque printf() não é "AS-Safe".
  write ( STDERR_FILENO, msg[0], strlen ( msg[0] ) );
  write ( STDERR_FILENO, 
          msg[1 + sigop], 
          strlen ( msg[1 + sigop] ) );
  exit ( EXIT_FAILURE );
}

Nesse programinha você pode usar qualquer ntp server que esteja disponível para a sua rede. Por exemplo:

$ cc -O2 -o ntp4 ntp4.c
$ ./ntp4 a.ntp.br
Fri Jun 15 14:27:55 2018

Aqui, a.ntp.br é um dos ntp servers de estrato 2, em conformidade com o “horário legal” brasileiro. Eis a estrutura, como descrita em ntp.br (aqui):

Segundo a documentação e orientações de ntp.br, todos os servers do estrato 1 também são acessíveis publicamente… Qual a vantagem de usar ntp.br? A data e hora são as chamadas “horário legal” brasileiro… Isso levanta a questão de que para obter a data/hora “corretas” teríamos que realizar uma consulta na Internet… Não necessariamente.

Instalando e configurando seu próprio NTP server no Linux:

Com seu próprio ntp server você pode apontar todos os seus hosts para sincronização de horário por ele. Deixe-o ir até o ntp.br por você…

Nada mais simples:

  • Instale o pacote ntp:
$ sudo apt-get install ntp
  • Modifique /etc/ntp.conf:
# Retire as linhas contendo 'pool' e 'server' e as substitua por:
server a.st1.ntp.br iburst
server b.st1.ntp.br iburst
server c.st1.ntp.br iburst
server d.st1.ntp.br iburst
server gps.ntp.br iburst
server a.ntp.br iburst
server b.ntp.br iburst
server c.ntp.br iburst
  • Reinicie o serviço ntp.service:
$ sudo systemctl restart ntp.service

Pronto… Basta apenas liberar a porta 123 e, se quiser, registrar um nome para seu ntp server. Existem algumas configurações extras que você pode fazer para tornar seu servidor mais seguro, mas, essencialmente, isso é tudo.

Listas circulares, do jeito Knuth (e do Linux)…

Já falei sobre listas encadeadas ao estilo Sedgewick aqui, mas eis uma maneira de implementar listas que fica ainda mais simples… O header abaixo mostra a implementação atual do MyToyOS, devidamente copiado do estilo Knuth e Linus de listas circulares:

#ifndef __LIST_INCLUDED__
#define __LIST_INCLUDED__

// Essa é a estrutura de um nó de uma lista, 
// mas também do nó "batente" ou "cabeça" da
// lista. E, uma vez que mantemos o nó "cabeça",
// a lista é "circular", para facilitar a 
// manipulação (sempre haverá um nó cabeça!).
// A lista "vazia" tem apenas a cabeça, apontando
// para si própria.
//
// Note que a lista é double linked e circular.
//
// COMO USAR:
//
// Suponha que você queira manter uma "lista de
// retângulos", onde um retângulo tenha a estrutura:
//
//   struct rect {
//     int left, top, right, bottom;
//   };
//
// A lista pode ser definida como:
//
//  struct _list_head rect_list_head;
//  INIT_LIST_HEAD(&rect_list_head);
//
// É claro, podemos inicializar a lista diretamente:
//
//  struct _list_head rect_list_head = 
//             EMPTY_LIST(rect_list_head);
//
// A última forma é útil para inicialização de listas
// locais estáticas e globais.
//
// No exemplo dos retângulos, os nós da lista, DEVEM
// ser definidos como:
//
//   struct rect_node {
//     struct _list_head next_rect;  // Isso tem que estar
//                                   // no início!
//     struct rect rc;
//   };
//
// Para adicionar um retângulo no fim da lista:
//
//   _list_add_tail(&rect_list_head, 
//                  (struct _list_head *)&rc_node);
//
// Para adicionar um retângulo no início da lista:
//
//   _list_add_after(&rect_list_head, 
//                   (struct _list_head *)&rc_node);
//
// Para retirar um retângulo da lista:
//
//   _list_unlink((struct _list_head *)&rc_node);
//
// Para percorrer a lista, do início ao fim:
//
//   struct _list_head *p;
//   _list_foreach(p, &rect_list_head)
//   {
//     // faz algo com o ponteiro p aqui.
//   }
//
// Para percorrer a lista do fim para o início:
//
//   struct _list_head *p;
//   _list_foreach_reverse(p, &rc_list_head)
//   {
//     // faz algo com o ponteiro p aqui.
//   }
//
// Infelizmente AINDA não criei a rotina para 
// percorrer toda a lista à partir de um nó 
// específico. Mas, você pode fazer isso guardando
// o nó en questão, percorrendo a lista em uma
// direção até o nó head (_list_foreach*) e 
// depois percorrer do head para o nó guardado. 
// Por exemplo, para frente:
//
//  struct _list_head *p = (struct _list_head *)&rc_node;
//  struct _list_head *q = p->prev;
//  struct _list_head *r = p;
//  _list_foreach(r, q)
//  { ... }
//  _list_forearch(r, &rc_list_head)
//  {
//    if (r == q) break;
//  }
//
// Repare também que, embora tenhamos um "batente", o 
// macro aceita qualquer nó da lista como "alvo". Mas 
// é necessário cuidado porque o "batente" real não tem
// dados.
//

// Estrutura da "cabeça" da lista.
struct _list_head { 
  struct _list_head *next;
  struct _list_head *prev; // Funciona como 'tail' para a 
                           // cabeça da lista.
};

// Os macros citados acima...
#define INIT_LIST_HEAD(name) \
  { (name)->next = (name)->prev = (name); }
#define EMPTY_LIST(name) \
  { .next = .prev = &(name); }

// Essencialmente a mesma coisa que acima, mas lida com o
// ponteiro para a "cabeça" da lista.
static inline void _list_init(struct _list_head *head)
{
  head->next = head->prev = head;
}

// A lista está vazia? Se next aponta para a cabeça, está!
static inline _Bool _list_empty(struct _list_head *head)
{ return head == head->next; }

// Insere o novo item depois do item "prev".
static inline void _list_add_after(struct _list_head *prev, 
                                   struct _list_head *_new)
{
  struct _list_head *next;
  struct _list_head *prev;

  next = head->next;
  prev = head->prev;

  if (_list_empty(head)) // Caso especial!
    head->prev = _new;
  _new->next = next;
  _new->prev = prev;
  head->next = _new;
}

// Addiciona um item no fim da lista.
static inline void _list_add_tail(struct _list_head *head, 
                                  struct _list_head *_new)
{
  _list_add_after(head->prev, _new);
}

// Apaga um nó da lista.
// OBS: Cuidado ao usar free(). o nó pode ser a cabeça!
static inline void _list_unlink(struct _list_head *node)
{
  // Note que se a lista estiver vazia, nada acontece.
  struct _list_head *next;
  struct _list_head *prev;

  next = node->next;
  prev = node->prev;
  prev->next = node->next;
  next->prev = node->prev;
}

// Percorrer uma lista pode ser feito com:
//
// struct _list_head *p = &mylist;
//
// for (p = mylist->next; p != &mylist; p = p->next)
// { ... }
//
// Dai o macro abaixo (tanto p quanto head são ponteiros:
//
// OBS: É bom lembrar que a cada iteração do loop o 
//      ponteiro de controle apontará para o próximo
//      item da lista (ou para o anterior se o sentido
//      for reverso) até que ele retorne para a "cabeça".
//      Assim, usá-lo para deletar itens na lista deve
//      ser feito com cuidado.
#define _list_foreach(p,head) \
  for (p=head->next; p != head; p=p->next)

#define _list_foreach_reverse(p,head) \
  for (p=head->prev; p != head; p=p->prev)

#endif

Por que as funções estão definidas no header e não num módulo C separado? Ora, as rotinas são tão simples que posso fazê-las inline. Rotinas em módulos separados, necessariamente, não são “inlineáveis”. Os macros _list_foreach e _list_foreach_reverse não podem ser funções porque são atalhos para a construção do loop que percorrerá toda a lista… Aliás, um conselho: Cuidado com o código abaixo

struct _list_head *p;
_list_foreach(p, &list_of_rects)
{
  _list_unlink(p);
}

Dependendo do que você fizer com os ponteiros do nó apontado por p, o loop não fará o que você quer (mas, do jeito que está, deve funcionar, porque o componente next de p continua apontando para o próximo item, embora o nó não faça mais parte da lista… No fim das contas apenas o a cabeça da lista sobrará (não será “desligada” da lista)…

Note também que nenhuma das rotinas é responsável por alocar ou dealocar a estrutura de um nó. Tudo o que elas fazem são as ligações corretas para manter a lista. É sua responsabilidade alocar ou liberar nós, incluindo a “cabeça”, é sempre deixada na lista no caso do desligamento de todos os nós…

A característica circular da lista torna fácil algumas operações (principalmente _list_foreach e sua contraparte).

Um macete usando NULL pointers…

Você provavelmente aprendeu que ponteiros NULL são perigosos. Eles causam “segmentation faults” ou “General Protection Faults” e devem ser evitados. Eis o detalhe: Isso só acontece se o ponteiro for derreferenciado!

O macete abaixo mostra um jeito de obter o offset de um membro de uma estrutura sem usar o macro offsetof, definido em stddef.h, pelo menos desde a versão ISO 9989:1999 da linguagem C:

#define offsetof(T,member) \
  ( (unsigned int) &( (T *)0 )->member )

Notou o uso do NULL pointer? Pois é! Não estamos derreferenciando o bicho, só calculando o endereço efetivo do membro da estrutura… Se você fizer:

Yep! MS-DOS!

O membro z da estrutura S está no offset 8 (Faz sentido: x está no início da estrutura, portanto no offset 0. Como cada float ocupa 4 bytes, y estará no offset 4)… Fiz isso no MS-DOS, usando o Borland C++ 3.1 para mostrar para vocês que isso sempre foi válido, desde os primórdios.

O que essa macro faz, na verdade? Ora, o endereço efetivo é calculado com base no endereço do objeto, cujo endereço está no ponteiro… Ao usar o endereço 0 (zero), tudo o que temos é o offset do membro da estrutura!

 

De novo: Por que programar diretamente em assembly geralmente não é uma boa ideia…

Sim… de novo estou advogando contra mim mesmo. Eu uso assembly sempre que posso e quando isso não implica em códigos dependentes de arquitetura. Mas, para o novato, que está aprendendo assembly agora, programar nessa linguagem quase sempre é sinônimo de desastre. Eis o porquê:

Convenções de chamadas existem por um bom motivo

Quando estamos aprendendo é sugerido que manter o contexto da função chamadora é importante. Por contexto quero dizer os registradores que estavam sendo usados pela função que chamou a sua… Isso parece razoável, mas não é assim que compiladores de nível mais alto fazem… Um exemplo simples é a implementação padrão da função strcpy, em C:

char *strcpy(char *d, char *s)
{
  char *p = d;
  while (*p++ = *s++);
  return d;
}

Note que a função não verifica o conteúdo dos ponteiros (eles PODEM ser NULL e causarem segmentation fault) e o código gerado também não se preocupa em preservar o conteúdo dos registradores. No x86-64 isso é mais evidente:

strcpy:
  mov   rax,rdi
  mov   rdx,rdi
.loop:
  add   rsi,1
  movzx ecx,byte [rsi-1]
  add   rdx,1
  test  cl,cl
  mov   [rdx-1],cl
  jne   .loop
  ret

Aqui a função alterou, sem escrúpulos, o conteúdo de RAX, RCX, RDX, RSI e RDI. Isso acontece porque, no modo x86-64, o GCC só se preocupa em manter os registradores RBX, RBP, RSP e de R12 até R15. Já no modo i386, os mantidos devem ser EBX, EBP e ESP apenas. Todos os demais podem ser modificados.

Há uma vantagem escondida nessa aproximação: Se você sabe que o compilador C vai manter, à todo custo, esses registradores inalterados entre chamadas, tanto ele quanto você pode usá-los como “variáveis temporárias” (desde que preserve os seus conteúdos vindos da função chamadora!)… Quanto aos demais, se sua função chama outra e você precisa preservar o conteúdo de EAX, por exemplo, então é obrigá-lo a salvá-lo você mesmo (na pilha, por exemplo!).

Ahhh, sim… nas convenções de chamada usadas por C, pelo menos no modo i386, EAX é usado para retornar um valor e o par EDX:EAX, se esse valor for maior que 32 bits. Se sua função não retorna valores, pode mexer com EAX e EDX à vontade e deixá-los “bagunçados”. Eles não precisam ser preservados, neste caso.

Usando a pilha para manter variáveis locais entre chamadas

O GCC tenta manter todas as variáveis locais em registradores, mas, eventualmente, precisa guardá-los para uso futuro (depois do retorno da chamada de alguma função). Geralmente ele faz algo assim:

f:
  sub esp,4
  ...
  mov [esp],eax
  call some_function
  mov eax,[esp]
  ...
  add esp,4
  ret

ESP, quando sua função é chamada, aponta para o endereço de retorno da função chamadora. Como a pilha cresce para baixo, nossa função resolveu alocar 4 bytes (no modo i386 a pilha deve estar sempre alinhada por DWORD) adicionando 4 ao ESP. Antes de retornar (RET) o conteúdo de ESP deve voltar ao valor original que estava (lembra-se que ele é um dos que têm que ser preservados?).

É claro que você poderia guardar o conteúdo dessa variável “local” dentro de algum endereço no segmento de dados ou, até mesmo, no segmento de código, mas ai ela deixaria de ser “local”, não é?

Por que não usar PUSH e POP?

Depois que você aprende o conceito de pilha, parece muito mais simples usar as instruções PUSH e POP do que usar o esquema que mostrei acima. O mesmo fragmento de código em poderia fazer, retirando aqueles SUB e ADD e codificando como:

f:
  ...
  push eax
  call some_function
  pop  eax
  ...
  ret

A função fica menor? Fica! Parece mais simples? Parece! Mas ela tem um problema… As instruções PUSH e POP são ligeiramente mais lentas que MOV porque cada PUSH e cada POP, além de lerem/gravarem na pilha os valores dos registradores têm que adicionar 4 ou retirar 4 de ESP. O código acima, sem usar PUSH e POP, é equivalente a:

f:
  ...
  sub  esp,4
  mov  [esp],eax
  call some_function
  mov  eax,[esp]
  add  esp,4
  ...
  ret

Essa sequência de SUB/MOV e MOV/ADD acontece em cada um dos PUSHes e POPs, respectivamente. O que o compilador C faz é, já que ele sabe quantos bytes vai precisar usar da pilha, ele os aloca de antemão e usa apenas MOVs para guardar os valores!

Ainda não se convenceu que usar a pilha é melhor?

Se você ainda acha que guardar estados no segmento de dados ou código é uma boa ideia, existe mais um motivo para não fazê-lo. Um acesso à memória depende do cálculo do “endereço efetivo”, que pode ser dado por uma expressão. Por exemplo:

mov eax,[esp]
mov eax,[esp+4]
mov eax,[ebx+esi+8]
mov eax,[ebx+esi*8+8]
mov eax,[0x7ffffff0]

Nos primeiros 4 casos o “endereço” de onde um DWORD será lido é dado pelas expressões envolvendo registradores. No primeiro e no segundo caso não há gasto de ciclos de clock adicionais e o acesso é muito parecido com quando você acessa um registrador qualquer, em termos de performance. Os outros casos envolvem cálculos mais complicados e, no caso do último, um deslocamento maior que 2047. Qualquer deslocamento maior que 2047 causa o consumo de 1 ciclo de clock extra (além do cálculo)… Por exemplo, se usássemos [esp+4096] teríamos o consumo de 1 ciclo a mais na instrução… Se usássemos [ebx+esi+4096], teríamos o consumo de 1 ciclo a mais, graças ao cálculo de EBX+ESI e ainda mais 1 porque o offset é maior que 2047.

Ao usar a pilha e manter as variáveis locais próximas de onde ESP aponta, digamos, menos de 2 KiB perto, não há consumo de ciclo extra. É como se o processador estivesse lendo/gravando num registrador.

É claro que existem outras penalidades: Cache Misses, TLB misses e Page Faults, por exemplo… Mas, isso poderia ocorrer nos outros casos também…

 

Adote uma convenção de chamadas ao estilo de C…

Deixar para as funções chamadoras manterem seus próprios contextos e evitar fazer testes desnecessários em funções de baixo nível são sempre boas ideias… Suas funções de baixo nível devem fazer apenas o que elas precisam fazer. Verificar se ponteiros são nulos, se valores estão dentro da faixa desejada etc, são tarefas para funções de níveis mais altos, mais “inteligentes”. Essa estratégia leva a códigos menores e mais rápidos, sempre!

Para quem está criando o próprio “toy OS”…

Conheço algumas pessoas que estão experimentando a criação do próprio sistema operacional de brinquedo. Já vi alguns projetos interessantes, a maioria voltada para o modo protegido i386 (32 bits). Aqui vai uma dica para quem está usando o GCC para gerar código freestanding

A convenção de chamada padrão da linguagem C é chamada de cdecl, onde os argumentos de uma função são sempre empilhadas, na ordem do último argumento para o primeiro; e a função chamadora é a responsável por “limpar” a pilha dos argumentos. Acontece que ficar usando pilha para passagem de argumentos é um jeito bem lento de se fazer as coisas. Será que não tem alguma forma de usar registradores? Felizmente, tem! Antes, vamos dar uma olhada nas funções abaixo:

__attribute__((noinline))
int f(int a, int b, int c) 
{ return a * b + c; }

__attribute__((noinline))
int g(int a, int b, int c, int d, int e) 
{ return ( f(a, b, c) - d) / e; }

Se você compilar com as opções tradicionais, obterá algo assim:

; Código equivalente, para NASM, compilado com
; o GCC via:
;
; $ cc -O2 -S -masm=intel -ffreestanding -m32 test.c
;
  bits 32
  section .text

; A estrutura da pilha após a chamada de f é:

;   ESP+0:  Endereço de retorno colocado pelo CALL.
;   ESP+4:  Valor de 'a';
;   ESP+8:  Valor de 'b':
;   ESP+12: Valor de 'c';
;
  global f
f:
  mov  eax,[esp+8]
  imul eax,[esp+4]
  add  eax,[esp+12]
  ret

; A estrutura da pilha após a chamada de g é:
;
; ESP+0:  Endereço de retorno colocado pelo CALL;
; ESP+4:  Valor de 'a';
; ESP+8:  Valor de 'b';
; ESP+12: Valor de 'c';
; ESP+16: Valor de 'd';
; ESP+20: Valor de 'e';
;
  global g
g:
  ; Aqui tem um "macete"... Como cada PUSH
  ; decrementará ESP em 4, para acessar a próxima referência
  ; à pilha, temos que usar o mesmo offset "ESP+12".
  push	dword [esp+12]  ; Empilha 'c'.
  push	dword [esp+12]  ; Empilha 'b'.
  push	dword [esp+12]  ; Empilha 'a'.
  call	f
  add   esp, 12         ; Livra-se dos 12 bytes empilhados.

  sub   eax, [esp+16]
  cdq
  idiv  dword [esp+20]
  ret

Cada referência à memória consome, pelo menos, 1 ciclo de clock adicional para a instrução e, ainda, aumenta um bocado o tamanho do código. Eis o micro código das duas funções, obtida com o objdump:

f:
   0: 8b 44 24 08      mov  eax,[esp+0x8]
   4: 0f af 44 24 04   imul eax,[esp+0x4]
   9: 03 44 24 0c      add  eax,[esp+0xc]
   d: c3               ret

g:
  10: ff 74 24 0c      push DWORD PTR [esp+0xc]
  14: ff 74 24 0c      push DWORD PTR [esp+0xc]
  18: ff 74 24 0c      push DWORD PTR [esp+0xc]
  1c: e8 fc ff ff ff   call f
  21: 83 c4 0c         add  esp,0xc
  24: 2b 44 24 10      sub  eax,[esp+0x10]
  28: 99               cdq  
  29: f7 7c 24 14      idiv DWORD PTR [esp+0x14]
  2d: c3               ret

Agora, vejamos como fica o mesmo código, compilado com o GCC, mas incluindo a opção -mregparm=3:

f:
   0:	0f af d0         imul edx,eax
   3:	8d 04 0a         lea  eax,[edx+ecx*1]
   6:	c3               ret  

g:
  10:	e8 fc ff ff ff   call f
  15:	2b 44 24 04      sub  eax,[esp+0x4]
  19:	99               cdq  
  1a:	f7 7c 24 08      idiv DWORD PTR [esp+0x8]
  1e:	c3               ret    

O que aconteceu aqui é que o atributo regparm faz com que os 3 primeiros argumentos, se forem inteiros, são passados sempre pelos registradores EAX, EDX e ECX, nessa ordem. Os demais argumentos são empilhados, de acordo com a convenção de chamada cdecl. Na função g, apenas os argumentos d e e estarão empilhados e, como e é empilhado primeiro, ele estará em [ESP+8], deixando d em [ESP+4].

Note que, como EAX, EDX e ECX já contém os argumentos que serão passados para f, na função g, nenhum empilhamento é necessário, retirando os 3 PUSHs que existiam antes, bem como a limpeza da pilha. Do lado da função f, já que os argumentos estão todos em registradores, podemos usar LEA para fazer a adição final…

Bem… o resultado final é que as duas funções ficarão bem mais rápidas e com a metade do tamanho das originais!

Cuidado com as expressões…

Uma “linguagem de programação” geralmente é composta de sequências de operações, que são divididas em expressões aritméticas e operações de controle de fluxo (if..then..else, while, do…while, …). Humpf… E já ouvi gente dizendo que “programação nada tem a ver com matemática!”…

Como na aritmética e na álgebra, expressões devem ser avaliadas de acordo com a “importância” do operador. Por exemplo, ao fazermos x+y\cdot z, devemos multiplicar y e z antes de adicionarmos x. A isso damos o nome de precedência (no sentido de “o que vem antes”). Mas, como não temos apenas as 4 operações fundamentais da álgebra (+, -, × e ÷), todos os operadores têm que seguir alguma regra de precedência… Na linguagem C temos a seguinte tabela:

Ordem de precedência Operador Associatividade
1 ++, — (pós-incremento e pós-decremento)
() (chamada de função)
[]
.
->
(T){list} (composição)
E→D
2 ++, — (pré-incremento e pré-decremento)
+, – (unários)
!
~
(T) (casting)
* (indireção)
& (“endereço de”)
sizeof
_Alignof
D→E
3 *, /, % E→D
4 +, –
5 <<, >>
6 <, <=, >, >=
7 ==, !=
8 &
9 ^
10 |
11 &&
12 ||
13 ?: D→E
14 =, +=, -=, *=, /=, %=
&=, |=, ^=
<<=, >>=
15 , E→D

Quanto menor for a ordem de precedência, maior é o grau de precedência do operador, ou seja, ele será avaliado antes. Mas, como o leitor pode observar, isso não é suficiente. O termo “associatividade” significa “de que lado” devemos começar. Operadores como + e -, por exemplo, têm associatividade da esquerda para direita (E→D), significando que a sub expressão do lado esquerdo do operador deve ser avaliada antes da do lado direito.

Pôxa! Temos que decorar esses trecos de associatividade também? Não! Note que, além dos operadores de assinalamento, somente os operadores unários (que só têm um único argumento à direita) têm associatividade D→E, e isso faz todo sentido! As únicas exceções são os pós-incrementos e pós-decrementos, chamadas de função, arrays, e o pseudo-operador condicional ?:, que é problemático (às vezes) e recomendo (pela primeira vez neste texto) que seja evitado. Todos os outros operadores têm associatividade da esquerda para direita.

Note também que os operadores de assinalamento (= e seus primos) têm a mais baixa precedência de todos, exceto pelo operador vírgula e, portanto, serão avaliados por último. Por exemplo, numa expressão como:

*p = x + 1;

Temos 3 operadores: O de indireção (*), o de atribuição (=) e a adição (+). Nessa expressão o operador de indireção tem maior precedência (menor ordem), a adição tem precedência menor, mas a atribuição tem a mais baixa (maior ordem). Podemos entender a expressão acima como (*p)=(x+1). De acordo com a associatividade das operações e sabendo que o assinalamento é feito por último, a sub expressão da direita de = é resolvida primeiro e depois a expressão do lado esquerdo. Só então a atribuição é feita.

Existe, é claro, outra forma de entender isso: Uma expressão pode ser organizada numa árvore binária que pode ser percorrida de forma pós-ordenada (visita esquerda, visita direita, visita raiz). É um pouco mais complexo do que isso devido a associatividade, mas em essência, para compreensão, podemos manter essa analogia… Nessa árvore, os operadores com menor precedência são colocados no topo da árvore e os com maior, mais perto dos nós folha…

Isso parece simples, mas note o problema no fragmento de código abaixo:

int x;

x = 1 << 3 + 1

Qual será o resultado? 9 [de (1 << 3)+1] ou 16 [de 1 << (3+1)]? Vejamos: Vimos que = tem precedência mais baixa e associatividade da direita para esquerda, então a sub expressão à sua direita será resolvida primeiro, ou seja, 1 << 3 + 1. Aqui, tanto o operador << quanto + têm a mesma regra de associatividade (da esquerda para direita), mas + precede <<. Assim, a sub expressão fica (1 << (3 + 1)). O resultado é 16.

À primeira vista, pode parecer que o shift lógico tem maior prioridade que a adição ou, pelo menos, a mesma. Parece que o programador queria fazer um shift e depois somar 1. Entender como funciona o esquema precedência e associatividade é importante para evitar esse tipo de problema, senão você terá que usar parenteses em todas as suas expressões (o que não é má ideia, em caso de dúvida).

Outro exemplo está nas comparações. Note que os operadores lógicos (tanto binários [&. | e ^] quando os “lógicos” [&& e ||]) tem precedência menor que comparações (==, !=, <, , >=). Daí, ambas as construções abaixo são idênticas:

if (x == 0 && y == 0) ...
if ((x == 0) && (y == 0)) ...

Substitua == por = e temos uma inversão de precedências, ou seja, o = é feito por último… A expressão x = 0 && y = 0 fica x=((0 && y)=0), o que gerará um erro de LVALUE. Lembre-se, se && tem maior precedência que =, então ele é executado primeiro…

O que diabos é um LVALUE? Bem… operadores de atribuição esperam que o que esteja do lado esquerdo da expressão seja um objeto onde um valor possa ser armazenado. A sub expressão do lado direito criará uma atribuição do tipo 0=0 e não é possível armazenar o valor zero dentro de outro valor zero…

Ainda outro ponto de nota é que cada operador espera um conjunto de argumentos (ou operandos). Falo conjunto porque alguns operadores precisam de apenas um argumento (todos os operadores de ordem de precedência 1 e 2, na tabela acima). O restante, precisa de dois argumentos, exceto pelo operador condicional (?:), que precisa de 3… mas esse, tecnicamente, não é bem um operador e tem lá seus problemas com precedência e associatividade (recomendo evitá-lo, pela segunda vez neste texto).

Ainda falando de precedência, vale relembrar sobre os operadores de maior precedência . e ->, bem como citar o operador de indireção (*), com precedẽncia imediatamente inferior. O problema de misturar o operador de resolução de membros de estruturas (.) com o indireção (quando usamos ponteiros) é que a coisa não funciona bem como pode ser sua intenção. A expressão *p.x = 1;, sendo x um membro de uma estrutura qualquer apontada por p não faz o que se espera dela. Por ‘.’ ter precedência maior que ‘*’, a expressão pode ser entendida como *(p.x) = 1;, ou seja, estamos dizendo que o membro x é que é o ponteiro, não p. Para fazermos o que queríamos, temos que resolver esse problema de precedência com o uso de parenteses: (*p).x = 1;.

Acontece que ficará meio chato ter que fazer isso toda vez que usarmos o ponteiro p para essa (e outras) estruturas. Daí o operador ‘->’ foi criado e ele espera ter, no seu lado esquerdo, um ponteiro. O operador dereferencia o ponteiro sem a necessidade do uso do operador ‘*’. Daí, a expressão p->x = 1; é a mesma coisa que (*p).x = 1;.

Outro problema para sua consideração: Note que casting (promoção de tipo) também é um operador e tem precedência menor, por exemplo, do que um pós-incremento. Isso quer dizer que uma expressão do tipo (char *)p++ pode não fazer exatamente o que você espera… Suponha que p seja do tipo int *. A expressão ao lado vai adicionar sizeof(int) ao ponteiro e depois converter a expressão para o tipo char *. Se você quisesse adicionar sizeof(char) ao ponteiro, terá que, necessariamente, fazer: ((char *)p)++. Isso é fácil demonstrar:

/* test.c */
#include <stdio.h>

void main(void)
{
  static int x[2] = { 1, 2 };
  int *p, *q;
  char c;

  p = q = x;
  c = *(char *)p++;

  printf("(%p) %p -> 0x%02hhx\n", q, p, c);
}

Ao compilar e executar:

$ cc -o test test.c
$ ./test
(0x601038) 0x60103c -> 0x01

E, por último, nem todos os operadores lógicos tem precedência baixa. A exceção são os operadores de negação (! e ~). Isso garante que expressões como !a == b realizem a negação lógica do argumento a apenas, ao invés de toda a sub expressão à direita de !. Compare isso com expressões como a == b && b == c