Scheduler mit Erweiterung zum Mini-Betriebssystem

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche


Vorwort:
Dieser Artikel behandelt die Erstellung eines Schedulers, der zu einem Mini-OS erweitert wird. Ziel ist es, das Verständnis sowie die Funktion eines OS besser verstehen zu können. Wie so manch ein anderer habe auch ich mich gefragt, wie ein Betriebssystem eigentlich funktioniert, und - weil Nachbauen eine der besten Möglichkeit ist etwas zu verstehen - habe mir mein eigenes kleines System geschrieben. Es geht nicht darum ein „Suppa-Duppa“ OS zu schreiben, sondern zu verstehen wie größere (RTOS->Linux->Windows) funktionieren.
Von: Sebastian Balz

Warnung
Es werden gute Kenntnisse in C und Assembler, sowie gute Kenntnisse der CPU benötigt. Des Weiteren ersetzt ein selbst geschriebenes OS keinesfalls ein herkömmliches OS (RTOS …), da diese zuverlässiger und effizienter laufen und mehr Funktionen haben.



Alle Beispiele beziehen sich auf einen ARM-Cortex-M4 (SAM4SD32C), sollten jedoch auf alle gängigen µC portiert werden können. Um den Kontext-Switch zu vereinfachen werden keine Gleitkomma Berechnungen unterstützt.


Voraussetzungen


  • Gute C und Assembler Kenntnisse
  • Gute Kenntnisse des UC‘s
  • Viel Zeit und Motivation :)


Zielsetzung


Das beschriebene System soll in der Lage sein:

  • Zwischen verschiedenen Aufgaben (Tasks) zu wechseln(Context-Switch). D.h. jeder Task hat das Gefühl, dass der UC ihm gehört.
  • Fähigkeit der Task’s sich selber zu beenden oder zu pausieren, um so CPU-Ressourcen zu sparen.
  • Keinerlei Auswirkung aufs Interrupt Handling.
  • Ein Zeit Management
  • Tasks und Threads mit Return-Wert und Kill
  • Kommunikation zwischen den Tasks mit Schreib-Kontrolle


Grundlagen

Die Hauptaufgabe des Systems ist es, zwischen den verschiedenen Tasks wechseln zu können. Um dies realisieren zu können, muss man erst einmal verstehen, wie überhaupt ein Kontext ausschaut.



Register

Register sind die Speicherzellen, die direkt mit der CPU verbunden sind. Sie haben die besten Lese- und Schreibraten, sind jedoch „teuer“, weshalb in einer CPU nur relativ wenige Register vorhanden sind. In der ARM-Cortex-M4 Serie sind es 12 sog. „General-Purpose Register“ und ein paar Spezial-Register.


Register Balz.PNG

Quelle: Datenblatt Register

Stack

Der Stack ist ein LIFO (Last in, First out), d.h. der zuletzt hinzugefügte Wert wird als erstes wieder ausgelesen. Über Push (Speicher) und POP (Laden) kann man Register-Inhalte auf den Stack speichern und laden. Des Weiteren gibt es einen Stack-Pointer. Dieser zeigt immer auf die letzte Adresse im Stack. Der Stack baut sich von einer Speicherzelle aus nach unten auf. D.h., wenn ein Wert auf den Stack geschrieben wird, verringert sich die Stack-Adresse.

Jedes Programm schreibt und erstellt seine lokalen Variablen auf dem Stack (RAM). Bei einem Funktionsaufruf werden Übergabeparameter und die Rücksprungadresse im Stack hinterlegt. Die aufgerufene Funktion des Speichers (Push) hat also so viele Register auf dem Stack, wie sie selber Register benötigt, damit beim „Return“ wieder die Start-Register vom Stack geholt (POP) werden können und damit der „vor-Funktion-Zustand“ wieder hergestellt ist.



Exception Call

Wird eine Funktion durch einen Interrupt unterbrochen, werden R0-R3,R12, LR, PC und xPSR (eine Verbindung aus “Interrupt Program Status Register“, “Application Program Status Register“, „Program Status Register“ und „Execution Program Status Register“) auf den Stack gepusht. Stack Balz.PNG

Quelle Datenblatt: Interrupt Stack
Der µC schreibt beim “Interrupt entry“ eine EXC_Return_Value ins Link um zu signalisieren, dass gerade eine Interrupt-Routine aktiv ist.

EXC Return Balz.PNG

Quelle: Datenblatt Exc_Return

Beim Verlassen des ISRs müssen nun nur noch die Register R0-R3, LR,SP und die Status-Register wieder hergestellt werden.

Context-Switch

Theorie

Um nun den aktuellen Kontext zu wechseln, müssen in einem Interrupt sämtliche Register gespeichert werden (PUSH R0-R12) und der Stack-Pointer auf den Stack des neuen Task‘s verschoben werden. Nun können die Register wieder hergestellt werden (POP R0-R12). Das Link-Register kann hier außer Acht gelassen werden, da es immer den Wert 0xFFFFFF9 (Return to Thread mode without floating point calculation) hat. Wenn man nun das ISR von außen betrachtet, darf man keinen Unterschied zum letzten Speicherzeitpunkt feststellen.


Move Stack Balz.jpg

Praxis

Speicher

Die Praxis ist leider etwas komplizierter: Zunächst einmal muss der Task-Stack im Speicher reserviert werden. Hierfür kann entweder malloc() verwendet werden - um dynamisch zur Laufzeit den Speicher zu reservieren - oder man „belegt“ den Speicher über Arrays. Da ein Stack-Overflow mit, dass Schlimmste ist, was unserem System passieren kann (Überschreibung der anderen Stacks/globalen Variablen), sollte die Größe des Stacks eher großzügig definiert sein.

Stackerzeugen

Da ein Stack sich von oben nach unten (aus Sicht des Speichers) aufbaut, muss der Stack-Pointer auf die Speicheradresse des letzten Array-Elementes zeigen. Da beim Kontext-Switch der letzte Zustand wiederhergestellt wird, muss nun der Stack befüllt werden:

  • Programm Counter: hier wird die Startadresse des Task‘s eingetragen
  • Link–Register: Da die Task-Ansicht keine Funktion ist und im Normalfall nicht irgendwann verlassen wird, kann dieses Feld freigelassen werden. Alternativ kann auch auf die Adresse eines Dummy-Handlers verwiesen werden, der sich dann um die Beendigung des Tasks kümmert.
  • Program Status Register: Im Status Register (xPSR) wird der Wert hinterlegt, der der CPU signalisiert, dass der aktuelle Programmabschnitt sich gerade in einem Interrupt befindet.
  • Register 0-3,12 sind die Register, die vor einem Interrupt gesichert und nach dem Interrupt wieder hergestellt werden. Da der Task noch keine Registerwerte verwendet hat, können diese Zellen frei bleiben.

Wenn nun ein Kontext-Switch ausführt wird, wird man relativ schnell feststellen, dass etwas noch nicht passt. Die Interrupt-Routine hat selber auch Variablen, die auf dem „alten“ Stack gespeichert und nach Verlassen des Stacks wieder vom Stack „gelöscht“ werden müssen. Diese müssen in dem neuen Stack berücksichtigt werden. Leider kann man hier keinen pauschalen Wert nennen, was dazu führt, dass jede Veränderung an der Interrupt-Routine Folgen haben kann. So muss z.B. bei dem neuem Stack die Anzahl der lokalen Register einbezogen werden.


Kontext wechseln

Nun, da der Speicher reserviert und der Stack aufgebaut worden ist, kann man den Kontext wechseln. Hierfür müssen alle General-Purpose-Register auf den Stack gespeichert werden. Da zu einem späteren Zeitpunkt der Stack wieder zurück gewechselt werden soll, muss nun der aktuelle Stack-Pointer gesichert werden, z.B. in der Task_List (siehe Task_Liste). Der „alte“ Kontext ist nun gesichert, und so kann der „Neue“ geladen werden. Zunächst muss der Stack-Pointer umgezogen werden. Um sicher zu stellen, dass der µC nun den neuen Stack auch verwendet, muss ein „Pipeline flush“ getätigt werden. Hierfür ist im TUMB2-Befehlssatz der Befehl „ISB“ (Instruction Synchronization Barrier) vorhanden, der direkt nach dem Stack-Pointer-Tausch ausgeführt werden sollte. Nun können die Register 0-12 wieder hergestellt werden. Das Linkregister verändert sich nicht, da das Exc_return Value immer gleich ist.

Wird nun die Interrupt Routine verlassen, werden die lokalen Variablen vom Stack gelöscht und die ISR-Inhalte gePOP’t. Der Prozessor springt dann „zurück“ in den neuen Task.



BSP1

Im folgenden Bild kann man gut sehen, wie zwei Tasks (weiße Kästen: PWM) abwechselnd aufgerufen werden. Dargestellt ist die Aktivität der Tasks als Funktion der Zeit; alle 50 ms wird in diesem Beispiel der Task gewechselt:

Context Switch.JPG


Task-Liste

Damit das System weiß, wie viele und welche Tasks gerade ausgeführt werden, muss eine Liste erstellt werden, in der die jeweiligen IDs der Tasks enthalten sind, sowie:

  • Stack-Pointer
  • Stack Start Adresse
  • Stack-Size

Optional kann in dieser Liste auch noch:

  • Task Status/Message
  • Thread Return_Value

enthalten sein.

Stack Pointer

Um bei einem Kontext Switch den alten Task wieder herzustellen, muss bekannt sein, an welcher Stelle im Speicher der Stack liegt.

Stack Start Adresse

Ist relevant, wenn überprüft werden soll, ob es einen Stack Overflow gab. Sollte es ein Stack-Overflow geben muss dies gemeldet und das System wieder auf den Ursprungszustand gesetzt werden.

Stack-Size

Wird in Verbindung mit der Stack_Start Adresse für die Overflow-Erkennung benötigt.

Task Status

Hier kann ein Task seinen Status mitteilen

  • IDLE
  • Started(läuft der Task gerade?)
  • Oder soll der Stack des Task‘s aufgebaut werden
  • Lese Fehler (siehe Speicher Management)

Return Value

wird ein Thread auf geplante Weise verlassen – (d.h. er beendet sich selber) –kann dieser auch ein Return_Value als Ergebnis hinterlegen. Hat der Thread z.B. die Aufgabe, eine Nutzereingabe zu erkennen und zu analysieren, so kann der erstellende Task (der die ID kennt) über das Ergebnis informiert werden.

Zeitmanagement

System-Zeit

Oftmals kommt es vor, dass ein Task eine Tätigkeit alle x ms ausführen soll. Da jedoch die Tasks möglichst unabhängig sein sollen (sie wissen also nicht mit welcher Taktrate der Prozessor läuft), ist es sinnvoll, über eine RTT oder einen Timer/Counter die Zeit mitzuzählen und diese über eine Funktion allen Tasks und Threads zur Verfügung zu stellen. Auch ist es sinnvoll, eine Funktion bereit zu stellen, die überprüft ob eine bestimmt Zeit vergangen ist; unter Berücksichtigung, dass die System-Zeit irgendwann wieder bei 0 anfängt. Eine solche Funktion könnte wie folgt aussehen:


int vergangene_zeit(int os_lasttime, int soll_time){
	int time_know = get_os_time(); // hole die aktuelle Systhem Zeit
	int delta;
	if (zeit-lasttime <0) // hat ein System-Zeit Wraparound stattgefunden 
	{
		zeit += os_max_time; // addiere Max SysthemZeit drauf
	}
	delta = zeit-lasttime; 
	return delta >= sollzeit;
}


So kann ein Task einfach in einer

 while(!(vergangene_zeit(lasttime,soll_time))){}

darauf warten, dass die Soll Zeit abgelaufen ist.


Kontext-Switch

Natürlich ist es auch wichtig, dass der Kontext-Switch zentral gesteuert wird. Hier ist es empfehlenswert, den Real-Time-Timer oder einen Timer/Counter zu verwenden, der die Systemzeit managt. Über ein Static counter kann/soll eine bestimmte Anzahl an Zyklen bestimmt werden, bei denen der Kontext-Switch getriggert wird.

IDLE

Hat man mehrere Tasks die darauf warten, dass Zeit vergeht, wird viel Rechenleistung verbraucht, da die Tasks mit Nichtstun beschäftigt sind, während andere Tasks diese Rechenzeit gut brauchen könnten. Um diesem Problem zu entgehen, sollte ein Task in der Lage sein, sich als inaktiv zu deklarieren. Dies könnte über einen Status-Bit geschehen, welches der Time_manager bei jedem Erhöhen der Systemzeit überprüft und den Kontext-Switch triggert. Eine bessere Lösung wäre jedoch, zusätzlich zur IDLE Funktion einen Software Interrupt auszulösen, welcher den TC_Handler oder RTT_Handler des Zeitmanagements triggert. So kann im Zeitmanagement geprüft werden, ob ein Task IDLE ist, oder ob der Interrupt auf herkömmliche Weise generiert worden ist. Sollten ein Großteil oder alle Task IDLE sein, sollte die CPU in ein LOW-Power-Mode wechseln.


Beispiel 1

Im folgenden Beispiel kann gut erkannt werden, wie sich Task1 zur Hälfte der möglichen Laufzeit als IDLE deklariert und einen Kontext-Switch triggert.


Task IDLE.JPG Saleae Logic Analyser: Task IDLE

Tasks und Threads zur Laufzeit erstellen und beenden

Eine weite Stärke eines solchen Systems ist es, dass dann ohne großen Aufwand einfach ein neuer Task oder Thread hinzugefügt werden kann. Ein Thread hat eine feste Aufgabe die er erfüllen soll. Ist diese erfüllt, können weitere Ereignisse getriggert werden, oder der Thread kann beendet werden.


Starten

Hierfür muss lediglich ein neuer Stack mit Linkregister, Einsprung-Adresse etc. hinzugefügt werden und über die Task_Liste dem Zeitmanagement mitgeteilt werden, sodass es einen neuen Taskeintrag gibt, der gestartet werden soll.

Beispiel 2

Hier kann gut erkannt werden, wie Thread 1 während der Laufzeit erstellt wird und von nun an vom Scheduler aufgerufen wird. Hierdurch werden jedoch Task1 und Task2 seltener aufgerufen.
Thread start.JPG

Beenden

Ein Thread kann auf zwei Arten beendet werden. Die reguläre Art ist, dass er sich selber schließt. Hierfür kann eine Funktion geschrieben werden, die der Task_Liste mitteilt, dass der Task beim nächsten Kontext-Switch nicht mehr aufgerufen werden soll. Optional kann in der Task_List ein Return Value hinterlegt werden, damit der Ersteller des Tasks z. B. ein Ergebnis erhält. Hierbei ist wichtig, zuerst den Return_value zu setzen bevor der Task als beendet definiert wird, da ansonsten ein ungünstiges Timing mit dem Kontext-Switch das Schreiben des Return_Values verhindert. Nachdem Return_value und der Task als beendet definiert worden sind, sollte der Task einen Task_IDLE an den Zeitmanager senden, um so den Task direkt zu verlassen und keine Rechenzeit zu vergeuden. Auch ist es nötig, dass der Ersteller des Threads die ID des Threads in der Task_Liste erhält, da hinter der ID des Threads auch der Return_Value gespeichert wird. Eine andere Art ein Thread zu beenden ist, ihn zu killen. Wenn z. B. die Ausführung des Threads nicht mehr relevant ist, weil das Ergebnis zu spät kommen würde (Real-Time-OS), oder die Ausführung des Threads nicht mehr benötigt wird, kann man einen Thread auch über die Task-Liste von außen beenden. Hierbei muss nur das Task_Active-Bit gelöscht werden.

Beispiel 3


Thread closing.JPG

Hier kann gut erkannt werden, wie Thread1 wieder geschlossen wird. Dadurch werden Task 1 und Task 2 wieder öfter aufgerufen.

Interrupts


Eigentlich sollte es keinerlei Probleme bereiten, einen Interrupt auszuführen, da dieser den aktuellen Task nicht berührt. Es muss lediglich auf zwei Punkte Rücksicht genommen werden:

  • Der Zeitmanager muss in der niedrigsten Priorität laufen. Würde er mit einer höheren Priorität laufen während gerade ein anderer Interrupt aktiv ist, würden die gespeicherten Register in den falschen Stack abgelegt. Die wieder herzustellenden Register kämen aus einem Stack, in dem keine Register gesichert worden sind. Dieser Vorgang würde beide Stacks zerstören.
  • Während des Kontext Switches dürfen keine anderen Interrupts aktiv werden, da es auch hier zu Problemen mit den Stacks kommen könnte.

Deshalb ist es empfehlenswert, den Zeitmanager in der niedrigsten Priorität laufen zu lassen und während der Ausführung andere Interrupts zu unterbinden.


Erweiterung zum OS

Von nun an sind die Übergänge zu einem Betriebssystem fließend.

Priorisierung

Es ist wichtig, einzelne Tasks abhängig von ihrer Wichtigkeit zu unterscheiden. So kann es z. B. einen Thread geben, der innerhalb kürzester Zeit viel Rechenzeit benötigt. Nach dem bisherigen System würde der Thread jedoch genauso viel Rechenzeit wie alle anderen Threads bekommen. Möchte man dies vermeiden, muss eine Priorisierung der Task eingeführt werden. Dazu gibt es zwei Möglichkeiten:

  • Task erhält längere Laufzeit
  • Task wird öfters ausgeführt

Mehr Laufzeit:

Eine Möglichkeit ist, einem Task beim Initiieren eine erhöhte Laufzeit zuzuweisen. So kann der Scheduler überprüfen, ob die erhöhte Task-Laufzeit abgelaufen ist und dann erst zum nächsten Task wechseln.

Vorteile

  • Leichtes Umsetzen: ein neuer Eintrag in der Task-Liste
  • Einfache Übersicht darüber wie viel Rechenzeit jeder Task am Ende innerhalb eines Zeitabschnittes hat

Nachteile:

  • Nicht Flexibel: hat ein Task sehr viel Rechenzeit bekommen, werden alle anderen für den gesamte Zeitraum blockiert

Öfters Toggeln

Eine andere Möglichkeit ist es, die Laufzeit nicht zu verändern, sondern Zyklen zu Definieren. Es gibt eine Basis Laufzeit und jeder Task erhält anhand der Priorisierung ein vielfaches dieser Laufzeit Pro Zyklus. So wird jeder Task abhängig seiner Priorität ausgeführt. BSP. Ein Task mit der Priorität „8“ wird 8 mal öfters ausgeführt als ein Task mit der Priorität „1“ jedoch nur 2x öfters als ein Task mit der Priorität „4“

Vorteile:

  • Sehr Flexibler: Es werden immer noch alle Task Regelmäßig ausgeführt(wenn auch seltener)

Nachteile:

  • Komplexe Umsetzung
  • Schwerere Bestimmung der Rechen Zeit, die ein Task in einem bestimmten Zeitabschnitt hat.


Speicher Management

Der erste Schritt in Richtung Betriebssystem ist ein Speicher-Management, welches in der Lage ist, Speicher zu reservieren und gegebenenfalls auch zu blockieren, sowie freizugeben. Hier kommt wieder – wie beim Stack-Aufbau - malloc() oder ein Array zum Einsatz. Der Einfachheit halber werden die einzelnen Speicher über ID’s angesprochen. Auch muss zentral geregelt werden, ob die jeweilige ID bereits in Verwendung ist.

Erstellen

Möchte ein Task oder Thread einen solchen Speicher erstellen, muss zunächst überprüft werden, ob noch Speicherplätze frei sind. In diesem Fall kann die ID der Speicherzelle zurückgegeben werden (return-Wert). Andernfalls sollte ein Error_Value zurück geliefert werden.

Freigeben

Um Speicher zu sparen, müssen Task und Threads in der Lage sein, den Speicher wieder frei zu geben. Sollte ein Task der beim Speicher durch einen Kontext –Switch unterbrochen worden ist, auf denselben Speicher zugreifen, sollte vor dem Freigeben überprüft werden, ob und warum der Speicher gesperrt worden ist.

Speichern

Um ein ungünstiges Timing zu verhindern (z. B. ein Prozess beschreibt eine Speicherzelle und wird während des Schreibvorganges durch einen Kontext-Switch unterbrochen), sollte nun der Speicher durch einen anderen Task wieder freigegeben werden. Hierzu muss vor dem Schreibvorgang signalisiert werden, dass die jeweilige Zelle blockiert ist. Als Return kann hier dann entweder ein Error_value oder ein Success_Value übertragen werden.

Lesen

Zum Lesen muss lediglich überprüft werden, ob der Speicher der angegebenen ID reserviert ist. Als Return kann hier dann entweder der gespeicherte Wert oder ein Error_Value verwendet werden. Um zu erkennen ob es sich hier um einen Fehler handelt oder um einen gespeicherten Wert, kann über die Task_Liste (ID sollte bekannt sein!) ein Statusbit gesetzt werden.


Kommunikation mit anderen Tasks

Oftmals ist es notwendig, dass die Tasks in der Lage sind, miteinander zu kommunizieren und so Ergebnisse auszutauschen. Hierfür kann ein weiterer Eintrag in der Task-Liste weiterhelfen, der einen anderen Task darüber informiert unter welcher Speicher-ID neue Informationen verfügbar sind. Die jeweiligen Tasks müssen dazu jedoch über die ID der jeweils anderen Task Bescheid wissen.

Realtime

Je nach Anwendungsfall kann es wichtig sein, dass manche Threads innerhalb einer bestimmten Zeit ausgeführt werden müssen. Hierzu werden für die Threads Prioritäten vergeben, welche gewährleisten, dass die Threads länger oder häufiger ausgeführt werden (je nach Definition). Möglicherweise ergibt die Ausführung/Beendigung eines Threads auch nach einem bestimmten Zeitpunkt keinen Sinn mehr. Beispiel: Ein Sensor soll alle 10 ms ausgelesen werden. Der Sensor liefert immer das aktuelle Messergebnis. Wird nun der Thread (der alle 10 ms getriggert wird um einen Sensor auszulesen) von einem wichtigeren Ereignis unterbrochen und so z. B. über 50ms verzögert, so warten dann 5 getriggerte Threads darauf, CPU Zeit zugewiesen zu bekommen. Da aber immer nur der aktuelle Sensorwert ausgegeben werden kann, liefern alle 5 Threads dasselbe Ergebnis. Um nun Ressourcen zu sparen, muss erkannt werden, ob die Ausführung dieses Threads noch sinnvoll ist. Andernfalls sollten die Threads beendet werden und es kann eine Fehlermeldung generiert werden.




Threads von außen Starten, Löschen und Erstellen

Möchte man von einem Computer aus einzelne Threads erstellen starten oder Pausieren braucht eine Datenverbindung wie z.B. UART/USART oder USB zum Pc. Kann man entweder einen Background Task schreiben, dessen Aufgabe es ist auf der entsprechenden Schnittstelle zu Lesen, oder man erstellt einen Handler ins System der dies Interrupt Basiert macht. Um nun einen existierenden Task zu starten oder stoppen kann dies über ein Steuer Befehl geschehen. Die dazu ausgeführten Codes Teilen sind Identisch zu denen die auch ein Thread oder die Main-Routine Verwendet werden um neue Task zu starten oder zu erstellen. Auf diese Weise kann einfach von einem PC aus der Selbe Task mehrmals( und nach Bedarf) hintereinander gestartet werden.

Bootloader

Möchte man jedoch ein Komplet neuen Task über die Serielle Schnittstelle erstellen wird dies schwieriger. Das Grundprinzip wird hier und hier Erklärt. Theoretisch muss nur ein neuer Stack angelegt werden und der Speicher im RAM und Flash Reserviert werden. Dann muss der Übertragene Byte-Code in den Flash gespeichert werden und wäre dann ausführbar.

Linker

Jedoch weiß der neu erstellte Thread nicht wo er selber und damit wo seine eigene Funktionen liegen. Auch weiß er nicht an welche stelle der eine Variablen im Ram ablegen darf und wo nicht. Um dies zu verhindern muss vor den Compilern des Scheduler in einem Linker-Skript definiert werden auf welche Speicher Adresse für solche Fälle freigehalten werden sollen. Nun kann dem neuen Thread über ein weiteres Linker-Skript sein Speicher Bereich mitgeteilt werden.

MMU

Eine Einfachere Möglichkeit setzt voraus das die Verwendete CPU eine MMU(Memory Management Unit) hat. Diese ist in der Lage mit Virtuellen Adresse zu Arbeiten. So wird die Arbeit des Mappens der Scheduler Überlassen der nur noch ausreichend Speicher für die Task Reservieren muss.

Fehlersuche und Kontrolle

Register Kontrolle

Zunächst ist es wichtig zu überprüfen, ob alle Variablen und Register denselben Wert wie nach einem Kontext Switch haben. Hier ist es empfehlenswert, beim Erstellen des Tasks lokale Variablen mit eindeutigen Werten zu erstellen und anschließend in einer while(1)-Schleife laufen zu lassen. Die einfachste Überprüfung ist hier: Mit dem Debugger einen Breakpoint in der while(1)-Schleife des Programms zu setzen und nach dem Kontext-Switch anzuhalten, um auf diese Weise die Variablen zu überprüfen.

Mögliche Fehlerquellen:

  • Stack Pointer zeigt auf die falsche Adresse
  • Zuviele oder nicht ausreichend lokale Variablen -der ISR- nach Verlassen der ISR gelöscht
  • Register in falscher Reihenfolge wiederhergestellt

Beispiel

Void Test_Task1(){
Start_task();
int a = 3; 
	int b = 1;
	int c = 2; 
	int d = 3; 
	int e = 4;
	int f = 5;
	int g = 6;
	int h = 7;
while(1){
  // BreakPoint 
  }
}


Rechenoperationen

Wenn sichergestellt wurde, dass die Register und Variablen nach einem Kontext Switch unverändert bestehen, kann mit einer einfachen Rechenoperation, die je nach Ausgangswert ein anderes Ergebnis liefert, überprüft werden, ob die Wiedereinsprungsadresse stimmt. Da lokale Variablen zerstört werden können, sollten globale Variablen hierfür verwendet werden, da diese eben nicht im Stack gespeichert werden. Zusätzlich sollte ein Fehlercounter inkrementiert werden.

Mögliche Fehlerquellen:

  • Falsches Link Register
  • Falscher PC
  • Fehlerhafte Register und/oder Variablen

Beispiel Hier eignet sich die Berechnung der ersten Fibonacci-Zahlen, da hier jedes weitere Ergebnis vom Vorherigen abhängt.

Int error;
Void Test_Task2(){
Start_Task();
int a = 1;
	int b = 1;
	int c;
int counter = 0;
while(1){
		c = a;
		a = b+a;
		b = c;	
switch(counter){
		case 0:if(!(b==1)){
				error++;
				}break;
		case 1:if(!(b==2)){
				error++;}break;
		case 2:if(!(b==3)){
				error++;}break;
		case 3:if(!(b==5)){
		                error++;}break;

        ...
        ...
        ...


                default:counter = -1; b = 1; a = 1;break;
             }
          counter ++;
     }
}


Funktionskaskade

Um zu überprüfen, ob Linkregister und Programmcounter richtig gesichert wurden, kann man via Funktionkaskaden überprüfen, ob alle Funktionen in der richtigen Reihenfolge aufgerufen werden, und ob sie auch wieder richtig verlassen werden. Auch wichtig sind Übergabeparameter und return values. Sollte es hier zu einem Fehler kommen, kann es sein, dass nur die lokalen Variablen verloren gehen. Es kann jedoch auch passieren, dass nach dem Verlassen der Funktion zu viel oder zu wenig vom Stack geladen wird, wodurch eine falsche Rücksprungadresse geladen wird. Mit etwas Glück erkennt der µC dies und springt dann in einen Fault-Handler.

Möglichen Fehler Quellen:

  • Link Register hatt die Falsche Adresse
  • Fehlerhafte Register und/oder Variablen
  • Falscher Programcounter
  • Register 7 (kann von UC, Compiler oder IDE unterschiedlich sein) bei einem Funktions Call wird in einem Register der alte Stackpointer hinterlegt, ob zu zeigen, wie viele Lokale Variablen nach Verlassen der aktuellen Funktion auf dem Stack nicht mehr benötigt werden.

Beispiel

int tester;
int error;
void Task_test3(){
	while(1){
		tester = 1;
		int dummy = test1(5,6);
		
		if(dummy != 9 || tester != 4){
			error ++;
		}
	}
}
	
	
int test1(int a, int b){
		if(tester != 1){
			error++;
		}
		tester = 2;
		int dummy = test2(7);
		if(a != 5 || b != 6 || dummy != 8 || tester != 3){
			error++;
		}
		tester = 4;
		return 9;
	}
	
int test2(int a)
{
		if(a != 7 || tester != 2){
			error ++;
		}
		tester = 3;
		return 8;
}


Kein Kontext Switch mehr

Sollten plötzlich keine Kontext-Switche mehr stattfinden(ein Task wird nicht mehr verlassen) sollten man im Interrupt_Manager –„ Nested Vectored Interrupt Controller“ (NVIC) - nachschauen, ob das Interrupt_Active_Bit gesetzt ist. Sollte dies der Fall sein, wurde der Kontext-Switch nicht richtig verlassen und der Prozessor glaubt, er würde noch immer den Interrupt ausführen. Viele Prozessoren - unter anderem ARM - erlauben kein Repending(doppeltes Ausführen) eines Interruptes.
Mögliche Fehlerquellen:

  • Linkregister: Bei einem Interrupt Aufruf wird das Link Register mit einem Exp_return_Value beschreiben. Dieser sagt aus, auf welche Art und ob nach Verlassen der Funktion das Interrupt abgeschlossen wurde. So weiss der UC, ob z.B. noch ein Interrupt mit einer niedrigeren Priorität unterbrochen wurde.


Lektüre


Quellen

Link Sammlung

Bootloader


Autor

  • Sebastian Balz