[导读] 大家好,我是逸珺。
前面说会写一下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配置做定义。
所以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来验证一下:
#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);
到这里不继续了,你如果再找就发现
.\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
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
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配置,可能会小一些,比如:
不同的单片机编译器对printf的处理会不相同,具体可以查查相关文档。
不安全这个printf内部再很多编译环境下,有可能是线程安全的。如果函数实现内部有加锁,在应用程序中调用了printf,但还没有执行完。但此时中断来了,转而执行中断,中断时是无法获取这个锁的,此时程序就挂了。
解决办法
可以自己实现一个print系统,开辟一个环形缓冲区。如果想在中断里打印一点数据,不要同步打印,先将数据打印到内存,再设置一个标志,然后再中断外面实现真正的串口输出。
-
如果是裸机程序,只需要在主循环里检测缓冲区是否有数据,有就输出到真正的串口。
-
如果是RTOS应用,可以开辟一个任务,将优先级设的低一点,在任务内管理这个缓冲区,如果有数据就输出到串口。需要注意的是,就如前面所说,调用接口是不能加锁的,否则就不能在中断里使用。
有了这个思路,要实现就不难了。
—END—