Overflow firmato in C ++ e comportamento indefinito (UB)


56

Mi chiedo l'uso del codice come il seguente

int result = 0;
int factor = 1;
for (...) {
    result = ...
    factor *= 10;
}
return result;

Se il ciclo viene ripetuto nel ntempo, factorviene moltiplicato per i tempi 10esatti n. Tuttavia, factorviene sempre utilizzato solo dopo essere stato moltiplicato per 10un totale di n-1volte. Se assumiamo che factornon trabocchi mai eccetto che nell'ultima iterazione del ciclo, ma che potrebbe traboccare nell'ultima iterazione del ciclo, allora tale codice dovrebbe essere accettabile? In questo caso, il valore di non factorverrebbe mai utilizzato dopo l'overflow.

Sto discutendo sull'opportunità di accettare codici come questo. Sarebbe possibile inserire la moltiplicazione all'interno di un'istruzione if e semplicemente non fare la moltiplicazione sull'ultima iterazione del ciclo quando può traboccare. Il rovescio della medaglia è che ingombra il codice e aggiunge un ramo non necessario che dovrebbe controllare su tutte le iterazioni del ciclo precedente. Potrei anche ripetere l'iterazione del ciclo meno volte e replicare il corpo del ciclo una volta dopo il ciclo, ancora una volta, questo complica il codice.

Il codice effettivo in questione viene utilizzato in un stretto circuito interno che consuma una grande parte del tempo totale della CPU in un'applicazione grafica in tempo reale.


5
Sto votando per chiudere questa domanda come fuori tema perché questa domanda dovrebbe essere su codereview.stackexchange.com non qui.
Kevin Anderson,

31
@KevinAnderson, no è valido qui, poiché il codice di esempio deve essere corretto, non semplicemente migliorato.
Bathsheba,

1
@harold Sono molto vicini.
Corse di leggerezza in orbita,

1
@LightnessRaceswithMonica: gli autori dello standard intendevano e si aspettavano che le implementazioni destinate a varie piattaforme e scopi estendessero la semantica disponibile ai programmatori elaborando in modo significativo varie azioni in modi utili per tali piattaforme e scopi indipendentemente dal fatto che lo standard lo richiedesse o meno, e ha anche dichiarato di non voler sminuire il codice non portatile. Pertanto, la somiglianza tra le domande dipende da quali implementazioni devono essere supportate.
supercat

2
@supercat Per comportamenti definiti dall'implementazione sicuramente, e se sai che la tua toolchain ha qualche estensione che puoi usare (e non ti importa della portabilità), va bene. Per UB? Dubbioso.
Corse di leggerezza in orbita dal

Risposte:


51

I compilatori presumono che un programma C ++ valido non contenga UB. Considera ad esempio:

if (x == nullptr) {
    *x = 3;
} else {
    *x = 5;
}

Se x == nullptrquindi dereferenziandolo e assegnando un valore è UB. Quindi l'unico modo in cui questo potrebbe finire in un programma valido è quando x == nullptrnon produrrà mai true e il compilatore può assumere in base alla regola if if, quanto sopra equivale a:

*x = 5;

Ora nel tuo codice

int result = 0;
int factor = 1;
for (...) {      // Loop until factor overflows but not more
   result = ...
   factor *= 10;
}
return result;

L'ultima moltiplicazione di factornon può avvenire in un programma valido (l'overflow firmato non è definito). Quindi anche l'incarico resultnon può avvenire. Poiché non è possibile diramare prima dell'ultima iterazione, anche l'iterazione precedente non può avvenire. Alla fine, la parte del codice che è corretta (cioè, non accade mai un comportamento indefinito) è:

// nothing :(

6
"Undefined Behaviour" è un'espressione che sentiamo molto nelle risposte SO senza spiegare chiaramente come può influenzare un programma nel suo insieme. Questa risposta rende le cose molto più chiare.
Gilles-Philippe Paillé,

1
E questa potrebbe anche essere una "utile ottimizzazione" se la funzione viene chiamata solo su target INT_MAX >= 10000000000con una funzione diversa chiamata nel caso in cui INT_MAXsia più piccola.
R .. GitHub smette di aiutare ICE il

2
@ Gilles-PhilippePaillé Ci sono momenti in cui vorrei poter scrivere un post su questo. Benign Data Races è uno dei miei preferiti per catturare quanto possano essere cattivi. C'è anche un ottimo rapporto sui bug in MySQL che non riesco a trovare di nuovo: un controllo di overflow del buffer che ha invocato accidentalmente UB. Una versione particolare di un particolare compilatore presupponeva semplicemente che l'UB non si verificasse mai e ottimizzava l'intero controllo di overflow.
Cort Ammon,

1
@SolomonSlow: Le principali situazioni in cui UB è controversa sono quelle in cui parti dello Standard e la documentazione delle implementazioni descrivono il comportamento di alcune azioni, ma alcune altre parti dello Standard lo caratterizzano come UB. La pratica comune prima della stesura dello Standard era che gli autori di compilatori elaborassero tali azioni in modo significativo, tranne quando i loro clienti ne avrebbero beneficiato facendo qualcos'altro, e non credo che gli autori dello Standard abbiano mai immaginato che gli autori di compilatori avrebbero fatto volontariamente qualsiasi altra cosa .
supercat

2
@ Gilles-PhilippePaillé: anche ciò che ogni programmatore C dovrebbe sapere sul comportamento indefinito del blog LLVM è buono. Spiega come, ad esempio, l'overflow con numero intero con segno UB può consentire ai compilatori di dimostrare che i i <= nloop sono sempre non infiniti, come i i<nloop. E promuovi la int ilarghezza del puntatore in un ciclo invece di dover ripetere il segno per poter avvolgere l'indicizzazione dell'array con i primi elementi dell'array 4G.
Peter Cordes,

34

Il comportamento intdell'overflow non è definito.

Non importa se leggi factorfuori dal corpo del loop; se è stato traboccato da allora, allora il comportamento del codice su, dopo, e un po 'paradossalmente prima che l'overflow sia indefinito.

Un problema che potrebbe sorgere nel mantenere questo codice è che i compilatori stanno diventando sempre più aggressivi quando si tratta di ottimizzazione. In particolare stanno sviluppando un'abitudine in cui assumono che un comportamento indefinito non accada mai. Perché ciò avvenga, potrebbero rimuovere del fortutto il ciclo.

Non puoi usare un unsignedtipo per factoranche se allora dovresti preoccuparti della conversione indesiderata di intin unsignedin espressioni contenenti entrambi?


12
@nicomp; Perchè no?
Bathsheba,

12
@ Gilles-PhilippePaillé: la mia risposta non ti dice che è problematico? La mia frase di apertura non è necessariamente lì per l'OP, ma la comunità più ampia ed factorè "usata" nell'incarico di nuovo a se stessa.
Bathsheba,

8
@ Gilles-PhilippePaillé e questa risposta spiega perché è problematico
idclev 463035818

1
@Bathsheba Hai ragione, ho frainteso la tua risposta.
Gilles-Philippe Paillé,

4
Come esempio di comportamento indefinito, quando quel codice viene compilato con i controlli di runtime abilitati, terminerebbe invece di restituire un risultato. Il codice che mi richiede di disattivare le funzioni diagnostiche per funzionare è rotto.
Simon Richter,

23

Potrebbe essere perspicace considerare gli ottimizzatori del mondo reale. Lo srotolamento ad anello è una tecnica nota. L'idea base che sta svolgendo il ciclo è quella

for (int i = 0; i != 3; ++i)
    foo()

potrebbe essere meglio implementato dietro le quinte come

 foo()
 foo()
 foo()

Questo è il caso semplice, con un limite fisso. Ma i compilatori moderni possono farlo anche per limiti variabili:

for (int i = 0; i != N; ++i)
   foo();

diventa

__RELATIVE_JUMP(3-N)
foo();
foo();
foo();

Ovviamente questo funziona solo se il compilatore sa che N <= 3. Ed è qui che torniamo alla domanda originale. Poiché il compilatore sa che non si verifica un overflow con segno , sa che il ciclo può essere eseguito al massimo 9 volte su architetture a 32 bit. 10^10 > 2^32. Può quindi eseguire un ciclo di ripetizione di 9 iterazioni. Ma il massimo previsto era di 10 iterazioni! .

Ciò che potrebbe accadere è che si ottiene un salto relativo a un'istruzione di assemblaggio (9-N) con N = 10, quindi un offset di -1, che è l'istruzione di salto stessa. Ops. Questa è un'ottimizzazione del loop perfettamente valida per C ++ ben definito, ma l'esempio fornito si trasforma in un loop infinito stretto.


9

Qualsiasi overflow di numeri interi con segno comporta un comportamento indefinito, indipendentemente dal fatto che il valore di overflow sia o meno leggibile.

Forse nel tuo caso d'uso puoi sollevare la prima iterazione dal ciclo, trasformandola

int result = 0;
int factor = 1;
for (int n = 0; n < 10; ++n) {
    result += n + factor;
    factor *= 10;
}
// factor "is" 10^10 > INT_MAX, UB

in questo

int factor = 1;
int result = 0 + factor; // first iteration
for (int n = 1; n < 10; ++n) {
    factor *= 10;
    result += n + factor;
}
// factor is 10^9 < INT_MAX

Con l'ottimizzazione abilitata, il compilatore potrebbe srotolare il secondo loop sopra in un salto condizionale.


6
Questo può essere un po 'troppo tecnico, ma "l'overflow firmato è un comportamento indefinito" è troppo semplificato. Formalmente, il comportamento di un programma con overflow firmato non è definito. Cioè, lo standard non ti dice cosa fa quel programma. Non è solo che c'è qualcosa di sbagliato nel risultato che è traboccato; c'è qualcosa che non va nell'intero programma.
Pete Becker,

Buona osservazione, ho corretto la mia risposta.
elbrunovsky,

O più semplicemente, sbuccia l' ultima iterazione e rimuovi i mortifactor *= 10;
Peter Cordes,

9

Questo è UB; in termini ISO C ++ l'intero comportamento dell'intero programma è completamente non specificato per un'esecuzione che alla fine colpisce UB. L'esempio classico riguarda lo standard C ++, può far volare i demoni dal naso. (Consiglio di non usare un'implementazione in cui i demoni nasali sono una possibilità reale). Vedi altre risposte per maggiori dettagli.

I compilatori possono "causare problemi" in fase di compilazione per i percorsi di esecuzione che possono vedere portando a un UB visibile in fase di compilazione, ad esempio supponendo che i blocchi di base non vengano mai raggiunti.

Vedi anche quello che ogni programmatore C dovrebbe sapere sul comportamento indefinito (blog LLVM). Come spiegato qui, UB con overflow firmato consente ai compilatori di dimostrare che i for(... i <= n ...)loop non sono loop infiniti, anche per sconosciuti n. Inoltre, consente loro di "promuovere" i contatori int loop alla larghezza del puntatore anziché ripetere l'estensione del segno. (Quindi la conseguenza di UB in quel caso potrebbe essere l'accesso al di fuori degli elementi a 64k o 4G bassi di un array, se ti aspettavi un wrapping firmato inel suo intervallo di valori.)

In alcuni casi i compilatori emetteranno un'istruzione illegale come x86 ud2per un blocco che provoca UB in modo dimostrabile se mai eseguito. (Si noti che una funzione potrebbe non essere mai chiamata, quindi i compilatori non possono in generale impazzire e interrompere altre funzioni, o persino possibili percorsi attraverso una funzione che non colpisce UB. Vale a dire che il codice macchina che compila deve ancora funzionare per tutti gli input che non portano a UB.)


Probabilmente la soluzione più efficiente è quella di sbucciare manualmente l'ultima iterazione in modo factor*=10da evitare l'eventuale necessità .

int result = 0;
int factor = 1;
for (... i < n-1) {   // stop 1 iteration early
    result = ...
    factor *= 10;
}
 result = ...      // another copy of the loop body, using the last factor
 //   factor *= 10;    // and optimize away this dead operation.
return result;

O se il corpo del loop è grande, considera semplicemente l'uso di un tipo senza segno per factor. Quindi puoi lasciare che il segno senza segno si moltiplichi e si limiterà a eseguire il wrapping ben definito con una potenza di 2 (il numero di bit di valore nel tipo senza segno).

Questo va bene anche se lo usi con tipi firmati, specialmente se la conversione non firmata> firmata non trabocca mai.

La conversione tra il segno senza segno e il complemento di 2 firmato è gratuita (stesso schema di bit per tutti i valori); il modulo wrapping per int -> unsigned specificato dallo standard C ++ semplifica l'utilizzo dello stesso bit-pattern, a differenza del complemento o del segno / magnitudine.

E unsigned-> signed è altrettanto banale, sebbene sia definito dall'implementazione per valori maggiori di INT_MAX. Se non stai usando l'enorme risultato non firmato dell'ultima iterazione, non hai nulla di cui preoccuparti. Ma se lo sei, vedi La conversione da unsigned a Sign Undefined? . Il caso value-non-fit è definito dall'implementazione , il che significa che un'implementazione deve scegliere un comportamento; quelli sani semplicemente troncano (se necessario) il pattern di bit senza segno e lo usano come firmato, perché funziona allo stesso modo per i valori nell'intervallo senza alcun lavoro aggiuntivo. E sicuramente non è UB. Quindi i grandi valori senza segno possono diventare numeri interi con segno negativo. ad es. dopo int x = u; gcc e clang non ottimizzare viax>=0come sempre vero, anche senza -fwrapv, perché hanno definito il comportamento.


2
Non capisco il downvote qui. Per lo più volevo pubblicare post sul peeling dell'ultima iterazione. Ma per rispondere ancora alla domanda, ho messo insieme alcuni punti su come ingannare UB. Vedi altre risposte per maggiori dettagli.
Peter Cordes,

5

Se riesci a tollerare alcune istruzioni di assemblaggio aggiuntive nel loop, anziché

int factor = 1;
for (int j = 0; j < n; ++j) {
    ...
    factor *= 10;
}

tu puoi scrivere:

int factor = 0;
for (...) {
    factor = 10 * factor + !factor;
    ...
}

per evitare l'ultima moltiplicazione. !factornon introdurrà un ramo:

    xor     ebx, ebx
L1:                       
    xor     eax, eax              
    test    ebx, ebx              
    lea     edx, [rbx+rbx*4]      
    sete    al    
    add     ebp, 1                
    lea     ebx, [rax+rdx*2]      
    mov     edi, ebx              
    call    consume(int)          
    cmp     r12d, ebp             
    jne     .L1                   

Questo codice

int factor = 0;
for (...) {
    factor = factor ? 10 * factor : 1;
    ...
}

porta anche all'assemblaggio senza rami dopo l'ottimizzazione:

    mov     ebx, 1
    jmp     .L1                   
.L2:                               
    lea     ebx, [rbx+rbx*4]       
    add     ebx, ebx
.L1:
    mov     edi, ebx
    add     ebp, 1
    call    consume(int)
    cmp     r12d, ebp
    jne     .L2

(Compilato con GCC 8.3.0 -O3)


1
Più semplice semplicemente sbucciare l'ultima iterazione, a meno che il corpo del loop non sia grande. Questo è un trucco intelligente ma aumenta factorleggermente la latenza della catena di dipendenze trasportata da loop . O no: quando si compila a 2x LEA si tratta solo di efficiente come LEA + ADD a che fare f *= 10, come f*5*2, con testuna latenza nascosto dalla prima LEA. Ma ha un costo extra all'interno del loop, quindi c'è un possibile svantaggio di throughput (o almeno un problema di hyperthreading-friendeness)
Peter Cordes,

4

Non hai mostrato cosa c'è tra parentesi fordell'affermazione, ma suppongo che sia qualcosa del genere:

for (int n = 0; n < 10; ++n) {
    result = ...
    factor *= 10;
}

Puoi semplicemente spostare l'incremento del contatore e il controllo di terminazione del loop nel corpo:

for (int n = 0; ; ) {
    result = ...
    if (++n >= 10) break;
    factor *= 10;
}

Il numero di istruzioni di assemblaggio nel loop rimarrà lo stesso.

Ispirato alla presentazione di Andrei Alexandrescu "Speed ​​Is Found In The Minds of People".


2

Considera la funzione:

unsigned mul_mod_65536(unsigned short a, unsigned short b)
{
  return (a*b) & 0xFFFFu;
}

Secondo il Rationale pubblicata, della norma autori sarebbe aspettato che se questa funzione è stata richiamata su (es) un comune computer a 32 bit con argomenti di 0xC000 e 0xC000, promuovendo gli operandi *per signed intcauserebbe il calcolo di cedere -0x10000000 , che una volta convertito in unsigneddarebbe 0x90000000ula stessa risposta come se avessero fatto unsigned shortpromozione unsigned. Tuttavia, a volte gcc ottimizzerà tale funzione in modi che si comporterebbero in modo insensato se si verifica un overflow. Qualsiasi codice in cui una combinazione di input potrebbe causare un overflow deve essere elaborato con l' -fwrapvopzione, a meno che non sia accettabile consentire ai creatori di input deliberatamente non validi di eseguire codice arbitrario di loro scelta.


1

Perché non questo:

int result = 0;
int factor = 10;
for (...) {
    factor *= 10;
    result = ...
}
return result;

Ciò non esegue il ...corpo del loop per factor = 1o factor = 10, solo 100 e versioni successive. Dovresti sbucciare la prima iterazione e iniziare ancora factor = 1se vuoi che funzioni.
Peter Cordes,

1

Esistono molte facce diverse del comportamento indefinito e ciò che è accettabile dipende dall'uso.

stretto circuito interno che consuma una grande parte del tempo totale della CPU in un'applicazione grafica in tempo reale

Questo, di per sé, è un po 'una cosa insolita, ma sia come sia ... se questo è davvero il caso, allora l'UB è probabilmente nel regno "ammissibile, accettabile" . La programmazione grafica è nota per hack e cose brutte. Finché "funziona" e non ci vuole più di 16.6ms per produrre un frame, di solito a nessuno importa. Tuttavia, fai attenzione a cosa significa invocare UB.

Innanzitutto, c'è lo standard. Da quel punto di vista, non c'è nulla da discutere e nessun modo per giustificare, il tuo codice è semplicemente non valido. Non ci sono if o whens, semplicemente non è un codice valido. Potresti anche dire che è il medio-alto dal tuo punto di vista, e il 95-99% delle volte sarai comunque bravo ad andare.

Successivamente, c'è il lato hardware. Ci sono alcune architetture insolite e strane in cui questo è un problema. Sto dicendo "non comune, strano" perché da quella architettura che costituisce l'80% di tutti i computer (o le due architetture che insieme costituiscono il 95% di tutti i computer) di overflow è un "sì, qualunque sia, non importa" cosa a livello hardware. Sicuramente ottieni un risultato spazzatura (anche se ancora prevedibile), ma non accadono cose cattive.
Questo non lo ènel caso di ogni architettura, potresti benissimo avere una trappola sull'overflow (anche se vedendo come parli di un'applicazione grafica, le possibilità di trovarti su un'architettura così strana sono piuttosto piccole). La portabilità è un problema? Se lo è, potresti voler astenervi.

Infine, c'è il lato compilatore / ottimizzatore. Uno dei motivi per cui l'overflow è indefinito è che semplicemente lasciarlo in quel momento era più facile da affrontare con l'hardware una volta. Ma un'altra ragione è che ad esempio x+1è garantito che sia sempre più grande di x, e il compilatore / ottimizzatore può sfruttare questa conoscenza. Ora, per il caso menzionato in precedenza, i compilatori sono effettivamente noti per agire in questo modo e semplicemente eliminare i blocchi completi (esisteva un exploit Linux alcuni anni fa che si basava sul fatto che il compilatore aveva eliminato del codice di convalida proprio per questo).
Per il tuo caso, dubiterei seriamente che il compilatore faccia delle ottimizzazioni speciali, strane. Tuttavia, cosa sai, cosa so. In caso di dubbi, provalo. Se funziona, sei a posto.

(E infine, c'è ovviamente il controllo del codice, potresti dover perdere tempo a discuterne con un revisore se sei sfortunato.)

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.