Apuntes · Capítulo 3Fundamentos

Bits: cómo se representan los datos

Bits: cómo se representan los datos

En el capítulo anterior guardaron números y letras en variables de tipo int, double y char, y con sizeof vieron cuántos bytes ocupa cada una. Pero dejamos una pregunta sin responder: ¿qué hay realmente dentro de esos bytes?

Este capítulo responde esa pregunta. Dentro de la computadora todo se guarda con bits. Veremos cómo escribir números en binario y en hexadecimal, cómo se guardan los negativos, por qué un int puede hacer overflow, por qué 0.1 + 0.2 no da exactamente 0.3 y por qué la ñ ocupa más espacio que la n. Con esto podrán prever errores que parecen misteriosos si uno no conoce la representación.

Al final aprenderán lo básico de Git, la herramienta para guardar el historial de un trabajo. Cierra el laboratorio de esta semana.

Lectura. Este capítulo es el texto de la semana. Todo el código está en code/week-03. Dentro del entorno del curso, en esa carpeta, make compila los programas y make test comprueba que su salida coincide con lo que dice este capítulo.

Ruta de lectura.

  • Antes de clase (lectura obligatoria, 15 a 20 minutos): la introducción, “Todo son bits” y “Sistemas posicionales”. Es lo mínimo para seguir la clase.
  • En clase: “Bytes y múltiplos”, “Enteros con signo y complemento a dos”, “Overflow”, “Punto flotante”, “char es un número pequeño: ASCII” y “Unicode y UTF-8”.
  • Después de clase: para el laboratorio, “Laboratorio: bits a mano” y “Primeros pasos con Git”; para repasar, “Resumen” y “Ejercicios”.

Todo son bits

Un bit es la unidad mínima de información: vale 0 o 1. La palabra viene de binary digit, dígito binario. Una computadora usa bits porque es fácil construir circuitos con dos estados bien separados, como voltaje alto y voltaje bajo. Es mucho más difícil distinguir con seguridad diez niveles distintos.

Un bit solo dice muy poco. Con varios bits se forman combinaciones: cada bit que se agrega duplica la cantidad.

Bits Combinaciones Ejemplo de uso
1 2 sí o no
3 8 los días de la semana (sobra una combinación)
8 256 un byte
32 4 294 967 296 un int en nuestro entorno

En general, con n bits hay 2ⁿ combinaciones.

Un byte es un grupo de 8 bits. Es la unidad con la que se organiza la memoria: cada byte tiene su lugar propio, y por eso sizeof cuenta bytes y no bits.

Ahora lo más importante de la sección. Los bits no traen una etiqueta que diga qué significan. El byte 01000001 puede ser el número 65 o la letra A, y son los mismos ocho bits. Lo que decide cómo se leen es el tipo de la variable y el formato con que se escribe. Por eso, en C, printf("%d", letra) y printf("%c", letra) escriben cosas distintas a partir del mismo byte.

Idea clave. Un dato en memoria es solo un patrón de bits. Su significado lo pone el tipo con que se interpreta.

Sistemas posicionales

Decimal: lo que ya saben hacer

Escribimos los números con un sistema posicional: cada dígito vale según el lugar donde está. En el número 202, los dos dígitos 2 valen distinto:

202 = 2 × 100 + 0 × 10 + 2 × 1

Cada posición vale diez veces más que la de su derecha, porque usamos base 10, y por eso necesitamos diez dígitos, del 0 al 9. Nada obliga a usar el 10. Es la base que adoptamos, y cualquier otra funciona con la misma regla.

Binario: base 2

En base 2 hay solo dos dígitos, 0 y 1, y cada posición vale el doble que la de su derecha: 1, 2, 4, 8, 16, 32, 64, 128. Para leer un número en binario, se suman los pesos de las posiciones que tienen un 1.

Veamos 11001010:

Peso 128 64 32 16 8 4 2 1
Bit 1 1 0 0 1 0 1 0
Suma 128 64 8 2

128 + 64 + 8 + 2 = 202. El bit de la derecha es el de menor peso. Si vale 1, el número es impar.

De decimal a binario

Para ir al revés, dividimos entre 2 una y otra vez. Cada resto es un bit, y el primer resto que sale es el de menor peso. En pseudocódigo escribimos n mod 2 para el resto de la división y n div 2 para el cociente entero. En C son n % 2 y n / 2 entre int, como vieron la semana pasada.

algoritmo decimal_a_binario(n)
    mientras n ≠ 0 hacer
        escribir n mod 2
        n ← n div 2
    fin mientras
fin algoritmo

Sigámoslo a mano con 202. Cada fila es una vuelta del mientras:

n al empezar n mod 2 (se escribe) n div 2 (nuevo n)
202 0 101
101 1 50
50 0 25
25 1 12
12 0 6
6 0 3
3 1 1
1 1 0

El algoritmo escribe 0 1 0 1 0 0 1 1, es decir, del bit de menor peso al de mayor peso. Leídos de abajo hacia arriba forman 11001010, y ya vimos que eso vale 202. Con n = 0 el algoritmo no escribe nada, porque nunca entra al mientras. Es un caso que se debe tratar aparte: 0 en binario es 0.

Todavía no tenemos ciclos en C: llegan en la semana 5. Pero sí podemos resolver el caso de un número de 8 bits repitiendo los dos pasos ocho veces:

// to_binary.c (extracto; el archivo tiene las ocho repeticiones)
int n = 0;
// Mostramos cuántos datos leyó scanf. Con if, la próxima semana, ya podremos reaccionar.
int read_count = scanf("%d", &n);
printf("scanf leyó %d dato(s)\n", read_count);

int b0 = n % 2;
n = n / 2;
int b1 = n % 2;
n = n / 2;
// ... lo mismo para b2, b3, b4, b5 y b6 ...
int b7 = n % 2;

// El primer resto es el bit de menor peso: se escribe al final.
printf("%d%d%d%d%d%d%d%d\n", b7, b6, b5, b4, b3, b2, b1, b0);
$ echo 202 | build/to_binary
scanf leyó 1 dato(s)
11001010

Dos cosas nuevas en esa orden. echo escribe lo que le piden, y la barra | conecta su salida con la entrada del programa, sin que tengamos que escribir el número con el teclado. Es parecido al < de la semana pasada, pero con otro programa en lugar de un archivo.

En C. El programa escribe cuántos datos leyó scanf, porque scanf puede fallar. Con la entrada hola escribe scanf leyó 0 dato(s) y sigue con n en 0. Eso es lo más que podemos hacer hoy. Si la entrada se termina antes de leer algo, scanf devuelve EOF, que en nuestro entorno es -1, como vieron la semana pasada, y el programa escribe scanf leyó -1 dato(s). Reaccionar a esos errores, por ejemplo terminando el programa con un mensaje, requiere if, y lo veremos la próxima semana. También falta rechazar un número mayor que 255: el programa daría solo sus 8 bits más bajos.

De binario a decimal

Leer 11001010 sumando pesos es cómodo a mano. Para un programa hay una forma mejor: se recorre el número de izquierda a derecha y, por cada bit, se duplica lo acumulado y se le suma el bit. para repite el bloque una vez por cada valor de i. Aquí n es la cantidad de bits, y devolver entrega el resultado del algoritmo.

algoritmo binario_a_decimal(n)
    valor ← 0
    para i desde 0 hasta n - 1 hacer
        leer bit
        valor ← valor * 2 + bit
    fin para
    devolver valor
fin algoritmo

Sigámoslo con 10110110:

Bit leído Cálculo valor
1 0 × 2 + 1 1
0 1 × 2 + 0 2
1 2 × 2 + 1 5
1 5 × 2 + 1 11
0 11 × 2 + 0 22
1 22 × 2 + 1 45
1 45 × 2 + 1 91
0 91 × 2 + 0 182

En C, otra vez sin ciclos, ocho veces lo mismo. El formato %1d le pide a scanf un solo dígito, así que 10110110 no se lee como un número grande sino como ocho datos:

// from_binary.c (extracto; el par de líneas se repite ocho veces)
// %1d lee un solo dígito. scanf devuelve 1 cada vez que lo logra.
read_count = read_count + scanf("%1d", &bit);
value = value * 2 + bit;
// ... siete veces más ...

printf("scanf leyó %d dígitos\n", read_count);
printf("decimal: %d\n", value);
printf("hexadecimal: %x\n", value);
$ echo 10110110 | build/from_binary
scanf leyó 8 dígitos
decimal: 182
hexadecimal: b6

Ese programa no comprueba que cada dígito sea 0 o 1: si recibe 2, lo usa igual. Tampoco controla que lleguen los ocho. Con la entrada 101, las cinco lecturas que fallan devuelven -1, y el total deja de contar dígitos: escribe scanf leyó -2 dígitos, y su decimal no sirve. Para detectar estos casos también hace falta if.

Hexadecimal: base 16

El binario es lo que hay en la máquina, pero es incómodo para las personas: 11001010 se lee mal y es fácil perder un dígito. El hexadecimal resuelve esto. Es base 16, y como solo tenemos diez dígitos, se agregan seis letras:

Hex Decimal Binario Hex Decimal Binario
0 0 0000 8 8 1000
1 1 0001 9 9 1001
2 2 0010 a 10 1010
3 3 0011 b 11 1011
4 4 0100 c 12 1100
5 5 0101 d 13 1101
6 6 0110 e 14 1110
7 7 0111 f 15 1111

Lo útil es esto: como 16 = 2⁴, un dígito hexadecimal equivale exactamente a 4 bits. Para convertir, se agrupan los bits de cuatro en cuatro desde la derecha y se reemplaza cada grupo:

1100 1010
   c    a

Así, 11001010 en binario es ca en hexadecimal. Un byte siempre son dos dígitos hexadecimales, y por eso las herramientas que muestran bytes usan esta base.

Para volver a decimal se usa la misma regla posicional, con pesos 16 y 1: ca vale 12 × 16 + 10 × 1 = 202. Y el mismo algoritmo de divisiones sirve para pasar de decimal a hexadecimal, dividiendo entre 16: 202 entre 16 da cociente 12 (c) y resto 10 (a).

En C, un número escrito en el código que empieza con 0x está en hexadecimal, y printf escribe en esa base con %x (minúsculas) o %X (mayúsculas):

// bases.c (extracto)
int n = 202;
int m = 0xCA;  // 0x indica que el literal está en hexadecimal

printf("%x en hexadecimal, con minúsculas\n", n);
printf("0xCA vale %d\n", m);
$ build/bases
202 en decimal
ca en hexadecimal, con minúsculas
CA en hexadecimal, con mayúsculas
0xCA vale 202

Las variables n y m guardan exactamente lo mismo. La base es solo la forma de escribir el número, no algo que quede guardado en la variable.

Error común. Creer que un número “está en hexadecimal” dentro de la variable. En memoria siempre hay bits. Decimal y hexadecimal son dos maneras de mostrarlos.

Bytes y múltiplos

Para cantidades grandes se usan múltiplos, y aquí hay una confusión muy extendida. Hay dos familias: la decimal, con potencias de 1000, y la binaria, con potencias de 1024.

Decimal Bytes Binario Bytes
1 kB (kilobyte) 1000 1 KiB (kibibyte) 1024
1 MB (megabyte) 1000² 1 MiB (mebibyte) 1024²
1 GB (gigabyte) 1000³ 1 GiB (gibibyte) 1024³

Muchos fabricantes de discos usan la familia decimal, y varios programas informan la binaria aunque escriban “GB”. Por eso un disco de 500 GB aparece como unos 465.66 GiB: son los mismos 500 000 000 000 bytes, divididos entre 1024³. No falta espacio. Cuando leamos en este curso “kB” o “KiB”, significan lo que dice esta tabla.

Otra confusión común: b minúscula es bit y B mayúscula es byte. Las velocidades de conexión se suelen dar en megabits por segundo (Mbps). Un plan de 100 Mbps transfiere como máximo 12.5 MB por segundo, porque un byte tiene 8 bits.

Cuánto ocupa cada tipo

Este programa lo muestra en nuestro entorno. Agrega limits.h, un archivo estándar que define los límites de los tipos, y usa el formato %zu, que es el que corresponde al resultado de sizeof.

// sizes.c (extracto)
printf("sizeof(int)    = %zu\n", sizeof(int));
printf("INT_MAX = %d\n", INT_MAX);
printf("INT_MIN = %d\n", INT_MIN);
$ build/sizes
bits en un byte: 8
sizeof(char)   = 1
sizeof(int)    = 4
sizeof(double) = 8
INT_MAX = 2147483647
INT_MIN = -2147483648

sizeof(char) es 1 por definición, en todas las plataformas. Los otros tamaños dependen de la plataforma: C solo garantiza que un int tenga al menos 16 bits. En las computadoras actuales lo habitual, y lo que tenemos en el entorno del curso, es que ocupe 4 bytes, es decir, 32 bits. Enseguida veremos de dónde salen los dos límites.

Enteros con signo y complemento a dos

Sin signo

Con 8 bits hay 256 combinaciones. Si todas representan números no negativos, el rango va de 00000000 (0) a 11111111 (255). Es la lectura que venimos usando. En C, este tipo se llama unsigned char, y lo usaremos en este capítulo.

Una primera idea que no funciona bien

¿Y los negativos? Lo primero que se nos ocurre es reservar el bit de la izquierda para el signo: 0 para positivo y 1 para negativo. Así, 00000011 sería 3 y 10000011 sería -3. Tiene dos problemas. Primero, hay dos ceros: 00000000 y 10000000. Segundo, la suma común y corriente da un resultado equivocado: 3 + (-3) daría 10000110, que sería -6.

Complemento a dos

La solución usada en las computadoras actuales es el complemento a dos (two’s complement). La idea es simple: el bit de la izquierda tiene peso negativo. Con 8 bits los pesos son:

Peso -128 64 32 16 8 4 2 1

Todo lo demás se lee igual que siempre. Veamos los ocho bits de 11111101: -128 + 64 + 32 + 16 + 8 + 4 + 1 = -3.

Con 4 bits, los pesos son -8, 4, 2 y 1, y se pueden mostrar todos los valores:

Bits Valor Bits Valor
0000 0 1000 -8
0001 1 1001 -7
0010 2 1010 -6
0011 3 1011 -5
0100 4 1100 -4
0101 5 1101 -3
0110 6 1110 -2
0111 7 1111 -1

Miren lo que muestra la tabla:

  • Hay un solo cero.
  • Si el bit de la izquierda es 1, el número es negativo.
  • 1111 es -1, con cualquier cantidad de bits: el peso negativo es uno más que la suma de todos los demás.
  • El rango no es simétrico. Con 4 bits va de -8 a 7, porque el 0 ocupa uno de los lugares del lado no negativo.

En general, con n bits el rango va de -2ⁿ⁻¹ a 2ⁿ⁻¹ - 1. Con 8 bits, de -128 a 127. Con 32 bits, de -2147483648 a 2147483647, los valores de INT_MIN e INT_MAX que imprimió sizes.

Cómo cambiar el signo: invertir y sumar 1

Para obtener -x a partir de x: se invierten todos los bits y se suma 1. Con -3 en 8 bits:

Paso Bits
3 00000011
Invertir 11111100
Sumar 1 11111101

¿Por qué funciona? Si se suma un número con su versión invertida, todas las posiciones dan 1, y 11111111 es -1. O sea, x + invertido(x) = -1. Despejando, invertido(x) + 1 = -x.

Hay un caso especial. Si lo aplican a -128 (10000000), invierten a 01111111, suman 1 y vuelven a 10000000. No es un error del método: +128 no existe en 8 bits.

Por qué conviene: la suma es la misma

La ventaja principal es que el circuito que suma no necesita saber si hay negativos. Suma en binario como siempre, y el acarreo que no cabe se descarta. Veamos 5 + (-3):

   00000101     5
 + 11111101    -3
 ----------
 1 00000010     2

El 1 de la izquierda no cabe en 8 bits y se pierde. Lo que queda es 2, el resultado correcto. Por eso restar se reduce a sumar el negativo. Volveremos a esto al ver las compuertas lógicas y el sumador.

Del número a sus bits

Un número en complemento a dos de 8 bits también se puede ver como un número sin signo. Si x es negativo, sus bits leídos sin signo valen x + 256. Por ejemplo, -3 da 253, y 253 en binario es 11111101.

algoritmo patron_8_bits(x)
    si x < 0 entonces
        devolver x + 256
    sino
        devolver x
    fin si
fin algoritmo

En C todavía no tenemos if, pero el resto da el mismo resultado sin él: (x + 256) % 256. Para x = -3 calcula 253 % 256 = 253; para x = 5 calcula 261 % 256 = 5. Después se escriben los bits, dividiendo entre los pesos: pattern / 128 % 2 es el bit de peso 128, y así con los demás. Recuerden que / y % tienen la misma precedencia y se evalúan de izquierda a derecha.

// twos_complement.c (extracto)
// Sin if: si x es negativo da x + 256; si no, da x. Solo vale de -128 a 127.
int pattern = (x + 256) % 256;

printf("%d en 8 bits: %d%d%d%d%d%d%d%d\n", x,
       pattern / 128 % 2, pattern / 64 % 2, pattern / 32 % 2, pattern / 16 % 2,
       pattern / 8 % 2, pattern / 4 % 2, pattern / 2 % 2, pattern % 2);
// %x espera un valor sin signo: por eso el cast. Es el int completo, de 32 bits.
printf("%d en un int: %08x\n", x, (unsigned int) x);
$ echo -3 | build/twos_complement
scanf leyó 1 dato(s)
-3 en 8 bits: 11111101
-3 en un int: fffffffd
$ echo -1 | build/twos_complement
scanf leyó 1 dato(s)
-1 en 8 bits: 11111111
-1 en un int: ffffffff
$ echo 127 | build/twos_complement
scanf leyó 1 dato(s)
127 en 8 bits: 01111111
127 en un int: 0000007f

%08x significa hexadecimal, con 8 dígitos como mínimo y rellenando con ceros a la izquierda. Fíjense en que ffffffff son 32 unos: -1 en un int.

En C. %x espera un valor unsigned int, un entero sin signo del mismo tamaño que int. Por eso se hace la conversión (cast) (unsigned int) x. C define esa conversión: suma 2³² a un negativo, y el resultado tiene justamente los bits del complemento a dos. Pasar un int negativo directamente a %x no es correcto, aunque el compilador no lo señale con las opciones que usamos. Un int positivo, como 202, sí se puede pasar.

En C. El estándar C17 todavía admite otras representaciones de los negativos, aunque ninguna computadora actual las usa en la práctica. El gcc de nuestro entorno usa complemento a dos, y el estándar siguiente, C23, ya lo exige.

Overflow

La idea: el odómetro

Piensen en el contador de kilómetros de un carro antiguo, con 6 dígitos. Después de 999999 vuelve a 000000: el resultado real no cabe. Pasa lo mismo con los bits. Un overflow es un resultado que no cabe en el tipo que debe guardarlo.

Con 8 bits en complemento a dos, sumemos 127 + 1 a nivel de bits:

   01111111    127
 + 00000001      1
 ----------
   10000000   -128

El circuito suma correctamente, pero el patrón que resulta es el de -128. Sumando dos positivos salió un negativo. Eso es lo que hace el hardware.

Rueda con los 16 patrones de 4 bits, de 0000 arriba y en sentido horario, cada uno junto a su valor en complemento a dos: de 0 a 7 en azul, para los que empiezan con 0, y de -8 a -1 en rojo, para los que empiezan con 1. Una flecha interior indica que sumar 1 avanza un lugar. Una flecha marca el salto entre 7, patrón 0111, y -8, patrón 1000. A la derecha, 0111 + 0001 = 1000: 7 + 1 da el patrón de -8, porque el resultado real, 8, no cabe. También 1111 + 0001 = 0000: -1 + 1 da 0, que es correcto, aunque salga un acarreo que no cabeRueda con los 16 patrones de 4 bits, de 0000 arriba y en sentido horario, cada uno junto a su valor en complemento a dos: de 0 a 7 en azul, para los que empiezan con 0, y de -8 a -1 en rojo, para los que empiezan con 1. Una flecha interior indica que sumar 1 avanza un lugar. Una flecha marca el salto entre 7, patrón 0111, y -8, patrón 1000. A la derecha, 0111 + 0001 = 1000: 7 + 1 da el patrón de -8, porque el resultado real, 8, no cabe. También 1111 + 0001 = 0000: -1 + 1 da 0, que es correcto, aunque salga un acarreo que no cabe
Con 4 bits se ve toda la rueda: el circuito solo avanza un lugar. Al cruzar de 0111 a 1000, dos positivos dan un patrón negativo. Esto describe el hardware; para un int, C no promete este resultado.

En C, el overflow con signo es comportamiento indefinido

Aquí hay que tener cuidado, porque la regla de C no es “da la vuelta”. En C, el overflow de un entero con signo es comportamiento indefinido (undefined behavior). Quiere decir que el estándar no dice qué debe pasar: no promete un resultado ni un error. El compilador tiene derecho a suponer que eso nunca ocurre y a generar su código a partir de esa suposición. Un programa con comportamiento indefinido puede parecer correcto hoy y dar otro resultado mañana, con otra versión del compilador o con otras opciones.

Para detectarlo tenemos el sanitizer de comportamiento indefinido, UBSan. Es una ayuda que gcc agrega al ejecutable con la opción -fsanitize=undefined: el programa revisa las operaciones mientras corre y avisa si encuentra un problema. Las opciones de nuestro Makefile lo incluyen, junto con address, que vigila la memoria.

// Suma 1 a un int leído con scanf. Con INT_MAX se produce overflow.
#include <stdio.h>

int main(void) {
    int n = 0;
    int read_count = scanf("%d", &n);
    printf("scanf leyó %d dato(s), n = %d\n", read_count, n);

    int next = n + 1;
    printf("n + 1 = %d\n", next);
    return 0;
}

Con un valor normal no pasa nada especial. Con el mayor int posible:

$ echo 41 | build/overflow
scanf leyó 1 dato(s), n = 41
n + 1 = 42
$ echo 2147483647 | build/overflow
scanf leyó 1 dato(s), n = 2147483647
overflow.c:9:9: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
n + 1 = -2147483648

Leamos el mensaje por partes:

Parte Significado
overflow.c:9:9 Archivo, línea y columna
runtime error Error de ejecución, no de compilación
signed integer overflow Overflow de un entero con signo
2147483647 + 1 cannot be represented in type 'int' Esa suma no cabe en un int

Error común. Ver n + 1 = -2147483648 y concluir que “en C el int da la vuelta”. Ese valor es lo que salió en esta ejecución, en esta máquina, con estas opciones. Sin el sanitizer no hay ningún mensaje, y C no garantiza ese número ni ningún otro. El error ya ocurrió al calcular la suma. Lo que se imprime después no sirve.

Por qué se lee con scanf

Si el valor está escrito en el código, el compilador hace la cuenta al compilar y avisa:

$ gcc -Wall -Wextra -std=c17 bad/constant_overflow.c -o build/constant_overflow
bad/constant_overflow.c: In function ‘main’:
bad/constant_overflow.c:6:24: warning: integer overflow in expression of type ‘int’ results in ‘-2147483648’ [-Woverflow]
    6 |     int next = INT_MAX + 1;
      |                        ^

UBSan, en cambio, trabaja mientras el programa corre, y en este caso no tiene nada que vigilar: la suma ya no existe en el ejecutable. Compilado con -fsanitize=undefined, ese programa no da ningún mensaje al ejecutarse. Por eso, para ver el sanitizer en acción, el valor tiene que llegar en la ejecución. scanf hace justamente eso.

También hay un límite en la lectura misma. Si escriben un número que no cabe en un int, como 2147483648, C tampoco define qué guarda scanf. Es otra razón para probar con 2147483647, que sí cabe.

En C. Al programa le falta algo: con la entrada hola, scanf no lee nada y escribe scanf leyó 0 dato(s), n = 0. Hoy no podemos hacer más que mostrarlo. Y por haber inicializado n en 0, al menos no usamos una variable sin valor. La próxima semana, con if, el programa va a poder detenerse en ese caso, y también comprobar antes de sumar si la suma cabe.

Los tipos sin signo sí dan la vuelta

Los enteros sin signo tienen otra regla: C define que su aritmética da la vuelta, módulo 2ⁿ. Aquí es correcto hablar de “dar la vuelta” y no hay nada indefinido:

// unsigned_wrap.c (extracto)
unsigned char b = 255;
printf("b = %d\n", b);
b = b + 1;
printf("b = %d\n", b);
$ build/unsigned_wrap
b = 255
b = 0

El orden importa. En b = b + 1, la suma se hace como int y da 256. Al guardarla en el unsigned char se reduce módulo 256, y queda 0.

No es un problema teórico

  • El año 2038. Muchos sistemas contaron el tiempo como segundos desde el 1 de enero de 1970 en un entero de 32 bits con signo. Ese contador llega a su máximo, 2147483647, el 19 de enero de 2038. Los sistemas actuales están pasando a 64 bits por esto.
  • Ariane 5. En 1996, en su vuelo inaugural, este cohete se destruyó segundos después del despegue. Su software convertía un valor de punto flotante a un entero de 16 bits con signo, y el valor no cabía.

Idea clave. Un int tiene un máximo. Antes de multiplicar o de acumular, piensen si el resultado cabe. Si puede no caber, el programa está mal aunque hoy pase sus pruebas.

Punto flotante

Decimales en binario

La regla posicional también funciona a la derecha del punto. En decimal, las posiciones valen 1/10, 1/100, 1/1000. En binario valen 1/2, 1/4, 1/8, 1/16. Por ejemplo:

Binario Cálculo Decimal
0.1 1/2 0.5
0.01 1/4 0.25
0.11 1/2 + 1/4 0.75

Hasta aquí todo es exacto. Pero en decimal ya conocemos números que no terminan: 1/3 es 0.3333..., porque 3 no divide a ninguna potencia de 10. Con el binario pasa lo mismo, y con números que en decimal se ven inocentes. 0.1 no tiene escritura finita en binario.

Cómo sacar los bits de 0.1

Se duplica el número una y otra vez. Cada vez, la parte entera, 0 o 1, es el siguiente bit, y se sigue trabajando solo con la parte fraccionaria.

algoritmo fraccion_a_binario(x, k)
    para i desde 0 hasta k - 1 hacer
        x ← x * 2
        si x < 1 entonces
            escribir 0
        sino
            escribir 1
            x ← x - 1
        fin si
    fin para
fin algoritmo
x al empezar x * 2 Bit escrito x queda en
0.1 0.2 0 0.2
0.2 0.4 0 0.4
0.4 0.8 0 0.8
0.8 1.6 1 0.6
0.6 1.2 1 0.2
0.2 0.4 0 0.4

En la sexta fila volvemos a 0.2, igual que en la segunda. A partir de ahí se repite sin fin: 0.1 en binario es 0.0001100110011..., con el grupo 0011 repitiéndose.

Sin if todavía, en C el bit se obtiene con el cast (int) x, que descarta los decimales:

// fraction_to_binary.c (extracto; se repite para los 9 bits)
double x = 0.0;
int read_count = scanf("%lf", &x);
printf("scanf leyó %d dato(s)\n", read_count);

// Cada paso duplica x. La parte entera, 0 o 1, es el bit; el cast la obtiene.
x = x * 2;
int b1 = (int) x;
x = x - b1;
// ... lo mismo para b2 a b9 ...
$ echo 0.1 | build/fraction_to_binary
scanf leyó 1 dato(s)
0.000110011...

El formato %lf es el que scanf usa para leer un double.

Qué es un double

Como la memoria es finita, hay que cortar en algún punto. En nuestro entorno y en casi todas las computadoras actuales, un double usa 64 bits, con un formato llamado IEEE 754. C no lo exige, pero es lo que encontrarán. Guarda el número como en la notación científica, en base 2:

número = signo × 1.xxxxx (en binario) × 2^exponente
Parte Bits Para qué
Signo 1 Positivo o negativo
Exponente 11 Dónde cae el punto (de ahí “punto flotante”)
Mantisa 52 Los dígitos xxxxx después del 1.

Tres consecuencias:

  • Un double conserva unas 15 a 17 cifras decimales significativas.
  • Llega a números enormes, de unos 308 dígitos. Lo que tiene de alcance no lo tiene de precisión: no puede guardar con todas sus cifras un número tan grande.
  • Solo algunos números se guardan exactamente: los que son una suma finita de potencias de 2 y caben en esos bits. 0.5, 0.25 y 0.75 sí. 0.1, 0.2 y 0.3 no. Para esos se guarda el double más cercano.

Por qué 0.1 + 0.2 no da 0.3

Con %.20f pedimos 20 decimales, y se empieza a ver lo que realmente está guardado:

// float_sum.c (extracto)
double a = 0.1;
double b = 0.2;
double c = 0.3;
double sum = a + b;

printf("0.1 + 0.2 = %.20f\n", sum);
printf("diferencia = %.20f\n", sum - c);
// Una comparación vale 1 si es verdadera y 0 si es falsa.
printf("sum == c vale %d\n", sum == c);
$ build/float_sum
con %f:     0.300000
0.1       = 0.10000000000000000555
0.2       = 0.20000000000000001110
0.3       = 0.29999999999999998890
0.1 + 0.2 = 0.30000000000000004441
diferencia = 0.00000000000000005551
sum == c vale 0

Sigamos lo que pasó:

  1. El double más cercano a 0.1 es un poco mayor que 0.1. El de 0.2, también.
  2. Al sumarlos, el resultado exacto tampoco cabe, así que se redondea a un double vecino. Aquí cae justo en medio de dos, y una regla de desempate elige el de arriba.
  3. Ese double no es el más cercano a 0.3. Son dos valores vecinos, y no hay ningún otro double entre ellos.

La primera línea explica por qué esto pasa desapercibido: %f escribe solo 6 decimales y redondea, y entonces parece 0.3.

Recta numérica a escala cerca de 0.3. A la izquierda, un punto azul, el double de 0.3, que vale 0.29999999999999998890. A la derecha, un punto rojo, el double de 0.1 + 0.2, que vale 0.30000000000000004441. Una marca a un quinto del tramo, más cerca del punto azul, es el 0.3 exacto, que no se puede guardar. Debajo, una línea une los dos puntos con la diferencia 0.00000000000000005551, y una nota dice que entre los dos no hay ningún otro doubleRecta numérica a escala cerca de 0.3. A la izquierda, un punto azul, el double de 0.3, que vale 0.29999999999999998890. A la derecha, un punto rojo, el double de 0.1 + 0.2, que vale 0.30000000000000004441. Una marca a un quinto del tramo, más cerca del punto azul, es el 0.3 exacto, que no se puede guardar. Debajo, una línea une los dos puntos con la diferencia 0.00000000000000005551, y una nota dice que entre los dos no hay ningún otro double
Son dos double vecinos, y la distancia entre ellos es la diferencia que escribe el programa. Para == son distintos, aunque ninguno de los dos sea 0.3.

Esto no es un bug de C ni de gcc. Es la consecuencia de guardar en binario y en una cantidad fija de bits. Cualquier lenguaje que use double da lo mismo.

Idea clave. Un double es casi siempre una aproximación. El error es muy pequeño, pero existe, y las operaciones lo arrastran.

Error común: comparar double con ==

La línea sum == c vale 0 usa un operador que adelantamos: == compara dos valores, y = asigna. Una comparación en C vale 1 si es verdadera y 0 si es falsa. La usaremos en serio con if, la próxima semana. Aquí nos muestra que para C 0.1 + 0.2 y 0.3 son valores distintos.

Error común. Comparar dos double con ==. Los números que “deberían” ser iguales rara vez coinciden en todos sus bits.

Lo correcto es preguntar si están suficientemente cerca: si el valor absoluto de la diferencia es menor que una tolerancia. fabs es de math.h, y por eso se compila con -lm:

// compare_doubles.c (extracto)
double sum = 0.1 + 0.2;
double target = 0.3;
double tolerance = 0.000000001;

printf("sum == target vale %d\n", sum == target);
printf("fabs(sum - target) < tolerance vale %d\n", fabs(sum - target) < tolerance);
$ build/compare_doubles
sum == target vale 0
fabs(sum - target) < tolerance vale 1

La tolerancia se elige según el problema. Una de 0.000000001 tiene sentido con valores cercanos a 1, pero no con números de mil millones, donde los errores son mayores.

Los errores se acumulan y la precisión se acaba

Tres líneas más sobre lo que un double puede y no puede hacer, de float_limits.c:

$ build/float_limits
0.5 + 0.25     = 0.75000000000000000000
diez veces 0.1 = 0.99999999999999988898
big            = 9007199254740992.0
big + 1        = 9007199254740992.0

La primera línea es exacta, como esperábamos. En la segunda, los errores pequeños se van sumando. En las dos últimas, big vale 2⁵³. A partir de ahí, los 52 bits de la mantisa ya no alcanzan para distinguir enteros consecutivos, y sumar 1 no cambia nada.

El dinero se guarda en céntimos

Si un error de 0.00000000000000005 parece inofensivo, piensen en una cuenta que debe quedar exactamente en cero. La solución habitual es no usar decimales: en vez de guardar soles, se guardan céntimos en un int. Los enteros son exactos mientras no haya overflow.

// money.c (extracto)
int price_cents = 110;
int total_cents = price_cents * 3;
printf("3 x 110 céntimos:    %d\n", total_cents);
printf("en soles:            S/ %d.%02d\n", total_cents / 100, total_cents % 100);
$ build/money
3 x 1.10 con double: 3.30000000000000026645
lo mismo con %.2f:   3.30
saldo con double:    0.00000000000000013878
3 x 110 céntimos:    330
en soles:            S/ 3.30
saldo en céntimos:   0

El saldo empieza en un sol y se le restan diez veces 10 céntimos. Con double no llega a cero. Con int, sí. Para mostrar el monto, total_cents / 100 da los soles y total_cents % 100 los céntimos. El formato %02d escribe al menos 2 dígitos, rellenando con ceros: 5 céntimos se ven como 05, y no como 5, que se leería como 50 céntimos.

Esta forma también tiene un límite. Un int de 32 bits llega a 2147483647 céntimos, es decir, S/ 21 474 836.47. Para una cuenta personal alcanza. Para un banco se usaría un entero más grande.

char es un número pequeño: ASCII

Un char ocupa un byte, y lo que guarda es un número. Para que ese número represente una letra, hace falta una tabla que asigne un número a cada carácter. La tabla básica es ASCII, de la década de 1960. Usa 7 bits, así que define 128 códigos, del 0 al 127:

Caracteres Códigos
Salto de línea (\n) 10
Espacio 32
'0' a '9' 48 a 57
'A' a 'Z' 65 a 90
'a' a 'z' 97 a 122

Hay un orden pensado: las letras son consecutivas, y cada minúscula está 32 lugares después de su mayúscula. Por eso se puede hacer aritmética con caracteres:

// char_codes.c (extracto)
char letter = 'A';
char digit = '7';

printf("%c tiene el código %d\n", letter, letter);
printf("%c tiene el código %d\n", letter + 1, letter + 1);
printf("'a' - 'A' = %d\n", 'a' - 'A');
printf("minúscula de %c: %c\n", letter, letter + 32);
printf("%c tiene el código %d\n", digit, digit);
printf("'7' - '0' = %d\n", digit - '0');
$ build/char_codes
A tiene el código 65
B tiene el código 66
'a' - 'A' = 32
minúscula de A: a
7 tiene el código 55
'7' - '0' = 7

Error común. Confundir el carácter '7' con el número 7. El char guarda el código 55. Para obtener el valor del dígito, se resta '0'. Y las comillas importan: 'A' es un carácter, un número en el fondo, mientras que "A" es otra cosa, que veremos al estudiar las cadenas.

En C. C no dice si char a secas tiene signo: lo decide cada plataforma. Entre 0 y 127 no hay diferencia, y por eso ASCII funciona igual en todas partes. Pero un byte de 128 o más se ve distinto. Este programa, char_sign.c, lee un byte y lo muestra de dos maneras. Le pasamos el texto con printf en la terminal, que a diferencia de echo no agrega salto de línea:

$ printf "ñ" | build/char_sign
scanf leyó 1 dato(s)
CHAR_MIN = 0, CHAR_MAX = 255
como char:          195
como unsigned char: 195

Esa salida es de Linux sobre un procesador ARM, donde se prepararon estos apuntes. En Linux sobre un procesador x86-64, como el de GitHub Codespaces, char tiene signo. Con la opción -fsigned-char de gcc se puede ver ese caso en cualquier máquina:

$ gcc -Wall -Wextra -std=c17 -fsigned-char char_sign.c -o build/char_sign_signed
$ printf "ñ" | build/char_sign_signed
scanf leyó 1 dato(s)
CHAR_MIN = -128, CHAR_MAX = 127
como char:          -61
como unsigned char: 195

Es el mismo byte, leído con otro tipo. Si les interesa el valor de un byte de 128 o más, usen unsigned char.

Unicode y UTF-8

Carácter, código y byte

ASCII alcanza para el inglés, pero no incluye la ñ ni la á. Durante años, cada región usó sus propias tablas de un byte para los códigos del 128 al 255. La tabla Latin-1, por ejemplo, ponía la ñ en el 241, mientras que otras tablas tenían otro carácter en ese lugar. Un mismo archivo se veía distinto en cada computadora.

Unicode resolvió esto con una sola tabla para todos los sistemas de escritura. Conviene separar tres ideas que se suelen mezclar:

Concepto Qué es Ejemplo
Carácter La letra o el símbolo, como idea la letra ñ
Código (code point) El número que Unicode le asigna, escrito U+ y en hexadecimal U+00F1, que es 241
Bytes Cómo se guarda ese número en un archivo o en memoria c3 b1 en UTF-8

Unicode define las dos primeras columnas. La tercera la define una codificación. La que se usa casi siempre hoy es UTF-8.

Cómo guarda UTF-8 un código

UTF-8 usa de 1 a 4 bytes, según el tamaño del código:

Rango de códigos Bytes Patrón de bits
U+0000 a U+007F (0 a 127) 1 0xxxxxxx
U+0080 a U+07FF (128 a 2047) 2 110xxxxx 10xxxxxx
U+0800 a U+FFFF (2048 a 65535) 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000 a U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Las x son los bits del código. Todo lo demás marca la estructura: el primer byte dice cuántos bytes tiene el carácter, y los siguientes siempre empiezan con 10.

La ñ, a mano

  1. El código de la ñ es U+00F1, que es 241. Es mayor que 127 y menor que 2048: usa 2 bytes.
  2. 241 en binario es 11110001. El patrón de 2 bytes tiene 11 lugares para bits, así que completamos con ceros a la izquierda: 00011110001.
  3. Repartimos: los 5 primeros van al primer byte y los 6 últimos al segundo.
Arriba, la ñ es U+00F1, es decir, 241. Debajo, el código en 11 bits, partido en dos grupos: 00011, de 5 bits, y 110001, de 6 bits. Dos flechas los llevan a dos bytes. El primer byte es el prefijo 110 seguido de 00011, y una nota dice que el prefijo 110 indica que el carácter tiene 2 bytes. El segundo byte es el prefijo 10 seguido de 110001, y una nota dice que el prefijo 10 indica un byte de continuación. Debajo de cada byte está su valor en hexadecimal, c3 y b1Arriba, la ñ es U+00F1, es decir, 241. Debajo, el código en 11 bits, partido en dos grupos: 00011, de 5 bits, y 110001, de 6 bits. Dos flechas los llevan a dos bytes. El primer byte es el prefijo 110 seguido de 00011, y una nota dice que el prefijo 110 indica que el carácter tiene 2 bytes. El segundo byte es el prefijo 10 seguido de 110001, y una nota dice que el prefijo 10 indica un byte de continuación. Debajo de cada byte está su valor en hexadecimal, c3 y b1
Los bits del código llenan los lugares libres del patrón, y los prefijos marcan la estructura. Así salen los bytes 11000011 y 10110001.

La ñ se guarda como c3 b1. La n, cuyo código 110 está en el rango de ASCII, usa un solo byte: 6e. Por eso la ñ ocupa 2 bytes y la n ocupa 1.

Tomar los 6 bits de abajo es lo mismo que calcular el resto entre 64, y los bits de arriba son el cociente. En cuanto a los prefijos, 110 seguido de 5 ceros vale 192, y 10 seguido de 6 ceros vale 128. Así se calcula sin mirar los bits uno por uno:

algoritmo dos_bytes_utf8(codigo)
    primero ← 192 + codigo div 64
    segundo ← 128 + codigo mod 64
    escribir primero, segundo
fin algoritmo
// utf8_two_bytes.c (extracto)
// Sin if no podemos rechazar otros códigos: fuera del rango, el resultado no sirve.
int first = 192 + code / 64;   // 110xxxxx: los 5 bits altos
int second = 128 + code % 64;  // 10xxxxxx: los 6 bits bajos

printf("bytes: %x %x\n", first, second);
printf("carácter: %c%c\n", first, second);
$ echo 241 | build/utf8_two_bytes
scanf leyó 1 dato(s)
bytes: c3 b1
carácter: ñ
$ echo 233 | build/utf8_two_bytes
scanf leyó 1 dato(s)
bytes: c3 a9
carácter: é

Ese programa solo sirve para códigos de 128 a 2047. Elegir el patrón según el rango de la tabla es trabajo para if.

Mirar los bytes de verdad

xxd es un programa que muestra los bytes de un archivo. Creemos un archivo con la palabra año. Usamos year.txt, porque los nombres de archivo van sin ñ en este curso:

$ echo "año" > year.txt
$ xxd year.txt
00000000: 61c3 b16f 0a                             a..o.
$ wc -c year.txt
5 year.txt

Cómo se lee la línea de xxd:

Columna Contenido
00000000: Posición del primer byte de la línea, en hexadecimal
61c3 b16f 0a Los bytes, en hexadecimal, en grupos de dos
a..o. Los mismos bytes como texto; un punto indica un byte que no es un carácter ASCII visible

Los bytes son 61 (a), c3 b1 (ñ), 6f (o) y 0a. Este último es el salto de línea, el código 10, que echo agrega al final. Son 3 caracteres visibles, y 5 bytes en total, como dice wc -c, que cuenta bytes.

Dos caracteres más, mirados del mismo modo. El último 0a de cada línea sigue siendo el salto de línea de echo, no parte del carácter:

$ echo "€" | xxd
00000000: e282 ac0a                                ....
$ echo "😀" | xxd
00000000: f09f 9880 0a                             .....
Carácter Código Bytes en UTF-8 Cuántos
n U+006E 6e 1
ñ U+00F1 c3 b1 2
€ U+20AC e2 82 ac 3
😀 U+1F600 f0 9f 98 80 4

Por lo mismo, un archivo puede tener 5 caracteres y 7 bytes. La palabra ñandú tiene dos caracteres de 2 bytes y tres de 1 byte:

$ echo "ñandú" | xxd
00000000: c3b1 616e 64c3 ba0a                      ..and...

Sin contar el salto de línea final, son 7 bytes.

Idea clave. Número de caracteres y número de bytes no son lo mismo. Un texto en ASCII coincide, y por eso es fácil no notar la diferencia. Con ñ y tildes deja de coincidir.

UTF-8 tiene una ventaja que explica por qué se impuso: todo texto ASCII ya es UTF-8 válido, con los mismos bytes. Los programas viejos siguieron funcionando.

Cuando los bytes se leen con la tabla equivocada

Si un programa lee bytes UTF-8 como si fueran Latin-1, cada byte se toma como un carácter aparte. iconv convierte de una codificación a otra, y aquí le decimos a propósito que el archivo está en Latin-1, aunque no sea cierto:

$ iconv -f latin1 -t utf8 year.txt
año

Seguramente ya vieron algo así en un correo o en una página web. No es un archivo dañado: c3 en Latin-1 es à y b1 es ±. Los bytes están bien; la tabla es la equivocada.

La ñ no cabe en un char

Como la ñ son dos bytes en UTF-8, no cabe en un char. Si lo intentan, gcc avisa:

$ gcc -Wall -Wextra -std=c17 bad/char_enye.c -o build/char_enye
bad/char_enye.c: In function ‘main’:
bad/char_enye.c:5:14: warning: multi-character character constant [-Wmultichar]
    5 |     char c = 'ñ';
      |              ^~~
bad/char_enye.c:5:14: warning: unsigned conversion from ‘int’ to ‘char’ changes value from ‘50097’ to ‘177’ [-Woverflow]

Lean la primera advertencia: dentro de las comillas simples hay más de un carácter, pues el compilador ve dos bytes. La segunda es que 50097 (c3b1 en hexadecimal) no cabe en un char, y queda solo 177 (b1), que no es un carácter válido por sí solo. Esta segunda línea depende de la plataforma: donde char tiene signo, el mensaje es otro.

%c tampoco lee “un carácter”, sino un byte. Este programa lee los primeros 4 bytes de la entrada. Con año, esos 4 bytes son 3 caracteres:

// read_bytes.c (extracto)
int read_count = scanf("%c%c%c%c", &b0, &b1, &b2, &b3);
printf("scanf leyó %d byte(s)\n", read_count);
// El cast evita que un byte de 128 o más se vea negativo donde char tiene signo.
printf("%02x %02x %02x %02x\n", (unsigned char) b0, (unsigned char) b1,
       (unsigned char) b2, (unsigned char) b3);
$ printf "año" | build/read_bytes
scanf leyó 4 byte(s)
61 c3 b1 6f

Unicode también permite escribir la ñ como dos códigos: una n seguida de una tilde combinable, U+0303. Se ve igual, pero son los bytes 6e cc 83. Lo habitual en un texto escrito con teclado en español es U+00F1, y es lo que usamos en este curso.

Laboratorio: bits a mano

El laboratorio está en code/week-03/lab/. Allí encontrarán tres programas por completar y el archivo answers.txt, donde anotan sus respuestas. Mientras trabajan, make test-lab les dice qué falta. Falla hasta que terminen.

Actividad 1: 202 a mano. Conviertan 202 a binario con las divisiones y a hexadecimal agrupando bits. Anótenlo en answers.txt. Después completen lab/hex_202.c para comprobarlo con printf("%x").

Actividad 2: leer el mensaje del sanitizer. En lab/int_overflow.c, lean un int con scanf, súmenle 1 y escriban el resultado. Hagan lo mismo que en overflow.c, pero escribiéndolo ustedes. Luego:

make lab
echo 2147483647 | build/lab/int_overflow

Busquen la línea runtime error y anoten en answers.txt el número de línea que indica. Debe ser la línea donde hacen la suma.

Actividad 3: 20 decimales. En lab/float_print.c, escriban 0.1 + 0.2 con %.20f. Comparen con lo que sale con %f.

Actividad 4: la ñ con xxd. Dentro de la carpeta lab/, creen el archivo year.txt con año y mírenlo con xxd. Identifiquen los bytes de la ñ y anoten el total de bytes del archivo.

Actividad 5: Git. La siguiente sección la explica paso a paso. No tiene prueba automática: se revisa en el laboratorio.

Primeros pasos con Git

Para qué sirve

Cuando se trabaja en algo durante semanas, tarde o temprano se rompe algo que funcionaba, y uno quisiera volver atrás. La solución ingenua son archivos como tarea_final.c, tarea_final2.c, tarea_final_ahora_si.c. Git resuelve esto bien: guarda el historial de una carpeta, con cada estado anterior y una descripción de qué cambió.

Tres palabras:

  • Repositorio: la carpeta cuyo historial lleva Git. El historial se guarda en una subcarpeta oculta llamada .git.
  • Commit: un registro de cómo estaban los archivos en un momento, con un mensaje que dice qué cambió.
  • Área de preparación (staging area): la lista de cambios que van a entrar en el próximo commit.

El flujo básico tiene dos pasos, y esa es la diferencia entre los dos comandos principales:

Comando Qué hace
git add archivo Pone los cambios del archivo en el área de preparación. Todavía no guarda nada en el historial.
git commit -m "mensaje" Crea un commit con lo que está en el área de preparación.

¿Por qué dos pasos? Porque así se elige qué cambios van juntos. Si hoy tocaron dos cosas sin relación, pueden hacer un commit para cada una.

Tres cajas en fila. La primera es la carpeta de trabajo, con grades.txt y su línea nueva. Una flecha, git add, lleva a la segunda, el área de preparación, con lo que entrará en el próximo commit. Otra flecha, git commit, lleva a la tercera, el historial, con los commits f7f7370, de la práctica 2, y 3517bfb, de la práctica 1, el más reciente primero. Debajo de las dos primeras cajas, un corchete con git diff, que muestra lo que cambió y aún no se agregó. Debajo del historial, git log, que lo muestraTres cajas en fila. La primera es la carpeta de trabajo, con grades.txt y su línea nueva. Una flecha, git add, lleva a la segunda, el área de preparación, con lo que entrará en el próximo commit. Otra flecha, git commit, lleva a la tercera, el historial, con los commits f7f7370, de la práctica 2, y 3517bfb, de la práctica 1, el más reciente primero. Debajo de las dos primeras cajas, un corchete con git diff, que muestra lo que cambió y aún no se agregó. Debajo del historial, git log, que lo muestra
Un cambio pasa por dos pasos antes de quedar en el historial. Los identificadores son de la sesión que sigue, y en su máquina serán otros.

Dónde crear el repositorio

Git no es solo para código, y este repositorio de práctica es desechable. Créenlo en su carpeta personal, ~, y no dentro de la carpeta del curso. La carpeta del curso puede ser ya un repositorio, y un repositorio dentro de otro complica las cosas sin necesidad.

Un trabajo con dos commits

Una sesión completa, con un archivo de notas. Los mensajes de Git están en inglés.

$ cd ~
$ mkdir lab03-git
$ cd lab03-git
$ git init
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint: 	git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint: 	git branch -m <name>
Initialized empty Git repository in /root/lab03-git/.git/

git init convierte la carpeta en repositorio. Las líneas hint: son solo sugerencias, y por ahora las podemos ignorar: hablan de branches, que no veremos en este curso. La ruta de la última línea depende de su entorno.

Ahora creamos un archivo y preguntamos el estado con git status, el comando que conviene usar todo el tiempo:

$ echo "Práctica 1: 15" > grades.txt
$ git status
On branch master

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	grades.txt

nothing added to commit but untracked files present (use "git add" to track)

Untracked quiere decir que Git ve el archivo pero todavía no lleva su historial. Lo agregamos:

$ git add grades.txt

git status ahora lo mostraría bajo Changes to be committed: ya está en el área de preparación. Hagamos el commit. La primera vez, Git puede quejarse:

$ git commit -m "Agrega la nota de la práctica 1"
Author identity unknown

*** Please tell me who you are.

Run

  git config --global user.email "you@example.com"
  git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: unable to auto-detect email address (got 'root@616988be0ce2.(none)')

Cada commit guarda quién lo hizo, y Git todavía no lo sabe. El mensaje dice cómo resolverlo. Se hace una sola vez, con su propio nombre y correo. Lo que aparece entre comillas al final de ese mensaje cambia según la máquina, y en algunos entornos la identidad ya viene configurada y no verán este error.

$ git config --global user.name "Ana Quispe"
$ git config --global user.email "ana@example.com"
$ git commit -m "Agrega la nota de la práctica 1"
[master (root-commit) 3517bfb] Agrega la nota de la práctica 1
 1 file changed, 1 insertion(+)
 create mode 100644 grades.txt

El 3517bfb identifica el commit y será otro en su máquina: depende del contenido, del autor y de la fecha.

Agreguemos una segunda nota. El operador >> agrega al final del archivo, a diferencia de >, que lo reemplaza:

$ echo "Práctica 2: 17" >> grades.txt
$ git diff
diff --git a/grades.txt b/grades.txt
index 12e499b..c4a572a 100644
--- a/grades.txt
+++ b/grades.txt
@@ -1 +1,2 @@
 Práctica 1: 15
+Práctica 2: 17

git diff muestra lo que cambió en la carpeta y todavía no se agregó con git add. La línea que empieza con + es la nueva. Hay que leer el diff antes de hacer un commit: es la última oportunidad de ver lo que van a guardar.

$ git add grades.txt
$ git diff
$ git commit -m "Agrega la nota de la práctica 2"
[master f7f7370] Agrega la nota de la práctica 2
 1 file changed, 1 insertion(+)
$ git log
commit f7f7370f44d4394951d5cf9a47ef6e9f423a6987
Author: Ana Quispe <ana@example.com>
Date:   Mon Nov 2 07:52:42 2026 +0000

    Agrega la nota de la práctica 2

commit 3517bfb6bcc1946cfd3249b6357c2a3172d879ec
Author: Ana Quispe <ana@example.com>
Date:   Mon Nov 2 07:52:42 2026 +0000

    Agrega la nota de la práctica 1

Después de git add, git diff no muestra nada: ese comando no incluye lo que ya está en el área de preparación. git log muestra el historial, con el commit más reciente primero. Los identificadores y las fechas serán otros en su máquina. Si la lista no cabe en la pantalla, se sale de ella con la tecla q. También existe una versión corta:

$ git log --oneline
f7f7370 Agrega la nota de la práctica 2
3517bfb Agrega la nota de la práctica 1

Error común. Editar un archivo, hacer git commit y creer que quedó guardado. Sin git add, el cambio no entra en el commit. git status les dice qué va a entrar y qué no.

Error común. Escribir mensajes como “cambios” o “arreglo”. Dentro de un mes no van a decir nada. Un buen mensaje dice qué cambió: “Agrega la nota de la práctica 2”.

Por ahora todo esto vive en su propia máquina. Git no necesita internet para funcionar. GitHub es un servicio para guardar y compartir repositorios en línea, y no lo necesitamos esta semana.

Resumen

  • Un bit vale 0 o 1, y un byte son 8 bits. Con n bits hay 2ⁿ combinaciones. Un patrón de bits no significa nada hasta que un tipo lo interpreta.
  • En un sistema posicional, cada dígito vale según su lugar. De decimal a binario se divide entre 2 y se leen los restos al revés. Un dígito hexadecimal son 4 bits, y un byte son dos dígitos.
  • 1 kB son 1000 bytes y 1 KiB son 1024. b es bit y B es byte.
  • En complemento a dos, el bit de la izquierda pesa negativo. Con n bits el rango va de -2ⁿ⁻¹ a 2ⁿ⁻¹ - 1. Para cambiar el signo se invierten los bits y se suma 1.
  • El overflow de un entero con signo es comportamiento indefinido en C. UBSan lo reporta para valores que llegan en la ejecución. Los enteros sin signo sí dan la vuelta, porque C lo define.
  • Un double tiene 64 bits y casi siempre guarda una aproximación. No se comparan con ==, sino con una tolerancia. El dinero se guarda en céntimos, en un entero.
  • Un char es un número pequeño. ASCII asigna códigos del 0 al 127. C no dice si char tiene signo.
  • Unicode asigna un código a cada carácter, y UTF-8 lo guarda en 1 a 4 bytes. La ñ es U+00F1 y se guarda como c3 b1. xxd muestra los bytes.
  • Git guarda el historial de una carpeta: git init, git status, git add, git diff, git commit y git log. git add prepara y git commit registra.

Ejercicios

  1. Conviertan 10110110 a decimal y a hexadecimal.
  2. Conviertan a mano 45 y 200 a binario de 8 bits y a hexadecimal. Comprueben el resultado con to_binary y printf("%x").
  3. ¿Cuántos valores distintos se pueden representar con 10 bits? ¿Y cuántos bytes hacen falta para un int de 32 bits? Un archivo de 3 KiB, ¿cuántos bytes tiene exactamente?
  4. ¿Cuál es el mayor entero con signo de 8 bits? ¿Cómo se escribe -1 en complemento a dos? ¿Y -20? Muestren el proceso de invertir y sumar 1.
  5. Sumen en binario, con 8 bits en complemento a dos, 100 + 27 y 100 + 28. ¿En cuál hay overflow? ¿Cómo se nota en los bits?
  6. Sin ejecutarlo, ¿qué pueden decir de lo que escribe un programa que lee 2147483647 en un int n y escribe n + 1? ¿Qué cambia si n es unsigned char, vale 255 y se ejecuta n = n + 1?
  7. Con el algoritmo fraccion_a_binario, saquen los primeros 8 bits de 0.3 y de 0.625. ¿Cuál de los dos se guarda exactamente en un double? ¿Por qué?
  8. ¿Por qué no conviene guardar el saldo de una cuenta en un double? Escriban un programa que lea un precio en céntimos y una cantidad, y escriba el total en el formato S/ 12.05.
  9. Sin ejecutarlo, ¿qué escribe printf("%c %d\n", 'C' + 32, '9' - '0');? Después compruébenlo.
  10. Un archivo tiene 5 caracteres y 7 bytes en UTF-8. ¿Cómo es posible? Den otro ejemplo de una palabra con 6 caracteres y 8 bytes, y compruébenlo con xxd.
  11. Calculen a mano los bytes UTF-8 de la é (U+00E9) y de la á (U+00E1). ¿Cuántos bytes ocupa € y por qué? Usen la tabla de patrones.
  12. ¿Para qué sirven git add y git commit, y en qué se diferencian? En un repositorio de práctica, hagan tres commits: uno crea un archivo y los otros dos lo modifican. Muestren git log --oneline y expliquen qué mostraría git diff justo antes de cada git add.