Visits heute: 239
Aktuelle Seite: Speicherklassen in C
In C bestimmen Speicherklassen (storage classes) vor allem, wo und wie lange eine Variable existiert und welche Sichtbarkeit bzw. Verknüpfung (Linkage) sie besitzt.
Die wichtigsten Schlüsselwörter sind:
autoregisterstaticextern
Zusätzlich ist es wichtig, zwischen Speicherdauer (storage duration) und Sichtbarkeit/Linkage zu unterscheiden.
1. auto – die normale lokale Variable
auto ist für lokale Variablen die Standard-Speicherklasse.
void foo(void)
{
auto int x = 10;
int y = 20; // identisch, auto ist implizit
}
x und y sind hier praktisch dasselbe.
Eigenschaften
- lokal zur Funktion bzw. zum Block
- wird beim Eintritt in den Block erzeugt
- verschwindet beim Verlassen des Blocks
- bei jedem erneuten Aufruf neu erzeugt
- nicht automatisch initialisiert
Beispiel:
void counter(void)
{
int count = 0;
count++;
printf("%d\n", count);
}
Bei:
counter();
counter();
counter();
kommt:
1
1
1
Denn count wird bei jedem Aufruf neu erzeugt und mit 0 initialisiert.
In modernem C schreibt man
autofast nie explizit.int countist die übliche Schreibweise.
2. static innerhalb einer Funktion
Hier wird es interessant.
Eine lokale Variable mit static behält ihren Wert zwischen Funktionsaufrufen.
void counter(void)
{
static int count = 0;
count++;
printf("%d\n", count);
}
Jetzt:
counter();
counter();
counter();
ergibt:
1
2
3
Obwohl count weiterhin nur innerhalb von counter() sichtbar ist, wird sie nicht bei jedem Aufruf neu erzeugt.
Das Entscheidende
sichtbar? Lebensdauer
----------------------------------------------------
int count nur in Funktion bis Blockende
static int count nur in Funktion gesamtes Programm
Man kann sich static hier also als "lokale Variable mit dauerhaftem Gedächtnis" vorstellen.
Beispiel: Zustand einer Funktion
Das ist beispielsweise praktisch für eine Funktion, die wissen soll, wie oft sie aufgerufen wurde:
int get_next_id(void)
{
static int id = 0;
return ++id;
}
Dann:
printf("%d\n", get_next_id());
printf("%d\n", get_next_id());
printf("%d\n", get_next_id());
ergibt:
1
2
3
3. static auf Dateiebene
static hat außerhalb einer Funktion noch eine andere, sehr wichtige Bedeutung.
Betrachten wir:
/* motor.c */
static int speed = 100;
Diese Variable ist nur innerhalb von motor.c bekannt.
Sie kann also nicht von anderen Übersetzungseinheiten aus angesprochen werden.
Das Gleiche gilt für Funktionen:
static void calculate_speed(void)
{
...
}
calculate_speed() ist dann eine private Funktion dieses C-Moduls.
Das ist bei größeren C-Projekten extrem nützlich.
Beispiel
motor.c
#include "motor.h"
static int speed = 0;
static void update_pwm(void)
{
...
}
void motor_set_speed(int value)
{
speed = value;
update_pwm();
}
main.c
#include "motor.h"
int main(void)
{
motor_set_speed(50);
}
main.c kann motor_set_speed() verwenden.
Aber:
speed = 100;
geht nicht.
Und:
update_pwm();
geht ebenfalls nicht.
Damit kann man ein Modul sauber kapseln.
4. extern – eine Variable befindet sich woanders
extern bedeutet im Wesentlichen:
Diese Variable existiert, aber ihre Definition befindet sich an einer anderen Stelle.
Beispielsweise:
config.c
int debug_level = 2;
Das ist die Definition.
In einer anderen Datei:
main.c
extern int debug_level;
int main(void)
{
printf("%d\n", debug_level);
}
Hier ist debug_level nur deklariert.
Der Compiler bekommt damit die Information:
Es gibt irgendwo ein
int debug_level.
Der Linker findet später die eigentliche Definition in config.c.
Noch besser: Header-Datei
Typischerweise macht man:
config.h
#ifndef CONFIG_H
#define CONFIG_H
extern int debug_level;
#endif
config.c
#include "config.h"
int debug_level = 2;
main.c
#include <stdio.h>
#include "config.h"
int main(void)
{
printf("Debug-Level: %d\n", debug_level);
}
Das ist eine typische C-Projektstruktur.
5. register – bitte in Ruhe lassen
register ist historisch interessant.
register int i;
Damit konnte man dem Compiler früher einen Hinweis geben:
Diese Variable wird sehr häufig benutzt; leg sie möglichst in einem CPU-Register ab.
Früher war das durchaus relevant.
Heute optimieren moderne Compiler wesentlich besser:
gcc -O2
gcc -O3
Der Compiler entscheidet selbst, ob eine Variable in einem Register landet.
Deshalb schreibt man normalerweise einfach:
int i;
und überlässt die Optimierung dem Compiler.
6. Ein wichtiger Unterschied: Initialisierung
Hier lauert eine typische Falle.
Eine normale lokale Variable:
void foo(void)
{
int x;
printf("%d\n", x); // FEHLER: undefinierter Wert
}
hat keinen definierten Anfangswert.
Eine static-Variable dagegen wird automatisch mit 0 initialisiert:
void foo(void)
{
static int x;
printf("%d\n", x); // 0
}
Das gilt auch für globale Variablen:
int global;
global beginnt mit:
0
7. static wird automatisch initialisiert
Das gilt auch für komplexere Datentypen:
static int values[10];
Alle Elemente sind zunächst:
0 0 0 0 0 0 0 0 0 0
Auch:
static char buffer[1024];
ist vollständig mit '\0' initialisiert.
Das ist beispielsweise bei Puffern sehr praktisch.
8. Die vier Speicherklassen im Überblick
| Speicherklasse | Typischer Ort | Lebensdauer | Sichtbarkeit |
|---|---|---|---|
auto |
lokal | Block | Block |
register |
lokal | Block | Block |
static lokal |
lokal | gesamtes Programm | Block |
static global |
Datei | gesamtes Programm | nur diese Datei |
extern |
global | gesamtes Programm | über Dateien hinweg |
Dabei ist extern etwas anders als die anderen: Es beschreibt hauptsächlich eine Deklaration bzw. externe Verknüpfung, nicht einfach einen bestimmten Speicherort.
9. Besonders wichtig: static hat zwei Bedeutungen
Das ist wahrscheinlich die wichtigste Sache, die man sich merken sollte.
Innerhalb einer Funktion
void foo(void)
{
static int x;
}
bedeutet:
xbleibt über Funktionsaufrufe hinweg erhalten.
Außerhalb einer Funktion
static int x;
bedeutet:
xist nur in dieser Quelldatei sichtbar.
Und:
static void foo(void)
{
}
bedeutet:
foo()ist nur in dieser Quelldatei sichtbar.
10. Zusammenspiel in einem echten C-Modul
Nehmen wir beispielsweise ein kleines Motor-Modul:
motor.c
#include "motor.h"
static int current_speed = 0;
static void update_hardware(void)
{
printf("PWM = %d\n", current_speed);
}
void motor_set_speed(int speed)
{
current_speed = speed;
update_hardware();
}
int motor_get_speed(void)
{
return current_speed;
}
Hier sind:
static int current_speed;
und
static void update_hardware(void)
private Implementierungsdetails.
Nach außen gibt es nur:
void motor_set_speed(int speed);
int motor_get_speed(void);
Der Header könnte deshalb so aussehen:
#ifndef MOTOR_H
#define MOTOR_H
void motor_set_speed(int speed);
int motor_get_speed(void);
#endif
Das ist ein sehr schönes Beispiel dafür, wie man mit static in C eine Art Kapselung erreicht.
11. Speicherklasse ist nicht dasselbe wie Speicherort
Ein häufiger Denkfehler ist:
staticbedeutet "liegt im RAM".
Das ist so nicht ganz richtig.
Die Speicherklasse beschreibt Eigenschaften wie Lebensdauer und Linkage. Der tatsächliche Speicherort kann beispielsweise sein:
Stack
Heap
Data Segment
BSS
Register
...
Zum Beispiel:
void foo(void)
{
int a;
static int b;
}
Typischerweise könnte das vereinfacht so aussehen:
Stack:
a
statischer Speicher:
b
a verschwindet nach dem Verlassen von foo().
b bleibt während der gesamten Programmlaufzeit erhalten.
12. Ein schönes Gesamtbeispiel
#include <stdio.h>
int global = 10;
static int private_global = 20;
void test(void)
{
int normal = 0;
static int persistent = 0;
normal++;
persistent++;
printf("normal: %d\n", normal);
printf("persistent: %d\n", persistent);
}
int main(void)
{
test();
test();
test();
printf("global: %d\n", global);
printf("private: %d\n", private_global);
return 0;
}
Ausgabe:
normal: 1
persistent: 1
normal: 1
persistent: 2
normal: 1
persistent: 3
global: 10
private: 20
Der entscheidende Unterschied ist:
normal
│
├── Aufruf 1 → 0 → 1 → zerstört
├── Aufruf 2 → 0 → 1 → zerstört
└── Aufruf 3 → 0 → 1 → zerstört
persistent
│
├── Aufruf 1 → 0 → 1
├── Aufruf 2 → 1 → 2
└── Aufruf 3 → 2 → 3
Meine praktische Faustregel
Beim Programmieren in C brauchst du in der Praxis hauptsächlich diese drei Muster:
/* normale lokale Variable */
int value;
/* dauerhaft, aber nur innerhalb dieser Funktion */
static int value;
/* privat für dieses C-Modul */
static int value;
static void helper(void);
Und für Variablen, die zwischen mehreren .c-Dateien geteilt werden:
/* header.h */
extern int value;
/* source.c */
int value = 42;
register kannst du bei modernem C praktisch vergessen.
Ja — und genau hier wird C richtig interessant. static, extern, const und volatile beschreiben unterschiedliche Eigenschaften und können deshalb teilweise miteinander kombiniert werden.
Der wichtigste Punkt zuerst:
staticundexternbetreffen hauptsächlich Sichtbarkeit/Linkage bzw. Lebensdauer, währendconstundvolatileden Zugriff auf ein Objekt beschreiben.
Man kann sich die vier Schlüsselwörter deshalb zunächst so merken:
| Schlüsselwort | Hauptbedeutung |
|---|---|
static |
dauerhaft bzw. nur in dieser Übersetzungseinheit sichtbar |
extern |
Definition befindet sich woanders |
const |
über diesen Namen nicht verändern |
volatile |
Wert kann sich außerhalb des normalen Programmflusses ändern |
1. static const
Das ist eine sehr häufige Kombination.
static const int MAX_RETRIES = 5;
Hier kommen zwei Eigenschaften zusammen:
static→ nur in dieser.c-Datei sichtbarconst→ der Wert darf über diesen Namen nicht verändert werden
Zum Beispiel:
static const int MAX_RETRIES = 5;
void foo(void)
{
printf("%d\n", MAX_RETRIES);
MAX_RETRIES = 10; // Fehler
}
Das ist ideal für Konstanten, die nur ein Modul benötigt.
Zum Beispiel:
/* motor.c */
static const int PWM_MAX = 255;
static const int PWM_MIN = 0;
Diese Werte sind für motor.c verfügbar, aber nicht für andere .c-Dateien.
2. extern const
Jetzt wird es etwas interessanter.
extern const int version;
Das bedeutet:
Es gibt irgendwo ein konstantes
intnamensversion.
Beispielsweise:
config.c
const int version = 42;
main.c
extern const int version;
printf("%d\n", version);
main.c kann den Wert lesen:
printf("%d\n", version);
aber:
version = 43;
ist nicht erlaubt.
Aber Vorsicht bei const auf globaler Ebene
In C hat ein globales
const int version = 42;
standardmäßig interne Linkage.
Das bedeutet, dass diese Definition nicht einfach aus einer anderen .c-Datei über extern erreichbar ist.
Wenn du eine globale Konstante tatsächlich zwischen Dateien teilen möchtest, ist diese Variante sinnvoll:
config.h
extern const int version;
config.c
const int version = 42;
Hier gibt es allerdings eine wichtige C-Nuance: Die Definition in config.c muss tatsächlich externe Linkage haben. Bei const allein ist das in C anders als viele C++-Programmierer erwarten.
Die robuste Variante ist:
/* config.c */
extern const int version = 42;
oder, je nach Projektstruktur, eine nicht-const Definition mit entsprechendem API-Design.
Für gewöhnliche Modulkonstanten ist deshalb oft einfach:
static const int version = 42;
die bessere Lösung.
3. static volatile
Diese Kombination ist bei Mikrocontrollern besonders wichtig.
Zum Beispiel:
static volatile int flag;
bedeutet:
static→ das Objekt bleibt während der gesamten Programmlaufzeit bestehen und ist bei Dateiebene nur innerhalb des Moduls sichtbarvolatile→ der Compiler darf nicht davon ausgehen, dass sich der Wert zwischen zwei Zugriffen nicht verändert
Ein typisches Beispiel:
static volatile int ready = 0;
void interrupt_handler(void)
{
ready = 1;
}
void wait_for_ready(void)
{
while (!ready)
;
}
Hier könnte ein Interrupt ready verändern.
Ohne volatile könnte ein optimierender Compiler theoretisch annehmen:
readywird innerhalb dieser Schleife nicht verändert.
Dann könnte er die Schleife beispielsweise effektiv zu einer Endlosschleife optimieren.
Mit:
volatile
wird jeder Zugriff tatsächlich als relevanter Zugriff behandelt.
4. volatile bedeutet NICHT "thread-safe"
Das ist extrem wichtig.
Viele C-Programmierer denken:
volatile int counter;
würde parallelen Zugriff sicher machen.
Nein.
volatile bedeutet nicht:
- atomar
- mutex
- threadsicher
- synchronisiert
Zum Beispiel:
volatile int counter;
counter++;
ist nicht automatisch atomar.
counter++ besteht vereinfacht aus:
lesen
addieren
schreiben
Ein anderer Thread kann dazwischenkommen.
Für echte Thread-Synchronisation benötigt man beispielsweise C11-Atomics:
#include <stdatomic.h>
atomic_int counter;
5. const volatile
Diese Kombination sieht zunächst widersprüchlich aus:
const volatile int status;
Ist der Wert nun konstant oder kann er sich ändern?
Beides.
const bedeutet:
Dein C-Programm darf den Wert nicht über diesen Zugriff verändern.
volatile bedeutet:
Der Wert kann sich außerhalb des normalen C-Programms verändern.
Das ist bei Hardware-Registern sehr typisch.
Beispielsweise könnte ein Mikrocontroller ein Statusregister besitzen:
#define STATUS (*(const volatile uint32_t *)0x40000000)
Dann:
uint32_t status = STATUS;
liest den Hardwarewert.
Aber:
STATUS = 123;
ist verboten.
Warum?
Weil das Register möglicherweise read-only ist, sich aber durch Hardware jederzeit ändern kann.
6. volatile const ist dasselbe
Diese beiden Schreibweisen sind identisch:
const volatile int status;
und:
volatile const int status;
Die Reihenfolge spielt hier keine Rolle.
7. static volatile bei Interrupts
Für deine Mikrocontroller-Projekte ist dieses Muster besonders relevant.
Stell dir vor, ein Interrupt setzt ein Flag:
static volatile bool event = false;
void timer_interrupt(void)
{
event = true;
}
void main_loop(void)
{
while (1)
{
if (event)
{
event = false;
do_something();
}
}
}
static sorgt hier für die Kapselung:
event
↓
nur dieses C-Modul
volatile sagt dem Compiler:
event
↓
kann jederzeit außerhalb des normalen Programmflusses geändert werden
Das ist ein sehr typisches Embedded-C-Muster.
8. extern volatile
Auch das ist häufig.
Nehmen wir an, ein Interrupt-Handler befindet sich in interrupt.c.
interrupt.c
volatile int adc_ready = 0;
void ADC_IRQHandler(void)
{
adc_ready = 1;
}
In einer anderen Datei:
main.c
extern volatile int adc_ready;
int main(void)
{
while (!adc_ready)
;
process_adc();
}
Hier bedeutet:
extern volatile int adc_ready;
gleichzeitig:
extern→ Definition befindet sich in einer anderen Übersetzungseinheitvolatile→ Wert kann sich unabhängig vom normalen Programmfluss ändern
9. extern volatile + Header
In einem richtigen Projekt würde ich das so organisieren:
adc.h
#ifndef ADC_H
#define ADC_H
extern volatile int adc_ready;
void ADC_IRQHandler(void);
#endif
adc.c
#include "adc.h"
volatile int adc_ready = 0;
void ADC_IRQHandler(void)
{
adc_ready = 1;
}
main.c
#include "adc.h"
int main(void)
{
while (!adc_ready)
;
process_adc();
}
Das ist eine saubere Trennung zwischen Deklaration und Definition.
10. static und extern kann man nicht sinnvoll kombinieren
Das hier ist ein Widerspruch:
static extern int value;
Denn:
static → nur innerhalb dieser Übersetzungseinheit
extern → Definition mit externer Linkage
Die beiden Linkage-Eigenschaften widersprechen sich.
Man sollte also normalerweise nicht schreiben:
static extern int value;
11. Kombinationen als Übersicht
Sehr nützlich ist diese Tabelle:
| Deklaration | Bedeutung |
|---|---|
static int x; |
dauerhaft, intern sichtbar |
static const int x = 10; |
private Konstante |
static volatile int x; |
private Variable, kann extern verändert werden |
static const volatile int x; |
private, read-only aus C-Sicht, extern veränderbar |
extern int x; |
Variable wird woanders definiert |
extern const int x; |
externe read-only-Variable |
extern volatile int x; |
externe Variable, kann sich außerhalb des normalen Programmflusses ändern |
extern const volatile int x; |
externe, read-only Hardware-/Statusvariable |
12. Ein besonders schönes Embedded-Beispiel
Angenommen, wir haben ein UART-Modul.
uart.c
#include "uart.h"
static volatile bool rx_ready = false;
static const int BUFFER_SIZE = 128;
static char rx_buffer[BUFFER_SIZE];
void UART_IRQHandler(void)
{
/*
* Zeichen vom UART lesen
*/
rx_ready = true;
}
bool uart_data_available(void)
{
return rx_ready;
}
Hier haben wir gleich mehrere Speicherklassen bzw. Qualifier:
static volatile bool rx_ready;
bedeutet:
rx_readygehört ausschließlich zu diesem Modul und kann jederzeit durch einen Interrupt verändert werden.
Und:
static const int BUFFER_SIZE = 128;
bedeutet:
BUFFER_SIZEist eine unveränderliche Konstante, die ausschließlich dieses Modul benötigt.
13. Der wichtigste Denkansatz
Ich würde die vier Begriffe nicht als eine einzige Kategorie lernen.
Denke stattdessen in zwei Ebenen:
Ebene 1: Wer sieht die Variable?
static
extern
Also:
Wer kann darauf zugreifen?
│
┌─────────┴─────────┐
│ │
static extern
│ │
dieses Modul andere Module möglich
Ebene 2: Wie darf auf den Wert zugegriffen werden?
const
volatile
Also:
Zugriff auf den Wert
│
┌─────────┴─────────┐
│ │
const volatile
│ │
nicht schreiben kann sich unerwartet
über diesen Namen verändern
Und genau deshalb lassen sich die Qualifier kombinieren:
Sichtbarkeit Zugriff
│ │
static/extern const/volatile
│ │
└──────────┬──────────┘
│
static volatile
extern volatile
static const
static const volatile
extern const volatile
Das ist meiner Meinung nach die sauberste Art, sich das zu merken.
Das ist ein Bereich, bei dem man in C leicht eine falsche Vorstellung bekommt:
volatilesorgt nicht für Synchronisation._Atomicsorgt nicht automatisch für Hardware-I/O. Memory Barriers sind wiederum noch eine dritte Sache.
Gerade bei deinen Mikrocontroller-Projekten mit Interrupts ist die Unterscheidung wichtig.
1. Drei völlig unterschiedliche Probleme
Stell dir vor, zwei Ausführungskontexte greifen auf dieselben Daten zu:
Hauptprogramm
│
│
▼
gemeinsame Daten
▲
│
│
Interrupt
Es gibt dabei drei verschiedene Fragen:
A. Darf der Compiler den Zugriff wegoptimieren?
→ volatile
B. Ist ein Zugriff atomar und zwischen Threads korrekt synchronisiert?
→ _Atomic
C. In welcher Reihenfolge werden Speicherzugriffe für andere Ausführungskontexte sichtbar?
→ Memory Ordering / Memory Barriers
Diese drei Dinge werden häufig miteinander verwechselt.
2. Was volatile tatsächlich macht
Nehmen wir:
volatile int flag;
Wenn du schreibst:
while (!flag) {
}
muss der Compiler davon ausgehen, dass sich flag zwischen zwei Lesezugriffen verändern kann.
Ohne volatile könnte ein optimierender Compiler möglicherweise daraus machen:
int temp = flag;
while (!temp) {
}
Denn aus Sicht des normalen C-Programms gibt es keinen Grund anzunehmen, dass flag plötzlich von außen verändert wird.
Mit:
volatile int flag;
sind die Zugriffe auf das Objekt observable accesses und dürfen nicht einfach wie normale Zugriffe wegoptimiert werden.
3. Was volatile NICHT macht
volatile macht einen Zugriff nicht automatisch atomar.
Das hier:
volatile int counter;
counter++;
ist nicht zwingend eine unteilbare Operation.
Vereinfacht:
LOAD counter
ADD 1
STORE counter
Wenn zwei Ausführungskontexte gleichzeitig arbeiten:
A: LOAD counter → 10
B: LOAD counter → 10
A: ADD 1 → 11
B: ADD 1 → 11
A: STORE → 11
B: STORE → 11
Ergebnis:
11
obwohl man vielleicht 12 erwartet hätte.
volatile verhindert das nicht.
4. _Atomic löst ein anderes Problem
C11 bietet dafür:
#include <stdatomic.h>
atomic_int counter;
Dann:
atomic_fetch_add(&counter, 1);
ist eine atomare Operation.
Zwei Threads können gleichzeitig:
atomic_fetch_add(&counter, 1);
ausführen, ohne dass eine Erhöhung verloren geht.
5. volatile vs. _Atomic
Eine gute Merkhilfe:
volatile
↓
"Der Wert könnte sich außerhalb meines normalen
Programmflusses verändern."
_Atomic
↓
"Mehrere Ausführungskontexte dürfen sicher
auf dieses Objekt zugreifen."
Oder noch kürzer:
volatile → Sichtbarkeit gegenüber Compiler
atomic → Synchronisation zwischen Ausführungskontexten
6. Ein klassisches Beispiel
Angenommen, ein Thread erzeugt Daten:
data = 42;
ready = 1;
Ein anderer Thread wartet:
while (!ready)
;
printf("%d\n", data);
Mit einfachen Variablen besteht hier ein Synchronisationsproblem.
Auch:
volatile int ready;
macht daraus noch keine saubere Thread-Synchronisation.
7. Mit C11-Atomics
Wir können schreiben:
#include <stdatomic.h>
int data;
atomic_bool ready = false;
Produzent:
data = 42;
atomic_store_explicit(
&ready,
true,
memory_order_release
);
Konsument:
while (!atomic_load_explicit(
&ready,
memory_order_acquire
)) {
}
printf("%d\n", data);
Jetzt haben wir eine definierte Synchronisation.
Der entscheidende Punkt ist:
Produzent Konsument
data = 42
│
▼
release store ───────────────► acquire load
│
▼
data == 42
Der release-Store veröffentlicht die vorherigen Speicherzugriffe.
Der acquire-Load stellt sicher, dass die entsprechenden vorherigen Schreibvorgänge sichtbar werden.
8. memory_order_release
Bei:
atomic_store_explicit(
&ready,
true,
memory_order_release
);
bedeutet release vereinfacht:
Alles, was ich vor diesem Store geschrieben habe, darf nicht einfach hinter den Store verschoben werden.
Also:
data = 42;
data2 = 123;
atomic_store_explicit(
&ready,
true,
memory_order_release
);
Die Veröffentlichung von ready kommt logisch nach den Daten.
9. memory_order_acquire
Auf der anderen Seite:
atomic_load_explicit(
&ready,
memory_order_acquire
);
bedeutet vereinfacht:
Nach dem erfolgreichen Acquire dürfen nachfolgende Zugriffe nicht einfach vor diesen Zugriff verschoben werden.
Damit:
if (atomic_load_explicit(
&ready,
memory_order_acquire))
{
use(data);
}
data korrekt nach der Synchronisation gelesen wird.
10. Memory Ordering ist nicht dasselbe wie Memory Barrier
Hier wird die Terminologie etwas kompliziert.
Eine Memory Barrier/Fence ist eine konkrete Synchronisationsoperation.
C11 bietet beispielsweise:
atomic_thread_fence(memory_order_release);
oder:
atomic_thread_fence(memory_order_acquire);
Atomare Operationen mit acquire/release enthalten aber bereits die notwendige Speicherordnung für den jeweiligen Synchronisationsfall.
Deshalb braucht man häufig keine explizite Fence.
11. Warum Memory Barriers überhaupt notwendig sind
Moderne Prozessoren und Compiler dürfen Anweisungen umordnen, wenn das beobachtbare Verhalten innerhalb eines einzelnen Threads gleich bleibt.
Beispielsweise könnte man sich vorstellen:
a = 1;
b = 2;
als:
a = 1
b = 2
oder intern:
b = 2
a = 1
Wenn ein einzelner Thread beide Werte nur danach liest, ist das normalerweise egal.
Bei zwei Ausführungskontexten kann es aber relevant werden.
12. Compiler-Reordering vs. CPU-Reordering
Es gibt sogar zwei verschiedene Ebenen:
C-Quellcode
│
▼
Compiler
│
│ kann Anweisungen umordnen
▼
Maschinencode
│
▼
CPU
│
│ kann Speicherzugriffe unterschiedlich
│ sichtbar machen
▼
Speicher
Eine korrekte Synchronisation muss gegebenenfalls beide Ebenen berücksichtigen.
Deshalb ist ein simples:
volatile
kein Ersatz für eine Speicherbarriere.
13. Warum das bei ARM besonders interessant ist
Bei modernen ARM-Prozessoren, also beispielsweise vielen Cortex-M- und Cortex-A-Systemen, gibt es ebenfalls definierte Speicherordnungen und entsprechende Hardwaremechanismen.
Eine C11-Atomic-Operation kann vom Compiler beispielsweise in entsprechende ARM-Instruktionen übersetzt werden.
Das ist einer der großen Vorteile von _Atomic:
Du formulierst:
atomic_store_explicit(
&flag,
true,
memory_order_release
);
und der Compiler erzeugt die für die jeweilige Architektur erforderliche Implementierung.
Du musst normalerweise nicht selbst ARM-Assembler schreiben.
14. Interrupts sind ein Sonderfall
Jetzt kommen wir zu deinem wahrscheinlich wichtigsten Anwendungsfall.
Angenommen:
volatile bool event = false;
Interrupt:
void TIMER_IRQHandler(void)
{
event = true;
}
Hauptprogramm:
while (1)
{
if (event)
{
event = false;
process_event();
}
}
Das ist bei Mikrocontrollern ein sehr verbreitetes Muster.
Und volatile ist hier sinnvoll.
Warum?
Weil event durch den Interrupt verändert wird.
15. Aber ist das vollständig sicher?
Nicht unbedingt.
Es gibt mehrere Fragen.
Ist bool atomar?
Auf einer bestimmten MCU wahrscheinlich ja.
Aber C garantiert dir nicht pauschal für jedes Objekt und jede Plattform dieselbe Hardware-Eigenschaft.
Gibt es noch andere Daten?
Zum Beispiel:
volatile bool event;
int adc_value;
Interrupt:
void ADC_IRQHandler(void)
{
adc_value = read_adc();
event = true;
}
Hauptprogramm:
if (event)
{
printf("%d\n", adc_value);
}
Jetzt haben wir ein Ordering-Problem.
Wir wollen:
adc_value schreiben
↓
event setzen
↓
Hauptprogramm sieht event
↓
Hauptprogramm liest adc_value
und nicht:
event setzen
↓
Hauptprogramm sieht event
↓
adc_value möglicherweise noch nicht korrekt sichtbar
Auf vielen kleinen MCUs funktioniert das einfache Muster in der Praxis aufgrund der konkreten Architektur und Compiler-Annahmen.
Aber wenn du es formal sauber und portabel machen willst, brauchst du eine geeignete Synchronisation.
16. _Atomic für ein Interrupt-Flag
C11 erlaubt:
#include <stdatomic.h>
static atomic_bool event = false;
ISR:
void TIMER_IRQHandler(void)
{
atomic_store_explicit(
&event,
true,
memory_order_release
);
}
Hauptprogramm:
if (atomic_load_explicit(
&event,
memory_order_acquire))
{
process_event();
}
Das ist semantisch wesentlich stärker als:
volatile bool event;
17. Aber: Atomics in einer ISR sind nicht automatisch harmlos
Das ist ein sehr wichtiger Embedded-Punkt.
C11 sagt dir nicht:
Jede Atomic-Operation ist garantiert interrupt-sicher.
Eine Atomic-Operation kann auf einer Plattform beispielsweise intern eine Bibliotheksfunktion aufrufen oder komplexere Synchronisation benötigen.
Für eine ISR möchte man normalerweise:
- deterministische Laufzeit
- keine Locks
- keine Blockierung
- möglichst wenige Instruktionen
Deshalb sollte man prüfen, ob der verwendete Atomic-Typ lock-free ist.
Zum Beispiel:
atomic_is_lock_free(&event)
kann Auskunft geben.
Noch besser ist die Dokumentation des konkreten Compilers und der MCU.
18. Ein typischer Embedded-Sonderfall: Hardware-Register
Hier ist volatile genau das richtige Werkzeug.
Beispielsweise:
#define GPIO_STATUS \
(*(volatile uint32_t *)0x40000000u)
Dann:
while (!(GPIO_STATUS & 1))
;
Hier darf der Compiler nicht einfach einmal lesen und das Ergebnis wiederverwenden.
Denn die Hardware kann das Register jederzeit verändern.
_Atomic wäre hier nicht das eigentliche Konzept.
Denn wir wollen nicht sagen:
Mehrere Threads verändern dieses Objekt atomar.
Wir wollen sagen:
Dieses Objekt repräsentiert Hardware und jeder Zugriff ist relevant.
19. const volatile bei Hardware
Besonders schön ist:
#define STATUS \
(*(const volatile uint32_t *)0x40000000u)
Das bedeutet:
const
↓
Programm darf nicht schreiben
volatile
↓
Hardware darf den Wert jederzeit verändern
Also beispielsweise:
uint32_t status = STATUS;
ist erlaubt.
Aber:
STATUS = 123;
nicht.
Das ist eine der wichtigsten praktischen Anwendungen von const volatile.
20. Memory Barrier bei Hardware
Es gibt noch einen weiteren Fall.
Angenommen, du schreibst:
DATA_REG = value;
CONTROL_REG = START;
Du willst möglicherweise sicherstellen:
DATA_REG schreiben
↓
CONTROL_REG schreiben
↓
Hardware startet
Hier kann eine Hardware-Memory-Barrier erforderlich sein.
Bei GCC/Clang findet man beispielsweise Architekturfunktionen oder Builtins wie:
__atomic_thread_fence(__ATOMIC_RELEASE);
Aber bei Embedded-Programmierung sollte man sich hier an die Vorgaben des konkreten MCU-Herstellers halten.
Denn es gibt einen Unterschied zwischen:
Compiler Barrier
und:
CPU/Hardware Memory Barrier
und:
C11 Atomic Memory Ordering
21. C11 Atomic Memory Orders
C11 stellt mehrere Memory Orders bereit:
memory_order_relaxed
memory_order_consume
memory_order_acquire
memory_order_release
memory_order_acq_rel
memory_order_seq_cst
In der Praxis sind besonders diese vier wichtig:
relaxed
atomic_fetch_add_explicit(
&counter,
1,
memory_order_relaxed
);
Garantiert Atomizität, aber keine zusätzliche Reihenfolge anderer Speicherzugriffe.
Gut für:
Statistiken
Zähler
Referenzzähler in bestimmten Designs
release
Für den Produzenten:
atomic_store_explicit(
&ready,
true,
memory_order_release
);
acquire
Für den Konsumenten:
atomic_load_explicit(
&ready,
memory_order_acquire
);
Das klassische Paar:
PRODUCER
│
Daten schreiben
│
▼
release store
│
│
Synchronisation
│
▼
acquire load
│
▼
Daten lesen
CONSUMER
seq_cst
Die stärkste und einfachste Variante:
atomic_store(
&ready,
true
);
Wenn du keine Memory Order angibst, ist die Standardeinstellung:
memory_order_seq_cst
Das ist die strengste Ordnung.
Für den Einstieg ist das oft die beste Wahl.
22. Praktischer Vergleich
Nur volatile
volatile bool flag;
Gut für:
Hardware-Register
Interrupt-induzierte Änderungen
Memory-mapped I/O
Aber:
keine allgemeine Thread-Synchronisation
keine Atomizitätsgarantie
keine allgemeine Memory-Ordering-Garantie
_Atomic
atomic_bool flag;
Gut für:
Threads
Lock-free Kommunikation
atomare Zustandsänderungen
Synchronisation
volatile _Atomic
Das kann tatsächlich sinnvoll sein:
volatile atomic_int register_shadow;
Aber nur, wenn beide Eigenschaften gebraucht werden.
Das ist allerdings deutlich seltener.
23. Ein besonders wichtiges Beispiel: Daten + Flag
Angenommen:
struct sensor_data {
int temperature;
int humidity;
};
static struct sensor_data data;
static volatile bool ready;
ISR:
void SENSOR_IRQHandler(void)
{
data.temperature = read_temperature();
data.humidity = read_humidity();
ready = true;
}
Hauptprogramm:
if (ready)
{
printf(
"%d %d\n",
data.temperature,
data.humidity
);
ready = false;
}
Das funktioniert auf vielen Embedded-Systemen in der Praxis.
Aber die saubere Synchronisationsidee ist:
ISR
temperature = ...
humidity = ...
│
│
▼
release
│
▼
ready = true
│
│
▼
========================
│
▼
acquire
│
▼
Hauptprogramm
data.temperature
data.humidity
Das Flag ist dabei nicht einfach nur ein Flag.
Es ist ein Synchronisationspunkt.
24. Was ich bei deinen Mikrocontroller-Projekten verwenden würde
Ich würde die Dinge gedanklich in drei Kategorien aufteilen:
Hardware-Register
volatile uint32_t *reg;
oder:
const volatile uint32_t *reg;
Einfaches Interrupt-Flag
Wenn der konkrete MCU/Compiler das garantiert und der Zugriff natürlich atomar ist:
static volatile bool event;
Das ist ein sehr typisches Embedded-Muster.
Komplexere Kommunikation zwischen ISR und Hauptprogramm/Threads
Wenn dein Compiler/Target C11-Atomics sinnvoll unterstützt:
static atomic_bool event;
mit geeigneter Memory Order.
Bei Datenstrukturen und mehreren gemeinsam genutzten Variablen sollte man die Synchronisation bewusst entwerfen und nicht einfach überall volatile davor schreiben.
25. Und noch eine wichtige Falle: volatile schützt nicht vor Race Conditions
Dieser Code:
volatile int counter;
void interrupt(void)
{
counter++;
}
int main(void)
{
counter++;
}
ist trotz volatile problematisch.
Denn beide machen:
lesen
ändern
schreiben
gleichzeitig.
Wenn der Zähler wirklich atomar erhöht werden soll, wäre beispielsweise:
atomic_int counter;
und:
atomic_fetch_add(&counter, 1);
das richtige Konzept — sofern diese Atomic-Operation auf deinem konkreten MCU in einer ISR geeignet ist.
Alternativ kann man im Embedded-Bereich einen kurzen kritischen Abschnitt verwenden:
disable_interrupts();
counter++;
enable_interrupts();
Das ist ebenfalls eine Form der Synchronisation, aber natürlich plattformspezifisch.
26. Die wichtigste Tabelle
| Problem | Werkzeug |
|---|---|
| Hardware kann Wert verändern | volatile |
| Hardware-Register lesen | volatile |
| Read-only Hardware-Register | const volatile |
| Atomarer Zugriff | _Atomic |
| Thread-Synchronisation | _Atomic + Memory Order |
| Compiler darf Zugriff nicht entfernen | volatile |
| Reihenfolge zwischen Threads | Acquire/Release |
| CPU-Speicherbarriere | Atomic Fence / CPU Barrier |
| Race Condition verhindern | Atomics, Mutex oder kritischer Abschnitt |
| ISR verändert Flag | häufig volatile, ggf. _Atomic |
| ISR verändert komplexe Daten | Synchronisationskonzept erforderlich |
Die vier Begriffe in einem Bild
GEMEINSAME DATEN
│
┌────────────────┼────────────────┐
│ │ │
volatile _Atomic Barrier
│ │ │
▼ ▼ ▼
"Zugriff ist "Zugriff ist "Diese
tatsächlich atomar und Speicher-
relevant" kann operationen
synchronisieren" haben diese
Ordnung"
│ │ │
└────────────────┴────────────────┘
│
const als Zusatz
│
▼
"Über diesen Zugriff
nicht veränderbar"
Für Embedded C ist die vielleicht wichtigste Merkhilfe daher:
volatileist für Dinge, die sich verändern können._Atomicist für Dinge, auf die mehrere Ausführungskontexte zugreifen. Eine Memory Barrier regelt die Reihenfolge dieser Zugriffe.
Und bei einem Memory-Mapped Hardware-Register ist volatile richtig, während bei einer Kommunikation zwischen zwei Threads _Atomic bzw. Mutex/Lock richtig ist. Ein volatile vor eine normale globale Variable zu setzen, um damit eine Race Condition zu "beheben", ist dagegen ein klassischer Fehler.