Sistemas de archivos: del bloque crudo al árbol de directorios
Sistemas de archivos: del bloque crudo al árbol de directorios
En el capítulo 10 trabajamos con datos que viajaban: mensajes entre procesos, canales, y las garantías criptográficas que hacían que un mensaje llegara íntegro y sin ser leído por terceros. Ahí el dato tenía una vida corta y un destinatario explícito. Ahora invertimos el problema. Este capítulo trata sobre datos que se quedan: información que debe sobrevivir al proceso que la creó, al cierre de sesión del usuario, al reinicio de la máquina y, en el peor de los casos, a un corte de energía en medio de una escritura.
El dispositivo que guarda esos datos no entiende de archivos. Un disco duro, un SSD o una tarjeta SD exponen al sistema operativo una sola abstracción: un arreglo gigantesco de bloques numerados de tamaño fijo, típicamente de 512 o 4096 bytes. El disco sabe hacer exactamente dos cosas: “dame el bloque número 8.412.093” y “escribe estos 4096 bytes en el bloque número 8.412.093”. No hay nombres, no hay carpetas, no hay permisos, no hay fechas. Todo eso lo inventa una capa de software.
Esa capa es el sistema de archivos. Su trabajo es traducir entre dos mundos que no se parecen en nada: por un lado un usuario que dice /home/ana/informe.pdf, por el otro un dispositivo que solo responde a números de bloque. Este capítulo desarma esa traducción pieza por pieza.
Qué problema resuelve exactamente un sistema de archivos
Imagina que el sistema operativo no ofreciera archivos. Cada programa tendría que llevar su propia contabilidad de qué bloques del disco le pertenecen, anotarla en algún lugar, y confiar en que ningún otro programa escriba encima. El primer instalador mal escrito destruiría los datos de todos los demás. Y si el equipo se apagara mientras esa contabilidad se estaba actualizando, no habría forma de saber en qué estado quedó.
Un sistema de archivos resuelve cinco problemas simultáneos:
- Nombrado. Asociar una cadena legible por humanos a un conjunto de bloques. Esto incluye una jerarquía, porque una lista plana de nombres deja de escalar apenas hay unos miles de elementos.
- Asignación de espacio. Decidir qué bloques libres se le entregan a un archivo cuando crece, y devolverlos cuando el archivo se borra, minimizando la fragmentación.
- Metadatos. Guardar todo lo que se sabe del archivo sin ser su contenido: tamaño, dueño, permisos, fechas, cantidad de nombres que apuntan a él.
- Protección. Decidir quién puede leer, escribir o ejecutar cada elemento, y hacer cumplir esa decisión en cada llamada al sistema.
- Consistencia. Garantizar que las estructuras internas queden en un estado válido incluso si la energía se corta a la mitad de una operación que tocaba varios bloques.
Los primeros cuatro puntos son de diseño. El quinto es el que separa un sistema de archivos de juguete de uno que puedes usar en producción, y es la razón por la que existe el journaling.
flowchart TD
A["Aplicación<br/>open('/home/ana/informe.pdf')"] --> B["Llamada al sistema<br/>frontera usuario-kernel"]
B --> C["Capa VFS<br/>interfaz común, independiente del formato"]
C --> D["Implementación concreta<br/>ext4 / btrfs / ZFS / FAT"]
D --> E["Caché de página<br/>bloques en memoria RAM"]
E --> F["Capa de bloques<br/>planificador de E/S, colas"]
F --> G["Controlador del dispositivo<br/>SATA / NVMe / USB"]
G --> H["Medio físico<br/>arreglo de bloques numerados"]
Cada capa de ese recorrido reduce la distancia entre el nombre y el número de bloque. La aplicación solo ve la primera flecha; todo lo demás ocurre dentro del kernel.
El archivo: contenido, metadatos y descriptores
Un archivo, visto desde el sistema de archivos, son tres cosas separadas que la gente suele confundir en una sola.
El contenido es una secuencia de bytes. El sistema de archivos no interpreta esos bytes: para él, un PDF, un ejecutable y un archivo de texto son idénticos. La interpretación la hace la aplicación, ayudada a veces por convenciones como la extensión del nombre o los primeros bytes del archivo, conocidos como número mágico. En UNIX el sistema operativo no exige que la extensión coincida con nada: renombrar foto.png a foto.txt no cambia un solo byte del contenido.
Los metadatos son la ficha administrativa del archivo. Viven en una estructura separada del contenido, y contienen el tamaño en bytes, el identificador del dueño, el del grupo, los bits de permiso, tres marcas de tiempo, la cantidad de nombres que apuntan a este archivo y la lista de bloques donde está el contenido. En la familia UNIX esa estructura se llama inodo, y la estudiamos en detalle más adelante.
El descriptor de archivo no pertenece al archivo sino al proceso que lo abrió. Es un número entero pequeño, un índice dentro de una tabla que el kernel mantiene por proceso. Cuando un programa hace open(), el kernel resuelve el nombre hasta el inodo, crea una estructura intermedia que guarda el desplazamiento de lectura actual y el modo de apertura, y devuelve el índice. Todas las lecturas y escrituras posteriores usan ese número.
La distinción importa porque explica comportamientos que sorprenden. Dos procesos que abren el mismo archivo tienen descriptores independientes con desplazamientos independientes: si uno lee 100 bytes, el otro sigue en el byte cero. En cambio, un proceso que hace fork() después de abrir un archivo comparte con su hijo la misma estructura intermedia, y por lo tanto el mismo desplazamiento: si el hijo escribe, el padre continúa desde donde el hijo dejó.
También explica por qué borrar un archivo grande a veces no libera espacio. El comando rm no borra el contenido: elimina la entrada del directorio y decrementa el contador de enlaces del inodo. El sistema de archivos libera los bloques solo cuando ese contador llega a cero y ningún proceso tiene el archivo abierto. Si un servidor mantiene abierto un archivo de registro de 40 GiB y alguien lo borra, df seguirá mostrando el disco lleno hasta que ese proceso se reinicie.
Este programa muestra los metadatos completos de cualquier ruta:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include <pwd.h>
#include <grp.h>
static void formato_permisos(mode_t modo, char salida[11]) {
strcpy(salida, "----------");
if (S_ISDIR(modo)) salida[0] = 'd';
if (S_ISLNK(modo)) salida[0] = 'l';
if (S_ISCHR(modo)) salida[0] = 'c';
if (S_ISBLK(modo)) salida[0] = 'b';
if (S_ISFIFO(modo)) salida[0] = 'p';
if (S_ISSOCK(modo)) salida[0] = 's';
if (modo & S_IRUSR) salida[1] = 'r';
if (modo & S_IWUSR) salida[2] = 'w';
if (modo & S_IXUSR) salida[3] = 'x';
if (modo & S_IRGRP) salida[4] = 'r';
if (modo & S_IWGRP) salida[5] = 'w';
if (modo & S_IXGRP) salida[6] = 'x';
if (modo & S_IROTH) salida[7] = 'r';
if (modo & S_IWOTH) salida[8] = 'w';
if (modo & S_IXOTH) salida[9] = 'x';
if (modo & S_ISUID) salida[3] = (modo & S_IXUSR) ? 's' : 'S';
if (modo & S_ISGID) salida[6] = (modo & S_IXGRP) ? 's' : 'S';
if (modo & S_ISVTX) salida[9] = (modo & S_IXOTH) ? 't' : 'T';
}
static void imprimir_fecha(const char *etiqueta, time_t t) {
char buffer[64];
struct tm tm_local;
localtime_r(&t, &tm_local);
strftime(buffer, sizeof buffer, "%Y-%m-%d %H:%M:%S", &tm_local);
printf(" %-24s %s\n", etiqueta, buffer);
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "uso: %s <ruta>\n", argv[0]);
return 1;
}
struct stat st;
if (lstat(argv[1], &st) != 0) {
perror("lstat");
return 1;
}
char permisos[11];
formato_permisos(st.st_mode, permisos);
struct passwd *duenio = getpwuid(st.st_uid);
struct group *grupo = getgrgid(st.st_gid);
printf("Ruta: %s\n", argv[1]);
printf(" %-24s %llu\n", "numero de inodo",
(unsigned long long) st.st_ino);
printf(" %-24s %llu\n", "dispositivo",
(unsigned long long) st.st_dev);
printf(" %-24s %s (%04o)\n", "permisos", permisos,
st.st_mode & 07777);
printf(" %-24s %s (%u)\n", "duenio",
duenio ? duenio->pw_name : "?", st.st_uid);
printf(" %-24s %s (%u)\n", "grupo",
grupo ? grupo->gr_name : "?", st.st_gid);
printf(" %-24s %lu\n", "enlaces duros",
(unsigned long) st.st_nlink);
printf(" %-24s %lld bytes\n", "tamanio logico",
(long long) st.st_size);
printf(" %-24s %lld (de 512 bytes)\n", "bloques asignados",
(long long) st.st_blocks);
printf(" %-24s %ld bytes\n", "tamanio de bloque de E/S",
(long) st.st_blksize);
long long ocupado = (long long) st.st_blocks * 512;
if (ocupado < (long long) st.st_size) {
printf(" %-24s si (ocupa menos de lo que declara)\n",
"archivo disperso");
}
imprimir_fecha("ultimo acceso (atime)", st.st_atime);
imprimir_fecha("ultima modificacion (mtime)", st.st_mtime);
imprimir_fecha("cambio de inodo (ctime)", st.st_ctime);
return 0;
}
Compílalo con gcc -Wall -o inspeccionar inspeccionar.c y pruébalo sobre un directorio, un archivo común y un enlace simbólico. Fíjate en la diferencia entre mtime y ctime: el primero cambia cuando cambia el contenido, el segundo cuando cambia el inodo. Si ejecutas chmod sobre un archivo, mtime se queda igual y ctime avanza. Esa distinción es la base de muchas herramientas de auditoría.
Directorios: el archivo que contiene nombres
Un directorio no es un contenedor especial del kernel. Es un archivo común cuyo contenido tiene un formato conocido por el sistema de archivos: una lista de pares que asocian un nombre con un número de inodo. Cada uno de esos pares se llama entrada de directorio, o dentry.
Esta es la observación que desbloquea la comprensión de todo el árbol de UNIX. El nombre de un archivo no vive en el archivo. Vive en el directorio que lo contiene. El inodo no sabe cómo se llama; sabe cuántos nombres apuntan a él, pero no cuáles son.
De ahí se deducen varias propiedades:
- Enlaces duros. Dos entradas de directorio distintas pueden apuntar al mismo número de inodo. Ambas son igualmente reales; ninguna es “la original”. El contador
st_nlinkvale 2. Borrar una deja la otra intacta. Como los números de inodo son locales a cada sistema de archivos, un enlace duro no puede cruzar de un sistema de archivos a otro: intentarlo devuelve el errorEXDEV. - Enlaces simbólicos. Son un archivo distinto, con su propio inodo, cuyo contenido es simplemente una ruta en texto. Cuando el kernel resuelve una ruta y encuentra uno, sustituye y sigue resolviendo. Pueden cruzar sistemas de archivos y pueden apuntar a nada, quedando “colgados”.
- Los directorios
.y..son entradas reales. Por eso un directorio recién creado tienest_nlinkigual a 2: la entrada en su padre, y su propia entrada.. Cada subdirectorio que se le agregue lo sube en uno, por el..del hijo. - Renombrar es barato.
mvdentro del mismo sistema de archivos no mueve un solo byte de contenido: reescribe entradas de directorio. Entre sistemas de archivos distintos, en cambio, es una copia completa seguida de un borrado, y por eso tarda y no es atómico.
flowchart LR
subgraph DIR["Directorio /home/ana"]
E1["informe.pdf → inodo 4711"]
E2["copia.pdf → inodo 4711"]
E3["atajo → inodo 4712"]
E4["notas.txt → inodo 4890"]
end
subgraph INODOS["Tabla de inodos"]
I1["inodo 4711<br/>nlink=2, 2 MiB<br/>bloques: 900..1411"]
I2["inodo 4712<br/>tipo: enlace simbolico<br/>contenido: '/var/log/sys.log'"]
I3["inodo 4890<br/>nlink=1, 3 KiB<br/>bloques: 77"]
end
E1 --> I1
E2 --> I1
E3 --> I2
E4 --> I3
I2 -.->|"se resuelve de nuevo"| OTRO["/var/log/sys.log<br/>otro inodo, quiza otro disco"]
En sistemas de archivos antiguos el directorio era una lista lineal: buscar un nombre entre 100.000 entradas obligaba a recorrerlas todas. Los sistemas modernos usan estructuras indexadas. ext4 activa por defecto la opción dir_index, que organiza las entradas en un árbol B con hashing del nombre (conocido como HTree), llevando la búsqueda de tiempo lineal a tiempo logarítmico. btrfs y ZFS usan árboles B propios para lo mismo.
Este programa recorre un directorio y clasifica lo que encuentra:
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
int main(int argc, char *argv[]) {
const char *ruta = (argc > 1) ? argv[1] : ".";
DIR *dir = opendir(ruta);
if (dir == NULL) {
perror("opendir");
return 1;
}
unsigned long archivos = 0, directorios = 0, enlaces = 0, otros = 0;
unsigned long long bytes_totales = 0;
struct dirent *entrada;
while ((entrada = readdir(dir)) != NULL) {
if (strcmp(entrada->d_name, ".") == 0 ||
strcmp(entrada->d_name, "..") == 0) {
continue;
}
char completa[4096];
snprintf(completa, sizeof completa, "%s/%s", ruta, entrada->d_name);
struct stat st;
if (lstat(completa, &st) != 0) {
continue;
}
const char *tipo;
if (S_ISDIR(st.st_mode)) { tipo = "directorio"; directorios++; }
else if (S_ISLNK(st.st_mode)) { tipo = "enlace"; enlaces++; }
else if (S_ISREG(st.st_mode)) { tipo = "archivo"; archivos++;
bytes_totales += st.st_size; }
else { tipo = "especial"; otros++; }
printf("%-12s inodo=%-10llu nlink=%-3lu %10lld %s\n",
tipo,
(unsigned long long) st.st_ino,
(unsigned long) st.st_nlink,
(long long) st.st_size,
entrada->d_name);
}
closedir(dir);
printf("\nResumen de %s\n", ruta);
printf(" archivos regulares : %lu (%llu bytes)\n",
archivos, bytes_totales);
printf(" directorios : %lu\n", directorios);
printf(" enlaces simbolicos : %lu\n", enlaces);
printf(" otros : %lu\n", otros);
return 0;
}
Ejecútalo sobre un directorio donde hayas creado un enlace duro con ln archivo copia y verás dos entradas con el mismo número de inodo y nlink=2.
El inodo por dentro
El inodo es la estructura central del diseño UNIX. Es un registro de tamaño fijo, almacenado en un área reservada del disco llamada tabla de inodos, que contiene todo lo que se sabe de un archivo excepto su nombre y su contenido.
Los campos típicos de un inodo de la familia ext son:
| Campo | Tamaño aproximado | Qué guarda |
|---|---|---|
| Modo | 2 bytes | Tipo de archivo (4 bits altos) y bits de permiso (12 bits bajos) |
| UID / GID | 2 + 2 bytes | Identificadores numéricos de dueño y grupo |
| Tamaño | 4 + 4 bytes | Longitud lógica del archivo en bytes |
| Marcas de tiempo | 4 × 4 bytes | atime, mtime, ctime y en ext4 también crtime (creación) |
| Contador de enlaces | 2 bytes | Cuántas entradas de directorio apuntan aquí |
| Bloques asignados | 4 bytes | Cuántos bloques de 512 bytes ocupa realmente |
| Banderas | 4 bytes | Atributos como inmutable, solo-anexar, compresión |
| Punteros de datos | 60 bytes | En ext2/ext3, 15 punteros; en ext4, la raíz del árbol de extents |
Un inodo de ext2 y ext3 ocupa 128 bytes. En ext4 el tamaño por defecto es 256 bytes, y ese espacio adicional se usa para marcas de tiempo con resolución de nanosegundos, la fecha de creación y atributos extendidos pequeños que caben dentro del propio inodo.
Fíjate en algo importante: la cantidad de inodos se fija al formatear. Si creas un sistema de archivos con un inodo por cada 16 KiB de espacio, y luego llenas el disco con millones de archivos de 200 bytes, agotarás los inodos mucho antes que los bloques. df -h mostrará espacio libre y las escrituras fallarán con ENOSPC. La herramienta para diagnosticarlo es df -i, que muestra el uso de inodos. btrfs y ZFS no tienen este problema porque asignan sus metadatos dinámicamente.
Los punteros indirectos
En ext2 y ext3 los 60 bytes de punteros se organizan como 15 entradas de 4 bytes:
- Los 12 primeros son punteros directos: apuntan a un bloque de datos cada uno.
- El 13.º es un puntero indirecto simple: apunta a un bloque que contiene únicamente punteros a bloques de datos.
- El 14.º es un indirecto doble: apunta a un bloque de punteros a bloques de punteros a datos.
- El 15.º es un indirecto triple: un nivel más.
Con bloques de 4 KiB y punteros de 4 bytes, cada bloque de punteros contiene 1024 entradas. El tamaño máximo direccionable es entonces:
- Directos: 12 bloques → 48 KiB
- Indirecto simple: 1024 bloques → 4 MiB
- Indirecto doble: 1024² = 1.048.576 bloques → 4 GiB
- Indirecto triple: 1024³ = 1.073.741.824 bloques → 4 TiB
El diseño es deliberadamente asimétrico. La mayoría de los archivos de un sistema real son pequeños, y para ellos los 12 punteros directos bastan: el inodo se lee en una sola operación y ya se sabe dónde está todo el contenido. Solo los archivos grandes pagan el costo de los saltos adicionales.
flowchart TD
INODO["Inodo<br/>metadatos + 15 punteros"]
INODO -->|"punteros 0 a 11<br/>directos"| D["12 bloques de datos<br/>48 KiB"]
INODO -->|"puntero 12"| S["Bloque de punteros<br/>1024 entradas"]
INODO -->|"puntero 13"| DD["Bloque de punteros<br/>a bloques de punteros"]
INODO -->|"puntero 14"| TT["Bloque de punteros<br/>nivel 1"]
S --> SD["1024 bloques de datos<br/>4 MiB"]
DD --> DI["1024 bloques de punteros"]
DI --> DDD["1.048.576 bloques de datos<br/>4 GiB"]
TT --> T2["1024 bloques de punteros<br/>nivel 2"]
T2 --> T3["1.048.576 bloques de punteros<br/>nivel 3"]
T3 --> TDD["1.073.741.824 bloques de datos<br/>4 TiB"]
El costo se paga en accesos: leer el último byte de un archivo de 3 TiB en ext3 exige leer el inodo, el bloque indirecto triple, el doble, el simple y recién ahí el bloque de datos. Cinco accesos donde un archivo pequeño necesita dos. Esa penalización es la razón principal por la que ext4 abandonó el esquema y adoptó extents.
Asignación de bloques: cuatro estrategias
Cuando un archivo crece, el sistema de archivos debe elegir qué bloques libres entregarle y cómo anotar esa decisión. Históricamente se han usado cuatro enfoques.
Asignación contigua
El archivo ocupa un rango de bloques consecutivos. El inodo solo necesita guardar el bloque inicial y la cantidad. Es el esquema más rápido para leer de forma secuencial, porque en un disco mecánico el cabezal casi no se mueve, y es el que usan los formatos de solo lectura como ISO 9660 para CD.
El problema es que un archivo no puede crecer si el bloque siguiente ya está ocupado, y que borrar archivos deja huecos de tamaños variables. Después de un tiempo el disco tiene mucho espacio libre pero ningún hueco contiguo suficientemente grande. Eso es fragmentación externa.
Asignación enlazada
Cada bloque de datos guarda, además del contenido, un puntero al siguiente bloque del archivo. El inodo solo necesita el primer bloque. No hay fragmentación externa: cualquier bloque libre sirve.
El precio es brutal para el acceso aleatorio. Para leer el byte que está en el bloque 5000 del archivo hay que leer los 4999 anteriores solo para seguir la cadena de punteros. Además, el puntero consume espacio dentro del bloque, así que el bloque ya no es una potencia de dos limpia.
Asignación enlazada con tabla en memoria: FAT
FAT es una variante ingeniosa de la asignación enlazada. En lugar de guardar el puntero dentro de cada bloque, todos los punteros se concentran en una única tabla al principio del volumen: la File Allocation Table. La tabla tiene una entrada por cada clúster del disco, y el valor de cada entrada es el número del clúster siguiente en la cadena, o un marcador de fin de cadena, o un marcador de clúster libre, o uno de clúster defectuoso.
Como la tabla completa se puede cargar en memoria, seguir la cadena ya no requiere accesos al disco. Sigue siendo una búsqueda lineal, pero ocurre en RAM. El número que da nombre a cada variante es el ancho de cada entrada de la tabla: 12 bits en FAT12, 16 en FAT16, y 28 bits útiles de 32 en FAT32.
Las consecuencias de ese ancho son directas y explican los límites históricos:
| Variante | Bits por entrada | Clústeres máximos | Límite práctico de volumen | Límite de archivo |
|---|---|---|---|---|
| FAT12 | 12 | 4.084 | ~32 MiB | ~32 MiB |
| FAT16 | 16 | 65.524 | 2 GiB (4 GiB con clústeres de 64 KiB) | 2 GiB |
| FAT32 | 28 | 268.435.445 | 2 TiB (Windows formatea hasta 32 GiB) | 4 GiB menos 1 byte |
| exFAT | 32 | Muy alto | 128 PiB teórico | 16 EiB teórico |
El límite de 4 GiB por archivo en FAT32 es la causa de un error clásico: copiar una imagen de máquina virtual o un video largo a un pendrive formateado en FAT32 falla, sin importar cuánto espacio libre haya. El campo que guarda el tamaño del archivo en la entrada de directorio es de 32 bits, y ahí no cabe un número mayor.
FAT tampoco guarda dueño, grupo ni bits de permiso. Solo tiene un puñado de atributos booleanos: solo lectura, oculto, sistema, archivo, directorio y etiqueta de volumen. Por eso cuando montas un pendrive FAT en Linux todos los archivos aparecen con el mismo dueño y los mismos permisos: el kernel los inventa a partir de las opciones de montaje uid, gid y umask, porque el medio no tiene esa información.
flowchart LR
subgraph FATT["Tabla FAT en memoria"]
F2["entrada 2 → 3"]
F3["entrada 3 → 7"]
F7["entrada 7 → 9"]
F9["entrada 9 → FIN"]
F4["entrada 4 → LIBRE"]
end
subgraph DATOS["Area de datos del volumen"]
C2["clúster 2"]
C3["clúster 3"]
C7["clúster 7"]
C9["clúster 9"]
end
ENTRADA["Entrada de directorio<br/>video.mp4<br/>primer clúster = 2<br/>tamaño = 8.912.345"] --> F2
F2 --> F3 --> F7 --> F9
F2 -.-> C2
F3 -.-> C3
F7 -.-> C7
F9 -.-> C9
Asignación indexada y extents
La asignación indexada es la de los inodos: un bloque de índice guarda la lista de bloques que forman el archivo. Resuelve el acceso aleatorio, porque para llegar al bloque N basta con leer la entrada N del índice. Su debilidad es el tamaño del índice cuando el archivo es enorme, que es lo que motiva los niveles indirectos que ya vimos.
Los extents son la evolución. Un extent es un descriptor que dice “los bloques lógicos del 0 al 32767 de este archivo están en los bloques físicos del 900000 al 932767”. Un solo descriptor de 12 bytes reemplaza a 32.768 punteros individuales. En ext4 cada extent puede cubrir hasta 32.768 bloques, o sea 128 MiB con bloques de 4 KiB, y el inodo tiene espacio para 4 extents. Un archivo de 512 MiB que quedó bien ubicado en disco cabe entero en su inodo, sin un solo bloque de índice adicional. Si el archivo está fragmentado y necesita más de 4 extents, la raíz apunta a un árbol B de extents.
| Estrategia | Acceso secuencial | Acceso aleatorio | Fragmentación externa | Metadatos por archivo | Ejemplo real |
|---|---|---|---|---|---|
| Contigua | Óptimo | Óptimo | Severa | Mínimos (inicio + largo) | ISO 9660, formatos de solo lectura |
| Enlazada pura | Bueno | Pésimo | Ninguna | Un puntero por bloque, dentro del bloque | Sistemas didácticos |
| FAT | Bueno | Regular | Ninguna | Tabla global proporcional al disco | FAT32, exFAT |
| Indexada | Bueno | Bueno | Baja | Índice proporcional al archivo | ext2, ext3, UFS |
| Extents | Muy bueno | Muy bueno | Baja si hay preasignación | Un descriptor por rango contiguo | ext4, XFS, NTFS, btrfs |
El tamaño del bloque y la fragmentación interna
El bloque es la unidad mínima que el sistema de archivos asigna. Si el bloque es de 4 KiB y guardas un archivo de 100 bytes, ese archivo consume 4096 bytes de disco. Los 3996 bytes restantes se pierden. Eso es fragmentación interna, y es el precio de trabajar en unidades fijas.
El compromiso es directo:
- Bloques grandes reducen la cantidad de metadatos, permiten extents más largos y hacen las transferencias secuenciales más eficientes, pero desperdician más espacio con archivos pequeños.
- Bloques pequeños aprovechan mejor el espacio pero multiplican los punteros y los accesos.
Un cálculo concreto ayuda a dimensionarlo. Un proyecto con 400.000 archivos fuente de un promedio de 2 KiB cada uno ocupa unos 800 MiB de contenido real. Con bloques de 4 KiB ocupa 1600 MiB en disco: el doble. Con bloques de 64 KiB ocuparía 25 GiB. Por eso los sistemas de archivos que apuntan a bloques grandes suelen incorporar algún mecanismo de empaquetado: ext4 puede guardar el contenido de archivos muy pequeños dentro del propio inodo cuando está activada la opción de datos en línea, btrfs hace lo mismo con archivos por debajo de cierto umbral, y ZFS usa un tamaño de registro variable por archivo.
El caso opuesto son los archivos dispersos. Si creas un archivo, saltas al byte 10.000.000.000 y escribes un byte, el sistema de archivos no necesita asignar diez gigabytes de ceros: anota que ese rango no tiene bloques asignados y devuelve ceros al leerlo. ls -l reporta 10 GB porque muestra el tamaño lógico; du reporta unos pocos KiB porque cuenta bloques reales. Esa discrepancia, que confunde a mucha gente, es exactamente la información que devuelven los campos st_size y st_blocks del programa de la sección anterior.
Gestión del espacio libre
El sistema de archivos también necesita saber qué bloques están disponibles. Los dos enfoques clásicos son:
- Mapa de bits. Un arreglo con un bit por bloque: 1 ocupado, 0 libre. Un sistema de archivos de 1 TiB con bloques de 4 KiB tiene 268 millones de bloques, o sea un mapa de 32 MiB. Es compacto, se puede mantener en memoria por partes y permite buscar rápidamente rangos contiguos con operaciones sobre palabras completas, lo que es justo lo que se necesita para asignar extents.
- Lista enlazada de bloques libres. No requiere estructura adicional porque los punteros viven en los propios bloques libres, pero encontrar un rango contiguo es caro.
ext4 usa mapas de bits, uno por cada grupo de bloques. btrfs y ZFS usan estructuras de árbol que registran extents libres, lo que encaja mejor con su modelo de copia en escritura.
Hay además un detalle operativo importante: ext4 reserva por defecto el 5 % del espacio para el usuario root. No es un capricho. Sirve para que los procesos del sistema puedan seguir escribiendo cuando el disco se llena, y para que el asignador tenga margen y evite fragmentar. En una partición de datos de 8 TiB ese 5 % son 400 GiB, y ahí sí conviene bajarlo con tune2fs -m 1.
Journaling: sobrevivir al corte de energía
Aquí está el problema central de la consistencia. Crear un archivo no es una operación: son varias.
- Marcar un inodo libre como ocupado en el mapa de bits de inodos.
- Escribir el contenido del inodo (permisos, tamaño, punteros).
- Marcar bloques como ocupados en el mapa de bits de bloques.
- Escribir los bloques de datos.
- Agregar la entrada de directorio con el nombre y el número de inodo.
Si la energía se corta entre el paso 3 y el paso 5, el disco queda con bloques marcados como ocupados que no pertenecen a ningún archivo. Si se corta entre el 1 y el 5, hay un inodo asignado que nadie referencia. Si el orden hubiera sido distinto y la entrada de directorio se escribiera primero, quedaría un nombre apuntando a un inodo con basura, lo que es mucho peor: un archivo aparentemente válido con contenido aleatorio.
La solución antigua era fsck: al arrancar, recorrer el sistema de archivos completo verificando que cada estructura sea coherente. Funciona, pero el tiempo de verificación crece con el tamaño del volumen. En un disco de varios terabytes puede tardar horas, y durante ese tiempo el servicio está caído.
El journaling invierte el enfoque. En lugar de reparar después, se anota antes. La técnica se llama write-ahead logging y viene directamente del mundo de las bases de datos:
- Escribir en un área reservada del disco, el diario, la descripción completa de todos los cambios que se van a hacer.
- Esperar a que esa escritura esté físicamente en el medio.
- Escribir un registro de confirmación que marca la transacción como completa.
- Recién entonces aplicar los cambios reales en sus posiciones definitivas.
- Una vez aplicados, marcar la transacción como retirada del diario.
Si el corte ocurre antes del paso 3, la transacción está incompleta en el diario y simplemente se descarta: el sistema de archivos queda como estaba antes de empezar. Si ocurre después del paso 3, la transacción está completa en el diario y al montar se vuelve a aplicar. En ambos casos el resultado es consistente, y el trabajo de recuperación es proporcional al tamaño del diario (típicamente entre 32 y 1024 MiB), no al del volumen.
sequenceDiagram
participant App as Aplicación
participant FS as Sistema de archivos
participant J as Diario (área reservada)
participant D as Ubicación definitiva
App->>FS: crear archivo y escribir 8 KiB
FS->>J: escribe descriptor de transacción
FS->>J: escribe bloques de metadatos modificados
J-->>FS: escritura confirmada por el medio
FS->>J: escribe registro de confirmación (commit)
J-->>FS: commit en el medio
FS-->>App: la operación es durable
Note over FS,D: a partir de aquí el corte ya no pierde nada
FS->>D: aplica los cambios en su lugar real (checkpoint)
D-->>FS: aplicado
FS->>J: libera la transacción del diario
Los tres modos de journaling de ext4
No todo lo que se escribe tiene que pasar por el diario. Escribir los datos dos veces cuesta ancho de banda. ext4 ofrece tres políticas, seleccionables con la opción de montaje data=:
| Modo | Qué pasa por el diario | Garantía tras un corte | Costo |
|---|---|---|---|
data=journal | Metadatos y datos | Metadatos y contenido consistentes | Toda escritura se hace dos veces |
data=ordered (por defecto) | Solo metadatos, pero los datos se fuerzan al disco antes de confirmar los metadatos | Nunca se ve un archivo apuntando a bloques con contenido viejo o basura | Bajo, con una restricción de orden |
data=writeback | Solo metadatos, sin restricción de orden | El sistema de archivos queda consistente, pero un archivo puede exponer datos antiguos de otro archivo borrado | El más rápido, el menos seguro |
El modo ordered es el compromiso que casi todo el mundo usa. Su garantía se enuncia con precisión: la estructura nunca queda corrupta, y ningún archivo termina mostrando bloques que pertenecieron a otro. Lo que no garantiza es que los últimos datos que escribió tu aplicación estén ahí. Eso es responsabilidad de la aplicación, y se pide explícitamente.
Durabilidad desde la aplicación
Una escritura con write() no llega al disco: llega a la caché de página en RAM. El kernel la escribe después, cuando le conviene. Si quieres una garantía real necesitas fsync(), que bloquea hasta que los datos de ese descriptor están en el medio.
Y hay un detalle que se olvida casi siempre: fsync() sobre un archivo garantiza su contenido, pero no garantiza que la entrada de directorio que le da nombre esté en disco. Para un archivo recién creado hay que sincronizar también el directorio que lo contiene.
El patrón correcto para reemplazar un archivo de configuración sin riesgo de dejarlo a medias es este:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <libgen.h>
/*
* Escribe 'contenido' en 'destino' de forma atomica:
* o queda el archivo viejo completo, o el nuevo completo.
* Nunca una mezcla de los dos.
*/
static int escritura_atomica(const char *destino,
const char *contenido,
size_t largo) {
char temporal[4096];
snprintf(temporal, sizeof temporal, "%s.tmp", destino);
int fd = open(temporal, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("open temporal");
return -1;
}
size_t escrito = 0;
while (escrito < largo) {
ssize_t n = write(fd, contenido + escrito, largo - escrito);
if (n < 0) {
perror("write");
close(fd);
unlink(temporal);
return -1;
}
escrito += (size_t) n;
}
/* 1. el contenido del archivo temporal llega al medio */
if (fsync(fd) != 0) {
perror("fsync archivo");
close(fd);
unlink(temporal);
return -1;
}
close(fd);
/* 2. rename es atomico dentro del mismo sistema de archivos:
* el nombre 'destino' apunta al inodo viejo o al nuevo,
* nunca a un estado intermedio */
if (rename(temporal, destino) != 0) {
perror("rename");
unlink(temporal);
return -1;
}
/* 3. la entrada de directorio tambien debe ser durable */
char copia[4096];
snprintf(copia, sizeof copia, "%s", destino);
char *directorio = dirname(copia);
int dfd = open(directorio, O_RDONLY | O_DIRECTORY);
if (dfd < 0) {
perror("open directorio");
return -1;
}
if (fsync(dfd) != 0) {
perror("fsync directorio");
close(dfd);
return -1;
}
close(dfd);
return 0;
}
int main(void) {
const char *texto =
"servidor = 10.0.0.14\n"
"puerto = 8443\n"
"modo = produccion\n";
if (escritura_atomica("configuracion.conf", texto, strlen(texto)) == 0) {
printf("configuracion escrita de forma durable\n");
return 0;
}
fprintf(stderr, "fallo la escritura\n");
return 1;
}
Los tres pasos son necesarios y ninguno es redundante. Sin el primer fsync, rename puede publicar un archivo cuyo contenido todavía no llegó al disco. Sin rename, un corte deja el archivo destino truncado. Sin el fsync del directorio, el nombre nuevo puede no estar tras un corte.
ext4 por dentro
ext4 es el sistema de archivos por omisión de la mayoría de las distribuciones de Linux. Es la cuarta versión de una familia que empezó en 1992, y mantiene compatibilidad hacia atrás: un volumen ext4 sin características nuevas activadas se puede montar como ext3.
Su organización física divide el volumen en grupos de bloques, cada uno con la misma estructura interna. La idea es la localidad: los metadatos de un archivo y sus datos tienden a quedar en el mismo grupo, así que el cabezal de un disco mecánico se mueve poco.
flowchart TD
subgraph VOL["Volumen ext4"]
BOOT["Bloque 0<br/>reservado para el arranque"]
G0["Grupo de bloques 0"]
G1["Grupo de bloques 1"]
GN["Grupo de bloques N"]
end
subgraph G["Contenido de cada grupo de bloques"]
SB["Superbloque<br/>(copia de respaldo en algunos grupos)"]
GD["Descriptores de grupo"]
BB["Mapa de bits de bloques<br/>1 bit por bloque del grupo"]
IB["Mapa de bits de inodos"]
IT["Tabla de inodos<br/>256 bytes por inodo"]
DB["Bloques de datos"]
end
G0 --> G
SB --> GD --> BB --> IB --> IT --> DB
El superbloque es la ficha de identidad del volumen: tamaño de bloque, cantidad total de bloques e inodos, cuántos quedan libres, el UUID, la etiqueta, el estado (montado limpiamente o no), la fecha del último chequeo y qué características opcionales están activadas. Como perder el superbloque significa perder el volumen, ext4 guarda copias de respaldo distribuidas en varios grupos, y fsck puede usar una de ellas con la opción -b.
Las características que definen a ext4 frente a ext3 son cuatro:
- Extents. Ya explicados. Reemplazan el esquema de punteros indirectos y bajan drásticamente el costo de los archivos grandes. Elevan el tamaño máximo de archivo de 2 TiB a 16 TiB con bloques de 4 KiB, y el del volumen a 1 EiB.
- Asignación diferida. ext4 no elige los bloques físicos cuando la aplicación llama a
write(), sino cuando los datos realmente bajan al disco. Para entonces sabe cuántos bytes tiene que ubicar en total y puede elegir un rango contiguo, en vez de ir asignando de a un bloque a ciegas. El resultado es mucho menos fragmentación. - Asignador multibloque. Reserva rangos completos de una vez y mantiene por archivo un área de preasignación, de modo que un archivo que crece de a poco no queda esparcido por todo el disco.
- Suma de verificación de metadatos y del diario. Detecta corrupción en las estructuras internas y en el propio diario, evitando reproducir una transacción dañada.
Un punto que conviene entender bien: ext4 no verifica los datos del usuario, solo los metadatos. Si un sector del disco devuelve bits distintos a los que se escribieron, ext4 entrega esos bytes corruptos a la aplicación sin advertirlo. Esa limitación es exactamente lo que motivó el diseño de btrfs y ZFS.
btrfs: copia en escritura y árboles B
btrfs abandona la idea de modificar bloques en su lugar. Su principio es copy-on-write: cuando hay que cambiar un bloque, se escribe una copia modificada en un bloque libre y luego se actualizan los punteros que apuntaban al original. Como esos punteros viven en otro bloque, ese bloque también se copia, y así hacia arriba hasta la raíz del árbol. El cambio se hace efectivo con una única escritura atómica del superbloque, que pasa a apuntar a la raíz nueva.
Ese mecanismo produce varias propiedades sin esfuerzo adicional:
- No hace falta un diario para los metadatos. La estructura vieja permanece intacta hasta que la raíz cambia. Un corte de energía deja al sistema en la versión anterior, que es consistente por construcción.
- Las instantáneas son casi gratis. Una instantánea es simplemente conservar una raíz antigua. No copia datos: los bloques se comparten hasta que alguien los modifica, momento en que se copian solo los que cambian.
- Los subvolúmenes son árboles independientes dentro del mismo conjunto de espacio libre. Se pueden montar por separado, tener instantáneas propias y cuotas propias, sin tener que decidir de antemano cuánto espacio le toca a cada uno.
- Sumas de verificación de datos y metadatos. btrfs calcula una suma por cada bloque y la guarda separada del bloque. Al leer, la vuelve a calcular y compara. Si el disco devolvió datos corruptos, la lectura falla con un error explícito en vez de entregar basura; y si hay una copia redundante (por ejemplo en un perfil RAID1), la lee de ahí y repara la mala automáticamente. El algoritmo por defecto es CRC32C, y se puede elegir xxHash, SHA-256 o BLAKE2b al crear el sistema de archivos.
btrfs también gestiona varios dispositivos por sí mismo, sin necesitar una capa RAID por debajo, y permite definir perfiles distintos para datos y para metadatos. Los perfiles de espejo y de repartición están consolidados; los de paridad tienen un problema conocido de consistencia ante cortes de energía y no se recomiendan para datos críticos.
El costo de la copia en escritura es la fragmentación con archivos que se modifican en el medio una y otra vez, como las bases de datos y las imágenes de máquinas virtuales. Cada escritura de 8 KiB dentro de un archivo de 40 GiB crea un fragmento nuevo. La solución habitual es desactivar la copia en escritura para esos archivos concretos con el atributo correspondiente, aceptando perder para ellos las sumas de verificación.
Otra particularidad que confunde: en btrfs el espacio libre no es un número simple. Los datos y los metadatos se asignan en trozos con perfiles de redundancia que pueden ser distintos, así que la cantidad de bytes de usuario que caben depende de qué tipo de dato escribas. Por eso df puede dar una respuesta engañosa y existe el comando específico btrfs filesystem usage, que desglosa la situación real.
ZFS: el volumen y el sistema de archivos fusionados
ZFS, creado en Sun Microsystems y hoy mantenido en el proyecto OpenZFS, tomó una decisión de diseño más radical: eliminar la separación entre el gestor de volúmenes y el sistema de archivos. En el esquema tradicional hay un RAID por debajo que presenta un dispositivo virtual, y encima un sistema de archivos que no sabe nada de la redundancia. ZFS junta ambos niveles, y esa fusión es lo que le permite hacer cosas que ninguna de las dos capas podría hacer por separado.
Su vocabulario propio:
- vdev (dispositivo virtual): un disco, o un conjunto de discos con una política de redundancia entre ellos: espejo, o RAIDZ1, RAIDZ2 y RAIDZ3, que toleran uno, dos o tres discos caídos respectivamente.
- pool: el conjunto de vdevs que forma el espacio disponible. Se reparte automáticamente entre ellos.
- dataset: un sistema de archivos dentro del pool. Se crean y destruyen en segundos, comparten el espacio del pool y cada uno tiene sus propias propiedades: compresión, tamaño de registro, cuota, reserva, cifrado.
Las capacidades técnicas más relevantes:
- Integridad de extremo a extremo. Cada bloque tiene una suma de verificación almacenada en el bloque padre que lo apunta, no junto al dato. Eso detecta no solo bits alterados, sino también escrituras que fueron al lugar equivocado o que nunca se hicieron aunque el disco dijo que sí. Con redundancia disponible, ZFS repara el bloque malo en el momento de leerlo. El recorrido preventivo de todo el pool para encontrar y reparar errores latentes se llama scrub.
- Copia en escritura y transacciones. Igual que btrfs, ZFS nunca sobrescribe un bloque vivo. Los cambios se agrupan en grupos de transacción que se confirman de forma atómica.
- Instantáneas y clones. Instantáneas de solo lectura de costo casi nulo, y clones que son instantáneas escribibles. La transmisión incremental entre máquinas se hace enviando la diferencia entre dos instantáneas.
- ARC. ZFS gestiona su propia caché de lectura en RAM con un algoritmo adaptativo que balancea entre lo usado recientemente y lo usado frecuentemente. Puede extenderse a un dispositivo rápido dedicado como segundo nivel.
- Registro de intenciones (ZIL). Las escrituras que la aplicación pide de forma síncrona se anotan primero en un registro para poder confirmarlas rápido. Ese registro se puede mover a un dispositivo de baja latencia dedicado.
- Compresión transparente. Activada por dataset. En muchas cargas mejora el rendimiento en vez de empeorarlo, porque el ahorro de bytes a transferir compensa con creces el costo de CPU.
ZFS se distribuye bajo una licencia incompatible con la del kernel de Linux, así que no forma parte del árbol oficial y se instala como módulo aparte. En FreeBSD y en illumos es un componente nativo.
Tabla comparativa de sistemas de archivos
| Sistema | Origen | Asignación | Consistencia | Suma de verificación de datos | Instantáneas | Permisos POSIX | Uso típico |
|---|---|---|---|---|---|---|---|
| FAT32 | Microsoft, 1996 | Cadena de clústeres en tabla | Ninguna | No | No | No | Medios extraíbles antiguos, firmware, particiones EFI |
| exFAT | Microsoft, 2006 | Cadena de clústeres con mapa de bits | Ninguna | No | No | No | Tarjetas SD y pendrives modernos, intercambio entre sistemas |
| NTFS | Microsoft, 1993 | Extents en la tabla maestra de archivos | Diario de metadatos | No | Sí, vía servicio externo | Listas de control de acceso propias | Windows |
| ext4 | Linux, 2008 | Extents | Diario configurable | No (solo metadatos) | No | Sí | Servidores y escritorios Linux de propósito general |
| XFS | SGI, 1994 | Extents y árboles B | Diario de metadatos | Solo metadatos | Vía gestor de volúmenes | Sí | Volúmenes grandes, alta concurrencia de E/S |
| btrfs | Linux, 2009 | Extents sobre árboles B con copia en escritura | Copia en escritura | Sí | Sí, nativas | Sí | Escritorios, contenedores, sistemas con reversión |
| ZFS | Sun, 2005 | Bloques de tamaño variable con copia en escritura | Copia en escritura y transacciones | Sí, de extremo a extremo | Sí, nativas | Sí | Almacenamiento de datos críticos, servidores de archivos |
| APFS | Apple, 2017 | Extents con copia en escritura | Copia en escritura | Solo metadatos | Sí | Sí | macOS, iOS |
| ISO 9660 | ECMA, 1988 | Contigua | No aplica (solo lectura) | No | No | No | Discos ópticos e imágenes de instalación |
Permisos: el modelo de UNIX en detalle
El modelo de permisos de UNIX es deliberadamente pequeño. Cada archivo tiene un dueño, un grupo, y nueve bits organizados en tres tríos.
Cada trío tiene los mismos tres bits: lectura (valor 4), escritura (valor 2) y ejecución (valor 1). Sumados dan un dígito octal entre 0 y 7. Los tres tríos se aplican, en orden, al dueño, al grupo y a todos los demás.
| Octal | Bits | Significado sobre un archivo | Significado sobre un directorio |
|---|---|---|---|
| 0 | --- | Sin acceso | Sin acceso |
| 1 | --x | Ejecutar | Atravesar (usar la ruta, sin poder listar) |
| 2 | -w- | Escribir | Crear o borrar entradas (inútil sin x) |
| 4 | r-- | Leer contenido | Listar nombres (sin poder leer sus metadatos) |
| 5 | r-x | Leer y ejecutar | Listar y atravesar: lo normal para consulta |
| 6 | rw- | Leer y escribir | Poco útil por sí solo |
| 7 | rwx | Todo | Listar, atravesar, crear y borrar |
El significado de los bits cambia según se trate de un archivo o de un directorio, y este es el punto que más confusión genera:
- En un directorio,
xno significa ejecutar: significa poder usar ese directorio como parte de una ruta. Sinxno puedes hacercdni abrir nada que esté dentro, aunque conozcas el nombre exacto. - En un directorio,
rpermite listar los nombres. Un directorio conrpero sinxte deja ver los nombres y nada más: no puedes obtener sus metadatos. - En un directorio,
wpermite crear y borrar entradas. Y aquí está la parte contraintuitiva: para borrar un archivo no necesitas permiso de escritura sobre el archivo, sino sobre el directorio que lo contiene, porque borrar es quitar una entrada del directorio. Puedes borrar un archivo de solo lectura que no te pertenece si el directorio es tuyo.
Los tres bits adicionales
Sobre los nueve bits básicos hay tres más, que forman un cuarto dígito octal al frente:
- setuid (4000). En un ejecutable, hace que el proceso corra con el identificador del dueño del archivo en lugar del de quien lo lanzó. Es lo que permite que
passwdmodifique la base de contraseñas del sistema siendo ejecutado por un usuario común. Es también una superficie de ataque enorme: cualquier defecto en un binario setuid propiedad de root es una escalada de privilegios. En archivos que no son ejecutables no tiene efecto en Linux. - setgid (2000). En un ejecutable, análogo al anterior pero con el grupo. En un directorio tiene otro significado, mucho más usado: los archivos creados dentro heredan el grupo del directorio en vez del grupo primario de quien los crea. Es la forma estándar de armar una carpeta compartida por un equipo.
- sticky (1000). En un directorio, restringe el borrado: solo el dueño de cada archivo, el dueño del directorio o root pueden eliminarlo, aunque el directorio tenga permiso de escritura para todos. Es lo que hace que
/tmpsea1777y que un usuario no pueda borrar los archivos temporales de otro.
umask y valores recomendados
Cuando un programa crea un archivo pide un modo, típicamente 0666 para archivos y 0777 para directorios. El kernel le quita a ese valor los bits presentes en la umask del proceso. Con la umask habitual de 022, el resultado es 644 para archivos y 755 para directorios: el dueño escribe, el resto solo lee. Con una umask de 077 el resultado es 600 y 700: nadie más que el dueño ve nada.
Los valores que se usan en la práctica:
| Valor | Lectura | Uso apropiado |
|---|---|---|
600 | rw------- | Claves privadas, tokens, archivos con secretos |
644 | rw-r--r-- | Archivos de datos y configuración legibles por todos |
700 | rwx------ | Directorios privados de un usuario |
755 | rwxr-xr-x | Directorios públicos y programas ejecutables |
775 | rwxrwxr-x | Directorio compartido por un grupo de trabajo |
1777 | rwxrwxrwt | Directorios temporales compartidos como /tmp |
777 | rwxrwxrwx | Ningún caso legítimo en un sistema multiusuario |
Cuando nueve bits no alcanzan (por ejemplo, dar acceso de lectura a dos grupos distintos con permisos diferentes), existen las listas de control de acceso POSIX, que se manipulan con getfacl y setfacl y requieren que el sistema de archivos esté montado con soporte para ellas. Y separado de todo esto están los atributos extendidos, pares de clave y valor asociados al inodo, que usan SELinux, las capacidades de proceso y algunas aplicaciones para guardar metadatos propios.
Montaje y la capa VFS
Linux no tiene letras de unidad. Todos los sistemas de archivos disponibles se injertan en un único árbol que empieza en /. La operación que conecta un sistema de archivos a un punto del árbol se llama montar.
El punto de montaje es un directorio existente. Mientras hay algo montado sobre él, su contenido original queda oculto: sigue en el disco, pero es inalcanzable por esa ruta hasta que se desmonte. Ese detalle causa un error frecuente: escribir datos en /mnt/respaldo antes de montar el disco de respaldo llena la partición raíz, y al montar el disco los datos desaparecen de la vista.
Lo que hace posible que un mismo open() funcione sobre ext4, sobre un pendrive exFAT, sobre un recurso de red y sobre /proc es la capa VFS, el sistema de archivos virtual. VFS define un conjunto de objetos e interfaces que toda implementación debe proveer:
- superbloque: representa un sistema de archivos montado.
- inodo: representa un archivo, con las operaciones para crearlo, buscarlo, enlazarlo y cambiar sus atributos.
- dentry: representa un componente de una ruta ya resuelto; el kernel mantiene una caché de ellos porque resolver rutas es una de las operaciones más frecuentes del sistema.
- file: representa un archivo abierto por un proceso, con su desplazamiento y sus operaciones de lectura y escritura.
Cada sistema de archivos concreto registra su propia tabla de funciones para esos objetos. Las llamadas al sistema hablan solo con VFS; VFS despacha a la implementación correspondiente.
flowchart TD
subgraph USUARIO["Espacio de usuario"]
P1["editor de texto"]
P2["servidor web"]
P3["comando ls"]
end
SC["Llamadas al sistema<br/>open, read, write, stat, unlink"]
VFS["Capa VFS<br/>superbloque, inodo, dentry, file<br/>caché de dentries"]
P1 --> SC
P2 --> SC
P3 --> SC
SC --> VFS
VFS --> EXT4["ext4<br/>montado en /"]
VFS --> XFS["xfs<br/>montado en /var"]
VFS --> EXFAT["exfat<br/>montado en /media/usb"]
VFS --> NFS["nfs<br/>montado en /red"]
VFS --> PROCFS["procfs<br/>montado en /proc<br/>sin respaldo en disco"]
VFS --> TMPFS["tmpfs<br/>montado en /tmp<br/>vive en RAM"]
EXT4 --> DISCO1["/dev/nvme0n1p2"]
XFS --> DISCO2["/dev/nvme0n1p3"]
EXFAT --> DISCO3["/dev/sdb1"]
NFS --> RED["servidor remoto"]
Dos de esos montajes no tienen disco detrás. procfs genera su contenido al vuelo a partir del estado del kernel: leer /proc/self/status no lee ningún bloque, ejecuta código. tmpfs vive enteramente en memoria y desaparece al reiniciar. Ambos usan exactamente la misma interfaz que un archivo real, lo que demuestra hasta qué punto la abstracción es genérica.
Opciones de montaje que importan
| Opción | Efecto | Cuándo usarla |
|---|---|---|
ro | Solo lectura | Análisis forense, medios que no deben modificarse |
noexec | Prohíbe ejecutar binarios desde ese sistema de archivos | Particiones de datos, /tmp, medios extraíbles |
nosuid | Ignora los bits setuid y setgid | Cualquier medio no confiable |
nodev | Ignora los archivos de dispositivo | Igual que el anterior |
noatime | No actualiza la marca de último acceso | Cargas con muchas lecturas, para evitar escrituras innecesarias |
relatime | Actualiza atime solo si era anterior a mtime o si pasó un día | Comportamiento por omisión, buen compromiso |
data=ordered | Modo de journaling de ext4 | Valor por omisión, el recomendado en general |
discard | Informa al SSD de los bloques liberados en cada borrado | Alternativa: ejecutar el recorte periódicamente con fstrim |
uid, gid, umask | Inventa dueño y permisos | Obligatorio en FAT y exFAT, que no los almacenan |
La configuración persistente de montajes vive en /etc/fstab, con seis campos por línea: dispositivo, punto de montaje, tipo, opciones, indicador de volcado y orden de verificación al arranque. Identificar el dispositivo por su UUID en lugar de por su nombre (/dev/sdb1) evita que el sistema no arranque cuando el orden de detección de discos cambia.
Y una advertencia operativa: desmontar falla con “dispositivo ocupado” si algún proceso tiene abierto un archivo dentro o si algún directorio de trabajo apunta ahí. lsof y fuser identifican al culpable. Desconectar físicamente un medio sin desmontarlo puede perder datos que todavía estaban en la caché de página, porque nadie forzó su bajada al disco.
Trabajar con el sistema de archivos desde Elixir
Elixir expone el sistema de archivos a través del módulo File, que a su vez se apoya en el módulo :file de Erlang. Este programa recorre un árbol de directorios, reúne estadísticas y detecta archivos duplicados por su suma de verificación:
defmodule Inventario do
@moduledoc """
Recorre un arbol de directorios, reune estadisticas de uso
y detecta archivos con contenido identico.
"""
def recorrer(raiz) do
raiz
|> listar_recursivo()
|> Enum.map(&describir/1)
|> Enum.reject(&is_nil/1)
end
defp listar_recursivo(ruta) do
case File.stat(ruta, time: :posix) do
{:ok, %File.Stat{type: :directory}} ->
case File.ls(ruta) do
{:ok, entradas} ->
entradas
|> Enum.flat_map(fn nombre ->
listar_recursivo(Path.join(ruta, nombre))
end)
{:error, _razon} ->
[]
end
{:ok, %File.Stat{type: :regular}} ->
[ruta]
_otro ->
[]
end
end
defp describir(ruta) do
case File.stat(ruta, time: :posix) do
{:ok, info} ->
%{
ruta: ruta,
tamanio: info.size,
inodo: info.inode,
enlaces: info.links,
modo_octal: modo_octal(info.mode),
uid: info.uid,
gid: info.gid,
modificado: info.mtime
}
{:error, _} ->
nil
end
end
# info.mode trae el tipo en los bits altos; nos quedamos
# con los 12 bits de permisos y los mostramos en octal.
defp modo_octal(modo) do
modo
|> Bitwise.band(0o7777)
|> Integer.to_string(8)
|> String.pad_leading(4, "0")
end
@doc """
Calcula la suma SHA-256 leyendo el archivo por partes,
para no cargar archivos grandes completos en memoria.
"""
def suma_verificacion(ruta) do
ruta
|> File.stream!([], 64 * 1024)
|> Enum.reduce(:crypto.hash_init(:sha256), fn trozo, estado ->
:crypto.hash_update(estado, trozo)
end)
|> :crypto.hash_final()
|> Base.encode16(case: :lower)
end
def duplicados(raiz) do
raiz
|> recorrer()
|> Enum.filter(&(&1.tamanio > 0))
|> Enum.group_by(& &1.tamanio)
|> Enum.filter(fn {_tam, lista} -> length(lista) > 1 end)
|> Enum.flat_map(fn {_tam, lista} -> lista end)
|> Enum.group_by(&suma_verificacion(&1.ruta), & &1.ruta)
|> Enum.filter(fn {_suma, rutas} -> length(rutas) > 1 end)
|> Map.new()
end
def informe(raiz) do
archivos = recorrer(raiz)
total = Enum.reduce(archivos, 0, &(&1.tamanio + &2))
IO.puts("Archivos analizados : #{length(archivos)}")
IO.puts("Bytes totales : #{total}")
IO.puts("\nLos cinco mas grandes:")
archivos
|> Enum.sort_by(& &1.tamanio, :desc)
|> Enum.take(5)
|> Enum.each(fn a ->
IO.puts(" #{String.pad_leading(to_string(a.tamanio), 12)} " <>
"#{a.modo_octal} #{a.ruta}")
end)
grupos = duplicados(raiz)
if map_size(grupos) > 0 do
IO.puts("\nGrupos de archivos con contenido identico:")
Enum.each(grupos, fn {suma, rutas} ->
IO.puts(" #{String.slice(suma, 0, 16)}...")
Enum.each(rutas, &IO.puts(" #{&1}"))
end)
else
IO.puts("\nNo se encontraron duplicados.")
end
end
end
Guárdalo como inventario.exs y ejecútalo con elixir -r inventario.exs -e 'Inventario.informe(".")'. La estrategia de agrupar primero por tamaño y solo después calcular la suma de verificación evita leer archivos que no pueden ser duplicados, porque dos archivos con tamaños distintos nunca tienen contenido idéntico.
Para escrituras durables desde Elixir, el patrón equivalente al de C usa el módulo :file de Erlang, que expone la sincronización explícita:
defmodule EscrituraDurable do
@doc """
Escribe contenido en destino de forma atomica y durable:
archivo temporal, sincronizacion al medio, y renombrado.
"""
def escribir(destino, contenido) do
temporal = destino <> ".tmp"
with {:ok, dispositivo} <- File.open(temporal, [:write, :binary]),
:ok <- IO.binwrite(dispositivo, contenido),
:ok <- :file.sync(dispositivo),
:ok <- File.close(dispositivo),
:ok <- File.rename(temporal, destino) do
:ok
else
{:error, razon} ->
File.rm(temporal)
{:error, razon}
end
end
end
El equivalente al fsync del directorio no está expuesto por la biblioteca estándar de Erlang. Si necesitas esa garantía completa desde la máquina virtual de Erlang, se resuelve con un puerto o un NIF que llame a fsync sobre el descriptor del directorio. Es un buen ejemplo de que las abstracciones de alto nivel a veces esconden una garantía que sí importa.
Trabajar con el sistema de archivos desde Ada
Ada define el acceso al sistema de archivos en el paquete Ada.Directories, con una interfaz fuertemente tipada donde el tipo de cada entrada es un valor enumerado y no un conjunto de bits que hay que interpretar. Este programa recorre un árbol y reporta el uso de espacio agrupado por extensión:
with Ada.Text_IO; use Ada.Text_IO;
with Ada.Directories; use Ada.Directories;
with Ada.Command_Line; use Ada.Command_Line;
with Ada.Strings.Unbounded; use Ada.Strings.Unbounded;
with Ada.Containers.Ordered_Maps;
procedure Inventario is
package Mapa_Extensiones is new Ada.Containers.Ordered_Maps
(Key_Type => Unbounded_String,
Element_Type => File_Size);
use Mapa_Extensiones;
Acumulado : Map;
Total_Archivos : Natural := 0;
Total_Bytes : File_Size := 0;
Total_Dirs : Natural := 0;
procedure Acumular (Clave : String; Tamanio : File_Size) is
K : constant Unbounded_String := To_Unbounded_String (Clave);
Posicion : constant Cursor := Acumulado.Find (K);
begin
if Posicion = No_Element then
Acumulado.Insert (K, Tamanio);
else
Acumulado.Replace_Element
(Posicion, Element (Posicion) + Tamanio);
end if;
end Acumular;
procedure Recorrer (Ruta : String) is
Busqueda : Search_Type;
Entrada : Directory_Entry_Type;
Filtro : constant Filter_Type :=
(Ordinary_File => True, Directory => True, Special_File => False);
begin
Start_Search (Busqueda, Ruta, "", Filtro);
while More_Entries (Busqueda) loop
Get_Next_Entry (Busqueda, Entrada);
declare
Nombre : constant String := Simple_Name (Entrada);
begin
if Nombre /= "." and then Nombre /= ".." then
case Kind (Entrada) is
when Directory =>
Total_Dirs := Total_Dirs + 1;
Recorrer (Full_Name (Entrada));
when Ordinary_File =>
declare
Tam : constant File_Size := Size (Entrada);
Ext : constant String := Extension (Nombre);
begin
Total_Archivos := Total_Archivos + 1;
Total_Bytes := Total_Bytes + Tam;
if Ext = "" then
Acumular ("(sin extension)", Tam);
else
Acumular (Ext, Tam);
end if;
end;
when others =>
null;
end case;
end if;
end;
end loop;
End_Search (Busqueda);
exception
when Name_Error | Use_Error =>
Put_Line (" aviso: no se pudo leer " & Ruta);
end Recorrer;
Raiz : constant String :=
(if Argument_Count >= 1 then Argument (1) else ".");
begin
if not Exists (Raiz) then
Put_Line ("La ruta no existe: " & Raiz);
return;
end if;
Put_Line ("Analizando: " & Full_Name (Raiz));
New_Line;
Recorrer (Raiz);
Put_Line ("Directorios :" & Natural'Image (Total_Dirs));
Put_Line ("Archivos :" & Natural'Image (Total_Archivos));
Put_Line ("Bytes totales :" & File_Size'Image (Total_Bytes));
New_Line;
Put_Line ("Uso por extension:");
for Posicion in Acumulado.Iterate loop
Put_Line (" " & To_String (Key (Posicion)) &
" :" & File_Size'Image (Element (Posicion)));
end loop;
end Inventario;
Compílalo con gnatmake inventario.adb y ejecútalo con ./inventario /usr/share. Dos detalles del diseño de Ada merecen atención. Primero, File_Size es un tipo propio y no un entero genérico, así que el compilador impide sumarle accidentalmente una cantidad de archivos. Segundo, los errores del sistema de archivos llegan como excepciones tipadas (Name_Error cuando la ruta no existe, Use_Error cuando existe pero no se puede usar), no como códigos de retorno que se pueden ignorar por descuido. Esa diferencia con C es deliberada y es una de las razones por las que Ada se usa en sistemas donde un error silencioso no es aceptable.
Errores comunes
| Síntoma | Causa real | Solución |
|---|---|---|
No space left on device con df mostrando espacio libre | Se agotaron los inodos, que se fijan al formatear | Verificar con df -i; borrar archivos pequeños o recrear el sistema de archivos con más inodos; migrar a btrfs o ZFS |
| Borrar un archivo enorme no libera espacio | Un proceso mantiene el descriptor abierto; el inodo no se libera hasta que se cierre | Identificar el proceso con lsof | grep deleted y reiniciarlo o truncar el archivo desde el proceso |
| Archivo de más de 4 GiB no se copia al pendrive | El volumen es FAT32, cuyo campo de tamaño es de 32 bits | Reformatear a exFAT, o dividir el archivo |
| Los datos escritos desaparecen tras un corte de energía | write() solo llegó a la caché de página; nunca se llamó a fsync() | Aplicar el patrón de archivo temporal, fsync, rename y fsync del directorio |
| El archivo aparece con nombre correcto pero contenido vacío o basura | Se hizo rename sin sincronizar antes el contenido | Igual que el anterior: sincronizar antes de publicar el nombre |
ln falla con Invalid cross-device link | Los enlaces duros usan números de inodo, que son locales a cada sistema de archivos | Usar un enlace simbólico con ln -s, o copiar |
mv entre discos deja un archivo a medias si se interrumpe | Entre sistemas de archivos distintos, mv es copia más borrado, y no es atómico | Copiar a un nombre temporal, verificar, y recién entonces renombrar y borrar el origen |
du y ls -l reportan tamaños muy distintos | El archivo es disperso, o hay enlaces duros contados una sola vez | Comparar st_size con st_blocks; usar du --apparent-size para el tamaño lógico |
umount responde target is busy | Hay un descriptor abierto o un directorio de trabajo dentro del punto de montaje | Localizar con lsof +D /punto o fuser -vm /punto y cerrar el proceso |
| Los archivos de respaldo desaparecieron al montar el disco | Se escribió en el punto de montaje antes de montar; el contenido quedó en la partición raíz oculto debajo | Desmontar, recuperar el contenido del directorio subyacente, y verificar con mountpoint antes de escribir |
El script no se ejecuta pese a tener el bit x | El sistema de archivos está montado con noexec | Revisar /proc/mounts; ejecutar el intérprete explícitamente o remontar sin esa opción |
| No puedo borrar un archivo pese a ser su dueño | El permiso de borrado lo da el directorio, no el archivo; o el directorio tiene el bit sticky | Verificar los permisos del directorio contenedor |
Los archivos del pendrive aparecen todos con permisos 777 | FAT y exFAT no almacenan permisos; el kernel los inventa según las opciones de montaje | Ajustar uid, gid y umask en la línea de montaje |
chmod 777 “arregló” el problema de acceso | Se ocultó un problema de dueño o de grupo dando acceso total a cualquier usuario | Revertir y corregir con chown o chgrp; usar listas de control de acceso si hacen falta permisos finos |
btrfs reporta disco lleno pero df mostraba espacio | Los perfiles de datos y metadatos consumen el espacio de forma distinta a lo que df estima | Usar btrfs filesystem usage; ejecutar un rebalanceo |
| El rendimiento de la base de datos cayó tras migrar a btrfs | La copia en escritura fragmenta archivos que se modifican en el medio | Desactivar la copia en escritura en ese directorio con el atributo correspondiente, asumiendo la pérdida de sumas de verificación |
| Una instantánea reciente no salvó los datos borrados por un fallo del disco | Una instantánea comparte los mismos bloques físicos: no es una copia de respaldo | Mantener respaldos en otro medio; usar transmisión de instantáneas a otra máquina |
Ejercicios propuestos
1. Anatomía de un inodo. Crea un archivo, ejecuta sobre él el programa inspeccionar de este capítulo y anota todos los campos. Luego, en cuatro pasos separados, ejecuta chmod, touch, agrega contenido con echo >> y crea un enlace duro. Después de cada paso vuelve a inspeccionarlo y construye una tabla que muestre qué campo cambió con qué operación. Explica en particular por qué ctime avanza en casos donde mtime no lo hace.
2. Nombres e inodos. Crea un archivo con contenido, hazle un enlace duro y un enlace simbólico. Verifica que dos de las tres rutas comparten número de inodo. Borra ahora el archivo original y comprueba qué le pasa a cada uno de los otros dos. Documenta el valor de nlink en cada etapa y explica por qué el enlace simbólico se rompe y el duro no.
3. Fragmentación interna medida. Genera 5.000 archivos de 300 bytes cada uno en un directorio. Compara la suma de sus tamaños lógicos con lo que reporta du -sh sobre el directorio. Calcula el factor de desperdicio y verifica que corresponde al tamaño de bloque de tu sistema de archivos, que puedes consultar con stat -f.
4. Archivos dispersos. Escribe un programa en C que cree un archivo, se desplace un gigabyte hacia adelante y escriba un solo byte. Compara ls -l con du -h sobre ese archivo. Luego cópialo con cp sin opciones y con cp --sparse=always, y compara el resultado en ambos casos.
5. Recorrer la cadena. Usando debugfs sobre una imagen de prueba creada con mkfs.ext4 en un archivo, inspecciona el inodo de un archivo pequeño y el de uno de 500 MiB. Compara la representación de sus bloques y verifica en cuál aparecen extents y en cuál no hacen falta. Anota cuántos extents necesita el archivo grande y explica por qué ese número no es 1.
6. Ver el diario en acción. Crea una imagen de sistema de archivos ext4 en un archivo, móntala con data=ordered y luego con data=writeback. Ejecuta en cada caso un programa que escriba mil archivos pequeños y mide el tiempo. Explica la diferencia y describe qué garantía se perdió al ganar velocidad.
7. La escritura no durable. Escribe un programa que abra un archivo, escriba 10 MiB y termine sin llamar a fsync. Ejecútalo y, sin esperar, corta la alimentación de una máquina virtual de pruebas. Al reiniciar, verifica cuánto quedó en el archivo. Repite el experimento con fsync y compara.
8. Permisos de directorio. Crea un directorio con permisos r-- para otro usuario y comprueba qué puede hacer: listar, leer un archivo de adentro, obtener metadatos. Repite con --x y con r-x. Construye una tabla de tres columnas con lo que funciona y lo que falla en cada caso, y explica cada resultado en términos de qué necesita el kernel para resolver la ruta.
9. El bit setgid en un directorio. Crea un grupo, un directorio con ese grupo y el bit setgid activo. Haz que dos usuarios distintos creen archivos dentro y verifica el grupo resultante. Repite sin el bit setgid y compara. Explica por qué este mecanismo es la forma estándar de armar una carpeta de equipo.
10. Sticky en la práctica. Reproduce el comportamiento de /tmp creando un directorio 1777 donde dos usuarios escriban archivos. Comprueba que ninguno puede borrar los del otro y que el dueño del directorio sí puede. Después quita el bit sticky y verifica qué cambia.
11. Montar por UUID. Crea un sistema de archivos dentro de un archivo con mkfs.ext4, móntalo mediante un dispositivo de bucle y agrégalo a /etc/fstab usando su UUID. Prueba montarlo con noexec y nosuid y verifica experimentalmente que ambas opciones se aplican. Documenta el mensaje de error exacto que obtienes al intentar ejecutar algo.
12. Instantáneas. En un sistema de archivos btrfs o ZFS de prueba, crea un subvolumen, escribe archivos, toma una instantánea, modifica y borra archivos, y restaura desde la instantánea. Mide el espacio ocupado antes y después de la instantánea y explica por qué casi no creció.
13. Integridad detectada. En una imagen btrfs o ZFS de prueba, escribe un archivo, desmonta, altera deliberadamente algunos bytes del archivo de imagen con una herramienta de edición binaria, vuelve a montar e intenta leer. Documenta el error obtenido y compáralo con el mismo experimento hecho sobre ext4, donde la lectura devuelve los bytes alterados sin advertencia.
14. Duplicados y enlaces. Extiende el módulo de Elixir para que, además de detectar duplicados, distinga los que son enlaces duros al mismo inodo de los que son copias independientes con contenido idéntico. Usa el campo inode de File.Stat. Reporta cuánto espacio se ahorraría reemplazando las copias reales por enlaces duros.
15. Inventario tipado. Modifica el programa de Ada para que además calcule la cantidad de archivos por rango de tamaño (menos de 4 KiB, entre 4 KiB y 1 MiB, más de 1 MiB) y estime el desperdicio por fragmentación interna asumiendo bloques de 4 KiB. Compara tu estimación con lo que reporta du.
16. Diseñar el compromiso. Para tres cargas de trabajo distintas (un servidor de correo con millones de archivos pequeños, un repositorio de video con archivos de decenas de gigabytes, y una base de datos transaccional), elige un sistema de archivos, un tamaño de bloque y un conjunto de opciones de montaje. Justifica cada decisión en función de los mecanismos estudiados en este capítulo, no de recomendaciones generales.
Qué sigue
En este capítulo cerramos el recorrido por los recursos que administra un sistema operativo. Vimos cómo una capa de software convierte un arreglo de bloques anónimos en un árbol de nombres: el inodo como ficha administrativa separada del contenido, el directorio como archivo que asocia nombres a números de inodo, las cuatro estrategias de asignación con sus compromisos entre acceso secuencial, acceso aleatorio y fragmentación, y el journaling como respuesta al problema de que una operación lógica necesita varias escrituras físicas. Después comparamos cuatro diseños reales (FAT con su tabla de cadenas, ext4 con extents y diario, btrfs y ZFS con copia en escritura y verificación de integridad) y terminamos con los dos mecanismos que el administrador toca todos los días: los permisos y el montaje sobre la capa VFS.
Con esto tenemos el mapa completo de la parte conceptual del curso: procesos, planificación, memoria, concurrencia, comunicación, seguridad y almacenamiento. Lo que viene ahora es un cambio de registro. Hasta aquí usamos varios lenguajes para ilustrar mecanismos; en la segunda parte del curso vamos a estudiar en profundidad los dos que aparecieron de forma recurrente, empezando por el que fue diseñado explícitamente para sistemas donde una falla no es una opción.
En el capítulo 12 empezamos con Ada desde cero: por qué el Departamento de Defensa de Estados Unidos convocó un concurso internacional para crear un lenguaje, qué problemas de los lenguajes de la época intentaba resolver, cómo se instala y compila un programa con GNAT, y cuáles son las construcciones básicas del lenguaje. Verás por qué muchas de las decisiones de diseño que hoy parecen verbosas son exactamente las que hacen que un error se detecte al compilar en vez de en producción. Puedes revisar el recorrido completo del curso en el índice.