Arduino Uno Bare-Metal-Programmierung

Ich entdeckte die Arduino-Boards vor einigen Jahren, experimentierte gelegentlich damit, fand aber nie wirklich den Zugang. Ich sah wenig Mehrwert darin, einfach 1:1 vorgefertigte Projekte, Schaltpläne und Code-Snippets aus Tutorials oder Büchern nachzubauen. Ich wollte immer verstehen, wie die Dinge intern funktionieren, und wenn ich auf Probleme stoße, selbst eine Lösung finden können.
Da ich hauptsächlich Erfahrung in der Mechanik und Softwareentwicklung habe, schien mir Elektronik kompliziert. Ich hatte die Grundlagen der Elektronik in meiner technischen Hochschule gelernt, aber das meiste davon ist mittlerweile in Vergessenheit geraten – bis auf das Ohmsche Gesetz.
Also wollte ich mehr über Bare-Metal-Programmierung und C-Programmierung im Allgemeinen lernen. Viele der Programmiersprachen, die ich kenne, basieren auf C oder haben ähnliche Syntaxelemente, darunter C#, Python oder TypeScript. Diesmal wollte ich tiefer in das Thema einsteigen und mich mit Low-Level-Speicherverwaltung beschäftigen.
Während ich mich mit Zeigern und Bitoperationen auseinandersetzte, wollte ich gleichzeitig auf einem echten Mikrocontroller experimentieren. So begann meine Reise mit der Bare-Metal-Programmierung auf dem Arduino Uno ATmega328P-Mikrocontroller.
Erste Schritte
Ein klassisches „Hello World“-Beispiel in der Mikrocontroller-Programmierung ist das Blinken einer LED. Viele Arduino-Boards haben bereits eine eingebaute LED. Diese LED ist mit einem digitalen Pin verbunden, dessen Nummer je nach Board-Typ variieren kann. Auf meinem Board war die LED mit dem digitalen Pin 13 verbunden (im Code über die Konstante LED_BUILTIN
verfügbar).
Mit der Arduino-IDE kann man dieses einfache Beispiel folgendermaßen umsetzen:
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
digitalWrite(LED_BUILTIN, LOW);
delay(1000);
}
Diese Implementierung nutzt die setup
- und loop
-Funktionen und toggelt die LED mit einer Verzögerung von einer Sekunde an und aus. Aber wie schwierig wäre es, dasselbe in reinem C ohne Abstraktionsschichten oder Helferbibliotheken umzusetzen?
Wie funktioniert der Speicher eines Mikrocontrollers?
Jeder Mikrocontroller besitzt reservierte Speicherbereiche (sogenannte Register oder Ports), die für den Betrieb essenziell sind. Der freie Speicher kann zur Speicherung von Variablen und Code verwendet werden.
Ein Register im AVR-Mikrocontroller besteht aus 8 Bits. Die Bits werden von rechts nach links gezählt, beginnend bei null:
- Bit 7: Most Significant Bit (MSB)
- Bit 0: Least Significant Bit (LSB)
Ein Register kann auf verschiedene Arten dargestellt werden:
- Binär:
0b10110011
- Dezimal:
179
- Hexadezimal:
0xB3
Kurzüberblick über C-Zeiger
Ein Zeiger ist eine Variable, die die Speicheradresse einer anderen Variable speichert. Damit ein Zeiger korrekt auf eine Variable verweist, sollte er denselben Datentyp haben, um sicherzustellen, dass genügend Speicherplatz für den Wert vorhanden ist.
Zeiger können auch direkt Speicheradressen speichern, ohne auf eine Variable zu verweisen. C ermöglicht außerdem Zeiger auf Zeiger und darüber hinaus. Falls du tiefer in dieses Thema eintauchen möchtest, gibt es zahlreiche Tutorials dazu online.
Zugriff auf die Register
Der Zugriff auf die Register erfolgt normalerweise byteweise, es wird also immer das gesamte Byte des Registers gelesen oder geschrieben.
Um zu prüfen, ob ein bestimmtes Bit gesetzt ist oder nicht, muss man eine Bitmaske verwenden. Das Setzen oder Löschen erfolgt auf einzelnen Bits des Registers mit einer geeigneten Schreibbitmaske, da die anderen Bits unverändert bleiben sollten, da sie im Mikrocontroller bestimmte Funktionen steuern können.
Setzen eines Bits eines Registers
Nehmen wir an, dass Bit2 eines Registers gesetzt werden soll. Die anderen Bits des Registers sollen nicht verändert werden. Dies kann durch eine bitweise ODER-Verknüpfung des Registers mit einer Bitmaske erreicht werden.

Dies kann mit dem folgenden Code erreicht werden:
REGx |= 0b00000100;
Ein Bit eines Registers löschen
Nehmen wir an, dass Bit2 eines Registers gelöscht werden soll. Die anderen Bits des Registers sollen unverändert bleiben. Dies kann durch eine bitweise UND-Verknüpfung des Registers mit einer Bitmaske erreicht werden.

REGx &= 0b11111011;
Oder kürzer mit einer Negation:
REGx &= ~0b00000100;
Speicheradressen der Register
Nachdem man verstanden hat, wie Bits manipuliert werden, muss man die richtigen Registeradressen finden. Dafür lohnt sich ein Blick ins Datenblatt des Mikrocontrollers.

Image by Arduino Uno Store from arduino.cc
Hier können Sie sehen, dass D13 auch PB5 ist, was Port B - Nummer 5 entspricht. Als nächstes müssen wir einen Blick in das Datenblatt werfen.

Als Erstes müssen Sie das DDRB-Register konfigurieren und das DDB5-Bit setzen. Damit setzen Sie PORTB5 als Ausgangspin.
Licht einschalten
Um den Pin zu aktivieren und das Licht einzuschalten, müssen Sie das PORTB-Register auf Nummer 5 setzen.

PORTB = PORTB | (1 << PORTB5);
Um das Bit zu löschen, verwenden Sie die Bitmaske erneut wie folgt:
PORTB = PORTB & ~(1 << PORTB5);
Jetzt verwenden wir die Verzögerungshilfsfunktion (util/delay.h), um die Verzögerung der Taktzählung zu vereinfachen.
Das endgültige Programm sollte ungefähr so aussehen:
#include <avr/io.h>
#include <util/delay.h>
int main(void) {
// PORTB5 als Ausgabe festlegen
DDRB = DDRB | (1 << DDB5);
while(1)
{
// PORTB5 festlegen
PORTB = PORTB | (1 << PORTB5);
// warten
_delay_ms(1000);
// PORTB5 löschen
PORTB = PORTB & ~(1 << PORTB5);
// noch etwas warten
_delay_ms(1000);
}
}
Build-Prozess
Wenn Sie versuchen möchten, den Code ohne die Arduino IDE zu erstellen, können Sie das mit einigen Befehlszeilentools tun. Auf dem Mac können Sie brew verwenden, um den avr-gcc-Compiler, avr-binutils und avrdude zu installieren.
brew tap osx-cross/avr
# Entfernung vor dem Upgrade erforderlich
brew remove avr-gcc avr-binutils avr-libc
# avr-libc ist jetzt in avr-gcc enthalten
brew install avr-gcc avr-binutils
brew install avrdude
Avrdude
Dieses kleine Dienstprogramm wird zum Herunterladen/Hochladen/Manipulieren des ROM- und EEPROM-Inhalts von AVR-Mikrocontrollern mithilfe der In-System-Programmiertechnik (ISP) verwendet.
avrdude -F -V -c arduino -p ATMEGA328P -P /dev/cu.usbmodem2101 -b 115200 -U flash:w:ledBlink.hex
Sie sollten “/dev/cu.usbmodem2101” in den Portnamen ändern, an den Ihr Arduino-Board angeschlossen ist.
Makefile
Um den Build-Prozess zu automatisieren, erstellen Sie ein Makefile mit dem folgenden Code:
default:
avr-gcc -Os -DF_CPU=16000000UL -mmcu=atmega328p -c -o ledBlink.o ledBlink.c
avr-gcc -o ledBlink.bin ledBlink.o
avr-objcopy -O ihex -j .text -j .data -R .eeprom ledBlink.bin ledBlink.hex
.PHONY: deploy
deploy:
avrdude -F -V -c arduino -p ATMEGA328P -P /dev/cu.usbmodem2101 -b 115200 -U flash:w:ledBlink.hex
Zuerst erstellt der C-Compiler eine Maschinencodedatei und mit Hilfe von avr-objcopy konvertieren wir die .bin in eine .hex-Datei.
$ make
Mithilfe von avrdude senden wir die generierte .hex-Datei über USB an den Mikrocontroller
$ make deploy
Fazit
Die Vorteile der Verwendung von reinem C anstelle der Arduino IDE mit der definierten HAL (Hardware Abstraction Layer) liegen in der Einsparung von viel Dateigröße. Sicher, das ist nur für Produktionsoptimierungen relevant, aber es ist gut, es im Hinterkopf zu behalten. Ich mochte die Arduino IDE und die Funktionalitäten nie, weil sich das für mich immer mehr wie Scripting als wie Programmieren anfühlte.
Aber im Gegenteil, wenn Sie einen schnellen Prototyp für eine neue Idee bauen möchten, ist es schneller und agiler, einfach ein Steckbrett mit ein paar Codeschnipseln in der IDE zusammenzustellen und Ihre Idee zu validieren. Nach der Validierung wäre es an der Zeit, den Code für Best Practices neu zu schreiben und vielleicht an einer benutzerdefinierten Leiterplatte zu arbeiten.
Für mich war diese Übung eine unterhaltsame Lernübung, und vielleicht haben Sie auch etwas Neues gelernt. Bitte lassen Sie es mich wissen, wenn Sie Fragen oder Feedback zu dem Artikel haben, ich würde mich freuen, Ihre Meinung zu hören.