Hem parlat a les cinc lliçons anteriors d'una frontera invisible: la que separa el codi de les teves aplicacions del codi privilegiat del sistema. Ha aparegut en parlar de què és el nucli, en classificar arquitectures i en seguir una petició HTTP. Ha arribat el moment de mirar-la de prop. En aquesta lliçó entendràs com el maquinari fa possible que el sistema operatiu es defensi dels programes que executa, què passa exactament —instrucció a instrucció— quan ingestor desa una lectura al disc, i per què una operació aparentment trivial com write() costa centenars de vegades més que una crida a funció normal. Quan acabis sabràs llegir la sortida de strace, que és probablement l'eina de diagnòstic més reveladora que hi ha a Linux, i entendràs per què el buffering no és un detall d'implementació sinó una decisió de rendiment de primer ordre.

Contingut

  1. Per què un programa no pot tocar el maquinari
  2. El mode dual: bit de mode i instruccions privilegiades
  3. Anells de privilegi
  4. Què és una crida al sistema
  5. El recorregut pas a pas, d'usuari a nucli i tornada
  6. API, biblioteca C i crida al sistema: tres coses diferents
  7. Categories de crides al sistema
  8. Exemple en C: write() davant de fprintf()
  9. Observació real amb strace
  10. El cost d'una crida al sistema i per què convé minimitzar-les

Per què un programa no pot tocar el maquinari

Comencem pel problema. Suposa que meteo-api, un programa ordinari, pogués executar qualsevol instrucció de la CPU i accedir a qualsevol adreça de memòria. N'hi hauria prou amb una fallada, o amb un atacant que hagués compromès el procés, perquè:

  • Llegís la memòria d'ingestor i obtingués dades d'altres clients.
  • Escrivís directament al disc, saltant-se el sistema de fitxers i els permisos, i modifiqués /etc/shadow.
  • Deshabilités les interrupcions i monopolitzés la CPU per sempre: ni tan sols el temporitzador la podria recuperar.
  • Reprogramés la taula de pàgines per accedir a tota la RAM física.

Fixa't en un matís decisiu: no n'hi ha prou que el sistema operatiu «no el deixi». Si el programa pot executar la instrucció que reprograma la MMU, l'executarà i el sistema no tindrà manera d'impedir-ho, perquè per aleshores ja s'ha executat. L'única solució possible és que el maquinari s'hi negui.

I aquí hi ha la idea central de tota la protecció en informàtica: la seguretat d'un sistema operatiu no descansa en el seu codi, sinó en un mecanisme del processador que el programari no pot eludir.

El mode dual: bit de mode i instruccions privilegiades

La CPU té un bit de mode al seu registre d'estat que indica en quin mode s'està executant:

Mode usuari Mode nucli (supervisor)
Qui s'hi executa Aplicacions, biblioteques, shell El nucli i els seus mòduls
Instruccions permeses Només les no privilegiades Totes
Memòria accessible Només el seu propi espai d'adreces Tota la memòria física
Accés a dispositius Cap de directe Total
Efecte d'una fallada Mor el procés Kernel panic o corrupció

Certes instruccions són privilegiades: si s'intenten executar en mode usuari, la CPU no les executa i genera una excepció. En x86-64, alguns exemples:

Instrucció Què fa Per què ha de ser privilegiada
hlt Atura la CPU fins a la interrupció següent Un programa podria aturar la màquina
cli / sti Deshabilita / habilita interrupcions Sense interrupcions, ningú no li pot treure la CPU
mov a cr3 Canvia la taula de pàgines activa Donaria accés a tota la memòria física
in / out Llegeix i escriu ports d'E/S Accés directe als dispositius
lgdt / lidt Carrega les taules de descriptors i d'interrupcions Permetria redefinir el mecanisme de protecció mateix
wrmsr Escriu registres específics del model Inclou el que defineix on salta una crida al sistema

Pots comprovar a la pràctica que la protecció funciona:

/* prova_privilegi.c  — compilar: gcc -o prova prova_privilegi.c */
#include <stdio.h>

int main(void) {
    printf("Abans de la instruccio privilegiada\n");
    __asm__ volatile ("cli");          /* deshabilitar interrupcions */
    printf("Aixo no s'imprimira mai\n");
    return 0;
}
$ ./prova
Abans de la instruccio privilegiada
Violació de segment («core» generat)

Què ha passat, línia a línia:

  • __asm__ volatile ("cli") insereix directament la instrucció màquina cli al programa. volatile impedeix que el compilador la reordeni o l'elimini per considerar-la inútil.
  • La CPU, en trobar-se cli amb el bit de mode a «usuari», no l'executa. Genera una excepció de protecció general.
  • El nucli atén aquesta excepció, comprova de quin procés ve i li envia el senyal SIGSEGV, que per defecte acaba el procés.
  • El segon printf no s'executa mai.

Això és exactament el que volíem demostrar: la negativa no ve del sistema operatiu, ve del silici. El sistema operatiu només decideix què fer després.

I com es posa el bit de mode?

Aquí hi ha la part elegant del disseny. Si un programa pogués posar el bit de mode a «nucli», tota la protecció seria inútil. Per això el bit no es pot modificar directament. Només canvia a mode nucli en tres circumstàncies, i en totes tres el control salta simultàniament a una adreça que el nucli va fixar per endavant:

  1. Una interrupció de maquinari (el disc ha acabat, ha arribat un paquet de xarxa). És asíncrona, no la provoca el programa.
  2. Una excepció (divisió per zero, fallada de pàgina, instrucció privilegiada). És síncrona però involuntària.
  3. Una crida al sistema, és a dir, una petició voluntària del programa.

En els tres casos, el canvi de mode i el salt són una sola operació atòmica del maquinari. No hi ha cap instant en què s'estigui en mode nucli executant codi triat pel programa. Aquesta atomicitat és el que fa que el sistema sigui segur.

Anells de privilegi

L'arquitectura x86 generalitza el mode dual a quatre nivells o anells, numerats del 0 (privilegi màxim) al 3 (mínim). La idea, heretada de Multics, era permetre nivells intermedis: per exemple, controladors a l'anell 1, amb menys privilegis que el nucli però més que les aplicacions.

Anell Ús previst originalment Ús real
0 Nucli Nucli (Linux, Windows)
1 Controladors de dispositiu Pràcticament cap
2 Serveis del sistema Pràcticament cap
3 Aplicacions Aplicacions

A la pràctica, gairebé tots els sistemes fan servir només el 0 i el 3, per dues raons: perquè altres arquitectures (ARM, RISC-V, MIPS) només ofereixen dos nivells i fer-ne servir quatre trencaria la portabilitat, i perquè els nivells intermedis compliquen molt el disseny per a un benefici discutible. Una dada curiosa: els anells 1 i 2 sí que es van arribar a fer servir en algunes tècniques de virtualització abans que existís el suport de maquinari.

La virtualització moderna sí que va afegir un nivell: l'anomenat anell -1 o mode arrel VMX, on s'executa l'hipervisor, per sota de l'anell 0 dels sistemes convidats. Ho veuràs a Virtualització: Hipervisors i Màquines Virtuals.

Què és una crida al sistema

Una crida al sistema (system call o syscall) és l'única porta legítima perquè un programa en mode usuari demani alguna cosa al nucli. És una petició voluntària i controlada de travessar la frontera.

Tres característiques la defineixen:

  • El punt d'entrada el tria el nucli, no el programa. El programa no pot saltar a una adreça arbitrària del nucli. Només pot dir «vull la crida número 1» i el nucli decideix quin codi executa.
  • Els arguments es validen. El nucli comprova que els punters que li passes apunten a memòria que realment és teva, que el descriptor de fitxer existeix, que la mida és raonable. Cap comprovació no és opcional: cadascuna tapa un forat de seguretat.
  • El retorn torna al mode usuari. Quan la crida acaba, el bit de mode torna a «usuari» i l'execució continua just després de la instrucció que va provocar l'entrada.

Linux té unes 350 crides al sistema en x86-64. En pots veure la llista completa:

ausyscall --dump | head -8
0	read
1	write
2	open
3	close
4	stat
5	fstat
6	lseek
7	mmap

Aquell número de l'esquerra és el número de crida al sistema, i és el que el programa posa en un registre per indicar què vol. Els números són estables per sempre dins d'una arquitectura: si canviessin, tots els binaris existents deixarien de funcionar. Per això read és el 0 i write l'1 des de fa dècades.

El recorregut pas a pas, d'usuari a nucli i tornada

Seguim el que passa quan ingestor executa write(fd, &lectura, 24).

sequenceDiagram
    participant U as ingestor (anell 3)
    participant L as glibc (anell 3)
    participant H as CPU
    participant K as Nucli (anell 0)

    U->>L: write(fd, &lectura, 24)
    Note over L: Col·loca arguments:<br/>rax=1 (núm. de syscall)<br/>rdi=fd, rsi=&lectura, rdx=24
    L->>H: instrucció SYSCALL
    Note over H: 1. Desa RIP a RCX i RFLAGS a R11<br/>2. Carrega RIP des del MSR LSTAR<br/>3. Canvia el bit de mode a nucli
    H->>K: entry_SYSCALL_64
    Note over K: Canvia a la pila del nucli<br/>Desa la resta de registres
    Note over K: Valida: existeix rax=1?<br/>fd és vàlid?<br/>&lectura apunta a memòria del procés?
    Note over K: Executa sys_write:<br/>copia els 24 bytes a la memòria cau de pàgines
    Note over K: Restaura registres<br/>Posa el resultat (24) a RAX
    K->>H: instrucció SYSRET
    Note over H: Restaura RIP des de RCX i RFLAGS des de R11<br/>Canvia el bit de mode a usuari
    H->>L: retorn amb RAX = 24
    Note over L: Si RAX és negatiu: errno = -RAX, retornar -1<br/>Si no: retornar RAX
    L->>U: 24 bytes escrits

Detallem les fases:

1. Preparació dels arguments (mode usuari). La convenció de crida de Linux en x86-64 fixa quin registre porta cada cosa:

Registre Contingut
rax Número de la crida al sistema (1 = write)
rdi Primer argument (el descriptor fd)
rsi Segon argument (el punter a lectura)
rdx Tercer argument (la mida, 24)
r10, r8, r9 Quart, cinquè i sisè argument

Observa que es fa servir r10 i no rcx per al quart argument, a diferència de les crides a funció normals. La raó és purament mecànica: la instrucció syscall destrueix rcx en desar-hi l'adreça de retorn.

2. La instrucció syscall. Una única instrucció màquina que, atòmicament:

  • Desa el comptador de programa (rip) a rcx i els flags (rflags) a r11.
  • Carrega a rip l'adreça que el nucli va escriure en arrencar al registre especial MSR_LSTAR.
  • Canvia el bit de mode a nucli.

Res d'això no ho controla el programa: l'adreça de destinació la va fixar el nucli molt abans.

3. Canvi de pila. El nucli no pot fer servir la pila del procés d'usuari, perquè el seu contingut podria estar manipulat o apuntar a memòria no vàlida. Cada procés té una pila de nucli separada (16 KB a Linux x86-64) i el nucli hi canvia immediatament. Aquest és un dels detalls que més es passen per alt i és essencial per a la seguretat.

4. Despatx i validació. El nucli comprova que rax sigui dins del rang de crides vàlides i consulta la taula de crides al sistema per saltar a la funció corresponent (sys_write). Després valida cada argument. Per exemple, per al punter fa servir copy_from_user() en comptes de llegir directament: aquesta funció comprova que l'adreça pertany a l'espai del procés. Un nucli que desreferenciés directament un punter d'usuari tindria una vulnerabilitat crítica.

5. Execució de la feina real. sys_write localitza el fitxer a partir del descriptor, copia els 24 bytes a la memòria cau de pàgines i actualitza la mida del fitxer.

6. Retorn. El resultat es col·loca a rax, es restauren els registres i la instrucció sysret retorna el rip i els flags desats, i posa el bit de mode a «usuari».

7. Traducció de l'error. Aquí passa una cosa que confon molta gent. El nucli no fa servir errno: retorna l'error com un número negatiu petit a rax (per exemple, -13 per a «permís denegat»). És la biblioteca C la que, en veure un valor entre −1 i −4095, fa:

if (resultat < 0 && resultat > -4096) {
    errno = -resultat;      /* errno = 13 (EACCES) */
    return -1;
}
return resultat;

Per això errno és una variable de la biblioteca C, no del nucli, i per això s'ha de consultar immediatament després de la crida fallida: qualsevol altra funció de biblioteca la pot sobreescriure.

API, biblioteca C i crida al sistema: tres coses diferents

Aquesta distinció es confon constantment i aclarir-la estalvia molta confusió posterior.

Què és Exemple On s'executa
API Un contracte, una especificació de funcions POSIX, Win32 Enlloc: és un document
Biblioteca C Codi real que implementa part d'aquesta API glibc, musl Mode usuari, dins del teu procés
Crida al sistema La petició concreta al nucli write (núm. 1) Travessa a mode nucli

Les relacions importants:

  • Una funció de biblioteca pot no fer cap crida al sistema. strlen(), malloc() quan hi ha memòria a la seva reserva interna, o printf() quan només omple la seva memòria intermèdia.
  • Una funció de biblioteca en pot fer diverses. fopen() fa open() i sovint fstat(). printf() amb la memòria intermèdia plena fa un write().
  • Una crida al sistema pot tenir diversos embolcalls diferents. open(), open64(), creat() i fopen() acaben totes a la mateixa família de crides.
  • Pots invocar una crida al sistema sense biblioteca, amb syscall(1, fd, buf, 24) o directament en assemblador. Gairebé mai no val la pena.

Un cas especialment interessant en el sentit contrari: algunes crides al sistema no travessen al nucli gràcies al vDSO (virtual Dynamic Shared Object), una petita biblioteca que el nucli mapa a l'espai de cada procés. gettimeofday() i clock_gettime() llegeixen l'hora d'una pàgina compartida de només lectura sense canviar de mode. La raó és pur rendiment: són crides tan freqüents que se'ls va fer una drecera especial.

Categories de crides al sistema

Categoria Què fan Exemples a Linux Ús a Meteora
Control de processos Crear, acabar, esperar i substituir processos fork, clone, execve, exit, wait4, kill systemd llança ingestor en arrencar
Gestió de fitxers Obrir, llegir, escriure, tancar, moure, esborrar open, read, write, close, lseek, unlink, rename ingestor escriu a 2026-08-31.dat
Gestió de dispositius Sol·licitar, alliberar i controlar dispositius ioctl, mmap, read/write sobre /dev/* Ajustar paràmetres de la targeta de xarxa
Informació del sistema Consultar i fixar hora, identitat, límits time, clock_gettime, getpid, uname, getrlimit Segellar la marca de temps de cada Lectura
Comunicació Sòcols, canonades, memòria compartida, senyals socket, bind, listen, accept, pipe, shmget meteo-api accepta connexions al port 8080
Protecció Permisos, identitat, capacitats chmod, chown, setuid, umask, capset Canviar de root a meteora després d'arrencar

Un patró que es repeteix a totes: les crides al sistema són deliberadament poques i de baix nivell. No hi ha cap crida «llegir un fitxer de configuració» ni «fer una petició HTTP». El nucli ofereix primitives mínimes i les biblioteques construeixen a sobre. Cada crida al sistema és una superfície d'atac i una promesa de compatibilitat per sempre, així que afegir-ne una de nova és una decisió que es pren amb molta cura.

Exemple en C: write() davant de fprintf()

Escriurem la mateixa Lectura de dues maneres i entendrem per què no són equivalents.

/* desar_lectura.c — compilar: gcc -O2 -o desar desar_lectura.c */
#include <stdio.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

struct Lectura {
    uint32_t estacio_id;
    int64_t  timestamp;
    float    temperatura;
    float    humitat;
    float    pressio;
};

/* Versió A: crida al sistema directa, format binari */
int desar_binari(const char *ruta, const struct Lectura *l) {
    int fd = open(ruta, O_WRONLY | O_CREAT | O_APPEND, 0640);
    if (fd == -1) {
        fprintf(stderr, "open ha fallat: %s\n", strerror(errno));
        return -1;
    }
    ssize_t escrits = write(fd, l, sizeof(*l));
    if (escrits != (ssize_t)sizeof(*l)) {
        fprintf(stderr, "write incomplet (%zd de %zu bytes)\n",
                escrits, sizeof(*l));
        close(fd);
        return -1;
    }
    close(fd);
    return 0;
}

/* Versió B: biblioteca estàndard, format de text */
int desar_text(const char *ruta, const struct Lectura *l) {
    FILE *f = fopen(ruta, "a");
    if (f == NULL) {
        fprintf(stderr, "fopen ha fallat: %s\n", strerror(errno));
        return -1;
    }
    fprintf(f, "%u;%ld;%.1f;%.1f;%.1f\n",
            l->estacio_id, l->timestamp,
            l->temperatura, l->humitat, l->pressio);
    fclose(f);          /* fclose buida la memòria intermèdia abans de tancar */
    return 0;
}

int main(void) {
    struct Lectura l = { .estacio_id = 118, .timestamp = 1756636800,
                         .temperatura = 27.4f, .humitat = 61.0f,
                         .pressio = 1013.2f };
    desar_binari("/var/lib/meteora/lectures/2026-08-31.dat", &l);
    desar_text("/var/lib/meteora/lectures/2026-08-31.csv", &l);
    return 0;
}

Anàlisi de la versió A (write):

  • open(...) és una crida al sistema directa. Retorna un int, el descriptor, o -1 en cas d'error.
  • La comprovació if (fd == -1) no és opcional: gairebé totes les fallades reals d'un servei en producció vénen d'errors no comprovats. strerror(errno) converteix el codi numèric en un missatge llegible.
  • write(fd, l, sizeof(*l)) lliura els 24 bytes al nucli immediatament: una crida al sistema per cada lectura.
  • La comprovació escrits != sizeof(*l) cobreix un cas que sorprèn molta gent: write pot escriure menys bytes dels demanats i retornar un número menor sense que sigui un error. Passa sobretot amb sòcols i canonades. Ignorar-ho produeix truncaments silenciosos.
  • El resultat al disc són 24 bytes binaris illegibles amb cat, però compactes i de mida exacta.

Anàlisi de la versió B (fprintf):

  • fopen(...) és una funció de biblioteca que embolcalla open() i a més reserva una memòria intermèdia (normalment 4096 bytes) i retorna un FILE *, una estructura de la biblioteca C, no del nucli.
  • fprintf(...) formata les dades com a text i les copia a la memòria intermèdia en memòria d'usuari. En aquest cas concret, no fa cap crida al sistema: la línia generada ocupa uns 35 bytes i hi cap de sobres.
  • fclose(f) buida la memòria intermèdia, cosa que sí que provoca un write(), i després tanca el descriptor.
  • El resultat al disc és 118;1756636800;27.4;61.0;1013.2, llegible però de mida variable i més gran (35 bytes davant de 24).

La taula que resumeix la diferència:

write() fprintf()
Nivell Crida al sistema Biblioteca C
Memòria intermèdia Cap en usuari Sí, típicament 4 KB
Crides al sistema per lectura 1 sempre 1 cada ~117 lectures
Format Binari, 24 bytes fixos Text, ~35 bytes variables
Llegible amb cat No
Portabilitat de les dades Depèn de l'arquitectura (ordre de bytes, farciment) Total
Dades en risc si el procés mor Les de la memòria intermèdia del nucli Les de la memòria intermèdia d'usuari i les del nucli
Rendiment amb moltes escriptures Pitjor Molt millor

L'última fila és la clau, i la quantifiquem tot seguit. La penúltima explica un fenomen freqüentíssim: un programa que mor de manera abrupta perd allò que tenia a la memòria intermèdia de la biblioteca C, i per això els registres es tallen just abans de l'error que busques. Per a això existeix fflush(), i per això stderr no té memòria intermèdia per defecte.

Observació real amb strace

strace intercepta i mostra totes les crides al sistema que fa un procés. És l'eina que converteix tot l'anterior en una cosa que pots veure.

sudo strace -f -T -p 1099 2>&1 | head -20
strace: Process 1099 attached
recvfrom(7, "\x76\x00\x01\x18\x00\x00...", 512, 0, NULL, NULL) = 48 <0.000009>
clock_gettime(CLOCK_REALTIME, {tv_sec=1756636800, tv_nsec=142883917}) = 0 <0.000001>
write(9, "\x76\x00\x00\x00\x00\x8e\xc1\x68...", 24) = 24 <0.000021>
recvfrom(7, 0x7ffd4a2b1c40, 512, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable) <0.000005>
epoll_wait(5, [{EPOLLIN, {u32=7}}], 64, 1000) = 1 <0.031472>
recvfrom(7, "\x77\x00\x01\x18\x00\x00...", 512, 0, NULL, NULL) = 48 <0.000008>
clock_gettime(CLOCK_REALTIME, {tv_sec=1756636800, tv_nsec=174301522}) = 0 <0.000001>
write(9, "\x77\x00\x00\x00\x00\x8e\xc1\x68...", 24) = 24 <0.000019>

Comencem pels arguments de la comanda:

  • -f segueix també els fils i els fills del procés. Sense això, en un programa amb diversos fils només en veuries una part.
  • -T mostra entre <> el temps que va trigar cada crida. És l'opció més útil per diagnosticar.
  • -p 1099 s'enganxa a un procés ja en execució, en aquest cas ingestor. Alternativament, strace ./programa el llança des del principi.
  • 2>&1 redirigeix la sortida d'error (on strace escriu) a l'estàndard, per poder passar-la per head.

Ara la interpretació del cicle, que és el que és interessant:

  1. recvfrom(7, ...) = 48 — llegeix 48 bytes del sòcol amb descriptor 7: el paquet d'una estació. El = 48 és el valor de retorn. Va trigar 9 microsegons.
  2. clock_gettime(CLOCK_REALTIME, ...) = 0 — obté l'hora per segellar la lectura. Va trigar 1 microsegon: sospitosament poc per a una crida al sistema, i la raó és que es resol pel vDSO sense travessar al nucli.
  3. write(9, ..., 24) = 24 — escriu els 24 bytes de la Lectura al descriptor 9, el fitxer del dia. Va trigar 21 µs.
  4. recvfrom(...) = -1 EAGAIN — torna a intentar llegir del sòcol i no hi ha res. EAGAIN en un sòcol no bloquejant no és un error real: significa «ara mateix no hi ha dades, torna més tard».
  5. epoll_wait(5, ..., 1000) = 1 — el procés es bloqueja esperant activitat, amb un temps màxim de 1000 ms. Va trigar 31.472 µs, és a dir, 31 ms. Durant aquest temps ingestor és en estat S i no consumeix gens de CPU: és el comportament correcte d'un servei que espera.

Per veure el resum agregat, que sol ser més útil que el detall:

sudo strace -c -f -p 1099
# ... esperar uns 30 segons i prémer Ctrl+C
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 71.42    0.982441        1966       500           epoll_wait
 15.31    0.210623           4     52000           write
  8.02    0.110336           4     26000           recvfrom
  3.15    0.043341           1     26000           clock_gettime
  2.10    0.028902           1     26000     26000 recvfrom
------ ----------- ----------- --------- --------- ----------------
100.00    1.375643                 130500     26000 total

Com es llegeix això, que és on hi ha el valor real de l'eina:

  • epoll_wait s'emporta el 71 % del temps, però això és bo: és temps bloquejat esperant feina, no CPU consumida. Un servei sa passa la major part del temps aquí.
  • La columna errors mostra 26.000 errors a recvfrom. Sembla alarmant, però són els EAGAIN esperats del sondeig no bloquejant. Molts errors a strace -c no signifiquen cap problema; cal mirar quins són.
  • I aquí hi ha la troballa important: 52.000 crides a write per a 26.000 lectures rebudes. Són exactament dues escriptures per lectura. Investigant el codi descobriríem que ingestor escriu la lectura al fitxer de dades i a més una línia al registre, sense cap memòria intermèdia. Aquest és l'objectiu d'optimització, i ens porta directament a l'últim apartat.

El cost d'una crida al sistema i per què convé minimitzar-les

Posem números al cost de travessar la frontera.

Operació Cost típic Comparació
Crida a funció normal ~1-2 ns Referència
Crida al sistema mínima (getpid) ~50-100 ns 50 vegades més
Crida al sistema amb mitigacions Spectre/Meltdown ~300-800 ns Fins a 400 vegades més
write de 24 bytes a la memòria cau de pàgines ~2.000-20.000 ns Milers de vegades més

D'on surt aquest cost? De diverses fonts que se sumen:

  • El canvi de mode i de pila en si.
  • Desar i restaurar registres.
  • La validació d'arguments.
  • I sobretot, des del 2018, les mitigacions de Meltdown i Spectre. La principal, KPTI (Kernel Page Table Isolation), separa les taules de pàgines del nucli i de l'usuari, cosa que obliga a buidar part de la TLB a cada transició. Aquesta mitigació va multiplicar el cost de les crides al sistema per un factor d'entre 2 i 5, i va fer que el buffering passés de ser recomanable a ser imprescindible.

El cas d'ingestor amb números

Amb 500 estacions enviant una lectura per minut:

500 lectures/min × 60 min × 24 h = 720.000 lectures al dia

Situació actual (dos write per lectura, sense memòria intermèdia):

720.000 × 2 = 1.440.000 crides al sistema al dia
1.440.000 × 5 µs = 7,2 segons de CPU al dia només per travessar la frontera

Amb una memòria intermèdia de 4 KB per al fitxer de dades. Com que cada Lectura ocupa 24 bytes, en 4 KB n'hi caben 170:

720.000 / 170 ≈ 4.235 escriptures al dia per a les dades
més el registre amb memòria intermèdia ≈ 4.235 més
Total ≈ 8.470 crides al sistema al dia
8.470 × 5 µs = 0,04 segons de CPU al dia

Reducció: un factor de 170. De 7,2 segons a 0,04 segons de CPU diaris.

En termes absoluts 7 segons al dia no semblen res, i a meteo-01 amb 500 estacions probablement no ho són. Però el raonament canvia del tot si Meteora creix fins a 50.000 estacions: passaríem de 12 minuts de CPU diaris malgastats a 4 segons, i amb càrrega en ràfegues la diferència es nota com a latència, no com a consum mitjà.

El compromís que cal entendre

La memòria intermèdia no és gratuïta. Canvia rendiment per durabilitat:

Sense memòria intermèdia Amb memòria intermèdia de 4 KB
Crides al sistema 720.000/dia 4.235/dia
Dades en risc si el procés mor 0 lectures Fins a 170 lectures
Latència fins que la dada és visible per a altres processos Immediata Fins que es buidi la memòria intermèdia

I hi ha un tercer nivell que convé distingir amb precisió, perquè gairebé ningú no ho té clar:

  1. Memòria intermèdia de la biblioteca C (espai d'usuari). Es buida amb fflush(). Si el procés mor, es perd.
  2. Memòria cau de pàgines del nucli. Les dades ja són fora del procés: si el procés mor, sobreviuen. Però si la màquina s'apaga, es perden.
  3. Disc físic. Es força amb fsync(). Només aquí la dada és realment duradora.

La decisió d'enginyeria per a Meteora seria: fer servir memòria intermèdia per a les dades de lectures (perdre 170 lectures en una caiguda és assumible, sempre es poden retransmetre) però no per a les alertes crítiques ni per al registre d'auditoria, on cada entrada ha d'arribar al disc encara que costi. Aquest mateix compromís, en la seva forma més general, reapareixerà a Assignació d'Espai, Journaling i Integritat.

Errors Habituals i Consells

  • Creure que errno ve del nucli. El nucli retorna un negatiu a rax; errno és una variable de la biblioteca C. Consulta-la immediatament després de la crida fallida, abans d'invocar cap altra funció.
  • No comprovar el valor de retorn de write. Pot escriure menys bytes dels demanats sense que sigui un error. El patró correcte és un bucle que reintenti amb el que falta.
  • Confondre funció de biblioteca amb crida al sistema. printf no és una crida al sistema; fopen no és open. strace és la manera definitiva de comprovar què travessa de debò la frontera.
  • Pensar que write() significa «ja és al disc». Significa que és a la memòria cau de pàgines del nucli. Només fsync() garanteix durabilitat, i només si el maquinari no menteix sobre les seves pròpies memòries cau.
  • Optimitzar sense mesurar. Abans de redissenyar res, strace -c durant un minut et diu exactament quines crides dominen. En el cas d'ingestor, les 52.000 escriptures van aparèixer soles.
  • Abusar d'strace en producció. Alenteix el procés observat de manera considerable (pot multiplicar per 10 o més el cost de cada crida), perquè cadascuna provoca dues aturades del procés. Per a producció són preferibles eines de menys impacte com perf o bpftrace.
  • Consell: quan un programa vagi lent i no sàpigues per què, executa strace -c -f durant trenta segons. En la majoria dels casos, la resposta salta a la vista a la primera línia de la taula.

Exercicis

Exercici 1

Per a cadascuna d'aquestes funcions d'un programa en C, indica quantes crides al sistema provoca aproximadament i per què. Raona en termes de memòries intermèdies:

  1. strlen("2026-08-31.dat")
  2. printf("Lectura rebuda\n") amb la sortida redirigida a un fitxer.
  3. printf("Lectura rebuda\n") amb la sortida en un terminal interactiu.
  4. Un bucle que crida 1.000 vegades fprintf(f, "%.1f\n", temp) sobre un fitxer obert amb fopen.
  5. Un bucle que crida 1.000 vegades write(fd, buf, 8).

Exercici 2

meteo-api respon a cada petició HTTP escrivint una línia de 120 bytes a /var/log/meteora/meteo-api.log amb write() directe, sense memòria intermèdia. Amb 200 peticions per segon i un cost de 5 µs per crida al sistema:

  1. Quanta CPU al dia es dedica només a aquestes crides?
  2. Si s'hi afegeix una memòria intermèdia de 8 KB, quantes crides quedarien i quanta CPU?
  3. Què es perd amb aquest canvi i en quin cas no s'hauria de fer?

Exercici 3

Escriu un programa en C que rebi 1.000 estructures Lectura simulades i les escrigui en un fitxer, minimitzant el nombre de crides al sistema sense fer servir la biblioteca stdio (és a dir, amb write() directe). Explica'n el disseny i calcula quantes crides fa davant de la versió ingènua.

Solucions

Solució 1

1. strlen(...) → 0 crides al sistema. Recorre memòria del procés mateix comptant bytes fins al terminador nul. No necessita res del nucli. És l'exemple canònic de funció de biblioteca que no travessa la frontera.

2. printf redirigit a un fitxer → 0 crides en aquella invocació. Quan la sortida estàndard no és un terminal, glibc la configura amb memòria intermèdia completa (4 KB). Els 15 bytes es copien a la memòria intermèdia i allà es queden. La crida a write() es produirà quan la memòria intermèdia s'ompli (després d'uns 270 missatges), quan es cridi fflush() o en acabar el programa de manera ordenada. Conseqüència pràctica important: si el programa mor de manera abrupta, el fitxer de sortida apareix truncat, i sovint hi falta just el missatge que explicava la fallada.

3. printf a un terminal → 1 crida al sistema. Quan la sortida és un terminal, glibc fa servir memòria intermèdia per línies: la buida en trobar un \n. Com que el missatge acaba amb un salt de línia, es produeix un write() immediat. Això explica un comportament que desconcerta molta gent: el mateix programa mostra els seus missatges a l'instant per pantalla però sembla que «no escrigui res» quan es redirigeix a un fitxer. No és cap fallada, és un canvi de política de buffering.

4. 1.000 fprintf d'uns 6 bytes → aproximadament 2 crides. 6.000 bytes en total, amb una memòria intermèdia de 4.096: es buida un cop en omplir-se i un altre en fer fclose. Reducció de 1.000 a 2.

5. 1.000 write de 8 bytes → exactament 1.000 crides. write() no té memòria intermèdia: cada invocació travessa a mode nucli. A 5 µs cadascuna són 5 ms de CPU per escriure 8 KB, quan amb una memòria intermèdia n'hi hauria prou amb 2 crides i 10 µs. És una diferència de 500 vegades, i és exactament l'error que hem detectat a ingestor amb strace -c.

Solució 2

1. Situació actual:

200 peticions/s × 86.400 s/dia = 17.280.000 escriptures al dia
17.280.000 × 5 µs = 86,4 segons de CPU al dia

Un minut i mig de CPU diari dedicat exclusivament a travessar la frontera per escriure el registre. Expressat d'una altra manera: el 0,1 % d'un nucli de manera contínua, només per registrar.

2. Amb memòria intermèdia de 8 KB:

8.192 bytes / 120 bytes per línia = 68 línies per buidatge
17.280.000 / 68 ≈ 254.118 crides al sistema al dia
254.118 × 5 µs = 1,27 segons de CPU al dia

Reducció d'un factor de 68, exactament el nombre de línies que caben a la memòria intermèdia. De 86,4 a 1,27 segons.

3. Què es perd i quan no s'ha de fer:

Es perden dues coses:

  • Durabilitat davant d'una caiguda del procés: fins a 68 línies de registre, que corresponen als últims 0,34 segons d'activitat. I aquí hi ha el problema seriós: són precisament les línies que descriuen el que va passar just abans de la fallada, és a dir, les més valuoses per diagnosticar-la.
  • Immediatesa de l'observació: un administrador que executi tail -f sobre el registre veurà els missatges a salts de 68 en 68, amb retard variable. I les eines de monitoratge que llegeixen el fitxer detectaran els problemes més tard.

No s'hauria de fer en tres casos concrets:

  • Registre d'auditoria o de seguretat. Si serveix com a evidència (qui va accedir a què i quan), perdre les últimes entrades és inacceptable. A més, un atacant que provoqui una caiguda esborraria amb ella el rastre més comprometedor.
  • Registre d'errors. Convé aplicar-hi la mateixa política que a stderr: sense memòria intermèdia. El volum d'errors és baix per definició, així que el cost és menyspreable i el valor diagnòstic és màxim.
  • Quan hi ha un requisit de traçabilitat extern (normatiu o contractual) que exigeixi constància de cada operació.

La solució d'enginyeria correcta és separar els fluxos: el registre d'accés, que és voluminós i poc crític, amb memòria intermèdia; i els registres d'error i d'auditoria, que són escassos i crítics, sense memòria intermèdia. És exactament el que fan els servidors web seriosos, i també la raó que journald distingeixi nivells de prioritat.

Solució 3

/* escriptor_lot.c — compilar: gcc -O2 -o escriptor_lot escriptor_lot.c */
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>

struct Lectura {
    uint32_t estacio_id;
    int64_t  timestamp;
    float    temperatura;
    float    humitat;
    float    pressio;
};

#define CAPACITAT 170          /* 170 × 24 = 4.080 bytes, cap en 4 KB */

struct Buffer {
    int fd;
    struct Lectura dades[CAPACITAT];
    size_t usats;
};

/* Escriu tota la memòria intermèdia, reintentant davant d'escriptures parcials */
static int buidar(struct Buffer *b) {
    if (b->usats == 0) return 0;

    const char *p = (const char *)b->dades;
    size_t pendents = b->usats * sizeof(struct Lectura);

    while (pendents > 0) {
        ssize_t n = write(b->fd, p, pendents);
        if (n == -1) {
            if (errno == EINTR) continue;       /* interromput: reintentar */
            return -1;                          /* error real */
        }
        p += n;
        pendents -= (size_t)n;
    }
    b->usats = 0;
    return 0;
}

static int afegir(struct Buffer *b, const struct Lectura *l) {
    if (b->usats == CAPACITAT) {
        if (buidar(b) == -1) return -1;
    }
    b->dades[b->usats++] = *l;
    return 0;
}

int main(void) {
    struct Buffer b = { .usats = 0 };
    b.fd = open("/var/lib/meteora/lectures/2026-08-31.dat",
                O_WRONLY | O_CREAT | O_APPEND, 0640);
    if (b.fd == -1) { perror("open"); return 1; }

    for (int i = 0; i < 1000; i++) {
        struct Lectura l = { .estacio_id = (uint32_t)(100 + i % 50),
                             .timestamp = 1756636800 + i,
                             .temperatura = 20.0f + (i % 15),
                             .humitat = 55.0f, .pressio = 1013.0f };
        if (afegir(&b, &l) == -1) { perror("afegir"); return 1; }
    }

    if (buidar(&b) == -1) { perror("buidat final"); return 1; }
    close(b.fd);
    return 0;
}

Explicació del disseny:

  • CAPACITAT = 170 no és arbitrari. 170 × 24 = 4.080 bytes, just per sota dels 4.096 d'una pàgina. Alinear la memòria intermèdia amb la mida de pàgina aprofita millor la memòria cau de pàgines del nucli i evita que una escriptura travessi innecessàriament el límit de dues pàgines.
  • buidar() fa servir un bucle, no un sol write. Això és el que distingeix el codi correcte del codi que funciona fins que un dia no. write() pot retornar menys bytes dels demanats, i el bucle avança el punter p i decrementa pendents fins a haver-ho escrit tot.
  • El tractament d'EINTR cobreix un altre cas real: si arriba un senyal mentre el procés està bloquejat a write, la crida pot retornar amb -1 i errno == EINTR sense haver escrit res. No és cap error: cal reintentar-ho. Ometre-ho produeix pèrdues de dades esporàdiques i irreproduïbles, de les pitjors de diagnosticar.
  • afegir() buida abans d'afegir, no després. Així la memòria intermèdia no desborda mai i l'ordre de les operacions és sempre correcte.
  • El buidar() final és imprescindible. Sense ell es perdrien les últimes 150 lectures (1.000 − 5 × 170 = 150), perquè quedarien a la memòria intermèdia sense escriure. És l'equivalent exacte de fflush(), i oblidar-ho és l'error més freqüent quan s'implementa buffering a mà.

Comparació de crides al sistema:

Versió Crides write Cost a 5 µs
Ingènua (una per lectura) 1.000 5.000 µs = 5 ms
Amb memòria intermèdia de 170 6 (5 de completes + 1 de final) 30 µs

Reducció d'un factor de 167. I fixa't en un detall que reforça el que hem vist a la lliçó: el nombre de crides al sistema no depèn de quantes dades escriguis, sinó de quantes vegades travessis la frontera. Escriure 4.080 bytes costa pràcticament el mateix que escriure'n 24, perquè el sobrecost dominant és el canvi de mode, no la còpia de les dades.

Afegit opcional per a robustesa: si aquestes lectures fossin crítiques, després de buidar() caldria cridar fsync(b.fd) per forçar l'abocament al disc físic. Costa de l'ordre de mil·lisegons, així que només es justifica quan la pèrdua de dades davant d'un tall de corrent és inacceptable.

Conclusió

La protecció d'un sistema operatiu no descansa en el seu codi sinó en el maquinari: el bit de mode de la CPU fa que les instruccions privilegiades simplement no s'executin en mode usuari, i el pas a mode nucli només pot passar per interrupció, excepció o crida al sistema, sempre saltant a una adreça que el nucli va fixar per endavant. Dels quatre anells de privilegi de x86, a la pràctica només es fan servir el 0 i el 3.

Una crida al sistema és l'única porta legítima a través d'aquesta frontera, i n'hem recorregut la mecànica completa: número de crida a rax, arguments en registres, la instrucció syscall, el canvi a la pila del nucli, la validació d'arguments, el despatx per taula, el retorn amb sysret i la traducció del valor negatiu a errno que fa la biblioteca C. També has vist per què API, biblioteca C i crida al sistema són tres coses diferents: printf no és una crida al sistema, fopen no és open, i strace és la manera de comprovar què travessa de debò.

I sobretot t'endús una idea amb conseqüències pràctiques diàries: travessar la frontera costa, entre 50 nanosegons i uns quants microsegons, i aquest cost no depèn de quantes dades moguis. D'aquí que el buffering no sigui cap adorn sinó una decisió de disseny que canvia rendiment per durabilitat, i que a ingestor reduiria les crides al sistema en un factor de 170. Saber on posar-lo —i on no posar-lo mai, com en un registre d'auditoria— és una d'aquelles decisions que separen el codi que funciona del codi que aguanta en producció.

Amb això tanques el mòdul 1. Saps què és un sistema operatiu, d'on ve, de quins tipus n'hi ha, quines funcions compleix, com s'organitza el seu nucli per dins i com es travessa la frontera que el protegeix. A partir d'aquí deixem de mirar el sistema des de fora i comencem a obrir-lo. Al Mòdul 2: Gestió de Recursos entrarem en la primera i més fonamental de les seves feines: Gestió de Processos, on veuràs què hi ha exactament dins d'aquella abstracció que hem fet servir a cada lliçó sense obrir-la mai, com neix un procés amb fork i execve, quins estats travessa i què passa en un canvi de context. Els tres processos de Meteora deixaran de ser noms en una llista de ps per convertir-se en estructures que sabràs llegir.

Fonaments de Sistemes Operatius

Mòdul 1: Introducció als Sistemes Operatius

Mòdul 2: Gestió de Recursos

Mòdul 3: Concurrència

Mòdul 4: Estructures de Fitxers

Mòdul 5: Protecció i Seguretat del Sistema

Mòdul 6: Virtualització i Contenidors

Mòdul 7: Administració i Diagnòstic a la Pràctica

© Copyright 2026. Tots els drets reservats