下冰雹 · 2020年09月26日

FreeRTOS系列第15篇---使用任务通知实现命令行解释器

虽然这是介绍FreeRTOS系列的文章,但这篇文章偏重于命令行解释器的实现。这一方面是因为任务通知使用起来非常简单,另一方面也因为对于嵌入式程序来说,使用命令行解释器来辅助程序调试是非常有用的。程序调试是一门技术,基本上我们需要两种调试手段,一种是可以单步仿真的硬件调试器,另外一种是可以长期监视程序状态的状态输出,可以通过串口、显示屏等等手段输出异常信息或者某些关键点。这里的命令行解释器就属于后者。

本文实现的命令行解释器具有以下特性:

  • 支持十进制参数,识别负号;
  • 支持十六进制参数,十六进制以‘0x’开始;
  • 命令名长度可定义,默认最大20个字符;
  • 参数数目可定义,默认最多8个参数;
  • 命令名和参数之间以空格隔开,空格个数任意;
  • 整条命令以回车换行符结束;
  • 整条命令最大长度可定义,默认64字节,包括回车换行符;
  • 如果使用SecureCRT串口工具(推荐),支持该软件的控制字符,比如退格键、左移键、右移键等。
    一个带参数的命令格式如下所示:

       参数名 <参数1> <参数2> … <参数3>[回车换行符]
    
1.编码风格

FreeRTOS的编码标准及风格见《FreeRTOS系列第4篇---FreeRTOS编码标准及风格指南》,但我自己的编码风格跟FreeRTOS并不相同,并且我也不打算改变我当前坚持使用的编码风格。所以在这篇或者以后的文章中可能会在一个程序中看到两种不同的编码风格,对于涉及FreeRTOS的代码,我尽可能使用FreeRTOS建议的编码风格,与FreeRTOS无关的代码,我仍然使用自己的编码风格。我可以保证,两种编码风格决不会影响程序的可读性,编写良好可读性的代码,是我一直注重并坚持的。

2.一些准备工作
2.1串口硬件驱动

命令行解释器使用一个硬件串口,需要外部提供两个串口底层函数:一个是串口初始化函数init_cmd_uart(),用于初始化串口波特率、中断等事件;另一个是发送单个字符函数my_putc()。此外,命令行为串口接收中断服务程序提供函数fill_rec_buf(),用于保存接收到的字符,当收到回车换行符后,该函数向命令行分析任务发送通知。

2.2一个类printf函数

类printf函数用来格式化输出,我一般用来辅助调试,为了方便的将调试代码从程序中去除,需要将类printf函数进行封装。我的文章《编写优质嵌入式C程序》第5.2节给出了一个完整的类printf函数实现和封装代码,最终我们使用到的类printf函数是如下形式的宏:

MY_DEBUGF(CMD_LINE_DEBUG,("第%d个参数:%d\n",i+1,arg[i]));    
3.使用任务通知

我们将会创建一个任务,用来分析接收到的命令,如果命令有效则调用命令实现函数。这个任务名字为vTaskCmdAnalyze()。串口接收中断用于接收命令,如果接收到回车换行符,则向任务vTaskCmdAnalyze()发送任务通知,表明已经接收到一条完整命令,任务可以去处理了。

示意框图如图3-1所示。
1.png

4.数据结构

命令行解释器程序需要涉及两个数据结构:一个与命令有关,包括命令的名字、命令的最大参数数目、命令的回调函数类型、命令帮助信息等;另一个与分析命令有关,包括接收命令字符缓冲区、存放参数缓冲区等。

4.1与命令有关的数据结构

定义如下:

typedef struct {
        char const *cmd_name;                //命令字符串
        int32_t max_args;                    //最大参数数目
        void (*handle)(int argc,void * cmd_arg); //命令回调函数
        char  *help;                        //帮助信息
    }cmd_list_struct;

需要说明一下命令回调函数的参数,argc保存接收到的参数数目,cmd_arg指向参数缓冲区,目前只支持32位的整形参数,这在绝大多数嵌入式场合是足够的。

4.2与分析命令有关数据结构

定义如下:

#define ARG_NUM     8          //命令中允许的参数个数
#define CMD_LEN     20         //命令名占用的最大字符长度
#define CMD_BUF_LEN 60         //命令缓存的最大长度
 
typedef struct {
    char rec_buf[CMD_BUF_LEN];            //接收命令缓冲区
    char processed_buf[CMD_BUF_LEN];      //存储加工后的命令(去除控制字符)
    int32_t cmd_arg[ARG_NUM];             //保存命令的参数
}cmd_analyze_struct;

缓冲区的大小使用宏来定义,通过更改相应的宏定义,可以设置整条命令的最大长度、命令参数最大数目等。

5.串口接收中断处理函数

本文使用的串口软件是SecureCRT,在这个软件下敲击的任何键盘字符,都会立刻通过串口硬件发送出去,这与Telnet类似。所以我们无需使用串口的FIFO,每接收到一个字符就产生一次中断。串口中断与硬件关系密切,所以命令行解释器提供了一个与硬件无关的函数fill_rec_buf(),每当串口中断接收到一个字符,就以收到的字符为参数调用这个函数。 fill_rec_buf()函数主要操作变量cmd_analyze,变量的声明原型为:

  cmd_analyze_struct cmd_analyze;

函数fill_rec_buf()的实现代码为:

/*提供给串口中断服务程序,保存串口接收到的单个字符*/
void fill_rec_buf(char data)
{
    //接收数据 
    static uint32_t rec_count=0;
   
   cmd_analyze.rec_buf[rec_count]=data;
    if(0x0A==cmd_analyze.rec_buf[rec_count] && 0x0D==cmd_analyze.rec_buf[rec_count-1])
    {
       BaseType_t xHigherPriorityTaskWoken = pdFALSE;
       rec_count=0;
       
       /*收到一帧数据,向命令行解释器任务发送通知*/
       vTaskNotifyGiveFromISR (xCmdAnalyzeHandle,&xHigherPriorityTaskWoken);
       
       /*是否需要强制上下文切换*/
       portYIELD_FROM_ISR(xHigherPriorityTaskWoken );
    }
    else
    {
       rec_count++;
       
       /*防御性代码,防止数组越界*/
       if(rec_count>=CMD_BUF_LEN)
       {
           rec_count=0;
       }
    }    
}
6.命令行分析任务

命令行分析任务大部分时间都会因为等待任务通知而处于阻塞状态。当接收到一个通知后,任务首先去除命令行中的无效字符和控制字符,然后找出命令名并分析参数数目、将参数转换成十六进制数并保存到参数缓冲区中,最后检查命令名和参数是否合法,如果合法则调用命令回调函数处理本条命令。

6.1去除无效字符和控制字符

串口软件SecureCRT支持控制字符。比如在输入一串命令的时候,发现某个字符输入错误,就要使用退格键或者左右移动键定位到错误的位置进行修改。这里的退格键和左右移动键都属于控制字符,比如退格键的键值为0x08、左移键的键值为0x1B0x5B 0x44。我们之前也说过,在软件SecureCRT中输入字符时,每敲击一个字符,该字符立刻通过串口发送给我们的嵌入式设备,也就是所有键值都会按照敲击键盘的顺序存入到接收缓冲区中,但这里面可能有我们不需要的字符,我们首先需要利用控制字符将不需要的字符删除掉。这个工作由函数get_true_char_stream()实现,代码如下所示:

/**
* 使用SecureCRT串口收发工具,在发送的字符流中可能带有不需要的字符以及控制字符,
* 比如退格键,左右移动键等等,在使用命令行工具解析字符流之前,需要将这些无用字符以
* 及控制字符去除掉.
* 支持的控制字符有:
*   上移:1B 5B 41
*   下移:1B 5B 42
*   右移:1B 5B 43
*   左移:1B 5B 44
*   回车换行:0D 0A
*  Backspace:08
*  Delete:7F
*/
static uint32_t get_true_char_stream(char *dest,const char *src)
{
   uint32_t dest_count=0;
   uint32_t src_count=0;
   
    while(src[src_count]!=0x0D && src[src_count+1]!=0x0A)
    {
       if(isprint(src[src_count]))
       {
           dest[dest_count++]=src[src_count++];
       }
       else
       {
           switch(src[src_count])
           {
                case    0x08:                          //退格键键值
                {
                    if(dest_count>0)
                    {
                        dest_count --;
                    }
                    src_count ++;
                }break;
                case    0x1B:
                {
                    if(src[src_count+1]==0x5B)
                    {
                        if(src[src_count+2]==0x41 || src[src_count+2]==0x42)
                        {
                            src_count +=3;              //上移和下移键键值
                        }
                        else if(src[src_count+2]==0x43)
                        {
                            dest_count++;               //右移键键值
                            src_count+=3;
                        }
                        else if(src[src_count+2]==0x44)
                        {
                            if(dest_count >0)           //左移键键值
                            {
                                dest_count --;
                            }
                           src_count +=3;
                        }
                        else
                        {
                            src_count +=3;
                        }
                    }
                    else
                    {
                        src_count ++;
                    }
                }break;
                default:
                {
                    src_count++;
                }break;
           }
       }
    }
   dest[dest_count++]=src[src_count++];
    dest[dest_count++]=src[src_count++];
    return dest_count;
}
6.2参数分析

接收到的命令中可能带有参数,我们需要知道参数的数目,还需要把字符型的参数转换成整形数并保存到参数缓冲区(这是因为命令回调函数需要这两个参数)。这个工作由函数cmd_arg_analyze()实现,代码如下所示:

/**
* 命令参数分析函数,以空格作为一个参数结束,支持输入十六进制数(如:0x15),支持输入负数(如-15)
* @param rec_buf   命令参数缓存区
* @param len       命令的最大可能长度
* @return -1:       参数个数过多,其它:参数个数
*/
static int32_t cmd_arg_analyze(char *rec_buf,unsigned int len)
{
   uint32_t i;
   uint32_t blank_space_flag=0;    //空格标志
   uint32_t arg_num=0;             //参数数目
   uint32_t index[ARG_NUM];        //有效参数首个数字的数组索引
   
    /*先做一遍分析,找出参数的数目,以及参数段的首个数字所在rec_buf数组中的下标*/
    for(i=0;i<len;i++)
    {
       if(rec_buf[i]==0x20)        //为空格
       {
           blank_space_flag=1;              
           continue;
       }
        else if(rec_buf[i]==0x0D)   //换行
       {
           break;
       }
       else
       {
           if(blank_space_flag==1)
           {
                blank_space_flag=0; 
                if(arg_num < ARG_NUM)
                {
                   index[arg_num]=i;
                    arg_num++;         
                }
                else
                {
                    return -1;      //参数个数太多
                }
           }
       }
    }
   
    for(i=0;i<arg_num;i++)
    {
        cmd_analyze.cmd_arg[i]=string_to_dec((unsigned char *)(rec_buf+index[i]),len-index[i]);
    }
    return arg_num;
}

在这个函数cmd_arg_analyze()中,调用了字符转整形函数string_to_dec()。我们只支持整形参数,这里给出一个字符转整形函数的简单实现,可以识别负号和十六进制的前缀’0x’。在这个函数中调用了三个C库函数,分别是isdigit()、isxdigit()和tolower(),因此需要包含头文件#include <ctype.h>。函数string_to_dec()实现代码如下:

/*字符串转10/16进制数*/
static int32_t string_to_dec(uint8_t *buf,uint32_t len)
{
   uint32_t i=0;
   uint32_t base=10;       //基数
   int32_t  neg=1;         //表示正负,1=正数
   int32_t  result=0;
   
    if((buf[0]=='0')&&(buf[1]=='x'))
    {
       base=16;
       neg=1;
       i=2;
    }
    else if(buf[0]=='-')
    {
       base=10;
       neg=-1;
       i=1;
    }
    for(;i<len;i++)
    {
       if(buf[i]==0x20 || buf[i]==0x0D)    //为空格
       {
           break;
       }
       
       result *= base;
       if(isdigit(buf[i]))                 //是否为0~9
       {
           result += buf[i]-'0';
       }
       else if(isxdigit(buf[i]))           //是否为a~f或者A~F
       {
            result+=tolower(buf[i])-87;
       }
       else
       {
           result += buf[i]-'0';
       }                                        
    }
   result *= neg;
   
    return result ;
}
6.3定义命令回调函数

我们举两个例子:第一个是不带参数的例子,输入命令后,函数返回一个“Helloworld!”字符串;第二个是带参数的例子,我们输入命令和参数后,函数返回每一个参数值。我们在讲数据结构的时候特别提到过命令回调函数的原型,这里要根据这个函数原型来声明命令回调函数。

6.3.1不带参数的命令回调函数举例
/*打印字符串:Hello world!*/
void printf_hello(int32_t argc,void *cmd_arg)
{
   MY_DEBUGF(CMD_LINE_DEBUG,("Hello world!\n"));
}
6.3.2带参数的命令行回调函数举例
/*打印每个参数*/
void handle_arg(int32_t argc,void * cmd_arg)
{
   uint32_t i;
   int32_t  *arg=(int32_t *)cmd_arg;
   
    if(argc==0)
    {
       MY_DEBUGF(CMD_LINE_DEBUG,("无参数\n"));
    }
    else
    {
       for(i=0;i<argc;i++)
       {
           MY_DEBUGF(CMD_LINE_DEBUG,("第%d个参数:%d\n",i+1,arg[i]));
       }
    }
}
6.4定义命令表

在讲数据结构的时候,我们定义了与命令有关的数据结构。每条命令需要包括命名名、最大参数、命令回调函数、帮助等信息,这里要将每条命令组织成列表的形式。

/*命令表*/
const cmd_list_struct cmd_list[]={
/*   命令    参数数目    处理函数        帮助信息                         */   
{"hello",   0,      printf_hello,   "hello                      -打印HelloWorld!"},
{"arg",     8,      handle_arg,      "arg<arg1> <arg2> ...      -测试用,打印输入的参数"},
};

如果要定义自己的命令,只需要按照6.3节的格式编写命令回调函数,然后将命令名、参数数目、回调函数和帮助信息按照本节格式加入到命令表中即可。

6.5命令行分析任务实现

有了上面的基础,命令行分析任务实现起来就非常轻松了,源码如下:

/*命令行分析任务*/
void vTaskCmdAnalyze( void *pvParameters )
{
   uint32_t i;
   int32_t rec_arg_num;
    char cmd_buf[CMD_LEN];      
   
    while(1)
    {
       uint32_t rec_num;
       
       ulTaskNotifyTake(pdTRUE,portMAX_DELAY);
    rec_num=get_true_char_stream(cmd_analyze.processed_buf,cmd_analyze.rec_buf);
       
       /*从接收数据中提取命令*/
       for(i=0;i<CMD_LEN;i++)
       {
           if((i>0)&&((cmd_analyze.processed_buf[i]==' ')||(cmd_analyze.processed_buf[i]==0x0D)))
           {
                cmd_buf[i]='\0';        //字符串结束符
                break;
           }
           else
           {
                cmd_buf[i]=cmd_analyze.processed_buf[i];
           }
       }
       
       rec_arg_num=cmd_arg_analyze(&cmd_analyze.processed_buf[i],rec_num);
       
       for(i=0;i<sizeof(cmd_list)/sizeof(cmd_list[0]);i++)
       {
           if(!strcmp(cmd_buf,cmd_list[i].cmd_name))       //字符串相等
           {
                if(rec_arg_num<0 || rec_arg_num>cmd_list[i].max_args)
                {
                    MY_DEBUGF(CMD_LINE_DEBUG,("参数数目过多!\n"));
                }
                else
                {
                    cmd_list[i].handle(rec_arg_num,(void *)cmd_analyze.cmd_arg);
                }
                break;
           }
           
       }
       if(i>=sizeof(cmd_list)/sizeof(cmd_list[0]))
       {
           MY_DEBUGF(CMD_LINE_DEBUG,("不支持的指令!\n"));
       }
    }
}
7.使用的串口工具

推荐使用SecureCRT软件,这是我觉得最适合命令行交互的串口工具。此外,这个软件非常强大,除了支持串口,还支持SSH、Telnet等。对于串口,SecureCRT工具还支持文件发送协议:Xmodem、Ymodem和Zmodem。这在使用串口远程升级时很有用,可以用来发送新的程序二进制文件。我曾经使用Ymodem做过远程升级,以后有时间再详细介绍SecureCRT的Ymodem功能细节。

要用于本文介绍的命令行解释器,要对SecureCRT软件做一些设置。

7.1设置串口参数

选择Serial功能、设置端口、波特率、校验等,特别要注意的是不要勾选任何流控制选项,如图2-1所示。
2.png

图2-1:设置串口参数

7.2设置新行模式

依次点击菜单栏的“选项”---“会话选项”,在弹出的“会话选项”界面中,点击左边树形菜单的“终端”---“仿真”---“模式”,在右边的仿真模式区域选中“换行”和“新行模式”,如图2-2所示。
3.png

图2-2:设置新行模式

7.3设置本地回显

依次点击菜单栏的“选项”---“会话选项”,在弹出的“会话选项”界面中,点击左边树形菜单的“终端”---“仿真”---“高级”,在右边的“高级仿真”区域,选中“本地回显”,如图2-3所示。
4.png

图2-3:设置本地回显

8.测试

我们通过6.3节和6.4接定义了两个命令,第一条命令的名字为”hello”,这是一个无参数命令,直接输出字符串”Hello world!”。第二条命令的名字为”arg”,是一个带参数命令,输出每个参数的值。下面对这两个命令进行测试。

8.1无参数命令测试

设置好SecureCRT软件,输入字符”hello”后,按下回车键,设备会返回字符串”Hello world!”。如图8-1所示。
5.png

图8-1:无参数命令测试

8.2带参数命令测试

设置好SecureCRT软件,输入字符”arg 1 2 -3 0x0a”后,按下回车键,设备会返回每个参数值。如图8-2所示。
6.png

图8-2:带参数命令测试

相关阅读

FreeRTOS系列第14篇---FreeRTOS任务通知
FreeRTOS系列第13篇---FreeRTOS内核控制

作者:朱工
首发博客:https://blog.csdn.net/zhzht19861011/article/details/50654242
关注FreeRTOS从基础到高级专栏,即时收取FreeRTOS系列文章。
推荐阅读
关注数
3260
内容数
54
介绍FreeRTOS的基本功能,移植与使用。主要介绍FreeRTOS的裁剪、任务、内存管理、队列、信号量、任务通知等基本组成,看完可以会用FreeRTOS,高级篇会深入介绍FreeRTOS的实现细节、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息