Não recomendo: Usando funções da libc em códigos puramente em assembly

Sempre topo com essa questão em foruns e mídias como o Discord: Como usar funções da biblioteca padrão de C em códigos puramente escritos em assembly? É “fácil”, mas eu não recomendo. Primeiro mostrarei como, usando o NASM e o GCC e depois digo o por quê da não recomendação. Eis um “hello, world” simples:

; test.asm
; Compile com:
;   nasm -felf64 -o test.o test.asm

bits 64

; A glibc exige "RIP relative addressing". 
; De qualquer maneira, isso é sempre uma boa 
; prática no modo x86-64.
default rel

section .rodata

msg:
  db    `hello, world`,0

section .text

  ; Este símbolo está na glibc!
  extern puts

  global main
main:
  ; Chama puts(), passando o ponteiro da string.
  lea   rdi,[msg]
  call  puts wrt ..plt

  ; return 0;
  xor   eax,eax
  ret

O código acima é diretamente equivalente a:

#include <stdio.h>

int main( void )
{
  puts( "hello, world" );
  return 0;
}

Para compilar, linkar e testar:

$ nasm -felf64 -o test.o test.asm
$ gcc -o test test.o
$ ./test
hello, world

Simples assim. O gcc deve ser usado como linker porque ele sabe onde a libc está.

O wrt ..plt é um operador do NASM chamado With Referece To. Ele diz que o símbolo está localizado em uma section específica. No caso a section ..plt, que contém os saltos para as funções da libc (late binding). Isso é necessário, senão obterá um erro de linkagem porque o linker não encontrará o símbolo… PLT é a sigla de “Procedure Linkage Table“. É uma tabela com ponteiros para as funções carregadas dinamicamente da libc.

Eis o porque de minha não recomendação: O compilador nem sempre usa o nome “oficial” da função, que pode ser um #define num header e, ás vezes, existem argumentos adicionais “invisíveis” para certas funções “intrínsecas”, mas o principal argumento contra essa prática é que seu código não ficará menor ou mais rápido só porque você está usando assembly. Por exemplo, ambos os códigos em C e Assembly, acima, fazem exatamente a mesma coisa e têm o mesmíssimo tamanho final (6 KiB, extirpando os códigos de debbugging e stack protection, no caso de C).

De fato, é provável que você crie um código em assembly pior do que o compilador C criaria…

Addedum: Não recomendo, particularmente, que se tente escrever códigos em assembly que usem a libc no ambiente Windows. O problema é que Windows coloca algum código adicional nas funções. Por exemplo, A pilha deve ser alterada para acomodar SEH (Structured Exception Handling), no caso de main, é necessário chamar uma função chamada _main… Coisas assim são problemáticas para o novato.

Portabilidade: O tamanho de um byte (ISO 9989)

Sempre achei que o símbolo CHAR_BIT, definido em limits.h, fosse apenas o tamanho, em bits, do tipo char. Well… estava enganado. A sessão 6.2.6.1.4 da ISO 9989:1999 (em diante) nos diz que:

“Values stored in non-bit-field objects of any other object type consist of n × CHAR_BIT bits, where n is the size of an object of that type, in bytes. The value may be copied into an object of type unsigned char [n] (e.g., by memcpy); the resulting set of bytes is called the object representation of the value. Values stored in bit-fields consist of m bits, where m is the size specified for the bit-field. The object representation is the set of m bits the bit-field comprises in the addressable storage unit holding it. Tw o values (other than NaNs) with the same object representation compare equal, but values that compare equal may have different object representations.”

Note a frase inicial: “Valores armazenados em objetos não bit-field de qualquer objeto consistem em n*CHAR_BIT bits”.

Isso é interessante, já que, em arquiteturas antigas, 1 “byte” podia ter tamanho diferente de 8 bits.

Fica a dica…

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!