Arquitectura de computadoras para entender el sistema operativo

Por: Artiko
sistemas-operativosadaelixirconcurrenciaarquitectura-de-computadorascpujerarquia-de-memoriacacheinterrupcionesdma

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:

  1. 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.
  2. 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:

ProcesadorAño aproximadoFrecuencia típicaDuración de un ciclo
MOS 650219751 MHz1000 ns
Intel 80386198516 MHz62,5 ns
Pentium III1999600 MHz1,67 ns
CPU de escritorio actual2020 en adelante3 a 6 GHz0,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:

BanderaNombre habitualSignificado
ZZeroEl resultado fue exactamente cero
N o SNegative / SignEl bit más significativo del resultado es 1
CCarryHubo acarreo o prestamo fuera del ancho del operando
V u OOverflowEl 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 ARM64Función
rippcContador de programa: dirección de la siguiente instrucción
rspspPuntero al tope de la pila
rbpx29 (por convención)Puntero al marco de pila actual
rflagspstate / nzcvBanderas de estado y bits de control
cr3ttbr0_el1 / ttbr1_el1Dirección física de la tabla de páginas del proceso actual
cr0, cr4sctlr_el1Bits de control: paginación activa, protección de escritura, extensiones
gdtr, idtrvbar_el1Dirección de las tablas de descriptores y de vectores de interrupción
msr varioselr_el1, spsr_el1Estado 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 -O2 la función entera colapsa a lea eax, [rdi+rsi] seguido de ret: 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.

NivelLatencia típicaEn ciclos a 3 GHzEscala humana (si 1 ciclo = 1 segundo)
Registro0,3 ns~11 segundo
Cache L11,3 ns~44 segundos
Cache L24 ns~1414 segundos
Cache L313 ns~4040 segundos
DRAM80 ns~2404 minutos
SSD NVMe (lectura 4 KiB)25 us~75.00021 horas
Disco mecánico (búsqueda)8 ms~24.000.0009 meses
Ida y vuelta en LAN0,5 ms~1.500.00017 días
Ida y vuelta transatlántica130 ms~390.000.00012 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íticaQué hace al escribirVentajaCosto
Write-throughEscribe en cache y en el nivel inferior a la vezCoherencia simple, sin líneas suciasMucho tráfico hacia memoria
Write-backEscribe solo en cache y marca la línea suciaAbsorbe escrituras repetidas al mismo lugarRequiere vaciado explícito antes de un DMA
Write-allocateAnte un fallo de escritura, trae la línea a cacheAprovecha escrituras posteriores cercanasTrae datos que quizás se sobrescriban enteros
No-write-allocateAnte un fallo de escritura, escribe directo al nivel inferiorEvita traer líneas inútilesPierde 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:

EstadoSignificadoOtros núcleos pueden tener copiaDifiere de memoria
ModifiedEste núcleo la modificó y es el único dueñoNo
ExclusiveEste núcleo la tiene y nadie más, sin modificarNoNo
SharedVarios núcleos la tienen para lecturaNo
InvalidLa línea no es utilizableIrrelevanteIrrelevante

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 en SIGSEGV.
  • 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:

  1. 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. Un malloc de 100 bytes normalmente no habla con el kernel en absoluto.
  2. El kernel solo entra cuando el asignador necesita más territorio, y entonces usa brk/sbrk para mover el borde del heap o mmap para 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 x86Nivel ARM64Nivel RISC-VQuién ejecuta ahíPuede
Ring 3EL0U-modeAplicacionesSolo instrucciones no privilegiadas, solo su memoria mapeada
Ring 2 / 1Prácticamente sin uso en sistemas modernos
Ring 0EL1S-modeEl kernelTodo el conjunto de instrucciones, toda la memoria física, E/S
VMX rootEL2HS-modeHipervisorControlar y virtualizar a los kernels invitados
SMMEL3M-modeFirmware / modo seguroEstado 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 LSTAR en 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 buf verifica 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:

TipoOrigenSíncrona con la instrucciónEjemplosQué hace el kernel
Interrupción de hardware (IRQ)Externo a la CPUNo: llega en cualquier momentoReloj, teclado, red, discoAtiende al dispositivo y reanuda lo interrumpido
Excepción / falla (fault)La instrucción actualFallo de página, división por cero, opcode inválido, violación de protecciónCorrige y reintenta, o mata al proceso
Trap / interrupción de softwareInstrucción explícitasyscall, int3 de un depuradorEjecuta 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:

VectorNombreCausa típica
0Divide ErrorDivisión entera por cero o desbordamiento del cociente
3BreakpointInstrucción int3, la que usan los depuradores
6Invalid OpcodeInstrucción inexistente o no permitida en ese modo
8Double FaultOcurrió una excepción mientras se atendia otra
13General Protection FaultAcceso privilegiado desde ring 3, descriptor inválido
14Page FaultLa 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

AspectoE/S programada (PIO)DMA
Quién mueve los bytesLa CPU, instrucción por instrucciónEl motor DMA del dispositivo o del chipset
Uso de CPU durante la transferenciaPrácticamente 100% de un núcleoCercano a cero
Aviso de finalizaciónImplícito: terminó el bucleInterrupción
Direcciones que manejaVirtuales, traducidas por la MMUFísicas, o IOVA si hay IOMMU
Complejidad del controladorBajaAlta: fijado de páginas, coherencia, mapeo
Conveniente paraTransferencias muy pequeñas, registros de controlBloques 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

ErrorCausa realCómo se corrige
Un bucle sobre una matriz tarda 8 veces más que su versión transpuestaSe recorre variando el primer índice, saltando una fila entera por acceso; ninguna línea de cache se reutilizaOrdenar 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 mutexFalso compartimiento: contadores por hilo caen en la misma línea de 64 bytes y se invalidan entre núcleosAlinear 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 bloquesCada escritura es una llamada al sistema completa, con ida y vuelta entre ring 3 y ring 0Usar 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ónSe devolvió la dirección de una variable local; su marco de pila ya fue descartado y reutilizado por otra llamadaReservar en el heap y transferir la propiedad, o recibir del llamante un buffer de salida
El proceso muere con SIGSEGV en una recursión profundaDesbordamiento de pila: se superó el límite (por defecto 8 MiB en Linux) y se tocó la página de guardaConvertir 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 otrasEl buffer de DMA no se sincronizó con la cache: el dispositivo escribió en DRAM pero la CPU leyó una línea cacheada obsoletaUsar 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 aleatoriaEl buffer de DMA no estaba fijado y el gestor de memoria movió o intercambio la página durante la transferenciaFijar 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 redSe hace demasiado trabajo en el top half, con la línea de interrupción enmascaradaMover 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 ceroLa granularidad del reloj usado es mayor que lo medidoUsar 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 medirEl resultado no se usa, así que es código muerto y se descarta con -O2Acumular 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ónEs una instrucción privilegiada; en ring 3 la CPU genera una excepción de protección generalPedir el servicio al kernel a través de la llamada al sistema o del controlador correspondiente

Ejercicios propuestos

  1. Mapa de tu máquina. Ejecuta lscpu y 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.

  2. 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.

  3. Localidad en Ada. Ejecuta el programa localidad.adb de 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.

  4. Falso compartimiento. Ejecuta falso_compartimiento.c en 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 con getconf LEVEL1_DCACHE_LINESIZE.

  5. Anatomía de una llamada al sistema. Ensambla hola.asm y ejecútalo bajo strace. Después escribe el mismo programa en C usando printf y compara la salida de strace en 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.

  6. Costo del cruce de modo. Ejecuta costo_syscall.c y calcula cuántas operaciones aritméticas en modo usuario “caben” en el tiempo de una sola llamada al sistema. Después busca vdso en /proc/self/maps y explica por qué obtener la hora del sistema puede ser mucho más barato que getppid.

  7. 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.

  8. Traza de DMA. Elige un dispositivo de bloque de tu sistema y busca en dmesg los 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.

  9. 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 NULL y 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.

  10. Elixir y el hardware. Ejecuta arquitectura.exs en tu máquina. Compara System.schedulers_online() con la salida de lscpu. Después ejecuta con elixir --erl "+S 1" arquitectura.exs y compara los tiempos de las tres mediciones. Explica cuál cambia y cuál no.

  11. 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 -O0 y 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.

  12. 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.