单片机的printf重定向到OLED/UART/SEGGER_RTT

以前在单片机上使用OLED或者做串口通信都是写第层驱动函数,然后使用底层函数显示/发送数据,但是这样的话使用起来始终感觉不是很方便。所以前两天开始琢磨有没有更方便的方式来显示数据,最好能够像printf一样输出格式化字符。到网上一查还真有,但是大多数说的都做串口的重定向,而且说的也不是很详细,但是经过两天的研究也大概了解了怎么去做printf的重定向。

首先要了解什么是重定向,简单来说printf的重定向就是把原本要输入到控制台的内容输出到其他的地方去,这个其他的地方可以是串口,也可以是液晶屏,甚至是重定向到jlink上输出到电脑(使用RTT)。

我的目最初的目的是想让液晶屏显示数据能用上printf所以就先研究了OLED上的printf重定向,在网上查资料发现printf的重定向实际上是有两种方式,一种是通过重新定义一个

int fputc(int ch, FILE *f){
        //里面是要重定向的设备显示/发送一个字节的代码
    };

具体我是这么去实现的:

//printf 函数重定向到OLED显示
int fputc(int ch, FILE *f)
{      
    static uint8_t x = 0, y = 0;
    if(ch == '\n')    //换行 
    { 
        x = 0; 
        y += 1; 
        return ch; 
    }
    if(x + 6 > 128)  //宽不能超过128
    { 
        x = 0;           //x置零 
        y += 1;         //y移至下一行 
    } 
    if(y + 1 > 7)  //行不超过7
    { 
        y=0;
        OLED_Fill(0x00); //直接清屏 重头开始 
        x = 0;
    }

    OLED_P6x8char(x, y, (uint8_t)ch); //打印字符ch 
    x += 6;                   //跳转到下一个位置, 是否越界有上面函数判断
    return ch; 
}

这样的一个函数。

另一种方式就是自己去写一个printf函数,我是这么写的:

signed int OLED_printf(uint8_t x,uint8_t y,const char *pFormat, ...){

    char pStr[25] = {'\0'}; 
    va_list ap;
    signed int result;

    // Forward call to vprintf
    va_start(ap, pFormat);
    result = vsprintf((char *)pStr, pFormat, ap);
    va_end(ap);

    OLED_P6x8Str(x,y,(const uint8_t *)pStr);

    return result;
}

实际上基本是照抄printf();里面的内容然后稍加改动来实现的。

上面所说的两种方法我都试过,都能实现printf的重定向,其中网上看到的大多数串口重定向都是通过第一种方式来实现。具体原理我先说第二种的实现再回头看第一种的就很简单。

    va_start(ap, pFormat);
    result = vsprintf((char *)pStr, pFormat, ap);
    va_end(ap);

第二种方法中,以上这三句话就做了一件事情,那就是把传进来的一个或多个参数全部整合成一个字符串,放到pStr这个字符串数组里。这样我们就得到了一整个要显示的字符串,再通过OLED的一个显示字符串的函数把这些字符串都显示出来就OK了,顺带着这种方法还可以实现想在OLED那个地方输出字符串都可以毕竟调用的就是OLED的字符串显示函数本来就要有一个要显示的坐标。
printf实现的具体原理,已经这三句话里面到底干了什么可以参考这篇帖子:
[printf函数实现原理]http://blog.csdn.net/yskcg/article/details/6073067

OLED_P6x8char(x, y, (uint8_t)ch); //打印字符ch 

再回头去看第一种方法的实现方式,在上面的那一连串的几个if都是防止OLED显示溢出,真正核心的只有上面这一个函数就是OLED输出一个字符。实际上你大概也能猜的到了,printf的底层下面实际上就是调用了int fputc(int ch, FILE *f)这个函数,我们重写了这个函数,让它变成在OLED上显示当然就完成了printf的重定向了但是这个方式在OLED上有一个缺陷,就是printf()本身是输出到控制台上的,所以不能带有坐标参数,但是OLED上右必须指定输出位置,所以程序中就只能让OLED从上到下输出,像在控制台上一样,输出满屏幕之后清屏然后再接着输出。

网上之所以串口重定向用的基本上都是第一种方法也是因为串口没必要指定输出位置重写fputc函数写起来也方便。

解决了重定向的问题了之后,我本以为已经可以随心所欲的使用printf了直到我测试了浮点数的输出,发现输出一直不对,然后去跟踪printf的底层(好像也就IAR能看到printf的实现MDK是看不到stdio.c的内容的),发现了IAR的标准输入输出库坑爹的一个地方:它本来就不支持浮点数的输出,这是它里面判断格式化字符的代码:他原本的代码里根本就没有判断%f这个格式符,坑爹啊!!

// Parse type
switch (*pFormat) {
case 'd': 
case 'i': num = PutSignedInt(pStr, fill, width, va_arg(ap, signed int)); break;
case 'u': num = PutUnsignedInt(pStr, fill, width, va_arg(ap, unsigned int)); break;
case 'x': num = PutHexa(pStr, fill, width, 0, va_arg(ap, unsigned int)); break;
case 'X': num = PutHexa(pStr, fill, width, 1, va_arg(ap, unsigned int)); break;
case 's': num = PutString(pStr, va_arg(ap, char *)); break;
case 'c': num = PutChar(pStr, va_arg(ap, unsigned int)); break;
//%f是自己添加的
case 'f': num = PutSignedInt(pStr, fill, width, va_arg(ap, signed int)); break;
default:
   return EOF;
}

一开始我还自己写了一个浮点数转字符串的函数,也能用就是每次printf都得用%s来输出转换的数据:
就像我想输出一个3.14就得OLED_printf(0,0,”%s \n”,float2str(3.14));始终觉得不爽

uint8_t* float2str(double num,int n){

    int i = 0;
    static uint8_t num_str[13] = {'\0'}; 
    uint8_t len = 0;
    uint8_t n_max = 0; //小数点最大位数

    for(i = 0;i<13;i++) //字符串清空
      num_str[i] = '\0';
    //确定符号
    if(num > 0){

      num_str[0] = '+';
    }
    else if(num < 0){

      num_str[0] = '-';
      num = -num;
    }

    //确定整数部分长度
    if((int)(num/100000) != 0){ //6位数
      len = 7;
      n_max = 4;
    }
    else if((int)(num/10000) != 0){ //5位数 
        len = 6;
        n_max = 5;
    }
    else if((int)(num/1000) != 0){ //4位数   
        len = 5;
        n_max = 6;
    }
    else if((int)(num/100) != 0){  //3位数
        len = 4;
        n_max = 7;
    }
    else if((int)(num/10) != 0){ //2位数
        len = 3;
        n_max = 8;
    }
    else{ //1位数
        len = 2;
        n_max = 9;
    }

    //小数点限幅
    if(n > n_max)
        n = n_max;
    len+=n; //确定长度

    //变成整数
    i = n;
    while(i--)
        num = (num*10);

    //inum = num;

    //转换小数
    while(n--){ 

        num_str[len--] = (int)num%10+0x30;
        num = num/10;
    }
    num_str[len] = '.';

    //转换整数
    while(len--){ 

        num_str[len] = (int)num%10+0x30;
        num = num/10;
        if(len == 1)
          break;
    }
    return num_str;
}

但是我又看了它整型输出格式化字符串的函数PutSignedInt();感觉好像用在浮点数上也没毛病,所以就自己加了一个%f的判断,把%d下面的代码抄了一遍放到%f下,神奇的是这居然真的就好使了!这个输出浮点数默认是保留6位小数,所以要想保留2位小数的话得用%.2f。总之输出浮点数也没问题了。

我个人感觉是自己重新写printf会比较灵活,如果用重写fputc的方法的话,假如串口拿来重写了,OLED就不能再定义这么一个函数了,所以只能有一个设备能用fputc的方式来实现printf重定向,但是自己实现printf就不一样了,改一个名字就是一种外设的printf,比如OLED_printf,UART_printf啊什么的个人感觉比较灵活。

注意:单片机使用printf的时候一定要保证堆栈是充足的!因为printf在实现的时候是不检测内存溢出的,所以堆栈不够大的时候就会出现内存溢出,影响程序中其他变量!


printf用不了浮点输出的根本原因是因为IAR没有设置对,在Option->General Option->Library Option选项卡里面有关于Printf formatter 的选项,选到FULL就可以使用printf的所有功能,选到Small就不支持浮点输出,当然占用的内存也会小一些,看需求选取即可单片机的printf重定向到OLED/UART/SEGGER_RTT_第1张图片


关于printf重定向的问题其实还没完,弄完OLED的重定向之后我又在琢磨,jlink是不是也能重定向。又是到网上一查发现还真能!而且SEGGER已经写好了代码了,直接调用就行,叫做RTT(real time terminal),jlink软件V4.9以上支持,有相应的RTT跟上位机差不多的软件有跟编译器配合用的也有不需要配合用的。

RTT其实就是跟串口重定向是一样的,它把数据重定向到jlink上输出到电脑。而且RTT还有一个优点就是不占用CPU时间,用了之后jlink自己去访问内存发送数据,不需要CPU进行参与,当然调用发送函数花的时间还是得有的。RTT从SEGGER官网下下来有4个文件,分别是这4个,都加到工程里,然后包含SEGGER_RTT.h就可以用了。
单片机的printf重定向到OLED/UART/SEGGER_RTT_第2张图片

但是坑爹的事情又出现了,RTT双不支持浮点数的数据,又是跟踪进去一看,又是底层没有判断%f,而且它没有调用标准输入输出库是自己实现printf的。其实我跟踪进去看,发现其实它和标准库的实现套路基本上是一样的,都是先把那些参数全部都给弄成一个一个字符串然后输出出来,但是我用改IAR标准库的方式去改RTT的printf发现不好使,看来它具体的实现跟标准库还是有差距的。
SEGGER它自己的实现有问题(其实不是它有问题而是它不支持浮点输出),但是我想要能用%f输出浮点数,那就只能自己写一个了呀,方法跟上面所提的是一样的。要重定向的关键是需要一个可以发送字符串的函数,这个函数SEGGER已经封装好了,如下所示:

int SEGGER_RTT_Write(unsigned BufferIndex, const char* pBuffer, unsigned NumBytes)

所以在 SEGGER_RTT_printf.c 里,把它自己实现的printf和其他相关函数全部注释掉再引入标准输入输出库stdio.h 和 stdarg.h(va_start 和 va_end使用)最后自己写printf函数如下:

signed int RTT_printf(unsigned BufferIndex,const char *pFormat, ...){

    char pStr[50] = {'\0'}; 
    va_list ap;
    signed int result;

    // Forward call to vprintf
    va_start(ap, pFormat);
    result = vsprintf((char *)pStr, pFormat, ap);
    va_end(ap);

    SEGGER_RTT_WriteString(BufferIndex,(char *)pStr);

    return result;
}

其实就是OLED 显示的printf吧显示字符串改成了RTT的发送字符串函数,然后把坐标变成了发送通道,测试之后问题完美解决,本质其实就是用标准库得到了正确的字符串所以输出正确,SEGGER自己的实现得不到,就显示出错。
单片机的printf重定向到OLED/UART/SEGGER_RTT_第3张图片

重定向到OLED 的现象其实也是差不多的,我就不多贴图了。


我之前用的SEGGER_RTT不是在官网下的(宿舍网速太渣,下载速度5Kb/s下不下来),可能版本比较旧是自己做printf实现的数据输出,我今天下了最新的RTT发现它自己都改成了用标准库做重定向,具体如下:

int printf(const char *fmt,...) {

  char buffer[128];
  va_list args;
  va_start (args, fmt);
  int n = vsnprintf(buffer, sizeof(buffer), fmt, args);
  SEGGER_RTT_Write(0, buffer, n);
  va_end(args);
  return n;
}

这直接是从官方RTT文件里面复制出来的,有没有觉得有有那么一些似曾相识的感觉。。。。。。
官方给的代码还默认是通道0的呢,实际上可以有15个通道,总感觉我那么些还比较好一点


2017-12-14
经过实测,Jlink RTT Viewer 好像只能接受通道0的数据,其实只用一个通过就够了

你可能感兴趣的:(单片机学习)