in Embedded Logs, Informatica

Harlock – Un piccolo linguaggio per la manipolazione di file hex ed elf

In questo articolo voglio introdurre un piccolo strumento che può essere utilizzato per automatizzare e semplificare la gestione di file ottenuti dalla compilazione di firmware per sistemi integrati.

Ho una semplice applicazione che fa lampeggiare un led su un microcontrollore AVR ATMega644, un classico esempio per iniziare a muovere i primi passi in C:

#include <avr/io.h>
#include <util/delay.h>

#define LED         0
#define HEADER_SIZE 24

const uint8_t header[HEADER_SIZE] __attribute__((section(".metadata")));

int main(void) {
    DDRA = (1U << LED);
    PORTA = (0U << LED);
    for(;;) {
        PORTA ^= (1U << LED);
        _delay_ms(2000);
    }
}

Nel mio sistema, questa applicazione deve potere essere aggiornata tramite una metodologia di update OTA/OTW. Immaginiamo quindi che ci sia un bootloader che, all’avvio, può entrare in modalità di aggiornamento o lanciare l’applicativo verificandone l’integrità. Questo può avvenire calcolando l’hash SHA1 dell’applicativo (ed in particolare dell’area .text dell’eseguibile), confrontandolo con uno pre-calcolato dopo la sua compilazione.

Una volta calcolato l’hash in qualche modo ancora non precisato, bisognerà caricarlo in memoria programma ad un indirizzo noto, che nel programma qui sopra riportato è rappresentato dalla sezione .metadata. Vado a piazzare questa sezione in flash ad una posizione specificata tramite un flag del linker (-Wl,–section-start=.metadata=0xDFE8).

Ora che ci siamo, sarebbe molto comodo avere la possibilità di trovare questo hash sia nel file hex che nel file elf prodotto dal nostro compilatore e di potere ottenere gli indirizzi e le dimensioni dei settori elf senza dover ricorrere ad hard-coding di valori.

Ipotizziamo di avere uno strumento capace di fare questo ed altro: dovrei comunque installare un’applicazione che aggiunge dipendenze al mio build system. Sarebbe bello potere evitare altre noie di questo tipo.

Ricapitolando, voglio un sistema che mi permetta di:

  • Manipolare file hex e binari in formato elf in lettura ed in scrittura;
  • Calcolare hash di dati estratti da file hex ed elf;
  • Sia inseribile in una pipeline di build senza troppi sforzi.
  • Non comporti l’aggiunta di una dipendenza per me e soprattutto per chi il firmware dovrà buildarlo.

Harlock – Un piccolo DSL

Ho avuto bisogno di uno strumento di questo tipo diverse volte sviluppando firmware su svariate piattaforme hardware, ed alla fine ho unito l’utile al dilettevole scrivendo un linguaggio con lo scopo ultimo di rendermi la vita più semplice.

Harlock è un domain specific language pensato per manipolare artefatti di compilazione di applicativi per sistemi integrati. Totalmente interpretato, è rilasciato sotto licenza MIT.

@github.com/Abathargh/harlock
Wiki @github.com/Abathargh/harlock/wiki

Manipolare file hex e binari in formato elf in lettura ed in scrittura

Harlock permette di manipolare file hex ed elf (ed anche, in generale, file come blob binari) tramite una API semplice ed intuitiva. Supponiamo di compilare l’applicazione ed ottenere due file, main.elf e main.hex, come prodotto del compilatore.

Manipolarli a questo punto è piuttosto semplice:

var section = ".text"

var e = try open("main.elf", "elf")
var addr = try e.section_address(section)
var size = try e.section_size(section)

print("Section", section, "Addr:", addr, "Size:", size)
try e.write_section(section, [0x01, 0x02, 0x03], 0)
save(e)

var h = try open("main.hex", "hex")
var text_hex = try h.read_at(addr, size)
try h.write_at(addr, [0x01, 0x02, 0x03])
save(h)

La funzione open carica tutto il contenuto del file in memoria e permette di effettuare delle operazioni diverse a seconda del tipo di file:

  • Si può andare ad ottenere informazioni sulle singole sezioni elf, come anche leggerne i contenuti o scriverci su.
  • Le operazioni di lettura e scrittura sono implementate anche per file hex: queste sono possibili tramite un vero e proprio parser ihex implementato da zero, che scannerizza i record del file hex e costruisce il layout del file in background.

Ogni funzione di lettura e scrittura utilizza dei semplici “array” di interi, codificati come liste di byte. Questo permette di manipolare i dati in modo semplice tramite altre funzionalità del linguaggio, che accettano questo tipo di dato.

Infine, tramite la funzione save, si può scrivere quanto modificato sul file d’origine.

Calcolare hash di dati estratti da file hex ed elf

Calcolare un hash è quasi banale, utilizzando la funzione hash:

var text_addr = 0x0000
var text_size = 0xae
var h    = try open("main.hex", "hex")
var text = try h.read_at(text_addr, text_size)
var cks  = hash(text, "sha1")
print(cks)

Il primo argomento da dare in pasto alla funzione è un array di byte di cui siamo interessati a calcolare l’hash, mentre il secondo è l’algoritmo desiderato. Di base, harlock supporta sha1, sha256 ed md5.

Lanciare uno script harlock

Lanciare uno script è piuttosto immediato, basta installare l’interprete harlock (come spiegato qui) ed invocarlo, passando lo script come primo argomento:

[~]$ echo "var input = if len(args) > 1 { args[1] } else { \"mondo\" }
dquote> print(\"Ciao\", input)" > test.hlk
[~]$ harlock test.hlk                                                 
Ciao mondo
[~]$ harlock test.hlk antima                                          
Ciao antima

L’interprete può essere anche avviato come REPL interattivo, per testare al volo delle righe di codice:

[~]$ harlock
Harlock v0.4.1-31-g8cfed2a - amd64 on linux
>>> print("test")
test
>>> [1, 2, 3, 4].reduce(fun(x, y) {           
... 	x+y
... })           
... 
10
>>> hash([1, 2, 3, 4].map(fun(x) { x*2 }), "sha1")
[49, 171, 192, 184, 240, 37, 141, 6, 123, 130, 103, 242, 99, 184, 217, 31, 61, 239, 176, 107]
>>> 

Zero dipendenze?

Un tool in più da installare ma, soprattutto, da fare installare non è sempre facile da far accettare. Per semplificare l’esperienza d’uso, è possibile generare un binario che contiene il proprio script ed il runtime harlock, tramite il flag -embed del tool harlock:

[~]$ harlock -embed test.hlk
go: creating new go.mod: module embedded_harlock
go: to add module requirements and sums:
	go mod tidy
go: finding module for package github.com/Abathargh/harlock/pkg/interpreter
go: found github.com/Abathargh/harlock/pkg/interpreter in github.com/Abathargh/harlock v0.4.1
[~]$ file test 
test: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=wpIyZxP0rIUSryTz7m8c/yZ-BQwygAlOEyecHh2K6/7BYmQrxn4xKInS-zJ0fT/hj3ijmHCG1KzI3NAtCSq, not stripped
[~]$ ./test
Ciao mondo
[~]$ ./test antima
Ciao antima

Questo processo genera un binario nativo che elimina la necessità di dovere installare l’interprete, e che può essere ridistribuito a chiunque voglia usare il tool senza installarlo. N.B. è richiesta un’installazione di go 1.18+ sul computer su cui si vuole generare l’eseguibile.

Harlock in azione

Un esempio più complesso di uso di questo strumento sta nel creare uno script che gestisca la generazione dinamica di metadati in una fase di post-build.

Consideriamo un semplice bootloader che ha il solo scopo di verificare l’integrità dell’applicazione precedentemente presentata, e di farla partire una volta effettuata questa verifica.

La board che uso per questa demo è simile a quanto ho mostrato in precedenza su antima nella serie Embedded Logs – From scratch.

(Codice completo @github.com/Abathargh/avr-harlock-demo)

#define METADATA ((uint8_t *)(0xdfe8))
#define METADATA_SIZE 24

#define OFFSET_TEXT_ADDR (METADATA + 0)
#define OFFSET_TEXT_SIZE (METADATA + 2)
#define OFFSET_SHA1HASH (METADATA + 4)
#define SHA1HASH_SIZE (20)
int main(void) { uint8_t digest[METADATA_SIZE]; SHA1Context ctx; DDRA = (1U << LED); PORTA = (0U << LED); // blink @1Hz per seganalare l'avvio del bootloader hello_blink(); SHA1Reset(&ctx); uint16_t text_addr = pgm_read_word(OFFSET_TEXT_ADDR); uint16_t text_size = pgm_read_word(OFFSET_TEXT_SIZE); for(size_t i = 0; i < text_size; i++) { uint8_t curr_byte = pgm_read_byte((text_addr+i)); SHA1Input(&ctx, &curr_byte, 1); } SHA1Result(&ctx, digest); if(is_app_valid(digest)) { // SHA1 check valido: avviamo l'applicazione __asm__("jmp 0000"); }
// SHA1 check non valido: blink @2Hz error_blink(); }


Il bootloader conosce la dimensione e la posizione in memoria dell’header contenente i metadati applicativi, oltre agli offset tra un dato e l’altro. Questi vengono inseriti dopo la compilazione dal seguente script harlock:

// Creo una funzione builder 'make_header' per costruire un header partendo dai metadati
var make_header = fun(text_addr, text_size, cks) {
	var word_size = 2
	var t_base_arr  = try as_array(text_addr, word_size, "little")
	var t_base_size = try as_array(text_size, word_size, "little")
	ret t_base_arr + t_base_size + cks
}

// Prendo il nome del progetto da linea di comando
var project = try args[1]

// Sezione di default per l'header: '.metadata'; se l'utente ne fornisce una, usiamo quella
var text = ".text"
var section = if len(args) == 3 { args[2] } else { ".metadata" }


var hex_file = "build/" + project + ".hex"
var elf_file = "build/" + project + ".elf"


// Apro il file .elf ed estraggo le informazioni sulle sezioni
var e = try open(elf_file, "elf")

var text_addr = try e.section_address(text)
var text_size = try e.section_size(text)

var meta_addr = try e.section_address(section)
var meta_size = try e.section_size(section)


// Creo e scrivo l'header su file
var h = try open(hex_file, "hex")

var text_hex = try h.read_at(text_addr, text_size)
var cks_hex  = try hash(text_hex, "sha1")

var header = try make_header(text_addr, text_size, cks_hex)
try h.write_at(meta_addr, header)
try save(h)

// E faccio lo stesso per il file elf
var text_elf = try e.read_section(text)
var cks_elf  = try hash(text_elf, "sha1")

var header2 = try make_header(text_addr, text_size, cks_elf)
try e.write_section(section, header, 0)
try save(e)

// Stampo un po' di info per l'utente
print(text, "          -- addr: ", hex(text_addr), "  size: ", hex(text_size))
print(section, "      -- addr: ", hex(meta_addr), "size: ", hex(meta_size))

print("Digest (.hex): ", hex(cks_hex), "Length: ", len(cks_hex))
print("Digest (.elf): ", hex(cks_elf), "Length: ", len(cks_elf))
print("Application section: @" + hex(meta_addr) + "\n")

Compilando tramite make, possiamo notare l’output dello script:

Output di compilazione per l'esempio incluso nell'articolo, contenente l dell'esecuzione dello script harlock.

E, con un veloce hexdump, possiamo osservare come i dati vengano effettivamente inseriti nella locazione di memoria specificata (dfe8-dfff):

Dump  del .bin ottenuto dalla compilazione dell'esempio contenente i dati inseriti tramite lo script harlock.

Il risultato

Per questo articolo è tutto, per eventuali approfondimenti o chiarimenti vi invito come al solito a commentare nella sezione qui sotto!

Scrivi un commento

Commento