Salta al contenuto
Note per Studenti Richiami di C per la programmazione di rete - memoria, puntatori, struct ed endianness

Richiami di C per la programmazione di rete - memoria, puntatori, struct ed endianness

In questa pagina 10

Un programma di rete in C non scambia "oggetti": scambia byte. Quello che il kernel consegna con read è una sequenza di byte di cui si conosce solo la lunghezza; quello che si spedisce con write è la memoria così com'è. Per scrivere client e server che funzionano su macchine diverse bisogna quindi sapere esattamente come il C dispone i dati in memoria. Questa nota richiama solo ciò che serve alla parte di rete; per il resto si rimanda a Puntatori in CUn puntatore contiene un indirizzo di memoria; operatori & e *, NULL, puntatori come parametri per modificare variabili del chiamante, aritmetica dei puntatori, legame tra array e puntatori, const, puntatori a puntatori.Puntatori in C →, Strutture (struct) in Cstruct per raggruppare campi di tipo diverso, typedef, accesso con punto e freccia, struct come parametri e valori di ritorno, array di struct, struct allocate dinamicamente e struct autoreferenziali.Strutture (struct) in C →, Array e stringhe in CArray di dimensione fissa in memoria contigua, inizializzazione, nessun controllo sugli indici, passaggio a funzioni con la lunghezza, matrici; stringhe come array di char terminati da '\0' e funzioni di string.h.Array e stringhe in C → e Gestione della memoria in CSegmenti di memoria di un processo (codice, dati statici, stack, heap); durata delle variabili; allocazione dinamica con malloc, calloc, realloc e free; errori classici: memory leak, dangling pointer, double free, buffer overflow; strumenti di controllo.Gestione della memoria in C →.

La memoria di un processo

Quando il sistema operativo avvia un programma gli dà uno spazio di indirizzi virtuale (Memoria virtualeIndirizzi virtuali e fisici, pagine e frame; traduzione con la tabella delle pagine e calcolo dei campi; page fault e sostituzione delle pagine; TLB e tempo di accesso effettivo; protezione e condivisione; confronto con la cache.Memoria virtuale →) diviso in segmenti:

segmento contenuto esempio in un server
codice (text) le istruzioni macchina, in sola lettura main, handle_client
dati statici (data, bss) variabili globali e static, stringhe letterali static char root[256]
heap memoria allocata con malloc, cresce verso l'alto il buffer di una risposta di dimensione variabile
stack variabili locali e indirizzi di ritorno, cresce verso il basso char line[1024] dentro una funzione
indirizzi alti   +----------------+
                 |     stack      |  <- variabili locali, un blocco per ogni chiamata
                 |       |        |
                 |       v        |
                 |                |
                 |       ^        |
                 |       |        |
                 |      heap      |  <- malloc / free
                 +----------------+
                 |  dati statici  |  <- globali, static, stringhe letterali
                 +----------------+
                 |     codice     |
indirizzi bassi  +----------------+

Due conseguenze pratiche per i programmi di rete:

  • Un array locale vive nello stack e sparisce al ritorno della funzione: restituire line da una funzione che lo ha dichiarato locale è un errore (puntatore pendente). Un buffer che deve sopravvivere va nello heap o nel chiamante.
  • Lo stack è piccolo (di solito 8 MB): char buf[10000000]; locale manda il programma in errore. Un buffer di qualche kilobyte è invece normale.

Con fork (System call POSIX, file descriptor e API delle socketLe system call sono le funzioni con cui un programma in user space chiede servizi al kernel (open, read, write, close, fork, pipe, dup2, socket, bind, listen, accept, connect); restituiscono -1 e impostano errno in caso di errore; un file descriptor e' un intero che indicizza la tabella dei file aperti del processo (0 stdin, 1 stdout, 2 stderr) e vale per file, pipe e socket; read e write possono trasferire MENO byte del richiesto (e sui socket TCP non c'e' nessun confine fra i messaggi), quindi servono cicli write_all e read_exact; l'API delle socket crea un punto finale di comunicazione (socket), lo lega a indirizzo e porta (bind), lo rende passivo (listen), accetta connessioni (accept) o si connette (connect); getaddrinfo traduce nomi e porte in indirizzi (IPv4 e IPv6); UDP usa sendto e recvfrom e conserva i confini dei datagrammi, TCP e' uno stream affidabile.System call POSIX, file descriptor e API delle socket →) il figlio riceve una copia di tutto lo spazio di indirizzi: una variabile modificata dal figlio non cambia nel padre.

Tipi e dimensioni

Le dimensioni di int e long dipendono da macchina e compilatore. Su Linux a 64 bit (modello LP64) valgono char 1 byte, short 2, int 4, long 8 e un puntatore 8. Nei protocolli le dimensioni devono essere esatte: si usano i tipi di <stdint.h>.

tipo significato uso tipico
uint8_t intero senza segno a 8 bit un byte di un buffer
uint16_t senza segno a 16 bit numero di porta, lunghezza
uint32_t senza segno a 32 bit indirizzo IPv4, timestamp
uint64_t senza segno a 64 bit lunghezza estesa di un frame WebSocket
size_t dimensione di un oggetto (senza segno) risultato di sizeof, strlen, argomento di read
ssize_t come size_t ma con segno valore di ritorno di read e write (-1 = errore)

Il punto delicato è ssize_t: read restituisce il numero di byte letti, 0 a fine flusso e -1 in caso di errore. Salvarlo in un size_t trasforma -1 in un numero enorme e il controllo if (n < 0) non scatta mai.

c
ssize_t n = read(fd, buf, sizeof buf);   /* giusto: n puo' valere -1 */
if (n < 0) { perror("read"); }

char può avere o non avere il segno a seconda della piattaforma: un byte 0xE8 letto in un char può diventare -24. Per trattare byte si usa unsigned char o uint8_t; è indispensabile con isxdigit, con gli indici di tabelle e con i confronti.

Byte, buffer e stringhe

Una stringa C è una sequenza di caratteri terminata da un byte '\0': strlen, strcpy, printf("%s") si fermano a quel byte. Un buffer è una zona di memoria con una lunghezza nota a parte. I dati letti da un socket sono un buffer, non una stringa:

Regole per non sbagliare:

  1. Prima di usare strlen, strcmp, strstr o printf("%s") su dati ricevuti, terminare a mano il buffer: buf[n] = '\0', riservando un byte in più (read(fd, buf, sizeof buf - 1)).
  2. Per i dati binari usare le funzioni mem*, che prendono la lunghezza: memcpy, memmove, memcmp, memset, memchr.
  3. memcpy non gestisce aree che si sovrappongono: per spostare dati dentro lo stesso buffer si usa memmove.
  4. strncpy non aggiunge il '\0' se la sorgente è lunga quanto il limite. Per copiare in sicurezza si usa snprintf(dst, sizeof dst, "%s", src), che termina sempre e tronca.
  5. snprintf restituisce la lunghezza che la stringa avrebbe avuto senza troncamento: se il valore è maggiore o uguale a sizeof dst, la stringa è stata tagliata.
c
char req[256];
int n = snprintf(req, sizeof req, "GET %s HTTP/1.0\r\nHost: %s\r\n\r\n", path, host);
if (n < 0 || (size_t)n >= sizeof req) { /* richiesta troppo lunga: errore */ }
write(fd, req, (size_t)n);               /* si scrivono n byte, non strlen: qui coincidono, ma con dati binari no */

Il terminatore di riga dei protocolli testuali come HTTP è \r\n (CR LF, byte 0x0D 0x0A), non il solo \n.

Puntatori e aritmetica dei puntatori

Un puntatore contiene un indirizzo (Puntatori in CUn puntatore contiene un indirizzo di memoria; operatori & e *, NULL, puntatori come parametri per modificare variabili del chiamante, aritmetica dei puntatori, legame tra array e puntatori, const, puntatori a puntatori.Puntatori in C →). Con un puntatore a T, sommare n significa avanzare di n * sizeof(T) byte: con char * e uint8_t * si avanza di n byte, ed è per questo che i buffer si indicano così.

Il modello di lettura "a pezzi" ricorrente nei client è:

c
char buf[65536];
size_t tot = 0;
ssize_t n;
while ((n = read(fd, buf + tot, sizeof buf - 1 - tot)) > 0)   /* si continua a scrivere dopo quanto gia' letto */
    tot += (size_t)n;
buf[tot] = '\0';

buf + tot è l'indirizzo del primo byte libero; sizeof buf - 1 - tot è lo spazio rimasto (meno un byte per il '\0'). Se non si controlla lo spazio residuo si ha un buffer overflow.

Altri usi che compaiono nel codice di rete:

  • char **argv: array di puntatori a stringa, argv[1] è il primo argomento (di solito l'host o la porta).
  • void *: puntatore senza tipo, restituito da malloc e accettato da read, write, memcpy.
  • Cast di puntatori a struct: bind vuole un struct sockaddr *, ma si passa l'indirizzo di una struct sockaddr_in: bind(fd, (struct sockaddr *)&addr, sizeof addr). È lecito perché le due strutture iniziano con lo stesso campo sa_family.
  • const: const char *s promette di non modificare i caratteri puntati; è il tipo giusto per le stringhe letterali ("GET"), che stanno nei dati statici in sola lettura.

Struct, allineamento e padding

Una struct mette i campi uno dopo l'altro, ma il compilatore rispetta l'allineamento: un tipo da k byte viene messo a un indirizzo multiplo di k (per int e uint32_t: multiplo di 4; per double e uint64_t: multiplo di 8), perché così la CPU lo legge in un accesso solo. Per ottenerlo inserisce byte di riempimento, il paddingbyte inutilizzati inseriti dal compilatore fra un campo e il successivo, oppure in coda, per rispettare l'allineamento. Anche la dimensione totale diventa un multiplo dell'allineamento più grande.

c
struct sparso   { char c; int i; char d; };    /* c@0, 3 byte di padding, i@4, d@8, 3 byte di padding in coda: sizeof = 12 */
struct compatto { int i; char c; char d; };    /* i@0, c@4, d@5, 2 byte di padding in coda: sizeof = 8 */

Il programma endian_demo.c (in fondo alla nota) stampa proprio queste dimensioni: 12 per sparso, 8 per compatto, 6 per la versione __attribute__((packed)). Regole utili:

  • ordinando i campi dal più grande al più piccolo il padding diminuisce;
  • offsetof(struct sparso, i) (da <stddef.h>) dà la posizione di un campo;
  • __attribute__((packed)) (estensione di GCC e Clang) elimina il padding ma rende lenti, e su alcune CPU impossibili, gli accessi non allineati;
  • una struct non si spedisce con write(fd, &s, sizeof s): il padding contiene byte casuali, l'allineamento cambia da compilatore a compilatore e l'ordine dei byte dei campi dipende dalla macchina. Il formato sul filo si definisce campo per campo.
  • Per lo stesso motivo una struct sockaddr_in si azzera con memset(&addr, 0, sizeof addr) prima di riempirla: contiene un campo di riempimento (sin_zero) che il kernel si aspetta a zero.

Endianness e ordine dei byte di rete

Un numero a più byte va scritto in memoria in qualche ordine (Codifiche binarie e informazione non numericaBit, byte e multipli (potenze di 2 e di 10); codici BCD e Gray; caratteri ASCII, Unicode e UTF-8; ordine dei byte (little e big endian); bit di parità e codice di Hamming per rilevare e correggere errori.Codifiche binarie e informazione non numerica →). Per uint32_t x = 0x12345678 i quattro byte sono 12 34 56 78:

ordine primo byte in memoria macchine
big endian il più significativo: 12 34 56 78 protocolli di rete, SPARC, PowerPC storici
little endian il meno significativo: 78 56 34 12 x86, x86-64, ARM nella configurazione abituale

Definizione (network byte order). Tutti i campi numerici a più byte dei protocolli Internet (porte, lunghezze, indirizzi IPv4, campi di TCP, UDP, DNS) viaggiano in big endian, detto network byte order. Il formato della macchina è l'ordine host.

Le funzioni di <arpa/inet.h> convertono:

funzione conversione tipo
htons host to network, short uint16_t
htonl host to network, long uint32_t
ntohs network to host, short uint16_t
ntohl network to host, long uint32_t

Esempio. La porta 8080 è 0x1F90. Su una macchina little endian la variabile uint16_t host = 8080 contiene in memoria i byte 90 1F; htons(8080) restituisce un valore che in memoria ha i byte 1F 90, cioè l'ordine di rete: ecco perché si scrive addr.sin_port = htons(8080). Su una macchina big endian htons non fa nulla, ma il codice resta uguale e quindi portabile.

0x12345678 in memoria:  78 56 34 12      (little endian)
porta 8080 (host):      90 1f
porta 8080 (rete):      1f 90
0x12345678 sul filo:    12 34 56 78

Quando serve la conversione e quando no:

  • serve per ogni campo numerico a 2, 4 o 8 byte che entra o esce da un messaggio binario: sin_port, sin_addr.s_addr, un campo di lunghezza di un frame WebSocket, gli identificatori di un messaggio DNS;
  • non serve per i singoli byte (uint8_t) né per il testo: una stringa ASCII o UTF-8 ha lo stesso ordine ovunque, e l'HTTP/1.x è testo;
  • non si converte due volte: htons(htons(x)) torna a x e produce un errore silenzioso.

Per i formati binari è spesso più chiaro serializzare a mano con shift e maschere, che non dipendono dalla macchina:

c
uint8_t buf[4];
buf[0] = (uint8_t)(x >> 24);      /* il byte piu' significativo per primo: big endian */
buf[1] = (uint8_t)(x >> 16);
buf[2] = (uint8_t)(x >> 8);
buf[3] = (uint8_t)x;
uint32_t y = (uint32_t)buf[0] << 24 | (uint32_t)buf[1] << 16 | (uint32_t)buf[2] << 8 | buf[3];   /* lettura inversa */

Il cast (uint32_t) prima dello shift è necessario: buf[0] << 24 è un'operazione su int e, con buf[0] >= 0x80, sposta un bit nel segno (comportamento non definito).

Bit, flag e maschere

I campi più piccoli di un byte (flag) si estraggono con AND e shift. Nell'intestazione di un messaggio DNS il campo flags a 16 bit vale per esempio 0x8180:

bit nome valore in 0x8180 significato
15 QR 1 1 = risposta, 0 = domanda
8 RD 1 ricorsione desiderata
7 RA 1 ricorsione disponibile
3..0 RCODE 0 codice di errore (0 = nessuno, 3 = nome inesistente)
c
uint16_t flags = 0x8180;
int qr    = (flags >> 15) & 1;      /* 1 */
int rcode = flags & 0x000F;         /* 0 */

Lo stesso schema (primo byte FIN | opcode, secondo byte MASK | lunghezza) serve per i frame WebSocket (WebSocket, QUIC e HTTP-3HTTP e' richiesta e risposta, quindi poco adatto a notifiche e dati in tempo reale (polling, long polling, SSE); WebSocket (RFC 6455) apre con un handshake HTTP/1.1 Upgrade (Sec-WebSocket-Key a 16 byte casuali in Base64, risposta 101 con Sec-WebSocket-Accept = Base64 di SHA-1 di chiave piu' GUID fisso) un canale bidirezionale persistente sulla stessa connessione TCP, con frame di 2-14 byte di intestazione (FIN, opcode, MASK, lunghezza su 7, 16 o 64 bit, chiave di mascheramento obbligatoria dal client al server) e messaggi di testo, binari, close, ping e pong. QUIC (RFC 9000) e' un protocollo di trasporto sopra UDP con TLS 1.3 integrato, flussi indipendenti (niente head-of-line blocking fra flussi), connection ID che permettono di cambiare rete, handshake in 1 RTT e ripresa in 0 RTT; HTTP/3 (RFC 9114) mappa HTTP su QUIC con un flusso per richiesta e QPACK per gli header, ed e' annunciato con Alt-Svc.WebSocket, QUIC e HTTP-3 →).

Un programma di prova

Il programma seguente stampa tutto quello che è stato descritto sopra (compilare con gcc -Wall -Wextra -o endian_demo endian_demo.c).

c
/* endian_demo.c - ordine dei byte, allineamento e padding delle struct.
 * Compilare e provare:  gcc -Wall -Wextra -o endian_demo endian_demo.c && ./endian_demo
 */
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <arpa/inet.h>                             /* htons, htonl, ntohs, ntohl */

struct sparso  { char c; int i; char d; };         /* int vuole indirizzi multipli di 4 */
struct compatto { int i; char c; char d; };        /* stessi campi, riordinati */
struct __attribute__((packed)) impacchettato { char c; int i; char d; };   /* niente padding (estensione GCC/Clang) */

static void dump(const char *label, const void *p, size_t n)
{
    const unsigned char *b = p;                    /* unsigned char: si legge il singolo byte senza segno */
    printf("%-22s", label);
    for (size_t i = 0; i < n; i++)
        printf(" %02x", b[i]);
    printf("\n");
}

int main(void)
{
    /* 1) Come e' scritto in memoria un intero a 32 bit */
    uint32_t x = 0x12345678;
    dump("0x12345678 in memoria:", &x, sizeof x);   /* little endian (x86, ARM): 78 56 34 12 */
    uint16_t host = 8080, rete = htons(host);       /* 8080 = 0x1F90 */
    dump("porta 8080 (host):", &host, sizeof host); /* little endian: 90 1f */
    dump("porta 8080 (rete):", &rete, sizeof rete); /* network byte order: 1f 90 */
    uint8_t first = *(uint8_t *)&x;                 /* test dell'endianness a run-time */
    printf("questa macchina e' %s endian\n", first == 0x78 ? "little" : "big");

    /* 2) Serializzare a mano, senza dipendere dall'endianness della macchina */
    uint8_t buf[4];
    buf[0] = (uint8_t)(x >> 24);                    /* il byte piu' significativo per primo = big endian */
    buf[1] = (uint8_t)(x >> 16);
    buf[2] = (uint8_t)(x >> 8);
    buf[3] = (uint8_t)x;
    dump("0x12345678 sul filo:", buf, sizeof buf);  /* 12 34 56 78 su QUALSIASI macchina */
    uint32_t back = (uint32_t)buf[0] << 24 | (uint32_t)buf[1] << 16 | (uint32_t)buf[2] << 8 | buf[3];
    printf("riletto: 0x%08x\n", (unsigned)back);

    /* 3) Allineamento e padding */
    printf("sizeof(struct sparso)          = %zu\n", sizeof(struct sparso));
    printf("  offset c=%zu i=%zu d=%zu\n", offsetof(struct sparso, c), offsetof(struct sparso, i), offsetof(struct sparso, d));
    printf("sizeof(struct compatto)        = %zu\n", sizeof(struct compatto));
    printf("sizeof(struct impacchettato)   = %zu\n", sizeof(struct impacchettato));

    /* 4) Il motivo per cui una struct NON si spedisce con write(fd, &s, sizeof s) */
    struct sparso s;
    memset(&s, 0xAA, sizeof s);                     /* il padding contiene spazzatura finche' non lo si azzera */
    s.c = 'A';
    s.i = 1;
    s.d = 'B';
    dump("struct sparso in memoria:", &s, sizeof s);/* 41 aa aa aa 01 00 00 00 42 aa aa aa */
    return 0;
}

Errori tipici

  • Trattare un buffer ricevuto come stringa senza terminarlo con '\0': printf("%s") legge oltre il buffer.
  • Salvare il risultato di read in un size_t e perdere il controllo dell'errore (-1).
  • Usare sizeof(p) su un puntatore pensando di avere la dimensione dell'array: dà 8, non la lunghezza del buffer. sizeof dà la dimensione dell'array solo sulla variabile array stessa (char buf[256]).
  • Spedire una struct con write e sizeof: padding casuale e ordine dei byte della macchina finiscono sul filo.
  • Dimenticare htons sulla porta: sin_port = 8080 collega alla porta 0x901F = 36895.
  • Applicare htons a un campo che non è un numero a più byte, oppure convertire due volte.
  • Fare shift su char o uint8_t senza cast a uint32_t: il risultato è un int e può avere segno.
  • Restituire l'indirizzo di un array locale.

Domande d'esame

1. Una struct { char tipo; uint32_t lunghezza; char flag; } viene spedita con write(fd, &m, sizeof m). Quali problemi ci sono? Come si corregge? Traccia. Con allineamento a 4 byte la struct occupa 12 byte: tipo a 0, tre byte di padding, lunghezza a 4, flag a 8, altri tre byte di padding. Il padding contiene byte casuali (informazione che esce dal processo e dati diversi a ogni esecuzione), il tipo uint32_t viaggia nell'ordine host (little endian su x86) mentre il ricevente si aspetta big endian, e un altro compilatore potrebbe disporre i campi in modo diverso. Correzione: definire il formato sul filo (1 byte di tipo, 4 byte di lunghezza in big endian, 1 byte di flag = 6 byte) e serializzare campo per campo con htonl o con shift, in un uint8_t buf[6].

2. Un client chiama read(fd, buf, sizeof buf) e poi printf("%s", buf). Cosa può andare storto e come si evita? Traccia. read non aggiunge '\0', quindi printf può leggere oltre i dati ricevuti (e mostrare spazzatura o terminare con un errore); read può restituire meno byte del previsto e -1 in caso di errore. Si salva il risultato in un ssize_t n, si controlla n < 0, si legge al massimo sizeof buf - 1 byte e si scrive buf[n] = '\0' prima di stampare; per dati che possono contenere byte 0 si usa fwrite(buf, 1, n, stdout).

Versione ripasso

c
char buf[65536]; size_t tot = 0; ssize_t n;
while ((n = read(fd, buf + tot, sizeof buf - 1 - tot)) > 0)
    tot += (size_t)n;                 /* n == 0: fine flusso; n < 0: errore */
buf[tot] = '\0';                      /* solo ora e' una stringa */
  • Schema di scrittura sicura di una richiesta: int n = snprintf(req, sizeof req, "GET %s HTTP/1.0\r\n\r\n", path); if (n < 0 || (size_t)n >= sizeof req) errore; write(fd, req, (size_t)n);
  • Layout della struct d'esame { char tipo; uint32_t lunghezza; char flag; }: 12 byte (tipo@0, padding 3, lunghezza@4, flag@8, padding 3). Sul filo: 1 byte di tipo, 4 byte di lunghezza in big endian, 1 byte di flag = 6 byte, costruiti in un uint8_t buf[6] con htonl o shift.
  • read + printf("%s"): manca il '\0', read può dare meno byte o -1: salvare in ssize_t, controllare n < 0, terminare a mano, per dati binari fwrite(buf, 1, n, stdout).
  • Errori tipici: buffer non terminato passato a %s; read in un size_t; sizeof di un puntatore (dà 8, non la lunghezza); struct spedita con write; porta senza htons (8080 diventa la porta 36895); htons su testo o doppia conversione; shift di un uint8_t senza cast; restituire un array locale.

Esercizi su questo argomento

Teoria collegata