重定向 MCU 标准 IO 函数

我们一般用 MCU 都是裸机或者 RTOS ,不带操作系统的。 STM32CubeMX 生成的 Makefile 也是默认不使用系统相关的函数,比如 printf, scanf 函数。如果我们想使用 UART 打印运行数据或者读取用户输入数据,就需要重定义标准 IO 函数的底层函数。

查看 STM32CubeMX 生成 syscalls.c 文件,里面有 2 个函数声明和 2 个函数定义:

extern int __io_putchar(int ch) __attribute__((weak));
extern int __io_getchar(void) __attribute__((weak));

__attribute__((weak)) int _read(int file, char *ptr, int len)
{
(void)file;
int DataIdx;

for (DataIdx = 0; DataIdx < len; DataIdx++)
{
    *ptr++ = __io_getchar();
}

return len;
}

__attribute__((weak)) int _write(int file, char *ptr, int len)
{
(void)file;
int DataIdx;

for (DataIdx = 0; DataIdx < len; DataIdx++)
{
    __io_putchar(*ptr++);
}
return len;
}

其中 _read_write 标准 IO 函数调用的底层函数,这里已经帮我们写好了。 __io_putchar__io_getchar 是需要我们实现的向 UART 写入和读出单个字符的驱动函数。这里我们以 UART1 为例,实现阻塞式发送和接收单字符的驱动。

int __io_putchar(int ch)
{
    while (LL_USART_IsActiveFlag_TXE(USART1) == 0)
    {
    }
    LL_USART_TransmitData8(USART1, (uint8_t)ch);
    return ch;
}

int __io_getchar(void)
{
    uint8_t ch;
    while (LL_USART_IsActiveFlag_RXNE(USART1) == 0)
    {
    }
    ch = LL_USART_ReceiveData8(USART1);
    return (int)ch;
}

到这里标准输出函数 putcharputsprintf 可以正常使用了。但是使用标准输入函数还有问题。

其一,当使用 _read 直接实现 scanf 时,必须先调用 scanf 再阻塞式地等待 RX 数据,否则会出现溢出错误。

其二, arm-none-eabi-gcc 使用 newlib ,里面使用 1024 字节的缓冲区结束对 _read 的调用!即 _read 第三个形参默认是 1024 ,在 UART 接收到 1024 个字符的输入之前不会返回。 scanfgetchar 函数都会受此影响。这不是我们通常想要的交互式通信。

第二个问题好解决,我们可以用

setvbuf( stdin, NULL, _IONBF, 0 );

重设内部缓冲区大小为 0 ,同时宏 _IONBF 表示无缓冲,直接从流中读入数据或者直接向流中写入数据。这样可以让 _read 函数每次只读取一个字符。注意 setvbuf 函数需要在任何 IO 函数前调用,而且调用一次即可。

我们可以用下面代码测试效果:

setvbuf( stdin, NULL, _IONBF, 0 );

int foo;
printf("Please input character:\n");
foo=getchar();
printf("echo: %c\n",foo);

中断方式实现底层驱动

阻塞式发送接收字符的实现并不能很好地解决上文提到的标准接收函数遇到两个问题。长时间等待接收字符在裸机中是不可接受的,如果一直收不到字符就会一直等待下去,其他任务永远得不到执行的机会。而且接收到的字符多于 scanf 或者 getchar 函数需要的数量也会导致 UART 出现接收溢出错误。

更好的方式是用中断的方式发送和接收字符。中断发送字符可以避免发送大量数据时, CPU 因长时间等待发送完成从而推迟其他代码的执行。中断接收字符可以保证每个字符都储存到缓冲区,避免 UART 出现溢出错误。而且缓冲区配合 sscanf 函数读取字符也更加可靠。

接下来我们修改 __io_putchar__io_getchar ,用中断的方式接收和发送数据。下面代码参考自 FatFs Module 示例

#define UART1_RXB 128 /* Size of RX buffer */
#define UART1_TXB 128 /* Size of TX buffer */

static volatile struct
{
    uint16_t tri, twi, tct; /* tx read index, tx write index, tx count */
    uint16_t rri, rwi, rct; /* rx read index, rx write index, rx count */
    uint8_t txbuf[UART1_TXB];
    uint8_t rxbuf[UART1_RXB];
} Fifo1;

void USART1_IRQHandler(void)
{
    uint8_t d;
    int i;
    uint32_t isr = READ_REG(USART1->ISR); /* Interrupt flags */

    if (isr & USART_CR1_RXNEIE)
    {                                       /* RXNE is set: rx ready */
        d = (uint8_t)READ_REG(USART1->RDR); /* Get received byte */
        i = Fifo1.rct;
        if (i < UART1_RXB)
        { /* Store it into the rx FIFO if not full */
            Fifo1.rct = ++i;
            i = Fifo1.rwi;
            Fifo1.rxbuf[i] = d;
            Fifo1.rwi = ++i % UART1_RXB;
        }
    }

    if (isr & USART_CR1_TXEIE)
    { /* TXE is set: tx ready */
        i = Fifo1.tct;
        if (i--)
        { /* There is any data in the tx FIFO */
            Fifo1.tct = (uint16_t)i;
            i = Fifo1.tri;
            WRITE_REG(USART1->TDR, Fifo1.txbuf[i]);
            Fifo1.tri = ++i % UART1_TXB;
        }
        else
        { /* No data in the tx FIFO, clear TXEIE - Disable TXE irq */
            CLEAR_BIT(USART1->CR1, USART_CR1_TXEIE);
        }
    }
}

int __io_putchar(int ch)
{
    int i;
    uint32_t primask;

    /* Wait for tx FIFO is not full */
    while (Fifo1.tct >= UART1_TXB)
    {
    }

    i = Fifo1.twi; /* Put a byte into tx FIFO */
    Fifo1.txbuf[i] = (uint8_t)ch;
    Fifo1.twi = ++i % UART1_TXB;

    primask = __get_PRIMASK();
    __set_PRIMASK(1);
    Fifo1.tct++;
    SET_BIT(USART1->CR1, USART_CR1_TXEIE); /* Set TXEIE - Enable TXE irq */
    __set_PRIMASK(primask);

    return ch;
}

int __io_getchar(void)
{
    uint8_t d;
    int i;
    uint32_t primask;

    /* Wait while rx FIFO is empty */
    while (!Fifo1.rct)
    {
    }

    i = Fifo1.rri; /* Get a byte from rx FIFO */
    d = Fifo1.rxbuf[i];
    Fifo1.rri = ++i % UART1_RXB;

    primask = __get_PRIMASK();
    __set_PRIMASK(1);
    Fifo1.rct--;
    __set_PRIMASK(primask);

    return (int)d;
}

示例中的代码还是比较清晰的,用 FIFO 结构来存储将发送的字符和接收到的字符, __io_putchar 负责把字符压入 FIFO , __io_getchar 函数负责从 FIFO 捞取字符,实际的发送和接收操作都在 UART 的中断处理函数中执行。这部分代码使用中也有需要注意的点,压入或者捞取字符时需要注意 FIFO 是否已满或者为空,这可能会导致程序卡在处理 FIFO 数据阶段。其次 FIFO 的长度最好是 2 的幂,示例代码在计算字符数量时会用 FIFO 长度取余操作,以避免溢出。如果 FIFO 长度是 2 的幂,编译器一般会将取余优化成位运算,否则会调用内置的除法运算来截取长度。众所周知,低端的 arm MCU 比如 m0 和 m0+ 是不带硬件除法单元的,做一次除法运算需要占用大量指令时间。所以强烈推荐 FIFO 长度设置成 2 的幂。

第三方 IO 函数

上文我们考虑的是编译器是 GCC 的标准 IO 函数实现,实际国内还是用 Keil 和 IAR 比较多,这两个的内置编译器标准 IO 底层函数接口还是跟 GCC 不太一样的。如果换个编译器,底层接口函数还是需要做一些修改,上文的代码通用性差了点。而且各大编译器为了标准 IO 函数通用性会做大量的兼容,比如支持 long long int 类型,支持浮点数,是否可重入。这些平时我们用不到的特性会占用大量代码空间。

因此这里推荐两个第三方的 IO 函数库,用于支持程序 log 打印或者基于 UART 的用户交互式通信。它们都是不依赖于编译器的 IO 底层实现,可以移植到任意的编译器。而且它们的代码量更小, long long int 类型是可选的,浮点数也不必支持。

第一个推荐的库是 xprintf ,它的底层驱动函数用的是 UART 中断收发字符, API 也跟标准 IO 函数类似,是标准 IO 函数的最佳平替。 xprintf 库的缺陷是底层驱动函数是中断收发单个字符,不能一次性发送多个字符,也就意味着它对 DMA 方式发送字符不太友好。 DMA 接收字符倒是没影响。

xprintf 的代码有八百多行,这里就不贴出来了。它提供的 API 相当丰富,可以说是专为交互式通信而生的。常用的标准 IO 函数都有对应的替代函数,可以从标准 IO 函数无缝切换 xprintf 库。

void xputc(int chr);
void xfputc(void (*func)(int), int chr);
void xputs(const char *str);
void xfputs(void (*func)(int), const char *str);
void xprintf(const char *fmt, ...);
void xsprintf(char *buff, const char *fmt, ...);
void xfprintf(void (*func)(int), const char *fmt, ...);

void put_dump(const void *buff, unsigned long addr, int len, int width);

int xgets(char *buff, int len);
int xatoi(char **str, long *res);
int xatof(char **str, double *res);

使用也很简单,只需要事先调用 xdev_out 注册底层单字符发送函数, xdev_in 注册底层单字符接收函数。然后就可以直接使用上面列出的 API 了。而且它还用定义了一些宏用于开关 long long int ,浮点数之类的支持,使用者可以根据需求自由裁剪库的特性和体积,也是相当灵活。

第二个推荐的库是 tiny printf ,这个库代码量比较小,只有两百行出头,不支持浮点数和字符输入函数,算是轻量级的标准 IO 函数代替品。如果不需要做交互式通信,只是单纯打印程序运行时的状态,它是很合适的库,代码量足够小,甚至能用在 8 位机上。就是代码写得比较晦涩,没有注释,甚至出现了四星级的代码(两重指针,两重解引用)! tiny printf 代码量少,改起来比较容易,既支持单字符发送,也可以做到多字符发送。也就是说它是可以支持 UART+DMA 发送字符的。 tiny printf 的使用跟 xprintf 类似,事先调用 init_printf 注册字符发送函数,然后就能直接使用了。

相关的代码我放在 gitlab 的 embedded-printf 上。

参考文章:

stm32 use scanf with usart

xprintf - Embedded String Functions

A tiny printf for embedded applications