Curso de C desde cero, en español

C es el lenguaje con el que están escritos los núcleos de Linux y de buena parte de Windows y macOS, el intérprete de Python, SQLite, Git, curl y el firmware de millones de aparatos. Tiene más de cincuenta años y sigue segundo en el índice TIOBE de octubre de 2026, solo detrás de Python. Aprenderlo enseña cómo funciona de verdad un ordenador: la memoria, los bytes y las direcciones que otros lenguajes esconden. También exige más cuidado que ninguno, porque el compilador confía en ti y casi ningún error se detiene solo. Este curso empieza desde cero, usa C23, el estándar actual, con GCC 15, y en cada lección enseña las herramientas que atrapan esos errores antes que un atacante.

La ruta: ocho lecciones

Están pensadas para leerse en orden. Las dos primeras están en esta misma página y las demás tienen la suya. Cada lección tiene un recuadro «El error que te ahorra», con un fallo que el compilador o un sanitizador detectan si los usas bien, un recuadro «Ojo con la seguridad» con el fallo típico ligado a ese tema, al menos un esquema y retos al final con la solución escondida. Al terminar hay un test de 24 preguntas: adivinar qué imprime cada fragmento.

Lección 1. Primeros pasos

Qué es C

C nació en los Laboratorios Bell entre 1969 y 1973, en paralelo con el sistema operativo Unix. Su autor, Dennis Ritchie, lo derivó de B, el lenguaje de Ken Thompson, que a su vez venía de BCPL, y el año más creativo fue 1972. A principios de 1973 el lenguaje estaba lo bastante maduro como para reescribir en C el núcleo de Unix, que hasta entonces estaba en ensamblador, y eso cambió la historia: por primera vez un sistema operativo se podía llevar a otra máquina recompilándolo. En 1978 Brian Kernighan y Ritchie publicaron The C Programming Language, el libro que todo el mundo llama «el K&R», y a finales de los ochenta el lenguaje se estandarizó: primero ANSI C (1989) y después las revisiones ISO C99, C11, C17 y C23, la actual, que es la que usa este curso.

C es un lenguaje compilado y de bajo nivel, aunque no tanto como el ensamblador: escribes funciones, bucles y variables, y el compilador los traduce directamente a instrucciones del procesador. No hay máquina virtual, ni recolector de basura, ni comprobaciones al acceder a un array. Por eso es tan rápido y tan pequeño, y por eso mismo tantos fallos de seguridad famosos, de Heartbleed a regreSSHion, son fallos de memoria en programas escritos en C. Lo contamos en Estándares y parches y en Seguridad en C.

Instalar un compilador

  • Linux: sudo apt install build-essential en Debian y Ubuntu (instala GCC, make y las cabeceras), o sudo dnf install gcc make en Fedora.
  • macOS: xcode-select --install. Instala Clang, aunque la orden se siga llamando gcc; todo lo del curso funciona igual.
  • Windows: lo más cómodo es WSL, que te da un Linux dentro de Windows con apt install build-essential. Otras opciones son MSYS2 (GCC nativo para Windows) o Visual Studio con su compilador MSVC, que va algo por detrás en C23.
  • Con Docker, sin instalar nada: docker run --rm -it -v "$PWD":/src -w /src gcc:15 bash. Es la imagen oficial que hemos usado para comprobar el curso.

Para ver qué versión tienes:

gcc --version
salidagcc (GCC) 15.3.0
Copyright (C) 2025 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Para el curso basta GCC 13 o posterior, o Clang 18; las novedades de C23 completas llegan con GCC 15, que además compila en C23 por omisión. Para escribir código vale cualquier editor: Visual Studio Code con la extensión C/C++ o clangd, CLion o el que ya uses.

El primer programa

Crea un archivo hola.c:

#include <stdio.h>

int main(void)
{
    printf("Hola, mundo\n");
    return 0;
}

Compílalo y ejecútalo desde la terminal, en la misma carpeta:

gcc -Wall -Wextra hola.c -o hola
./hola
salidaHola, mundo

Línea a línea: #include <stdio.h> trae las declaraciones de la entrada y salida estándar, entre ellas printf. int main(void) es la función por la que empieza todo programa: no recibe nada (void) y devuelve un entero. printf escribe texto, y \n es el salto de línea. Cada instrucción termina en punto y coma y los bloques van entre llaves.

Las opciones -Wall -Wextra activan casi todos los avisos del compilador. No son obligatorias, pero en C son la diferencia entre enterarte de un error al compilar o cuando el programa ya falla. Úsalas siempre; -o hola le da nombre al ejecutable, que si no se llamaría a.out.

Qué hace gcc por dentro

hola.c pasa por el preprocesador, que resuelve include y define y produce hola.i; el compilador lo traduce a ensamblador, hola.s; el ensamblador produce código máquina, hola.o; y el enlazador lo une con la biblioteca de C para formar el ejecutable hola. gcc hace los cuatro pasos de una vezDe hola.c a un programa que se ejecutahola.cpreprocesador#include, #definehola.icompiladorC a ensambladorhola.sensambladora código máquinahola.oenlazador+ biblioteca de Cholagcc hace los cuatro pasos de una vez: gcc hola.c -o holaEl resultado ya no necesita a gcc: es código máquina para tu procesador y tu sistema.

Los cuatro pasos de la compilación. Con gcc -save-temps se conservan los archivos intermedios. Esquema: decodigo.com.

Puedes ver cada paso. El preprocesador solo sustituye texto: copia dentro el contenido de stdio.h y quita los comentarios. Con gcc -E se detiene ahí, y el resultado son cientos de líneas antes de tu main:

gcc -E hola.c | wc -l
gcc -E hola.c | tail -n 8
salida825

# 3 "hola.c"
int main(void)
{
    printf("Hola, mundo\n");
    return 0;
}

Con -save-temps, gcc guarda los archivos intermedios del esquema:

gcc -save-temps hola.c -o hola
ls
salidahola
hola.c
hola.i
hola.o
hola.s

El valor de retorno de main

El número que devuelve main llega al sistema operativo como código de salida: 0 significa «todo bien» y cualquier otro valor, un error. Los scripts y herramientas como make lo usan para saber si deben seguir. En la terminal se ve con echo $? justo después de ejecutar:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    fprintf(stderr, "No encuentro el archivo de configuración\n");
    return EXIT_FAILURE;
}
./salida
echo $?
salidaNo encuentro el archivo de configuración
1

fprintf(stderr, ...) escribe en la salida de errores, que se puede separar de la normal, y EXIT_FAILURE (de stdlib.h) vale 1. Si main llega al final sin return, devuelve 0.

El error que te ahorra: olvidar el #include

int main(void)
{
    printf("Hola\n");
    return 0;
}
gcc: errorsin_include.c: In function 'main':
sin_include.c:3:5: error: implicit declaration of function 'printf' [-Wimplicit-function-declaration]
    3 |     printf("Hola\n");
      |     ^~~~~~
sin_include.c:1:1: note: include '<stdio.h>' or provide a declaration of 'printf'
  +++ |+#include <stdio.h>
    1 | int main(void)
sin_include.c:3:5: warning: incompatible implicit declaration of built-in function 'printf' [-Wbuiltin-declaration-mismatch]
    3 |     printf("Hola\n");
      |     ^~~~~~
sin_include.c:3:5: note: include '<stdio.h>' or provide a declaration of 'printf'

Durante décadas, C dejó llamar a funciones no declaradas y adivinaba que devolvían int, lo que producía fallos muy difíciles de encontrar. Desde GCC 14 es un error. El propio mensaje te dice qué cabecera falta.

Escribir con printf

printf recibe una cadena de formato con huecos, que empiezan por %, y después los valores que van en cada hueco, en orden. El hueco dice de qué tipo es el valor:

#include <stdio.h>

int main(void)
{
    int edad = 31;
    double altura = 1.68;
    char inicial = 'M';
    const char *ciudad = "Monterrey";

    printf("%c. vive en %s\n", inicial, ciudad);
    printf("Tiene %d años y mide %.2f m\n", edad, altura);
    printf("[%5d] [%-5d] [%05d]\n", edad, edad, edad);
    printf("En hexadecimal: %x, en binario: %b\n", 255, 5);
    printf("El 100%% de las veces\n");
    return 0;
}
salidaM. vive en Monterrey
Tiene 31 años y mide 1.68 m
[   31] [31   ] [00031]
En hexadecimal: ff, en binario: 101
El 100% de las veces
  • HuecoParaEjemplo
  • %dint42
  • %ld, %lldlong, long long9000000000
  • %u, %zuunsigned, size_t (tamaños)sizeof(x)
  • %f, %.2fdouble (y float)1.68
  • %cun carácterM
  • %suna cadenaMonterrey
  • %x, %bhexadecimal, binario (C23)ff, 101
  • %puna dirección de memorialección 5

Entre el % y la letra puede ir un ancho (%5d), la alineación a la izquierda (%-5d), ceros a la izquierda (%05d) o los decimales (%.2f). %% escribe un signo de porcentaje. El hueco %b es nuevo de C23; con un compilador o una biblioteca antiguos no funciona.

Leer del teclado

Para leer, lo más seguro es leer la línea entera con fgets, que nunca escribe más de lo que cabe, y después convertirla al tipo que necesitas. Aquí, con strtol, que además dice si el texto era de verdad un número:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char linea[100];

    printf("¿En qué año naciste? ");
    if (fgets(linea, sizeof linea, stdin) == NULL) {
        return EXIT_FAILURE;
    }

    char *fin;
    long anio = strtol(linea, &fin, 10);
    if (fin == linea) {
        printf("Eso no es un número.\n");
        return EXIT_FAILURE;
    }

    printf("En 2026 cumples %ld años.\n", 2026 - anio);
    return 0;
}
salida¿En qué año naciste? En 2026 cumples 36 años.

No te preocupes todavía por el char linea[100], el &fin o el NULL: los arrays y las cadenas son la lección 4 y los punteros, la 5. Por ahora basta con la receta: fgets para leer la línea y strtol (enteros) o strtod (decimales) para convertirla, comprobando siempre el resultado.

Ojo con la seguridad: nunca gets ni scanf(«%s»)

Muchos tutoriales antiguos leen con gets(linea) o scanf("%s", linea). Ninguna de las dos sabe cuánto mide linea: si alguien escribe más de 100 caracteres, el resto se escribe en la memoria de al lado. Es el desbordamiento de búfer, el fallo de seguridad más clásico de C. gets se eliminó del lenguaje en C11 y GCC avisa si la usas; scanf("%s") sigue existiendo. Usa fgets con sizeof, o scanf("%99s") con el límite escrito.

Más en Los fallos de seguridad típicos de C.

Si en vez de un número escribes otra cosa, strtol no avanza y el programa lo detecta:

salida (escribiendo «hace mucho»)¿En qué año naciste? Eso no es un número.

Lección 2. Tipos, variables y operadores

Variables y tipos básicos

En C cada variable tiene un tipo fijo que se declara al crearla, y el tipo decide cuánta memoria ocupa y qué valores caben. Los básicos son los enteros (char, short, int, long, long long), los decimales (float, double) y, desde C23, bool con true y false sin necesidad de ninguna cabecera:

#include <stdio.h>

int main(void)
{
    int unidades = 3;
    double precio = 249.90;
    char talla = 'M';
    bool en_oferta = true;

    double total = unidades * precio;
    printf("%d camisetas talla %c: %.2f\n", unidades, talla, total);
    printf("¿En oferta? %d\n", en_oferta);

    unidades = unidades + 1;
    unidades += 2;
    unidades++;
    printf("Ahora son %d\n", unidades);
    return 0;
}
salida3 camisetas talla M: 749.70
¿En oferta? 1
Ahora son 7

Un bool se imprime como 1 o 0: para C, verdadero y falso son números. +=, -=, *= o ++ son atajos para modificar una variable.

Cuánto ocupa cada tipo: sizeof

El estándar de C no fija el tamaño exacto de los tipos, solo un mínimo: un int tiene al menos 16 bits, aunque en los ordenadores actuales tenga 32. El operador sizeof dice cuántos bytes ocupa algo en tu sistema:

#include <stdio.h>

int main(void)
{
    printf("char        %zu\n", sizeof(char));
    printf("short       %zu\n", sizeof(short));
    printf("int         %zu\n", sizeof(int));
    printf("long        %zu\n", sizeof(long));
    printf("long long   %zu\n", sizeof(long long));
    printf("float       %zu\n", sizeof(float));
    printf("double      %zu\n", sizeof(double));
    printf("bool        %zu\n", sizeof(bool));
    return 0;
}
salidachar        1
short       2
int         4
long        8
long long   8
float       4
double      8
bool        1

Tamaños medidos con sizeof: char 1 bytes, short 2 bytes, int 4 bytes, long 8 bytes, float 4 bytes, double 8 bytes, bool 1 bytes. En otros sistemas int y long pueden medir distintoCuánto ocupa cada tipo en Linux de 64 bitschar1 byteshort2 bytesint4 byteslong8 bytesfloat4 bytesdouble8 bytesbool1 byteCada cuadro es un byte (8 bits). En otros sistemas, int y long pueden medir distinto.

Los tamaños medidos en Linux de 64 bits con GCC. En Windows, long mide 4 bytes. Esquema: decodigo.com.

Por eso, cuando el tamaño importa (un formato de archivo, un protocolo de red, un registro de un microcontrolador), se usan los tipos de <stdint.h>, que dicen su tamaño en el nombre:

#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>

int main(void)
{
    uint8_t byte = 255;
    int32_t temperatura = -12;
    uint64_t poblacion = 8'200'000'000;

    printf("%" PRIu8 " %" PRId32 " %" PRIu64 "\n", byte, temperatura, poblacion);
    printf("El int más grande: %d\n", INT32_MAX);
    return 0;
}
salida255 -12 8200000000
El int más grande: 2147483647

La u significa unsigned, sin signo: solo valores de cero hacia arriba. Las comillas simples dentro de 8'200'000'000 son separadores de miles, nuevos de C23, que el compilador ignora. Las macros PRId32 y compañía, de <inttypes.h>, dan el hueco de printf correcto para cada tipo, sea cual sea el sistema.

Operadores y la división entera

#include <stdio.h>

int main(void)
{
    int pizzas = 7, personas = 2;

    printf("%d\n", pizzas / personas);
    printf("%d\n", pizzas % personas);
    printf("%f\n", pizzas / personas * 1.0);
    printf("%f\n", (double) pizzas / personas);
    printf("%d\n", -7 / 2);
    return 0;
}
salida3
1
3.000000
3.500000
-3

Entre dos enteros, / es la división entera: descarta los decimales, siempre hacia cero, y % da el resto. La tercera línea es una trampa clásica: primero se hace 7 / 2, que ya da 3, y luego se multiplica por 1.0. Para dividir con decimales hay que convertir uno de los dos antes, con un cast: (double) pizzas.

Decimales: double y sus límites

#include <stdio.h>

int main(void)
{
    double a = 0.1 + 0.2;
    printf("%f\n", a);
    printf("%.17f\n", a);
    printf("%d\n", a == 0.3);

    float f = 16777217;
    printf("%.1f\n", f);
    return 0;
}
salida0.300000
0.30000000000000004
0
16777216.0

Los decimales se guardan en binario y la mayoría de las fracciones, como 0.1, no tienen una representación exacta. %f redondea a seis decimales y lo esconde, pero el valor no es 0.3, así que comparar con == falla. float es todavía más corto: a partir de 16,777,216 ya no puede representar todos los enteros. Usa double salvo que tengas un buen motivo, y para dinero, enteros en céntimos.

Desbordamiento: cuando el número no cabe

Un entero tiene un tamaño fijo, así que tiene un máximo. Qué pasa al pasarse depende del tipo. En los enteros sin signo, el valor da la vuelta, como un cuentakilómetros:

#include <stdio.h>

int main(void)
{
    unsigned char nivel = 250;
    nivel += 10;
    printf("%d\n", nivel);

    unsigned int cero = 0;
    cero = cero - 1;
    printf("%u\n", cero);
    return 0;
}
salida4
4294967295

En los enteros con signo, en cambio, desbordarse es comportamiento indefinido: el estándar no dice qué pasa, y el compilador tiene permiso para suponer que nunca ocurre. Muchas veces parece que da la vuelta, pero no puedes contar con ello. Para verlo hay que compilar con el sanitizador de comportamiento indefinido, -fsanitize=undefined, que añade comprobaciones al programa:

#include <stdio.h>
#include <limits.h>

int main(void)
{
    int puntos = INT_MAX;
    puntos = puntos + 1;
    printf("%d\n", puntos);
    return 0;
}
gcc -Wall -Wextra -g -fsanitize=undefined -fno-sanitize-recover con_signo.c -o con_signo
salida con errorcon_signo.c:7:12: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'

C23 trae por fin una forma estándar de sumar sin riesgo: ckd_add, ckd_sub y ckd_mul, de <stdckdint.h>, hacen la operación y devuelven true si el resultado no cabía:

#include <stdio.h>
#include <stdckdint.h>
#include <limits.h>

int main(void)
{
    int total;
    if (ckd_add(&total, INT_MAX, 1)) {
        printf("No cabe: no sumo\n");
    } else {
        printf("Total: %d\n", total);
    }

    if (!ckd_mul(&total, 1000, 1000)) {
        printf("Total: %d\n", total);
    }
    return 0;
}
salidaNo cabe: no sumo
Total: 1000000

Ojo con la seguridad: los desbordamientos de enteros

Un desbordamiento parece un problema de cuentas, pero suele acabar en uno de memoria: si un programa calcula cuánto espacio reservar con cantidad * tamaño y el producto da la vuelta, reserva muy poco y luego escribe mucho. Es el origen de muchos fallos graves. Cuando un número viene de fuera (un archivo, la red, el usuario) y se usa para un tamaño, comprueba con ckd_mul o con límites explícitos antes de usarlo, y prueba tu código con -fsanitize=undefined.

Más en Los fallos de seguridad típicos de C.

char también es un número

#include <stdio.h>

int main(void)
{
    char letra = 'A';
    printf("%c %d\n", letra, letra);
    printf("%c\n", letra + 2);
    printf("%c\n", 'a' - 'A' + 'M');
    printf("%d\n", '7' - '0');
    return 0;
}
salidaA 65
C
m
7

Un char es un entero de un byte que guarda el código del carácter: la 'A' es un 65 en ASCII. Por eso se puede sumar y restar con letras: '7' - '0' convierte un dígito en su valor. Los caracteres van entre comillas simples; las comillas dobles son para cadenas.

El error que te ahorra: un hueco de printf que no cuadra

#include <stdio.h>

int main(void)
{
    double precio = 19.99;
    long visitas = 3000000000;
    printf("Precio: %d, visitas: %d\n", precio, visitas);
    return 0;
}
gcc: avisoformato_mal.c: In function 'main':
formato_mal.c:7:22: warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]
    7 |     printf("Precio: %d, visitas: %d\n", precio, visitas);
      |                     ~^                  ~~~~~~
      |                      |                  |
      |                      int                double
      |                     %f
formato_mal.c:7:35: warning: format '%d' expects argument of type 'int', but argument 3 has type 'long int' [-Wformat=]
    7 |     printf("Precio: %d, visitas: %d\n", precio, visitas);
      |                                  ~^             ~~~~~~~
      |                                   |             |
      |                                   int           long int
      |                                  %ld

Para printf, el formato es la única pista del tipo de cada valor. Si no cuadran, el resultado es basura o algo peor. Sin -Wall, gcc compila esto en silencio.

El error que te ahorra: una variable sin valor inicial

#include <stdio.h>

int main(void)
{
    int total;
    for (int i = 1; i <= 3; i++) {
        total += i;
    }
    printf("%d\n", total);
    return 0;
}

Curiosamente, con gcc -Wall -Wextra a secas este programa compila sin una queja: el análisis que detecta el problema solo se hace al optimizar. Con -O2 sí aparece:

gcc: avisosin_valor.c: In function 'main':
sin_valor.c:7:15: warning: 'total' is used uninitialized [-Wuninitialized]
    7 |         total += i;
      |         ~~~~~~^~~~
sin_valor.c:5:9: note: 'total' was declared here
    5 |     int total;
      |         ^~~~~

A diferencia de Java o Go, C no pone las variables locales a cero: valen lo que hubiera en esa memoria, y el programa puede funcionar en tu máquina y fallar en otra. Inicializa siempre (int total = 0;) y compila de vez en cuando también con -O2, que destapa avisos que sin optimizar no salen.

Constantes: const, constexpr y #define

#include <stdio.h>

#define DIAS_SEMANA 7
constexpr double IVA = 0.16;

int main(void)
{
    const int horas_turno = 8;
    double base = 1000.0;

    printf("%d horas a la semana\n", horas_turno * DIAS_SEMANA);
    printf("Con IVA: %.2f\n", base * (1 + IVA));
    return 0;
}
salida56 horas a la semana
Con IVA: 1160.00

Hay tres formas, de la más antigua a la más moderna. #define es del preprocesador: sustituye el texto DIAS_SEMANA por 7 antes de compilar, sin saber nada de tipos. const declara una variable que no se puede modificar. Y constexpr, nuevo de C23, declara una constante con tipo cuyo valor se conoce al compilar, que es lo que normalmente quieres.

Resumen de las lecciones 1 y 2

  • ParaEscribeRecuerda
  • Compilargcc -Wall -Wextra x.c -o xlos avisos son tu primera red
  • Comprobar errores de ejecución-g -fsanitize=address,undefinedmientras desarrollas
  • Escribirprintf("%d %s\n", n, s)cada hueco con su tipo
  • Leer una líneafgets(buf, sizeof buf, stdin)nunca gets
  • Texto a númerostrtol, strtodcomprueba que avanzó
  • Tamaño exactoint32_t, uint8_tde <stdint.h>
  • Dividir con decimales(double) a / bentre enteros, / trunca
  • Sumar sin desbordarckd_add(&r, a, b)C23, <stdckdint.h>
  • Constantesconstexpr int N = 10;mejor que #define

Retos

Reto 1. Escribe un programa que convierta 98.6 grados Fahrenheit a Celsius con la fórmula (F - 32) * 5 / 9 y lo imprima con un decimal. Cuidado con el orden de las operaciones y con los enteros.

Ver la solución
#include <stdio.h>

int main(void)
{
    double fahrenheit = 98.6;
    double celsius = (fahrenheit - 32) * 5 / 9;
    printf("%.1f °F son %.1f °C\n", fahrenheit, celsius);
    return 0;
}
salida98.6 °F son 37.0 °C

Funciona porque fahrenheit ya es double y todas las operaciones se hacen con decimales. Si hubieras escrito 5 / 9 * (fahrenheit - 32), el 5 / 9 entre enteros daría 0 y el resultado sería siempre 0.

Reto 2. Lee una cantidad de segundos desde el teclado y escríbela en horas, minutos y segundos (por ejemplo, 3725 son 1 h 2 min 5 s).

Ver la solución
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char linea[50];
    if (fgets(linea, sizeof linea, stdin) == NULL) {
        return EXIT_FAILURE;
    }
    char *fin;
    long total = strtol(linea, &fin, 10);
    if (fin == linea || total < 0) {
        printf("Escribe un número de segundos.\n");
        return EXIT_FAILURE;
    }

    long horas = total / 3600;
    long minutos = total % 3600 / 60;
    long segundos = total % 60;
    printf("%ld h %ld min %ld s\n", horas, minutos, segundos);
    return 0;
}
salida1 h 2 min 5 s

Aquí la división entera es justo lo que se necesita: / da cuántas horas completas hay y %, lo que sobra.

Reto 3. ¿Qué escribe este programa? Piénsalo antes de mirar: unsigned int a = 3; int b = -1; printf("%d\n", a > b);

Ver la solución
#include <stdio.h>

int main(void)
{
    unsigned int a = 3;
    int b = -1;
    printf("%d\n", a > b);
    return 0;
}
salida0

Escribe 0: según C, 3 no es mayor que -1. Al comparar un unsigned con un int, el int se convierte a unsigned, y -1 pasa a ser el número sin signo más grande, 4,294,967,295. Con -Wall -Wextra, gcc avisa:

gcc: avisoreto3.c: In function 'main':
reto3.c:7:22: warning: comparison of integer expressions of different signedness: 'unsigned int' and 'int' [-Wsign-compare]
    7 |     printf("%d\n", a > b);
      |                      ^

Mezclar enteros con y sin signo es una de las fuentes de errores más típicas de C, y la vemos de nuevo en las rarezas.

Cómo está hecho este curso

Cada fragmento de código se compiló con gcc (GCC) 15.3.0 en un contenedor limpio de la imagen oficial gcc:15 (con glibc 2.41), con -Wall -Wextra, un usuario normal y sin acceso a la red. Las salidas, los avisos del compilador y los informes de los sanitizadores están copiados de esa ejecución; por eso los archivos que citan se llaman como los ejemplos del curso. Los mensajes de GCC salen en inglés, como en la mayoría de las instalaciones.

¿Buscas una receta concreta en vez de una lección? En Ejemplos de C hay tareas resueltas: leer archivos, CSV, argumentos, ordenar, listas enlazadas, tablas hash, hilos… Y antes de escribir C para producción, lee Los fallos de seguridad típicos de C.