Skip to content

Programmazione MCU - L'ottimizzazione O2 del C++ interrompe il ciclo while

Questa sezione è stata approvata da specialisti, quindi garantiamo la veridicità della nostra sezione.

Soluzione:

L'ottimizzatore di codice ha analizzato il codice e da quello che può vedere il valore di choice non cambierà mai. E poiché non cambierà mai, non ha senso controllarlo.

La soluzione consiste nel dichiarare la variabile volatile in modo che il compilatore sia costretto a emettere codice che controlla il suo valore, indipendentemente dal livello di ottimizzazione utilizzato.

(Duplicato del sito su SO relativo al caso del thread, piuttosto che a quello dell'interrupt/signal-handler). Correlato: Quando usare la volatile con il multi threading?


Una corsa ai dati su un sistema non atomic variabile 1 è un comportamento non definito in C++11 2. cioè lettura+scrittura o scrittura+scrittura potenzialmente contemporanee senza alcuna sincronizzazione che fornisca una relazione "happens-before", ad esempio un mutex o una sincronizzazione release/acquire.


Il compilatore è autorizzato a supporre che nessun altro thread abbia modificato il file choice tra due letture di esso (perché sarebbe un data-race UB (Undefined Behaviour)), quindi può fare il CSE e sollevare il controllo dal ciclo.

Questo è in effetti ciò che fa gcc (e anche la maggior parte degli altri compilatori):

while(!choice){}

ottimizza in asm che assomiglia a questo:

if(!choice)     // conditional branch outside the loop to skip it
    while(1){}  // infinite loop, like ARM  .L2: b .L2

Questo avviene nella parte di gcc indipendente dal target, quindi si applica a tutte le architetture.

Voi vuole il compilatore sia in grado di fare questo tipo di ottimizzazione, perché il codice reale contiene cose come for (int i=0 ; i < global_size ; i++ ) { ... }. Si vuole che il compilatore sia in grado di caricare il globale al di fuori del ciclo, senza continuare a ricaricarlo a ogni iterazione del ciclo o per ogni accesso successivo in una funzione. I dati devono essere nei registri perché la CPU possa lavorarci, non nella memoria.


Il compilatore potrebbe anche assumere che il codice non venga mai raggiunto con choice == 0perché un ciclo infinito senza effetti collaterali è un comportamento non definito. (Letture e scritture di dati non volatile non contano come effetti collaterali). Cose come printf è un effetto collaterale, ma la chiamata di una funzione non in linea impedirebbe al compilatore di ottimizzare le riletture di choicea meno che non si tratti di static int choice. (Allora il compilatore saprebbe che printf non poteva modificarlo, a meno che qualcosa in questa unità di compilazione non avesse passato &choice a una funzione non in linea. Ad esempio, l'analisi dell'escape potrebbe permettere al compilatore di provare che static int choice non può essere modificato da una chiamata a una funzione non in linea "sconosciuta").

In pratica, i compilatori reali non ottimizzano i semplici loop infiniti, ma assumono (per un problema di qualità dell'implementazione o qualcosa del genere) che si intendeva scrivere while(42){}. Ma un esempio in https://en.cppreference.com/w/cpp/language/ub mostra che clang ottimizzerà un loop infinito se c'è è stato codice senza effetti collaterali che è stato ottimizzato.


Modi ufficiali supportati al 100% portatili/legali del C++11 per farlo:

Non si fa davvero più thread, ma un gestore di interrupt. In termini di C++11, è esattamente come un gestore di segnali: può essere eseguito in modo asincrono con il programma principale, ma sullo stesso core.

C e C++ hanno una soluzione per questo da molto tempo: volatile sig_atomic_t è garantito che sia possibile scrivere in un gestore di segnali e leggere nel programma principale.

Un tipo intero a cui si può accedere come entità atomica anche in presenza di interruzioni asincrone effettuate da segnali.

void reader() {

    volatile sig_atomic_t shared_choice;
    auto handler = a lambda that sets shared_choice;

    ... register lambda as interrupt handler

    sig_atomic_t choice;        // non-volatile local to read it into
    while((choice=shared_choice) == 0){
        // if your CPU has any kind of power-saving instruction like x86 pause, do it here.
        // or a sleep-until-next-interrupt like x86 hlt
    }

    ... unregister it.

    switch(choice) {
        case 1: goto constant;
        ...
        case 0: // you could build the loop around this switch instead of a separate spinloop
                // but it doesn't matter much
    }
}

Altro volatile non sono garantiti dallo standard come atomici (anche se in pratica lo sono almeno fino alla larghezza del puntatore su architetture normali come x86 e ARM, perché i locali saranno naturalmente allineati). uint8_t è un singolo byte e le moderne ISA possono memorizzare atomicamente un byte senza leggere/modificare/scrittura della parola circostante, nonostante la disinformazione che si può aver sentito sulle CPU orientate alle parole).

Quello che si vorrebbe davvero è un modo per rendere volatile un accesso specifico, invece di avere bisogno di una variabile separata. Si potrebbe fare con *(volatile sig_atomic_t*)&choicecome il kernel Linux ACCESS_ONCE ma Linux compila con lo strict-aliasing disabilitato per rendere questo tipo di cose sicure. Penso che in pratica funzionerebbe con gcc/clang, ma credo che non sia strettamente legale per il C++.


Con std::atomic per la libertà di blocco T

(con std::memory_order_relaxed per ottenere un asm efficiente senza istruzioni a barriera, come si può ottenere da volatile)

Il C++11 introduce un meccanismo standard per gestire il caso in cui un thread legge una variabile mentre un altro thread (o un gestore di segnali) la scrive.

Fornisce un controllo sull'ordinamento della memoria, con una coerenza sequenziale predefinita, che è costosa e non necessaria per il vostro caso. std::memory_order_relaxed I carichi e i salvataggi atomici verranno compilati con lo stesso asm (per la CPU K60 ARM Cortex-M4) come volatile uint8_tcon il vantaggio di consentire l'uso di un file uint8_t invece di qualsiasi larghezza sig_atomic_t evitando anche solo un accenno di gara di dati UB in C++11.

(Ovviamente è portabile solo su piattaforme in cui atomic è esente da lock per la T; altrimenti l'accesso asincrono dal programma principale e un gestore di interrupt possono bloccarsi.. Alle implementazioni C++ non è consentito inventare scritture sugli oggetti circostanti, quindi se hanno uint8_t dovrebbero essere atomiche senza lock. Oppure utilizzare semplicemente unsigned char. Ma per i tipi troppo ampi per essere naturalmente atomici, atomic utilizzerà un blocco nascosto. Con il codice normale incapace di risvegliarsi e rilasciare un blocco mentre l'unico core della CPU è bloccato in un gestore di interrupt, si è fregati se arriva un segnale/interruzione mentre il blocco è trattenuto).

#include 
#include 

volatile uint8_t v;
std::atomic a;

void a_reader() {
    while (a.load(std::memory_order_relaxed) == 0) {}
    // std::atomic_signal_fence(std::memory_order_acquire); // optional
}
void v_reader() {
    while (v == 0) {}
}

Entrambi compilano lo stesso asm, con gcc7.2 -O3 per ARM, sul compilatore Godbolt explorer

a_reader():
    ldr     r2, .L7      @ load the address of the global
.L2:                     @ do {
    ldrb    r3, [r2]        @ zero_extendqisi2
    cmp     r3, #0
    beq     .L2          @ }while(choice eq 0)
    bx      lr
.L7:
    .word   .LANCHOR0

void v_writer() {
    v = 1;
}

void a_writer() {
    // a = 1;  // seq_cst needs a DMB, or x86 xchg or mfence
    a.store(1, std::memory_order_relaxed);
}

Asm ARM per entrambi:

    ldr     r3, .L15
    movs    r2, #1
    strb    r2, [r3, #1]
    bx      lr

Quindi in questo caso per questa implementazione, volatile può fare la stessa cosa di std::atomic. Su alcune piattaforme, volatile potrebbe implicare l'uso di istruzioni speciali necessarie per accedere ai registri di I/O mappati in memoria. (Non sono a conoscenza di piattaforme di questo tipo e non è il caso di ARM. Ma questa è una caratteristica di volatile che sicuramente non si vuole).


Con atomicsi può anche bloccare il riordino in fase di compilazione rispetto a variabili non atomiche, senza costi aggiuntivi in fase di esecuzione, se si fa attenzione.

Non usare .load(mo_acquire)che renderà l'asm sicuro rispetto ad altri thread in esecuzione su altri core nello stesso momento. Invece, utilizzare carichi/stoccaggio rilassati e utilizzare atomic_signal_fence (non thread_fence) dopo un carico rilassato o prima di un deposito rilassato. per ottenere un ordine di acquisizione o di rilascio.

Un possibile caso d'uso potrebbe essere un gestore di interrupt che scrive un piccolo buffer e poi imposta un flag atomico per indicare che è pronto. Oppure un indice atomico per specificare che di un insieme di buffer.

Si noti che se il gestore dell'interrupt può essere eseguito di nuovo mentre il codice principale sta ancora leggendo il buffer, si ha una gara di dati UB (e un'anomalia di ). reale bug sull'hardware reale) In C++ puro dove ci sono nessun restrizioni o garanzie di temporizzazione, si potrebbe avere un potenziale UB teorico (che il compilatore dovrebbe presumere non si verifichi mai).

Ma è UB solo se si verifica effettivamente a runtime; Se il vostro sistema embedded ha garanzie di tempo reale, potreste essere in grado di garantire che il lettore possa sempre terminare il controllo del flag e la lettura dei dati non atomici prima che l'interrupt possa essere attivato di nuovo, anche nel caso peggiore in cui qualche altro interrupt arrivi e ritardi le cose. Potrebbe essere necessario un qualche tipo di barriera di memoria per assicurarsi che il compilatore non ottimizzi continuando a fare riferimento al buffer, invece che a qualsiasi altro oggetto in cui si legge il buffer. Il compilatore non capisce che per evitare l'UB è necessario leggere subito il buffer, a meno che non glielo si dica in qualche modo. (Qualcosa come GNU C asm("":::"memory") dovrebbe servire allo scopo, o anche asm(""::"m"(shared_buffer[0]):"memory")).


Naturalmente, operazioni di lettura/modifica/scrittura come a++ si compilano in modo diverso da v++, ad un RMW atomico thread-safe, utilizzando un ciclo di retry LL/SC, o un x86 lock add [mem], 1. Il volatile si compila con un caricamento e poi con una memorizzazione separata. Si può esprimere questo concetto con atomiche come:

uint8_t non_atomic_inc() {
    auto tmp = a.load(std::memory_order_relaxed);
    uint8_t old_val = tmp;
    tmp++;
    a.store(tmp, std::memory_order_relaxed);
    return old_val;
}

Se si vuole incrementare choice in memoria, si potrebbe considerare volatile per evitare problemi di sintassi, se è questo che si vuole al posto degli incrementi atomici. Ma ricordate che ogni accesso a un oggetto volatile o atomic è un ulteriore caricamento o memorizzazione, quindi si dovrebbe scegliere quando leggerlo in un locale non atomico/non volatile.

Attualmente i compilatori non ottimizzano gli atomici, ma lo standard li consente in casi sicuri, a meno che non si utilizzi il metodo volatile atomic choice.

Di nuovo quello che siamo veramente come atomic l'accesso mentre il gestore di interrupt è registrato, poi l'accesso normale.

Il C++20 lo fornisce con std::atomic_ref<>

Ma né gcc né clang lo supportano ancora nelle loro librerie standard (libstdc++ o libc++). no member named 'atomic_ref' in namespace 'std'con gcc e clang -std=gnu++2a. Tuttavia, non dovrebbe esserci alcun problema nell'implementazione; le builtin GNU C come __atomic_load lavorano su oggetti regolari, quindi l'atomicità è su base per-accesso piuttosto che su base per-oggetto.

void reader(){ 
    uint8_t choice;
    {  // limited scope for the atomic reference
       std::atomic_ref atomic_choice(choice);
       auto choice_setter = [&atomic_choice] (int x) { atomic_choice = x; };

       ui::Context::addEventListener(ui::EventType::JOYSTICK_DOWN, &choice_setter);
       while(!atomic_choice) {}

       ui::Context::removeEventListener(ui::EventType::JOYSTICK_DOWN, &choice_setter);

    }

    switch(choice) { // then it's a normal non-atomic / non-volatile variable
    }
}

Probabilmente si finisce per caricare una variabile in più rispetto a while(!(choice = shared_choice)) ;ma se si chiama una funzione tra lo spinloop e il momento in cui la si usa, è probabilmente più semplice non costringere il compilatore a registrare l'ultimo risultato letto in un altro locale (che potrebbe dover versare). Oppure, dopo il deregistro, si potrebbe fare un'operazione finale choice = shared_choice; per fare in modo che il compilatore mantenga il valore di choice solo in un registro e rileggere l'atomico o il volatile.


Nota 1: volatile

Anche le tracce di dati su volatile sono tecnicamente UB, ma in questo caso il comportamento che si ottiene in pratica nelle implementazioni reali è utile e normalmente identico a quello di atomic con memory_order_relaxedse si evitano operazioni atomiche di lettura-modifica-scrittura.

Quando usare volatile con il multi threading? spiega più dettagliatamente il caso multi-core: in pratica mai, usare std::atomic (con memory_order rilassato).

Codice generato dal compilatore che carica o memorizza uint8_t è atomico sulla CPU ARM. Leggere/modificare/scrivere come choice++ sarebbe non sarebbe un RMW atomico su volatile uint8_t choice, solo un carico atomico, poi un successivo deposito atomico che potrebbe passare su altri depositi atomici.

Nota 2: C++03:

Prima del C++11 lo standard ISO C++ non parlava di thread, ma i compilatori più vecchi funzionavano allo stesso modo; il C++11 ha sostanzialmente ufficializzato che il modo in cui i compilatori già lavorano è corretto, applicando la regola as-if per preservare il comportamento di un singolo thread solo a meno che non si utilizzino speciali caratteristiche del linguaggio.

Se ti piace questo mondo, hai la possibilità di lasciare un tutorial su cosa ti ha colpito di questa recensione.



Utilizzate il nostro motore di ricerca

Ricerca
Generic filters

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.