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
- Per què un programa no pot tocar el maquinari
- El mode dual: bit de mode i instruccions privilegiades
- Anells de privilegi
- Què és una crida al sistema
- El recorregut pas a pas, d'usuari a nucli i tornada
- API, biblioteca C i crida al sistema: tres coses diferents
- Categories de crides al sistema
- Exemple en C:
write()davant defprintf() - Observació real amb
strace - 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'
ingestori 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;
}Què ha passat, línia a línia:
__asm__ volatile ("cli")insereix directament la instrucció màquinaclial programa.volatileimpedeix que el compilador la reordeni o l'elimini per considerar-la inútil.- La CPU, en trobar-se
cliamb 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
printfno 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:
- Una interrupció de maquinari (el disc ha acabat, ha arribat un paquet de xarxa). És asíncrona, no la provoca el programa.
- Una excepció (divisió per zero, fallada de pàgina, instrucció privilegiada). És síncrona però involuntària.
- 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:
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) arcxi els flags (rflags) ar11. - Carrega a
ripl'adreça que el nucli va escriure en arrencar al registre especialMSR_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, oprintf()quan només omple la seva memòria intermèdia. - Una funció de biblioteca en pot fer diverses.
fopen()faopen()i sovintfstat().printf()amb la memòria intermèdia plena fa unwrite(). - Una crida al sistema pot tenir diversos embolcalls diferents.
open(),open64(),creat()ifopen()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 unint, el descriptor, o-1en 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:writepot 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 embolcallaopen()i a més reserva una memòria intermèdia (normalment 4096 bytes) i retorna unFILE *, 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 unwrite(), 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 | Sí |
| 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.
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:
-fsegueix també els fils i els fills del procés. Sense això, en un programa amb diversos fils només en veuries una part.-Tmostra entre<>el temps que va trigar cada crida. És l'opció més útil per diagnosticar.-p 1099s'enganxa a un procés ja en execució, en aquest casingestor. Alternativament,strace ./programael llança des del principi.2>&1redirigeix la sortida d'error (onstraceescriu) a l'estàndard, per poder passar-la perhead.
Ara la interpretació del cicle, que és el que és interessant:
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.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.write(9, ..., 24) = 24— escriu els 24 bytes de laLecturaal descriptor 9, el fitxer del dia. Va trigar 21 µs.recvfrom(...) = -1 EAGAIN— torna a intentar llegir del sòcol i no hi ha res.EAGAINen un sòcol no bloquejant no és un error real: significa «ara mateix no hi ha dades, torna més tard».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 tempsingestorés en estatSi 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:
% 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_waits'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
errorsmostra 26.000 errors arecvfrom. Sembla alarmant, però són elsEAGAINesperats del sondeig no bloquejant. Molts errors astrace -cno signifiquen cap problema; cal mirar quins són. - I aquí hi ha la troballa important: 52.000 crides a
writeper a 26.000 lectures rebudes. Són exactament dues escriptures per lectura. Investigant el codi descobriríem queingestorescriu 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:
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:
- Memòria intermèdia de la biblioteca C (espai d'usuari). Es buida amb
fflush(). Si el procés mor, es perd. - 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.
- 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
errnove del nucli. El nucli retorna un negatiu arax;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.
printfno és una crida al sistema;fopenno ésopen.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ésfsync()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 -cdurant un minut et diu exactament quines crides dominen. En el cas d'ingestor, les 52.000 escriptures van aparèixer soles. - Abusar d'
straceen 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 comperfobpftrace. - Consell: quan un programa vagi lent i no sàpigues per què, executa
strace -c -fdurant 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:
strlen("2026-08-31.dat")printf("Lectura rebuda\n")amb la sortida redirigida a un fitxer.printf("Lectura rebuda\n")amb la sortida en un terminal interactiu.- Un bucle que crida 1.000 vegades
fprintf(f, "%.1f\n", temp)sobre un fitxer obert ambfopen. - 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:
- Quanta CPU al dia es dedica només a aquestes crides?
- Si s'hi afegeix una memòria intermèdia de 8 KB, quantes crides quedarien i quanta CPU?
- 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 -fsobre 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 solwrite. 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 punterpi decrementapendentsfins a haver-ho escrit tot.- El tractament d'
EINTRcobreix un altre cas real: si arriba un senyal mentre el procés està bloquejat awrite, la crida pot retornar amb-1ierrno == EINTRsense 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 defflush(), 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
- Conceptes Bàsics de Sistemes Operatius
- Història i Evolució dels Sistemes Operatius
- Tipus de Sistemes Operatius
- Funcions Principals d'un Sistema Operatiu
- Arquitectura del Nucli: Monolític, Microkernel i Híbrid
- Mode Usuari, Mode Nucli i Crides al Sistema
Mòdul 2: Gestió de Recursos
- Gestió de Processos
- Planificació de la CPU
- Gestió de Memòria
- Memòria Virtual i Paginació
- Gestió d'Emmagatzematge
- Gestió de Dispositius
- Controladors, Interrupcions i Operacions d'E/S
Mòdul 3: Concurrència
- Conceptes de Concurrència
- Fils i Processos
- Comunicació entre Processos (IPC)
- Sincronització i Exclusió Mútua
- Problemes Clàssics de Concurrència
- Interbloquejos: Prevenció, Detecció i Recuperació
Mòdul 4: Estructures de Fitxers
- Sistemes de Fitxers
- Estructures de Directoris
- Particions, Muntatge i Sistema de Fitxers Virtual
- Gestió de Fitxers
- Assignació d'Espai, Journaling i Integritat
- Seguretat i Permisos de Fitxers
Mòdul 5: Protecció i Seguretat del Sistema
- Principis de Protecció i Control d'Accés
- Usuaris, Autenticació i Escalada de Privilegis
- Amenaces Habituals i Enfortiment del Sistema
- Auditoria, Registres i Resposta a Incidents
Mòdul 6: Virtualització i Contenidors
- Virtualització: Hipervisors i Màquines Virtuals
- Contenidors: Namespaces i cgroups
- El Sistema Operatiu al Núvol
- Sistemes Operatius Mòbils i de Temps Real
