Pular para o conteúdo

Hardware e sistemas operacionais · parte 2 de 27

Inteiros, complemento de dois e overflow: quando o número dá a volta

Como o complemento de dois guarda números negativos, por que o overflow dá a volta e como C, Go, Java e Python tratam o estouro na prática.

iniciantePublicada em · 35 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Inteiros, complemento de dois e overflow: quando o número dá a volta

O hodômetro de um carro antigo tem cinco rodinhas com dígitos. Quando ele marca 99999 km e o carro anda mais um quilômetro, o painel mostra 00000. Ninguém conclui que o carro ficou zero quilômetro novo. O painel simplesmente não tem mais uma rodinha para mostrar o número, então a contagem recomeça do zero. O computador faz a mesma coisa com inteiros, com uma diferença: as rodinhas são bits, e o painel tem 8, 16, 32 ou 64 delas. Quando a conta passa do que cabe, o resultado não vira erro. Vira outro número, que pode até ser negativo.

Esta aula responde duas perguntas. A primeira é como um padrão de bits representa um número negativo, e por que a solução adotada pelos processadores modernos é a menos intuitiva de todas. A segunda é o que acontece quando a soma não cabe, o que os engenheiros chamam de overflow, e por que cada linguagem trata esse caso de um jeito. Pré-requisito: binário e hexadecimal, vistos em Bits e binário. A aula também usa a ideia de porta lógica da aula Álgebra booleana e portas lógicas para montar o somador, e os números de ponto flutuante, que têm outra regra, ficam em Ponto flutuante.

Mapa da aula

A aula começa com os inteiros sem sinal, que são o caso mais simples. Depois vêm as três maneiras clássicas de marcar um sinal, sinal e magnitude, complemento de um e complemento de dois, com o motivo de a última ter vencido. Em seguida vêm as faixas de cada tamanho de inteiro, a extensão de sinal, o somador com as flags, o overflow e o wraparound, a multiplicação e a divisão por deslocamento, o comportamento de cada linguagem e três casos reais: a Ariane 501, os contadores de 32 bits e o ano 2038. Fecham a aula os erros comuns, os exercícios com resposta, o glossário, as fontes e a lista do que não foi confirmado.

1. Sem sinal: todo bit é potência de dois

Quando um número não pode ser negativo, a regra é direta. Cada bit vale uma potência de dois, e o número é a soma dos bits que estão acesos. Com quatro bits, 0101 vale 4 + 1, ou seja, 5. O maior valor é 1111, que vale 15, e com n bits o maior valor sem sinal é 2 elevado a n menos 1. Com oito bits, a faixa vai de 0 a 255. Com 32 bits, vai de 0 a 4.294.967.295.

Em C e Go existem tipos sem sinal com nome próprio (unsigned int e uint32). Java não tem tipo sem sinal para inteiros comuns: o int é sempre com sinal, e para ler o mesmo padrão como sem sinal ele usa métodos como Integer.toUnsignedString. Em Python, todo int tem sinal. A regra que importa aqui vale para os três: um número sem sinal de n bits só guarda valores entre 0 e 2 elevado a n menos 1. Qualquer valor fora dessa faixa é reduzido módulo 2 elevado a n, e é aqui que começa o nosso problema.

O programa a seguir mostra os limites em C, usando os tipos de tamanho fixo de stdint.h, e a volta do contador. O código foi compilado com clang no macOS e executado nesta sessão.

uint8_t u = 255;
u += 1;
printf("uint8 255 + 1 = %u (volta ao zero)\n", u);

Saída real: uint8 255 + 1 = 0 (volta ao zero). O número 256 não cabe em oito bits, e o que sobra é 256 módulo 256, que é zero. Essa é a regra do sem sinal, e ela é definida pela linguagem. O comportamento de C para o sem sinal é previsível, porque a linguagem define essa aritmética como módulo 2 elevado a n (a norma do C não foi lida nesta sessão, e isso está na lista do que não foi confirmado). O caso com sinal é diferente, e a seção 8 volta a ele.

2. Para que serve: marcar o sinal com um bit

Um computador que só soma números positivos é pouco útil. Temperatura, saldo, altitude, uma coordenada no eixo x: quase tudo que se mede pode ser negativo. A pergunta é onde colocar o sinal. A resposta mais simples é reservar um bit para isso. Se o bit mais alto é 0, o número é positivo, e se é 1, é negativo. Essa é a ideia do sinal e magnitude, a primeira solução que aparece quando alguém tenta representar negativos em binário.

A ideia tem um problema escondido, e ele aparece na hora de somar. Com sinal e magnitude, somar dois números de sinais diferentes exige comparar as magnitudes, subtrair a menor da maior e decidir o sinal do resultado. Ou seja, a soma precisa de um circuito diferente para cada combinação de sinais. Engenheiros de computador gostam de circuitos que fazem uma coisa só. Os próximos dois sistemas nasceram dessa busca.

3. Exemplos no plural: onde o sinal aparece

Um contador de ponto de jogo é um bom exemplo de número que sobe e desce. Ele começa em zero, soma quando o jogador acerta e subtrai quando erra, e o placar pode ficar negativo. Um saldo bancário é outro caso, com a diferença de que ninguém quer que a soma dê a volta sem aviso. E uma coordenada de tela ou de mapa tem negativos porque a origem fica no meio, não na borda. Os três usam o mesmo mecanismo por baixo, mas cada um precisa de um tratamento diferente quando o número passa do limite, e isso é o assunto das seções 6 e 9.

Vale notar que os processadores não sabem qual é o contexto. O mesmo padrão 11111111 pode significar 255 num pixel de imagem e -1 num contador de placar. Quem decide o significado é o programa, pelo tipo declarado. Essa separação entre bits e significado vai reaparecer em Texto, ASCII, Unicode e UTF-8, com caracteres no lugar de números.

4. Como funciona por dentro: complemento de um e de dois

A terceira tentativa é o complemento de um. Para representar um número negativo, você inverte todos os bits do número positivo correspondente. Com quatro bits, 5 é 0101, e -5 é 1010. O bit mais alto continua funcionando como sinal, e a soma também fica mais simples, porque a mesma porta somadora pode ser usada para números de qualquer sinal, com um ajuste no vai-um que volta ao início. O problema é que o zero ganha duas representações. 0000 é +0, e invertendo esse padrão, 1111 é -0. Um processador que compara dois números precisa tratar +0 e -0 como iguais, o que é mais uma regra no circuito e mais uma fonte de bugs.

A quarta tentativa, o complemento de dois, resolve os dois problemas. Para representar -n, você inverte os bits de n e soma 1. Com oito bits, 5 é 00000101. Invertendo, temos 11111010. Somando 1, dá 11111011, que é o padrão de -5. A conta mostrada pelo programa de verificação da aula confirma o passo a passo:

print(format(c2(-5, 8), '08b'))   # complemento de dois de -5

Saída real: 11111011. Repare que a versão com sinal e magnitude daria 10000101, e a de complemento de um daria 11111010. Os três padrões têm o bit mais alto aceso, mas só o complemento de dois, quando somado ao 5 de 00000101, dá exatamente zero com o vai-um saindo para fora do byte: 00000101 + 11111011 = 100000000, e os oito bits de baixo valem zero. Essa é a propriedade que faz o complemento de dois vencer.

A regra de leitura fica mais clara com a fórmula. Num padrão de n bits, o bit mais alto vale -2 elevado a n menos 1, e os outros valem como sempre. Com n igual a 8, o padrão 11111011 vale -128 + 64 + 32 + 16 + 8 + 0 + 2 + 1, que dá -5. Essa fórmula explica a faixa assimétrica que aparece na seção 5: há um negativo a mais do que positivo, porque o zero ocupa uma posição do lado positivo.

A figura a seguir mostra os 16 padrões de quatro bits dispostos em anel. O sentido horário corresponde a somar 1, e o salto de 0111 para 1000 é justamente a passagem de 7 para -8, o overflow de um número com sinal.

Os 16 padrões de 4 bits em anel, lidos em complemento de dois: somar 1 anda no sentido horário, e 0111 (7) passa direto para 1000 (-8).
figura 1 · Os 16 padrões de 4 bits em anel: somar 1 anda no sentido horário

Olhe o anel como um relógio sem ponteiro. Os valores positivos ocupam o meio superior, e os negativos ocupam o meio inferior, com o -8 logo depois do 7. Quem anda no sentido horário a partir de 0000 sobe até 0111 e, no passo seguinte, cai direto para -8. A analogia do relógio tem um limite claro: o relógio não tem valor negativo, e o anel é justamente a forma de dizer que os padrões de bits são um relógio com 16 posições, cujas metades foram rotuladas como positivas e negativas.

Um ponto que costuma confundir é o que a soma realmente faz. Com complemento de dois, o processador soma os padrões como se fossem sem sinal, e o resultado está certo para as duas interpretações ao mesmo tempo. Somar 5 (0101) e -3 (1101) dá 10010 com quatro bits mais um vai-um para fora. Descartando o vai-um, sobra 0010, que é 2, e 5 menos 3 é 2. O mesmo circuito serve para positivos e negativos, e é justamente isso que o complemento de dois oferece e que o sinal e magnitude não oferece.

5. Faixas, extensão de sinal e o somador

Com n bits em complemento de dois, a faixa é de -2 elevado a n menos 1 até 2 elevado a n menos 1 menos 1. Os números abaixo saem do programa de verificação e do cabeçalho stdint.h do compilador, e o trecho de C foi executado nesta sessão:

printf("int8   %d .. %d\n", INT8_MIN, INT8_MAX);
printf("int16  %d .. %d\n", INT16_MIN, INT16_MAX);
printf("int32  %d .. %d\n", INT32_MIN, INT32_MAX);

Saída real: int8 -128 .. 127, int16 -32768 .. 32767, int32 -2147483648 .. 2147483647. Para 64 bits, a faixa vai de -9223372036854775808 a 9223372036854775807, e as linguagens expõem esses limites como constantes (math.MaxInt64 em Go e Long.MAX_VALUE em Java). A faixa de 8 bits é a que mais aparece em exemplos, e o erro mais comum com ela é imaginar que é simétrica. Existe -128, mas o +128 não cabe, porque o zero ocupa uma posição do lado positivo.

Um detalhe de implementação resolve um problema prático. Quando um número de 8 bits precisa virar um número de 32 bits, o processador não pode simplesmente acrescentar zeros à esquerda, porque isso transformaria -5 em 251. O procedimento correto é copiar o bit de sinal para todas as posições novas. Esse procedimento se chama extensão de sinal, e o programa abaixo o demonstra com -5:

int8_t neg = -5;
int32_t estendido = neg;
uint8_t bits = (uint8_t)neg;
printf("int8 -5 -> bits 0x%02X; estendido para 32 bits 0x%08X\n", bits, (uint32_t)estendido);

Saída real: int8 -5 -> bits 0xFB; estendido para 32 bits 0xFFFFFFFB. A figura mostra a mesma operação com os bits desenhados:

Extensão de sinal do int8 -5, 11111011, para 32 bits: o bit de sinal 1 é copiado para as 24 posições novas e o valor não muda.
figura 2 · Extensão de sinal: o int8 -5 ganha 24 bits 1 à esquerda para caber em 32

A figura lê da esquerda para a direita. Os oito bits de -5 ocupam a caixa da esquerda, com o bit a7 aceso, que é o sinal. A caixa da direita tem quatro bytes, e os três de cima são FF, porque o sinal foi copiado para as posições novas. O byte de baixo continua FB, que são exatamente os oito bits originais. Repare que a extensão de sinal não muda o valor, só o tamanho. Um número positivo estende com zeros, e um negativo com uns, e é por isso que a conversão de um tipo menor para um maior quase sempre é segura. A conversão contrária, de um tipo maior para um menor, é o que quebra.

O somador de n bits

A soma de dois números de n bits é feita por uma cadeia de somadores completos, um por bit. Cada um recebe o bit de A, o bit de B e o vai-um que veio da coluna anterior, e devolve o bit da soma e um novo vai-um. A figura seguinte mostra a cadeia de quatro bits somando 0111 com 0001:

Somador de 4 bits em cadeia: o vai-um percorre do bit 0 ao bit 3 e transforma 0111 mais 0001 em 1000.
figura 3 · Somador de 4 bits em cadeia: o vai-um desce do bit 0, no topo, até o bit 3

Siga a conta pela figura. No bit 0, 1 mais 1 dá 0 com vai-um 1. No bit 1, 1 mais 0 mais o vai-um dá 0 com vai-um 1. O bit 2 faz a mesma coisa, e no bit 3, 0 mais 0 mais o vai-um dá 1 com vai-um 0. O resultado é 1000, que em decimal é 8, e 7 mais 1 é 8. Note que o vai-um atravessou três colunas antes de morrer, e num somador de 64 bits ele pode atravessar 63 colunas. Por isso o atraso de um somador em cadeia cresce com o tamanho, e processadores reais usam atalhos para antecipar esse vai-um. Esses atalhos ficam para a fase 3, em Como a CPU executa instruções.

A figura 09 mostra o mesmo somador em nível de portas, para um único bit. Ele tem cinco portas, duas XOR, duas E e uma OU, e recebe o vai-um de entrada de baixo:

Somador completo de um bit com duas XOR, duas E e uma OU: A, B e o vai-um de entrada geram a soma S e o vai-um de saída.
figura 4 · Somador completo de um bit com portas: A = 1, B = 1, vai-um 0 dá soma 0 e vai-um

O caminho do bit da soma sai pela XOR 1 e pela XOR 2, e o caminho do vai-um sai pela E 1, pela E 2 e pela OU. A figura não mostra valores, então vale seguir a conta de 1 mais 1 mais 0: a XOR 1 dá 0, a E 1 dá 1, a XOR 2 com o 0 parcial e o vai-um 0 dá soma 0, e a OU recebe 1 da E 1 e 0 da E 2, e devolve vai-um 1. O resultado é 10 em binário, que é 1 mais 1. As duas E nunca acendem juntas: se A e B valem 1, a XOR 1 dá 0, e a E 2 recebe esse 0. Por isso a OU nunca recebe dois 1 ao mesmo tempo, e num somador completo ela poderia ser trocada por uma XOR sem mudar o resultado. A OU é a escolha usual porque descreve o vai-um com mais clareza. Os fios cruzados da figura são inevitáveis nesse desenho, e são a parte que mais confunde quem lê um somador pela primeira vez.

As flags: carry, overflow e NZCV

O processador não guarda o resultado da soma sem mais nada. Ele também acende algumas flags num registrador de estado, e as que importam aqui são quatro. N (negativo) copia o bit mais alto do resultado. Z (zero) acende quando todo o resultado é zero. C (carry) acende quando o vai-um sai do bit mais alto, e serve para números sem sinal. V (overflow) acende quando um número com sinal não coube, e serve para números com sinal. Os nomes são os do ARM, e o trecho a seguir usa a instrução adds, que soma e atualiza as flags, para observar os valores reais:

__asm__ volatile(
    "adds %w0, %w2, %w3\n\t"   /* ADDS: soma e atualiza N, Z, C, V */
    "mrs  %1, nzcv"            /* lê o registrador de flags */
    : "=&r"(r), "=r"(nzcv)
    : "r"(a), "r"(b)
    : "cc");

Saída real, rodada no ARM64 do Mac nesta sessão:

sem estouro              0x00000005 + 0x00000003 = 0x00000008   N=0 Z=0 C=0 V=0
carry sem sinal          0xFFFFFFFF + 0x00000001 = 0x00000000   N=0 Z=1 C=1 V=0
overflow com sinal       0x7FFFFFFF + 0x00000001 = 0x80000000   N=1 Z=0 C=0 V=1
negativo + negativo      0x80000000 + 0x80000000 = 0x00000000   N=0 Z=1 C=1 V=1

Leia as linhas com calma. Na segunda, o carry acende porque o resultado sem sinal, 4294967296, não cabe em 32 bits. Na terceira, o overflow acende porque 2147483647 mais 1 dá um número com sinal que não cabe. E na quarta os dois acendem, porque somar dois negativos com sinal dá um positivo, e o vai-um saiu do bit mais alto. Essas duas flags são independentes, e a mesma soma pode ter uma, a outra ou as duas.

A figura de flags mostra a segunda e a terceira linhas, cada uma com a faixa de quatro flags destacada:

Flags NZCV de duas somas de 32 bits: 0xFFFFFFFF mais 1 acende Z e C, e 0x7FFFFFFF mais 1 acende N e V.
figura 5 · Flags NZCV em duas somas de 32 bits: cada soma acende um par diferente

A figura tem duas caixas empilhadas, uma por soma, e cada uma mostra o valor dos quatro bits de flag. Na de cima, Z e C estão acesos, o que diz que o resultado foi zero e que a soma sem sinal estourou. Na de baixo, N e V estão acesos, o que diz que o resultado tem o sinal trocado e que a soma com sinal estourou. A regra para o overflow com sinal, usada pelo somador em Python da aula, é mais curta do que parece: houve overflow quando as duas parcelas têm o mesmo sinal e o resultado tem sinal diferente. Esse é o resumo da figura de decisão da seção 6.

O programa de Python confere a mesma regra com o somador bit a bit, e a saída real é esta:

  100 (01100100) +  28 (00011100) = 10000000 sem sinal=128 (carry=0), com sinal=-128 (overflow=1)
  200 (11001000) + 100 (01100100) = 00101100 sem sinal=44 (carry=1), com sinal=44 (overflow=0)
  128 (10000000) + 128 (10000000) = 00000000 sem sinal=0 (carry=1), com sinal=0 (overflow=1)

Repare nas três linhas. Em 100 mais 28, o resultado sem sinal, 128, ainda cabe, mas com sinal dá -128, e a flag de overflow acende. Em 200 mais 100, o carry acende, mas o overflow não, porque, com sinal, 200 vale -56, e -56 mais 100 dá 44, que cabe na faixa. E em 128 mais 128, os dois acendem. A diferença entre carry e overflow é justamente a diferença entre “o número sem sinal não coube” e “o número com sinal não coube”, e a mesma soma pode responder de forma diferente conforme o que o programa quer dizer com ela.

6. Overflow e wraparound

Overflow é o nome do fenômeno em que o resultado de uma operação não cabe no tipo. Wraparound é o nome do comportamento que o hardware produz quando isso acontece: o valor dá a volta na faixa e continua a partir do outro extremo. Os dois termos são usados como sinônimos no dia a dia, mas vale separar. O overflow é a condição, e o wraparound é a consequência que a linguagem escolhe, ou não, definir.

Em 8 bits com sinal, somar 1 a 127 dá -128, que é o menor valor da faixa. O número deu a volta. O programa de C mostra o caso com a função de soma que devolve a informação de estouro:

int8_t s;
bool estourou = __builtin_add_overflow((int8_t)127, (int8_t)1, &s);
printf("int8 127 + 1 = %d, estourou=%s\n", s, estourou ? "sim" : "nao");

Saída real: int8 127 + 1 = -128, estourou=sim. O resultado já vem reduzido, e a função devolve a informação de que houve estouro. Esse é o padrão que se deseja para somas que não podem falhar em silêncio.

A figura de decisão mostra como detectar o overflow com sinal sem depender de nenhuma função especial, só com a regra das parcelas e do resultado:

Fluxo para detectar overflow com sinal: se as parcelas têm o mesmo sinal e o resultado tem sinal diferente, a soma estourou.
figura 6 · Regra para saber se uma soma com sinal estourou

A figura começa na soma de a e b e pergunta primeiro se as duas parcelas têm o mesmo sinal. Se não têm, a soma nunca estoura, porque um positivo e um negativo se aproximam de zero e o resultado cabe. Se têm, a pergunta passa a ser se o resultado tem sinal diferente do das parcelas. Se tem, houve overflow, porque somar dois positivos não pode dar negativo sem dar a volta, e somar dois negativos não pode dar positivo. O losango de baixo é o que mais importa, porque é o teste que se escreve no código.

A figura do dente de serra mostra o mesmo fenômeno em tempo contínuo, com uma entrada que cresce de 0 até 70000 e o valor guardado em 16 bits com sinal:

Valor guardado em int16 para uma entrada que cresce: sobe até 32767, cai para -32768 e volta a subir, formando um dente de serra.
figura 7 · Entrada crescente guardada em int16: a saída sobe até 32767 e volta a -32768

Acompanhe a linha rosa da esquerda para a direita. Ela sobe em reta até 32767, que é o máximo, e no ponto seguinte cai para -32768, que é o mínimo, e segue subindo. Os dois marcadores laranja mostram 32767 e 40000. O segundo cai, depois de dar a volta, em -25536, que é 40000 menos 65536. A figura confirma uma regra simples: a cada 65536 unidades que a entrada anda, a saída percorre a faixa inteira uma vez. Essa é a forma matemática do wraparound, e o programa Go da aula chega ao mesmo número por outro caminho, com a conversão explícita int16(40000).

O último caso é o que acontece com os bits. Somar 1 a 0x7FFF, o maior int16, muda vários bits de uma vez, e isso é o que torna o overflow tão difícil de ver num depurador:

Somar 1 a 0x7FFF em 16 bits dá 0x8000: os bits baixos viram zero, o bit de sinal acende e o valor vai de 32767 para -32768.
figura 8 · Somar 1 a 0x7FFF em 16 bits: os bits baixos viram zero e o bit de sinal acende

O valor 0x7FFF tem o bit de sinal apagado e todos os outros quinze acesos. Somar 1 faz o vai-um percorrer os quinze bits, apagando cada um, até chegar ao bit de sinal e acendê-lo. O resultado, 0x8000, é o menor valor da faixa. Repare no detalhe que muita gente esquece: o bit de sinal foi aceso pelo vai-um, e não por um bit “de propósito”, então o valor parece sem sentido quando lido no hexadecimal, mas é exatamente -32768 em complemento de dois.

7. Multiplicar e dividir com deslocamento

Um deslocamento para a esquerda de uma casa multiplica o número por 2. Um deslocamento de k casas multiplica por 2 elevado a k. Essa é a razão de o processador usar deslocamento para multiplicar por potências de dois: é uma operação de fiação, sem circuito de multiplicação. Para multiplicar por 10, por exemplo, basta somar o número deslocado 3 casas com o número deslocado 1 casa, porque 8 mais 2 dá 10. A figura abaixo faz a conta com o número 13:

Multiplicar 13 por 10 com deslocamentos: 13 deslocado 3 casas (104) somado a 13 deslocado 1 casa (26) dá 130.
figura 9 · Multiplicar 7 por 10 com deslocamentos em 8 bits: 56 mais 14 dá 70

Confira os valores. 13 é 00001101. Deslocado 3 casas para a esquerda vira 01101000, que é 104. Deslocado 1 casa vira 00011010, que é 26. A soma 104 mais 26 dá 130, que é 13 vezes 10. A conta tem dois termos e uma soma, e um processador faz essa soma com o mesmo somador da seção 5.

O cuidado está no overflow. Um deslocamento para a esquerda que empurra um bit para fora do tipo é um wraparound sem aviso. Por exemplo, em 8 bits, 13 deslocado 4 casas dá 208, e como o int8 só vai até 127, o resultado vira -48. Quem multiplica por potências de dois precisa verificar a faixa antes, ou usar um tipo maior.

O deslocamento para a direita divide por 2, mas há uma diferença importante para números negativos. O deslocamento aritmético copia o bit de sinal para a esquerda, e por isso arredonda para baixo, em direção a menos infinito. A divisão inteira das linguagens comuns, por outro lado, trunca em direção a zero. Os dois resultados diferem quando o número é negativo e não é divisível. A conta de -5 mostra o problema:

print(-5 >> 1)        # deslocamento aritmético
print(int(-5 / 2))    # divisão truncada, como C, Go e Java

Saída real: -3 para o deslocamento e -2 para a divisão truncada. A especificação do Go diz o mesmo com outras palavras: a divisão inteira trunca em direção a zero, e o deslocamento é aritmético quando o operando é com sinal. Quem usa shift para dividir um número que pode ser negativo está, sem saber, trocando a regra de arredondamento. Esse é um erro comum, e a próxima seção o coloca na tabela de linguagens.

8. Comportamento por linguagem

Cada linguagem decide o que o overflow significa para ela, e essa decisão é uma das coisas que mais variam entre elas. A tabela abaixo resume o comportamento antes de cada caso, e os programas da aula executam o mesmo teste em cada uma.

Em C, o overflow em aritmética com sinal é comportamento indefinido. A norma da linguagem diz, no §6.5 parágrafo 5 do rascunho N1570 do C11, que uma condição excepcional na avaliação de uma expressão torna o comportamento indefinido quando o resultado não está na faixa do tipo. Isso quer dizer que o compilador pode assumir que o overflow nunca acontece, e otimizar o código com base nessa suposição. A consequência prática é que um teste como if (x + 1 < x) pode ser removido pelo compilador, porque ele conclui que x + 1 nunca é menor que x. O overflow sem sinal, por outro lado, é definido como módulo 2 elevado a n, e é seguro usar. Esse parágrafo do N1570 não foi possível ler nesta sessão, porque o PDF não devolveu texto legível, e a referência fica como indicação, não como conferência.

Em Go, a especificação define o overflow com sinal como um valor determinístico, o complemento de dois com a volta. O programa a seguir executa os casos com variáveis, não com constantes, porque uma constante estourada é recusada pelo compilador:

var i8 int8 = 127
i8++
fmt.Println("int8 127 + 1 =", i8)
var zero uint32
fmt.Println("uint32 0 - 1 =", zero-1)
grande := 40000
fmt.Println("int16(40000) =", int16(grande))

Saída real: int8 127 + 1 = -128, uint32 0 - 1 = 4294967295 e int16(40000) = -25536. Go nunca entra em pânico com overflow de inteiro, e o deslocamento de um negativo preserva o sinal. Repare que a especificação também trata a divisão do menor valor por -1 como um caso de overflow que devolve o próprio dividendo, sem erro. Isso aparece no exercício 4.

Em Java, o tipo int tem 32 bits com complemento de dois, e o overflow é silencioso. A linguagem dá uma saída para quem quer detectar o problema, que é a família de métodos Math.xxxExact, que lança exceção quando o resultado não cabe. A saída real do programa de teste foi:

Integer.MAX_VALUE + 1 = -2147483648
Math.addExact(MAX, 1) lança: integer overflow
(short) 40000 = -25536

Python é o caso oposto. Um int do Python tem precisão arbitrária: o interpretador cresce o número conforme a necessidade, e não existe overflow no sentido de C ou Go. O custo é que a aritmética é mais lenta e que o número ocupa mais memória quando cresce. Bibliotecas que usam arrays de tipo fixo, como o NumPy, reintroduzem o problema, e nesse caso o overflow volta a dar a volta sem aviso. Esse ponto é descrito pelo modelo de dados do Python e não foi medido nesta aula.

A tabela resume os quatro casos:

LinguagemOverflow com sinalComo detectarObservação
CComportamento indefinidoFunções de verificação do compilador, como __builtin_add_overflowO otimizador pode apagar testes de overflow
GoDá a volta, de forma determinísticaTestar a faixa antes da operaçãoConstante estourada é erro de compilação
JavaDá a volta em silêncioMath.addExact e famíliaLança ArithmeticException
PythonNão existe, o número cresceNão é precisoBibliotecas de tipo fixo podem dar a volta

9. Casos reais: Ariane 501, contadores e o ano 2038

O caso mais estudado de overflow em sistema real é o do foguete Ariane 5, da Agência Espacial Europeia, em 4 de junho de 1996. O voo inaugural perdeu o controle cerca de 40 segundos depois do início da sequência de decolagem. O comunicado da ESA, de 23 de julho de 1996, apresenta o relatório da comissão de investigação e diz que a orientação e as informações de atitude foram perdidas 37 segundos após o início da ignição dos motores principais, o que correspondeu a 30 segundos após a decolagem. O mesmo comunicado atribui a falha a erros de especificação e de projeto no software do sistema de referência inercial, e informa que os dois sistemas de referência inercial falharam ao mesmo tempo.

A parte que a ESA não descreve no comunicado é a que mais aparece em resumos populares: a conversão de um número de ponto flutuante de 64 bits para um inteiro de 16 bits com sinal, que estourou. Essa informação, com o nome de uma função e o papel do computador de bordo, vem de descrições públicas que citam o relatório da comissão de Jacques-Louis Lions. Eu não consegui conferir esse detalhe no relatório primário nesta sessão, e a figura a seguir descreve a sequência com essa ressalva.

Linha do tempo da Ariane 501: a conversão de 64 para 16 bits falha, os dois módulos param e o computador de bordo comanda os bocais.
figura 10 · Ariane 501: uma conversão fora da faixa vira diagnóstico lido como voo

Leia a figura de cima para baixo. A conversão acusa um valor que não cabe em 16 bits com sinal. A referência inercial, que tem dois módulos iguais, entra em exceção nos dois, e para. O diagnóstico que sai desses módulos é lido pelo computador de bordo como se fosse dado de voo, e os bocais dos motores recebem um comando de deflexão total. Essa parte, a do computador de bordo interpretando o diagnóstico, é a que a ESA não confirma no comunicado, e a figura a apresenta como a descrição pública. O que a figura ensina é o padrão, que apareceu de novo em outros sistemas: um erro de conversão que deveria ser tratado vira um dado que o restante do sistema aceita como verdadeiro.

A lição de overflow dessa história é a conversão. Um número de 64 bits com ponto flutuante pode ter um valor que não cabe em 16 bits com sinal, e a conversão precisa de uma checagem explícita antes de acontecer. Uma faixa de int16 vai de -32768 a 32767, e o programa de Go da aula mostra o que a conversão sem checagem faz com 40000: devolve -25536 sem nenhum aviso.

Os contadores de 32 bits são o segundo caso, e ele é mais comum do que parece. Um contador sem sinal de 32 bits guarda até 4.294.967.295. Se ele recebe mil eventos por segundo, dá a volta em cerca de 49,7 dias. A conta é simples: 2 elevado a 32 dividido por 1000 por segundo dá 4.294.967 segundos, que são 49,7 dias. Um contador que mede tempo de atividade, bytes transferidos ou quantidade de requisições pode dar a volta sem que ninguém perceba, e a métrica passa a mostrar um valor menor do que o anterior. Quem monta painéis precisa tratar esse caso, seja com um tipo de 64 bits, seja com a regra de que um valor menor que o anterior significa reinício ou volta.

O terceiro caso é o ano 2038. Muitos sistemas Unix guardam o tempo como o número de segundos desde 1º de janeiro de 1970, em um inteiro de 32 bits com sinal. O maior valor representável é 2147483647 segundos, e ele corresponde a 2038-01-19 03:14:07 UTC. No segundo seguinte, o valor dá a volta para -2147483648, que corresponde a 1901-12-13 20:45:52 UTC. Os programas da aula calculam as duas datas nas duas linguagens, com a saída real:

Go:   maior tempo em int32: 2038-01-19 03:14:07 +0000 UTC
Go:   depois do estouro:   1901-12-13 20:45:52 +0000 UTC
Java: int32 em 2038: 2038-01-19T03:14:07Z
Java: depois do estouro: 1901-12-13T20:45:52Z

A data é conhecida e não depende de nenhum evento: é uma consequência aritmética da escolha de 32 bits. A consequência prática é que sistemas que ainda guardam o tempo nesse formato vão tratar a data de 2038 como uma data de 1901. Muitos sistemas novos guardam o tempo em 64 bits, com uma data limite muito mais distante, mas o código antigo e os formatos de arquivo com campo de 32 bits continuam em uso.

10. Erros comuns e boas práticas

O primeiro erro, e o mais comum, é achar que o tipo é grande o bastante. Quase todo mundo esquece uma vez que 255 mais 1 vira zero num byte, ou que 127 mais 1 vira -128 num int8. Quem diz que nunca esqueceu ainda não passou por uma depuração de contador às três da manhã. A boa prática é escolher o tipo pela faixa do dado com margem, e não pelo que cabe hoje. Um campo de idade cabe em 8 bits, mas um campo de quantidade de itens de uma venda pode crescer, e um identificador de transação quase sempre precisa de 64 bits.

O segundo erro é comparar um número sem sinal com zero esperando que ele fique negativo. Em C, unsigned int i = n - 1; if (i >= 0) é sempre verdadeiro, porque um número sem sinal nunca é negativo. Se n for zero, a subtração dá o maior valor do tipo, e o laço que parecia terminar nunca termina. A regra prática é que um índice de laço com decremento deve ser um tipo com sinal, ou o teste deve ser feito antes da subtração.

O terceiro erro é achar que o overflow sempre dá erro. Em Java e em Go ele passa em silêncio, e em C ele torna o programa indefinido, o que é pior porque o compilador pode mudar o comportamento de forma imprevisível. A boa prática é checar a faixa antes da operação quando o dado vem de fora, ou usar as funções de detecção que a linguagem oferece. Em Java, Math.addExact faz exatamente isso, e em C, __builtin_add_overflow devolve a informação de estouro sem depender do comportamento indefinido.

O quarto erro é usar deslocamento para dividir um número que pode ser negativo. O resultado difere da divisão truncada, e o erro aparece só com números negativos e ímpares, que são justamente os que costumam faltar nos testes. A boa prática é usar a divisão da linguagem e deixar o deslocamento para os casos em que a intenção é mexer nos bits.

O quinto erro é a conversão sem checagem, que é o caso da Ariane. Converter um valor maior para um tipo menor trunca os bits sem aviso, e o valor que sobra pode ser um número sem relação com o original. A boa prática é validar a faixa na fronteira, no ponto em que o dado entra no sistema, e não espalhar conversões pelo código.

O sexto erro é tratar o tempo como inteiro de 32 bits. Se a aplicação vai rodar por décadas, ou se o formato será lido por outros sistemas, o tipo precisa cobrir o período inteiro. A boa prática é usar 64 bits para tempo e para qualquer contador de longa duração, e escrever a escolha num comentário, para que a próxima pessoa saiba por que o tipo é grande.

11. Exercícios

  1. Escreva -7 em complemento de dois com oito bits, passo a passo: primeiro o padrão de 7, depois a inversão e por fim o mais 1. Confira o resultado somando os valores dos bits acesos com a fórmula do bit de sinal.
  2. Em um int8, some 127 e 1 e diga o valor e as flags C e V que um somador de 8 bits acenderia. Explique por que a soma sem sinal de 127 e 1 não dá overflow no sentido de carry.
  3. Multiplique 13 por 6 usando apenas deslocamentos e somas. Mostre quais deslocamentos você usou.
  4. Em Go, um int8 com o valor -128 dividido por -1 dá qual resultado? Consulte a regra da especificação citada na seção 8 e explique por que o resultado não é +128, que não cabe no tipo.

Respostas dos exercícios

  1. O 7 é 00000111. Invertendo os bits, temos 11111000. Somando 1, o resultado é 11111001. Para conferir, o bit de sinal vale -128, e os bits acesos valem 64, 32, 16, 8 e 1, que somam 121. Então -128 mais 121 dá -7.

  2. 127 é 01111111 e 1 é 00000001. A soma dá 10000000, que em complemento de dois é -128. Há overflow com sinal, então V acende, e a soma tem N aceso. O carry, C, é zero, porque não houve vai-um saindo do bit mais alto. Por isso a soma sem sinal de 127 e 1 não gera carry: o resultado 128 cabe em 8 bits sem sinal. O estouro só existe na leitura com sinal, e é essa leitura que a flag V registra.

  3. 6 é 4 mais 2. Deslocamos 13 duas casas para a esquerda, que dá 52, e uma casa, que dá 26. A soma 52 mais 26 é 78, que é 13 vezes 6.

  4. O resultado é -128. A especificação do Go diz que, para o menor valor do tipo, o quociente de x dividido por -1 é igual a x, por causa do overflow de complemento de dois. O valor +128 não cabe em int8, que vai até 127, então o resultado precisa ser o próprio dividendo. O programa não entra em pânico nesse caso, e o resto da divisão é zero.

Glossário

Complemento de dois: forma de representar inteiros com sinal em que o negativo de n é a inversão dos bits de n somada a 1. Só tem um zero e a soma usa o mesmo circuito para qualquer sinal.

Sinal e magnitude: forma de representar inteiros em que o bit mais alto marca o sinal e os demais guardam o valor absoluto. Tem dois zeros e exige circuito diferente para somar números de sinais diferentes.

Complemento de um: forma de representar inteiros em que o negativo é a inversão dos bits. Tem dois zeros, e por isso foi superada pelo complemento de dois.

Extensão de sinal: cópia do bit de sinal para as posições novas ao converter um inteiro para um tamanho maior.

Overflow: situação em que o resultado de uma operação não cabe no tipo.

Wraparound: comportamento em que o valor que passa do limite de um tipo volta para o outro extremo da faixa.

Vai-um (carry): bit que sai da coluna de um somador e entra na coluna seguinte.

Flags NZCV: quatro bits de estado do processador, com N para negativo, Z para zero, C para carry e V para overflow com sinal.

Deslocamento aritmético: deslocamento para a direita que copia o bit de sinal, e por isso arredonda números negativos para baixo.

Comportamento indefinido: situação em que a linguagem não define o resultado, e o compilador pode produzir qualquer comportamento. No C, o overflow com sinal é um caso desses.

Fontes

Consultadas em 1 de novembro de 2026.

  • Comunicado da ESA sobre o relatório da comissão de investigação do Ariane 501, datado de 23 de julho de 1996, em https://www.esa.int/Newsroom/Press_Releases/Ariane_501_-_Presentation_of_Inquiry_Board_report. Usado para a data do voo, os 37 segundos após o início da ignição, os 30 segundos após a decolagem e a falha simultânea dos dois sistemas de referência inercial.
  • Especificação da linguagem Go, na versão 1.27.2 instalada no Mac (go env GOROOT seguido de doc/go_spec.html), nas seções de tipos inteiros, operadores aritméticos, operadores de deslocamento e operadores inteiros. Usada para a aritmética sem sinal módulo 2 elevado a n, o overflow determinístico com sinal, a divisão truncada e o deslocamento aritmético.
  • Java Language Specification, SE 21, §4.2.2, em https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html. Usada para dizer que os operadores inteiros não indicam overflow.
  • Rascunho N1570 da norma C11, em https://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf. O PDF não devolveu texto legível nesta sessão, então a citação do §6.5 parágrafo 5 vem da indicação da pesquisa e não foi conferida no documento.
  • Programas executados nesta sessão, com as versões: clang no macOS para C com flags de ARM64, Go 1.27.2 no darwin/arm64, OpenJDK 21.0.8 para Java e Python 3.12.8 para o somador e a verificação numérica.

O que não foi confirmado ou medido

A citação da norma do C, §6.5 parágrafo 5 do N1570, não foi conferida no texto, porque o PDF não foi lido nesta sessão. A afirmação de que o compilador pode remover um teste de overflow é consequência do comportamento indefinido e não foi testada com nenhum compilador específico.

As flags CF e OF do x86 não foram conferidas no manual da Intel. A aula usa as flags NZCV do ARM64, que foram lidas diretamente do processador, e não afirma nada sobre o comportamento de CF e OF nas instruções de x86.

A conversão de 64 bits para int16 no caso da Ariane 501 não foi confirmada no relatório primário da comissão. A descrição de que o computador de bordo tratou o diagnóstico como dado de voo também vem de descrições públicas, e a figura a apresenta com essa ressalva. O relatório de Lions não foi consultado diretamente nesta sessão.

O comportamento de bibliotecas de tipo fixo em Python, como o NumPy, foi descrito a partir do conhecimento geral da área e não foi executado nesta aula.

A figura do dente de serra usa pontos amostrados para desenhar a curva e é ilustrativa. Os valores dos marcadores foram calculados pela aritmética, não medidos em um sistema real.

Nenhuma medida de desempenho foi feita. O custo de um somador em cadeia, citado na seção 5, é uma explicação qualitativa, sem tempo medido.

Os números de contadores (49,7 dias para 1000 eventos por segundo) e as datas do ano 2038 são contas e não observações. As datas foram conferidas pelos programas em Go e Java.

Relacionados

  • Trilha de Hardware
  • CPU
  • Bits e binário
  • Ponto flutuante
  • Texto, ASCII, Unicode e UTF-8
  • Álgebra booleana e portas lógicas
  • Como a CPU executa instruções

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?