Самые частые грабли при использовании printf в программах под микроконтроллеры

в 10:45, , рубрики: C, c++, Cpp, debug, gcc, ld script, linker, stm, Компиляторы, компоновщик, микроконтроллеры, отладка, программирование микроконтроллеров, системы сборки

Время от времени в моих проектах приходится применять printf в связке с последовательным портом (UART или абстракция над USB, имитирующая последовательный порт). И, как обычно, времени между его применениями проходит много и я успеваю напрочь забыть все нюансы, которые требуется учитывать, чтобы он нормально работал в крупном проекте.

В данной статье я собрал свой собственный топ нюансов, которые возникают при использовании printf в программах под микроконтроллеры, сортированный по очевидности от самых очевидных к полностью неочевидным.

Краткое введение

По сути для того, чтобы использовать printf в программах под микроконтроллеры, достаточно:

  • подключить заголовочный файл в коде проекта;
  • переопределить системную функцию _write на вывод в последовательный порт;
  • описать заглушки системных вызовов, которые требует компоновщик (_fork, _wait и прочие);
  • использовать printf вызов в проекте.

На деле же, не все так просто.

Описать нужно все заглушки, а не только используемые

Наличие кучи неопределенных ссылок при компоновке проекта по началу удивляет, но немного почитав, становится понятно, что и для чего. Во всех своих проектах подключаю вот этот субмодуль. Таким образом, в основном проекте я переопределяю только нужные мне методы (только _write в данном случае), а остальные остаются неизменными.

Важно отметить, что все заглушки должны быть C функциями. Не C++ (или обернутые в extern «C»). Иначе компоновка пройдет неудачно (помним про изменение имен при сборке у G++).

В _write приходит по 1 символу

Несмотря на то, что в прототипе метода _write есть аргумент, передающий длину выводимого сообщения, он имеет значение 1 (на самом деле мы сами сделаем так, что он всегда будет 1, но об этом далее).

int _write (int file, char *data, int len) {
   ...
}

В интернете часто можно видеть вот такую реализацию этого метода:

Частая реализация функции _write
int uart_putc( const char ch)
{
        while (USART_GetFlagStatus(USART2, USART_FLAG_TC) == RESET);
        {}
        USART_SendData(USART2, (uint8_t) ch);
return 0;
}

int _write_r (struct _reent *r, int file, char * ptr, int len)
{  
  r = r;
  file = file;
  ptr = ptr;
#if 0
  int index;
  /* For example, output string by UART */
  for(index=0; index<len; index++)
  {
    if (ptr[index] == 'n')
    {
      uart_putc('r');
    }  
    uart_putc(ptr[index]);
  }  
#endif   
  return len;
}

У такой реализации есть следующие недостатки:

  • низкая производительность;
  • потоковая незащищенность;
  • невозможность использовать последовательный порт для других целей;

Низкая производительность

Низкая производительность объясняется отправкой байт с использованием ресурсов процессора: приходится следить за регистром состояния вместо использования того же DMA. Для решения этой проблемы можно заранее готовить буфер для отправки, и при получении символа конца строки (или заполнения буфера) производить отправку. Данный способ требует наличия памяти под буфер, однако значительно повышает производительность при частой отправке.

Пример реализации _write с буфером

#include "uart.h"

#include  <errno.h>
#include  <sys/unistd.h>

extern mc::uart uart_1;

extern "C" {

// Буфер для кеширования обращений к uart.
static const uint32_t buf_size = 254;
static uint8_t tx_buf[buf_size] = {0};
static uint32_t buf_p = 0;

static inline int _add_char (char data) {
    tx_buf[buf_p++] = data;

    if (buf_p >= buf_size) {
        if (uart_1.tx(tx_buf, buf_p, 100) != mc_interfaces::res::ok) {
            errno = EIO;
            return -1;
        }
        buf_p = 0;
    }

    return 0;
}

// Putty хочет rn в качестве перехода
// в начало новой строки.
static inline int _add_endl () {
    if (_add_char('r') != 0) {
        return -1;
    }

    if (_add_char('n') != 0) {
        return -1;
    }

    uint32_t len = buf_p;
    buf_p = 0;
    if (uart_1.tx(tx_buf, len, 100) != mc_interfaces::res::ok) {
        errno = EIO;
        return -1;
    }

    return 0;
}

int _write (int file, char *data, int len) {
    len = len;

    // Проверяем корректность потока.
    if ((file != STDOUT_FILENO) && (file != STDERR_FILENO)) {
        errno = EBADF;
        return -1;
    }

    // Внутри приложения стандартом завершения
    // строки является n.
    if (*data != 'n') {
        if (_add_char(*data) != 0) {
            return -1;
        }
    } else {
        if (_add_endl() != 0) {
            return -1;
        }
    }

    return 1;
}

}

Здесь за непосредственную отправку с использованием dma отвечает объект класса uart — uart_1. Объект использует методы FreeRTOS для блокировки стороннего доступа к объекту на момент отправки данных из буфера (взятие и возвращение mutex-а). Таким образом, никто не может воспользоваться объектом uart-а во время отправки из другого потока.
Немного ссылок:

  • код функции _write в составе реального проекта здесь
  • интерфейс класса uart здесь
  • реализация интерфейса класса uart под stm32f4 здесь и здесь
  • создание экземпляра класса uart в составе проекта здесь

Потоковая незащищенность

Данная реализация так же остается потока незащищенной, поскольку никто не мешает в соседнем потоке FreeRTOS начать отправку в printf другой строки и тем самым перетереть отправляемый в данный момент буфер (mutex внутри uart-а защищает объект от использования в разных потоках, но не передаваемые им данные). В случае, если есть риск, что будет вызван printf другого потока, то требуется реализовать объект-прослойку, который будет блокировать доступ к printf целиком. В моем конкретном случае с printf взаимодействует лишь один поток, поэтому дополнительные усложнения лишь уменьшат производительность (постоянное взятие и отпуск mutex-а внутри прослойки).

Невозможность использовать последовательный порт для других целей

Поскольку мы производим отправку только после того, как будет принята строка целиком (или заполнен буфер), то вместо объекта uart можно вызывать метод-конвертер в какой-либо интерфейс верхнего уровня для последующей пересылки пакета (например доставка с гарантией по протоколу передачи схожим с пакетами транзакций modbus). Это позволит использовать один uart как для вывода отладочной информации, так и, например, для взаимодействия пользователя с консолью управления (если таковая имеется в устройстве). Достаточно будет написать декомпрессор на стороне получателя.

По умолчанию не работает вывод float

Если вы используете newlib-nano, то по умолчанию printf (а так же все их производные по типу sprintf/snprintf… и прочие) не поддерживают вывод float значений. Это легко решается добавлением в проект следующих флагов компоновщика.

SET(LD_FLAGS
        "-Wl,-u _scanf_float"
        "-Wl,-u _printf_float"
        "другие_флаги")

Посмотреть полный перечень флагов можно здесь.

Программа зависает где-то в недрах printf

Это еще одна недоработка флагов компоновщика. Чтобы прошивка была скомпонована с нужной версией библиотеки нужно явно указать параметры процессора.

SET(HARDWARE_FLAGS
        -mthumb
        -mcpu=cortex-m4
        -mfloat-abi=hard
        -mfpu=fpv4-sp-d16)

SET(LD_FLAGS
        ${HARDWARE_FLAGS}
        "другие_флаги")

Посмотреть полный перечень флагов можно также здесь.

printf заставляет микроконтроллер попасть в hard fault

Тут могут быть как минимум две причины:

  • проблемы со стеком;
  • проблемы с _sbrk;

Проблемы со стеком

Эта проблема действительно проявляет себя при использовании FreeRTOS или любой другой ОС. Проблема заключается в использовании буфера. В первом пункте было сказано, что в _write приходит по 1 байту. Для того, чтобы это произошло, нужно в коде, перед первым использованием printf запретить использование буферизации.

setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);

Из описания функции следует, что можно установить так же и одно из следующих значений:

#define	_IOFBF	0		/* setvbuf should set fully buffered */
#define	_IOLBF	1		/* setvbuf should set line buffered */
#define	_IONBF	2		/* setvbuf should set unbuffered */

Однако это может привести к переполнению стека задачи (или прерывания, если вы вдруг очень нехороший человек, который вызывает printf из прерываний).

Чисто технически, можно очень аккуратно расставить для каждого потока стеки, однако такой подход требует тщательного планирования и ошибки, которые он несет в себе, трудно отловить. Куда более простым решением является прием по одному байту, складирование в собственный буфер с последующим выводом в требуемом формате, разобранный ранее.

Проблемы с _sbrk

Эта проблема была лично для меня самой неявной. И так, что мы знаем о _sbrk?

  • очередная заглушка, которую требуется реализовать для поддеркжи немалой части стандартных библиотек;
  • требуется для выделения памяти в куче;
  • используется всякими библиотечными методами по типу malloc, free.

Лично я в своих проектах в 95% случаев использую FreeRTOS с переопределенными методами new/delete/malloc, использующими кучу FreeRTOS. Так что когда я выделяю память, то уверен, что выделение идет в куче FreeRTOS, которая занимает заранее известное количество памяти в bss области. Посмотреть на прослойку можно здесь. Так что, чисто технически, никакой проблемы быть не должно. Функция просто не должна вызываться. Но давайте подумаем, если она вызовится, то где она попытается взять память?

Вспомним разметку оперативной памяти «классического» проекта под микроконтроллеры:

  • .data;
  • .bss;
  • пустое пространство;
  • начальный стек.

В data у нас начальные данные глобальных объектов (переменные, структуры и прочие глоабльные поля проекта). В bss — глобальные поля, имеющие начальное нулевое значение и, внимательно, куча FreeRTOS. Она представляет из себя просто массив в памяти. с которым потом работают методы из файла heap_x.c. Далее идет пустое пространство, после которого (вернее сказать с конца) располагается стек. Т.к. в моем проекте используется FreeRTOS, то данный стек используется только до момента запуска планировщика. И, таким образом, его использование, в большинстве случаев, ограничивается коллобайтом (на деле обычно 100 байт предел).

Но где же тогда выделяется память с помощью _sbrk? Взглянем на то, какие переменные она использует из linker script-а.

void *__attribute__ ((weak)) _sbrk (int incr) {
    extern char __heap_start;
    extern char __heap_end;
...

Теперь найдем их в linker script-е (мой скрипт немного отличается от того, который предоставляет st, однако эта часть там примерно такая же):

__stack = ORIGIN(SRAM) + LENGTH(SRAM);

__main_stack_size = 1024;
__main_stack_limit = __stack  - __main_stack_size;

...секции во flash, потом в оперативную память...
.bss (NOLOAD) : ALIGN(4) {
        ...
        . = ALIGN(4);
        __bss_end = .;
} >SRAM

    __heap_start = __bss_end;
    __heap_end = __main_stack_limit;

То есть он использует память между стеком (1 кб от 0x20020000 вниз при 128 кб RAM) и bss.

Разобрались. Но ведь у нес есть переопределние методов malloc, free и прочих. Использовать _sbrk ведь ведь не обязательно? Как оказалось, обязательно. Причем этот метод использует не printf, а метод для установки режима буферизации — setvbuf (вернее сказать _malloc_r, который в библиотеке объявлен не как слабая функция. В отличии от malloc, который можно легко заменить).
Самые частые грабли при использовании printf в программах под микроконтроллеры - 1
Так как я был уверен, что sbrk не используется, расположил кучу FreeRTOS (секцию bss) вплотную к стеку (поскольку точно знал, что стека используется раз в 10 меньше, чем требуется).

Решения проблемы 3:

  • сделать некоторый отступ между bss и стеком;
  • переопределить _malloc_r, чтобы _sbrk не вызывался (отделить от библиотеки один метод);
  • переписать sbrk через malloc и free.

Я остановился на первом варианте, поскольку безопасно заменить стандартный _malloc_r (который находится внутри libg_nano.a(lib_a-nano-mallocr.o)) мне не удалось (метод не объявлен как __attribute__ ((weak)), а исключить только единственную функцию из биюлиотеки при компановке мне не удалось). Переписывать же sbrk ради одного вызова — очень не хотелось.

Конечным решением стало выделение отдельных разделов в RAM под начальный стек и _sbrk. Это гарантирует, что на этапе компановки секции не налезут друг на друга. Внутри sbrk так же есть проверка на выход за пределы секции. Пришлось внести небольшую правку, чтобы при детектировании перехода за границу поток зависал в while цикле (посколько использование sbrk происходит только на начальном этапе инициализации и должно быть обработано на этапе отладки устройства).

Измененный mem.ld

MEMORY {
    FLASH (RX) :      ORIGIN = 0x08000000, LENGTH = 1M
    CCM_SRAM (RW) :   ORIGIN = 0x10000000, LENGTH = 64K
    SRAM (RW) :       ORIGIN = 0x20000000, LENGTH = 126K
    SBRK_HEAP (RW) :  ORIGIN = 0x2001F800, LENGTH = 1K
    MAIN_STACK (RW) : ORIGIN = 0x2001FC00, LENGTH = 1K
}

Изменения в section.ld

__stack = ORIGIN(MAIN_STACK) + LENGTH(MAIN_STACK);

__heap_start = ORIGIN(SBRK_HEAP);
__heap_end = ORIGIN(SBRK_HEAP) + LENGTH(SBRK_HEAP);

Посмотреть на mem.ld и section.ld можно в моем проекте-песочнице в этом коммите.

Автор: Вадим

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js