单片机 GUI 设计(七)- 终端控件
单片机 GUI 设计(七)- 终端控件

单片机 GUI 设计(七)- 终端控件

基于 GD32F303ZET6 苹果派开发板

简介

终端,可以理解为类似于 Windows 下的 DOS 或 Linux 下的 Shell 交互界面,常常被用于字符串输出,适合用于显示大量文字,如下所示。

在单片机中,显示多行字符串不是什么大难题,设定好需要显示的内容和区域,直接调用 LCD 的文字绘制函数即可。可终端还有一个很重要的特性,那就是屏幕会向上滚动。显示区域已满的情况下,为了显示最新的字符串,终端会选择将显示内容向上滚动,空出最下边一行,用于显示最新的字符串。本文中将介绍两种方式实现终端控件。

1、通过移动显存区块的方式实现终端

对于终端向上滚动,最直接的方法是将终端窗口部分区域(除了第一行)的显存保存下来,然后调用 LCD 的填充函数,将这部分显存填充到终端最开始的位置,最后再调用 LCD 的填充函数将最下边的一行清空,即可实现向上滚动。

对于 RGB 显示屏,因为显存通常是储存在外部 SDRAM 中,直接操纵显存的速度比重新绘制所有的文字要快,而且因为能驱动 RGB 显示屏的处理器一般都会有硬件加速的支持,显存移动就是一瞬间的事,肉眼根本看不出来。

对于 MCU 显示屏,一些 MCU 屏支持通过指令操作显存,实现显存块的移动,同样可以实现像 RGB 显示屏那样的显存搬运效果。因为时间有限,本文中并未使用到 MCU 屏的显存搬运指令,而是使用读点函数将显存读入内存中,再将读入内存的显存数据填充到终端的开头,这样同样也可以实现显存搬运效果。因为 MCU 屏读取屏幕显存数据效率很低,所以速度会比较慢。

因为显存数据比较大,内部 SRAM 可能不够用,因此示例中添加了外拓 SRAM 驱动,并用动态内存管理组件进行管理。

控制结构体设计

终端驱动头文件中使用了一个结构体来描述终端控件,如下所示。除了字体大小和背景颜色之外,为了更好的显示,在终端的控制结构体中还额外添加了两个成员变量来描述两行字符串之间的间隔、字符显示区域与控件边界之间的间隔。textXRecord 和 textYRecord 这两个成员变量用来跟踪记录光标位置。

//终端控件控制结构体
typedef struct
{
  u16 x, y, width, height;  //原点、宽度、高度
  u8  textSize;             //字体大小,可以是 12、16、24
  u16 textColor;            //字体颜色
  u16 backColor;            //背景颜色
  u16 lineSpace;            //每行之间的间隔
  u16 showSpace;            //显示区域与边框的间隙
  u16 textXRecord;          //字符显示位置横坐标记录
  u16 textYRecord;          //字符显示位置纵坐标记录
}StructTerminal;

API 函数设计

终端控件驱动的 API 函数只设计了两个,分别是终端的创建函数和字符串打印函数,如下所示。

void CreateTerminal(StructTerminal* widget);                //创建终端控件
void TerminalPrint(StructTerminal* widget, char* fmt, ...); //终端打印字符串

终端打印函数使用到了可变参数,这使得用户可以像 printf 函数一样打印字符串,如下所示。

TerminalPrint(&s_structTerminal, "Hello world(%d)", 1234);

包含头文件

源文件中首先要添加包含终端驱动所需要的头文件,如下所示

#include "Terminal.h"
#include "LCD.h"
#include "Malloc.h"
#include "stdio.h"
#include "stdarg.h"

终端向上滚动实现

显存向上移动可以通过如下代码实现。首先将显存块,除了第一行,读入内存中,然后再将该显存块填充到第一行所在位置,最后再将最后一行清空即可。因为显存占据的内存空间很大,所以需要引入外拓 SRAM,将显存保存到外部 SRAM 中。举个简单的例子,假设需要移动的区域是 400*200,按照 RGB565格式,那么所消耗的内存为 400*200*2 = 160KB。对于 GD32F303ZET6,即苹果派使用的主控,它的内部 SRAM 只有 64KB,距离 160KB 差的很远;而外拓的 SRAM 内存足足有 1MB,用来储存显存数据妥妥的。

static void MoveUp(StructTerminal* widget)
{
  u32 lineHeight, maxLineNum, showX0, showY0, showX1, showY1;
  u32 x, y, i, x0, y0, x1, y1, width, height;
  u16* image;
  
  //统计一行的高度
  lineHeight = widget->textSize + widget->lineSpace;
  
  //统计最大行号
  maxLineNum = (widget->height - (2 * widget->showSpace)) / lineHeight;
  
  //统计字符显示区域
  showX0 = widget->x + widget->showSpace;
  showY0 = widget->y + widget->showSpace;
  showX1 = (widget->x + widget->width - 1) - widget->showSpace;
  showY1 = showY0 + lineHeight * maxLineNum - 1;
  
  //统计需要向上移动的区域
  x0 = showX0;
  y0 = showY0 + lineHeight;
  x1 = showX1;
  y1 = showY1;
  width = x1 - x0 + 1;
  height = y1 - y0 + 1;
  
  //申请动态内存,申请失败直接卡死,方便后期调试寻找原因
  image = MyMalloc(SRAMEX, width * height * 2);
  if(NULL == image)
  {
    printf("Fail to malloc for terminal\r\n");
    while(1){}
  }
  
  //保存像素点数据
  i = 0;
  for(y = y0; y <= y1; y++)
  {
    for(x = x0; x <= x1; x++)
    {
      image[i] = LCDReadPoint(x, y);
      i++;
    }
  }
  
  //统计需要覆盖的区域
  x0 = showX0;
  y0 = showY0;
  x1 = x0 + width - 1;
  y1 = y0 + height - 1;
  
  //填充
  LCDColorFill(x0, y0, x1, y1, image);
  
  //清空最后一行
  x0 = showX0;
  y0 = showY0 + (maxLineNum - 1) * lineHeight;
  x1 = showX1;
  y1 = y0 + lineHeight - 1;
  
  //填充
  LCDFill(x0, y0, x1, y1, widget->backColor);
  
  //释放动态内存
  MyFree(image);
}

实现添加一个字符

在终端显示中,我们会引入一个光标的概念。光标,即下一个字符将要显示的位置。在终端的控制结构体中,textXRecord 和 textYRecord 就是用来跟踪记录字符显示的位置,如果光标超过了显示区域,那么就要将终端显示内容向上滚动一行,空出最下边的一行以供新的字符显示。向终端添加一个字符显示时,需要特别注意 ‘\r’ 和 ‘\n’ 这两个字符。’\r’ 表示要将光标移动到行首,’\n’ 表示将光标移动到下一行。

实现了向终端添加一个字符显示的函数后,我们就可以轻松的在终端打印出一行字符串。

static void AddChar(StructTerminal* widget, char code)
{
  u32 lineHeight, maxLineNum, showX0, showY0, showX1, showY1;
  u32 x0, y0, x1, y1;

  //统计一行的高度
  lineHeight = widget->textSize + widget->lineSpace;
  
  //统计最大行号
  maxLineNum = (widget->height - (2 * widget->showSpace)) / lineHeight;
  
  //统计字符显示区域
  showX0 = widget->x + widget->showSpace;
  showY0 = widget->y + widget->showSpace;
  showX1 = (widget->x + widget->width - 1) - widget->showSpace;
  showY1 = showY0 + lineHeight * maxLineNum - 1;

  //回车符号
  if ('\r' == code)
  {
    widget->textXRecord = showX0;
    return;
  }

  //换行符号
  if ('\n' == code)
  {
    //切换到下一行
    widget->textXRecord = showX0;
    widget->textYRecord = widget->textYRecord + lineHeight;

    //超标,需要往前挪
    if ((widget->textYRecord) > showY1)
    {
      widget->textXRecord = showX0;
      widget->textYRecord = showY0 + lineHeight * (maxLineNum - 1);
      MoveUp(widget);
    }

    //返回
    return;
  }

  //非法字符
  if ((code < ' ') || (code > '~'))
  {
    return;
  }

  //计算当前字符显示区域
  x0 = widget->textXRecord;
  y0 = widget->textYRecord;
  x1 = x0 + (widget->textSize / 2) - 1;
  y1 = y0 + lineHeight - 1;

  //未显示到一行的终点,可以直接显示
  if (x1 <= showX1)
  {
    //显示字符
    g_iLCDPointColor = widget->textColor;
    LCDShowChar(x0, y0, code, widget->textSize, 1);

    //更新横坐标
    widget->textXRecord = widget->textXRecord + (widget->textSize / 2);
    
    //返回
    return;
  }

  //到了一行的终点,需要切换到下一行
  else
  {
    //更新显示位置
    widget->textXRecord = showX0;
    widget->textYRecord = widget->textYRecord + lineHeight;

    //重新计算显示区域
    x0 = widget->textXRecord;
    y0 = widget->textYRecord;
    x1 = x0 + (widget->textSize / 2) - 1;
    y1 = y0 + lineHeight - 1;

    //当前纵坐标并未超标
    if (y1 <= showY1)
    {
      //显示字符
      g_iLCDPointColor = widget->textColor;
      LCDShowChar(x0, y0, code, widget->textSize, 1);

      //更新横坐标
      widget->textXRecord = widget->textXRecord + (widget->textSize / 2);

      //返回
      return;
    }

    //纵坐标已经超标,需要往前挪
    else
    {
      //数据往前挪
      widget->textXRecord = showX0;
      widget->textYRecord = showY0 + lineHeight * (maxLineNum - 1);
      MoveUp(widget);

      //显示新字符
      g_iLCDPointColor = widget->textColor;
      LCDShowChar(widget->textXRecord, widget->textYRecord, code, widget->textSize, 1);

      //更新横坐标
      widget->textXRecord = widget->textXRecord + (widget->textSize / 2);

      //返回
      return;
    }
  }
}

打印字符串

对于字符串打印,我们只需要调用上边实现的 AddChar 函数,将一个个字符,按照顺序输出到终端上,即可在终端打印出一行完整的字符串。为了方便用户使用,终端的打印函数使用到了可变参数,用户可以像使用 printf 函数一样,输入一个格式化的字符串,打印函数会自动做字符串转换,最终输出到终端显示。

void TerminalPrint(StructTerminal* widget, char* fmt, ...)
{
  //字符串转换缓冲区
  static char s_arrStringBuf[1024];

  //循环变量
  unsigned int i;

  //定义一个 va_list 类型的变量,用来存储单个参数
  va_list args;

  //为空,直接返回
  if(NULL == fmt)
  {
    return;
  }

  //使 args 执行可变参数的第一个参数
  va_start(args, fmt);

  //字符串转换
  vsprintf(s_arrStringBuf, fmt, args);

  //循环打印整个字符串
  i = 0;
  while(0 != s_arrStringBuf[i])
  {
    AddChar(widget, s_arrStringBuf[i]);
    i++;
  }

  //结束可变参数的获取
  va_end(args);
}

创建函数

最后,只需要再完成终端的创建函数即可。终端的创建函数很简单,主要工作便是根据用户提供的背景色,清空终端所在区域,最后再初始化光标位置,如下所示。

void CreateTerminal(StructTerminal* widget)
{
  u32 x0, y0, x1, y1;
  
  //计算区域范围
  x0 = widget->x;
  y0 = widget->y;
  x1 = x0 + widget->width - 1;
  y1 = y0 + widget->height - 1;
  
  //填充背景
  LCDFill(x0, y0, x1, y1, widget->backColor);
  
  //清空显示位置记录
  widget->textXRecord = x0 + widget->showSpace;
  widget->textYRecord = y0 + widget->showSpace;
}

验证

验证代码可以使用如下代码。需要特别注意,终端的控制结构体需要是一个静态变量,不能是局部变量。

#include "Terminal.h"

int main(void)
{
  static StructTerminal s_structTerminal;
  static unsigned int s_iTerminalCnt = 0;
  
  //初始化
  InitHardware();   //初始化硬件相关
  InitSoftware();   //初始化软件相关

  //设置 LCD 初始状态
  LCDDisplayDir(1);
  LCDClear(WHITE);
  
  //创建终端
  s_structTerminal.x = 10;
  s_structTerminal.y = 10;
  s_structTerminal.width = 400;
  s_structTerminal.height = 200;
  s_structTerminal.textSize = 24;
  s_structTerminal.textColor = WHITE;
  s_structTerminal.backColor = BLACK;
  s_structTerminal.lineSpace = 5;
  s_structTerminal.showSpace = 5;
  CreateTerminal(&s_structTerminal);
  
  //清空计数
  s_iTerminalCnt = 0;

  while(1)
  {
    //LED 闪烁
    LEDFlicker(0);
    
    //触屏扫描
    //TouchScanTask();
    
    //终端打印输出字符串
    TerminalPrint(&s_structTerminal, "Hello World(%d)\n", s_iTerminalCnt);
    s_iTerminalCnt++;
    
    //延时 1s
    DelayNms(1000);
  }
}

2、通过记录显示终端显示字符的方式实现终端

对于使用到 MCU 屏的单片机来说,移动显存块相当麻烦,而且耗时巨大,效率极低,容易造成系统卡顿的现象。为此,在这里提供了第二套解决方案,那就是将终端需要显示的字符保存下来,当需要刷新的时候,只需要执行三个步骤。第一步更新字符缓冲区;第二步清屏,将终端所在区域用背景色清空;第三步将字符一个个重新绘制到终端中。

为了方便记录终端字符,我们需要建立一个二维数组,这个二维数组一共有 N 行,每一行唯一对应终端中的一样。终端各个参数确定了之后,它所显示的最大行数也就确定了下来。当终端需要向上滚动时,只需要按行将终端字符数据向上挪动一格,最后再刷新终端显示即可。因为字符数据是以 ASCII 码的形式储存,所以拷贝字符数据比拷贝显存数据速度要快得多。

当然,因为向上滚动时需要做清屏处理,而且绘制字符也是要消耗时间的,所以用这种方式实现的终端空间会有显示闪烁的问题。

控制结构体设计

在原本的基础上增加了 textBuf 成员变量,用来做为终端字符缓冲区,该缓冲区由创建函数自动分配动态内存,大小为 width * height 个字节。

//终端控件控制结构体
typedef struct
{
  u16   x, y, width, height;  //原点、宽度、高度
  u8    textSize;             //字体大小,可以是 12、16、24
  u16   textColor;            //字体颜色
  u16   backColor;            //背景颜色
  u16   lineSpace;            //每行之间的间隔
  u16   showSpace;            //显示区域与边框的间隙
  u16   textXRecord;          //字符显示位置横坐标记录
  u16   textYRecord;          //字符显示位置纵坐标记录
  char* textBuf;              //字符串缓冲区,由动态内存自动分配
}StructTerminal;

终端向上滚动实现

终端的向上滚动由显存移动变成了字符数据移动,如下所示。为了避免减少闪烁,此处绘制字符时使用了非透明的方式,但会增加终端刷新消耗的时间。

static void MoveUp(StructTerminal* widget)
{
  u32 lineHeight, maxLineNum, maxLineCodeNum, showX0, showY0, showX1, showY1, showWidth, showHeight;
  u32 line, pos, x, y;
  char code;
  
  //统计字符显示区域
  showX0 = widget->x + widget->showSpace;
  showY0 = widget->y + widget->showSpace;
  showX1 = (widget->x + widget->width - 1) - widget->showSpace;
  showY1 = (widget->y + widget->height - 1) - widget->showSpace;
  showWidth = showX1 - showX0 + 1;
  showHeight = showY1 - showY0 + 1;
  
  //统计一行的高度
  lineHeight = widget->textSize + widget->lineSpace;
  
  //统计最大行号
  maxLineNum = showHeight / lineHeight;
  
  //统计一行中能显示的最大字符数量
  maxLineCodeNum = showWidth / (widget->textSize / 2);
  
  //字符串往前挪
  for(line = 0; line <= (maxLineNum - 2); line++)
  {
    for(pos = 0; pos <= maxLineCodeNum; pos++)
    {
      widget->textBuf[(line * maxLineCodeNum) + pos] = widget->textBuf[((line + 1) * maxLineCodeNum) + pos];
    }
  }
  
  //清空最后一个字符串
  for(pos = 0; pos <= maxLineCodeNum; pos++)
  {
    widget->textBuf[((maxLineNum - 1) * maxLineCodeNum) + pos] = 0;
  }
  
  //清屏
  //LCDFill(showX0, showY0, showX1, showY1, widget->backColor);
  
  //刷新显示
  for(line = 0; line < maxLineNum; line++)
  {
    //计算行起始横坐标和纵坐标
    x = showX0;
    y = showY0 + line * lineHeight;

    //显示一行字符
    for(pos = 0; pos < maxLineCodeNum; pos++)
    {
      //获取该字符
      code = widget->textBuf[(line * maxLineCodeNum) + pos];

      //为 0,表示字符串结束
      if(0 == code)
      {
        g_iLCDPointColor = widget->textColor;
        g_iLCDBackColor = widget->backColor;
        LCDShowChar(x, y, ' ', widget->textSize, 0);
      }
      else
      {
        g_iLCDPointColor = widget->textColor;
        g_iLCDBackColor = widget->backColor;
        LCDShowChar(x, y, code, widget->textSize, 0);
      }
      x = x + (widget->textSize / 2);
    }
  }
}

实现添加一个字符

添加一个字符时,跟踪记录的不再是屏幕坐标,而是行号和列号,如下所示。但大致思路还是和之前的一样,根据光标所在位置是否超出显示区域来决定是否要向上滚动。

static void AddChar(StructTerminal* widget, char code)
{
  u32 lineHeight, maxLineNum, maxLineCodeNum, showX0, showY0, showX1, showY1, showWidth, showHeight;
  u32 x, y;

  //统计字符显示区域
  showX0 = widget->x + widget->showSpace;
  showY0 = widget->y + widget->showSpace;
  showX1 = (widget->x + widget->width - 1) - widget->showSpace;
  showY1 = (widget->y + widget->height - 1) - widget->showSpace;
  showWidth = showX1 - showX0 + 1;
  showHeight = showY1 - showY0 + 1;
  
  //统计一行的高度
  lineHeight = widget->textSize + widget->lineSpace;
  
  //统计最大行号
  maxLineNum = showHeight / lineHeight;
  
  //统计一行中能显示的最大字符数量
  maxLineCodeNum = showWidth / (widget->textSize / 2);

  //回车符号
  if ('\r' == code)
  {
    widget->textXRecord = 0;
    return;
  }

  //换行符号
  if ('\n' == code)
  {
    //切换到下一行
    widget->textXRecord = 0;
    widget->textYRecord = widget->textYRecord + 1;

    //超标,需要往前挪
    if ((widget->textYRecord) >= maxLineNum)
    {
      widget->textXRecord = 0;
      widget->textYRecord = maxLineNum - 1;
      MoveUp(widget);
    }

    //返回
    return;
  }

  //非法字符
  if ((code < ' ') || (code > '~'))
  {
    return;
  }

  //未显示到一行的终点,可以直接显示
  if (widget->textXRecord < maxLineCodeNum)
  {
    //保存字符
    widget->textBuf[(widget->textYRecord * maxLineCodeNum) + widget->textXRecord] = code;

    //计算字符坐标
    x = showX0 + widget->textXRecord * (widget->textSize / 2);
    y = showY0 + widget->textYRecord * lineHeight;

    //显示字符
    g_iLCDPointColor = widget->textColor;
    g_iLCDBackColor = widget->backColor;
    LCDShowChar(x, y, code, widget->textSize, 1);

    //更新横坐标
    widget->textXRecord = widget->textXRecord + 1;
    
    //返回
    return;
  }

  //到了一行的终点,需要切换到下一行
  else
  {
    //更新显示位置
    widget->textXRecord = 0;
    widget->textYRecord = widget->textYRecord + 1;

    //当前纵坐标并未超标
    if (widget->textYRecord < maxLineNum)
    {
      //保存字符
      widget->textBuf[(widget->textYRecord * maxLineCodeNum) + widget->textXRecord] = code;

      //计算字符坐标
      x = showX0 + widget->textXRecord * (widget->textSize / 2);
      y = showY0 + widget->textYRecord * lineHeight;

      //显示字符
      g_iLCDPointColor = widget->textColor;
      g_iLCDBackColor = widget->backColor;
      LCDShowChar(x, y, code, widget->textSize, 1);

      //更新横坐标
      widget->textXRecord = widget->textXRecord + 1;

      //返回
      return;
    }

    //纵坐标已经超标,需要往前挪
    else
    {
      //数据往前挪
      widget->textXRecord = 0;
      widget->textYRecord = maxLineNum - 1;
      MoveUp(widget);

      //保存字符
      widget->textBuf[(widget->textYRecord * maxLineCodeNum) + widget->textXRecord] = code;

      //计算字符坐标
      x = showX0 + widget->textXRecord * (widget->textSize / 2);
      y = showY0 + widget->textYRecord * lineHeight;

      //显示新字符
      g_iLCDPointColor = widget->textColor;
      g_iLCDBackColor = widget->backColor;
      LCDShowChar(x, y, code, widget->textSize, 1);

      //更新横坐标
      widget->textXRecord = widget->textXRecord + 1;

      //返回
      return;
    }
  }
}

创建函数

创建函数中需要为字符缓冲区申请动态内存,如下所示。

void CreateTerminal(StructTerminal* widget)
{
  u32 lineHeight, maxLineNum, maxLineCodeNum, showX0, showY0, showX1, showY1, showWidth, showHeight;
  u32 i;
  
  //填充背景
  LCDFill(widget->x, widget->y, widget->x + widget->width - 1, widget->y + widget->height - 1, widget->backColor);
  
  //清空显示位置记录
  widget->textXRecord = 0;
  widget->textYRecord = 0;
  
  //统计字符显示区域
  showX0 = widget->x + widget->showSpace;
  showY0 = widget->y + widget->showSpace;
  showX1 = (widget->x + widget->width - 1) - widget->showSpace;
  showY1 = (widget->y + widget->height - 1) - widget->showSpace;
  showWidth = showX1 - showX0 + 1;
  showHeight = showY1 - showY0 + 1;
  
  //统计一行的高度
  lineHeight = widget->textSize + widget->lineSpace;
  
  //统计最大行号
  maxLineNum = showHeight / lineHeight;
  
  //统计一行中能显示的最大字符数量
  maxLineCodeNum = showWidth / (widget->textSize / 2);
  
  //为字符串缓冲区申请动态内存,申请失败直接卡死,方便后期调试寻找原因
  widget->textBuf = MyMalloc(SRAMIN, maxLineNum * maxLineCodeNum);
  if(NULL == widget->textBuf)
  {
    printf("Fail to malloc for terminal\r\n");
    while(1){}
  }
  
  //清空字符串缓冲区
  for(i = 0; i < maxLineNum * maxLineCodeNum; i++)
  {
    widget->textBuf[i] = 0;
  }
}

结尾

本文中提供的终端驱动只能显示英文字母,还不具备显示中文支付的能力。如果要显示中文字符,因为中文字符占据两个字节,且字宽是英文字符的两倍,因此记录光标位置时需要特别注意,别让汉字显示到了终端控件的外边。

实验结果

实验结果如下所示。

源码

本章节中的源码请参考《单片机 GUI 设计(零)- 大纲