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).

Anúncios

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

Ponteiro para um array…

Eu evito algumas construções mais “esotéricas” da linguagem C, não porque não saiba o que elas signifiquem, mas em virtude da legibilidade… Recentemente me perguntaram o que significa a declaração:

int (*p)[4];

Bem… isso ai é um ponteiro para um array de 4 inteiros. O que é bem diferente de:

int *p[4];

Que é um array de 4 ponteiros para o tipo int.

O detalhe é que deixei meio de lado a utilidade da primeira declaração. Considere isso:

int a[4][4] =
  { { 1, 2, 3, 4 },
    { 5, 6, 7, 8 },
    { 9, 0, 1, 2 },
    { 3, 4, 5, 6 } };

void showarray(int a[][4])
{
  int i, j;

  for (i = 0; i < 4; i++)
  {
    for (j = 0; j < 4; j++)
      printf("%d ", a[i][j];
    putchar('\n');
  }
  putchar('\n');
}

A rotina showarray, como declarada acima, é um jeito de mostrar todos os itens de um array. Podemos chamá-la com showarray(a); e tudo funcionará perfeitamente bem… Mas, e se quiséssemos usar ponteiros ao invés de 2 índices (i e j)?

Note que, se você tem um array simples, pode usar um ponteiro para apontar para o primeiro item e incrementar o endereço contido no ponteiro para obter o próximo item, como em:

int a[] = { 1, 2, 3, 4, 5, 0 };

void showarray(int *p)
{
  for (; *p; p++)
    printf("%d ", *p);
  putchar('\n');
}

Aqui, o compilador criará código para somar 4 ao conteúdo de p a cada vez que ele for incrementado. O valor 4 é adicionado porque sizeof(int)==4. A pergunta original é: “Dá pra fazer algo semelhante com arrays bidimensionais?”. A resposta, obviamente, é: SIM!

Ao declarar um ponteiro p como int (*p)[4];, toda vez que você incrementar o ponteiro, a ele será adicionado o valor 16 (4*sizeof(int)) e podemos escrever a rotina showarray original, assim:

void showarray(int (*p)[4])
{
  int i;

  // Não é bem a rotina original, aqui considero que
  // se o primeiro item da "linha" for zero, devemos
  // encerrar o loop.
  //
  // p++ fará o ponteiro p apontar para o início da
  // próxima linha...
  for (; (*p)[0]; p++)
  {
    for (i = 0; i < 4; i++)
      printf("%d ", (*p)[i]);
    putchar('\n');
  }
  putchar('\n');
}

O array original, é claro, deverá ser definido assim, para a rotina funcionar:

int a[][4] =
  { { 1, 2, 3, 4 },
    { 5, 6, 7, 8 },
    { 9, 0, 1, 2 },
    { 3, 4, 5, 6 },
    {} };  // note: 4 zeros aqui!

Mais um exemplo: Nem sempre assembly é a resposta!

Já escrevi sobre isso aqui: Criar suas próprias rotinas em assembly não é uma panaceia para a obtenção de alta performance. É possível acabar com uma rotina pior do que a gerada pela linguagem de alto nível. Especialmente se estivermos falando de compiladores que oferecem recursos de otimização.

Para demonstrar, consideremos a simples rotina de selection sort, abaixo:

#define swap(a,b) \
    { int _t; _t = (a); (a) = (b); (b) = _t; }

// Para ser justo, não vou permitir inlining...
__attribute__((noinline))
static void selection_sort(int *p, unsigned int size)
{
  unsigned int i, j;

  for (i = 0; i < size - 1; i++)
    for (j = i+1; j < size; j++)
      if (p[i] > p[j])
        swap(p[i], p[j]);
}

É legítimo pensar que essa rotina seja menos que ótima, já que usa o macro swap(), que lança mão de uma variável temporária que talvez seja alocada em memória. Assim, o programador pode criar uma rotina equivalente, em assembly, assim:

bits 64

section .text

; void selection_sort_asm(int *, unsigned int);
; -- Para ser justo, alinho em DWORD o ponto de entrada
;    da rotina!
  global selection_sort_asm
  align 4
selection_sort_asm:
  lea r8d,[rsi-1] ; r8d == size - 1;

  xor ecx,ecx     ; i = 0;

.loop2:
  lea edx,[rcx+1] ; j = i + 1;

.loop1:
  mov eax,[rdi+rcx*4]   ; if (ptr[i] > ptr[j]) ...
  cmp eax,[rdi+rdx*4]
  jle .skip

  xchg eax,[rdi+rdx*4]  ; ... swap(p[i], p[j]);
  mov [rdi+rcx*4],eax

.skip:
  add edx,1     ; j++
  cmp edx,esi   ; if (j < size) goto loop1;
  jb  .loop1

  add ecx,1     ; i++;
  cmp ecx,r8d   ; if (i < size - 1) goto loop2;
  jb  .loop2

  ret

Claro que essa rotina pode ser melhorada, especialmente entre os labels .loop1 e .skip, mas, em arquiteturas mais modernas (Ivy Bridge, SandyBridge e Haswell, sem contar com as mais recentes Broadwell e Skylake) os cálculo de endereços efetivos podem ser cacheados, numa espécie de common subexpression elimination, ou seja, isso não tem grande influência… Além do que, quero mostrar como uma abordagem “ingênua” se parece…

Bem… a rotina acima não é tão “ingênua” assim porque levei em conta algumas possíveis otimizações: Note que usei a instrução XCHG, usada para aproveitar o conteúdo de EAX na “troca” e evitar o uso de um outro registrador temporário. Repare também que não uso a instrução INC para evitar a penalidade de 1 ciclo de clock pelo fato dessa instrução não afetar o flag de carry (e precisar preservá-lo). É preferível usar ADD, mesmo tendo tamanho maior, em seu microcódigo… Ainda, os saltos condicionais são feitos “para trás”, para aproveitar o algoritmo estático de branch prediction (exceto para o label .skip). Uso também as instruções LEA para adições rápidas, carryless

Mas, se você medir a performance de ambas as rotinas contra um array de 10 elementos aleatórios obterá (num i5-3570 @ 3.4 GHz, arquitetura Ivy Bridge) algo em torno de 1800 ciclos para a rotina em C e 2200 ciclos para a rotina em assembly! Ou seja, a rotina em C é 18% mais rápida!!! Num i7, com arquitetura Haswell poderá obter menos ciclos…

A rotina em Assembly equivalente para o selection sort, gerada pelo GCC com as opções de compilação “-O2 -mtune=native“, é esta:

bits 64

section .text

  global selection_sort
  align 4
selection_sort:
  cmp esi, 1
  jbe .exit
  lea r11d, [rsi-1]
  mov r9, rdi
  xor r10d, r10d

  align 4
.loop2:
  add r10d, 1
  mov eax, r10d
  cmp esi, r10d
  jbe .skip2

  align 4
.loop1:
  mov edx, eax
  mov ecx, [r9]
  lea rdx, [rdi+rdx*4]
  mov r8d, [rdx]
  cmp ecx, r8d
  jle .skip1
  mov [r9], r8d
  mov [rdx], ecx

.skip1:
  add eax, 1
  cmp esi, eax
  jne .loop1

.skip2:
  add r9, 4
  cmp r10d, r11d
  jne .loop2
  ret

.exit:
  ret

Essencialmente, ela é a mesma coisa da rotina em assembly que mostrei acima. A diferença está na organização das instruções e no aproveitamento do paralelismo das unidades de execução do processador (não confundir com os “cores” ou “núcleos”!).

O GCC também evita instruções problemáticas como XCHG (que, quando faz acesso à memória, “trava” [lock] o barramento de dados). O compilador também entende que pontos de retorno de loops podem precisar estar alinhados para que eles não cruzem a fronteira de uma linha do cache L1I e, por isso, coloca aquele “align 4” lá… Ele também tenta evitar problemas de “interlocking” de registradores (quando modificamos um registrador e tentamos usá-lo, na instrução seguinte, como operando fonte. Isso evita que duas instruções sejam executadas em paralelo).

Ou seja, o compilador tenta levar em conta a grande quantidade de regras de otimização (que podem ser estudadas no manual de otimização de software da Intel [baixe aqui] – prepare-se: são quase 700 páginas!) que você, ou eu, poderíamos deixar “passar batido”. Isso não significa que não seja possível para você ou eu elaborarmos uma rotina que seja melhor da gerada pelo compilador. Isso significa, porém, que na maioria das vezes, o compilador faz um trabalho melhor do que faríamos!