逸珺 · 2021年06月11日

扒一扒中断为什么不能调printf

首发:嵌入式客栈
作者:逸珺

[导读] 大家好,我是逸珺。前面说会写一下Modbus-RTU的实现,写了1000多字了,有兴趣的稍等一下哈。前面在一个群里看到一个朋友在一个串口接收中断里打印遇到了问题,今天聊下这个话题。

扒一扒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。好接着扒下去,在

.\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配置做定义。
640-3.png

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


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

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

__ATTRIBUTES int  putchar(int); 

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

#define __ATTRIBUTES  __intrinsic __nounwind  

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

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

然后再看看map文件:

    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);  

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

.\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,或者打印到串口。

比如在:

.\8.0\arm\src\flashloader\ST\FlashSTM32F10x\Flash\_stm32f10xx.c

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

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

640-4.png

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

为啥ISR不能printf


=

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

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

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

image.png

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


=

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


int main()  
{  
    return 0;  
}  

编译出来的结果是:

  1024 bytes of readwrite data memory  

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

7470 bytes of readonly  code memory  
     34 bytes of readonly  data memory  
  1037 bytes of readwrite data memory  

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

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

不安全

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

解决办法

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

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

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

END

推荐阅读

更多硬核嵌入式技术干货请关注嵌入式客栈专栏。
推荐阅读
关注数
2829
内容数
158
分享一些在嵌入式应用开发方面的浅见,广交朋友
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息