Manipulação ao nível do bit na Linguagem C

É sabido que um computador trabalha em modo binário, armazenando e manipulando bits, isto é, zeros e uns. Este artigo procura resumir as metodologias mais comuns para uso e manipulação de bits através da linguagem C.

Base binária, octal e hexadecimal

A designação bit identifica um valor da base binária. Como o nome sugere, a base binária é composta por dois valores distintos, representados por zero e um, daí também se designar por base dois. Assim, um bit pode assumir um desses dois valores, sendo muitas vezes empregue para representar um estado ativo (bit com o valor a 1) ou inativo (bit com valor a 0).

Vários bits podem ser agrupados para formar valores com maior amplitude. Concretamente, sempre que se acrescenta um bit, está-se a duplicar o número de valores passíveis de serem representados pelo conjunto de bits. Por exemplo, com um bit consegue-se representar dois estados (0 e 1). Com dois bits já é possível representar quatro estados (00, 01, 10 e 11) e com três bits oito estados (000, 001, 010, 011, 100, 101, 110 e 111). De uma forma geral, n bits permitem a representação de 2n estados diferentes. Por exemplo, 16 bits permitem a representação de 216 valores distintos, isto é, 65536. É essa a razão porque um inteiro de 16 bits sem sinal (i.e., unsigned) pode representar valores entre 0 e 65535. Outro exemplo é o do octeto, que designa um conjunto de oito bits e que pode representar 28, i.e., 256 valores inteiros, seja entre -128 e 127 (octeto com sinal) ou entre 0 e 255 (octeto sem sinal). O termo byte é frequentemente empregue para designar um octeto.

A representação binária é usualmente pouco conveniente para o ser humano que facilmente se perde na contagem e localização dos bits, especialmente se existir um número elevado de bits. Deste modo, os programadores usam frequentemente a base octal e a base hexadecimal como alternativa à representação binária. Estas duas bases são empregues por serem mais compactas e pela facilidade com que se consegue converter de uma representação para a representação binária e vice-versa.

A base octal tem um conjunto de oito símbolos para a representação de uma dada quantidade. Os símbolos são os dígitos compreendidos entre 0 e 7. No caso da linguagem C (e de muitas outras), um valor em base octal é representado com um zero à esquerda. Assim, o valor 0123 numa listagem de código C identifica o valor octal 123 e não o valor decimal 123. Na realidade, o valor 0123 corresponde ao valor 83 em base decimal. A conversão de octal para decimal pode ser feita somando-se as parcelas resultantes da multiplicação de 3 por 80, de 2 por 81 e de 1 por 82 obtendo-se o valor 3×1+2×8+8×8=83. Note-se que o uso do zero à esquerda para indicar que uma constante inteira está em base octal pode confundir o programador menos atento que poderá pensar tratar-se de uma constante em base 10 com um insignificante zero à esquerda.

Conforme já referido, o maior interesse da base octal é a facilidade de conversão para a respetiva representação binária e vice-versa. De facto, por ser composta por 8 símbolos, cada símbolo octal é representado em binário por três bits, pois três bits permitem gerar 8 valores distintos (23 = 8). Por exemplo, o símbolo octal 3 é representado em binário por 011, o símbolo 6 por 110. A Tabela 1 mostra a conversão entre octal e binário para os oito símbolos da base octal. Voltando ao exemplo anterior, o valor octal 123 (0123 na linguagem C) tem a representação binária 001.010.011, correspondendo à substituição dos símbolos da base octal pelos respetivos valores binários (os pontos empregues na representação binária destinam-se somente a simplificar a leitura). Importa referir que algumas linguagens de programação já suportam representação binária, usando o prefixo 0b antes da quantidade numérica. É o caso da linguagem Java (somente a partir da versão 7), na qual o valor binário correspondendo ao valor octal 0123 poderia ser representado como 0b001010011.

Tabela 1: Mapeamento entre base octal e base binária
OctalBinário
0000
1001
2010
3011
4100
5101
6110
7111

De modo similar à base octal, uma representação em base hexadecimal é facilmente convertida numa representação binária e vice-versa. A base hexadecimal assenta num conjunto de 16 símbolos, usando os algarismos 0 a 9 para a representação dos 10 primeiros símbolos e as letras de A a F para os restantes seis símbolos. Nas linguagens de programação, as constantes em base hexadecimal são identificadas pelo prefixo 0x. Assim, retomando o exemplo anterior, 0x123 representa uma quantidade em base 16. A conversão de uma valor hexadecimal para base 10 consiste em somar as parcelas resultantes da multiplicação do símbolo mais à direita por 160 (símbolo designado como o menos significativo), da multiplicação do segundo símbolo mais à direita por 161, da multiplicação do terceiro símbolo mais à direita por 162 e assim sucessivamente. Para o caso do valor 0x123, obtém-se 3×160 + 2×161+ 1×162, isto é, 3+32+256, ou seja o valor decimal 291. Usando uma notação frequentemente empregue, pode dizer-se que (123)16 corresponde ao valor (291)10.

A conversão de um valor hexadecimal para a representação binária equivalente processa-se de forma similar à conversão de um valor octal para binário, exceto que cada símbolo hexadecimal deve ser mapeado para um valor de 4 bits de acordo com a Tabela 2. O uso de 4 bits por símbolo decorre do facto que são necessário 4 bits para representar todos os 16 símbolos empregues na base hexadecimal (24=16). Aplicando-se a metodologia de conversão hexadecimal para binário ao exemplo 0x123, obtém-se a seguinte representação em binário: 0001.0010.0011.

Tabela 2: Mapeamento entre hexadecimal e base binária
HexadecimalBinário
00000
10001
20010
30011
40100
50101
60110
70111
81000
91001
A1010
B1011
C1100
D1101
E1110
F1111

A maior frequência de uso na programação da representação hexadecimal em relação à representação octal deriva do facto de um valor hexadecimal apresentar um tamanho que é sempre múltiplo de 4 bits. Essa característica possibilita que facilmente possa ser encontrado um valor hexadecimal com o mesmo número de bits de um tipo de dados inteiro. Por exemplo, para o caso de se pretender um valor inteiro com 16 bits, apenas é necessário garantir que a representação hexadecimal tenha 4 símbolos. Similarmente, para um valor de 32 bits, sabe-se que é apropriado um valor hexadecimal com 8 símbolos e assim sucessivamente. Adicionalmente, o formato hexadecimal é empregue para a representação de endereços, dado os endereços terem geralmente um número de bits que é uma potência de dois (8, 16, 32, 64, etc.). Deste modo, não surpreende que a linguagem C disponibilize através da função printf e do respetivo operador de formatação %x, a representação de um determinado valor em formato hexadecimal. Note-se que em alternativa ao operador %x, pode ser empregue o operador %X (maiúscula) que apresenta o mesmo resultado, exceto que usa maiúsculas para representar os símbolos hexadecimais entre A e F.

Especificação de campos de bits em estruturas

A linguagem C possibilita a declaração de campos binários em estruturas do tipo struct. Assim, é possível declarar um ou mais elementos de uma struct como sendo um conjunto de bits. A manipulação do conjunto de bits assim definidos faz-se através do campo da struct. Considere-se o exemplo da struct exemplo1 apresentado na Listagem 1, na qual estão declarados os campos campo01 e campo02, respetivamente com dois e quatro bits. O uso de um campo de bits é efetuado da mesma forma que qualquer outro campo da estrutura, especificando-se o nome do campo. No caso da Listagem 1 são atribuídos os valores 1 (em decimal, correspondendo a 01 em binário) e 0xA (em hexadecimal, correspondendo a 1010 em binário), respetivamente, aos campos campo01 e campo02.

/* Exemplo: "bit_fields.c" */
#include <stdio.h>
typedef struct exemplo1{
   int campo_bit01:2;
   unsigned int campo_bit02:4;
   float valor_float;
}exemplo1_t;

exemplo1_t exemplo;
exemplo1.campo01 = 1;
exemplo1.campo02 = 0xA;
printf("campo01=%d\n", exemplo1.campo01);
printf("campo02=%d\n", exemplo1.campo02);
Listagem 1: exemplo bit_fields.c

Importa notar que um elemento especificado como campo de bits deve ser obrigatoriamente declarado como sendo do tipo int (ou equivalentemente do tipo signed int) ou do tipo unsigned int. O número de bits definido para o campo condiciona os valores que lá podem ser armazenado. Assim, para o caso do campo01, os dois bits do campo permitem armazenar um dos conjuntos binários 00, 01, 10 ou 11. Adicionalmente, dado que campo01 é declarado com int, isto é, inteiro com sinal, os valores inteiros que o campo pode armazenar são o -2, o -1, 0 e 1. Por sua vez, o elemento campo02 tem espaço para quatro bits, pelo que lhe pode ser atribuído um valor hexadecimal desde que tenha somente um dígito, como é o caso do valor 0xA empregue na Listagem 1.

Bits e variáveis inteiras

Na linguagem C, o acesso ao nível do bits não está limitado a campos de bits definidos em structs. De facto, é possível efetuar operações envolvendo operadores binários em variáveis do tipo inteiro, sejam elas int, short, long ou mesmo char, independentemente de ser considerado o sinal ou não (signed/unsigned). A principal diferença entre o uso de um campo de bits e o uso de uma variável inteira reside no facto que uma operação binária numa variável inteira envolve todos os bits da variável, ao passo que num campo de bits, apenas são afetados os bits do campo de bits. Assim, quando se efetua uma operação binária envolvendo, por exemplo, uma variável inteira sem sinal com 32 bits, é necessário considerar os efeitos da operação sobre os 32 bits que compõem a variável. É pois importante, quando se faz uso de uma variável inteira, ter em conta o número de bits da variável, algo que pode ser determinado multiplicando o resultado devolvido pelo operador sizeof por 8, dado que esse operador devolve o tamanho em octetos (bytes) da variável ou do tipo de dados que lhe é passado como parâmetro (Listagem 2). A norma C99 introduziu tipos de dados com tamanho explicitado, como é o caso do tipo int8_t que corresponde a um valor inteiro com sinal de 8 bits (i.e., um octeto) ou o uint16_t que tem 16 bits para guardar valores inteiros sem sinal (Open-STD, 2003). A norma C99 especifica ainda que os tipos inteiros explicitados se encontram definidos no ficheiro <inttypes.h>.

// Exemplo: 
int var_a;
printf("nº bits 'int'=%d\n",sizeof(var_a)*8);
printf("nº bits 'short'=%d\n", sizeof(short)*8);
Listagem 2: exemplo sizeof.c

Conceito de transbordo

Por terem um número finito de bits, as variáveis do tipo inteiro apenas podem representar um número finito de valores inteiros compreendidos entre um valor mínimo e um valor máximo. Por exemplo, uma variável do tipo uint16 apenas pode representar os valores inteiros do intervalo inteiro [0, 216-1], isto é, [0, 65535]. Assim, caso se pretenda guardar um valor maior do que aquele suportado pela variável, ocorrerá o que se designa por um transbordo, perdendo-se a parte mais significativa do resultado. A Listagem 3 exemplifica o que sucede quando se soma uma unidade à variável transbordo_1 do tipo uint16 (inteiro sem sinal de 16 bits) que foi previamente carregada com o máximo valor que suporta, isto é, 65535: o valor da variável passa para 0. A Listagem 3 mostra ainda o transbordo da variável transbordo_2 que é do tipo int16, isto é, uma variável inteira de 16 bits com sinal, que pode ser empregue para representar os valores do intervalo inteiro [-32768, +32767]. Assim, quando se carrega a variável com o valor máximo (32767) e posteriormente se soma uma unidade à variável, o valor da variável passa a ser o valor mais negativo, isto é, -32768 (ver Listagem 4). A possibilidade de transbordo é algo ao qual o programador deve estar muito atento, pois usualmente provoca comportamentos erráticos da aplicação (Baraniuk, 2015).

/*
 * Exemplo: "transbordo.c"
 * "Manipulação de bits na linguagem C" 
 * Compilar:
 * gcc -Wall -W -std=c99 transbordo.c -o transbordo.exe
 * Revista Programar 
 * Patricio R. Domingues
 */
#include <stdio.h>
#include <inttypes.h>

int main(void){
 uint16_t transbordo_1;
 int16_t transbordo_2;
 printf("Nº de bits de 'unsigned short': %u\n",
 sizeof(transbordo_1)*8);
 transbordo_1 = 65535;/* Carrega valor máximo */
 printf("valor de transbordo_1=%u\n",
 transbordo_1);
 transbordo_1++; /* transbordo! */
 printf("(após +1) transbordo_1=%u\n",
 transbordo_1);
 transbordo_2 = 32767;
 printf("valor de transbordo_2=%d\n", 
 transbordo_2);
 transbordo_2++; /* transbordo! */
 printf("(após +1) transbordo_2=%d\n", 
 transbordo_2);
 return 0;
}
Listagem 3: exemplo transbordo.c
Nº de bits de ‘unsigned short’: 16
valor de transbordo_1=65535
(após +1) transbordo_1=0
valor de transbordo_2=32767
(após +1) transbordo_2=-32768
Listagem 4: resultados da execução de transbordo.c

Operações binárias acessíveis na linguagem C

Por operação binária entende-se a operação que tem por operando(s) um ou mais valores que são tratados de forma binária, isto é, as operações decorrem bit a bit.

As operações binárias disponibilizadas na linguagem C correspondem às operações habituais de manipulação de bits que são: i) negação; ii) “e” (conjunção); iii) “ou” (disjunção); iv) “ou exclusivo” (disjunção exclusiva); v) deslocamento para a esquerda e vi) deslocamento para a direita. As operações binárias são usualmente executadas de forma muito eficiente pelo computador, pois muitos processadores implementam diretamente as operações binárias.

Detalham-se de seguida, as operações binárias anteriormente enumeradas.

Operador de negação

Como o nome sugere, a operação de negação consiste na troca bit a bit, sendo que um bit a 1 é convertido para um bit a 0, e vice-versa. Na linguagem C, a operação de negação (not na designação anglo-saxónica) é representada pelo operador ~ (tilde). O operador de negação é dito unário, porque apenas requer um operando. Na Listagem 5, o operador de negação binária é empregue para atribuir à variável out o resultado da negação do conteúdo da variável in, isto é, a negação de 0x012345678, resultando no valor 0xfedcba98 conforme mostrado na Listagem 6.

#include <stdio.h>

int main(void){
 unsigned int in = 0x01234567;
 unsigned int out;
 out = ~in;
 printf("in: %x\n", in);
 printf("out: %x\n", out);
 return 0;
}
Listagem 5: exemplo not_binario.c
in: 1234567
out: fedcba98
Listagem 6: resultado da execução de not_binario.c

Operador AND binário

O operador “e” binário, também designado por operador de conjunção. É ainda conhecido pela sua designação anglo‑saxónica and, sendo identificado na linguagem C através do símbolo &. O operador requer dois operandos. A tabela de verdade do operador (Tabela 3) mostra que uma operação de AND binário em que pelo menos um dos operandos é o bit 0 resulta sempre no resultado bit 0. Pelo contrário, se um dos operandos for bit a 1, então o resultado corresponderá ao bit do outro operando: será 1 se o bit do outro operando for 1 e 0 se o outro operando for 0. Essas características do and binário podem ser empregues para conhecer o valor de um determinado bit (and binário com um dos operandos a 1) ou para zerar um determinado bit (and binário com um dos operandos a 0). A Listagem 7 apresenta código em linguagem C onde é efetuada a operação and binário entre os valores numéricos 0x12 (0001.0010 em binário) e 0x0F (0000.1111). A operação produz o resultado binário 0000.0010 (0x02 em hexadecimal), correspondendo ao and binário entre cada bit homólogo dos dois operandos 0x12 e 0x0F.

Tabela 3: tabela de verdade do operador and (&)
and binário (&)01
000
101
/* Exemplo: "and_binario.c" */
#include <stdio.h>

int main(void){
 int a = 0x12; /* 0001.0010b, 18 base 10 */
 int b = 0x0F; /* 0000.1111b, 15 base 10 */ 
 int c;
 c = a & b; /* and binario */
 /* 0001.0010 & 0000.1111 => 0000.0010 */
 printf("c = %d & %d => %x\n", a, b, c);
 return 0;
}
Listagem 7: exemplo and_binario.c

É importante distinguir o operador and binário do operador and lógico no contexto da linguagem C. Em termos de representação na linguagem C, o primeiro é representado pelo símbolo &, ao passo que o operador and lógico requer dois símbolos & (&&). No que respeita à funcionalidade, o operador and lógico (&&) trata os operandos como entidades lógicas, isto é, tendo um valor verdadeiro ou falso, usualmente designado de booleano, e não bit a bit como sucede com o operador and binário. Assim, por exemplo, na expressão if( (a==0) && (b==2)){…}, a mesma será considerada verdadeira apenas se o valor da variável a for 0 e se o valor da variável b for 2, isto é, se ambas as operações a==0 e b==2 tiverem valor lógico verdadeiro. Se qualquer uma das expressões for falsa, ou ambas, então o resultado do and lógico é falso. A Tabela 4 mostra a tabela de verdade do operador and lógico.

and lógico (&&)Verd.Falso
Tabela 4: tabela de verdade do operador and lógico (&&)
Verd.Verd.Falso
FalsoFalsoFalso

A Listagem 8 efetua a operação de and lógico sobre as condições a==0 e b==2 atribuindo o valor resultante da operação à variável inteira result. Da análise do resultado da execução do código (Listagem 9), verifica-se que, na linguagem C, o valor lógico verdadeiro é mapeado para o valor inteiro 1 (um), e o valor lógico falso para o valor inteiro 0 (zero). Esse mapeamento mantém-se mesmo com o aparecimento do tipo de dados bool_t com a norma C99.

/* Exemplo: "and_logico.c" */
#include <stdio.h>
int main(void){
 int a = 0;
 int b = 2;
 int result;
 /* Condicao verdadeira */
 result = ((a==0) && (b==2));
 printf("verdadeiro => %d\n", result);
 /* Condicao falsa */
 result = ((a==0) && (b==3));
 printf("falso => %d\n", result);
 return 0;
}
Listagem 8: exemplo and_logico.c
verdadeiro => 1
falso => 0
Listagem 9: resultado da execução de and_logico.c

Publicado na edição 50 (PDF) da Revista PROGRAMAR.