Arquitectura de computadoras para entender el sistema operativo
Arquitectura de computadoras para entender el sistema operativo
En el capítulo 3 revisamos los roles que cumple un sistema operativo: administrar procesos, memoria, archivos, dispositivos y usuarios. Ese capítulo describió el qué. Este capítulo describe el sobre qué: la máquina física que el sistema operativo administra.
La razón es simple. Casi todas las decisiones de diseño de un kernel son respuestas a una propiedad del hardware. La planificación de procesos existe porque hay un reloj que genera interrupciones periódicas. La memoria virtual existe porque la CPU tiene una unidad de traducción de direcciones. Las llamadas al sistema son caras porque cambiar de modo de ejecución implica guardar y restaurar estado del procesador. El DMA existe porque copiar bytes con la CPU desperdicia ciclos que podrían usarse en otra cosa.
Si aprendes estos mecanismos primero, el resto del curso deja de ser una lista de definiciones y se convierte en una cadena de consecuencias. Este capítulo cubre siete piezas: el modelo general de la máquina, la CPU y su ciclo, los registros, la jerarquía de memoria con su cache, el mapa de memoria de un proceso (stack y heap), las interrupciones, el DMA y la separación entre modo usuario y modo kernel.
El modelo de la máquina: von Neumann y los buses
La inmensa mayoría de las computadoras que usamos siguen el modelo de von Neumann: instrucciones y datos viven en la misma memoria principal, y un procesador las lee de a una para ejecutarlas. Esa decisión, que parece trivial, tiene consecuencias enormes. Significa que un programa puede modificar otro programa (o a si mismo), que un error de escritura puede corromper código, y que el ancho de banda hacia memoria es el cuello de botella central de la máquina.
Los componentes mínimos son cuatro: la CPU, la memoria principal, los dispositivos de entrada/salida y los buses que los conectan.
flowchart LR
subgraph CPU["CPU"]
UC["Unidad de control<br/>decodifica instrucciones"]
ALU["ALU<br/>suma, resta, AND, OR, XOR"]
REG["Banco de registros<br/>PC, SP, generales, flags"]
L1["Cache L1 I + L1 D"]
L2["Cache L2"]
end
L3["Cache L3 compartida"]
MC["Controlador de memoria"]
RAM["Memoria principal (DRAM)"]
subgraph IO["Subsistema de E/S"]
DMAC["Motor DMA"]
NVME["Controlador NVMe"]
NIC["Interfaz de red"]
IRQC["Controlador de interrupciones<br/>APIC / GIC / PLIC"]
end
UC <--> REG
UC <--> ALU
REG <--> L1
L1 <--> L2
L2 <--> L3
L3 <--> MC
MC <--> RAM
L3 <--> IO
DMAC <--> MC
NVME --> DMAC
NIC --> DMAC
NVME --> IRQC
NIC --> IRQC
IRQC -->|señal de interrupción| UC
Fíjate en dos caminos del diagrama que van a ser el hilo conductor del capítulo:
- El camino de datos normal: registros, L1, L2, L3, controlador de memoria, DRAM. Cada salto es aproximadamente un orden de magnitud más lento que el anterior.
- El camino de los dispositivos: un dispositivo escribe directamente en memoria a través del motor DMA y después avisa a la CPU con una interrupción. La CPU no copió ningún byte; solo fue notificada al final.
El sistema operativo vive en la intersección de esos dos caminos.
La CPU por dentro
El reloj y el concepto de ciclo
La CPU es un circuito síncrono: todos sus elementos de estado (registros, latches del pipeline) cambian de valor en el flanco de una señal periódica llamada reloj. Un ciclo de reloj es el intervalo entre dos flancos. Es la unidad de tiempo más pequeña con la que se puede razonar sobre el rendimiento del procesador.
La frecuencia es cuántos ciclos ocurren por segundo:
| Procesador | Año aproximado | Frecuencia típica | Duración de un ciclo |
|---|---|---|---|
| MOS 6502 | 1975 | 1 MHz | 1000 ns |
| Intel 80386 | 1985 | 16 MHz | 62,5 ns |
| Pentium III | 1999 | 600 MHz | 1,67 ns |
| CPU de escritorio actual | 2020 en adelante | 3 a 6 GHz | 0,17 a 0,33 ns |
Un dato útil para calibrar la intuición: en un ciclo de 0,3 ns, la luz recorre unos 9 centimetros. Ese límite físico explica por qué las caches deben estar dentro del chip: una señal eléctrica no alcanza a ir y volver a un módulo de DRAM que esta a varios centimetros en menos de un ciclo.
Subir la frecuencia dejó de ser la vía principal de mejora alrededor de 2005, porque el consumo dinámico crece de forma más que lineal con la frecuencia y el voltaje necesario. Desde entonces el crecimiento vino por otro lado: más núcleos, pipelines más profundos, ejecución fuera de orden, predicción de saltos y caches más grandes. Para el sistema operativo eso cambió todo: administrar concurrencia real dejó de ser un caso especial y pasó a ser el caso normal.
El ciclo de instrucción
En su forma más básica, la CPU repite tres pasos indefinidamente.
stateDiagram-v2
[*] --> Fetch
Fetch: Fetch (búsqueda)
Fetch: lee la instrucción en la dirección que indica el PC
Fetch: incrementa el PC al largo de la instrucción
Decode: Decode (decodificación)
Decode: interpreta el opcode
Decode: identifica operandos y registros involucrados
Execute: Execute (ejecución)
Execute: la ALU opera, o se accede a memoria, o se salta
Writeback: Writeback (escritura de resultado)
Writeback: guarda el resultado en un registro o en memoria
Writeback: actualiza las banderas de estado
Check: Verificación de interrupciones
Check: hay una señal pendiente?
Fetch --> Decode
Decode --> Execute
Execute --> Writeback
Writeback --> Check
Check --> Fetch: no hay interrupción
Check --> Interrupción: si hay interrupción
Interrupción: Atención de interrupción
Interrupción: guarda contexto mínimo
Interrupción: salta al manejador vía tabla de vectores
Interrupción --> Fetch: retorno de interrupción
Ese último estado, la verificación de interrupciones al final de cada instrucción, es el punto donde el sistema operativo recupera el control de la máquina. Sin el, un programa en un bucle infinito sería imposible de detener. Volveremos ahí en la sección de interrupciones.
En procesadores reales el ciclo esta segmentado (pipeline): mientras una instrucción se ejecuta, la siguiente se decodifica y la subsiguiente se busca. Un pipeline de 14 a 20 etapas puede tener decenas de instrucciones en vuelo simultáneamente, y la CPU las reordena internamente mientras el resultado observable sea el mismo que en orden secuencial. Esta reordenación es invisible para un programa de un solo hilo, pero se vuelve visible entre hilos, y es la razón por la que existen las barreras de memoria que veremos en los capítulos de sincronización.
La ALU y las banderas
La unidad aritmética lógica es el circuito combinacional que toma uno o dos operandos y un código de operación y produce un resultado. Internamente es un conjunto de sumadores, desplazadores y compuertas lógicas (AND, OR, XOR, NOT) seleccionados por multiplexores.
Además del resultado, la ALU produce banderas de estado que se guardan en un registro especial:
| Bandera | Nombre habitual | Significado |
|---|---|---|
| Z | Zero | El resultado fue exactamente cero |
| N o S | Negative / Sign | El bit más significativo del resultado es 1 |
| C | Carry | Hubo acarreo o prestamo fuera del ancho del operando |
| V u O | Overflow | El resultado excede el rango con signo representable |
Las instrucciones de salto condicional no comparan nada por si mismas: leen estas banderas. Un if (a == b) en C se compila típicamente en una resta o comparación que fija la bandera Z, seguida de un salto que se toma si Z esta encendida. Toda la lógica de control de cualquier programa se reduce, en el hardware, a estos cuatro bits.
Registros
Los registros son celdas de almacenamiento dentro del propio núcleo de la CPU. Son la memoria más rápida que existe en la máquina: acceder a uno no cuesta ciclos adicionales porque forman parte del camino de datos. También son la más escasa: un x86-64 tiene 16 registros generales de 64 bits, es decir 128 bytes de espacio nombrable directamente.
Registros de uso general
En x86-64 los registros generales son rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp y r8 a r15. Cada uno es accesible también en anchos menores: eax son los 32 bits bajos de rax, ax los 16 bajos, al los 8 bajos.
En ARM64 (AArch64) hay 31 registros generales, x0 a x30, con sus vistas de 32 bits w0 a w30.
Registros de propósito especial
Estos son los que el sistema operativo manipula directamente:
| Registro (x86-64) | Equivalente ARM64 | Función |
|---|---|---|
rip | pc | Contador de programa: dirección de la siguiente instrucción |
rsp | sp | Puntero al tope de la pila |
rbp | x29 (por convención) | Puntero al marco de pila actual |
rflags | pstate / nzcv | Banderas de estado y bits de control |
cr3 | ttbr0_el1 / ttbr1_el1 | Dirección física de la tabla de páginas del proceso actual |
cr0, cr4 | sctlr_el1 | Bits de control: paginación activa, protección de escritura, extensiones |
gdtr, idtr | vbar_el1 | Dirección de las tablas de descriptores y de vectores de interrupción |
msr varios | elr_el1, spsr_el1 | Estado guardado al entrar al kernel |
El contador de programa merece atención especial. Es el registro que define “donde va el flujo de ejecución”. Cambiar su valor es lo único que hace un salto, una llamada a función, un retorno, una interrupción o un cambio de contexto. Cuando el sistema operativo conmuta de un proceso a otro, lo que en el fondo hace es guardar el valor actual de todos los registros (incluido el PC) en una estructura en memoria, cargar los valores guardados del otro proceso y saltar. Nada más.
Ver los registros en acción
Compila una función trivial y mira el código generado.
/* suma.c -- compilar con: gcc -O0 -S -masm=intel suma.c -o suma.s */
int suma(int a, int b)
{
int r = a + b;
return r;
}
Con gcc -O0 -S -masm=intel suma.c -o suma.s obtienes un archivo suma.s cuyo cuerpo, ignorando directivas del ensamblador, es equivalente a esto en x86-64 Linux:
suma:
push rbp ; guarda el marco de pila anterior
mov rbp, rsp ; establece el marco nuevo
mov DWORD PTR [rbp-20], edi ; primer argumento (a) a la pila
mov DWORD PTR [rbp-24], esi ; segundo argumento (b) a la pila
mov edx, DWORD PTR [rbp-20]
mov eax, DWORD PTR [rbp-24]
add eax, edx ; la ALU suma; se actualizan las banderas
mov DWORD PTR [rbp-4], eax ; guarda r
mov eax, DWORD PTR [rbp-4] ; valor de retorno en eax
pop rbp
ret
Tres observaciones que resumen toda la sección anterior:
- Los argumentos llegaron en registros (
edi,esi), no en la pila. Eso lo define la ABI del sistema, no el lenguaje. - Sin optimización, el compilador guarda todo en la pila y lo vuelve a leer. Con
-O2la función entera colapsa alea eax, [rdi+rsi]seguido deret: dos instrucciones y cero accesos a memoria. - El valor de retorno viaja en
eax. Esa es también una convención de la ABI, y es la que usa el kernel para devolver el resultado de una llamada al sistema.
Compara ambas versiones tu mismo:
gcc -O0 -S -masm=intel suma.c -o suma-O0.s
gcc -O2 -S -masm=intel suma.c -o suma-O2.s
diff suma-O0.s suma-O2.s
La jerarquía de memoria
El problema
Existe una tensión irreducible entre velocidad, capacidad y costo del almacenamiento. Una memoria rápida es cara y por lo tanto pequeña; una memoria grande es barata y por lo tanto lenta. La solución de la industria no fue elegir, sino apilar: varios niveles, cada uno más grande y lento que el anterior, con el hardware moviendo datos entre niveles de forma automática.
flowchart TD
R["Registros<br/>~128 bytes<br/>0 ciclos"] --> L1["Cache L1<br/>32-64 KiB por núcleo<br/>~4 ciclos"]
L1 --> L2["Cache L2<br/>512 KiB - 2 MiB por núcleo<br/>~14 ciclos"]
L2 --> L3["Cache L3<br/>8-64 MiB compartida<br/>~40 ciclos"]
L3 --> RAM["Memoria principal DRAM<br/>8-128 GiB<br/>~200-300 ciclos"]
RAM --> SSD["SSD NVMe<br/>0,5-8 TB<br/>~50.000-300.000 ciclos"]
SSD --> HDD["Disco mecánico / red<br/>TB a PB<br/>millones de ciclos"]
R -.->|"gestionado por el compilador"| L1
L1 -.->|"gestionado por el hardware"| RAM
RAM -.->|"gestionado por el sistema operativo"| SSD
La última columna de flechas punteadas es la más importante para este curso. La jerarquía esta administrada por tres agentes distintos:
- El compilador decide qué variables viven en registros.
- El hardware decide, sin intervención de nadie, qué líneas de memoria están en cache.
- El sistema operativo decide qué páginas de memoria están en RAM y cuáles en disco (swap), y además decide qué archivos se mantienen en la cache de páginas.
El sistema operativo no controla la cache de la CPU. Solo puede influirla indirectamente: eligiendo como dispone las estructuras de datos, en que núcleo ejecuta cada hilo, y con que política de cache marca cada región de memoria.
Tabla de latencias de referencia
Los valores exactos varían entre modelos, pero los órdenes de magnitud son estables desde hace más de una década. Esta tabla es la que conviene tener memorizada.
| Nivel | Latencia típica | En ciclos a 3 GHz | Escala humana (si 1 ciclo = 1 segundo) |
|---|---|---|---|
| Registro | 0,3 ns | ~1 | 1 segundo |
| Cache L1 | 1,3 ns | ~4 | 4 segundos |
| Cache L2 | 4 ns | ~14 | 14 segundos |
| Cache L3 | 13 ns | ~40 | 40 segundos |
| DRAM | 80 ns | ~240 | 4 minutos |
| SSD NVMe (lectura 4 KiB) | 25 us | ~75.000 | 21 horas |
| Disco mecánico (búsqueda) | 8 ms | ~24.000.000 | 9 meses |
| Ida y vuelta en LAN | 0,5 ms | ~1.500.000 | 17 días |
| Ida y vuelta transatlántica | 130 ms | ~390.000.000 | 12 años |
La columna de la derecha es la que explica decisiones de diseño del kernel que de otro modo parecen exageradas. Cuando un proceso pide un dato que esta en disco, el sistema operativo lo bloquea y ejecuta otro proceso, porque esperar sería equivalente a que una persona se quede parada nueve meses frente a una puerta.
Cómo consultar la jerarquía real de tu máquina
# Resumen de nucleos, hilos y tamanos de cache
lscpu
# Tamano de la linea de cache de nivel 1 de datos, en bytes
getconf LEVEL1_DCACHE_LINESIZE
# Detalle nivel por nivel, tal como lo expone el kernel
for d in /sys/devices/system/cpu/cpu0/cache/index*; do
echo "=== $d"
cat "$d/level" "$d/type" "$d/size" "$d/coherency_line_size" \
"$d/ways_of_associativity" "$d/shared_cpu_list"
done
Ese último bucle imprime, para cada nivel: número de nivel, si guarda instrucciones o datos, tamaño total, tamaño de línea, asociatividad y que núcleos comparten esa cache. El campo shared_cpu_list es especialmente útil: si dos identificadores de CPU aparecen juntos en la L1 o L2, son hilos SMT del mismo núcleo físico, y planificar dos tareas intensivas en ellos las hace competir por la misma cache.
Cache: cómo funciona realmente
Localidad, el principio que lo hace posible
La cache funciona porque los programas reales no acceden a memoria al azar. Exhiben dos formas de localidad:
- Localidad temporal: si un dato se usó hace poco, probablemente se use de nuevo pronto. El contador de un bucle, el puntero a una estructura, una variable de configuración.
- Localidad espacial: si se usó una dirección, probablemente se usen las direcciones vecinas. Recorrer un arreglo, leer los campos de una estructura, ejecutar instrucciones consecutivas.
La localidad espacial es la que da origen a la línea de cache: el hardware nunca trae un solo byte desde DRAM. Trae un bloque alineado, típicamente de 64 bytes. Leer un int de 4 bytes en una dirección que no esta en cache cuesta lo mismo que leer los 64 bytes completos que lo rodean, porque el bus y el protocolo de DRAM están diseñados para transferencias en ráfaga.
Anatomía de un acceso
Una dirección de memoria se descompone en tres campos para consultar la cache.
flowchart TD
A["Dirección virtual del acceso"] --> B["MMU + TLB<br/>traduce a dirección física"]
B --> C["Descomposicion de la dirección<br/>etiqueta | índice de conjunto | desplazamiento"]
C --> D{"Alguna vía del conjunto<br/>tiene esa etiqueta y es valida?"}
D -->|Sí: acierto| E["Devuelve el dato desde la línea<br/>~4 ciclos en L1"]
D -->|No: fallo| F["Consulta al siguiente nivel<br/>L2, luego L3, luego DRAM"]
F --> G{"Hay una vía libre<br/>en el conjunto?"}
G -->|Sí| H["Instala la línea de 64 bytes"]
G -->|No| I["Elige victima según política<br/>pseudo-LRU"]
I --> J{"La victima esta sucia?"}
J -->|Sí| K["Escribe la línea a nivel inferior<br/>write-back"]
J -->|No| L["Descarta la línea"]
K --> H
L --> H
H --> E
- El desplazamiento son los bits bajos: con líneas de 64 bytes, los 6 bits menos significativos.
- El índice de conjunto selecciona en que conjunto de la cache podría estar la línea.
- La etiqueta son los bits restantes, y se compara contra las etiquetas guardadas en cada vía del conjunto.
Una cache “asociativa por conjuntos de 8 vías” significa que cada conjunto tiene 8 ranuras, y una línea puede ir en cualquiera de esas 8. Los dos extremos son el mapeo directo (1 vía, rápido pero con muchos conflictos) y el totalmente asociativo (una sola posición posible: cualquiera, caro de implementar).
Políticas de escritura
| Política | Qué hace al escribir | Ventaja | Costo |
|---|---|---|---|
| Write-through | Escribe en cache y en el nivel inferior a la vez | Coherencia simple, sin líneas sucias | Mucho tráfico hacia memoria |
| Write-back | Escribe solo en cache y marca la línea sucia | Absorbe escrituras repetidas al mismo lugar | Requiere vaciado explícito antes de un DMA |
| Write-allocate | Ante un fallo de escritura, trae la línea a cache | Aprovecha escrituras posteriores cercanas | Trae datos que quizás se sobrescriban enteros |
| No-write-allocate | Ante un fallo de escritura, escribe directo al nivel inferior | Evita traer líneas inútiles | Pierde localidad si vuelve a escribir cerca |
Las caches de datos modernas de propósitos generales son write-back con write-allocate. Ese detalle importa cuando hablemos de DMA: si un dispositivo va a leer un buffer directamente desde DRAM, el kernel debe asegurarse de que las líneas sucias correspondientes ya fueron volcadas.
Coherencia entre núcleos
Cada núcleo tiene su propia L1. Si dos núcleos leen la misma dirección, ambos tienen una copia. Si uno escribe, la copia del otro queda obsoleta. El hardware resuelve esto con un protocolo de coherencia; el más citado es MESI, que asigna a cada línea uno de cuatro estados:
| Estado | Significado | Otros núcleos pueden tener copia | Difiere de memoria |
|---|---|---|---|
| Modified | Este núcleo la modificó y es el único dueño | No | Sí |
| Exclusive | Este núcleo la tiene y nadie más, sin modificar | No | No |
| Shared | Varios núcleos la tienen para lectura | Sí | No |
| Invalid | La línea no es utilizable | Irrelevante | Irrelevante |
Escribir sobre una línea en estado Shared obliga al hardware a invalidarla en todos los demas núcleos antes de proceder. Esa invalidación cuesta decenas de ciclos, y es la causa de un problema de rendimiento muy común en programación concurrente.
Falso compartimiento (false sharing)
Dos hilos que escriben en variables distintas, sin ninguna carrera de datos lógica, pueden degradarse mutuamente si esas variables caen en la misma línea de cache. El hardware no razona en variables, razona en líneas de 64 bytes.
Este programa lo demuestra de forma medible. Compila y ejecuta con y sin la separación.
/* falso_compartimiento.c
* Compilar: gcc -O2 -pthread falso_compartimiento.c -o fc
* Ejecutar: ./fc juntos y luego ./fc separados
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <time.h>
#define LINEA 64
#define ITERACIONES 200000000L
#define HILOS 4
typedef struct {
volatile long contador;
char relleno[LINEA - sizeof(long)];
} contador_separado_t;
typedef struct {
volatile long contador;
} contador_junto_t;
static contador_separado_t separados[HILOS] __attribute__((aligned(LINEA)));
static contador_junto_t juntos[HILOS];
static void *trabajo_separado(void *arg)
{
int id = *(int *)arg;
for (long i = 0; i < ITERACIONES; i++)
separados[id].contador++;
return NULL;
}
static void *trabajo_junto(void *arg)
{
int id = *(int *)arg;
for (long i = 0; i < ITERACIONES; i++)
juntos[id].contador++;
return NULL;
}
static double ahora_segundos(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec / 1e9;
}
int main(int argc, char **argv)
{
int separar = (argc > 1 && strcmp(argv[1], "separados") == 0);
pthread_t hilos[HILOS];
int ids[HILOS];
double t0, t1;
printf("Tamano de contador_separado_t: %zu bytes\n", sizeof(contador_separado_t));
printf("Tamano de contador_junto_t: %zu bytes\n", sizeof(contador_junto_t));
t0 = ahora_segundos();
for (int i = 0; i < HILOS; i++) {
ids[i] = i;
pthread_create(&hilos[i], NULL,
separar ? trabajo_separado : trabajo_junto,
&ids[i]);
}
for (int i = 0; i < HILOS; i++)
pthread_join(hilos[i], NULL);
t1 = ahora_segundos();
printf("Modo: %s\n", separar ? "separados (1 linea por contador)"
: "juntos (todos en pocas lineas)");
printf("Tiempo: %.3f s\n", t1 - t0);
return 0;
}
En una máquina típica de cuatro núcleos, la versión “juntos” tarda entre tres y diez veces más que la versión “separados”, ejecutando exactamente las mismas instrucciones. La diferencia completa es tráfico de coherencia de cache. Ningún perfilador a nivel de código fuente muestra esa causa; hay que conocer el hardware.
Localidad espacial medida en Ada
El mismo principio se ve al recorrer una matriz. En Ada los arreglos multidimensionales se almacenan por filas (row-major), igual que en C, así que recorrer variando el último índice en el bucle interno respeta la localidad espacial.
-- localidad.adb
-- Compilar: gnatmake -O2 localidad.adb
-- Ejecutar: ./localidad
with Ada.Text_IO; use Ada.Text_IO;
with Ada.Real_Time; use Ada.Real_Time;
procedure Localidad is
N : constant := 2048;
type Matriz is array (1 .. N, 1 .. N) of Integer;
type Ref_Matriz is access Matriz;
M : constant Ref_Matriz := new Matriz;
Suma : Long_Long_Integer;
Inicio, Fin : Time;
procedure Reportar (Etiqueta : String; Transcurrido : Time_Span) is
begin
Put_Line (Etiqueta & ": " & Duration'Image (To_Duration (Transcurrido)) & " s");
end Reportar;
begin
-- Inicializacion
for I in 1 .. N loop
for J in 1 .. N loop
M (I, J) := I + J;
end loop;
end loop;
-- Recorrido por filas: aprovecha la linea de cache completa
Suma := 0;
Inicio := Clock;
for I in 1 .. N loop
for J in 1 .. N loop
Suma := Suma + Long_Long_Integer (M (I, J));
end loop;
end loop;
Fin := Clock;
Reportar ("Por filas (M(I,J), J interno)", Fin - Inicio);
-- Recorrido por columnas: un fallo de cache por cada acceso
Suma := 0;
Inicio := Clock;
for J in 1 .. N loop
for I in 1 .. N loop
Suma := Suma + Long_Long_Integer (M (I, J));
end loop;
end loop;
Fin := Clock;
Reportar ("Por columnas (M(I,J), I interno)", Fin - Inicio);
Put_Line ("Suma final (para evitar eliminacion de codigo): " &
Long_Long_Integer'Image (Suma));
end Localidad;
Ambos bucles ejecutan exactamente 4.194.304 sumas. La versión por columnas suele tardar entre cuatro y diez veces más. Cada acceso salta 8 KiB en memoria (2048 enteros de 4 bytes), así que ninguna de las líneas traidas se reutiliza antes de ser desalojada.
Si quieres confirmar la causa en vez de suponerla, mide los fallos de cache directamente:
perf stat -e cache-references,cache-misses,L1-dcache-load-misses ./localidad
El mapa de memoria de un proceso: stack y heap
El sistema operativo no le entrega a un proceso “la memoria” como un bloque indiferenciado. Le construye un espacio de direcciones virtual con regiones de propósito distinto.
flowchart TD
subgraph ESP["Espacio de direcciones virtual de un proceso (64 bits, esquema Linux)"]
K["Espacio del kernel<br/>direcciones altas, inaccesible en modo usuario"]
SK["Pila (stack)<br/>crece hacia direcciones bajas<br/>marcos de llamada, variables locales"]
LIB["Regiones mapeadas<br/>bibliotecas compartidas, mmap anonimo, archivos mapeados"]
HE["Montículo (heap)<br/>crece hacia direcciones altas<br/>malloc / free"]
BSS["Segmento .bss<br/>variables globales sin inicializar, puestas a cero"]
DAT["Segmento .data<br/>variables globales inicializadas"]
TXT["Segmento .text<br/>código máquina, solo lectura y ejecutable"]
end
K --- SK
SK --- LIB
LIB --- HE
HE --- BSS
BSS --- DAT
DAT --- TXT
La pila
La pila es una estructura LIFO gestionada por el hardware a través del puntero de pila (rsp). Cada llamada a función empuja un marco de pila con la dirección de retorno, los registros que hay que preservar y las variables locales; cada retorno lo descarta.
Propiedades que importan:
- Es rapidísima. Reservar espacio es restarle un valor a
rsp: una instrucción. Liberarlo es sumarselo. Además el tope de la pila esta casi siempre en L1, porque se usa constantemente. - Tiene tamaño acotado. En Linux el límite por defecto de la pila del hilo principal suele ser 8 MiB; consúltalo con
ulimit -s. Superarlo produce un desbordamiento de pila, que el kernel detecta como fallo de página en una zona de guarda y convierte enSIGSEGV. - Su tiempo de vida es estrictamente anidado. Devolver un puntero a una variable local es un error clásico: la memoria sigue existiendo, pero pertenece a quien llame después.
El montículo
El heap sirve para objetos cuyo tiempo de vida no coincide con el de ninguna función. El programa pide bloques de tamaño arbitrario y los devuelve cuando quiere.
Es importante distinguir dos niveles:
- El asignador de la biblioteca C (
malloc,free,realloc) administra bloques dentro de regiones que ya le pertenecen al proceso. Es código de usuario. Unmallocde 100 bytes normalmente no habla con el kernel en absoluto. - El kernel solo entra cuando el asignador necesita más territorio, y entonces usa
brk/sbrkpara mover el borde del heap ommappara pedir una región nueva. Estas si son llamadas al sistema.
Este programa muestra el mapa de memoria del propio proceso y donde caen sus objetos:
/* mapa.c -- compilar con: gcc -O0 mapa.c -o mapa y ejecutar: ./mapa */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int global_inicializado = 42; /* .data */
int global_sin_inicializar; /* .bss */
static void mostrar_maps(void)
{
char linea[512];
FILE *f = fopen("/proc/self/maps", "r");
if (!f) return;
printf("\n--- /proc/self/maps ---\n");
while (fgets(linea, sizeof linea, f))
fputs(linea, stdout);
fclose(f);
}
int main(void)
{
int local = 7;
void *del_heap = malloc(128);
void *heap_grande = malloc(4 * 1024 * 1024);
printf("codigo (main) : %p\n", (void *)main);
printf("data (global_inicializado): %p\n", (void *)&global_inicializado);
printf("bss (global_sin_init) : %p\n", (void *)&global_sin_inicializar);
printf("heap (malloc 128 B) : %p\n", del_heap);
printf("heap/mmap(malloc 4 MiB) : %p\n", heap_grande);
printf("stack (local) : %p\n", (void *)&local);
printf("borde actual del heap (brk) : %p\n", sbrk(0));
mostrar_maps();
free(del_heap);
free(heap_grande);
return 0;
}
Al ejecutarlo verás que la dirección de local es enorme comparada con la del heap, que main esta en una región marcada r-xp y que el bloque de 4 MiB suele caer en una región completamente distinta de la del bloque de 128 bytes: malloc sirve las peticiones grandes con mmap directo en vez de usar el heap tradicional, para poder devolverlas al sistema al liberarlas.
Confirma esa diferencia observando las llamadas al sistema:
strace -e trace=brk,mmap,munmap ./mapa
Modo usuario y modo kernel
Por qué existen dos modos
Un proceso de usuario no puede tener permiso para ejecutar cualquier instrucción. Si pudiera escribir en cr3 cambiaria su propia tabla de páginas y leería la memoria de cualquier otro proceso. Si pudiera deshabilitar interrupciones se quedaría con la CPU para siempre. Si pudiera hablar directamente con el controlador de disco leería archivos ajenos.
La solución es un bit de estado en la CPU que define el nivel de privilegio actual. En x86 se llaman anillos:
| Nivel x86 | Nivel ARM64 | Nivel RISC-V | Quién ejecuta ahí | Puede |
|---|---|---|---|---|
| Ring 3 | EL0 | U-mode | Aplicaciones | Solo instrucciones no privilegiadas, solo su memoria mapeada |
| Ring 2 / 1 | — | — | Prácticamente sin uso en sistemas modernos | — |
| Ring 0 | EL1 | S-mode | El kernel | Todo el conjunto de instrucciones, toda la memoria física, E/S |
| VMX root | EL2 | HS-mode | Hipervisor | Controlar y virtualizar a los kernels invitados |
| SMM | EL3 | M-mode | Firmware / modo seguro | Estado más privilegiado de la máquina |
Casi todos los sistemas operativos de propósito general usan solo dos: ring 3 para las aplicaciones y ring 0 para el kernel.
Qué se vuelve imposible en modo usuario
En modo usuario, intentar cualquiera de estas cosas genera una excepción de protección, no un resultado:
- Ejecutar instrucciones privilegiadas (cargar tablas de descriptores, escribir registros de control, deshabilitar interrupciones, acceder a puertos de E/S).
- Leer o escribir una dirección virtual que no este mapeada, o que este mapeada solo para el kernel.
- Escribir en una página marcada como solo lectura.
- Ejecutar código en una página marcada como no ejecutable.
El hardware no confía en la buena fe del programa. Verifica cada acceso en la MMU, en cada instrucción.
El único puente: la llamada al sistema
Como un proceso solo puede hacer lo suyo, y necesita cosas que solo el kernel puede hacer (leer un archivo, crear un proceso, enviar un paquete), debe existir una puerta controlada. Esa puerta es una instrucción especial que hace dos cosas de forma atómica: sube el nivel de privilegio y salta a una dirección que el kernel definió de antemano. El proceso no elige adónde salta; solo elige qué número de servicio pide.
En x86-64 Linux esa instrucción es syscall. El número de servicio va en rax, y los argumentos en rdi, rsi, rdx, r10, r8, r9. El resultado vuelve en rax.
sequenceDiagram
participant App as Aplicacion (ring 3)
participant Libc as libc (ring 3)
participant HW as CPU / MSR LSTAR
participant K as Kernel (ring 0)
participant Disp as Controlador del dispositivo
App->>Libc: write(1, buf, 12)
Libc->>Libc: coloca rax=1, rdi=1, rsi=buf, rdx=12
Libc->>HW: instrucción syscall
HW->>HW: guarda rip en rcx y rflags en r11
HW->>HW: cambia a ring 0 y a la pila del kernel
HW->>K: salta a entry_SYSCALL_64 (dirección en el MSR LSTAR)
K->>K: guarda el resto de los registros de usuario
K->>K: valida rax contra la tabla de llamadas al sistema
K->>K: valida que buf pertenezca al proceso (copy_from_user)
K->>Disp: entrega los datos al dispositivo
Disp-->>K: aceptado
K->>K: coloca el resultado en rax
K->>HW: instrucción sysret
HW->>HW: restaura rip desde rcx, rflags desde r11, vuelve a ring 3
HW-->>Libc: retorno con rax = 12
Libc-->>App: devuelve 12
Dos detalles del diagrama que suelen pasarse por alto:
- La dirección del punto de entrada del kernel esta en un registro de control (el MSR
LSTARen x86-64) que solo se puede escribir en ring 0. El proceso no puede redirigir la puerta. - El kernel no confía en los punteros que recibe. Antes de tocar
bufverifica que la región pertenezca al espacio del proceso llamante y copia con rutinas especiales que toleran fallos. Un kernel que dereferencie directamente un puntero de usuario tiene un agujero de seguridad.
Una llamada al sistema escrita a mano
Este programa en ensamblador x86-64 para Linux no usa libc en absoluto. Hace dos llamadas al sistema directas.
; hola.asm
; Ensamblar y enlazar:
; nasm -f elf64 hola.asm -o hola.o
; ld hola.o -o hola
; ./hola ; echo "codigo de salida: $?"
section .data
mensaje: db "Hola desde ring 3", 10
largo: equ $ - mensaje
section .text
global _start
_start:
; write(1, mensaje, largo)
mov rax, 1 ; numero de la llamada: sys_write
mov rdi, 1 ; descriptor 1: salida estandar
mov rsi, mensaje ; puntero al buffer
mov rdx, largo ; cantidad de bytes
syscall ; transicion ring 3 -> ring 0 -> ring 3
; exit_group(7)
mov rax, 231 ; numero de la llamada: sys_exit_group
mov rdi, 7 ; codigo de salida
syscall ; esta no retorna
Comprueba lo que ocurre desde fuera:
nasm -f elf64 hola.asm -o hola.o
ld hola.o -o hola
strace ./hola
strace funciona precisamente porque las llamadas al sistema pasan por una puerta única y observable: el kernel ofrece un mecanismo (ptrace) que detiene al proceso en cada cruce de esa puerta.
El costo real de cruzar el límite
Cambiar de modo no es gratis. Hay que guardar registros, cambiar de pila, cambiar de tabla de páginas en algunos casos, y volver. En hardware moderno la ida y vuelta de una llamada al sistema trivial cuesta del orden de 100 a 500 ciclos, y las mitigaciones contra vulnerabilidades de ejecución especulativa (aislamiento de tablas de páginas del kernel, vaciados de predictores) la encarecieron todavía más.
Este programa mide el costo de una llamada al sistema que casi no hace trabajo:
/* costo_syscall.c
* Compilar: gcc -O2 costo_syscall.c -o costo_syscall
* Ejecutar: ./costo_syscall
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
#define REPETICIONES 2000000
static double ahora(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec + ts.tv_nsec / 1e9;
}
int main(void)
{
double t0, t1;
volatile long acumulador = 0;
/* 1) Llamada al sistema real: getppid no se cachea en libc */
t0 = ahora();
for (long i = 0; i < REPETICIONES; i++)
acumulador += syscall(SYS_getppid);
t1 = ahora();
printf("syscall(SYS_getppid): %.1f ns por llamada\n",
(t1 - t0) * 1e9 / REPETICIONES);
/* 2) Llamada a funcion normal en el mismo proceso */
t0 = ahora();
for (long i = 0; i < REPETICIONES; i++)
acumulador += i & 0xF;
t1 = ahora();
printf("operacion en modo usuario: %.2f ns por iteracion\n",
(t1 - t0) * 1e9 / REPETICIONES);
printf("(acumulador = %ld)\n", acumulador);
return 0;
}
La diferencia suele ser de dos a tres órdenes de magnitud. Esa medición explica muchas decisiones de diseño que verás más adelante: por qué existe el vDSO (una región de código del kernel mapeada en el proceso para que funciones como obtener la hora no crucen a ring 0), por qué las bibliotecas de E/S usan buffers en vez de escribir byte por byte, y por qué interfaces como io_uring buscan enviar cientos de operaciones con una sola transición.
Puedes ver el vDSO en el mapa de memoria de cualquier proceso:
grep -E 'vdso|vsyscall' /proc/self/maps
Interrupciones
El problema que resuelven
Un dispositivo tarda millones de ciclos en completar una operación. La CPU tiene dos formas de enterarse de que terminó:
- Sondeo (polling): preguntar en un bucle. Simple, pero consume la CPU entera mientras espera.
- Interrupción: seguir con otra cosa y dejar que el dispositivo avise. La CPU no gasta ciclos esperando.
Las interrupciones son lo que convierte a la computadora en una máquina que puede hacer varias cosas a la vez sin dedicar un núcleo a cada espera.
Taxonomía
No todo lo que interrumpe el flujo es lo mismo. Conviene distinguir tres familias:
| Tipo | Origen | Síncrona con la instrucción | Ejemplos | Qué hace el kernel |
|---|---|---|---|---|
| Interrupción de hardware (IRQ) | Externo a la CPU | No: llega en cualquier momento | Reloj, teclado, red, disco | Atiende al dispositivo y reanuda lo interrumpido |
| Excepción / falla (fault) | La instrucción actual | Sí | Fallo de página, división por cero, opcode inválido, violación de protección | Corrige y reintenta, o mata al proceso |
| Trap / interrupción de software | Instrucción explícita | Sí | syscall, int3 de un depurador | Ejecuta el servicio pedido y retorna a la instrucción siguiente |
La distinción entre falla y trap tiene una consecuencia práctica: tras una falla, la CPU reintenta la misma instrucción (por eso un fallo de página puede resolverse cargando la página y continuando como si nada); tras un trap, continúa en la instrucción siguiente.
La tabla de vectores
Cuando llega una interrupción, la CPU necesita saber a dónde saltar. Usa un número de vector como índice en una tabla que el kernel instaló durante el arranque. En x86 esa tabla es la IDT (Interrupt Descriptor Table), tiene 256 entradas, y su dirección vive en el registro idtr. Los vectores 0 a 31 están reservados por la arquitectura:
| Vector | Nombre | Causa típica |
|---|---|---|
| 0 | Divide Error | División entera por cero o desbordamiento del cociente |
| 3 | Breakpoint | Instrucción int3, la que usan los depuradores |
| 6 | Invalid Opcode | Instrucción inexistente o no permitida en ese modo |
| 8 | Double Fault | Ocurrió una excepción mientras se atendia otra |
| 13 | General Protection Fault | Acceso privilegiado desde ring 3, descriptor inválido |
| 14 | Page Fault | La dirección no esta mapeada, o los permisos no alcanzan |
Del 32 en adelante quedan disponibles para los dispositivos. En ARM64 el esquema es distinto en la forma pero idéntico en el fondo: un registro (vbar_el1) apunta a una tabla de vectores y el manejador consulta un registro de causa para saber qué pasó.
El flujo completo de una interrupción
sequenceDiagram
participant Disp as Dispositivo (NIC)
participant IC as Controlador de interrupciones (APIC)
participant CPU as CPU
participant K as Manejador del kernel
participant Sched as Planificador
Note over CPU: ejecutando un proceso de usuario en ring 3
Disp->>IC: señal / mensaje MSI: llegaron datos
IC->>IC: aplica mascara y prioridad, elige núcleo destino
IC->>CPU: solicitud de interrupción, vector N
CPU->>CPU: termina la instrucción en curso
CPU->>CPU: guarda rip, rflags y nivel de privilegio
CPU->>CPU: cambia a ring 0 y a la pila de interrupción
CPU->>K: salta a IDT[N]
K->>K: guarda el resto de los registros
K->>Disp: lee el registro de estado y reconoce la interrupción
K->>K: top half: trabajo mínimo, encolar el paquete
K->>IC: señal de fin de interrupción (EOI)
K->>Sched: marca pendiente el bottom half (softirq)
K->>K: restaura registros
K->>CPU: instrucción de retorno de interrupción (iretq)
CPU->>CPU: vuelve a ring 3 y al rip guardado
Note over Sched: el bottom half procesa el paquete después,<br/>con interrupciones habilitadas
Sched->>Sched: si toca, elige otro proceso al volver
Top half y bottom half
Un manejador de interrupción corre con prioridad máxima y, en general, con esa línea de interrupción enmascarada. Mientras esta activo, el sistema no puede atender otras interrupciones del mismo tipo, y en algunos diseños ninguna. Por eso el trabajo se parte en dos:
- Top half (contexto de interrupción): lo mínimo imprescindible. Reconocer la interrupción ante el dispositivo, copiar un descriptor a una cola, marcar trabajo pendiente. No puede dormir, no puede tomar mutex que duerman, no puede llamar a asignadores que bloqueen.
- Bottom half (contexto diferido): el procesamiento real. En Linux se implementa con softirqs, tasklets, workqueues o hilos de interrupción. Corre con interrupciones habilitadas, y las workqueues incluso pueden dormir porque corren en un hilo del kernel.
Un controlador de dispositivo Linux registra su manejador con request_irq() o, si quiere que el manejador corra en un hilo dedicado, con request_threaded_irq(). En esta última el top half puede devolver IRQ_WAKE_THREAD y el kernel despierta al hilo asociado.
El reloj: la interrupción que hace posible la multitarea
Entre todas las fuentes de interrupción hay una que define la naturaleza del sistema operativo: el temporizador. Es la que garantiza que el kernel recupere el control aunque el proceso en ejecución no coopere. Un proceso puede entrar en while (1) {} y aún así el sistema sigue respondiendo, porque el temporizador interrumpe la CPU y el planificador decide darle la CPU a otro.
Sin esa interrupción solo puede existir multitarea cooperativa, en la que cada tarea cede voluntariamente el control y un solo programa mal escrito congela la máquina entera. Este es el mecanismo que sostiene la expropiación (preemption), tema del capítulo de planificación.
Observa las interrupciones de tu sistema en vivo:
# Conteo acumulado por linea de interrupcion y por nucleo
cat /proc/interrupts
# Conteo de trabajo diferido (bottom halves)
cat /proc/softirqs
# Ver cómo crecen los contadores en tiempo real
watch -n1 'grep -E "LOC|TIMER|eth|nvme|xhci" /proc/interrupts'
En /proc/interrupts, la fila LOC (Local timer interrupts) es el temporizador por núcleo. Si la generas mientras el sistema esta inactivo verás que casi no crece en kernels con tickless idle: el kernel programa el temporizador para el próximo evento útil en vez de despertarse cientos de veces por segundo sin necesidad, con el objetivo de ahorrar energía.
DMA: acceso directo a memoria
Sin DMA: E/S programada
En el esquema más simple, la CPU mueve cada byte. Para leer 4 KiB de un dispositivo, ejecuta un bucle que lee un registro del dispositivo y escribe en memoria, miles de veces. Se le llama E/S programada o PIO.
El problema no es que sea lento en si mismo, sino que ocupa a la CPU en la tarea más trivial posible: mover bytes. Durante toda la transferencia, ese núcleo no puede ejecutar nada útil.
Con DMA
El DMA delega la transferencia a un componente que también puede ser maestro del bus de memoria. La CPU se limita a describir el trabajo (dirección física de origen o destino, cantidad de bytes, sentido) y a arrancar la operación. Después sigue ejecutando otras cosas. Cuando el motor DMA termina, dispara una interrupción.
sequenceDiagram
participant App as Proceso de usuario
participant K as Kernel (controlador de bloque)
participant CPU as CPU
participant DMA as Motor DMA del dispositivo
participant RAM as Memoria principal
participant SSD as SSD NVMe
App->>K: read(fd, buffer, 4096)
K->>K: reserva buffer apto para DMA y lo fija en memoria física
K->>K: traduce dirección virtual a dirección física (o IOVA con IOMMU)
K->>K: vacia o invalida las líneas de cache del buffer
K->>SSD: escribe el descriptor de comando en la cola de envio
K->>SSD: campanazo (doorbell): hay trabajo
K->>App: bloquea el proceso; el planificador elige otro
Note over CPU: la CPU ejecuta OTRO proceso durante toda la transferencia
SSD->>DMA: inicia transferencia
loop rafagas hasta completar 4096 bytes
DMA->>RAM: escribe directamente, sin pasar por la CPU
end
DMA->>SSD: transferencia completa
SSD->>CPU: interrupción de completado (MSI-X)
CPU->>K: manejador de interrupción
K->>K: procesa la entrada de la cola de completado
K->>K: sincroniza cache si corresponde y libera el fijado
K->>App: desbloquea el proceso; read() retorna 4096
Comparación directa
| Aspecto | E/S programada (PIO) | DMA |
|---|---|---|
| Quién mueve los bytes | La CPU, instrucción por instrucción | El motor DMA del dispositivo o del chipset |
| Uso de CPU durante la transferencia | Prácticamente 100% de un núcleo | Cercano a cero |
| Aviso de finalización | Implícito: terminó el bucle | Interrupción |
| Direcciones que maneja | Virtuales, traducidas por la MMU | Físicas, o IOVA si hay IOMMU |
| Complejidad del controlador | Baja | Alta: fijado de páginas, coherencia, mapeo |
| Conveniente para | Transferencias muy pequeñas, registros de control | Bloques grandes, red, disco, video, audio |
Los tres problemas que el DMA le crea al sistema operativo
El DMA es un ejemplo perfecto de como una optimización de hardware genera trabajo nuevo para el kernel.
1. El dispositivo no ve direcciones virtuales. Un proceso conoce direcciones virtuales; el motor DMA escribe en direcciones físicas (o en direcciones de E/S traducidas por la IOMMU). El kernel debe traducir y, además, fijar (pin) las páginas involucradas para que el gestor de memoria no las mueva ni las envie a swap mientras el dispositivo escribe en ellas. Escribir en una página que ya fue reasignada a otro proceso sería corrupción silenciosa.
2. La cache puede tener datos obsoletos o sin volcar. Si el dispositivo va a leer un buffer que la CPU acaba de escribir, y la cache es write-back, la versión buena esta en la cache y no en DRAM: el kernel debe forzar el volcado. Si el dispositivo va a escribir un buffer que la CPU tiene cacheado, la copia en cache queda obsoleta: el kernel debe invalidarla. En arquitecturas con DMA coherente el hardware lo resuelve por si mismo; en las que no, el controlador debe pedir la sincronización explícitamente. Linux expone esto en su API de DMA: existen buffers coherentes (asignados con funciones como dma_alloc_coherent) y buffers de flujo, que requieren llamadas explícitas de sincronización antes y después de cada transferencia.
3. Un dispositivo con DMA puede leer toda la memoria física. Si el hardware puede escribir en cualquier dirección física, un dispositivo malicioso o comprometido puede leer claves del kernel. La respuesta es la IOMMU (Intel VT-d, AMD-Vi, ARM SMMU): una MMU para dispositivos, que traduce las direcciones que estos emiten y solo permite las regiones autorizadas. Es lo mismo que la MMU hace por los procesos, aplicado a los periféricos, y es también lo que permite asignar un dispositivo físico completo a una máquina virtual de forma segura.
Verifica si tu máquina tiene la IOMMU activa:
# Grupos de aislamiento de la IOMMU; si el directorio esta vacio, no esta activa
ls /sys/kernel/iommu_groups/
# Mensajes del arranque relacionados
dmesg | grep -iE 'iommu|dmar|amd-vi'
Cómo se ve todo esto desde un lenguaje de alto nivel
Los mecanismos anteriores no desaparecen porque escribas en Elixir o en Ada; se vuelven invisibles pero siguen determinando el rendimiento. Este programa en Elixir muestra tres de ellos en una sola ejecución: la cantidad de núcleos que la máquina virtual detectó, el costo de una operación que cruza al kernel frente a una que no, y el efecto de la localidad de memoria.
# arquitectura.exs
# Ejecutar: elixir arquitectura.exs
defmodule Arquitectura do
@repeticiones 200_000
def hardware_detectado do
IO.puts("Schedulers en linea: #{System.schedulers_online()}")
IO.puts("Procesadores logicos: #{:erlang.system_info(:logical_processors)}")
IO.puts("Procesadores disponibles: #{:erlang.system_info(:logical_processors_available)}")
IO.puts("Tamano de palabra (bytes): #{:erlang.system_info(:wordsize)}")
end
# Lee el reloj monotono del sistema. En Linux esto normalmente NO cruza a
# ring 0: se resuelve en el vDSO, una region de codigo del kernel mapeada
# de solo lectura en el espacio del proceso.
def costo_reloj do
{microsegundos, _} =
:timer.tc(fn ->
Enum.each(1..@repeticiones, fn _ -> :erlang.monotonic_time() end)
end)
IO.puts("monotonic_time: #{Float.round(microsegundos * 1000 / @repeticiones, 1)} ns por llamada")
end
# Escribir a stdout SI cruza a ring 0: es una llamada al sistema write()
# por cada vaciado del buffer. Por eso se mide con muchas menos repeticiones.
def costo_escritura do
{:ok, dispositivo} = StringIO.open("")
{microsegundos, _} =
:timer.tc(fn ->
Enum.each(1..@repeticiones, fn _ -> IO.write(dispositivo, ".") end)
end)
StringIO.close(dispositivo)
IO.puts("IO.write a memoria: #{Float.round(microsegundos * 1000 / @repeticiones, 1)} ns por llamada")
end
# Un binario grande es un bloque contiguo: recorrerlo respeta la localidad
# espacial. Una lista del mismo tamano son celdas dispersas en el heap del
# proceso Erlang, con un puntero por elemento.
def localidad do
n = 2_000_000
binario = :binary.copy(<<1>>, n)
lista = :binary.bin_to_list(binario)
{t_bin, suma_bin} =
:timer.tc(fn ->
for <<byte <- binario>>, reduce: 0 do
acc -> acc + byte
end
end)
{t_lista, suma_lista} =
:timer.tc(fn -> Enum.reduce(lista, 0, &(&1 + &2)) end)
IO.puts("Binario contiguo (#{suma_bin}): #{t_bin} us")
IO.puts("Lista enlazada (#{suma_lista}): #{t_lista} us")
end
end
IO.puts("=== Hardware que detectó la máquina virtual de Erlang ===")
Arquitectura.hardware_detectado()
IO.puts("\n=== Costo de operaciones ===")
Arquitectura.costo_reloj()
Arquitectura.costo_escritura()
IO.puts("\n=== Localidad de memoria ===")
Arquitectura.localidad()
La máquina virtual de Erlang arranca por defecto un scheduler por procesador lógico detectado, y opcionalmente los fija a núcleos específicos. Esa decisión es directamente arquitectura de computadoras: fijar un scheduler a un núcleo mantiene sus datos calientes en la L1 y L2 de ese núcleo en vez de arrastrarlos de un lado a otro. Es el mismo razonamiento que aplica el planificador del kernel cuando prefiere volver a ejecutar un hilo en el núcleo donde ya estaba (afinidad de cache).
Errores comunes
| Error | Causa real | Cómo se corrige |
|---|---|---|
| Un bucle sobre una matriz tarda 8 veces más que su versión transpuesta | Se recorre variando el primer índice, saltando una fila entera por acceso; ninguna línea de cache se reutiliza | Ordenar los bucles para que el índice más rápido sea el último en arreglos por filas (C, Ada), o el primero en lenguajes por columnas (Fortran, Julia) |
| Un programa con 8 hilos rinde peor que con 1, sin usar mutex | Falso compartimiento: contadores por hilo caen en la misma línea de 64 bytes y se invalidan entre núcleos | Alinear y rellenar cada estructura por hilo a la línea de cache, o acumular en una variable local y volcar una sola vez al final |
| Escribir a un archivo byte por byte es miles de veces más lento que en bloques | Cada escritura es una llamada al sistema completa, con ida y vuelta entre ring 3 y ring 0 | Usar E/S con buffer, o acumular en memoria y escribir bloques grandes |
| El proceso muere con SIGSEGV al leer un puntero devuelto por una función | Se devolvió la dirección de una variable local; su marco de pila ya fue descartado y reutilizado por otra llamada | Reservar en el heap y transferir la propiedad, o recibir del llamante un buffer de salida |
| El proceso muere con SIGSEGV en una recursión profunda | Desbordamiento de pila: se superó el límite (por defecto 8 MiB en Linux) y se tocó la página de guarda | Convertir la recursión en iterativa, reducir el tamaño de los marcos, o subir el límite con ulimit -s cuando corresponda |
| Un controlador de dispositivo lee datos correctos a veces y basura otras | El buffer de DMA no se sincronizó con la cache: el dispositivo escribió en DRAM pero la CPU leyó una línea cacheada obsoleta | Usar buffers coherentes o llamar a las rutinas de sincronización de DMA antes y después de cada transferencia |
| El sistema se congela al conectar un dispositivo, o hay corrupción de memoria aleatoria | El buffer de DMA no estaba fijado y el gestor de memoria movió o intercambio la página durante la transferencia | Fijar las páginas mientras dure la operación y liberarlas solo después del completado |
| El sistema deja de responder a otras interrupciones bajo carga de red | Se hace demasiado trabajo en el top half, con la línea de interrupción enmascarada | Mover el procesamiento al bottom half (softirq, workqueue o hilo de interrupción) |
| Medir el tiempo de un bloque con relojes de baja resolución da siempre cero | La granularidad del reloj usado es mayor que lo medido | Usar relojes monótonos de alta resolución (clock_gettime(CLOCK_MONOTONIC), Ada.Real_Time.Clock, :erlang.monotonic_time) y repetir la operación muchas veces |
| El compilador elimina el código que se quería medir | El resultado no se usa, así que es código muerto y se descarta con -O2 | Acumular en una variable volatile, imprimir el resultado, o usar barreras del compilador |
| Un programa intenta acceder a un puerto de E/S y recibe una violación de protección | Es una instrucción privilegiada; en ring 3 la CPU genera una excepción de protección general | Pedir el servicio al kernel a través de la llamada al sistema o del controlador correspondiente |
Ejercicios propuestos
-
Mapa de tu máquina. Ejecuta
lscpuy el bucle sobre/sys/devices/system/cpu/cpu0/cache/. Anota tamaño, asociatividad y tamaño de línea de cada nivel. Calcula cuántas líneas de cache caben en tu L1 de datos y cuántos enteros de 32 bits entran en una línea. -
Punto de quiebre de la cache. Escribe un programa que recorra secuencialmente arreglos de tamaño creciente (4 KiB, 16 KiB, 64 KiB, 256 KiB, 1 MiB, 4 MiB, 16 MiB, 64 MiB) midiendo el tiempo por elemento. Grafica el resultado. Los escalones deben coincidir con los tamaños de L1, L2 y L3 que anotaste en el ejercicio 1.
-
Localidad en Ada. Ejecuta el programa
localidad.adbde este capítulo con N = 512, 1024, 2048 y 4096. Explica por qué la diferencia entre los dos recorridos crece con N en vez de mantenerse constante. -
Falso compartimiento. Ejecuta
falso_compartimiento.cen ambos modos y anota la razón entre los tiempos. Después modifica el relleno de la estructura para que ocupe 32 bytes en lugar de 64 y vuelve a medir. Explica el resultado usando el tamaño de línea que obtuviste congetconf LEVEL1_DCACHE_LINESIZE. -
Anatomía de una llamada al sistema. Ensambla
hola.asmy ejecútalo bajostrace. Después escribe el mismo programa en C usandoprintfy compara la salida destraceen ambos casos. Explica por qué la versión en C hace una cantidad distinta de llamadas al sistema y cuáles aparecen antes de tu mensaje. -
Costo del cruce de modo. Ejecuta
costo_syscall.cy calcula cuántas operaciones aritméticas en modo usuario “caben” en el tiempo de una sola llamada al sistema. Después buscavdsoen/proc/self/mapsy explica por qué obtener la hora del sistema puede ser mucho más barato quegetppid. -
Observar interrupciones. Toma una foto de
/proc/interrupts, genera tráfico de red sostenido (por ejemplo con una descarga grande), toma otra foto y calcula la diferencia por línea. Identifica qué línea corresponde a tu interfaz de red y cuántas interrupciones por segundo generó. Investiga qué es la mitigación de interrupciones (interrupt coalescing) y por qué una tarjeta moderna no interrumpe una vez por paquete. -
Traza de DMA. Elige un dispositivo de bloque de tu sistema y busca en
dmesglos mensajes de su controlador durante el arranque. Identifica si menciona la IOMMU, colas de comandos o vectores MSI-X. Describe con tus palabras qué camino sigue un bloque de datos desde el medio físico hasta el buffer de tu proceso. -
Excepciones en la práctica. Escribe tres programas en C que provoquen, respectivamente: una división entera por cero, un acceso a la dirección
NULLy una escritura en una cadena literal. Ejecuta cada uno y anota la señal que recibe. Relaciona cada señal con el número de vector de la tabla de excepciones que aparece en este capítulo. -
Elixir y el hardware. Ejecuta
arquitectura.exsen tu máquina. ComparaSystem.schedulers_online()con la salida delscpu. Después ejecuta conelixir --erl "+S 1" arquitectura.exsy compara los tiempos de las tres mediciones. Explica cuál cambia y cuál no. -
De alto nivel a instrucciones. Escribe una función en C que sume los elementos de un arreglo de 8 enteros y compílala con
-O0y con-O3 -march=native. Compara ambos ensambladores. Identifica en la versión optimizada si el compilador usó instrucciones vectoriales y explica qué relación tiene eso con la línea de cache. -
Diseño. Un servicio recibe 500.000 mensajes por segundo de red, cada uno de 200 bytes, y debe escribirlos a disco. Enumera, usando los conceptos de este capítulo, cuatro decisiones de diseño que reducirian el consumo de CPU: una sobre interrupciones, una sobre llamadas al sistema, una sobre cache y una sobre DMA.
Lo que viene
Con este capítulo ya tienes el vocabulario del hardware: sabes que la CPU repite un ciclo de búsqueda, decodificación y ejecución; que sus registros son el estado que define a un hilo de ejecución; que la memoria es una pirámide donde cada nivel cuesta un orden de magnitud más que el anterior; que la cache trabaja en líneas de 64 bytes y premia la localidad; que la pila y el montículo tienen reglas de vida distintas; que las interrupciones son el mecanismo por el que el kernel recupera el control; que el DMA libera a la CPU de mover bytes a cambio de trabajo de coordinación; y que la frontera entre ring 3 y ring 0 es la única puerta por la que un programa puede pedir algo al sistema.
En el capítulo 5 cruzamos esa puerta. Vamos a ver qué hay del otro lado: como esta organizado un kernel, qué estructuras mantiene para cada proceso, en qué se diferencian los diseños monolíticos de los micronúcleos, como se implementa un cambio de contexto usando exactamente los registros que revisamos aquí, y por qué casi todo lo que hace el kernel puede describirse como la respuesta a una de las tres cosas que estudiamos: una llamada al sistema, una excepción o una interrupción.
Si quieres revisar el recorrido completo del curso, el temario esta en el índice.