10 - Komunikace STM32F446RE s PC programem

* pro vyučující: 10

Procvičovaná témata

  • Seriová vícebajtová (paketová) komunikace v prostředí mbed.org
  • Vyvolání přerušení a obslužné rutiny po stisku tlačítka v mbed.org
  • Definice komunikačních zpráv PC-Nucleo (marshalling and unmarshalling)
  • Program pro Nucleo v mbed.org (Nucleo část HW 10)
  • Načítání a posílání vícebajtových zpráv v PC aplikaci
  • Možná architektura ovládací aplikace pro PC
Ve verzi mbed 149 došlo k úpravě chování přerušení Tx při odeslání znaku. Popis práce s přerušením v mbed r148 a nižší je popsán na původní verzi informací ke cvičení..

Podpůrné soubory včetně binárního obrazu aplikace pro STM32F446RE.

Úkoly na cvičení

Sériová komunikace s přerušením v mbed.org

Přerušení a obslužné rutiny

V případě vícebajtové (paketové) komunikace mezi Nucleo deskou a řidícím počítačem může být výhodné v Nucleo aplikaci vyčítat a zapisovat data bajt po bajtu asynchronně (“na pozadí”) a v hlavním programu používát pouze operace pro odeslání nebo načtení celé zprávy, což značně zjednodušuje implementaci. Toho můžeme docílít vytvořením kruhových front (bufferů) pro zápis a čtení ze sériového portu. V případě zápisu můžeme využít generování přerušení v okamžiku přenesení bytu po sériové lince, kdy dojde k vyvolání přerušení v okamžiku prázdného odesílacího jednobajtového bufferu. V programu si tak můžeme připravit zprávu jako posloupnost bajtů, kterou pošleme na sériový port tak, že překopírujeme data do kruhového bufferu, ze kterého budou jednotlivé bajty postupně odesílány na sériový port v obsluze přerušení, např.

void Tx_interrupt()
{
    // send a single byte as the interrupt is triggered on empty out buffer 
    if (tx_in != tx_out) {
        serial.putc(tx_buffer[tx_out]);
        tx_out = (tx_out + 1) % BUF_SIZE;
    } else { // buffer sent out, disable Tx interrupt
        USART2->CR1 &= ~USART_CR1_TXEIE; // disable Tx interrupt
    }
    return;
}
V obsluze přerušení je nutné zamaskovat Tx přerušení v okamžiku, kdy jsou všechna data z bufferu odeslána. Přerušení je totiž vyvoláno při prázdném buffer a program by tak nikdy přerušení neopustil, pokud by se zrovna žádná data neodesílala.

Podobně pro čtení můžeme využít generování přerušení v okamžiku příjmu bajtu na sériovém portu, který v obsluze přerušení pouze vložíme do bufferu. V hlavní aplikaci pak můžeme vykonávat jinou činnost a v nějakém kontrolním bodě otestujeme, zdali jsou v bufferu nová data, která z bufferu načteme do vlastní struktury reprezentující jendotlivé zprávy (pakety). Např.

void Rx_interrupt()
{
    // receive bytes and stop if rx_buffer is full
    while ((serial.readable()) && (((rx_in + 1) % BUF_SIZE) != rx_out)) {
        rx_buffer[rx_in] = serial.getc();
        rx_in = (rx_in + 1) % BUF_SIZE;
    }
    return;
}

V prostřed mbed.org můžeme přiradit obsluhu přerušení prostřednictvím funkce serial.attach(). Nejdříve však deklarujeme funkce pro obsluhu přerušení při vysílání a příjmu dat, tj. Tx_interrupt() a Rx_interrupt(). Dále deklarujeme globání proměnné pro kruhové buffery pro vysílání a příjem dat spolu s příslušnými ukazateli do kruhového bufferu.

Serial serial(SERIAL_TX, SERIAL_RX);
 
void Tx_interrupt();
void Rx_interrupt();
 
#define BUF_SIZE 255
 
char tx_buffer[BUF_SIZE];
char rx_buffer[BUF_SIZE];
 
// pointers to the circular buffers
volatile int tx_in = 0;
volatile int tx_out = 0;
volatile int rx_in = 0;
volatile int rx_out = 0;
 
int main(void) {
    ...
    serial.attach(&Rx_interrupt, Serial::RxIrq); // attach interrupt handler to receive data
    serial.attach(&Tx_interrupt, Serial::TxIrq); // attach interrupt handler to transmit data
    ...

Funkce pro zápis (poslání) zprávy po sériovém portu

V našem případě definujeme jednotlivé komunikační zprávy jako posloupnost bajtů v definovaném rámci (paketu), tj. každá zpráva (paket) má přesně definovanou délku, přičemž první bajt zprávy určuje typ zprávy a poslední bajt zprávy obsahuje tzv. kontrolní součet (cksum). Bajty můžeme zapisovat do tx_buffer přímo. Alternativně definujeme složený typ struct message, který v kombinaci s union umožní pohodlný přístup na jednotlivé položky zpráv (paketů). Následně takovou zprávu převedeme na posloupnost bajtů, kterou uložíme do kruhového bufferu. Přestože budeme provádět kopírování jednotlivých bajtů vícekrát, výhodou tohoto přístupu je přehlednost a flexibilita ve snadné rozšiřitelnosti. Nicméně při kopírování dat do tx_buffer může být aktuální buffer plný, a celá zpráva se tam nemusí vejít. Proto kopírování dat pozastavíme do doby, než dojde k uvolnění tx_buffer. Během kopírování však musíme zajistit exkluzivní přístup k ukazatelům tx_in a tx_out v rámci kritické sekce, což provedeme zakázáním přerušení, které naopak pro uvolnění tx_buffer musíme opět povolit. Funkce pro odeslání paketu definované délky provedeme například funkcí send_buffer().

bool send_buffer(const uint8_t* msg, unsigned int size)
{
    if (!msg && size == 0) {
        return true;    // size must be > 0
    }
    bool ret = false;
    int i = 0;
    NVIC_DisableIRQ(USART2_IRQn); // start critical section for accessing global data
    USART2->CR1 |= USART_CR1_TXEIE; // enable Tx interrupt on empty out buffer
    bool empty = (tx_in == tx_out);
    while ( (i == 0) || i < size ) { //end reading when message has been read
        if ( ((tx_in + 1) % BUF_SIZE) == tx_out) { // needs buffer space
            NVIC_EnableIRQ(USART2_IRQn); // enable interrupts for sending buffer
            while (((tx_in + 1) % BUF_SIZE) == tx_out) {
                /// let interrupt routine empty the buffer
            }
            NVIC_DisableIRQ(USART2_IRQn); // disable interrupts for accessing global buffer
        }
        tx_buffer[tx_in] = msg[i];
        i += 1;
        tx_in = (tx_in + 1) % BUF_SIZE;
    } // send buffer has been put to tx buffer, enable Tx interrupt for sending it out
    USART2->CR1 |= USART_CR1_TXEIE; // enable Tx interrupt
    NVIC_EnableIRQ(USART2_IRQn); // end critical section
    return ret;
}

Funkce čtení (příjem zprávy ze sériovém portu

Podobně můžeme postupovat při čtení zprávy ze sériového portu. Jelikož chceme v programu vždy pracovat s celými zprávami, je nutné po načtení prvního bajtu identifikovat zprávu (paket) a určit její délku. Délku určíme funkcí bool get_message_size(uint8_t msg_type, int *len), která vrací hodnotu true pokud se podařilo zprávu identifikovat dle předané hodnoty msg_type. Jakmile načteme délku zprávy můžeme pokračovat v načítání příslušného počtu bajtů z rx_buffer, ale podobně jako při odesílání, nemusí být zpráva ještě kompletně přijata. V takovém případě vyčkáme dokud se rx_buffer nenaplní v obsluze přerušení Rx_interrupt(). Také v tomto případě musíme zajistit přístup k rx_buffer v rámci kritické sekce. Načtení zprávy můžeme například implementovat ve funkci receive_message().

bool receive_message(uint8_t *msg_buf, int size, int *len)
{
    bool ret = false;
    int i = 0;
    *len = 0; // message size
    NVIC_DisableIRQ(USART2_IRQn); // start critical section for accessing global data
    while ( (i == 0) || (i != *len) ) {
        if (rx_in == rx_out) { // wait if buffer is empty
            NVIC_EnableIRQ(USART2_IRQn); // enable interrupts for receing buffer
            while (rx_in == rx_out) { // wait of next character
            }
            NVIC_DisableIRQ(USART2_IRQn); // disable interrupts for accessing global buffer
        }
        uint8_t c = rx_buffer[rx_out];
        if (i == 0) { // message type
            if (get_message_size(c, len)) { // message type recognized
                msg_buf[i++] = c;
                ret = *len <= size; // msg_buffer must be large enough
            } else {
                ret = false;
                break; // unknown message
            }
        } else {
            msg_buf[i++] = c;
        }
        rx_out = (rx_out + 1) % BUF_SIZE;
    }
    NVIC_EnableIRQ(USART2_IRQn); // end critical section
    return ret;
}

Podrobnější informace o seriovém portu a přerušení v mbed.org lze najít na https://developer.mbed.org/cookbook/Serial-Interrupts.

Obsluha stisknutého tlačítka v přerušení v mbed.org

V naší aplikaci bude Nucleo deska dostávát výpočetní úlohy od ovládacího počítače a dílčí výsledky posílát přes sériový port řidící aplikaci. V průběhu výpočtu může dojít k jeho přerušení buď příslušnou zprávou od řidícího programu nebo po stisknutí uživatelského tlačítka na Nucleo desce. Jelikož v hlavním programu budeme chtít prioritně vyčítat zprávy (na bufferované v rx_buffer) a především počítat jednolivé dávky výpočtů, nemusí být příma obsluha tlačítka efektivní, neboť vyčtení hodnoty může být realizováno až s velkým zpožděním a změnu tak vůbec nemusíme zaregistrovat. Z tohoto důvodu zavedeme globální proměnnou abort_request, kterou nastavíme obslužné rutině button() přerušení vyvolané změnou náběžnou hranou signálu od tlačítka, tj. po uvolnění stisknutého tlačítka. Změnu proměnné pak provedeme v hlavním smyčce programu po zpracování požadavku na přeručení výpočtu. Zdrojový kód může vypadat například následovně.

InterruptIn button_event(USER_BUTTON);
volatile bool abort_request = false;
 
void button()
{
   abort_request = true;
}
 
...
int main()
{
   ...
   button_event.rise(&button);
   ...
   while (1) {
      if (abort_request) {
         if (computing) {  //abort computing
            msg.type = MSG_ABORT;
            send_message(&msg, msg_buf, MESSAGE_SIZE);
            computing = false;
            abort_request = false;
            ticker.detach();
            myled = 0;
         }
      }
   ... /// handle incoming messages
   }
}

Definice komunikačních zpráv

Komunikace mezi Nucleo programem a řidícím program probíhá prostřednictvím paketových zpráv, ve které je PC program nadřazený Nucleo programu, tj. PC program zasílá zprávy s požadavky a Nucleo program odpovídá jednou nebo více zprávámi. Typy zpráv jsou následující:

  • MSG_OK - kladná odpověď na přijatou zprávu MSG_ABORT, MSG_COMPUTE
  • MSG_ERROR - odpověď na přijatou zprávu MSG_ABORT, MSG_COMPUTE došlo k chybě
  • MSG_ABORT - Z PC požadavek na předčasné ukončení výpočtu. Z Nucleo desky se jedná asynchronní zprávu po detekci stisknutí tlačítka, pokud běží výpočet, je tento výpočet ukončen a Nucleo zasílá abort zprávu.
  • MSG_DONE - Poslední zpráva definující úspěšné ukončení kompletního výpočtu MSG_COMPUTE.
  • MSG_GET_VERSION - Žádost o zaslání verze firmware Nucleo programu. Nucleo odpovídá zprávou MSG_VERSION.
  • MSG_VERSION - Informace o verzi firmware Nucleo programu.
  • MSG_STARTUP - Asynchronní zpráva zasílána Nucleo programem po startu jedná se o posloupnost 9 znaků PRG-LAB10.
  • MSG_COMPUTE - Požadavek na výpočet balíku úloh identifikovaného chunk_id s počtem úloh nbr_tasks.
  • MSG_COMPUTE_DATA - Dílčí výsledek výpočtu identifikovaný chunk_id, task_id.

Datové struktury komunikačních zpráv

Dílčí typy zpráv definujeme výčtovým typem message_type.

// Definition of the communication messages
enum {
    MSG_OK,               // ack of the received message
    MSG_ERROR,            // report error on the previously received command
    MSG_ABORT,            // abort - from user button or from serial port
    MSG_DONE,             // report the requested work has been done
    MSG_GET_VERSION,      // request version of the firmware
    MSG_VERSION,          // send version of the firmware as major,minor, patch level, e.g., 1.0p1
    MSG_STARTUP,          // init of the message (id, up to 8 bytes long string, cksum
    MSG_COMPUTE,          // request computation of a batch of tasks (chunk_id, nbr_tasks)
    MSG_COMPUTE_DATA,     // computed result (chunk_id, task_id, result)
    MSG_NBRb
} message_type;

Každá zpráva začíná 1 bajtem indetifikující typ zprávy a končí posledním bajtem kontrolního součtu. Zprávy, které mají další data definujeme jako složené typy (struct)

typedef struct {
    uint8_t major;
    uint8_t minor;
    uint8_t patch;
} msg_version;
 
#define STARTUP_MSG_LEN 9
 
typedef struct {
    uint8_t message[STARTUP_MSG_LEN];
} msg_startup;
 
typedef struct {
    uint16_t chunk_id;
    uint16_t nbr_tasks;
} msg_compute;
 
typedef struct {
    uint16_t chunk_id;
    uint16_t task_id;
    uint8_t result;
} msg_compute_data;

Všechny možné zprávy definujeme ve složeném typu message s využítím union pro datové části jednotlivých dílčích typů zpráv.

typedef struct {
    uint8_t type;   // message type
    union {
        msg_version version;
        msg_startup startup;
        msg_compute compute;
        msg_compute_data compute_data;
    } data;
    uint8_t cksum; // message command
} message;

V programu dále využijeme deklarovaný obsah zprávy msg_version s informací o verzi firmware z textovaých konstant preprocesoru, např.

#define VERSION_MAJOR 0
#define VERSION_MINOR 9
#define VERSION_PATCH 0
 
msg_version VERSION = { .major = VERSION_MAJOR, .minor = VERSION_MINOR, .patch = VERSION_PATCH };

Načtení zprávy z pole bajtů (unmarshalling) a vytvoření posloupnosti bajtů ze zprávy (marshalling)

Pro načtení zprávy potřebujeme informaci o velikosti (počtu bajtů) zprávy, což můžeme implementovat přes inicializované pole nebo například případně funkcí get_message_size():

bool get_message_size(uint8_t msg_type, int *len)
{
    bool ret = true;
    switch(msg_type) {
        case MSG_OK:
        case MSG_ERROR:
        case MSG_ABORT:
        case MSG_DONE:
        case MSG_GET_VERSION:
            *len = 2; // 2 bytes message - id + cksum
            break;
        case MSG_STARTUP:
            *len = 2 + STARTUP_MSG_LEN;
            break;
        case MSG_VERSION:
            *len = 2 + 3 * sizeof(uint8_t); // 2 + major, minor, patch
            break;
        case MSG_COMPUTE:
            *len = 2 + 2 * sizeof(uint16_t); // 2 + chunk_id (16bit) + nbr_tasks (16bit)
            break;
        case MSG_COMPUTE_DATA:
            *len = 2 + 2 * sizeof(uint16_t) + 1; // 2 + chunk_id (16bit) + task_id (16bit) results (8bit)
            break;
        default:
            ret = false;
            break;
    }
    return ret;
}

Jakmile známe délku zprávy a příslušný počet bajtů byl načten, můžeme vyplnit datovou strukturu zprávy např. funkcí parse_message()

bool parse_message(const uint8_t *buf, int size, message *msg)
{
    uint8_t cksum = 0;
    for (int i = 0; i < size; ++i) {
        cksum += buf[i];
    }
    bool ret = false;
    int message_size;
    if (
        size > 0 && cksum == 0xff && // sum of all bytes must be 255
        ((msg->type = buf[0]) > 0) && msg->type < MSG_NBR &&
        get_message_size(msg->type, &message_size) && size == message_size) {
        ret = true;
        switch(msg->type) {
            case MSG_OK:
            case MSG_ERROR:
            case MSG_ABORT:
            case MSG_DONE:
            case MSG_GET_VERSION:
                break;
            case MSG_STARTUP:
                for (int i = 0; i < STARTUP_MSG_LEN; ++i) {
                    msg->data.startup.message[i] = buf[i+1];
                }
                break;
            case MSG_VERSION:
                msg->data.version.major = buf[1];
                msg->data.version.minor = buf[2];
                msg->data.version.patch = buf[3];
            case MSG_COMPUTE: // type + chunk_id + nbr_tasks
                msg->data.compute.chunk_id = (buf[1] << 8) | buf[2];
                msg->data.compute.nbr_tasks = (buf[3] << 8) | buf[4];
                break;
            case MSG_COMPUTE_DATA:  // type + chunk_id + task_id + results
                msg->data.compute_data.chunk_id = (buf[1] << 8) | buf[2];
                msg->data.compute_data.task_id = (buf[3] << 8) | buf[4];
                msg->data.compute_data.result = buf[5];
            default: // unknown message type
                ret = false;
                break;
        } // end switch
    }
    return ret;
}

ve které je patrné, že datové části chunk_id, nbr_tasks a task_id jsou 16 bitové hodnoty (uint16_t) a ve zprávě se nejdříve ukládá více významný bajt (hi-lo).

Podobně můžeme implementovat vyplnění pole bajtů ze struktury message podle dílčího typu zprávy. V našem případě voláme jak parse_message(), tak odeslání zprávy z hlavní smyčky programy. Navíc odeslání znamená překopírování zprávy do tx_buffer, proto vytvoříme funkce send_message(), která nejdříve provede tzv. marshalling a následně zavolá funkci send_buffer().

bool send_message(const message *msg, uint8_t *buf, int size)
{
    if (!msg || size < sizeof(message) || !buf) {
        return false;
    }
    // 1st - serialize the message into a buffer
    bool ret = true;
    int len = 0;
    switch(msg->type) {
        case MSG_OK:
        case MSG_ERROR:
        case MSG_ABORT:
        case MSG_DONE:
        case MSG_GET_VERSION:
            len = 1;
            break;
        case MSG_STARTUP:
            for (int i = 0; i < STARTUP_MSG_LEN; ++i) {
                buf[i+1] = msg->data.startup.message[i];
            }
            len = 1 + STARTUP_MSG_LEN;
            break;
        case MSG_VERSION:
            buf[1] = msg->data.version.major;
            buf[2] = msg->data.version.minor;
            buf[3] = msg->data.version.patch;
            len = 4;
            break;
        case MSG_COMPUTE:
            buf[1] = (uint8_t)(msg->data.compute.chunk_id >> 8); // hi - chunk_id
            buf[2] = (uint8_t)msg->data.compute.chunk_id;        // lo - chunk_id
            buf[3] = (uint8_t)(msg->data.compute.nbr_tasks >> 8);// hi - nbr_tasks
            buf[4] = (uint8_t)msg->data.compute.nbr_tasks;       // lo - nbr_tasks
            len = 5;
            break;
        case MSG_COMPUTE_DATA:
            buf[1] = (uint8_t)(msg->data.compute_data.chunk_id >> 8);// hi - chunk_id
            buf[2] = (uint8_t)msg->data.compute_data.chunk_id;       // lo - chunk_id
            buf[3] = (uint8_t)(msg->data.compute_data.task_id >> 8); // hi - task_id
            buf[4] = (uint8_t)msg->data.compute_data.task_id;        // lo - task_id
            buf[5] = msg->data.compute_data.result; // results
            len = 6;
            break;
        default: // unknown message type
            ret = false;
            break;
    }
    // 2nd - send the message buffer
    if (ret) { // message recognized
        buf[0] = msg->type;
        buf[len] = 0; // cksum
        for (int i = 0; i < len; ++i) {
            buf[len] += buf[i];
        }
        buf[len] = 255 - buf[len]; // compute cksum
        len += 1; // add cksum to buffer
        ret = send_buffer(buf, len);
    }
    return ret;
}

Aplikace pro Nucleo (mbed.org)

Výše uvedené funkce pro práci se zprávami můžeme s výhodou využít pro implementanci hlavní smyčky programu pro Nucleo desku. Program má reagovat na zasílané zprávy z řidícího počítače a v případě zahájení výpočtu je výpočet možné přerušit buď stiskem tlačítka nebo zaslání zprávy MSG_ABORT. V našem případě budeme náročný výpočet simulovat 200 ms čekáním, což představuje kompromis mezi odezvou systému a využitím výpočetních zdrojů. Výsledkem tohoto “výpočtu” je náhodné číslo od 0 do 255 (funkce rand()). Nakonec největší brzdou aplikace je právě komunikace po sériovém portu rychlostí 115200 bps spolu s relativně neúsporným komunikačním protokolem (na jeden vypočtený bajt potřebujeme přenést 7 bajtů), který však na druhou stranu umožňuje relativně jednoduše dávkovat výpočet na dílčí části. Vlastnosti aplikace jsou

  • Po startu program 5x krátce zabliká LED a pošle zprávu MSG_STARTUP.
  • Dále čeká na příkazy ze sériového portu.
  • Na zprávu MSG_GET_VERSION zasílá MSG_VERSION.
  • Na zprávu MSG_COMPUTE odpovídá MSG_ERROR při chybě nebo MSG_OK a zahajuje výpočet, při kterém bliká LED. Po dokončení dílčího výpočtu zasílá zprávu MSG_COMPUTE_DATA a po dokončení celé dávky výpočtu zasílá MSG_DONE a ukončuje blikání LED.
  • Běžící výpočet je možné přerušit zprávou MSG_ABORT, na kterou Nucleo odpovídá MSG_OK nebo MSG_ERROR
  • Výpočet je také možné přerušit stisknutím tlačítka, které nejen ukončí výpočet a blikání LED, ale také zašle zprávu MSG_ABORT, tj. výpočet končí buď zaslání MSG_ABORT nebo MSG_DONE.

Hlavní smyčka může například vypadat

  while (1) {
        if (abort_request) {
            if (computing) {  //abort computing
                msg.type = MSG_ABORT;
                send_message(&msg, msg_buf, MESSAGE_SIZE);
                computing = false;
                abort_request = false;
                ticker.detach();
                myled = 0;
            }
        }
        if (rx_in != rx_out) { // something is in the receive buffer
            if (receive_message(msg_buf, MESSAGE_SIZE, &msg_len)) {
                if (parse_message(msg_buf, msg_len, &msg)) {
                    switch(msg.type) {
                        case MSG_GET_VERSION:
                            msg.type = MSG_VERSION;
                            msg.data.version  = VERSION;
                            send_message(&msg, msg_buf, MESSAGE_SIZE);
                            break;
                        case MSG_ABORT:
                            msg.type = MSG_OK;
                            send_message(&msg, msg_buf, MESSAGE_SIZE);
                            computing = false;
                            abort_request = false;
                            ticker.detach();
                            myled = 0;
                            break;
                        case MSG_COMPUTE:
                            if (msg.data.compute.nbr_tasks > 0) {
                                ticker.attach(tick, period);
                                compute_data.chunk_id = msg.data.compute.chunk_id;
                                compute_data.nbr_tasks = msg.data.compute.nbr_tasks;
                                compute_data.task_id = 0; // reset the task counter
                                computing = true;
                            }
                            msg.type = MSG_OK;
                            send_message(&msg, msg_buf, MESSAGE_SIZE);
                            break;
                    } // end switch
                } else { // message has not been parsed send error
                    msg.type = MSG_ERROR;
                    send_message(&msg, msg_buf, MESSAGE_SIZE);
                }
            } // end message received
        } else if (computing) {
            if (compute_data.task_id < compute_data.nbr_tasks) {
                wait(compute_time); // do some computation
                msg.type = MSG_COMPUTE_DATA;
                msg.data.compute_data.chunk_id = compute_data.chunk_id;
                msg.data.compute_data.task_id = compute_data.task_id;
                msg.data.compute_data.result = rand() % 255;
                compute_data.task_id += 1;
                send_message(&msg, msg_buf, MESSAGE_SIZE);
            } else { //computation done
                ticker.detach();
                myled = 0;
                msg.type = MSG_DONE;
                send_message(&msg, msg_buf, MESSAGE_SIZE);
                computing = false;
            }
        } else {
            sleep(); // put the cpu to sleep mode, it will be wakeup on interrupt
        }
    } // end while (1)
}

Úkoly

  • Implementujete program pro Nucleo.
  • Otestujte základní chování programu terminálem např. cutecom nebo GTKTerm případně prostřednictvím přiložené aplikace lab10-main
Program představuje řešení úlohy HW 10 části pro Nucleo.
  • Ve zbývajícím čase cvičení se seznamte s úlohou HW 10 a možným řešením ovládací aplikace
  • Implementujte ovládací aplikaci hw10-main.
courses/b3b36prg/labs/lab10.txt · Last modified: 2018/04/26 20:46 by faiglj