Recipe

In Yocto, un recipe definisce l’intero processo di contorno di un qualsiasi applicativo o funzionalità, ovvero TUTTE le operazioni necessarie per poterla fare funzionare correttamente. Queste comprendono:

  • dove trovare il codice sorgente (tar file, repository git pubblica o privata)
  • quali modifiche è necessario applicare a questo (eventuali patch, configurazioni specifiche,…)
  • come configurare il compilatore o l’interprete del codice (eventuali #define, variabili di ambiente,…)
  • come compilare il codice (se necessario)
  • quali dipendenze sono richieste (tool aggiuntivi, librerie, moduli, configurazioni del kernel,…)
  • dove installare i risultati della compilazione nel file-system del target
  • quali altre operazioni eseguire (installazione di file di configurazione, creazione di link simbolici,…)

Il recipe inoltre deve fornire informazioni anche sulla licenza del applicativo in questione (GPL, software proprietario,…) e sulla sua versione. Tutto questo rende molto comodo il tracciare quali software saranno presenti sul nostro sistema finale.

Per fare tutto ciò non esistono delle regole rigide, ma piuttosto delle linee guida implementabili utilizzando gli strumenti forniti.

preliminari

Per cominciare, iniziamo a capire quali sono le variabili che ci troveremo ad utilizzare praticamente sempre quando lavoriamo con i recipe, ovvero:

  • PN
  • PV
  • WORKDIR

PN indica il Package Name, cioè il modo in cui bitbake può capire a quale recipe ci riferiamo. Si chiama “Package Name” invece che “Recipe Name” perchè generalmente un singolo recipe dovrebbe produrre un singolo package. Vedremo però che spesso questo non è il caso, quindi questo nome è da intendersi come riferito al package principale che viene prodotto.

PV indica la Package Version, quindi la versione specifica del software o funzionalità che vogliamo realizzare. Questo ci tornerà comodo perchè in un singolo sistema possiamo generare molteplici versioni diverse di un package ed è importante che queste non vadano ad interferire l’una con l’altra.

WORKDIR indica la Working Directory, ovvero il path assoluto nel nostro sistema che identifica la cartella in cui bitbake andrà a generare tutti i file temporanei durante la costruzione di un recipe.

Queste variabili sono importanti perchè le utilizzeremo spesso, ma non è necessario che le definiamo noi, in quanto bitbake gli assegna già dei valori di default. Queste assegnazioni le possiamo trovare all’interno del layer meta:

meta/conf/bitbake.conf
238##################################################################
239# Package default variables.
240##################################################################
241
242PN = "${@bb.parse.vars_from_file(d.getVar('FILE', False),d)[0] or 'defaultpkgname'}"
243PV = "${@bb.parse.vars_from_file(d.getVar('FILE', False),d)[1] or '1.0'}"
244PR = "r0"
245PE = ""
246PF = "${PN}-${EXTENDPE}${PV}-${PR}"
247EXTENDPE = "${@['','${PE}_'][int(d.getVar('PE') or 0) > 0]}"
248P = "${PN}-${PV}"
meta/conf/bitbake.conf
403STAMPS_DIR ?= "${TMPDIR}/stamps"
404STAMP = "${STAMPS_DIR}/${MULTIMACH_TARGET_SYS}/${PN}/${PV}"
405STAMPCLEAN = "${STAMPS_DIR}/${MULTIMACH_TARGET_SYS}/${PN}/*-*"
406BASE_WORKDIR ?= "${TMPDIR}/work"
407WORKDIR = "${BASE_WORKDIR}/${MULTIMACH_TARGET_SYS}/${PN}/${PV}"
408T = "${WORKDIR}/temp"
409D = "${WORKDIR}/image"
410S = "${WORKDIR}/${BP}"
411B = "${S}"

L’assegnazione di PN e PV viene fatta tramite un meccanismo di bitbake che legge il nome del file .bb e spezza il nome ad ogni carattere _ (underscore). Il primo pezzo viene assegnato a PN, il secondo a PV. Questa diventa quindi una convenzione necessaria per la denominazione dei file .bb, con l’aggiunta del fatto che il nome deve essere completamente lower-case. Alcuni esempi:

                                    PN      PV
myapp.bb                      >>   myapp    1.0
bash_1.7.0.bb                 >>   bash     1.7.0
bash_ubuntu-flavor-1.7.0.bb   >>   bash     ubuntu-flavor-1.7.0

Questo e altri meccanismi di bitbake li possiamo trovare nel layer meta in meta/bitbake/lib/ e ne parleremo meglio in un articolo dedicato.

L’assegnazione di WORKDIR invece la vediamo nel file bitbake.conf che abbiamo già visto qua sopra:

BASE_WORKDIR ?= "${TMPDIR}/work"
WORKDIR = "${BASE_WORKDIR}/${MULTIMACH_TARGET_SYS}/${PN}/${PV}"

Dove:

  • TMPDIR indica la cartella per i file generati dalla build
  • MULTIMACH_TARGET_SYS identifica la scheda target

La variabile TMPDIR ha un valore di default che corrisponde a build/tmp, ma è tranquillamente ridefinibile nel caso volessimo cambiarne la posizione.

La variabile MULTIMACH_TARGET_SYS è una variabile molto specifica, ma in generale è assegnata dalla variabile MACHINE, che utilizzeremo nei file di progetto local.conf per definire la scheda per cui vogliamo realizzare il software.

Quindi, dopo tutta questa trafila, abbiamo capito che il nome stesso del recipe determina il valore di PN e PV e a cascata anche la posizione della sua WORKDIR. E visto che una immagine vale più di mille parole, ecco un esempio:

Prima di continuare, notiamo che una unica build potrebbe costruire software per molteplici schede completamente diverse. Per questo nella definizione di WORKDIR compare anche il termine relativo alla scheda, perchè di default bitbake si assicura che il medesimo recipe, costruito però per due schede diverse, sia associato a due WORKDIR diverse, per evitare che una delle due vada a “sporcare” l’altra.

Inoltre se volessimo ridefinire la posizione di WORKDIR lo possiamo fare con una nuova assegnazione di questa variabile, ma questo lo vedremo in seguito perchè prima bisogna capire la sintassi di bitbake. Al momento ci serve parlare di altro.

task pipeline

La classica task pipeline è

fetch / unpack / configure / compile / install / package

è una sequenza abbastanza generica di step che si adatta bene a molti applicativi, e a cui in ogni momento possiamo introdurre nuovi step o rimuovere quelli non necessari.

L’obiettivo del task fetch è di procurare in un qualche modo i sorgenti dell’applicativo e di salvarli nella cartella downloads, comune a tutti i recipe della build. Solitamente i sorgenti vengono salvati qui in formato .tar.

L’obiettivo del task unpack è quindi di estrarre i sorgenti in una cartella specifica per il recipe in questione, quindi in una sottocartella di WORKDIR.

L’obiettivo del task configure è di applicare eventuali modifiche ai sorgenti, come configurazioni o opzioni di compilazione.

L’obiettivo del task compile è di effettuare la compilazione dell’applicativo. Se questo non richiede compilazione, come per un semplice script python, si omettere questo task.

L’obiettivo del task install è di copiare determinati file in una cartella temporanea che rappresenta la struttura di un tipico rootfs. Questi file sono quindi quelli che è necessario che siano poi presenti nel rootfs della scheda target. In questa fase devono essere gestiti anche i permessi Linux di tali file.

L’obiettivo del task package è di creare uno o più packages a partire dai contenuti della cartella temporanea di installazione. Oltre al main package, che spesso contiene il file binario del nostro applicativo, dei package tipici sono:

  • -devsolitamente installa i file necessari (come i file .h e altre librerie) per lo sviluppo di altri applicativi basati sul nostro applicativo
  • -doc solitamente installa i file di documentazione in una cartella standard per poter utilizzare il comando man sul nostro applicativo

Generalmente bitbake si aspetta di trovare all’interno del recipe delle funzioni come:

myapp.bb
 0do_fetch(){
 1    # download .tar file
 2}
 3
 4do_unpack(){
 5    # extract .tar file
 6}
 7
 8do_configure(){
 9    # edit header files
10}
11
12do_compile(){
13    # call gcc
14}
15
16do_install(){
17    # copy build output to 
18    # sysroot folder
19}
20
21do_package(){
22    # create .deb package
23}

Queste possono essere funzioni bash o python e per convenzione, devono iniziare con do_, per distinguerle da altre funzioni che non sono associate a dei task di bitbake.

Per promuovere una funzione ad un task bitbake si deve utilizzare l’istruzione addtask.

myapp.bb
25addtask fetch
26addtask unpack
27addtask configure
28addtask compile
29addtask install
30addtask package

Se il nome del task non inizia per do_, questo viene implicitamente aggiunto. Inoltre, se bitbake viene invocato senza specificare il nome del task da eseguire, esegue di default il task do_build. Se il task non esiste, allora bitbake non fa nulla.

Siccome Yocto poi andrà ad invocare proprio il task build per iniziare la costruzione del recipe, è necessario fare in modo che i task che abbiamo appena definito siano in relazione con il task build. Questo è possibile specificando le dipendenze tra task nell’istruzione addtask:

myapp.bb
25addtask fetch after build
26addtask unpack after fetch
27addtask configure after unpack
28addtask compile after configure
29addtask install after compile
30addtask package after install

In questo modo possiamo creare la nostra pipeline, ovvero l’esecuzione di un task dopo l’altro. In generale l’istruzione addtask ha la seguente sintassi:

addtask <taskname> before <task1 task2 ...> after <task3 task4 ...>

Se l’ordine non è ben specificato, bitbake assume che i task possano essere eseguiti in parallelo.

Per maggiori dettagli sul meccanismo, documentazione bitbake.

fetch

Utilizzando il layer meta, questo processo di definizione dei task viene molto semplificato, infatti questi task comuni vengono già definiti ed al loro interno vengono utilizzate delle variabili di cui poi noi potremo cambiare il contenuto. Ad esempio, la variabile SRC_URI permette di evitare di implementare il task do_fetch e richiede semplicemente un url:

myapp.bb
0SRC_URI = "git://github.com/author/repo"

Questa è una delle variabili più utilizzate, comode e configurabili, infatti permette di definire se il fetch viene fatto da:

  • git repository git://
  • webserver http://
  • file locale file://

La variabile può contenere molteplici url, purchè questi siano separati da uno spazio e in aggiunta, per ogni url, accetta anche altri argomenti che vengono passati tramite stringhe separate da ;. Quello che ho appena scritto potrebbe non avere senso, forse perchè non so spiegare o forse perchè è semplicemente un metodo molto contorto di usare le variabili. Quindi vediamo degli esempi e poi passiamo più in generale alla sintassi che bitbake usa per le variabili, sennò qua facciamo solo che bordello.

myapp.bb
0SRC_URI = "git://author/repo file://file1.patch file://file2.patch;apply=no"

Il valore assegnato a SRC_URI può quindi diventare anche molto lungo e per questo si utilizza spesso il carattere \ per spezzare la linea (tecnica presente in molti linguaggi di programmazione):

myapp.bb
0SRC_URI = " \
1    git://author/repo \
2    file://file1.patch \
3    file://file2.patch;apply=no \
4    "

Ultima cosa, se in SRC_URI utilizziamo file locali, questi vengono cercati nelle cartelle definite nella variabile di “path” utilizzata da bitbake, ovvero FILESPATH. Per aggiungere altri percorsi utilizzeremo però la variabile FILESEXTRAPATHS invece che modificare direttamente FILESPATH, ma questo lo vedremo in seguito.

Ultima ultima cosa, giuro, è bene definire anche il valore della variabile S che definisce la sottocartella di WORKDIR in cui vengono clonate le repository git, infatti di queste non viene scaricato direttamente un tar ma vengono prima clonate in una sottocartella di WORKDIR, poi compresse in un .tar e poi copiate nella cartella downloads. Se non la definiamo, questa assume il default:

S = "${WORKDIR}/${BP}"

Ma è buona prassi non affidarsi ai valori di default e rendere le cose il più esplicite possibile. Solitamente si assegna lla sottocartella il valore git oppure il nome della repository:

S = "${WORKDIR}/git"

Inoltre per maggiori dettagli possiamo sempre consultare il glossario delle variabili Yocto.

sintassi variabili bitbake

Tutte le variabili bitbake sono sostanzialmente delle variabili di tipo stringa. Una unica, anche molto lunga, stringa. Sia che vogliamo rappresentare un singolo elemento, sia che vogliamo rappresentare una lista di elementi. Bitbake tratta tutte le variabili come un insieme di valori separati da caratteri whitespace . Questo meccanismo è molto comune nei comandi da terminale linux, dove ogni (singolo o multiplo) spazio separa i vari argomenti. Nel seguente esempio di funzione, vediamo come iterare su una variabile bitbake:

0SRC_URI = "git://author/repo1 git://author/repo2"
1
2example_print(){
3    for ITEM in ${SRC_URI}; do
4        echo "url ${ITEM}"
5    done 
6}

E per sfruttare al meglio questo meccanismo, bitbake fornisce una sintassi speciale per poter aggiungere valori all’inizio o alla fine di una stringa:

La differenza tra gli operatori += .= :append è molto intricata e non è questo il momento di parlarne, intanto ci basta sapere che esistono.

unpack

È sufficiente definire il valore di UNPACKDIR, che di default vale WORKDIR/sources-unpack.

Fino alla versione 5.0 styhead di Yocto, i sorgenti venivano di default estratti in S, ma poi si è deciso di introdurre UNPACKDIR per fornire più possibilità all’utente. Solitamente in ogni recipe si assegna il medesimo valore sia a S che a UNPACKDIR:

S = "${WORKDIR}/git"
UNPACKDIR = "${S}"

configure

Non è strettamente necesario, ma solitamente in questa fase si utilizzano vari comandi per applicare delle modifiche a dei file di testo (come .h, Makefile) oppure per definire delle variabili di ambiente che saranno necessarie alla compilazione dell’applicativo. Questo processo è altamente dipendente dal tool che utilizziamo per compilare il codice, quindi vedremo degli esempi quanto discuteremo di compilazione utilizzando diversi tool come make, cmake e altro.

Questa fase può quindi essere omessa, oppure definita nulla nel seguente modo:

myapp.bb
0do_configure() {
1    :
2}

sfruttando il fatto che il comando : nella bash di Linux non fa letteralmente nulla.

compile

Di solito per compilare un qualsiasi applicativo si usano comandi specifici a seconda dell’ambiente in cui il codice è stato sviluppato, come ad esempio:

  • gcc
  • make
  • cmake
  • qmake
  • meson

e troppi altri.

Visto che questi sono necessari per la compilazione del recipe, ma non sono necessari per il funzionamento dell’applicativo finale, è necessario dichiarare una dipendenza del nostro recipe con il recipe che crea questi strumenti. Solitamente tali recipe sono di tipo native, nel senso che costruiscono degli applicativi che verranno utilizzati solo dal nostro pc host e non dalla nostra scheda target. Parlaremo più in dettaglio di questa differenza più avanti, per il momento ci basta sapere che questo tipo di dipendenza di dichiare utilizzando la variabile DEPENDS. Ad esempio, il compilatore gcc è costruito nel recipe glibc che si occupa di costruire una determinata versione della GNU C standard library:

DEPENDS = "glibc"

Siccome la C standard library è essenziale per praticamente qualsiasi tipo di applicativo, non è necessario dichiare esplicitamente questa dipendenza perchè lo fa già Yocto in automatico. L’unica accortezza è di non invocare direttamente gcc nel nostro recipe, ma la variabile CC, in quanto questa fa riferimento al binario di gcc creato da Yocto e non al gcc di sistema. Questo per rendere la build determinata e riproducibile, cioè sapere esattamente con quale versione del gcc i nostri applicativi sono stati compilati per permettere a chiunque di poterli ricreare esattamente identici (byte by byte). Ricordiamo che Yocto come primi passi si occupa di creare la toolchain da “zero”, quindi solo dai sorgenti e dalla nostra configurazione, anche per slegarsi dai tool del sistema host.

Detto questo, un esempio della fase di compilazione di un applicativo basato su codice c potrebbe essere:

myapp.bb
0do_compile () {
1    ${CC} myapp.c  -o myapp
2}

Tutti i comandi eseguiti in questa fase hanno come working directory B, ovvero la sottocartella di WORKDIR dedicata alla fase di build vera e propria di un recipe che di default è la stessa cartella in cui sono presenti i sorgenti:

meta/conf/bitbake.conf
406BASE_WORKDIR ?= "${TMPDIR}/work"
407WORKDIR = "${BASE_WORKDIR}/${MULTIMACH_TARGET_SYS}/${PN}/${PV}"
408T = "${WORKDIR}/temp"
409D = "${WORKDIR}/image"
410S = "${WORKDIR}/${BP}"
411B = "${S}"

Le definizioni delle variabili associate agli altri comandi classici della C standard library sono definite sempre nel solito file bitbake.conf:

meta/conf/bitbake.conf
557TOOLCHAIN_OPTIONS = " --sysroot=${STAGING_DIR_TARGET}"
558
559export CC = "${CCACHE}${HOST_PREFIX}gcc ${HOST_CC_ARCH}${TOOLCHAIN_OPTIONS}"
560export CXX = "${CCACHE}${HOST_PREFIX}g++ ${HOST_CC_ARCH}${TOOLCHAIN_OPTIONS}"
561export FC = "${HOST_PREFIX}gfortran ${HOST_CC_ARCH}${TOOLCHAIN_OPTIONS}"
562export CPP = "${HOST_PREFIX}gcc -E${TOOLCHAIN_OPTIONS} ${HOST_CC_ARCH}"
563export LD = "${HOST_PREFIX}ld${TOOLCHAIN_OPTIONS} ${HOST_LD_ARCH}"
564export CCLD = "${CC}"
565export AR = "${HOST_PREFIX}gcc-ar"
566export AS = "${HOST_PREFIX}as ${HOST_AS_ARCH}"
567export RANLIB = "${HOST_PREFIX}gcc-ranlib"
568export STRIP = "${HOST_PREFIX}strip"
569export OBJCOPY = "${HOST_PREFIX}objcopy"
570export OBJDUMP = "${HOST_PREFIX}objdump"
571export STRINGS = "${HOST_PREFIX}strings"
572export NM = "${HOST_PREFIX}gcc-nm"
573export READELF = "${HOST_PREFIX}readelf"
574PYTHON = "${@sys.executable}"

Per compilare applicativi più complessi con molteplici file .c, .h e chissà cos’altro, vedremo poi come utilizzare make, meson, cmake e altri all’interno di Yocto. Intanto continuiamo con la nostra pipeline.

install

È necessario implementare manualmente il processo di creare le varie cartelle del tipo usr bin all’interno della cartella definita dalla variabile D, ovvero la cartella temporanea del sysroot. Generalmente si fa grande uso del comando bash install, che permette di creare cartelle, copiare file in altre cartelle (seguendo i link simbolici) e assegnare i permessi Linux tutto con un unico comando. Nonostante il rootfs di Linux ha una struttura ormai ben definita e non soggetta a stravolgimenti, si preferisce comunque utilizzare delle variabili standard per riferirsi ai path comuni del tipo usr bin e altri. Le definizioni di queste sono visibili nel file:

meta/conf/bitbake.conf
17# Path prefixes
18export base_prefix = ""
19export prefix = "/usr"
20export exec_prefix = "${prefix}"
meta/conf/bitbake.conf
29# Architecture independent paths
30export sysconfdir = "${base_prefix}/etc"
31export servicedir = "${base_prefix}/srv"
32export sharedstatedir = "${base_prefix}/com"
33export localstatedir = "${base_prefix}/var"
34runtimedir = "${base_prefix}/run"
35export datadir = "${prefix}/share"
meta/conf/bitbake.conf
44# Architecture dependent paths
45export bindir = "${exec_prefix}/bin"
46export sbindir = "${exec_prefix}/sbin"
47export libdir = "${exec_prefix}/${baselib}"
48export libexecdir = "${exec_prefix}/libexec"
49export includedir = "${exec_prefix}/include"

Quindi una possibile definizione del task install per il nostro recipe può essere:

myapp.bb
0do_install () {
1    install -d ${D}${bindir}
2    install -m 644 ${B}/output.bin ${D}${bindir}/output.bin
3}

package

È sufficiente indicare quali file all’interno di D (holding area) andranno a comporre il package.

Si utilizza la variabile FILES per fare proprio questo:

myapp.bb
FILES:${PN} += "${bindir}/output.bin"

Il fatto che compaia FILES:${PN} invece che semplicemente FILES è dovuto ad un’altra particolare sintassi di bitbake, ovvero l’utilizzo degli override. Questo è un meccanismo complesso che affronteremo nella sezione dedicata alla sintassi bitbake, ma per il momento ci basta sapere che il layer meta di default assume che un recipe possa generare molteplici package, quindi è necessario che la variabile FILES possa assumere valori differenti per ogni package. Gli override permettono proprio di utilizzare un valore differente di FILES a seconda del package che vuole utilizzare tale variabile, gestendo quindi lo scope di un determinato package.

La lista dei package definiti di default è disponibile nel medesimo file bitbake.conf con la variabile PACKAGES:

meta/conf/bitbake.conf
326PACKAGE_BEFORE_PN ?= ""
327PACKAGES = "${PN}-src ${PN}-dbg ${PN}-staticdev ${PN}-dev ${PN}-doc ${PN}-locale ${PACKAGE_BEFORE_PN} ${PN}"
328PACKAGES_DYNAMIC = "^${PN}-locale-.*"
329FILES = ""

I package che non hanno un variabile FILES definita non vengono generati, per questo è importante utilizzare tale variabile in modo scoped invece che “globale”, altrimenti tutti i package di default vengono generati a partire dai medesimi file.

Con la variabile ALLOW_EMPTY possiamo forzare la generazione di un package anche nel caso questo sia vuoto:

meta/conf/bitbake.conf
355ALLOW_EMPTY:${PN}-dev = "1"

Troubleshooting

Ogni task è associato ad un file run, che rappresenta lo script finale che verrà eseguito, ed un file log, che contiene l’output a terminale che siamo abituati a vedere quando eseguiamo un comando bash.

Questi si trovano nella cartella temp all’interno della workdir del recipe in questione:

recipe workdir
${WORKDIR}
├── ${S}
├── ${B}
├── ${D}
└── temp
     ├── log.do_fetch
     ├── log.do_unpack
     ├── log.do_compile
     ├── log.do_install
     ├── ...
     ├── run.do_fetch
     ├── run.do_unpack
     ├── run.do_compile
     ├── run.do_install

E sono estremamente utili soprattutto perchè vengono generati di default senza doverci preoccupare di usare debug statements o altri meccanismi particolari.

Conclusioni

Bene, questo conclude la nostra prima visione di come funzionano i recipe, ma non gioire troppo presto perchè in realtà c’è ancora molta roba e ci dovremo ripassare sopra tra un po’. Semplicemente non volevo farti impazzire subito, ma piuttosto step-by-step. Un abbraccio.