Ich weiß, dass Leute sagen, dass Code-Optimierung nur den versteckten Fehler im Programm hervorbringen sollte, aber hören Sie mir zu. Ich bleibe auf einem Bildschirm, bis eine Eingabe über einen Interrupt erfolgt.
Folgendes sehe ich im Debugger. Beachten Sie die überprüfte Zeile und den abgefangenen Ausdruckswert.
Code im Bild:
//...
ui::Context::addEventListener(ui::EventType::JOYSTICK_DOWN, &constant_dynamic_handler);
while (true) {
if (choice != 0) //debugger pause
break;
}
ui::Context::removeEventListener(ui::EventType::JOYSTICK_DOWN, &constant_dynamic_handler);
if (choice == 1) goto constant;
else if (choice == 2) goto dynamic;
else if (choice == 3) goto reset;
else if (choice == 4) goto exit;
//...
//debugger view:
//expression: choice
//value: 1
Das constant_dynamic_handler
ist eine zuvor deklarierte Lambda-Funktion, die sich nur choice
in eine andere ganze Zahl als ändert 0
. Dadurch, dass ich in der Schleife pausieren kann, wird die Schleife nicht verlassen, sondern der Wert verändert. Ich kann im Debugger keinen Schritt überspringen, da der Speicher auf der CPU nicht gelesen werden kann und ein Neustart erforderlich ist, um erneut zu debuggen.
choice
wird einfach im gleichen Geltungsbereich wie der if-Anweisungsblock deklariert, as int choice = 0;
. Es wird nur innerhalb eines Interrupt-Listeners geändert, der mit einem Hardwareeingang ausgelöst wird.
Das Programm arbeitet mit O0
Flag statt O1
or O2
.
Ich verwende NXP K60 und c ++ 11, falls dies erforderlich ist. Ist es mein Problem? Kann es sein, dass mir etwas nicht bewusst ist? Ich bin ein Anfänger in der MCU-Programmierung, und dieser Code funktioniert auf dem Desktop(Gerade probiert, geht nicht).
( Site-übergreifendes Duplikat auf SO über den Thread-Fall und nicht über den Interrupt/Signal-Handler-Fall). Auch verwandt: Wann sollte man volatile mit Multi-Threading verwenden?
Ein Datenrennen auf einer Nicht- atomic
Variablen 1 ist Undefined Behavior in C++11 2 . dh potentiell gleichzeitiges Lesen+Schreiben oder Schreiben+Schreiben ohne irgendeine Synchronisation, um eine Ereignis-bevor-Beziehung bereitzustellen, z. B. eine Mutex- oder Release/Acquire-Synchronisation.
Der Compiler darf davon ausgehen, dass sich choice
zwischen zwei Lesevorgängen kein anderer Thread geändert hat (denn das wäre Data-Race UB ( Undefined Behavior )), sodass er CSE durchführen und den Check aus der Schleife ziehen kann.
Dies ist in der Tat das, was gcc tut (und die meisten anderen Compiler auch):
while(!choice){}
optimiert in asm, das so aussieht:
if(!choice) // conditional branch outside the loop to skip it
while(1){} // infinite loop, like ARM .L2: b .L2
Dies geschieht im zielunabhängigen Teil von gcc, gilt also für alle Architekturen.
Sie möchten , dass der Compiler diese Art der Optimierung durchführen kann, da echter Code Dinge wie for (int i=0 ; i < global_size ; i++ ) { ... }
. Sie möchten, dass der Compiler in der Lage ist, das Global außerhalb der Schleife zu laden, und es nicht bei jedem Schleifendurchlauf oder für jeden späteren Zugriff in einer Funktion neu zu laden. Daten müssen sich in Registern befinden, damit die CPU damit arbeiten kann, nicht im Speicher.
Der Compiler könnte sogar davon ausgehen, dass der Code nie mit erreicht choice == 0
wird, da eine Endlosschleife ohne Nebenwirkungen Undefined Behaviour ist. (Lesen / Schreiben von Nicht volatile
-Variablen zählen nicht als Nebenwirkungen). Sachen wie printf
sind ein Nebeneffekt, aber das Aufrufen einer Nicht-Inline-Funktion würde den Compiler auch daran hindern, das erneute Lesen von choice
wegzuoptimieren, es sei denn, es wäre static int choice
. (Dann würde der Compiler wissen, dass es nicht geändert werden printf
kann, es sei denn, etwas in dieser Kompilierungseinheit wird &choice
an eine Nicht-Inline-Funktion übergeben. Das heißt , die Escape-Analyse könnte es dem Compiler ermöglichen zu beweisen , dass static int choice
es nicht durch einen Aufruf an eine "unbekannte" geändert werden kann Nicht-Inline-Funktion.)
In der Praxis optimieren echte Compiler einfache Endlosschleifen nicht weg, sie gehen davon aus (als Qualitätsproblem der Implementierung oder so), dass Sie schreiben wollten while(42){}
. Aber ein Beispiel in https://en.cppreference.com/w/cpp/language/ub zeigt, dass Clang eine Endlosschleife wegoptimiert, wenn es Code ohne Nebeneffekte gab, den es wegoptimierte.
Sie haben nicht wirklich mehrere Threads, Sie haben einen Interrupt-Handler. In C++11-Begriffen ist das genau wie ein Signalhandler: Er kann asynchron mit Ihrem Hauptprogramm laufen, aber auf demselben Kern.
C und C++ haben dafür schon lange eine Lösung: volatile sig_atomic_t
Es ist garantiert in Ordnung, in einen Signalhandler zu schreiben und in Ihrem Hauptprogramm zu lesen
Ein ganzzahliger Typ, auf den als atomare Entität zugegriffen werden kann, selbst wenn asynchrone Interrupts durch Signale vorhanden sind.
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
}
}
Andere Typen werden vom Standard nicht als atomar garantiert (obwohl sie in der Praxis auf normalen Architekturen wie x86volatile
und ARM mindestens bis zu einer Zeigerbreite reichen , da Locals natürlich ausgerichtet werden. ist ein einzelnes Byte, und moderne ISAs können a atomar speichern Byte ohne Lesen/Ändern/Schreiben des umgebenden Wortes, trotz aller Fehlinformationen, die Sie vielleicht über wortorientierte CPUs gehört haben ).uint8_t
Was Sie wirklich möchten, ist eine Möglichkeit, einen bestimmten Zugriff flüchtig zu machen, anstatt eine separate Variable zu benötigen. Sie können das vielleicht mit tun *(volatile sig_atomic_t*)&choice
, wie das ACCESS_ONCE
Makro des Linux-Kernels, aber Linux wird mit deaktiviertem Strict-Aliasing kompiliert, um so etwas sicher zu machen. Ich denke, in der Praxis würde das auf gcc/clang funktionieren, aber ich denke, es ist kein streng legales C++.
std::atomic<T>
für lock-freeT
(mit std::memory_order_relaxed
um eine effiziente asm ohne Barriereanweisungen zu erhalten, wie Sie von bekommen können volatile
)
C++11 führt einen Standardmechanismus ein, um den Fall zu handhaben, in dem ein Thread eine Variable liest, während ein anderer Thread (oder Signalhandler) sie schreibt.
Es bietet Kontrolle über die Speicherreihenfolge, standardmäßig mit sequentieller Konsistenz, was teuer und für Ihren Fall nicht erforderlich ist. std::memory_order_relaxed
Atomic Loads/Stores werden mit dem gleichen Asm (für Ihre K60 ARM Cortex-M4-CPU) wie kompiliert volatile uint8_t
, mit dem Vorteil, dass Sie a uint8_t
anstelle der Breite verwenden sig_atomic_t
können, während Sie dennoch sogar einen Hinweis auf C++11 Data Race UB vermeiden .
( Natürlich ist es nur auf Plattformen portierbar, auf denen atomic<T>
Ihr T lock-frei ist; andernfalls kann der asynchrone Zugriff vom Hauptprogramm und ein Interrupt-Handler zu einem Deadlock führen . C++-Implementierungen dürfen keine Schreibvorgänge für umgebende Objekte erfinden , also wenn uint8_t
überhaupt , es sollte lock-free atomic sein.Oder verwenden Sie einfach unsigned char
.Aber für Typen, die zu breit sind, um natürlich atomar zu sein, atomic<T>
wird eine versteckte Sperre verwendet.Mit normalem Code ist es nicht möglich, jemals aufzuwachen und eine Sperre freizugeben, während der einzige CPU-Kern in einem steckt Interrupt-Handler, Sie sind am Arsch, wenn ein Signal/Interrupt ankommt, während diese Sperre gehalten wird.)
#include <atomic>
#include <stdint.h>
volatile uint8_t v;
std::atomic<uint8_t> 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) {}
}
Beide kompilieren mit gcc7.2 -O3 für ARM im Godbolt-Compiler-Explorer auf dieselbe Anweisung
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);
}
ARM asm für beide:
ldr r3, .L15
movs r2, #1
strb r2, [r3, #1]
bx lr
In diesem Fall kann also für diese Implementierung volatile
dasselbe wie std::atomic
. Auf einigen Plattformen kann volatile
die Verwendung spezieller Anweisungen erforderlich sein, die für den Zugriff auf speicherabgebildete E/A-Register erforderlich sind. (Mir sind keine solchen Plattformen bekannt, und bei ARM ist dies nicht der Fall. Aber das ist eine Funktion, die volatile
Sie definitiv nicht wollen).
Mit atomic
können Sie sogar die Neuordnung zur Kompilierzeit in Bezug auf nicht-atomare Variablen blockieren, ohne zusätzliche Laufzeitkosten, wenn Sie vorsichtig sind.
Verwenden Sie nicht .load(mo_acquire)
, das macht asm sicher in Bezug auf andere Threads, die gleichzeitig auf anderen Kernen laufen. Verwenden Sie stattdessen entspannte Loads/Stores und verwenden atomic_signal_fence
Sie (nicht thread_fence) nach einem entspannten Laden oder vor einem entspannten Store , um eine Bestellung zu erwerben oder freizugeben.
Ein möglicher Anwendungsfall wäre ein Interrupt-Handler, der einen kleinen Puffer schreibt und dann ein atomisches Flag setzt, um anzuzeigen, dass er bereit ist. Oder ein atomarer Index, um anzugeben, welcher einer Gruppe von Puffern.
Beachten Sie, dass Sie, wenn der Interrupt-Handler erneut ausgeführt werden kann, während der Hauptcode noch den Puffer liest, ein Data Race UB haben (und einen tatsächlichen Fehler auf echter Hardware). UB (was der Compiler annehmen sollte, passiert nie).
Aber es ist nur UB, wenn es tatsächlich zur Laufzeit passiert; Wenn Ihr eingebettetes System über Echtzeitgarantien verfügt, können Sie möglicherweise garantieren, dass der Leser immer das Flag überprüfen und die nicht atomaren Daten lesen kann, bevor der Interrupt erneut ausgelöst werden kann, selbst im schlimmsten Fall, wenn ein anderer Interrupt eingeht und verzögert Dinge. Möglicherweise benötigen Sie eine Art Speicherbarriere, um sicherzustellen, dass der Compiler nicht optimiert, indem er weiterhin auf den Puffer verweist, anstatt auf das andere Objekt, in das Sie den Puffer einlesen. Der Compiler versteht nicht, dass zur Vermeidung von UB der Puffer sofort einmal gelesen werden muss, es sei denn, Sie sagen ihm das irgendwie. (Etwas wie GNU C asm("":::"memory")
sollte es tun, oder sogar asm(""::"m"(shared_buffer[0]):"memory")
).
Natürlich werden Lese-/Änderungs-/Schreiboperationen wie a++
anders vonv++
, zu einem Thread-sicheren atomaren RMW kompiliert , indem eine LL/SC-Wiederholungsschleife oder ein x86 verwendet wird lock add [mem], 1
. Die volatile
Version wird zu einem Ladevorgang kompiliert und dann zu einem separaten Speicher. Sie können dies mit Atomen ausdrücken wie:
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;
}
choice
Wenn Sie tatsächlich jemals den Speicher erhöhen möchten , sollten Sie in Betracht ziehen volatile
, Syntaxschmerzen zu vermeiden, wenn Sie dies anstelle von tatsächlichen atomaren Inkrementen möchten. Denken Sie jedoch daran, dass jeder Zugriff auf ein volatile
oder atomic
ein zusätzliches Laden oder Speichern ist. Sie sollten also wirklich nur auswählen, wann Sie es in ein nicht atomares / nicht flüchtiges Lokal einlesen möchten.
Compiler optimieren derzeit keine atomaren , aber der Standard erlaubt es in Fällen, die sicher sind, es sei denn, Sie verwenden volatile atomic<uint8_t> choice
.
Nochmals , was wir wirklich mögen, ist atomic
der Zugriff, während der Interrupt-Handler registriert ist, dann der normale Zugriff.
std::atomic_ref<>
Aber weder gcc noch clang unterstützen dies tatsächlich in ihrer Standardbibliothek (libstdc++ oder libc++). no member named 'atomic_ref' in namespace 'std'
, mit gcc und clang-std=gnu++2a
. Es sollte jedoch kein Problem sein, es tatsächlich zu implementieren; GNU-C-Builtins __atomic_load
arbeiten gerne mit regulären Objekten, daher erfolgt die Atomizität eher pro Zugriff als pro Objekt.
void reader(){
uint8_t choice;
{ // limited scope for the atomic reference
std::atomic_ref<uint8_t> 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
}
}
Sie landen wahrscheinlich mit einem zusätzlichen Laden der Variablen vs. while(!(choice = shared_choice)) ;
, aber wenn Sie eine Funktion zwischen dem Spinloop und ihrer Verwendung aufrufen, ist es wahrscheinlich einfacher, den Compiler nicht zu zwingen, das letzte Leseergebnis in einem anderen lokalen (das es muss möglicherweise verschüttet werden). Oder ich denke, Sie könnten nach dem Deregistrieren ein Finale durchführen choice = shared_choice;
, damit der Compiler choice
nur ein Register behält und das Atomic oder Volatile erneut liest.
Fußnote 1:volatile
Sogar Datenrennen volatile
sind technisch gesehen UB, aber in diesem Fall ist das Verhalten, das Sie in der Praxis bei echten Implementierungen erhalten, nützlich und normalerweise identisch atomic
mit memory_order_relaxed
, wenn Sie atomare Read-Modify-Write-Operationen vermeiden.
Wann sollte man volatile mit Multi-Threading verwenden? für den Multi-Core-Fall genauer erklärt: im Grunde nie, std::atomic
stattdessen verwenden (mit entspannter memory_order).
Vom Compiler generierter Code, der geladen oder gespeichert uint8_t
wird, ist auf Ihrer ARM-CPU atomar. Lesen/Ändern/Schreiben wie choice++
wäre kein atomares RMW auf volatile uint8_t choice
, nur eine atomare Ladung, dann ein späterer atomarer Speicher, der auf andere atomare Speicher treten könnte.
Fußnote 2: C++03 :
Vor C++11 sagte der ISO-C++-Standard nichts über Threads aus, aber ältere Compiler funktionierten genauso; C ++ 11 hat im Grunde nur offiziell gemacht, dass die Art und Weise, wie Compiler bereits funktionieren, korrekt ist, indem die Als-ob-Regel angewendet wird, um das Verhalten eines einzelnen Threads nur beizubehalten, es sei denn, Sie verwenden spezielle Sprachfunktionen.
MEMW
Anweisung ("Memory Wait") vor dem Lesen und nach dem Schreiben in flüchtige Variablen einfügen sollte, um sicherzustellen, dass die Daten durch alle / alle Pipelines oder Caches. IIRC, es gab auch einen bekannten Siliziumfehler, bei dem mehrere Schreibvorgänge auf denselben Speicherort in schneller Folge (ohne MEMW) dazu führen konnten, dass frühere Schreibvorgänge übersprungen wurden und nur die späteren Schreibvorgänge an Off-Core-Hardware/Speicher weitergegeben wurden.Der Code-Optimierer hat den Code analysiert und nach dem, was er sehen kann, wird sich der Wert von choice
nie ändern. Und da es sich nie ändern wird, macht es keinen Sinn, es überhaupt zu überprüfen.
Die Lösung besteht darin, die Variable volatile
so zu deklarieren, dass der Compiler gezwungen ist, Code auszugeben, der ihren Wert unabhängig von der verwendeten Optimierungsstufe überprüft.
std::atomic<uint8_t> choice
wäre es gut für die Kommunikation zwischen einem Interrupt-Handler und anderem Code. Verwenden Sie choice.store(value, std::memory_order_relaxed)
, und in dieser Schleife uint8_t tmp; while(0 == (tmp=choice.load(std::memory_order_relaxed)) {}
wäre gut. (Und wahrscheinlich auf die gleiche Weise kompilieren wie volatile
)std::atomic<uint8_t>
wird sehr wahrscheinlich zu einer anderen Assembly führen als volatile
(es sei denn, Sie verwenden diese seltsame MSVC-Erweiterung, die IIRC nur für x86, x64 und möglicherweise ARM funktioniert). Atomic muss den Wert atomar aktualisieren, dh kein Beobachter sollte in der Lage sein, einen Zwischenzustand zu sehen. OTOH volatile
sagt nur "dieser Wert könnte sich seit dem letzten Lesen geändert haben", was viel weniger restriktiv ist. Außerdem gibt es auf einigen Plattformen spezielle Anweisungen für einige spezielle flüchtige Werte, zB speicherabgebildete Register..fetch_add
(oder choice++
). Ja, das wird natürlich anders kompiliert als beispielsweise volatile
zu x86 lock add
( Kann num++ für 'int num' atomar sein? ). Aber Pure-Load und Pure-Store sind bereits atomar für 8-Bit-Integer auf allen mir bekannten ISAs (außer dem frühen DEC Alpha, das nur Laden/Speichern in Wortgröße hat). (z. B. x86, Warum ist eine Integer-Zuweisung auf einer natürlich ausgerichteten Variablen auf x86 atomar? ).atomic
wollen, und ein guter Grund, . Wenn Sie jemals erhöhen möchten, können Sie es als schreiben tmp = choice; tmp++; choice=tmp;
, was add byte [choice], 1
auf x86 (ohne lock
Präfix) kompiliert werden darf.choice
Variable wurde innerhalb des Lambda referenziert , was eindeutig einen Nebeneffekt verursacht. Würde das den Compiler nicht dazu veranlassen, die Variable nicht wegzuoptimieren, da sich die Variable ändern könnte?volatile
oder Synchronisierungsprimitive), die andeuten, dass choice
sie an anderer Stelle geändert werden könnten .atomic
Variablen ist Undefined Behaviour in C++11 (concurrent read+write), weshalb der Compiler in konvertieren darf while(!choice){}
, if(!choice) infloop();
dh die Last aus der Schleife heben darf. Viele Codes referenzieren wiederholt dieselbe globale Variable innerhalb einer Funktion, und C++11-Compiler dazu zu zwingen, sie zu deoptimieren, wäre ziemlich nervig.volatile
innerhalb der Schleife ein Zugriff auf etwas anderes bestünde. (Oder so etwas wie printf
, wenn es beweisen könnte, dass der Wert von . zB mit printf
nicht geändert werden kann .) IMO data race UB ist hier wichtiger zu verstehen, weil es eine ganze Reihe von Optimierungen erklärt.
choice
static int choice
while(42)
oder nicht wegzuoptimieren while(u++ <= UINT_MAX)
. Einige Compiler optimieren basierend auf infloop UB (zumindest manchmal? Ich habe versucht, ein Beispiel zu erstellen, bin aber gescheitert: godbolt.org/g/KofYh6 ). Aber ich denke, als ich das letzte Mal gesehen habe, hat der Compiler (vielleicht MSVC?) Offensichtlich beabsichtigte Endlosschleifen beibehalten, als while(42){}
ob andere Infloops verschwunden wären.x = 2; x = 3
möglicherweise optimiert auf x = 3;
, also kein Speicher für, 2
da kein Beobachter sagen könnte, ob dieser Speicher stattgefunden hat oder nicht), die nicht für volatile
Werte durchgeführt werden können (seit dem Lesen oder Das Schreiben eines volatile
Werts kann bei einigen speziellen Werten, z. B. einem IO-Port, einen Nebeneffekt verursachen).volatile atomic
und optimieren sie nicht, da sie möglicherweise Probleme verursachen könnten. Siehe Warum führen Compiler redundante std::atomic-Schreibvorgänge nicht zusammen?volatile const
bin, bis mir klar wurde, dass seine Verwendung tatsächlich Sinn macht - es ist ein Zeiger mit einer konstanten Adresse auf Daten, die flüchtig sind - ein E / A-
Portvolatile const*
.
Arsenal
Jules
psmears
-O2
. Ich würde auch (aus Gründen des Stils) empfehlen, die Schleife zu vereinfachen - warum eine Schleife für immer ausführen und dann unterbrechen, wenn eine Bedingung erfüllt ist, anstatt nur zu tunwhile (choice == 0) {}
?OmarL
OmarL
choice
deklariert?Daniel Cheung
Daniel Cheung
hoffmännlich
while(true) { /*...*/ }
ein, die zusätzliche Kopfschmerzen verursachen könnten ... (Siehe diesen Teil eines Vortrags für ein relevantes Beispiel.)Daniel Cheung
Daniel Cheung
Graham
Daniel Cheung
goto
hier verwendet habe, ist, dass die vorherige Version nicht funktioniert hat. Ich habe alle möglichen Möglichkeiten ausgeschöpft, um den Code zu schreiben, der den gleichen Effekt hat.Benutzer194316
choice
, damit Sie keine Kommentare wie diese erhalten . Es ist durchaus möglich, dass der Fehler nicht in dem von Ihnen gezeigten Code liegt, obwohl sich das Programm dort anscheinend falsch verhält, können Sie vor langer Zeit ein undefiniertes Verhalten aufgerufen haben.UKMonkey
Leichtigkeitsrennen im Orbit
Adam Haun