扒一扒中断内为什么不建议用printf

[导读] 大家好,在一个群里看到一个朋友问在一个串口接收中断里打印遇到了问题,今天聊下这个话题。

扒一扒printf

对于单片机中printf到底向哪里打印,这个不同的编译器会有不同的处理方式。比如IAR的printf如果是在线调试,有可能通过c-spy打印到IAR的调试终端,如果已经将printf做了重映射到串口,那么会从指定的串口打印出去。

以IAR ARM开发环境为例,来撸一下printf背后究竟是怎么实现的:

首先写一个简单的hello world开始:

#include <stdio.h>
int main()
{
    printf("Hello world");
    return 0;
}

接着来查找一下printf的出处,在stdio.h中找到了其声明:

  __EFF_NW1    __ATTRIBUTES    void   perror(const char *);
  __EFF_NW1    __DEPREC_PRINTF int    printf(const char *_Restrict, ...);
  __EFF_NW1    __ATTRIBUTES    int    puts(const char *);
  __EFF_NW1    __DEPREC_SCANF  int    scanf(const char *_Restrict, ...);
  __EFF_NR1NW2 __DEPREC_PRINTF int    sprintf(char *_Restrict, 
                                              const char *_Restrict, ...);
  __EFF_NW1NW2 __DEPREC_SCANF  int    sscanf(const char *_Restrict, 

到这里好像无法再进行下去了,先看看map文件,这里只放了map的一部分:

dl7M_tln.a: [3]
    XShttio.o                    60        3        9
    abort.o                       6
    exit.o                        4
    low_level_init.o              4
    printf.o                     40
    putchar.o                    32
    xfail_s.o                    64        1        4
    xprintffull_nomb.o        3 618
    xprout.o                     22
    -------------------------------------------------
    Total:                    3 850        4       13
    
......
printf                  0x00001be9   0x28  Code  Gb  printf.o [3]
putchar                 0x00001c6d   0x20  Code  Gb  putchar.o [3]

看到了有一个printf.o的模块被编译了,有这个文件,那么应该有源文件,试着在IAR的安装目录下找找,果然有:

.\IAR Systems\Embedded Workbench 8.0\arm\src\lib\dlib\file\printf.c

int printf(const char * _Restrict fmt, ...)
{ /* print formatted to stdout */
  int ans;
  va_list ap;
  
  va_start(ap, fmt);
  ans = _Printf(&_Prout, (void *)1, fmt, &ap, 0);
  va_end(ap);
  return ans;
}

printf通过使用va_list/va_start/va_end,在这里进行可变参数的解析,而真正实现最终打印的函数是哪一个呢?是下面这句话在起作用:

_Printf(&_Prout, (void *)1, fmt, &ap, 0);

_Printf的原型是怎样的呢?在.\IAR Systems\Embedded Workbench 8.0\arm\src\lib\dlib\DLib.h中发现:

__ATTRIBUTES int _Printf(_PrintfPfnType *, void *, const char *, __Va_list *,int);

_PrintfPfnType这个是啥玩意?在撸下去:

#if _DLIB_PRINTF_CHAR_BY_CHAR
  typedef void *(_PrintfPfnType)(void *, char);
#else
  typedef void *(_PrintfPfnType)(void *, const char *, _Sizet);
#endif

明白了,这个是一个函数指针,根据打印方式时否是逐字符打印,函数指针分了两种模式:逐字符模式或者缓冲区模式。

在回到printf的定义处,发现这个指针传的是_Prout,好接着扒下去,在.\IAR Systems\Embedded Workbench 8.0\arm\src\lib\dlib\formatters\xprout.c发现了其具体的实现:

#if _DLIB_PRINTF_CHAR_BY_CHAR
  void *_Prout(void *str, char c)
  {
    return (putchar(c) == c ? str : 0);
  }
#else
  #if _DLIB_FILE_DESCRIPTOR
    void *_Prout(void *str, const char *buf, size_t n)
    {
      return fwrite(buf, 1, n, stdout) == n ? str : 0;
    }
  #else
    void *_Prout(void *str, const char *buf, size_t n)
    {
      return __write(_LLIO_STDOUT, (unsigned char const *)buf, n) == n 
             ? str : 0;
    }
  #endif
#endif

_DLIB_PRINTF_CHAR_BY_CHAR 宏是根据IAR的DLIB配置做定义。

所以IAR编译的时候会包含DLib_Defaults.h,这里就定义了_DLIB_PRINTF_CHAR_BY_CHAR,如果要采用文件方式则需要修改配置。但是一般单片机里不会这么干。所以真正的 _Prout的实现就是这样的了:

void *_Prout(void *str, char c)
{
    return (putchar(c) == c ? str : 0);
}

这样就定位到最终实现字符打印的函数是putchar了,而putchar是在哪里声明的呢?在stdio.h中发现了它的踪迹:

__ATTRIBUTES int                putchar(int);

来了一个好像没见过的函数前缀,在继续找一下,在.\AR Systems\Embedded Workbench 8.0\arm\inc\c\yvals.h中找到了

#define __ATTRIBUTES             __intrinsic __nounwind

这两个关键字是编译内部使用的,文档里没有说明这个是怎么使用的但是我猜想编译器在编译时可能会检测这个函数时否用户定义了同名函数,如定义了就使用用户定义的,没定义就使用系统的。放一个空的putchar来验证一下:

#include <stdio.h>
int putchar(int c)
{
    return(c);
}

int main()
{
    printf("Hello world");
    return 0;
}

然后再看看map文件:

dl7M_tln.a: [3]
    abort.o                      6
    exit.o                       4
    low_level_init.o             4
    printf.o                    40
    xfail_s.o                   64                 4
    xprintffull_nomb.o       3 618
    xprout.o                    22
    ------------------------------------------------
    Total:                   3 758                 4
    .......
 putchar                 0x00001bbd    0x2  Code  Gb  main.o [1]   

putchar使用了main.o的实现。而如果使用库实现的,从前面的map文件看到putchar.o,一找发现了putchar.c文件:

int putchar(int c)
{ /* put character to stdout */
     unsigned char uc = c;
     if (__write(_LLIO_STDOUT, &uc, 1) == 1)
     {
        return uc;
     }
     return EOF;
}

看系统原来是调用了__write函数,在.\IAR Systems\Embedded Workbench 8.0\arm\inc\c\LowLevelIOInterface.h中找到了:

 __ATTRIBUTES size_t        __write(int, const unsigned char *, size_t);

到这里不继续了,你如果再找就发现

.\Embedded Workbench 8.0\arm\RTOS\SEGGER\NXP\LPC4357\Start_LPC4357_CMSIS\Setup\SEGGER_RTT_Syscalls_IAR.c

有它的实现:

size_t __write(int handle, const unsigned char * buffer, size_t size) {
  (void) handle;  /* Not used, avoid warning */
  SEGGER_RTT_Write(0, (const char*)buffer, size);
  return size;
}

其实就是各种底层具体输出的实现了,比如打印到c-spy,或者打印到串口。

比如在.\IAR Systems\Embedded Workbench 8.0\arm\src\flashloader\ST\FlashSTM32F10x\Flash_stm32f10xx.c

int putchar(int c)
{
    USART1->DR = c;
    while(0 == (USART1->SR & (1UL << 7)));

    return(c);
}

这就是printf重映射到串口的实现,这个是一个同步查询字节串口输出函数。大致就上面的分析总结成一个图就是这样:

当然这里仅仅分析了逐字符打印的串口的情况。下面回到问题本身,为什么中断里不能调用printf?

为啥ISR不能printf

首先中断里肯定不适合调用printf,那么为什么呢?就比如上面的串口实现方式,就以9600,1个起始位,1个停止位,8个数据位的常见方式为例:

你看,传输一个字节要1个毫秒,如果打印好几个字节就是好几个毫秒了,所以答案几乎就已经很清楚了,在中断函数里打印,会增加中断函数执行的时间。中断需要快进快出!比如是一个串口逐字节接收中断函数,外部的报文逐字节输入,而中断函数先打印一点日志,好几个毫秒就过去了。如果UART外设是一个单字节的接收寄存器,那完了,报文指定被冲掉了。有的UART可能有多字节FIFO,但是即便是这样,也有很大的概率会被冲掉。

这是一个中断里不能调用printf的主要原因,执行费时!

在IAR的文档里也阔以看到,如果要实现printf的重定向,需要用户实现底层的_write函数,那为啥前面又是实现的putchar呢,其实putchar最终是调用的_write函数,所以直接覆盖putchar肯定也是可以的。

另外如果编译环境配置printf不一样,这个内部实现也可能需要很多的存储空间。这对单片机而言也是不合算的。来比较一下,把printf去掉:

int main()
{
    return 0;
}

编译出来的结果是:

    152 bytes of readonly  code memory
  1 024 bytes of readwrite data memory

加上后,编译出来是这样:

  7 470 bytes of readonly  code memory
     34 bytes of readonly  data memory
  1 037 bytes of readwrite data memory

看就这么一句printf,code区增加了近7K字节!当然如果你选择其他的printf配置,可能会小一些,比如:

不同的单片机编译器对printf的处理会不相同,具体可以查查相关文档。

不安全

这个printf内部再很多编译环境下,有可能是线程安全的,但一般不是中断安全的。如果函数实现内部有加锁,在应用程序中调用了printf,但还没有执行完。但此时中断来了,转而执行中断,中断时否无法获取这个锁的,此时程序就挂了。

解决办法

可以自己实现一个print系统,开辟一个环形缓冲区。如果想在中断里打印一点数据,不要同步打印,先将数据打印到内存,再设置一个标志,然后再中断外面实现真正的串口输出。

  • 如果是裸机程序,只需要再中循环里检测缓冲区时否有数据,有就输出到真正的串口。

  • 如果是RTOS应用,可以开辟一个任务,将优先级设的低一点,在任务内管理这个缓冲区,如果有数据就输出到串口。需要注意的是,就如前面所说,调用接口是不能加锁的,否则就不能再中断里使用。

有了这个思路,要实现就不难了。

声明:本内容为作者独立观点,不代表电子星球立场。未经允许不得转载。授权事宜与稿件投诉,请联系:editor@netbroad.com
觉得内容不错的朋友,别忘了一键三连哦!
赞 2
收藏 1
关注 68
成为作者 赚取收益
全部留言
0/200
成为第一个和作者交流的人吧