martedì 5 dicembre 2023

Wokwi custom chip

Wokwi e Custom Chip

Quando tra i componenti di wokwi non c'è il chip che vogliamo includere nella simulazione, ci tocca crearne uno. Wokwi espone al programmatore la api wokwi-api.h tramite la quale modelliamo il comportamento del nostro chip custom.

Oltre alla api in C possiamo modellare il nostro chip custom con il linguaggio rust o in verilog, ma lo stato di sviluppo attualmente di questi due linguaggi è alpha.

Create a Custom Chip

Per iniziare, dalla lista dei componenti di wokwi (pulsante +), scorriamo la lista verso il basso incontrando la categoria Misc (Miscelanea), selezioniamo Custom Chip. La finestra di dialogo (Create a Custom Chip) che compare ci permette di scegliere il linguaggio tra quelli elencati (scegliamo il C poiché è raccomandato) e assegniamo un nome al nuovo chip. Confermiamo la scelta premendo il pulsante CREATE CHIP, in conseguenza di ciò due nuovi file sono aggiunti al progetto, uno ha estensione .json e l'altro .c.

Graficamente il chip si presenta alla destra come una PCB con 4 terminali, due sono l'alimentazione, e due uscita e ingresso. Il numero di pin I/O può essere aumentato modificando il file .json. L'immagine sotto mostra quanto descritto prima, cioè sono stati aggiunti al progetto i file test-mychip.chip.json e test-mychip.chip.c, il termine chip viene aggiunto automaticamente per cui evitiamo di specificarlo nel nome del componente, limitandoci a scrivere la sigla del componente o il nome generico, ad esempio anemometro.

area di lavoro dopo la creazione del chip

Nell'area editor è visibile il contenuto del file .json, in ordine dall'alto verso il basso abbiamo il nome del chip, l'autore, un array di nome pins contenente i nomi dei 4 pins. Per ultimo, l'array controls, grazie al quale durante la simulazione cliccando sul componente compare un cursore, che useremo ad esempio per variare la velocità del vento o altre grandezze come temperatura, umidità ecc. Possiamo specificare di aggiungere più cursori, senza esagerare poiché prendono spazio e quando compaiono coprono i componenti sottostanti. Attualmente controls è vuoto e quindi non comparirà nessun cursore.

Nell'area destra, stazionando con il puntatore del mouse su uno dei 4 pin comparirà il suo nome. Già che ci siamo avviamo la simulazione per vedere come muta l'interfaccia grafica.

Terminale chip console

Se non abbiamo commesso errori la simulazione si avvia e nell'area in basso a destra (area terminale) compare la tab CHIP CONSOLE nella quale possiamo stampare stringhe di debug, come in effetti avviene osservando il file .c. Nel file .c compare la seguente chiamata a funzione printf. Ricordate sempre di aggiungere il carattere terminatore '\n' alla fine, diversamente non comparirà nulla nel terminale.

printf("Hello from custom chip!\n");

Il file .c contiene già una implementazione di base. La funzione void chip_init() verrà chiamata per ogni istanza presente nell'area grafica. Cioè se il nostro progetto richiede due o più chip custom, lo selezioniamo e con CTRL+C e CTRL+V copiamo e incolliamo. Questa operazione modifica il file diagram.json, aggiungendo una istanza a quella già presente, vediamo le due righe interessate di seguito.

{ "type": "chip-test-mychip", "id": "chip1", "top": -66.18, "left": -446.4, "attrs": {} },
{ "type": "chip-test-mychip", "id": "chip2", "top": -96.62, "left": -488.32, "attrs": {} }

Il campo attrs risulta vuoto, al suo interno possiamo specificare più variabili e il loro valore, il valore di ognuna può essere acquisito da codice C attraverso la api di wokwi. Una delle prime cose da fare nella funzione chip_init() è proprio questa acquisizione. Come saranno usati questi valori determina il modo in cui procedere, ci sono i seguenti 3 modi di procedere:

  • Valori usa e getta: Quando il valore acquisito serve solo all'interno della funzione chip_init() salviamo il valore in una variabile locale.
  • Valori in variabili globali: Il valore verrà salvato in una variabile dichiarata globale, per cui dobbiamo prima dichiarare questa variabile. Attenzione che il nome della variabile potrebbe anche essere usato altrove dalla api, stessa cosa per le macro.
  • Valori in user_data: Il tipo di dato chip_state_t è attualmente una struttura dati priva di membri, possiamo specificare tutti i membri che necessitiamo dentro questa struttura. Molte delle funzioni di wokwi prendono come argomento un puntatore a tipo generico void *user_data a queste possiamo passare come argomento la variabile puntatore chip dichiarata locale dentro la funzione chip_init(). Attualmente ho verificato che la variabile chip può essere anche dichiarata globale e quindi visibile da tutte le funzioni del chip custom.

Occupiamoci della struttura chip_state_t. Decidiamo per adesso di inserire i 4 pins del chip, la struttura si presenta cosi: 

typedef struct {
// TODO: Put your chip variables here
pin_t out;
pin_t inp;
pin_t vcc;
pin_t gnd;
} chip_state_t;

Per definire un pin serve usare il tipo pin_t seguito dal nome della variabile. Spostiamoci nella funzione chip_init() per inizializzare i pins. Ogni pin deve essere inizializzato e tra le api troviamo la funzione pin_t pin_init(const char *name, uint32_t mode)

L'argomento mode può assumere i seguenti valori:


    INPUT           0
    INPUT_PULLUP    2
    INPUT_PULLDOWN  3
    OUTPUT_LOW      16
    OUTPUT_HIGH     17
    ANALOG          4

Non usate le costanti numeriche manifeste (cioè 0, 2, 3 ecc, ma i loro nomi. Le chiamate a pin_init() devono trovarsi dentro la funzione chip_init() o in un funzione da questa chiamata. Se è necessario riconfigurare il pin durante la simulazione useremo la funzione void pin_mode(pin_t pin, uint32_t mode). Di seguito vediamo un esempio di inizializzazione:

void chip_init() {
// alloca lo spazio con malloc che restituisce il puntatore chip
chip_state_t *chip = malloc(sizeof(chip_state_t));
chip->out = pin_init("OUT", OUTPUT_LOW);
chip->inp = pin_init("INP", INPUT);
chip->vcc = pin_init("VCC", INPUT);
chip->gnd = pin_init("GND", INPUT);
}

Certe volte è necessario inizializzare anche il pin VCC, altre volte non serve. Mentre non ho trovato alcuna utilità ad inizializzare il pin GND. Per cui rimuovete i pin che non vi serve inizializzare. Ho trovato utile inizializzare VCC quando il chip viene alimentato durante la simulazione o da un relay o da arduino, ad esempio delle fotocellule che dopo avere ricevuto alimentazione portano il pin OUT da LOW ad HIGH per 100ms per poi tornare LOW.

Attributi

Un attributo è una coppia nome:valore specificato in uno dei file .json. Ogni attributo per il codice C è un parametro di ingresso. Prima di potere leggere il valore di un attributo dobbiamo inizializzarlo. La funzione attr_init() si occupa della inizializzazione. Vediamo un esempio per acquisire da C il valore di un attributo di nome initOutState specificato nel file diagram.json seguente:

{
"type": "chip-test-mychip",
"id": "chip1",
"top": 20.22,
"left": -244.8,
"attrs": { "initOutState": "0" }
}

Considerate anche la possibilità che l'attributo non sia presente, in tal caso da codice C dobbiamo prevedere un valore di default. Di seguito la funzione chip_init() che inizializza l'attributo, legge il valore con attr_read() e lo stampa con printf():

void _chip_init() {
chip_state_t *chip = malloc(sizeof(chip_state_t));
uint32_t attr_ios = attr_init("initOutState", 1);
// valore di default ^
printf("initOutState = %u\n", attr_read(attr_ios));
}

In questo caso stamperà initOutState = 0, mentre se rimuovete l'attributo "initOutState": "0" da diagram.json stamperà initOutState = 1.

Attributi del chip

Nel file name.chip.json possiamo specificare alcuni attributi speciali. Uno di questi ci fa comparire un cursore regolabile durante la simulazione se clicchiamo sul chip grafico. Nel file in questione esiste la proprietà controls la quale funge da contenitore dove specificare un array di oggetti. Attualmente il solo tipo di oggetto valido è un cursore con 6 proprietà che lo descrivono. Di seguito due oggetti cursore usati per un generatore di frequenza pwm specificati nel file name.chip.json

"controls": [
{
"id": "ctrlFreq",
"label": "Frequency",
"type": "range",
"min": 1,
"max": 10000,
"step": 1
},
{
"id": "ctrlDcycle",
"label": "Duty Cycle",
"type": "range",
"min": 1,
"max": 99,
"step": 1
}
]

Ciò che compare durante la simulazione lo possiamo vedere nell'immagine qui sotto. 

Entrambe i cursori possono essere regolati durante la simulazione, ciò ci permette di sperimentare applicazioni che fanno uso ad esempio di interrupt, come un frequenzimetro, un anemometro e persino controllare la posizione di servo RC direttamente collegato al chip. Per acquisire i valori di questi cursori usiamo le stesse due funzioni viste prima, cioè attr_init() e attr_read(). L'implementazione del chip richiede l'acquisizione del valore di questi cursori, attualmente non mi risulta ci siano delle funzioni specifiche per agganciare l'evento ad una funzione utente (callback), per cui ho usato un timer software che chiama una funzione ogni 100ms, al suo interno c'è il codice per leggere i cursori attraverso la funzione attr_read(). La prossima sezione riguarda appunto l'uso dei timer software, con un esempio che mostra come leggere periodicamente i cursori.

Software Timer

La gestione dei timer è affidata a quattro sole funzioni, di cui una conta il tempo in microsecondi e l'altra in nanosecondi. Per non mortificare le prestazioni, si consiglia di usare la funzione void timer_start(uint32_t timer_id, uint32_t micros, bool repeat) la quale opera in microsecondi. Prima che un timer possa essere usato è necessario inizializzarlo, ciò richiede la compilazione di una struttura composta da 2 campi. Il codice sotto dichiara e inizializza la variabile tcfg_controls, inizializza il timer e lo avvia. Al membro .user_data viene assegnato il puntatore chip, mentre al membro .callback viene assegnato il nome della funzione da chiamare.

// tid_controls is periodic 100ms timer
timer_config_t tcfg_controls = {
.user_data = chip,
.callback = tcb_controls
};
timer_t tid_controls = timer_init(&tcfg_controls);
timer_start(tid_controls, 100000, true);
// time__^ ^__auto repeat

La funzione tcb_controls (nome a piacere) deve prendere un argomento puntatore a tipo generico e non restituire nulla, segue un esempio:

void tcb_controls(void *u_data) {
chip_state_t *chip = (chip_state_t*)u_data;
}

Vero che il nome può essere scelto a piacere, ma per evitare confusione il nome della funzione inizia con tcb che mi fa capire che si tratta di una timer callback così posso distinguerla facilmente da altre funzioni generiche. Stessa cosa per tid che sta per timer id. Il puntatore u_data non può essere usato direttamente, per cui il cast esplicito lo trasforma in un puntatore di nome chip a tipo chip_state_t. Questa funzione viene chiamata periodicamente durante la simulazione ed è arrivato il momento di vedere e provare il codice. Il codice seguente ha l'obbiettivo di mostrare il timer in azione. Nella funzione chip_init() inizializzo gli attributi assegnando loro un valore di default, attr_freq e attr__dc sono membri di chip. In base al valore dell'attributo "initOutState" inizializzo il pin chip->out. Alla fine configuro il timer e lo avvio con timer_start().

// SPDX-License-Identifier: MIT
// Copyright 2023 Maurilio Pizzurro

// .abbreviazioni
// tid timer_id
// attr attribute
// cnt counter
// tcfg timer_config
// cb callback
// tcb timer callback
// CVT CONVERT

#include "wokwi-api.h"
#include <stdio.h>
#include <stdlib.h>

#define AUTO_REPEAT true
#define NO_REPEAT false
#define DFLT_FREQ 1
#define DFLT_DC 50
#define DLFT_IOS HIGH

#define CVT_msTOus(m) (m * 1000)

typedef struct {
// TODO: Put your chip variables here
pin_t out;
pin_t inp;
pin_t vcc;
pin_t gnd;
uint32_t attr_freq;
uint32_t attr_dc;
} chip_state_t;

// timer callback
void tcb_controls(void *u_data) {
chip_state_t *chip = (chip_state_t*)u_data;
uint32_t f = attr_read(chip->attr_freq);
uint8_t dc = attr_read(chip->attr_dc);
printf("Freq Hz: %u\n", f);
printf("DutyCycle %% : %u\n", dc);
}

void chip_init() {
chip_state_t *chip = malloc(sizeof(chip_state_t));
printf("Hello from custom chip!\n");

// !!! inizializazione degli attributi !!!
// inizializza gli attrinuti controls (vedi test-mychip.chip.json)
chip->attr_freq = attr_init("ctrlFreq", 1);
// attribute name_^ ^__default value
chip->attr_dc = attr_init("ctrlDcycle", 50);
// initOutState è nel file diagram.json
uint32_t attr_ios = attr_init("initOutState", DLFT_IOS);

bool ios = (bool)attr_read(attr_ios);
printf("initOutState = %u\n", ios);

// *** inizializazione dei pin I/O ***
// Se ios == true il pin out è in uscita impostato HIGH
if (ios) {
chip->out = pin_init("OUT", OUTPUT_HIGH);
// altrimenti out è uscita impostata LOW
} else {
chip->out = pin_init("OUT", OUTPUT_LOW);
}
chip->inp = pin_init("INP", INPUT_PULLUP);

// *** inizializazione dei timer ***
// tid_controls is periodic 100ms timer
timer_config_t tcfg_controls = {
.user_data = chip,
.callback = tcb_controls
};
timer_t tid_controls = timer_init(&tcfg_controls);
// avvia il timer ciclico
timer_start(tid_controls, CVT_msTOus(100), AUTO_REPEAT);
}

Link al progetto su wokwi,

Il link sopra punta direttamente al progetto da simulare, potrete constatare voi stessi come la CHIP CONSOLE viene inondata di messaggi molte volte con stessi valori dei cursori.

Freq Hz: 3032
DutyCycle % : 32
Freq Hz: 3032
DutyCycle % : 32
Freq Hz: 3032
DutyCycle % : 32  

Questo non è certo un problema per un test veloce, ma vogliamo stampare il valore dei cursori solo quando l'utente sposta il cursore. Risolviamo questo comportamento aggiungendo una struttura dove salvare i vecchi valori di frequenza e duty cycle e modifichiamo la funzione aggiungendo due condizioni if come mostrato di seguito:

struct {
int32_t freq;;
int8_t dc;
} oldValue = { .freq=-1, .dc = -1 };

// timer callback
void tcb_controls(void *u_data) {
chip_state_t *chip = (chip_state_t*)u_data;
uint32_t f = attr_read(chip->attr_freq);
uint8_t dc = attr_read(chip->attr_dc);
if (oldValue.freq != f) {
printf("Freq Hz: %u\n", f);
oldValue.freq = f;
}
if (oldValue.dc != dc) {
printf("DutyCycle %%: %u\n", dc);
oldValue.dc = dc;
}
}

Ci manca qualche rifinitura è il nostro generatore pwm è pronto per essere impiegato in uno sketch arduino. La versione a seguire aggiunge un timer e una callback che è quella che genera il segnale di uscita pwm. Vediamo le modifiche dall'inizio.

#define AUTO_REPEAT true
#define NO_REPEAT false
#define DFLT_FREQ 1
#define DFLT_DC 500
#define DLFT_IOS HIGH

#define T0 0
#define T1 1

#define CVT_msTOus(m) (m * 1000)

typedef struct {
uint32_t period;
uint32_t t01[2];
timer_t tid_pwm;
} pwm_t;

typedef struct {
// TODO: Put your chip variables here
pin_t out;
pin_t inp;
uint32_t attr_freq;
uint32_t attr_dc;
pwm_t pwm;
} chip_state_t;

struct {
int32_t freq;;
int16_t dc;
} oldValue = { .freq=-1, .dc = -1 };

// timer callback
void tcb_pwm(void *u_data) {
chip_state_t *chip = (chip_state_t*)u_data;
uint8_t out_state = !pin_read(chip->out); // Nota !
timer_start(chip->pwm.tid_pwm, chip->pwm.t01[out_state], NO_REPEAT);
pin_write(chip->out, out_state);
//printf("out_state:%u\n", !out_state);
}

Abbiamo definito un nuovo tipo pwm_t che usiamo dentro chip_state_t dove abbiamo eliminato i pin di alimentazione che non ci servono. La funzione tcb_pwm() viene chiamata dal timer tid_pwm. Questa funzione legge lo stato di chip->out ne inverte lo stato per poi avviare lo stesso timer. Il tempo chip->pwm.t01[out_state] cambia in relazione alla posizione del cursore Duty Cycle, ma anche in relazione allo stato della uscita out.

// chip->tid_pwm is oneshot timer
timer_config_t tcfg_pwm = {
.user_data = chip,
.callback = tcb_pwm
};
chip->pwm.tid_pwm = timer_init(&tcfg_pwm);
tcb_controls(chip);
timer_start(chip->pwm.tid_pwm, chip->pwm.t01[T0], NO_REPEAT);

Nella funzione chip_init() abbiamo agginto il timer tid_pwm e lo abbiamo avviato. Quindi il pwm viene generato dall'inizio della simulazione, ma si potrebbe desiderare di avviarlo a comando ad esempio tramite il pin INP. Prima di implementare questa funzionalità è bene prendere confidenza con le api I/O perché la funzione pin_watch ci permette di agganciare un callback al cambiamento di stato di un pin.

Link alla simulazione. 

Questa introduzione sui chip custom si ferma qui, penso che sia sufficiente per farsi una idea di base, per il momento non c'è nulla di complicato, ma sappiate che ci sono le api per SPI, i2c ecc, clicca qui per la documentazione ufficiale. 

Prosegui la lettura per scoprire la api pin_watch.

Licenza Creative Commons
Quest'opera è distribuita con Licenza Creative Commons Attribuzione - Condividi allo stesso modo 4.0 Internazionale

Nessun commento:

Posta un commento