← Programación en C para Sistemas Operativos Bloque 1 · Del código fuente al programa · Tema 3 de 42

Tema 3

Anatomía de un programa en C

El programa hola.c tiene siete líneas, y casi todas esconden algo. Detrás del #include hay ochocientas líneas que el programa nunca muestra; main no es la primera función que se ejecuta, ni el return la última; y el número que devuelve llega a la shell recortado a un byte. En este tema leemos el programa pieza por pieza, vemos qué ocurre antes y después de main, y repasamos las reglas de escritura que valen para todo programa en C: sentencias, cadenas, comentarios, nombres y estilo.

1. El programa, línea por línea

Este es el programa del tema 1, que prometimos leer con calma:

hola.c
#include <stdio.h>

int main(void)
{
    printf("Hola, sistema operativo\n");
    return 0;
}
LíneaQué esQué hace
#include <stdio.h>Una directiva del preprocesador.Copia en este lugar el contenido del encabezado stdio.h, que declara printf.
(línea en blanco)Espacio.Nada: el compilador la ignora. Solo separa visualmente las partes.
int main(void)La cabecera de una función.Dice que main no recibe argumentos (void) y devuelve un entero (int).
{Apertura del cuerpo.Empieza el bloque de sentencias de la función.
printf("…\n");Una sentencia: una llamada a función.Escribe la cadena en la salida estándar. El ; la termina.
return 0;Una sentencia de retorno.Termina main y entrega 0, el código de salida del programa.
}Cierre del cuerpo.Termina la función.

Un programa en C, por grande que sea, se compone de estas mismas piezas: directivas del preprocesador, que empiezan con #; declaraciones de funciones, variables y tipos; y funciones, cuyo cuerpo es una secuencia de sentencias. Una de esas funciones se llama main, y es la que el sistema ejecuta. El resto del tema recorre cada pieza.

2. #include y el preprocesador

Antes de compilar, gcc pasa el fuente por el preprocesador, un programa que trabaja con texto: procesa las líneas que empiezan con # y entrega al compilador el resultado. #include <stdio.h> le pide que reemplace esa línea por el contenido completo del archivo stdio.h. La opción -E, que ya usamos en el tema 2, detiene gcc después de esa etapa y muestra lo que ve el compilador:

terminal · lo que ve el compilador
$ gcc -E hola.c | wc -l
845
$ gcc -E hola.c | tail -n 12
extern int __overflow (FILE *, int);
# 977 "/usr/include/stdio.h" 3 4

# 2 "hola.c" 2


# 3 "hola.c"
int main(void)
{
    printf("Hola, sistema operativo\n");
    return 0;
}
$ gcc -E hola.c | grep '^# ' | awk '{print $3}' | sort -u | grep -c '\.h"'
26

Las siete líneas de hola.c se convirtieron en 845. Al final aparece nuestro programa, intacto; todo lo anterior es stdio.h, y los veinticinco encabezados que a su vez incluye, con sus propios #include, para definir tipos como FILE o size_t. Las líneas que empiezan con # y un número son marcas que deja el preprocesador para que el compilador sepa de qué archivo y de qué línea vino cada parte: gracias a ellas, un error en main se informa como hola.c:5, y no como la línea 842 de un texto que nadie escribió.

Hay dos formas de escribir un #include, y la diferencia es dónde se busca el archivo:

FormaDónde buscaSe usa para
#include <stdio.h>Solo en los directorios del sistema, la lista que mostraba gcc -v en el tema 2, y los que agregue -I.Los encabezados de la biblioteca de C y de otras bibliotecas instaladas.
#include "lista.h"Primero en el directorio del archivo que lo incluye, y después en los del sistema.Los encabezados de tu propio programa.

Los encabezados propios aparecerán en el tema 13, cuando dividamos un programa en varios archivos, y el preprocesador completo, con #define y la compilación condicional, en el tema 14. Por ahora alcanza con una regla: una directiva ocupa una línea entera, empieza con #, y no termina con punto y coma, porque no es una sentencia de C sino una orden para el preprocesador.

3. Los encabezados más usados

Cada función de la biblioteca está declarada en un encabezado, y el manual dice cuál en su SYNOPSIS. Con el tiempo, los más comunes se aprenden de memoria. Estos son los que aparecerán una y otra vez en el curso, separados según qué estándar los define:

EncabezadoEstándarDeclara, entre otras cosas
stdio.hCEntrada y salida: printf, fprintf, fgets, fopen, stdout, stderr.
stdlib.hCUtilidades generales: malloc, free, exit, atexit, getenv, strtol, EXIT_SUCCESS.
string.hCCadenas y bloques de memoria: strlen, strcmp, memcpy, memset.
stdint.h, stdbool.hC99Enteros de tamaño exacto como int32_t; bool, true y false (en C23 ya son parte del lenguaje).
limits.hCLos límites de cada tipo: INT_MAX, CHAR_BIT.
errno.hCerrno y los códigos de error como ENOENT.
unistd.hPOSIXLlamadas al sistema básicas: read, write, close, fork, _exit, getpid.
fcntl.hPOSIXopen y sus opciones, como O_RDONLY.
sys/types.h, sys/stat.hPOSIXTipos del sistema como pid_t; stat y los permisos de archivo.

Los encabezados de C existen en cualquier sistema con un compilador de C; los de POSIX, solo en Linux, macOS y los demás Unix. Un programa que incluye unistd.h ya no compila en Windows sin una capa de compatibilidad, y eso está bien para este curso, que trata justamente de la interfaz de los sistemas Unix. Los nombres con barra, como sys/stat.h, son rutas dentro de los directorios del sistema: /usr/include/x86_64-linux-gnu/sys/stat.h.

4. Declarar antes de usar

¿Qué hay, en concreto, dentro de stdio.h que el programa necesita? Una sola línea, entre casi mil:

terminal · la declaración de printf
$ grep -n 'extern int printf' /usr/include/stdio.h
370:extern int printf (const char *__restrict __format, ...);

Esa línea es una declaración: le dice al compilador que existe, en algún lugar, una función llamada printf, que recibe una cadena de formato y luego cualquier cantidad de argumentos (los ...), y que devuelve un int, la cantidad de caracteres que escribió. No contiene el código de printf: eso es la definición, y está ya compilada dentro de libc.so.6. Con la declaración, el compilador puede comprobar que la llamada es correcta y generar el código para hacerla; el enlazador, después, la conecta con la definición.

C exige que todo nombre esté declarado antes de usarse, y lee el archivo de arriba hacia abajo, una sola vez. Por eso el #include va al principio, y por eso, en el tema 2, quitarlo producía implicit declaration of function ‘printf’. Lo mismo valdrá para tus propias funciones en el tema 11.

Hay una sorpresa al mirar qué funciones de la biblioteca usa realmente el ejecutable. nm lista los símbolos de un archivo: T marca los definidos en él, y U los que vienen de otra parte:

terminal · los símbolos de hola
$ gcc -std=c17 -Wall -Wextra -o hola hola.c
$ nm hola | grep -E ' [TU] '
                 U __libc_start_main@GLIBC_2.34
0000000000001168 T _fini
0000000000001000 T _init
0000000000001060 T _start
0000000000001149 T main
                 U puts@GLIBC_2.2.5
$ gcc -std=c17 -fno-builtin -o hola hola.c && nm hola | grep ' U '
                 U __libc_start_main@GLIBC_2.34
                 U printf@GLIBC_2.2.5

El programa llama a printf, pero el ejecutable usa puts. El compilador conoce las funciones estándar, y sabe que un printf con una cadena sin % que termina en \n hace lo mismo que puts con la cadena sin el \n, que es más simple y más rápido. Lo reemplazó por su cuenta. Con -fno-builtin, que le prohíbe aprovechar ese conocimiento, vuelve a aparecer printf. Es un buen primer ejemplo de algo que veremos muchas veces: el compilador no traduce el programa palabra por palabra, sino que produce cualquier código que tenga el mismo efecto. Fíjate también en los otros nombres de la lista, _start y __libc_start_main: el programa tiene más funciones de las que escribimos. Volveremos a ellas en la sección 7.

5. La función main

main es la función que se ejecuta cuando arranca un programa en C. Todo programa completo tiene exactamente una, y su valor de retorno es el código de salida del programa. El estándar admite dos formas: sin parámetros, int main(void), o con los argumentos de la línea de órdenes, int main(int argc, char *argv[]).

La segunda forma recibe lo mismo que $# y $@ en un script de Bash: argc es la cantidad de argumentos, contando el nombre del programa, y argv es la lista de esos argumentos como cadenas. La usaremos desde la sección 7 de este tema, pero su estudio a fondo es del tema 23, cuando sepamos qué es un arreglo de punteros. Hay otras formas que se ven en programas viejos o en internet, y conviene reconocerlas:

FormaValidezComentario
int main(void)EstándarLa que usaremos cuando el programa no reciba argumentos.
int main(int argc, char *argv[])EstándarEquivale a char **argv. Los nombres son tradición, no obligación.
int main()Estándar, con maticesHasta C17, «parámetros sin especificar»: el compilador no comprueba las llamadas. En C23 equivale a (void).
int main(int argc, char *argv[], char *envp[])Extensión de UnixRecibe también el entorno. Funciona en Linux, pero lo portable es getenv.
void main(void)IncorrectaNo devuelve un código de salida. Aparece en libros viejos de otros sistemas.

La diferencia entre () y (void) antes de C23 es real, y el compilador lo muestra si se llama a main con argumentos que no espera:

terminal · mainvacio.c: int main() que se llama a sí misma con dos argumentos
$ gcc -std=c17 -Wall -Wextra -o mv mainvacio.c
mainvacio.c: In function ‘main’:
mainvacio.c:2:5: warning: infinite recursion detected [-Winfinite-recursion]
…
$ gcc -std=c23 -Wall -Wextra -o mv mainvacio.c
mainvacio.c: In function ‘main’:
mainvacio.c:4:12: error: too many arguments to function ‘main’; expected 0, have 2
    4 |     return main(1, 2);
      |            ^~~~ ~

En C17, main(1, 2) compila: el compilador solo advierte de la recursión infinita, no de los argumentos de más. En C23, () significa «ningún parámetro», y la llamada es un error. Escribir (void) da el comportamiento correcto con cualquier versión. Y void main muestra por qué el tipo int importa:

terminal · voidmain.c, con void main que solo llama a printf
$ gcc -std=c17 -Wall -Wextra -o voidmain voidmain.c
voidmain.c:3:6: warning: return type of ‘main’ is not ‘int’ [-Wmain]
    3 | void main(void)
      |      ^~~~
$ ./voidmain; echo $?
void main
10

El programa terminó con código 10, que nadie pidió. Al no haber un valor de retorno, el código de salida es lo que hubiera quedado en el registro del procesador donde se devuelven los resultados, y lo último que dejó un valor allí fue printf, que devuelve la cantidad de caracteres escritos: los diez de "void main\n". Un script que comprobara $? creería que el programa falló.

6. El valor de retorno

El número que devuelve main viaja hasta el núcleo, y de ahí al proceso que ejecutó el programa: en la shell, a $?. Por convención, que ya conoces del curso de Bash, 0 significa éxito y cualquier otro valor, algún tipo de fallo. stdlib.h define dos nombres para que el código lo diga explícitamente:

terminal · las dos constantes del estándar
$ grep -n 'define\s*EXIT_' /usr/include/stdlib.h
96:#define	EXIT_FAILURE	1	/* Failing exit status.  */
97:#define	EXIT_SUCCESS	0	/* Successful exit status.  */

Pero aunque main devuelve un int, que puede llegar a más de dos mil millones, el código de salida de un proceso en Unix ocupa un solo byte: solo se conservan los 8 bits más bajos, y el resultado está siempre entre 0 y 255.

terminal · tres programas que solo hacen return
$ ./retorno; echo $?       # return 300;
44
$ ./menosuno; echo $?      # return -1;
255
$ ./r256; echo $?          # return 256;
0

300 es 256 + 44, y se pierde el 256. −1, en binario, tiene todos los bits encendidos, y los últimos ocho forman 255. Y 256 se convierte en 0: un programa que quisiera informar un error con ese valor parecería haber funcionado. Por eso los códigos de salida se eligen siempre entre 0 y 125; los valores mayores tienen significados especiales en la shell, como 127, «comando no encontrado», o 128 más un número de señal cuando un proceso muere por una señal.

Hay una excepción curiosa a la regla de que una función int debe devolver algo: si la ejecución llega a la llave final de main sin un return, el estándar, desde C99, garantiza que el resultado es 0.

terminal · sinreturn.c, un main sin return
$ gcc -std=c17 -Wall -Wextra -o sinreturn sinreturn.c && ./sinreturn; echo $?
sin return
0

Ni siquiera hay advertencia. Pero la excepción vale solo para main: en cualquier otra función que devuelva un valor, olvidar el return y usar el resultado es comportamiento indefinido. En el curso escribiremos siempre el return explícito, con EXIT_SUCCESS y EXIT_FAILURE cuando el programa pueda fallar.

7. Antes y después de main

nm mostró que el ejecutable define una función _start que no escribimos, y que usa otra, __libc_start_main, de la biblioteca. Son las piezas que hacen funcionar a main: el núcleo no sabe nada de main, ni de C. Arranca el programa en su punto de entrada, que es _start, y el resto del recorrido es trabajo de la biblioteca de C:

La vida de un programa en C: el núcleo carga el programa con execve y salta a _start; _start llama a __libc_start_main, que prepara la biblioteca y llama a main; cuando main hace return n, se llama a exit(n), que ejecuta las funciones registradas con atexit, vacía los buffers y llama a exit_group(n) para que el núcleo termine el proceso. _exit(n) va directo a exit_group sin pasar por atexit ni por los buffers execve el núcleo carga _start punto de entrada __libc_start_main prepara la biblioteca main tu código exit(n) de la biblioteca atexit funciones registradas vaciar buffers stdout y archivos exit_group(n) el núcleo termina return n _exit(n): directo al núcleo La fila de arriba ocurre al arrancar; la de abajo, al terminar. Las cajas verdes son trabajo del núcleo; las demás, de la biblioteca de C.
El recorrido completo de un programa en C. return n en main equivale a llamar a exit(n).

__libc_start_main prepara la biblioteca, por ejemplo los flujos stdin, stdout y stderr, llama a main con argc y argv, y cuando main vuelve, pasa su resultado a exit. exit todavía tiene trabajo: ejecuta las funciones que el programa haya registrado con atexit, en orden inverso al de registro, y vacía los buffers de salida. Recién entonces llama a exit_group, la llamada al sistema que vimos con strace en el tema 1. Este programa registra una función que se ejecuta después de que main terminó:

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

static void despedida(void)
{
    printf("despedida: se ejecuta después de main\n");
}

int main(void)
{
    atexit(despedida);
    printf("main: empieza\n");
    printf("main: termina\n");
    return 0;
}
terminal
$ ./final
main: empieza
main: termina
despedida: se ejecuta después de main

Lo de los buffers merece una demostración, porque es fuente de sorpresas. printf no escribe directamente: guarda el texto en un buffer de la biblioteca, y lo entrega al núcleo con un write cuando el buffer se llena, cuando aparece un \n si la salida es una terminal, o cuando el programa termina con exit. Pero existe otra forma de terminar, _exit, de unistd.h, que es casi directamente la llamada al sistema y se saltea todo el trabajo de la biblioteca. Este programa escribe un texto sin \n y termina de tres formas, según el primer argumento:

salida.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    printf("antes de terminar");          /* sin \n: queda en el buffer */

    if (argc > 1 && argv[1][0] == 'e')
        exit(0);                          /* biblioteca: vacía los buffers */
    if (argc > 1 && argv[1][0] == '_')
        _exit(0);                         /* núcleo directo: no los vacía */
    return 0;
}
terminal · return, exit y _exit
$ ./salida; echo
antes de terminar
$ ./salida e; echo
antes de terminar
$ ./salida _; echo

$ strace ./salida e 2>&1 | tail -n 3
write(1, "antes de terminar", 17antes de terminar)       = 17
exit_group(0)                           = ?
+++ exited with 0 +++
$ strace ./salida _ 2>&1 | tail -n 2
exit_group(0)                           = ?
+++ exited with 0 +++

Con return y con exit, el texto aparece; con _exit, se pierde, y strace muestra por qué: no hubo ningún write, porque el texto nunca salió del buffer del proceso. _exit parece una curiosidad, pero es imprescindible en el curso Procesos e Hilos, donde un proceso hijo tiene que terminar sin vaciar buffers que comparte, copiados, con su padre. Por ahora, la lección es otra: lo que printf «escribió» no está escrito hasta que llega al núcleo.

8. Sentencias, bloques y espacios

C no da significado a los espacios, los tabuladores ni los saltos de línea, salvo para separar palabras. El compilador divide el texto en tokens, las unidades mínimas con significado, como int, main, (, "Hola…" o ;, y el espacio entre ellos le es indiferente. Este programa es idéntico a hola.c para el compilador:

compacto.c
#include <stdio.h>
int main(void){printf("Hola, sistema operativo\n");return 0;}

Compila sin advertencias y hace exactamente lo mismo. La única línea que no se puede juntar con las demás es el #include, porque las directivas del preprocesador sí dependen de los saltos de línea: cada una ocupa la suya. Que se pueda escribir así no significa que se deba: la sangría y los saltos de línea son para las personas que leen el código, y de eso trata la sección 13. Las reglas que sí importan al compilador son pocas:

9. Cadenas y secuencias de escape

Un texto entre comillas dobles, como "Hola, sistema operativo\n", es una cadena literal. Dentro de ella, la barra invertida inicia una secuencia de escape, una forma de escribir caracteres que no se pueden teclear directamente o que tienen un significado especial:

SecuenciaCarácterUso típico
\nSalto de línea (byte 10)Terminar cada línea de salida.
\tTabulador (byte 9)Separar columnas.
\\Una barra invertidaEscribir rutas de Windows o expresiones regulares.
\"Una comilla dobleComillas dentro de una cadena.
\'Una comilla simpleEl carácter '\''.
\0El byte nulo (byte 0)Marca el final de toda cadena en C.
\r, \aRetorno de carro, campanaVolver al principio de la línea; hacer sonar la terminal.
\x41, \101Un byte por su valor, en hexadecimal u octalAmbos son la letra A.

Este programa usa varias, y su salida pasa por od -c, que muestra cada byte, para ver qué se escribió realmente:

escapes.c
#include <stdio.h>

int main(void)
{
    printf("columna1\tcolumna2\n");
    printf("comillas: \"hola\"  barra: \\  porcentaje: %%\n");
    printf("una cadena "
           "partida en dos líneas\n");
    printf("antes\0después\n");
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o escapes escapes.c
escapes.c: In function ‘main’:
escapes.c:9:18: warning: embedded ‘\0’ in format [-Wformat-contains-nul]
    9 |     printf("antes\0después\n");
      |                  ^~
$ ./escapes
columna1	columna2
comillas: "hola"  barra: \  porcentaje: %
una cadena partida en dos líneas
antes$ ./escapes | od -c | tail -n 4
0000100   c   a   d   e   n   a       p   a   r   t   i   d   a       e
0000120   n       d   o   s       l 303 255   n   e   a   s  \n   a   n
0000140   t   e   s
0000143

Hay cuatro lecciones en esa salida:

10. Salida estándar y salida de errores

Un programa de sistema bien hecho separa sus resultados de sus mensajes de error, igual que los comandos que usaste en el curso de Bash: los resultados van a la salida estándar, el descriptor 1, y los errores a la salida de errores, el descriptor 2. En C, printf escribe en la primera; para la segunda se usa fprintf con el flujo stderr. Este programa reúne lo visto hasta aquí: encabezados con un comentario de para qué se incluyen, un valor de retorno con significado, y cada mensaje por su canal:

saludo.c
/*
 * saludo.c — saluda al usuario por su nombre.
 *
 * Toma el nombre de la variable de entorno USER. Si no existe,
 * avisa por la salida de errores y termina con código 1.
 */
#include <stdio.h>      /* printf, fprintf, stderr */
#include <stdlib.h>     /* getenv, EXIT_SUCCESS, EXIT_FAILURE */

int main(void)
{
    const char *usuario = getenv("USER");

    if (usuario == NULL) {
        fprintf(stderr, "saludo: la variable USER no está definida\n");
        return EXIT_FAILURE;
    }

    printf("Hola, %s\n", usuario);
    return EXIT_SUCCESS;
}
terminal · el programa, visto desde la shell
$ ./saludo; echo $?
Hola, ana
0
$ env -u USER ./saludo > salida.txt 2> errores.txt; echo $?
1
$ cat salida.txt
$ cat errores.txt
saludo: la variable USER no está definida
$ if env -u USER ./saludo 2>/dev/null; then echo ok; else echo "saludo falló"; fi
saludo falló

getenv devuelve el valor de una variable de entorno, o NULL, un puntero que no apunta a nada, si la variable no existe; env -u USER ejecuta el programa sin ella. Los punteros son el tema del bloque 4; aquí basta con ver la forma del programa, que se repetirá en todo el curso: comprobar el resultado de cada llamada que puede fallar, informar el problema por stderr con el nombre del programa al principio, como hacen los comandos de Unix, y terminar con un código distinto de cero. Con eso, el programa se integra en un script exactamente igual que grep o ls: se puede redirigir cada salida por separado y comprobar si funcionó con if.

11. Los comentarios

C tiene dos formas de comentario. /* … */ es la original: empieza con /*, termina con el primer */, y puede ocupar varias líneas. // …, tomado de C++ y estándar desde C99, llega hasta el final de la línea. El preprocesador reemplaza cada comentario por un espacio antes de compilar, de modo que no afectan al programa en nada.

Los comentarios /* */ tienen una trampa: no se pueden anidar. El primer */ cierra el comentario, aunque haya otro /* adentro:

terminal · comentario.c, con un comentario dentro de otro
$ cat -n comentario.c | sed -n '5p'
     5	    /* contador /* de intentos */ */
$ gcc -std=c17 -Wall -Wextra -o comentario comentario.c
comentario.c: In function ‘main’:
comentario.c:5:17: warning: ‘/*’ within comment [-Wcomment]
    5 |     /* contador /* de intentos */ */
comentario.c:5:36: error: expected expression before ‘/’ token
    5 |     /* contador /* de intentos */ */
      |                                    ^

El comentario terminó en el primer */, y el segundo quedó como código suelto: un * y una / que no significan nada en ese lugar. Esto importa sobre todo al «comentar» un bloque de código para desactivarlo: si ese código ya tenía comentarios /* */, el truco falla. La forma correcta de desactivar código es con el preprocesador, que sí admite anidamiento:

desactivar un bloque de código
#if 0
    printf("depuración: x = %d\n", x);   /* este comentario no molesta */
#endif

Más importante que la sintaxis es qué comentar. El código ya dice qué hace; un buen comentario dice por qué, o lo que el código no puede decir por sí mismo:

ComentarioValor
i = i + 1; /* suma 1 a i */Ninguno: repite el código, y si el código cambia, queda mintiendo.
/* el índice 0 es el nombre del programa: se salta */Explica una decisión que no es evidente.
/* sin \n: queda en el buffer */Advierte de un efecto que un lector podría no ver.
#include <stdlib.h> /* getenv */Dice por qué está esa línea, y permite saber cuándo sobra.
El bloque al principio de saludo.cDice qué hace el programa, qué espera y cómo informa los errores: lo primero que busca quien abre el archivo.

En el curso usamos /* */ por tradición y porque funciona con cualquier versión del estándar, pero // es igual de válido en C17 y muy común en el código moderno. Lo importante, como en todo lo relativo al estilo, es elegir una forma y mantenerla en todo el proyecto.

12. Identificadores y palabras clave

Los nombres que el programador elige para variables, funciones y tipos se llaman identificadores. Las reglas son pocas: pueden tener letras sin tilde, dígitos y guiones bajos; no pueden empezar con un dígito; y distinguen mayúsculas de minúsculas. Además, no pueden coincidir con una palabra clave del lenguaje:

terminal · palabra.c: una variable llamada default, otra Total, y un uso de total
$ gcc -std=c17 -Wall -Wextra -o palabra palabra.c
palabra.c: In function ‘main’:
palabra.c:5:9: error: expected identifier or ‘(’ before ‘default’
    5 |     int default = 3;
      |         ^~~~~~~
palabra.c:8:20: error: ‘total’ undeclared (first use in this function); did you mean ‘Total’?
    8 |     printf("%d\n", total);
      |                    ^~~~~
      |                    Total

default es una palabra clave, de switch, y no puede ser un nombre. Y total no existe, porque la variable declarada se llama Total: el compilador lo adivina y lo sugiere. C89 tenía 32 palabras clave, que todavía son el núcleo del lenguaje:

Para quéPalabras clave
Tiposchar short int long float double signed unsigned void
Tipos compuestosstruct union enum typedef
Calificadores y almacenamientoconst volatile auto register static extern
Control de flujoif else switch case default while do for break continue goto return
Operadorsizeof

Las versiones posteriores agregaron pocas: C99, inline y restrict, entre otras; C11, palabras como _Static_assert o _Thread_local. Estas tienen una forma extraña, con guion bajo y mayúscula, por una razón: los nombres así estaban reservados para la implementación, y ningún programa correcto podía estar usándolos, de modo que agregarlos no rompía nada. C23 dio el paso siguiente y agregó las versiones cómodas, como bool, true, static_assert o nullptr. De ahí sale una regla práctica para tus propios nombres:

13. El estilo

Como el compilador ignora los espacios, cada programador puede distribuir el código como quiera, y a lo largo de los años se formaron varias tradiciones. Estas son las tres más conocidas, aplicadas a la misma función:

estilo K&R, el del libro de Kernighan y Ritchie y del núcleo Linux
int mayor(int a, int b)
{
    if (a > b) {
        return a;
    } else {
        return b;
    }
}
estilo Allman, con cada llave en su propia línea
int mayor(int a, int b)
{
    if (a > b)
    {
        return a;
    }
    else
    {
        return b;
    }
}
estilo GNU, el de los programas del proyecto GNU, como bash o gcc
int
mayor (int a, int b)
{
  if (a > b)
    {
      return a;
    }
  else
    {
      return b;
    }
}

Ninguno es mejor que los otros, y los tres compilan igual. Lo que sí importa, y mucho, es la coherencia: un proyecto entero escrito en un solo estilo se lee sin esfuerzo, y uno que mezcla estilos obliga a pensar en la forma en lugar de en el contenido. Cuando trabajes en un proyecto existente, usa su estilo; el núcleo Linux, por ejemplo, documenta el suyo en Documentation/process/coding-style.rst, con sangría de tabuladores de ocho columnas, y lo comprueba con un script, checkpatch.pl. Este es el estilo de los ejemplos del curso:

AspectoEn el curso
LlavesK&R: la de una función en su propia línea; la de if, for o while, en la misma línea.
SangríaCuatro espacios por nivel, sin tabuladores.
NombresMinúsculas con guion bajo: leer_linea, total_bytes. Las constantes del preprocesador, en mayúsculas: TAM_MAXIMO.
Largo de líneaHasta unos 80 caracteres, para que el código se lea en una terminal y lado a lado.
EspaciosAlrededor de los operadores binarios (a + b), después de las comas y de if, for y while; no entre el nombre de una función y su (.
Un nombre por declaraciónint a; y int b; en líneas separadas, sobre todo con punteros.

Hay herramientas que aplican un estilo automáticamente. La más usada hoy es clang-format, del proyecto LLVM, que se instala con el paquete clang-format y reformatea un archivo según las reglas de un archivo .clang-format en el directorio del proyecto. Una configuración cercana al estilo del curso es esta:

~/c/.clang-format
BasedOnStyle: LLVM
IndentWidth: 4
BreakBeforeBraces: Linux
AllowShortIfStatementsOnASingleLine: Never
ColumnLimit: 80

Con ese archivo, clang-format -i saludo.c reescribe el fuente en el lugar, y VS Code puede aplicarlo cada vez que guardas. Automatizar el formato tiene una ventaja que va más allá de la estética: se deja de discutir sobre dónde van las llaves.

14. Errores frecuentes

ErrorQué pasaCorrección
Poner ; al final de un #includegcc advierte extra tokens at end of #include directive.Las directivas no son sentencias: sin ;.
Olvidar el encabezado de una funciónError: implicit declaration of function.Buscar el #include en el SYNOPSIS del manual.
Escribir Main o MAINError de enlace: undefined reference to `main'.En minúsculas: main.
Usar void mainAdvertencia, y un código de salida imprevisible.int main(void), con return.
Devolver un código mayor que 255 o negativoLa shell recibe solo los 8 bits más bajos: 256 llega como 0.Códigos entre 0 y 125; EXIT_SUCCESS y EXIT_FAILURE.
Olvidar el \n al final de la salidaEl siguiente $ queda pegado, y el texto puede quedar en el buffer.Terminar cada línea con \n.
Anidar comentarios /* */El primer */ cierra todo, y lo que sigue se compila como código.Desactivar código con #if 0 … #endif.
Escribir los errores con printfSe mezclan con los resultados, y una redirección los esconde en el archivo.fprintf(stderr, …).

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué hace exactamente #include <stdio.h>, y quién lo hace?

Lo hace el preprocesador, antes de la compilación: reemplaza la línea por el contenido completo del archivo stdio.h, que busca en los directorios del sistema. El compilador recibe el texto ya combinado, que para hola.c tiene 845 líneas.

2. ¿Qué diferencia hay entre la declaración de printf y su definición, y dónde está cada una?

La declaración dice cómo es la función: su nombre, sus parámetros y lo que devuelve. Está en stdio.h, y le permite al compilador comprobar y generar la llamada. La definición es el código de la función, y está ya compilada en la biblioteca de C, libc.so.6; el enlazador conecta la llamada con ella.

3. ¿Por qué nm hola muestra puts y no printf?

Porque el compilador sabe que un printf con una cadena sin especificadores que termina en \n equivale a puts con la cadena sin el \n, que es más simple, y lo reemplazó. El compilador puede generar cualquier código que tenga el mismo efecto observable. Con -fno-builtin no lo hace.

4. ¿Qué diferencia hay entre int main() e int main(void)?

Hasta C17, () en una definición significa que la función no tiene parámetros, pero en una declaración, que no se especifican, y el compilador no comprueba las llamadas: main(1, 2) compila. (void) dice explícitamente que no recibe ninguno. En C23 las dos formas son equivalentes. (void) es correcto en cualquier versión.

5. Un programa termina con return 300;. ¿Qué valor muestra echo $?, y por qué?

44. El código de salida de un proceso en Unix ocupa un byte: solo se conservan los 8 bits más bajos del valor, es decir, el resto de dividir por 256. 300 − 256 = 44.

6. ¿Qué devuelve main si la ejecución llega a la llave final sin un return? ¿Vale lo mismo para otras funciones?

Desde C99, llegar al final de main equivale a return 0. Para cualquier otra función que devuelva un valor, no: si la función termina sin return y quien la llamó usa el resultado, es comportamiento indefinido.

7. ¿Qué funciones se ejecutan antes de main, y qué hace exit después?

El núcleo arranca el programa en _start, el punto de entrada, que llama a __libc_start_main; esta prepara la biblioteca y llama a main. Cuando main vuelve, su resultado pasa a exit, que ejecuta las funciones registradas con atexit en orden inverso, vacía los buffers de salida y llama a la llamada al sistema exit_group.

8. En salida.c, ¿por qué el texto se pierde al terminar con _exit pero no con exit?

Porque printf guardó el texto, que no tenía \n, en un buffer de la biblioteca, sin entregarlo todavía al núcleo. exit vacía ese buffer con un write antes de terminar; _exit termina directamente, sin pasar por la biblioteca, y el contenido del buffer desaparece con el proceso. strace muestra que con _exit no hay ningún write.

9. ¿Por qué printf("antes\0después\n") solo muestra antes?

Porque en C una cadena termina en el primer byte nulo. \0 es ese byte, y printf deja de leer el formato al encontrarlo: después y el \n están en la memoria, pero para cualquier función de cadenas no forman parte de la cadena.

10. ¿Por qué no funciona «comentar» un bloque de código con /* */ si ya contiene comentarios, y qué se usa en su lugar?

Porque los comentarios /* */ no se anidan: el primer */ interior cierra el comentario exterior, y el resto se compila como código. Para desactivar un bloque se usa #if 0 … #endif, que el preprocesador elimina completo, con sus comentarios.

11. ¿Por qué los mensajes de error de un programa deben ir a stderr?

Para que no se mezclen con los resultados. Si se redirige la salida estándar a un archivo o a otro programa, los errores siguen apareciendo en la terminal, y los resultados quedan limpios. Es lo que hacen todos los comandos de Unix, y lo que permite que un programa en C se use en scripts como cualquiera de ellos.

12. ¿Por qué las palabras clave de C11 tienen nombres como _Static_assert?

Porque los nombres que empiezan con guion bajo y mayúscula estaban reservados para la implementación: ningún programa correcto podía usarlos, de modo que convertirlos en palabras clave no rompía código existente. C23 agregó después las versiones con nombres cómodos, como static_assert.

Para practicar

  1. Ejecuta gcc -E sobre un programa que incluya stdio.h, stdlib.h y string.h, y cuenta las líneas. Busca en el resultado las declaraciones de printf, exit y strlen, y compáralas con el SYNOPSIS de cada página del manual.
  2. Escribe un programa que devuelva el número que quieras y comprueba con echo $? qué llega a la shell para 0, 1, 125, 255, 256, 257 y −2. Explica cada resultado.
  3. Agrega a final.c una segunda función registrada con atexit, y comprueba en qué orden se ejecutan. Luego termina main con _exit(0) en lugar de return: ¿qué cambia?
  4. En salida.c, agrega \n al final del texto y vuelve a probar ./salida _, primero en la terminal y después con ./salida _ | cat. ¿Por qué el resultado es distinto en los dos casos?
  5. Modifica saludo.c para que salude con la variable HOME en lugar de USER, y que termine con código 2 si no existe. Comprueba con redirecciones que el mensaje de error no aparece en la salida estándar.
  6. Escribe un programa que muestre una tabla de tres columnas separadas por \t, con una fila que contenga comillas dobles y una barra invertida. Verifica los bytes con od -c.
  7. Toma compacto.c y reescríbelo en los tres estilos de la sección 13. Si tienes clang-format, aplícalo con la configuración del curso y compara el resultado con tu versión.