c - Qual è la regola rigorosa di aliasing?


5 Answers

La migliore spiegazione che ho trovato è di Mike Acton, Understanding Aliasing . Si concentra un pò sullo sviluppo di PS3, ma fondamentalmente è solo GCC.

Dall'articolo:

"Aliasing rigoroso è un'ipotesi, fatta dal compilatore C (o C ++), che i riferimenti di dereferenziazione a oggetti di tipi diversi non si riferiranno mai alla stessa locazione di memoria (cioè alias l'un l'altro)."

Quindi, in pratica se hai un int* punta a qualche memoria contenente un int e poi punti un float* a quella memoria e lo usi come float infrangi la regola. Se il tuo codice non rispetta questo, l'ottimizzatore del compilatore molto probabilmente romperà il tuo codice.

L'eccezione alla regola è un char* , che può indicare qualsiasi tipo.

Question

Quando si fa una domanda sul comportamento non definito comune in C , le anime sono più illuminate di quanto facesse riferimento alla rigida regola di aliasing.
Di cosa stanno parlando?




Aliasing rigoroso non si riferisce solo ai puntatori, influisce anche sui riferimenti, ne ho scritto uno per il wiki dello sviluppatore boost ed è stato così ben accolto che l'ho trasformato in una pagina del mio sito di consulenza. Spiega completamente di cosa si tratta, perché confonde così tanto le persone e cosa fare al riguardo. Libro bianco aliasing rigoroso . In particolare, spiega perché i sindacati sono un comportamento rischioso per C ++ e perché l'utilizzo di memcpy è l'unica soluzione portatile su C e C ++. Spero che questo sia utile.




Nota

Questo è tratto dal mio "Qual è la regola di aliasing severo e perché ci importa?" Scrivilo.

Cos'è il rigoroso aliasing?

In alias C e C ++ ha a che fare con quali tipi di espressioni siamo autorizzati ad accedere ai valori memorizzati attraverso. In entrambi C e C ++ lo standard specifica quali tipi di espressioni sono autorizzati ad alias di quali tipi. Il compilatore e l'ottimizzatore possono assumere che seguiamo rigorosamente le regole di aliasing, quindi il termine regola di aliasing rigoroso . Se tentiamo di accedere a un valore utilizzando un tipo non consentito, è classificato come comportamento non definito ( UB ). Una volta che abbiamo un comportamento indefinito, tutte le scommesse sono state annullate, i risultati del nostro programma non sono più affidabili.

Sfortunatamente con violazioni rigorose di aliasing, otterremo spesso i risultati che ci aspettiamo, lasciando la possibilità che una versione futura di un compilatore con una nuova ottimizzazione rompa il codice che pensavamo fosse valido. Questo non è auspicabile ed è un obiettivo utile per comprendere le rigide regole di aliasing e come evitare di violarle.

Per comprendere meglio il motivo per cui ci preoccupiamo, discuteremo i problemi che emergono quando violiamo le rigide regole di aliasing, il tipo puning, dal momento che le tecniche comuni usate nel puning di tipo spesso violano le rigide regole di aliasing e come digitare correttamente il pun.

Esempi preliminari

Diamo un'occhiata ad alcuni esempi, quindi possiamo parlare esattamente di ciò che dicono gli standard, esaminare alcuni ulteriori esempi e poi vedere come evitare il rigoroso aliasing e catturare le violazioni che abbiamo perso. Ecco un esempio che non dovrebbe sorprendere ( esempio dal vivo ):

int x = 10;
int *ip = &x;

std::cout << *ip << "\n";
*ip = 12;
std::cout << x << "\n";

Abbiamo un int * che punta alla memoria occupata da un int e questo è un aliasing valido. L'ottimizzatore deve presupporre che le assegnazioni tramite ip possano aggiornare il valore occupato da x .

Il prossimo esempio mostra l'aliasing che porta al comportamento non definito ( esempio dal vivo ):

int foo( float *f, int *i ) { 
    *i = 1;               
    *f = 0.f;            

   return *i;
}

int main() {
    int x = 0;

    std::cout << x << "\n";   // Expect 0
    x = foo(reinterpret_cast<float*>(&x), &x);
    std::cout << x << "\n";   // Expect 0?
}

Nella funzione foo prendiamo un int * e un float * , in questo esempio chiamiamo foo e impostiamo entrambi i parametri in modo che puntino alla stessa locazione di memoria che in questo esempio contiene un int . Nota: reinterpret_cast dice al compilatore di trattare l'espressione come se avesse il tipo specificato dal suo parametro template. In questo caso, stiamo dicendo di trattare l'espressione & x come se fosse di tipo float * . Possiamo ingenuamente aspettarci che il risultato del secondo cout sia 0 ma con l'ottimizzazione abilitata usando -O2 sia gcc che clang producono il seguente risultato:

0
1

Quale non può essere previsto ma è perfettamente valido poiché abbiamo invocato il comportamento non definito. Un float non può validamente alias un oggetto int . Pertanto, l'ottimizzatore può assumere la costante 1 memorizzata quando il dereferenziamento i sarà il valore di ritorno poiché un archivio attraverso f non potrebbe influenzare validamente un oggetto int . Collegare il codice in Compiler Explorer mostra che questo è esattamente ciò che sta accadendo ( esempio dal vivo ):

foo(float*, int*): # @foo(float*, int*)
mov dword ptr [rsi], 1  
mov dword ptr [rdi], 0
mov eax, 1                       
ret

L'ottimizzatore che utilizza l' analisi alias basata sui tipi (TBAA) presuppone che venga restituito 1 e sposta direttamente il valore costante nel registro eax che trasporta il valore restituito. TBAA utilizza le regole delle lingue su quali tipi sono consentiti per l'alias per ottimizzare carichi e negozi. In questo caso, TBAA sa che un float non può alias e int e ottimizza il carico di i .

Ora, al libro delle regole

Cosa dice esattamente lo standard che siamo autorizzati e non autorizzati a fare? La lingua standard non è semplice, quindi per ciascun elemento cercherò di fornire esempi di codice che ne dimostrino il significato.

Cosa dice lo standard C11?

Lo standard C11 dice quanto segue nella sezione 6.5 Espressioni paragrafo 7 :

Un oggetto deve avere il suo valore memorizzato accessibile solo da un'espressione lvalue che ha uno dei seguenti tipi: 88) - un tipo compatibile con il tipo effettivo dell'oggetto,

int x = 1;
int *p = &x;   
printf("%d\n", *p); // *p gives us an lvalue expression of type int which is compatible with int

- una versione qualificata di un tipo compatibile con il tipo effettivo dell'oggetto,

int x = 1;
const int *p = &x;
printf("%d\n", *p); // *p gives us an lvalue expression of type const int which is compatible with int

- un tipo che è il tipo firmato o senza segno corrispondente al tipo effettivo dell'oggetto,

int x = 1;
unsigned int *p = (unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type unsigned int which corresponds to 
                     // the effective type of the object

gcc / clang ha un'estensione e also permette di assegnare int * unsigned * a int * anche se non sono tipi compatibili.

- un tipo che è il tipo firmato o senza segno corrispondente a una versione qualificata del tipo effettivo dell'oggetto,

int x = 1;
const unsigned int *p = (const unsigned int*)&x;
printf("%u\n", *p ); // *p gives us an lvalue expression of type const unsigned int which is a unsigned type 
                     // that corresponds with to a qualified verison of the effective type of the object

- un tipo aggregato o sindacale che includa uno dei tipi sopra menzionati tra i suoi membri (incluso, in modo ricorsivo, un membro di un'unione subaggregata o contenuta), o

struct foo {
  int x;
};

void foobar( struct foo *fp, int *ip );  // struct foo is an aggregate that includes int among its members so it can
                                         // can alias with *ip

foo f;
foobar( &f, &f.x );

- un tipo di carattere.

int x = 65;
char *p = (char *)&x;
printf("%c\n", *p );  // *p gives us an lvalue expression of type char which is a character type.
                      // The results are not portable due to endianness issues.

Cosa dice la bozza di C ++ 17

Lo standard di bozza C ++ 17 nella sezione [basic.lval] paragrafo 11 dice:

Se un programma tenta di accedere al valore memorizzato di un oggetto tramite un glValue diverso da uno dei seguenti tipi, il comportamento non è definito: 63 (11.1) - il tipo dinamico dell'oggetto,

void *p = malloc( sizeof(int) ); // We have allocated storage but not started the lifetime of an object
int *ip = new (p) int{0};        // Placement new changes the dynamic type of the object to int
std::cout << *ip << "\n";        // *ip gives us a glvalue expression of type int which matches the dynamic type 
                                  // of the allocated object

(11.2) - una versione qualificata CV del tipo dinamico dell'oggetto,

int x = 1;
const int *cip = &x;
std::cout << *cip << "\n";  // *cip gives us a glvalue expression of type const int which is a cv-qualified 
                            // version of the dynamic type of x

(11.3) - un tipo simile (come definito in 7.5) al tipo dinamico dell'oggetto,

(11.4) - un tipo che è il tipo firmato o senza segno corrispondente al tipo dinamico dell'oggetto,

// Both si and ui are signed or unsigned types corresponding to each others dynamic types
// We can see from this godbolt(https://godbolt.org/g/KowGXB) the optimizer assumes aliasing.
signed int foo( signed int &si, unsigned int &ui ) {
  si = 1;
  ui = 2;

  return si;
}

(11.5) - un tipo che è il tipo firmato o senza segno corrispondente ad una versione qualificata cv del tipo dinamico dell'oggetto,

signed int foo( const signed int &si1, int &si2); // Hard to show this one assumes aliasing

(11.6) - un tipo aggregato o sindacale che include uno dei tipi sopra menzionati tra i suoi elementi o membri di dati non statici (incluso, in modo ricorsivo, un elemento o un membro di dati non statici di una unione parziale o contenuta),

struct foo {
 int x;
};

// Compiler Explorer example(https://godbolt.org/g/z2wJTC) shows aliasing assumption
int foobar( foo &fp, int &ip ) {
 fp.x = 1;
 ip = 2;

 return fp.x;
}

foo f; 
foobar( f, f.x ); 

(11.7) - un tipo che è un tipo di classe base (possibilmente qualificato cv) del tipo dinamico dell'oggetto,

struct foo { int x ; };

struct bar : public foo {};

int foobar( foo &f, bar &b ) {
  f.x = 1;
  b.x = 2;

  return f.x;
}

(11.8) - un carattere char, unsigned char o std :: byte.

int foo( std::byte &b, uint32_t &ui ) {
  b = static_cast<std::byte>('a');
  ui = 0xFFFFFFFF;                   

  return std::to_integer<int>( b );  // b gives us a glvalue expression of type std::byte which can alias
                                     // an object of type uint32_t
}

Degno di nota il char firmato non è incluso nella lista sopra, questa è una differenza notevole da C che dice un tipo di carattere .

Che cos'è Type Punning

Siamo arrivati ​​a questo punto e potremmo chiederci, perché vorremmo alias per? La risposta in genere consiste nel digitare il gioco di parole , spesso i metodi utilizzati violano le rigide regole di aliasing.

A volte vogliamo aggirare il sistema dei tipi e interpretare un oggetto come un tipo diverso. Questo è chiamato tipo punning , per reinterpretare un segmento di memoria come un altro tipo. Digitare la punteggiatura è utile per le attività che desiderano accedere alla rappresentazione sottostante di un oggetto da visualizzare, trasportare o manipolare. Le aree tipiche in cui viene utilizzato il tipo di punteggiatura sono compilatori, serializzazione, codice di rete, ecc ...

Tradizionalmente questo è stato ottenuto prendendo l'indirizzo dell'oggetto, gettandolo su un puntatore del tipo che vogliamo reinterpretarlo come e quindi accedendo al valore, o in altre parole tramite aliasing. Per esempio:

int x =  1 ;

// In C
float *fp = (float*)&x ;  // Not a valid aliasing

// In C++
float *fp = reinterpret_cast<float*>(&x) ;  // Not a valid aliasing

printf( "%f\n", *fp ) ;

Come abbiamo visto prima, questo non è un aliasing valido, quindi stiamo invocando un comportamento indefinito. Ma tradizionalmente i compilatori non si sono avvantaggiati delle rigide regole di aliasing e questo tipo di codice di solito ha funzionato, gli sviluppatori si sono purtroppo abituati a fare le cose in questo modo. Un metodo alternativo comune per la punteggiatura di tipo è attraverso i sindacati, che è valido in C ma il comportamento non definito in C ++ ( vedi esempio dal vivo ):

union u1
{
  int n;
  float f;
} ;

union u1 u;
u.f = 1.0f;

printf( "%d\n”, u.n );  // UB in C++ n is not the active member

Questo non è valido in C ++ e alcuni considerano lo scopo dei sindacati essere esclusivamente per l'implementazione di tipi di varianti e sentire l'uso di unioni per punire il tipo è un abuso.

Come digitiamo Pun correttamente?

Il metodo standard per la punteggiatura di tipo in C e C ++ è memcpy . Questo può sembrare un po 'pesante, ma l'ottimizzatore dovrebbe riconoscere l'uso di memcpy per il tipo punning e ottimizzarlo e generare un registro per registrare lo spostamento. Ad esempio, se sappiamo che int64_t ha la stessa dimensione del doppio :

static_assert( sizeof( double ) == sizeof( int64_t ) );  // C++17 does not require a message

possiamo usare memcpy :

void func1( double d ) {
  std::int64_t n;
  std::memcpy(&n, &d, sizeof d); 
  //...

Ad un livello di ottimizzazione sufficiente, qualsiasi compilatore moderno decente genera codice identico al metodo reinterpret_cast o al metodo unione per il tipo punning . Esaminando il codice generato vediamo che usa solo registrare mov ( live Compiler Explorer Example ).

C ++ 20 e bit_cast

In C ++ 20 potremmo ottenere bit_cast ( implementazione disponibile nel link dalla proposta ) che offre un modo semplice e sicuro di digitare-pun oltre che essere utilizzabile in un contesto di constexpr.

Di seguito è riportato un esempio di come utilizzare bit_cast per digitare pun a unsigned int come float , ( vederlo dal vivo ):

std::cout << bit_cast<float>(0x447a0000) << "\n" ; //assuming sizeof(float) == sizeof(unsigned int)

Nel caso in cui i tipi da e verso non abbiano le stesse dimensioni, ci richiede di utilizzare una struct15 intermedia. Useremo una struct contenente una matrice di caratteri sizeof (unsigned int) ( presuppone 4 byte unsigned int ) come tipo From e unsigned int come il tipo To .:

struct uint_chars {
 unsigned char arr[sizeof( unsigned int )] = {} ;  // Assume sizeof( unsigned int ) == 4
};

// Assume len is a multiple of 4 
int bar( unsigned char *p, size_t len ) {
 int result = 0;

 for( size_t index = 0; index < len; index += sizeof(unsigned int) ) {
   uint_chars f;
   std::memcpy( f.arr, &p[index], sizeof(unsigned int));
   unsigned int result = bit_cast<unsigned int>(f);

   result += foo( result );
 }

 return result ;
}

È un peccato che abbiamo bisogno di questo tipo intermedio, ma questo è il vincolo attuale di bit_cast .

Cattura violazioni di aliasing severe

Non abbiamo molti buoni strumenti per catturare il rigoroso aliasing in C ++, gli strumenti che abbiamo cattureranno alcuni casi di violazioni di aliasing e alcuni casi di carichi e negozi disallineati.

gcc usando il flag -fstrict-aliasing e -Wosted-aliasing può catturare alcuni casi anche se non senza falsi positivi / negativi. Ad esempio i seguenti casi genereranno un avviso in gcc (guardalo dal vivo ):

int a = 1;
short j;
float f = 1.f; // Originally not initialized but tis-kernel caught 
               // it was being accessed w/ an indeterminate value below

printf("%i\n", j = *(reinterpret_cast<short*>(&a)));
printf("%i\n", j = *(reinterpret_cast<int*>(&f)));

anche se non prenderà questo caso aggiuntivo ( vederlo dal vivo ):

int *p;

p=&a;
printf("%i\n", j = *(reinterpret_cast<short*>(p)));

Sebbene clang permetta a questi flag, apparentemente non implementa effettivamente gli avvertimenti.

Un altro strumento che abbiamo a nostra disposizione è ASan che può catturare carichi e negozi disallineati.Sebbene non si tratti di violazioni aliasing strettamente rigide, esse sono un risultato comune delle violazioni di aliasing. Ad esempio i seguenti casi genereranno errori di runtime se costruiti con clang usando -fsanitize = indirizzo

int *x = new int[2];               // 8 bytes: [0,7].
int *u = (int*)((char*)x + 6);     // regardless of alignment of x this will not be an aligned address
*u = 1;                            // Access to range [6-9]
printf( "%d\n", *u );              // Access to range [6-9]

L'ultimo strumento che raccomanderò è specifico per C ++ e non è strettamente uno strumento, ma una pratica di codifica, non permettere lanci in stile C. Sia gcc che clang produrranno una diagnostica per i cast in stile C usando -Wold-style-cast . Ciò importerà l'uso di reinterpret_cast per qualsiasi tipo di testo non definito, in generale reinterpret_cast dovrebbe essere un flag per una revisione più ravvicinata del codice. È anche più facile cercare nella tua base di codice reinterpret_cast per eseguire un controllo.

Per C abbiamo tutti gli strumenti già trattati e abbiamo anche TIS-interpreter, un analizzatore statico che analizza esaurientemente un programma per un ampio sottogruppo del linguaggio C. Dato un C verions dell'esempio precedente in cui l'uso di -fstrict-aliasing manca un caso ( vederlo dal vivo )

int a = 1;
short j;
float f = 1.0 ;

printf("%i\n", j = *((short*)&a));
printf("%i\n", j = *((int*)&f));

int *p; 

p=&a;
printf("%i\n", j = *((short*)p));

tis-interpeter è in grado di catturare tutti e tre, l'esempio seguente richiama tis-kernal come tis-interpreter (l'output è modificato per brevità):

./bin/tis-kernel -sa example1.c 
...
example1.c:9:[sa] warning: The pointer (short *)(& a) has type short *. It violates strict aliasing
              rules by accessing a cell with effective type int.
...

example1.c:10:[sa] warning: The pointer (int *)(& f) has type int *. It violates strict aliasing rules by
              accessing a cell with effective type float.
              Callstack: main
...

example1.c:15:[sa] warning: The pointer (short *)p has type short *. It violates strict aliasing rules by
              accessing a cell with effective type int.

Infine c'è TySan che è attualmente in sviluppo. Questo disinfettante aggiunge informazioni di controllo del tipo in un segmento di memoria shadow e controlla gli accessi per vedere se violano le regole di aliasing. Lo strumento potenzialmente dovrebbe essere in grado di catturare tutte le violazioni di aliasing ma potrebbe avere un sovraccarico di runtime di grandi dimensioni.




Secondo la logica C89, gli autori dello Standard non volevano richiedere che i compilatori fornissero il codice come:

int x;
int test(double *p)
{
  x=5;
  *p = 1.0;
  return x;
}

dovrebbe essere richiesto di ricaricare il valore xtra l'assegnazione e l'estratto conto in modo da consentire la possibilità che ppotrebbe indicare x, e l'assegnazione a *ppotrebbe di conseguenza alterare il valore di x. L'idea che un compilatore debba avere il diritto di presumere che non ci sarà aliasing in situazioni come quelle sopra non è stata controverso.

Sfortunatamente, gli autori della C89 hanno scritto la loro regola in un modo che, se letto letteralmente, renderebbe anche la seguente funzione invocare il comportamento non definito:

void test(void)
{
  struct S {int x;} s;
  s.x = 1;
}

perché usa un lvalue di tipo intper accedere a un oggetto di tipo struct S, e intnon è tra i tipi che possono essere utilizzati accedendo a struct S. Perché sarebbe assurdo trattare tutti i membri non-character-type di structs e unions come Undefined Behavior, quasi tutti riconoscono che ci sono almeno alcune circostanze in cui un lvalue di un tipo può essere usato per accedere ad un oggetto di un altro tipo . Sfortunatamente, il Comitato degli standard C non è riuscito a definire quali siano tali circostanze.

Gran parte del problema è il risultato di Defect Report # 028, che chiedeva il comportamento di un programma come:

int test(int *ip, double *dp)
{
  *ip = 1;
  *dp = 1.23;
  return *ip;
}
int test2(void)
{
  union U { int i; double d; } u;
  return test(&u.i, &u.d);
}

Il rapporto 28 del difetto afferma che il programma richiama il comportamento non definito perché l'azione di scrivere un membro dell'unione di tipo "double" e di leggere uno di tipo "int" richiama il comportamento definito dall'implementazione. Tale ragionamento è privo di senso, ma costituisce la base per le regole del tipo efficace che complicano inutilmente la lingua senza fare nulla per affrontare il problema originale.

Il modo migliore per risolvere il problema originale sarebbe probabilmente quello di trattare la nota a piè di pagina sullo scopo della regola come se fosse normativa, e ha reso la regola inapplicabile, tranne nei casi che implicano effettivamente accessi in conflitto usando alias. Dato qualcosa come:

 void inc_int(int *p) { *p = 3; }
 int test(void)
 {
   int *p;
   struct S { int x; } s;
   s.x = 1;
   p = &s.x;
   inc_int(p);
   return s.x;
 }

Non c'è conflitto all'interno inc_intperché tutti gli accessi allo spazio di archiviazione a cui si accede *psono fatti con un valore di tipo int, e non c'è conflitto testperché pè visibilmente derivato da a struct S, e per la prossima volta s, tutti gli accessi a quell'archivio che sarà mai fatto attraverso psarà già successo.

Se il codice è stato leggermente modificato ...

 void inc_int(int *p) { *p = 3; }
 int test(void)
 {
   int *p;
   struct S { int x; } s;
   p = &s.x;
   s.x = 1;  //  !!*!!
   *p += 1;
   return s.x;
 }

Qui, c'è un conflitto di alias tra pe l'accesso alla sxlinea segnata perché a quel punto in esecuzione esiste un altro riferimento che verrà utilizzato per accedere alla stessa memoria .

Se il rapporto sui difetti avessero avuto valore 028, l'esempio originale invocato UB a causa della sovrapposizione tra la creazione e l'uso dei due puntatori, avrebbe reso le cose molto più chiare senza dover aggiungere "Tipi efficaci" o altre complessità simili.




Aliasing rigoroso non consente tipi di puntatori diversi agli stessi dati.

Questo articolo dovrebbe aiutarti a comprendere il problema in dettaglio.




Related