Skip to content

Comportamento non definito in C. Regola di aliasing rigorosa o allineamento errato?

Esaminiamo attentamente ogni recensione nel nostro spazio con l'obiettivo di mostrarti informazioni accurate e aggiornate in ogni momento.

Soluzione:

Il codice infrange effettivamente la regola dell'aliasing stretto. Tuttavia, c'è non solo una violazione dell'aliasing, e il codice non si blocca a causa della violazione dell'aliasing.. Succede perché il file unsigned short è allineato in modo errato; anche il puntatore conversione del puntatore è indefinita se il risultato non è adeguatamente allineato.

C11 (bozza n1570) Appendice J.2:

1 Il comportamento è indefinito nelle seguenti circostanze:

....

  • La conversione tra due tipi di puntatore produce un risultato non allineato correttamente (6.3.2.3).

Con 6.3.2.3p7 che dice

[...] Se il puntatore risultante non è allineato correttamente [68] per il tipo di riferimento, il comportamento è indefinito. [...]

unsigned short ha un requisito di allineamento di 2 sull'implementazione (x86-32 e x86-64), che può essere testata con

_Static_assert(_Alignof(unsigned short) == 2, "alignof(unsigned short) == 2");

Tuttavia, si sta forzando l'opzione u16 *key2 a puntare a un indirizzo non allineato:

u16 *key2 = (u16 *) (keyc + 1);  // we've already got undefined behaviour *here*!

Ci sono innumerevoli programmatori che insistono sul fatto che l'accesso non allineato è garantito per funzionare in pratica su x86-32 e x86-64 ovunque, e che non ci sarebbe alcun problema nella pratica - beh, si sbagliano tutti.

Fondamentalmente ciò che accade è che il compilatore si accorge che

for (size_t i = 0; i < len; ++i)
     hash += key2[i];

può essere eseguito in modo più efficiente utilizzando le istruzioni SIMD se opportunamente allineate. I valori vengono caricati nei registri SSE usando MOVDQAche richiede che l'argomento sia allineato a 16 byte:

Quando l'operando di origine o di destinazione è un operando di memoria, l'operando deve essere allineato su un confine di 16 byte, altrimenti verrà generata un'eccezione di protezione generale (#GP).

Nel caso in cui il puntatore non sia adeguatamente allineato all'inizio, il compilatore genera un codice che somma i primi 1-7 corti senza segno uno per uno, finché il puntatore non è allineato a 16 byte.

Naturalmente, se si inizia con un puntatore che punta a un file dispari nemmeno aggiungendo 7 volte 2 si arriva a un indirizzo allineato a 16 byte. Naturalmente il compilatore non genererà nemmeno codice che rilevi questo caso, poiché "il comportamento è indefinito, se la conversione tra due tipi di puntatore produce un risultato non allineato correttamente" - e ignora completamente la situazione con risultati imprevedibili, che qui significa che l'operando a MOVDQA non sarà allineato correttamente, il che comporterà l'arresto del programma.


Si può facilmente dimostrare che ciò può accadere anche senza violare alcuna regola di aliasing. Si consideri il seguente programma che consiste in 2 unità di traduzione (se entrambe f e il suo chiamante sono collocati in una unità di traduzione, il mio GCC è abbastanza intelligente da notare che siamo utilizzando una struttura impacchettata e non genera codice con MOVDQA):

unità di traduzione 1:

#include 
#include 

size_t f(uint16_t *keyc, size_t len)
{
    size_t hash = len;
    len = len / 2;

    for (size_t i = 0; i < len; ++i)
        hash += keyc[i];
    return hash;
}

unità di traduzione 2

#include 
#include 
#include 
#include 
#include 

size_t f(uint16_t *keyc, size_t len);

struct mystruct {
    uint8_t padding;
    uint16_t contents[100];
} __attribute__ ((packed));

int main(void)
{
    struct mystruct s;
    size_t len;

    srand(time(NULL));
    scanf("%zu", &len);

    char *initializer = (char *)s.contents;
    for (size_t i = 0; i < len; i++)
       initializer[i] = rand();

    printf("out %zun", f(s.contents, len));
}

Ora compilateli e collegateli insieme:

% gcc -O3 unit1.c unit2.c
% ./a.out
25
zsh: segmentation fault (core dumped)  ./a.out

Si noti che non c'è alcuna violazione dell'aliasing. L'unico problema è l'unità di traduzione non allineata uint16_t *keyc.

con -fsanitize=undefined viene prodotto il seguente errore:

unit1.c:10:21: runtime error: load of misaligned address 0x7ffefc2d54f1 for type 'uint16_t', which requires 2 byte alignment
0x7ffefc2d54f1: note: pointer points here
 00 00 00  01 4e 02 c4 e9 dd b9 00  83 d9 1f 35 0e 46 0f 59  85 9b a4 d7 26 95 94 06  15 bb ca b3 c7
              ^ 

È lecito associare un puntatore a un oggetto a un puntatore a un carattere, e poi iterare tutti i byte dell'oggetto originale.

Quando un puntatore a char punta effettivamente a un oggetto (è stato ottenuto con un'operazione precedente), è legale convertirlo nuovamente in un puntatore al tipo originale e lo standard richiede che venga restituito il valore originale.

Ma la conversione di un puntatore arbitrario a un char in un puntatore a un oggetto e la dereferenziazione del puntatore ottenuto viola la regola dell'aliasing stretto e invoca un comportamento non definito.

Quindi, nel vostro codice, la riga seguente è UB:

const u16 *key2 = (const u16 *) (keyc + 1); 
// keyc + 1 did not originally pointed to a u16: UB

Per fornire ulteriori informazioni e insidie comuni all'eccellente risposta di @Antti Haapala:

TLDR: L'accesso ai dati non allineati è un comportamento non definito (UB) in C/C++. I dati non allineati sono dati a un indirizzo (alias valore del puntatore) che non è uniformemente divisibile per il suo allineamento (che di solito è la sua dimensione). In (pseudo-)codice: bool isAligned(T* ptr){ return (ptr % alignof(T)) == 0; }

Questo problema si presenta spesso durante l'analisi di formati di file o di dati inviati in rete: Si ha una struttura densamente impacchettata di diversi tipi di dati. Un esempio potrebbe essere un protocollo come questo: struct Packet{ uint16_t len; int32_t data[]; }; (letto come: una lunghezza di 16 bit seguita da len per un int di 32 bit come valore). Ora si potrebbe fare:

char* raw = receiveData();
int32_t sum = 0;
uint16_t len = *((uint16_t*)raw);
int32_t* data = (int32_t*)(raw2 + 2);
for(size_t i=0; i

Questo non funziona! Se si assume che raw sia allineato (nella propria mente si potrebbe impostare raw = 0 che è allineato a qualsiasi dimensione come 0 % n == 0 per tutti n) allora data non può essere allineato (assumendo che allineamento == dimensione del tipo): len è all'indirizzo 0, quindi data è all'indirizzo 2 e 2 % 4 != 0. Ma il cast dice al compilatore "Questi dati sono allineati correttamente" ("... perché altrimenti sono UB e noi non ci imbattiamo mai in UB"). Quindi durante l'ottimizzazione il compilatore userà le istruzioni SIMD/SSE per calcolare più velocemente la somma e queste si bloccano quando vengono forniti dati non allineati.
Nota a margine: esistono istruzioni SSE non allineate, ma sono più lente e poiché il compilatore assume l'allineamento promesso, non vengono utilizzate in questo caso.

Potete vederlo nell'esempio di @Antti Haapala che ho accorciato e messo su godbolt per farvi giocare: https://godbolt.org/z/KOfi6V. Osservate il "program returned: 255", alias "crashed".

Questo problema è piuttosto comune anche nelle routine di deserializzazione, che hanno questo aspetto:

char* raw = receiveData();
int32_t foo = readInt(raw); raw+=4;
bool foo = readBool(raw); raw+=1;
int16_t foo = readShort(raw); raw+=2;
...

Il read* si occupa dell'endianess ed è spesso implementato in questo modo:

int32_t readInt(char* ptr){
  int32_t result = *((int32_t*) ptr);
  #if BIG_ENDIAN
  result = byteswap(result);
  #endif
}

Si noti come questo codice dereferenzi un puntatore che puntava a un tipo più piccolo che potrebbe avere un allineamento diverso e si incorre esattamente in questo problema.

Questo problema è così comune che persino Boost ne ha sofferto per molte versioni. Esiste Boost.Endian che fornisce tipi endian semplici. Il codice C di godbolt può essere facilmente scritto in questo modo:

#include 
#include 

__attribute__ ((noinline)) size_t f(boost::endian::little_uint16_t *keyc, size_t len)
{
    size_t hash = 0;
    for (size_t i = 0; i < len; ++i)
        hash += keyc[i];
    return hash;
}

struct mystruct {
    uint8_t padding;
    boost::endian::little_uint16_t contents[100];
};

int main(int argc, char** argv)
{
    mystruct s;
    size_t len = argc*25;

    for (size_t i = 0; i < len; i++)
       s.contents[i] = i * argc;

    return f(s.contents, len) != 300;
}

Il tipo little_uint16_t è fondamentalmente solo qualche carattere con una conversione implicita da/a uint16_t con un byteswap se l'endianess della macchina corrente è BIG_ENDIAN. Sotto il cofano il codice usato da Boost:endian era simile a questo:

class little_uint16_t{
  char buffer[2];
  uint16_t value(){
    #if IS_x86
      uint16_t value = *reinterpret_cast(buffer);
    #else
    ...
    #endif
    #if BIG_ENDIAN
    swapbytes(value);
    #endif
    return value;
};

Utilizzava la conoscenza del fatto che sulle architetture x86 l'accesso non allineato è possibile. Il caricamento da un indirizzo non allineato era solo un po' più lento, ma anche a livello di assemblatore era uguale al caricamento da un indirizzo allineato.

Tuttavia "possibile" non significa valido. Se il compilatore ha sostituito il caricamento "standard" con un'istruzione SSE, allora questo fallisce, come si può vedere su godbolt. Questo è passato inosservato per molto tempo perché le istruzioni SSE vengono utilizzate solo quando si elaborano grandi quantità di dati con la stessa operazione, ad esempio aggiungendo un array di valori, che è quello che ho fatto per questo esempio. Questo problema è stato risolto in Boost 1.69 utilizzando l'istruzione memcopy che può essere tradotta in un'istruzione di caricamento "standard" in ASM che supporta i dati allineati e non allineati su x86, quindi non c'è alcun rallentamento rispetto alla versione cast. Ma non può essere tradotta in istruzioni SSE allineate senza ulteriori controlli.

Via: Non usate scorciatoie con i cast. Diffidate di ogni soprattutto quando si esegue il casting da un tipo più piccolo e verificare che l'allineamento non sia errato o utilizzare la memcpy sicura.

Hai la possibilità di diffondere questa recensione se ne è valsa la pena.



Utilizzate il nostro motore di ricerca

Ricerca
Generic filters

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.