1. Programas en C que ya usaste
En el curso de Bash escribiste cientos de líneas para la shell. Pero la shell misma es un programa, y se puede preguntar qué clase de archivo es:
$ file /usr/bin/bash
/usr/bin/bash: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=a292b52b…, for GNU/Linux 3.2.0, stripped
$ ldd /usr/bin/bash
linux-vdso.so.1 (0x00007ffd6f1dd000)
libtinfo.so.6 => /usr/lib/x86_64-linux-gnu/libtinfo.so.6 (0x0000759f3438c000)
libc.so.6 => /usr/lib/x86_64-linux-gnu/libc.so.6 (0x0000759f34000000)
/lib64/ld-linux-x86-64.so.2 (0x0000759f3454d000)
No es un texto como un script, sino un ejecutable ELF: instrucciones de máquina para un procesador x86-64, en el formato que entiende Linux. Fue escrito en C, compilado, y depende de unas pocas bibliotecas compartidas, entre ellas libc.so.6, la biblioteca de C del sistema. Todo eso, el formato ELF, las bibliotecas compartidas, el cargador ld-linux que las une al arrancar, es tema de este curso.
Lo mismo ocurre con casi todo lo que hay debajo de la terminal: el núcleo Linux, la biblioteca de C, systemd, el servidor SSH, gzip, tar, rsync, el propio compilador de C. Hay excepciones, y van en aumento: algunas distribuciones recientes reemplazaron los comandos básicos como ls por versiones escritas en Rust, y el núcleo admite desde 2022 partes en ese lenguaje. Pero incluso esos programas, al final, le hablan al sistema a través de la biblioteca de C y de interfaces definidas en C. Quien quiera entender cómo funciona un sistema operativo tiene que poder leer C.
2. Qué es C
C es un lenguaje de programación compilado, imperativo y de propósito general, diseñado para escribir sistemas operativos. Sus programas se traducen a instrucciones de máquina antes de ejecutarse, y le dan al programador acceso directo a la memoria a través de direcciones, con muy poca maquinaria entre el código y el procesador.
Cada palabra de esa definición tiene consecuencias que veremos a lo largo del curso:
- Compilado: un programa, el compilador, traduce el código fuente a un ejecutable una sola vez. Lo que se ejecuta después ya no necesita al compilador ni al código fuente, a diferencia de un script, que Bash lee y ejecuta línea por línea cada vez.
- Imperativo: el programa es una secuencia de órdenes que cambian el estado de la memoria, una tras otra, como un script de Bash.
- Acceso directo a la memoria: una variable es un lugar con una dirección, y C permite conocer esa dirección, guardarla, hacer cuentas con ella y leer o escribir lo que hay allí. Es el tema de los bloques 4 a 7.
- Poca maquinaria: no hay un recolector de basura que libere la memoria olvidada, ni comprobaciones de que un índice esté dentro de un arreglo, ni excepciones. Lo que el programa no hace explícitamente, no ocurre.
Se suele decir que C es un lenguaje «de bajo nivel», y es cierto comparado con Python o con Bash. Pero es de más alto nivel que el lenguaje ensamblador: tiene variables con tipo, funciones, estructuras y bucles, y el mismo programa puede compilarse para procesadores muy distintos. Esa combinación, cerca de la máquina y a la vez independiente de una máquina en particular, es la razón de su éxito.
3. Una historia compartida con Unix
C y Unix nacieron juntos, en los Laboratorios Bell de AT&T, y cada uno se hizo a la medida del otro:
En 1969, Ken Thompson escribió la primera versión de Unix en el lenguaje ensamblador de una pequeña computadora, la PDP-7. El ensamblador es la forma legible de las instrucciones de un procesador concreto, y tiene un problema: un sistema escrito en él solo funciona en ese procesador, y para llevarlo a otro hay que reescribirlo entero. Thompson creó entonces un lenguaje más cómodo, B, y Dennis Ritchie lo extendió con tipos de datos y estructuras para aprovechar las máquinas más nuevas. El resultado, hacia 1972, fue C.
El paso decisivo llegó en 1973: el núcleo de Unix se reescribió en C. Era una idea audaz, porque se creía que un sistema operativo solo podía escribirse en ensamblador para ser lo bastante rápido. Funcionó, y cambió la historia: por primera vez, llevar un sistema operativo a una computadora nueva no exigía reescribirlo, sino escribir un compilador de C para esa máquina y adaptar las pocas partes que dependían del hardware. Unix se extendió a universidades y empresas, y C con él.
En 1978, Brian Kernighan y Dennis Ritchie publicaron The C Programming Language, conocido como «K&R», que durante años fue la definición del lenguaje. En 1989 llegó el primer estándar oficial, ANSI C, y desde entonces el lenguaje se revisa cada tantos años. En 1991, Linus Torvalds empezó a escribir Linux, un núcleo nuevo con la forma de Unix, en C. Medio siglo después, el lenguaje sigue siendo el mismo en lo esencial: un programa de los años ochenta, con pocos cambios, compila hoy.
4. Por qué los sistemas operativos se escriben en C
Un sistema operativo tiene necesidades que la mayoría de los programas no tiene. C las cubre todas, y por eso, cincuenta años después, sigue siendo la elección por defecto:
| Un sistema operativo necesita… | Y C ofrece… |
|---|---|
| Controlar exactamente cómo se organizan los datos en la memoria, porque el hardware los espera en un formato preciso. | Estructuras cuya disposición en memoria se conoce y se puede controlar, y punteros a cualquier dirección. |
| Funcionar antes de que exista cualquier otra cosa: no hay un sistema debajo que le preste servicios. | Un lenguaje que no necesita un entorno de ejecución: ni intérprete, ni máquina virtual, ni recolector de basura. |
| Ser rápido y predecible, porque cada demora del núcleo la sufren todos los programas. | Código que se traduce casi directamente a instrucciones de máquina, sin costos ocultos. |
| Hablar con el hardware: registros de dispositivos, interrupciones, tablas de memoria. | Acceso a direcciones concretas y operaciones de bits, y la posibilidad de mezclar unas pocas líneas de ensamblador. |
| Funcionar en muchos procesadores distintos. | Compiladores para prácticamente cualquier procesador que exista. |
| Ofrecer servicios a programas escritos en cualquier lenguaje. | Una forma de llamar funciones, la «interfaz binaria de C», que casi todos los lenguajes saben usar. |
El último punto merece atención. Python, Rust, Go y Java pueden llamar a funciones escritas en C, porque la forma en que C pasa los argumentos y devuelve los resultados es un acuerdo que respetan todos. Por eso la frontera entre los programas y el sistema operativo, y entre lenguajes distintos, se define casi siempre en C. Aunque escribas tus programas en otro lenguaje, cuando hablen con el sistema estarán hablando en C.
5. El primer programa
Por tradición, desde el libro de Kernighan y Ritchie, el primer programa en C muestra un saludo. Este es el nuestro:
#include <stdio.h>
int main(void)
{
printf("Hola, sistema operativo\n");
return 0;
}
$ gcc -Wall -Wextra -o hola hola.c
$ ./hola
Hola, sistema operativo
$ echo $?
0
$ ls -l hola.c hola
-rwxr-xr-x 1 ana ana 15952 oct 15 10:22 hola
-rw-r--r-- 1 ana ana 94 oct 15 10:22 hola.c
En el tema 3 lo leeremos línea por línea; por ahora alcanza con lo esencial. #include <stdio.h> trae las declaraciones de la biblioteca de entrada y salida, donde está printf. main es la función por la que empieza todo programa en C. Y el return 0 final es el código de salida que el programa le devuelve a quien lo ejecutó: el mismo $? que usaste en todos los scripts del curso de Bash. Un script que llama a un programa en C se entera así de si funcionó.
gcc es el compilador: lee hola.c y produce hola, un ejecutable de casi dieciséis mil bytes a partir de un fuente de noventa y cuatro. La diferencia no es el saludo: es todo lo que hace falta para que el sistema pueda cargar y arrancar el programa, y para que encuentre printf en la biblioteca de C. Las opciones -Wall -Wextra piden al compilador que advierta todo lo que parezca un error, y las usaremos siempre. El tema 4 sigue paso a paso el camino del fuente al ejecutable.
6. Ver la memoria desde un programa
Lo que distingue a C se ve en cuanto un programa pregunta dónde están sus propios datos. Este muestra el valor de dos variables, cuánto ocupan y en qué dirección de memoria viven, y lo mismo para una variable global y para el propio código de main:
#include <stdio.h>
int contador = 7;
int main(void)
{
int edad = 30;
char inicial = 'A';
printf("edad vale %d, ocupa %zu bytes y vive en %p\n", edad, sizeof edad, (void *) &edad);
printf("inicial vale %c, ocupa %zu byte y vive en %p\n", inicial, sizeof inicial, (void *) &inicial);
printf("contador vale %d y vive en %p\n", contador, (void *) &contador);
printf("el código de main empieza en %p\n", (void *) main);
return 0;
}
$ gcc -Wall -Wextra -o memoria memoria.c
$ ./memoria
edad vale 30, ocupa 4 bytes y vive en 0x7ffed608d3a4
inicial vale A, ocupa 1 byte y vive en 0x7ffed608d3a3
contador vale 7 y vive en 0x558354925010
el código de main empieza en 0x558354922169
$ ./memoria
edad vale 30, ocupa 4 bytes y vive en 0x7fff04021ed4
inicial vale A, ocupa 1 byte y vive en 0x7fff04021ed3
contador vale 7 y vive en 0x56860c8a8010
el código de main empieza en 0x56860c8a5169
Hay mucho que leer en esas ocho líneas, y cada cosa tiene su tema en el curso:
- Cada dato ocupa una cantidad exacta de bytes: un
int, 4; unchar, 1.sizeoflo dice, y en el tema 5 veremos por qué importa. - Cada dato tiene una dirección, que
&obtiene y%pmuestra en hexadecimal. Una variable que guarda una dirección es un puntero, el centro del bloque 4. - Las variables viven en zonas distintas:
edadeinicial, declaradas dentro demain, están juntas, una byte al lado de la otra, en direcciones que empiezan con0x7ff…, la pila.contador, declarada afuera, y el código demainestán en otra zona, cerca uno del otro. Es el mapa de memoria de un proceso, del tema 27. - Las direcciones cambian en cada ejecución. El sistema coloca cada zona en un lugar al azar cada vez que arranca un programa, una protección llamada ASLR (Address Space Layout Randomization) que dificulta los ataques. Las distancias dentro de cada zona, en cambio, se mantienen:
contadorestá siempre 0x2ea7 bytes después del código demain.
Ningún programa en Bash puede preguntar estas cosas. En C son lo primero que se aprende a mirar, porque la memoria es la materia con la que se trabaja.
7. Lo que C ofrece
| Virtud | Qué significa en la práctica |
|---|---|
| Velocidad | Un bucle en C puede ser cientos de veces más rápido que el mismo bucle en Bash o en Python, porque se ejecuta como instrucciones de máquina, sin un intérprete en el medio. |
| Control | El programa decide cuánta memoria usa, cuándo la pide y cuándo la devuelve, y cómo se ordenan los datos en ella. |
| Transparencia | Casi cada línea se corresponde con unas pocas instrucciones de máquina previsibles. No hay operaciones costosas escondidas detrás de una sintaxis inocente. |
| Portabilidad | Hay compiladores de C para prácticamente todos los procesadores, desde los de un microondas hasta los de una supercomputadora. |
| Ubicuidad | Cada sistema tiene un compilador de C y una biblioteca de C, y cada lenguaje sabe llamar a funciones de C. |
| Pequeñez | El lenguaje entero cabe en un libro delgado, el de Kernighan y Ritchie. Se puede conocer completo, algo difícil con la mayoría de los lenguajes modernos. |
8. Lo que C exige a cambio
La otra cara de esa libertad es que C confía en el programador por completo, y no lo protege de sus errores. Este programa tiene uno muy común: el bucle recorre un arreglo de tres elementos, pero la condición i <= 3 deja que i llegue a 3, una posición más de las que existen:
#include <stdio.h>
int main(void)
{
int notas[3] = {7, 9, 10};
int otra = 1234;
for (int i = 0; i <= 3; i++) /* error: i llega a 3 */
printf("notas[%d] = %d\n", i, notas[i]);
printf("otra = %d\n", otra);
return 0;
}
$ gcc -Wall -Wextra -o fuera fuera.c
$ ./fuera
notas[0] = 7
notas[1] = 9
notas[2] = 10
notas[3] = -448843008
otra = 1234
$ echo $?
0
El compilador no advirtió nada, el programa no se detuvo, no hubo ningún mensaje de error, y terminó con código 0, como si todo estuviera bien. notas[3] leyó los cuatro bytes que había en la memoria justo después del arreglo, fueran lo que fueran, y los mostró como un número. En Python, el mismo error produce un IndexError inmediato. En C, el programa sigue adelante con un dato inventado. Y si en lugar de leer hubiera escrito, habría modificado otra variable, o algo peor, sin que nadie se enterara.
No estamos indefensos, sin embargo. El compilador detecta este caso concreto si se le pide optimizar, porque entonces analiza el bucle con más cuidado; y una herramienta de diagnóstico, AddressSanitizer, incorporada en gcc, agrega al programa comprobaciones que lo detienen en el acceso exacto:
$ gcc -O2 -Wall -Wextra -o fuera fuera.c
fuera.c: In function ‘main’:
fuera.c:9:9: warning: iteration 3 invokes undefined behavior [-Waggressive-loop-optimizations]
9 | printf("notas[%d] = %d\n", i, notas[i]);
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
fuera.c:8:23: note: within this loop
8 | for (int i = 0; i <= 3; i++) /* error: i llega a 3 */
| ~~^~~~
$ gcc -g -fsanitize=address -o fuera fuera.c
$ ./fuera
notas[0] = 7
notas[1] = 9
notas[2] = 10
=================================================================
==491==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x733399dd002c …
READ of size 4 at 0x733399dd002c thread T0
#0 0x6474b59943e6 in main /home/ana/c/fuera.c:9
…
This frame has 1 object(s):
[32, 44) 'notas' (line 5) <== Memory access at offset 44 overflows this variable
La advertencia de la optimización usa una expresión que aparecerá mucho, undefined behavior, comportamiento indefinido, y AddressSanitizer dice exactamente qué pasó: una lectura de 4 bytes justo después del final de notas, en la línea 9. Aprender C es, en buena medida, aprender a usar estas herramientas, que veremos en los temas 29, 39 y 40. Estas son las responsabilidades que C deja en tus manos:
| En C, nadie comprueba… | Por eso hay que… | Temas |
|---|---|---|
| Que un índice esté dentro del arreglo | Llevar la cuenta del tamaño de cada arreglo y respetarlo. | 17, 18 |
| Que una cadena quepa en su destino | Usar las funciones que reciben el tamaño del destino. | 21, 22 |
| Que la memoria pedida se devuelva | Liberar cada bloque exactamente una vez, y no usarlo después. | 28, 29 |
| Que un puntero apunte a algo válido | Inicializarlos siempre, y comprobar el puntero nulo. | 15 |
| Que una operación haya funcionado | Mirar el valor que devuelve cada llamada, y errno cuando falla. | 37 |
| Que una variable tenga un valor antes de usarla | Inicializar las variables al declararlas. | 6 |
9. El comportamiento indefinido
El estándar de C llama comportamiento indefinido a lo que ocurre cuando un programa hace algo que el lenguaje prohíbe pero no está obligado a detectar, como leer fuera de un arreglo. El estándar no promete nada sobre el resultado: el programa puede dar un valor absurdo, detenerse, parecer funcionar perfectamente, o comportarse distinto con otro compilador o con otras opciones.
Es el concepto más importante, y más traicionero, de C. Un programa con comportamiento indefinido no está «casi bien»: está mal, aunque las pruebas pasen. Un ejemplo clásico es el desbordamiento de un entero con signo:
#include <stdio.h>
#include <limits.h>
int main(void)
{
int x = INT_MAX;
printf("INT_MAX = %d\n", x);
printf("INT_MAX + 1 = %d\n", x + 1);
unsigned int u = UINT_MAX;
printf("UINT_MAX + 1 = %u\n", u + 1);
return 0;
}
$ gcc -Wall -Wextra -o desborde desborde.c
$ ./desborde
INT_MAX = 2147483647
INT_MAX + 1 = -2147483648
UINT_MAX + 1 = 0
$ gcc -fsanitize=undefined -o desborde desborde.c && ./desborde
desborde.c:8:5: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
…
La salida parece razonable: el número «da la vuelta» y se vuelve negativo. Pero para un int, que tiene signo, ese resultado es casualidad: el estándar dice que el desbordamiento es indefinido, y el compilador tiene derecho a suponer que nunca ocurre, lo que puede hacer que una comprobación como if (x + 1 < x) desaparezca al optimizar. Para un unsigned int, en cambio, la vuelta a cero está definida y garantizada. El sanitizador de comportamiento indefinido, -fsanitize=undefined, lo detecta en tiempo de ejecución. Estos son los casos más frecuentes, cada uno con su tema:
| Comportamiento indefinido | Ejemplo | Tema |
|---|---|---|
| Leer o escribir fuera de un arreglo | notas[3] en un arreglo de 3 | 17, 18 |
| Usar una variable sin inicializar | int x; printf("%d", x); | 6 |
| Desbordar un entero con signo | INT_MAX + 1 | 7 |
| Desreferenciar un puntero nulo o inválido | *p con p == NULL | 15 |
| Usar memoria ya liberada | Leer un bloque después de free | 29 |
| Liberar dos veces | free(p); free(p); | 29 |
| Modificar una cadena literal | char *s = "hola"; s[0] = 'H'; | 21 |
10. C y el sistema operativo
Un programa en C no puede escribir en la pantalla por sí mismo, ni abrir un archivo, ni pedir memoria: todo eso lo hace el núcleo, y el programa se lo pide con una llamada al sistema. La herramienta strace, que muestra cada llamada al sistema de un programa, deja ver qué hizo realmente hola:
$ strace ./hola 2>&1 | tail -n 6
brk(NULL) = 0x5622a618b000
brk(0x5622a61ad000) = 0x5622a61ad000
write(1, "Hola, sistema operativo\n", 24Hola, sistema operativo
) = 24
exit_group(0) = ?
+++ exited with 0 +++
El printf del programa terminó convertido en write(1, "Hola, sistema operativo\n", 24): escribir 24 bytes en el descriptor 1, la salida estándar de la que tanto hablamos en los cursos anteriores. Y el return 0, en exit_group(0), que le entrega al núcleo el código de salida. Las brk anteriores piden memoria para el buffer de printf. En total, este programa de dos líneas hizo 34 llamadas al sistema, casi todas para que el cargador preparara el proceso antes de llegar a main.
Entre el programa y el núcleo hay una capa intermedia, la biblioteca de C, que en Linux es casi siempre la de GNU, glibc: el libc.so.6 que mostraba ldd. Ofrece dos tipos de funciones, que el manual separa en secciones distintas:
| Sección del manual | Contiene | Ejemplos |
|---|---|---|
man 2 | Las llamadas al sistema: funciones finas que piden algo directamente al núcleo. | write, open, read, fork, brk |
man 3 | Las funciones de biblioteca: más cómodas, a menudo construidas sobre las anteriores. | printf, fopen, malloc, strlen |
En este curso usaremos sobre todo las de la sección 3, y en el tema 38 veremos las dos formas de trabajar con archivos, una sobre cada capa. Los cursos siguientes, empezando por Procesos e Hilos, viven en la sección 2.
11. C frente a otros lenguajes
C no es la mejor elección para todo, y conviene saber cuándo no lo es. Esta comparación, simplificada, ubica a C entre los lenguajes que probablemente conoces o conocerás:
| Lenguaje | Se ejecuta | La memoria | Brilla en |
|---|---|---|---|
| Bash | Interpretado | No se maneja | Unir programas y automatizar tareas del sistema. |
| Python | Interpretado | Automática | Datos, herramientas, prototipos: escribir rápido. |
| C | Compilado | Manual | Núcleos, controladores, bibliotecas del sistema, dispositivos pequeños. |
| C++ | Compilado | Manual, con ayudas | Programas grandes y rápidos: navegadores, juegos, bases de datos. |
| Rust | Compilado | Manual, verificada por el compilador | Lo mismo que C, con garantías contra los errores de memoria. |
| Go | Compilado | Automática | Servidores y herramientas de red e infraestructura. |
Rust merece una mención especial, porque fue creado precisamente para resolver los problemas de la sección 8: su compilador rechaza los programas que podrían acceder a memoria inválida. Por eso está entrando, de a poco, en terrenos que eran exclusivos de C, como el núcleo Linux y los comandos básicos de algunas distribuciones. Pero C sigue siendo el idioma común: las interfaces del sistema se definen en C, la enorme mayoría del código existente está en C, y entender C es la base para entender por qué existe Rust. Lo que aprendas aquí sobre la memoria vale para cualquier lenguaje de sistemas.
12. Las versiones del lenguaje
C está definido por un estándar internacional, ISO/IEC 9899, que se revisa cada tantos años. Cada versión agrega cosas sin romper los programas anteriores:
| Versión | Año | Lo más importante que trajo |
|---|---|---|
| K&R C | 1978 | El lenguaje original, definido por el libro. |
| C89 / C90 | 1989 | El primer estándar: prototipos de funciones y la biblioteca estándar. |
| C99 | 1999 | Comentarios con //, declarar variables en cualquier lugar, stdint.h, bool. |
| C11 | 2011 | Hilos y operaciones atómicas en el estándar, _Static_assert. |
| C17 | 2018 | Correcciones de C11, sin cambios nuevos: la versión más estable y difundida. |
| C23 | 2024 | bool, true y false como palabras clave, nullptr, constexpr, atributos. |
Los compiladores eligen una versión por omisión, que cambia con el tiempo; gcc 15, por ejemplo, usa C23 con extensiones propias de GNU. Para que los ejemplos del curso funcionen igual con compiladores de distintas épocas, compilaremos con -std=c17, y señalaremos lo que cambia en C23 cuando aparezca.
Hay además un segundo estándar que nos acompañará: POSIX, que no define el lenguaje sino la interfaz de los sistemas de la familia Unix. printf y malloc son de C, y existen en cualquier sistema; write, open o getopt son de POSIX, y existen en Linux, macOS y los demás Unix, pero no necesariamente en otros sistemas.
13. Cómo trabajaremos
Todos los ejemplos del curso están desarrollados y probados en Linux con gcc. Para seguirlos hace falta un sistema Linux, real, en una máquina virtual o en WSL, con las herramientas de desarrollo instaladas:
$ sudo apt install build-essential gdb valgrind strace
# build-essential incluye gcc, make y la biblioteca de C con sus encabezados
En el tema 2 preparamos el entorno con detalle. Desde ya, cuatro costumbres que valen para todo el curso:
- Compilar siempre con las advertencias encendidas:
-Wall -Wextra, y tratar cada advertencia como un error hasta entenderla. El compilador es el primer revisor de tu código. advertencias - Compilar a menudo, cada pocas líneas, en lugar de escribir cien y descubrir veinte errores juntos. pasos cortos
- Probar con los sanitizadores mientras se desarrolla:
-fsanitize=address,undefinedconvierte el comportamiento indefinido silencioso en un mensaje claro. sanitizadores - Consultar el manual:
man 3 printfyman 2 writedicen exactamente qué hace cada función, qué devuelve y cómo informa los errores. manual
Y la automatización del curso anterior sigue siendo útil: el build.sh del tema 36 de Bash, o un Makefile, que aprenderás a escribir en el bloque 8, compilan y prueban un programa con una sola orden.
14. El mapa del curso
Los 42 temas están ordenados para que cada bloque se apoye en los anteriores. Cada uno responde una pregunta:
| Bloque | Temas | La pregunta que responde |
|---|---|---|
| 1. Del código fuente al programa | 1–4 | ¿Qué es C, y cómo se convierte un archivo de texto en un programa? |
| 2. Tipos, operadores y control | 5–10 | ¿Cómo se representan los datos, y cómo decide y repite un programa? |
| 3. Funciones y organización | 11–14 | ¿Cómo se divide un programa en partes y en archivos? |
| 4. Punteros | 15–20 | ¿Qué es una dirección, y qué se puede hacer con ella? |
| 5. Cadenas y arreglos | 21–23 | ¿Cómo se trabaja con texto y con listas sin desbordarlos? |
| 6. Estructuras y tipos propios | 24–26 | ¿Cómo se agrupan datos, y cómo quedan en la memoria? |
| 7. Memoria dinámica | 27–30 | ¿Cómo se pide y se devuelve memoria mientras el programa corre? |
| 8. Compilación, enlazado y bibliotecas | 31–36 | ¿Cómo se construyen programas grandes y se comparte código? |
| 9. Errores, archivos y depuración | 37–40 | ¿Cómo se detectan, se informan y se corrigen los errores? |
| 10. Integración y cierre | 41–42 | ¿Cómo se usa todo junto en una herramienta completa? |
Los bloques 2 y 3 enseñan lo que C comparte con casi todos los lenguajes, y van rápido si ya programaste. Los bloques 4 a 7 son el corazón del curso: lo que hace a C distinto, y lo que más se usa en los cursos siguientes. El 8 enseña a construir programas reales, y el 9, a encontrar lo que falla en ellos.
15. Resumen
- El núcleo Linux, la biblioteca de C, la shell y la mayoría de los comandos del sistema están escritos en C; son ejecutables ELF que dependen de
libc.so.6. - C es un lenguaje compilado e imperativo, con acceso directo a la memoria y muy poca maquinaria entre el código y el procesador.
- C nació hacia 1972 junto con Unix, y en 1973 el núcleo de Unix se reescribió en C, lo que permitió llevarlo a otras máquinas sin reescribirlo. Linux, de 1991, también está escrito en C.
- Los sistemas operativos se escriben en C porque permite controlar la memoria, no necesita un entorno de ejecución, es rápido y predecible, habla con el hardware y existe para cualquier procesador.
gcc -Wall -Wextra -o hola hola.ccompila un programa; elreturndemaines su código de salida, el$?de la shell.- Cada dato en C ocupa una cantidad de bytes conocida y tiene una dirección; las direcciones cambian en cada ejecución por ASLR.
- C no comprueba los índices, ni la memoria liberada, ni los punteros: un error puede no producir ningún mensaje.
- El comportamiento indefinido es lo que ocurre cuando un programa viola las reglas del lenguaje: el estándar no promete nada, aunque el programa parezca funcionar.
- Las advertencias del compilador, AddressSanitizer y
-fsanitize=undefineddetectan muchos de esos errores. - Los programas le piden servicios al núcleo con llamadas al sistema, documentadas en la sección 2 del manual; la biblioteca de C, de la sección 3, las envuelve.
- El curso usa C17 sobre Linux, con
gcc, y presta atención a POSIX, la interfaz de los sistemas Unix.
16. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Qué diferencia hay entre un script de Bash y un programa en C a la hora de ejecutarlos?
El script es texto que Bash lee e interpreta línea por línea cada vez que se ejecuta. El programa en C se traduce una sola vez, con el compilador, a un ejecutable con instrucciones de máquina, que luego se ejecuta directamente, sin necesitar al compilador ni al código fuente.
2. ¿Por qué fue tan importante reescribir el núcleo de Unix en C en 1973?
Porque un sistema escrito en ensamblador solo funciona en el procesador para el que se escribió. Escrito en C, llevarlo a una máquina nueva solo requería un compilador de C para ella y adaptar las pocas partes que dependían del hardware. Eso permitió que Unix se extendiera a muchas computadoras distintas.
3. Nombra tres necesidades de un sistema operativo que C cubre bien.
Por ejemplo: controlar exactamente cómo se organizan los datos en la memoria; funcionar sin un entorno de ejecución debajo, como un intérprete o un recolector de basura; ser rápido y predecible; acceder al hardware; funcionar en muchos procesadores; y ofrecer una interfaz que cualquier lenguaje pueda llamar.
4. ¿Qué relación hay entre el return 0 de main y el $? de la shell?
El valor que devuelve main es el código de salida del programa, que el núcleo le entrega al proceso que lo ejecutó. En la shell, ese valor queda en $?, y es lo que usan if, && o set -e para saber si el programa funcionó.
5. En memoria.c, ¿por qué las direcciones cambian de una ejecución a otra?
Por ASLR: el sistema coloca la pila, el código y las demás zonas de memoria de cada proceso en lugares al azar cada vez que se ejecuta, para dificultar los ataques que necesitan conocer esas direcciones. Las distancias dentro de una misma zona se mantienen.
6. ¿Qué pasó en fuera.c cuando el bucle leyó notas[3], y por qué el programa terminó con código 0?
Leyó los bytes que había en la memoria justo después del arreglo y los mostró como un número. C no comprueba que el índice esté dentro del arreglo, de modo que no hubo ningún error ni ningún mensaje, y el programa siguió hasta el final con normalidad. Es comportamiento indefinido: el resultado podría haber sido cualquier otro.
7. ¿Qué es el comportamiento indefinido, y por qué un programa que lo tiene está mal aunque funcione?
Es lo que ocurre cuando un programa hace algo que el lenguaje prohíbe sin obligación de detectarlo. El estándar no promete ningún resultado: que hoy funcione es casualidad, y puede dejar de hacerlo con otro compilador, otras opciones de optimización u otros datos.
8. ¿Por qué INT_MAX + 1 es comportamiento indefinido, pero UINT_MAX + 1 no?
Porque el estándar define que la aritmética sin signo da la vuelta: UINT_MAX + 1 vale siempre 0. El desbordamiento de un entero con signo, en cambio, no está definido, y el compilador puede suponer que nunca ocurre, con efectos inesperados al optimizar.
9. ¿Qué mostró strace sobre el printf de hola.c?
Que terminó convertido en una llamada al sistema write(1, "Hola, sistema operativo\n", 24): escribir 24 bytes en el descriptor 1, la salida estándar. El programa no escribe en la pantalla por sí mismo, sino que se lo pide al núcleo.
10. ¿Qué diferencia hay entre las secciones 2 y 3 del manual?
La sección 2 documenta las llamadas al sistema, funciones finas que piden algo directamente al núcleo, como write u open. La sección 3, las funciones de la biblioteca de C, más cómodas y a menudo construidas sobre las anteriores, como printf o fopen.
Para practicar
- Ejecuta
fileylddsobre varios programas de tu sistema:bash,gzip,ssh,python3. ¿Cuáles dependen delibc.so.6? Si alguno es un script, ¿qué intérprete indicafile? - Compila y ejecuta
hola.c. Cambia elreturn 0porreturn 3, vuelve a compilar y comprueba el valor de$?. Prueba también./hola && echo funcionócon cada valor. - Compila
memoria.cy ejecútalo varias veces. Calcula la distancia entre las direcciones deedadeinicial, y entrecontadorymain. ¿Cuáles se mantienen? - Compila
fuera.csin opciones, con-O2 -Wally con-fsanitize=address, y compara lo que ocurre en cada caso. Luego cambia el bucle para que escriba ennotas[3]en lugar de leer, y observa el valor deotra. - Ejecuta
strace -c ./holaystrace -c ls. ¿Cuántas llamadas al sistema hace cada uno? ¿Cuáles aparecen enlsque no aparecen enhola? - Lee
man 3 printfyman 2 write, y anota qué devuelve cada función y cómo informa un error.