Aby ruszyć dalej z pracami nad micromousem, potrzebuję funkcji logujących dane z działania programu na konsolę w czasie rzeczywistym. Są mi one potrzebne do kalibracji czujników ścian i doboru nastaw dla regulatorów silników. Idealnym rozwiązaniem było by wykorzystanie standardowej funkcji printf. Na mikrokontrolerze jednak nie jest to takie proste, ponieważ trzeba dopisać warstwę obsługi drivera USARTa. Poza tym należy rozwiązać pewne problemy implementacyjne. To wszystko opiszę w tym artykule. Poza tym kod data loggera oraz drivera USART jest dostępny na moim GitHubie.

Printf na mikrokontrolerach

Funkcja printf z biblioteki standardowej rzadko jest wykorzystywana na mikrokontrolerach. Najczęściej zamiast niej implementuje się własną funkcję printf zawierającą wsparcie tylko dla kilku podstawowych stringów formatujących. Takie rozwiązanie pozwala na lepszą kontrolę zużycia pamięci zarówno RAM jak i FLASH.

Niektóre kompilatory, jak na przykład xc32 od Microchipa dla procesorów PIC32 zawierają lekką implementację biblioteki standardowej, w tym funkcję printf. Potrzebne wtedy jest jedynie dopisanie funkcji łączącej wypisywanie znaków z konkretnym peryferium UART. W PIC32 służy do tego funkcja _mon_putc.

Niestety printf z arm-none-eabi-gcc jest robiony raczej z myślą o bardziej złożonych systemach wykorzystujących Linuxa. Dlatego printf jest tutaj dosyć spory, przynajmniej jak na realia mikrokontrolerów (szczegółowe dane o rozmiarze w dalszej części wpisu). Nie przeszkadza mi to jednak użyć go w moim projekcie. Najwyżej później, kiedy będę potrzebował miejsca, przerzucę się na lżejsze rozwiązanie.

Data logger

Implementując funkcję printf musimy się uporać z jednym głównym problemem. Otóż drukowanie znaków za pomocą sprzętu trwa dosyć długo. W tym czasie program może chcieć zapisywać na konsolę kolejne dane. Możliwe następstwa to gubienie wiadomości, czy wywłaszczanie w środku pisania. To drugie objawia się przez rozpoczęcie drukowania jednej wiadomości i wydrukowanie drugiej w połowie pierwszej, albo drukowanie na zmianę litery z jednej i drugiej wiadomości. Aby wyeliminować te problemy należy zaimplementować kontrolę dostępu i buforowanie.

Zdecydowałem się zaimplementować data logger wykorzystujący driver USART Tx w trybie DMA i swój własny task działający na niskim priorytecie. Logger zawiera podwójny bufor. Do jednego bufora logowane są przychodzące wiadomości, a drugi jest używany przez DMA do wysyłania. W tasku loggera cyklicznie bufory są zamieniane, kiedy DMA skończy wysyłać i są nowe dane z aplikacji.

Rozmiar bufora jest dobrany na podstawie baudrate i okresu tasku. Baudrate to 115200 bps, a okres tasku to 50 ms. Driver jest w stanie wysłać 115200 bitów. Zakładając, że każdy bajt zawiera 8 bitów danych + 1 bit startu + 1 bit stopu, wysyłamy 11520 bajtów na sekundę. Czyli w 50 ms możemy wysłać  576 bajtów. Aby zapewnić dodatkowo trochę zapasu, rozmiar bufora został ustalony na 1024 bajty.

Zapisywanie do bufora zostało zrealizowane przez kopiowanie wiadomości przekazanej jako argument. Jeżeli cała wiadomość nie mieści się do bufora, zapisywana jest maksymalna możliwa ilość znaków, która jest zwracana przez funkcję zapisu. W takim wypadku wyższa warstwa decyduje, czy próbuje wysłać pozostałe dane ponownie, czy dropuje ramkę.

Aby zabezpieczyć się przed jednoczesnym dostępem do bufora przez task loggera i funkcję zapisu wywoływaną przez inne wątki wykorzystuję mutex. W driverze USART zaimplementowany jest podobny mechanizm. Semafor binarny synchronizuje funkcję zapisu drivera z przerwaniem od końca transmisji DMA. Ma on na celu uniemożliwienie zlecenia kolejnej transmisji, kiedy trwa poprzednia.

Kod loggera

Poniżej przedstawiam kod tasku loggera.

/** Period of sending logged data to USART driver in milliseconds. */
#define FLUSH_INTERVAL_MS   50

/**
 * Size of a single logger buffer.
 *
 * Buffer size is calculated to store more bytes than maximum driver throughput
 * so it can be send later when new data are not received.
 */
#define LOG_BUF_SIZE    1024

/** Structure containing logger internal parameters. */
struct logger_params
{
    uint8_t *buf;       /** Pointer to buffer currently used for writing data. */
    int32_t cnt;        /** Number of bytes written to current buffer. */
    uint32_t next_buf;  /** Index of next buffer to be used for logging. */
    mutex_t mutex;      /** Mutex for protecting buffer during sending to driver. */
};

/** Logger task internal parameters. */
static struct logger_params log_params;

/**
 * Buffers used for storing logger data.
 *
 * When one buffer is sent to USART driver, second is used for storing data received
 * from the application. After predefined time interval buffer with received data
 * is passed to driver and other buffer is used for writing new data.
 */
static uint8_t bufs[2][LOG_BUF_SIZE];

int32_t logger_write_buffer(uint8_t *buf, int32_t len)
{
    int32_t n_bytes;

    if (buf == NULL)
    {
        /* Invalid argument */
        return -EINVAL;
    }

    if (rtos_mutex_take(log_params.mutex, 10) != true)
    {
        /* Report timeout */
        return -EBUSY;
    }

    /* Calculate maximum possible number of bytes to write */
    n_bytes = (log_params.cnt + len > LOG_BUF_SIZE) ?
              (LOG_BUF_SIZE - log_params.cnt) :
              (len);

    /* Write data to the send buffer */
    memcpy(log_params.buf + log_params.cnt, buf, n_bytes);
    log_params.cnt += n_bytes;

    rtos_mutex_give(log_params.mutex);

    return n_bytes;
}

static void logger_task(void *params)
{
    (void) params;

    tick_t ticks;

    switch_bufs();

    while (true)
    {
        ticks = rtos_tick_count_get();

        if (rtos_mutex_take(log_params.mutex, 10) == true)
        {
            if (0 <= usart_send_buf(log_params.buf, log_params.cnt))
            {
                switch_bufs();
            }
            /*
             * If USART send failed - probably old data transfer not finished. When new
             * logged data arrives they will be added to current buffer. Next try to
             * flush data will be performed in the next iteration.
             */

            rtos_mutex_give(log_params.mutex);
        }

        rtos_delay_until(&ticks, FLUSH_INTERVAL_MS);
    }
}

static void switch_bufs(void)
{
    log_params.buf = bufs[log_params.next_buf];

    log_params.next_buf++;
    log_params.next_buf &= 0x00000001;
    log_params.cnt = 0;
}

Syscalls – wywołania systemowe

Warstwą pośrednią między biblioteką standardową, a konkretną implementacją sprzętową jest zbiór wywołań systemowych. Aby połączyć funkcję printf z naszą implementacją drivera UART musimy zaimplementować syscall _write_r. Inne wywołania systemowe odpowiadają między innymi za otwieranie/zamykanie plików, kończenie programu, operacje na procesach takie jak kill, getpid, czy fork, a także sbrk odpowiedzialny za obsługę heapa wykorzystywanego do dynamicznej alokacji pamięci.

Literka r w nazwie funkcji oznacza, że jest to wersja reentrant, czyli umożliwiająca wywoływanie w tym samym czasie przez wiele wątków. Jako argumenty funkcja przyjmuje:

  • struct _reent *r – struktura zawierająca stan dla bierzącego kontekstu, nieużywana przeze mnie.
  • int file – identyfikator pliku, do którego mają być zapisane dane. Moja funkcja obsługuje stdin (STDOUT_FILENO).
  • const void *buf – bufor do wydrukowania na konsoli.
  • size_t nbyte – rozmiar bufora

Moja implementacja próbuje wysłać otrzymany bufor do loggera, a gdy to się nie uda – ponawia próbę wysłania reszty bufora aż do wysłania całej wiadomości. Poniżej kod funkcji write_r:

ssize_t _write_r(struct _reent *r, int file, const void *buf, size_t nbyte)
{
    (void) r;

    int32_t ret;
    uint32_t offset;
    uint8_t *buf_ptr;

    switch (file)
    {
    case STDOUT_FILENO:
        offset = 0;
        buf_ptr = (uint8_t *)buf;

        do
        {
            ret = logger_write_buffer(buf_ptr + offset, nbyte - offset);

            /* Error while sending */
            if (ret == -EINVAL)
            {
                return ret;
            }
            else if (ret == -EBUSY)
            {
                /* need to restart transmission */
                return ret;
            }
            else if (ret >= 0)
            {
                offset += ret;
            }
            else
            {
                /* unexpected behavior */
                return ret;
            }

            /* Could not send all the data, wait for USART to be free */
            if (offset < nbyte)
            {
                rtos_delay(5);
            }
        } while (offset < nbyte);

        break;
    default:
        break;
    }

    return 0;
}

Zużycie pamięci

Dzięki opisanym wyżej krokom zintegrowałem systemową funkcję printf z własnym taskiem loggera wysyłającym dane na USART. Po uruchomieniu programu testowego na docelowym procesorze dane drukują się na konsolę. Tak więc zrealizowałem założenia i mam działającą konsolę operującą na systemowym printfie, czyli obsługującą stringi formatujące. Zobaczmy teraz ile taka fanaberia kosztowała mnie pamięci FLASH oraz RAM.

Program testowy z wykomentowaną funkcją printf ma rozmiar:

   text	   data	    bss
  11132	   8196	   7100

Natomiast program z włączonymi printfami:

   text	   data	    bss
  32052	   9704	   7152

Podane wyżej liczby są dla kompilacji z flagą -O0, czyli bez żadnej optymalizacji. Jak widać, printf potrzebuje 20kB FLASH i 1.5kB RAM – dosyć sporo. Lekkie implementacje printfa wymagają kilka razy mniej pamięci.

Podsumowanie

W artykule przedstawiłem sposób implementacji systemowego printfa wykorzystującego USART na STM32. Na pewno nie jest to najbardziej optymalna metoda. Jeżeli potrzebujemy lekkiej implementacji – lepiej poszukać gotowych funkcji w internecie. Jeżeli jednak zużycie pamięci nie jest dla nas problemem i chcemy szybko dodać printfa mającego wszystkie „ficzery” – przedstawiona metoda będzie idealna.

Przy okazji, kiedy spushowałem zmiany w loggerze okazało się, że build nie przechodzi na Travisie. Przyczyną jest za długa kompilacja nowej wersji toolchaina. Będę musiał do tego usiąść, albo dać za wygraną i przenieść się na oficjalny toolchain do ARMów.