Kampis Elektroecke

XMega Bootloader

Gerade wenn Mikrocontroller in fertigen Projekten oder Geräten eingesetzt werden, kann es vorkommen, dass der Mikrocontroller neu programmieren werden muss, aber gerade kein Programmiergerät zur Hand hat oder das der Programmierport gesperrt bzw. in Benutzung ist und somit nicht verwendet werden kann. Typischerweise greift man in so einem Fall auf einen Bootloader zurück. Dieser Bootloader nimmt das neue Programm über eine definierte Schnittstelle entgegen und kopiert es in den Programmspeicher des Mikrocontrollers.

Aufbau und Funktionsweise eines solchen Bootloaders soll Gegenstand dieses Artikels sein. Ich möchte einen kleinen Ausblick darauf geben, wie ein Bootloader für einen Mikrocontroller mit XMega-Architektur funktioniert und wie dieser dann programmiert werden kann. Der hier vorgestellte Bootloader verwendet als Programmierschnittstelle ein USART-Interface und akzeptiert Programme im Intel HEX-Format. Der Bootloader ist modular aufgebaut, sodass die Schnittstelle oder das Format mit wenig Aufwand geändert werden können.

Aufbau eines kompilierten AVR-Programms:

Bevor ich auf die Funktionsweise des Bootloaders eingehe, möchte ich einen kurzen Ausblick auf das Intel HEX-Format geben, da es sich bei diesem Format u. a. im Atmel Studio um das Standardausgabeformat für Binärdateien handelt. Wird ein Programm mit der AVR-GCC Toolchain kompiliert, so wird der erzeugte Maschinencode immer im Intel-Format ausgegeben und gespeichert. Sämtliche Binärdaten werden als ASCII-Zeichen abgelegt und sind als little-endian Hexadezimalzahl codiert.

Intels Spezifikation sieht für die Notation der Hexadezimalzahlen die Zeichen 09, sowie A F vor. Die Zeichen af werden in Intels Spezifikation nicht erwähnt, werden aber in der Regel unterstützt. Die Datei ist zeilenweise aufgebaut, wobei jede Zeile mit einem CR + LF (0x0D 0x0A) abgeschlossen und mit einem Doppelpunkt eingeleitet wird.

:100240008D819E81FC01218380EE97E08B839C83CE

Der Zeileninhalt setzt sich aus den folgenden Komponenten zusammen:

: 10 0240 00 8D819E81FC01218380EE97E08B839C83 CE
Start Anzahl Datenbytes Adresse Typ Datenbytes Checksumme

Über das Feld Typ wird die Art und die Funktion der Daten in der aktuellen Zeile definiert. Es wird zwischen sechs verschiedenen Typen unterschieden:

ID Bezeichnung Funktion
00 Data Record Nutzdaten
01 End of File (EOF) Record Dateiende
02 Extended Segment Address Record1 Erweiterte Segmentadresse für nachfolgende Nutzdaten
03 Start Segment Address Record1 Startsegmentadresse
04 Extended Linear Address Record1 Erweiterte lineare Adresse
05 Start Linear Address Record1 Lineare Startadresse
1 Diese Felder dienen als Erweiterung um die segmentierte Adressierung der Intel 80×86-Prozessoren zu unterstützen

.Für die Checksumme werden die Datenbytes einer kompletten Zeile (ohne das Startzeichen) aufsummiert. Im nächsten Schritt wird von dem niederwertigste Byte der Summe das Zweierkomplement gebildet. Für den oben gezeigten Datensatz ergibt sich somit die folgende Rechnung:

Checksum = 10_{16} + 02_{16} + ... + 9C_{16} + 83_{16}
Checksum = 922_{16}
Checksum = \neg (Checksum \land FF_{16})
Checksum = Checksum + 1
Checksum = CE_{16}

Entwurf des Bootloaders:

Zusammengefasst muss der zu entwerfende Bootloader die folgenden Aufgaben übernehmen:

  • Initialisieren der Bootloader-Schnittstelle und Löschen der alten Anwendung
  • Empfangen der Daten und Fehlerüberprüfung
  • Daten in den Programmspeicher schreiben
  • Zur Applikation springen

Der komplette Bootloader unterteilt sich in zwei große Untermodule:

  • Ein Dateiparser mit drei Funktionen:
    • Parser_Init
    • Parser_GetByte
    • Parser_Parse
  • Kommunikation über die Schnittstelle und Bedienung des NVM-Controllers um den Flashspeicher zu beschreiben. Dafür sind die folgenden Funktionen definiert:
    • Bootloader_Init
    • Bootloader_Enter
    • Bootloader_Exit

Funktionsweise des Parsers:

Der Parser hat die Aufgabe die übertragenen Daten entsprechend eines vorgegebenen Formats (hier das Intel Hex-Format) zu analysieren und aufzubereiten. Zuerst wird der Parser mit der Funktion Parser_Init initialisiert:

void Parser_Init(void)
{
	_ParserState = PARSER_INIT;
	_IsActive = false;
}

Anschließend wird die einzelnen Datenbytes einer Zeile mit der Funktion Parset_GetByte eingelesen und in einem Zeilenbuffer gespeichert.

Parser_State_t Parser_GetByte(const uint8_t Received)
{
	if(_Active)
	{
		if(Received == PARSER_LINE_END)
		{
			_Index = 0x00;
			_Active = false;
			_ParserState = PARSER_INIT;
			return PARSER_STATE_SUCCESSFUL;
		}

		if(_Index < sizeof(_ParserBuffer))
		{
			_ParserBuffer[_Index++] = Received;
		}
		else
		{
			_Index = 0x00;
			return PARSER_STATE_OVERFLOW;
		}
	}

	if(Received == ':')
	{
		_Index = 0x00;
		_Active = true;
	}

	return PARSER_STATE_BUSY;
}

Sobald eine komplette Zeile empfangen wurde, gibt der Parser den Zustand PARSER_STATE_SUCCESSFUL zurück und es kann die Funktion Parser_Parse aufgerufen werden um die eingelesene Zeile zu parsen. 

Parser_State_t Parser_Parse(Parser_Block_t* Line)
{
	_ParserEngineState = PARSER_STATE_BUSY;
	do
	{
		switch(_ParserState)
		{
			case PARSER_INIT:
			{
				Line->Checksum = 0x00;
				Line->Bytes = 0x00;
				_ParserState = PARSER_GET_SIZE;
				break;
			}
			case PARSER_GET_SIZE:
			{
				uint8_t Length = Hex2Num(PARSER_LENGTH_BYTES);
				Line->Bytes = Length;
				Line->Checksum += Length;
				_ParserState = PARSER_GET_ADDRESS;
				break;
			}
			case PARSER_GET_ADDRESS:
			{
				uint16_t Address = Hex2Num(PARSER_ADDRESS_BYTES);
				Line->Address = Address;
				Line->Checksum += (Address >> 0x08) + (Address & 0xFF);
				_ParserState = PARSER_GET_TYPE;
				break;
			}
			case PARSER_GET_TYPE:
			{
				uint8_t Type = Hex2Num(PARSER_TYPE_BYTES);
				Line->Type = Type;
				Line->Checksum += Type;
				switch(Type)
				{
					case PARSER_TYPE_DATA:
					{
						for(uint8_t i = 0x00; i < Line->Bytes; i++)
						{
							uint8_t Data = Hex2Num(PARSER_DATA_BYTES);
							*(Line->pBuffer + i) = Data;
							Line->Checksum += Data;
						}
						_ParserState = PARSER_GET_CHECK;
						break;
					}
					case PARSER_TYPE_EOF:
					{
						_ParserState = PARSER_GET_CHECK;
						break;
					}
					case PARSER_TYPE_ESA:
					{
						Line->Offset = 0x00;
						for(uint8_t i = 0x00; i < 0x02; i++)
						{
							uint8_t Data = Hex2Num(PARSER_DATA_BYTES);
							Line->Offset |= Data;
							Line->Offset <<= 0x04;
							Line->Checksum += Data;
						}
						Line->Offset <<= 0x04;
						_ParserState = PARSER_GET_CHECK;
						break;
					}
					case PARSER_TYPE_SSA:
					{
						Line->StartAddress = 0x00;
						for(uint8_t i = 0x00; i < 0x04; i++)
						{
							uint8_t Data = Hex2Num(PARSER_DATA_BYTES);
							Line->StartAddress |= Data;
							Line->StartAddress <<= 0x04;
							Line->Checksum += Data;
						}
						_ParserState = PARSER_GET_CHECK;
						break;
					}
					case PARSER_TYPE_ELA:
					{
						break;
					}
					case PARSER_TYPE_SLA:
					{
						break;
					}
					default:
					{
						_ParserState = PARSER_HANDLE_ERROR;
						break;
					}
				}
				break;
			}
			case PARSER_GET_CHECK:
			{
				uint8_t Checksum_Temp = ~(Line->Checksum & 0xFF) + 0x01;
				Line->Checksum = Hex2Num(PARSER_CHECK_BYTES);
				if(Line->Checksum == Checksum_Temp)
				{
					Line->Valid = true;
					_ParserEngineState = PARSER_STATE_SUCCESSFUL;
				}
				else
				{
					Line->Valid = false;
					_ParserEngineState = PARSER_STATE_ERROR;
				}
				break;
			}
			case PARSER_HANDLE_ERROR:
			{
				_ParserEngineState = PARSER_STATE_ERROR;
				break;
			}
		}
	}
	while (_ParserEngineState == PARSER_STATE_BUSY);
	return _ParserEngineState;
}

Mit Hilfe eines Zustandsautomaten wird die eingelesene Zeile analysiert, die notwendigen Informationen extrahiert und in ein übergebenes Objekt vom Typ Parser_Block_t geschrieben. Dieses Objekt ist folgendermaßen aufgebaut:

Feld Funktion
Length Länge des Datenfeldes.
Address Speicheradresse für die Daten.
Offset Adressoffset aus dem ESA und ELA Record.
StartAddress Startadresse der Anwendung aus dem SSA Record.
Type Art des Records.
pBuffer Zeiger auf einen Datenbuffer aus dem Record.
Checksum Checksumme des aktuellen Records.
Valid Boolescher Wert, der angibt ob der Record gültig ist oder nicht.

Diese Informationen müssen jetzt noch in den Programmspeicher geschrieben werden.

Der finale Bootloader:

Vor der ersten Verwendung des Bootloaders muss dieser mit der Funktion Bootloader_Init initialisiert werden. Während der Initialisierung wird eine bereits vorhandene Applikation vom NVM-Controller aus der Application Flash Section gelöscht, auf den internen 2 MHz Oszillator gewechselt, die USART-Schnittstelle mit den Einstellungen 8N1 @ 19200 Baud und der Parser initialisiert, sowie die softwareseitige Flusskontrolle des angeschlossenen Gerätes deaktiviert.

void Bootloader_Init(void)
{
	NVM_EraseApplication();
	asm volatile(	"movw r30,  %0"	    "\n\t"
			"ldi  r16,  %2"     "\n\t"
			"out   %3, r16"     "\n\t"
			"st     Z,  %1"     "\n\t"
		::	"r" (&CLK.CTRL), 
			"r" (0x00), 
			"M" (CCP_IOREG_gc), 
			"i" (&CCP) 
		 :      "r16", "r30"
	);

	USARTC0.DIRSET = (0x01 << 0x03);
	USARTC0.DIRCLR = (0x01 << 0x02);
	USARTC0.BAUDCTRLA = 11;
	USARTC0.BAUDCTRLB = ((-7) << USART_BSCALE_gp) & USART_BSCALE_gm;
	USARTC0.CTRLB = USART_RXEN_bm | USART_TXEN_bm;
	Parser_Init();
	Bootloader_PutChar(XON);
}
.section .text
.global NVM_EraseApplication
NVM_EraseApplication:
	in	r18, RAMPZ
	sts	RAMPZ, r1
	clr	ZH
	clr	ZL
	ldi	r26, NVM_CMD_ERASE_APP_gc
	call	NVM_ExecuteSPM
	call	NVM_WaitBusy
	out	RAMPZ, r18
	ret

Anschließend kann der Bootloader gestartet werde. Dazu wird die Funktion Bootloader_Enter aufgerufen.

boolean Bootloader_Enter(void)
{
	uint16_t Page = 0x00;
	uint8_t Offset = 0x00;
	uint16_t Words = 0x00;
	Bootloader_PutString("Enter bootloader...\n\r");
	do
	{
		if(Parser_GetByte(Bootloader_GetChar()) == PARSER_STATE_SUCCESSFUL)
		{
			Bootloader_PutChar(XOFF);
			if((Parser_Parse(&_Line) == PARSER_STATE_SUCCESSFUL) && _Line.Valid)
			{
				switch(_Line.Type == PARSER_TYPE_DATA)
				{
					uint32_t Address = (_Line.Offset + _Line.Address) >> 0x01;
					Offset = Address & 0xFF;
					Page = (Address & 0x3FF00) >> 0x08;
					for(uint8_t i = 0x00; i < _Line.Length; i += 0x02)
					{
						uint16_t CodeWord = (_Line.pBuffer[i + 1] << 0x08) | _Line.pBuffer[i];
						NVM_LoadFlashBuffer(Offset + (i >> 0x01), CodeWord);
						if(Words++ == (APP_SECTION_PAGE_SIZE / 2))
						{
							NVM_FlushFlash(Page);
							Words = 0x00;
						}
					}
				}
			}
			else
			{
				return false;
			}
			Bootloader_PutChar(XON);
		}
	} while(_Line.Type != PARSER_TYPE_EOF);
	NVM_FlushFlash(Page);
	return true;
}

In dieser Funktion wird in einer do-while-Schleife so lange mit der Funktion Bootloader_GetChar ein Zeichen von der USART-Schnittstelle eingelesen und über die Funktion Parser_GetByte in den Parser übergeben, bis der Parser eine vollständige Zeile eingelesen hat. Sobald der Parser eine komplette Zeile empfangen hat, wird zuerst ein XOFF für die softwareseitige Flusskontrolle gesendet um einen Übertragungsstop zu signalisieren. Anschließend wird die empfangene Zeile geparst und auf ihre Gültigkeit überprüft, indem das Valid-Feld abgefragt wird.

Wenn eine gültige Zeile empfangen wurde, wird der Record-Typ der Zeile überprüft. Bei einem Daten-Record wird die Adresse bestimmt und in eine Seite und einen Offset umgerechnet.

uint32_t Address = (_Line.Offset + _Line.Address) >> 0x01;
Offset = Address & 0xFF;
Page = (Address & 0x3FF00) >> 0x08;

Hinweis:

Der Offset wird durch einen Extended Segment Address (ESA)-Record gesetzt und stellt die oberen 16 Bit der nachfolgenden Adressen dar. Alle Adressen, die nach solch einem Record in die Hex-Datei geschrieben werden, müssen um die Adresse aus dem ESA-Record erweitert werden. Dies gilt solange, bis ein neuer ESA-Record geschrieben wird.


Im nächsten Schritt werden die einzelnen Datenwörter aus dem Datenbuffer in den Flash-Buffer des NVM-Controllers geschrieben:

for(uint8_t i = 0x00; i < _Line.Length; i += 0x02)
{
	uint16_t CodeWord = (_LineBuffer[i + 1] << 0x08) | _LineBuffer[i];
	NVM_LoadFlashBuffer(Offset + (i >> 0x01), CodeWord);
	if(Words++ == (APP_SECTION_PAGE_SIZE / 2))
	{
		NVM_FlushFlash(Page);
		Words = 0x00;
	}
}

Wenn der Flash-Buffer komplett gefüllt ist, wird der Inhalt in die entsprechende Seite des Flashspeichers geschrieben.

NVM_WaitBusy:
	lds	r20, NVM_STATUS
	sbrc	r20, NVM_NVMBUSY_bp
	rjmp	NVM_WaitBusy
	ret

NVM_ExecuteSPM:
	sts	NVM_CMD, r26
	ldi	r19, CCP_SPM_gc
	sts	CCP, r19
	spm
	clr	r1
	sts	NVM_CMD, r1
	ret

.section .text
.global NVM_LoadFlashBuffer
NVM_LoadFlashBuffer:
	clr	ZH
	clr	ZL
	mov	ZL, r24
	mov	ZH, r25
	lsl	ZL
	rol	ZH
	movw	r0, r22
	ldi	r26, NVM_CMD_LOAD_FLASH_BUFFER_gc
	call	NVM_ExecuteSPM
	clr	r1
	ret

.section .text
.global NVM_FlushFlash
NVM_FlushFlash:
	in	r18, RAMPZ
	mov	r19, r25
	mov	ZH, r24
	lsl	ZH
	rol	r19
	sts	RAMPZ, r19
	ldi	r26, NVM_CMD_ERASE_WRITE_FLASH_PAGE_gc
	call	NVM_ExecuteSPM
	call	NVM_WaitBusy
	out	RAMPZ, r18
	ret

Zum Abschluss wird noch ein XON gesendet um die Datenübertragung fortzusetzen und dann beginnt der ganze Ablauf von vorne. Sämtliche Schritte werden so lange wiederholt, bis ein EOF eingelesen wird. Dann wird die Schleife unterbrochen, der Inhalt des Flash-Buffers in den Flash geschrieben und die Funktion verlassen.

Wenn der Programmspeicher erfolgreich beschrieben wurde, wird die Funktion Bootloader_Exit aufgerufen.

void Bootloader_Exit(void)
{
	Bootloader_PutString("Leave bootloader...\n\r");
        for(uint16_t i = 0x00; i < 0xFFFF; i++);
	NVM_LockSPM();
	PORTC.DIRCLR = (0x01 << 0x03) | (0x01 << 0x02);
	USARTC0.CTRLB &= ~(USART_RXEN_bm | USART_TXEN_bm);
	EIND = 0x00;
	asm volatile("ld   %0, Z" "\n\t"
		     "ijmp"       "\n\t"
	          :: "r" (_Line.StartAddress)
		  :  "r30", "r31"
		);
}
.section .text
.global NVM_LockSPM
NVM_LockSPM:
	ldi	r18, CCP_IOREG_gc
	ldi	r19, NVM_SPMLOCK_bm
	sts	CCP, r18
	sts	NVM_CTRLB, r19
	ret

Diese Funktion gibt eine entsprechende Meldung über die Bootloaderschnittstelle aus und wartet bis die Übertragung vollständig abgeschlossen wurde. Anschließend werden der SPM-Befehl und der USART deaktiviert, die genutzten I/Os wieder als Eingang geschaltet und zur Startadresse der Anwendung gesprungen. 

Die programmierten Funktionen müssen jetzt noch aufgerufen werden:

#include "Bootloader/Bootloader.h"

int main(void)
{
	Bootloader_Init();
	if(Bootloader_Enter() == true)
	{
		Bootloader_Exit();
	}
	else
	{
		// Fehlerbehandlung
	}

    	while(1)
    	{
    	}

	return 0;
}

Bei AVR Mikrocontroller werden Bootloader in einem gesonderten Bereich des Programmspeichers, der sogenannten Boot Loader Flash Section, gespeichert. Dieser Bereich befindet sich immer am Ende des Programmspeichers und die Größe des Bereichs kann über Fusebits eingestellt werden.


Hinweis:

Die richtige Platzierung des Bootloaders ist wichtig, da der für die Programmierung des Programmspeichers genutzte SPM-Befehl nur ausgeführt werden kann, wenn sich der entsprechende Codeabschnitt in der Bootloadersektion befindet.


Damit der Linker den Bootloader an die richtige Adresse platziert, muss die Adresse des .text-Segments des Linkerskriptes angepasst werden.

Die entsprechende Wortadresse lässt sich dem Datenblatt des verwendeten Mikrocontrollers entnehmen. Alternativ kann auch ein neues Segment definiert und an die entsprechende Adresse gesetzt werden. Dann muss aber zusätzlich geprüft werden ob die einzelnen Codesegmente richtig gelinkt wurden.

Zum Abschluss muss mit einem Programmiergerät das BOOTRST-Bit im FUSE2BYTE-Register gesetzt werden, damit der Mikrocontroller bei einem Reset von dem Bootloaderbereich aus startet.

Wenn alles erledigt ist, kann der Bootloader programmiert und der Mikrocontroller mit einem entsprechenden USART-Adapter mit einem PC verbunden werden. Zum Test habe ich mir ein kleines Programm zum Togglen einer LED geschrieben.

int main(void)
{
        GPIO_SetDirection(&PORTR, 0, GPIO_DIRECTION_OUT);
        while(1) 
        {
                GPIO_Toggle(&PORTR, 0);
                for(uint16_t i = 0x00; i < 0xFFFF; i++);
        }
}

Auch für dieses Programm habe ich das Linkerskript angepasst um es an der Adresse 0x10000 zu platzieren. Das Testprogramm wird kompiliert und der Inhalt der erzeugten Hex-Datei kann dann über ein Terminalprogramm (z. B. PuTTY) an den Mikrocontroller gesendet werden.

Wenn alles geklappt hat, springt der Mikrocontroller am Ende des Kopiervorgangs zu der Startadresse des Programms und die LED fängt an zu blinken.

Das komplette Projekt steht selbstverständlich in meinem GitHub-Repository zum Download bereit.

2 Kommentare

  1. Vielen Dank für die sehr gut erklärten Artikel!
    Auf der verlinkten Wikipedia Seite habe ich gesehen dass das „Intel-Format“ für little-endian und Motorola-Format“ für big-endian steht. Im Ihrem Artikel scheint es anders rum beschrieben zu sein.

Schreibe einen Kommentar zu Tobias Liesching Antworten abbrechen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert