最近在优化公司的一款基于 RT-Thread 操作系统的液体探测仪产品。关于 RT-Thread,我最开始用的是 RT-Thread Nano,所以这款产品也是基于 RT-Thread nano 进行开发的,关于 RT-Thread 之前也写了一些文章,如下:

 

RT-Thread 编程高阶用法 - 函数扩展之$Sub$$与$Super$$

 

移植一个实时 OS 很难?那就手把手教你如何快速移植一个 RT-Thread Nano 吧!

 

在这个项目中就运用到了大彩串口屏,大彩串口屏是我们的优质合作厂家,他们的技术实力相当牛逼,所以在我们的产品上运用了很多年,质量相当可观!在很早以前我在公众号和 CSDN 博客就写过大彩串口屏使用的相关文章:

 

带串口屏显示的 Bootloader

 

 

大彩串口屏是基于类似消息队列的机制来实现的:

 

 

详情可以看之前的文章或者参考大彩科技官方提供的文档。

 

 

我们的产品是基于多页面进行开发的,在每个页面上又有 N 多个按钮控件,用于实现界面的交互。

 

1、串口屏解析逻辑

1.1、STM32CubeMX 配置

 

 

 

 

我在 CubeMX 上将串口配置为 DMA 模式,以便于高效的进行串口屏数据的处理和接收。

 

1.2、软件处理逻辑

串口接收数据结构:

#define HMI_LCD_U2_BUFFER_SIZE 100
typedef struct{
    uint8_t  HMI_LCD_U2_Buffer[HMI_LCD_U2_BUFFER_SIZE];
}HMI_LCD_HandleTypeDef;
extern HMI_LCD_HandleTypeDef HMI_LCD_Handler ;

暂时定义为以上规格,方便以后拓展和维护;串口屏中断处理函数实现:

/**
  * @brief This function handles USART2 global interrupt.
  */
void USART2_IRQHandler(void)
{
  /* USER CODE BEGIN USART2_IRQn 0 */
    uint32_t i ;
    uint32_t uart2_dma_rxlen ;
    /*获取空闲中断*/
    if(__HAL_UART_GET_IT_SOURCE(&huart2, UART_IT_IDLE) != RESET)
    {
        __HAL_UART_CLEAR_IDLEFLAG(&huart2);
        HAL_UART_DMAStop(&huart2);
        /*获取此次接收到的数据长度*/
        uart2_dma_rxlen = HMI_LCD_U2_BUFFER_SIZE - (__HAL_DMA_GET_COUNTER(huart2.hdmarx));
        /*将数据丢进大彩科技实现的环形队列里*/
        for(i = 0; i < uart2_dma_rxlen; i++)
        {
            queue_push(HMI_LCD_Handler.HMI_LCD_U2_Buffer[i]);
        }
        /*重新打开 DMA 和空闲中断,进行新的一轮数据接收*/
        __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);
        HAL_UART_Receive_DMA(&huart2, HMI_LCD_Handler.HMI_LCD_U2_Buffer, HMI_LCD_U2_BUFFER_SIZE);
    }

  /* USER CODE END USART2_IRQn 0 */
  HAL_UART_IRQHandler(&huart2);
  /* USER CODE BEGIN USART2_IRQn 1 */

  /* USER CODE END USART2_IRQn 1 */
}

 

在主任务中,我定义了一个串口屏任务以及信号量方式用于处理和同步串口屏上报的数据,关于创建和使用任务,创建信号量以及使用信号量的方法可以参考 RT-Thread 官方文档:

 

 

为什么要采用 RTOS 多任务?

对于普通的项目来说,比如密码锁类项目,单独的一个传感器模块的开发,某些简单的仪器仪表等等,对于这类场景单一,业务需求也单一的项目来说,使用状态机或者事件驱动的方式就足以完成项目的基本功能了。

 

但是如果开发一个巨量代码的工程项目,项目可能设计到传感器数据读取、无线数据上传与接收、数据传输、UI 实时刷新、算法处理等等,功能诸多还需要相互配合的情况下,那么如果还在用裸机的思想去完成,那么开发者一般会面临以下两个问题:

 

  • 设计思路过于复杂,光怎么想程序的设计思路就得想好久了设计下来的各个功能,要考虑相互配合的问题,实时性可能得不到要求

 

RTOS 的多任务就可以解决对应的问题,它既能让项目开发起来思路清晰,方便易维护;同时 RTOS 也能保证整个产品运行的实时性。

 

为什么要采用 RTOS 信号量?

信号量,俗话说就是信号的数量,它是一种任务间传递系统可用资源的机制;举一个生产者与消费者的问题;也就是说消费者在消费了一个资源之前需要等待资源释放,生产者生产资源以后要即时去通知其它的消费者,简单的说就是凡事都要有个先来后到,所以信号量最常用的地方就是实现任务间同步。

 

以下是我写的串口屏任务以及信号量的同步处理:

 

/***************串口液晶屏解析任务*************/
rt_sem_t lcd_sem = RT_NULL;
#define HMI_LCD_THREAD_PRIORITY         5
#define HMI_LCD_THREAD_STACK_SIZE       512
#define HMI_LCD_THREAD_TIMESLICE        5
static rt_thread_t hmi_lcd_thread = RT_NULL;
/*串口液晶屏线程入口函数 */
static void hmi_lcd_thread_entry(void *parameter);
/***************串口液晶屏解析任务*************/

int main(void)
{
    rt_kprintf("create main_thread\n");
    //..... 省略其它代码
    /*3、创建串口屏解析任务*/
    hmi_lcd_thread = rt_thread_create("lcd_thread",
                                      hmi_lcd_thread_entry, RT_NULL,
                                      HMI_LCD_THREAD_STACK_SIZE,
                                      HMI_LCD_THREAD_PRIORITY, HMI_LCD_THREAD_TIMESLICE);

    /* 如果获得线程控制块,启动这个线程 */
    if (hmi_lcd_thread != RT_NULL)
        rt_thread_startup(hmi_lcd_thread);
    //..... 省略其它代码
    while (1)
    {
        rt_thread_mdelay(100);
    }
}

/*串口液晶屏线程入口函数 */
static void hmi_lcd_thread_entry(void *parameter)
{
    rt_kprintf("create lcd_thread\n");
    rt_err_t result;
    queue_reset();
    rt_thread_mdelay(500);
    /* 创建一个动态信号量,初始值是 0 */
    lcd_sem = rt_sem_create("lcd_sem", 0, RT_IPC_FLAG_FIFO);

    if (lcd_sem == RT_NULL)
    {
        rt_kprintf("create lcd_sem failed.\n");
        return ;
    }
    /*使能空闲中断,打开 DMA 接收*/
    __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);
    HAL_UART_Receive_DMA(&huart2, HMI_LCD_Handler.HMI_LCD_U2_Buffer, HMI_LCD_U2_BUFFER_SIZE);
    while (1)
    {
        /*获取 LCD 信号量*/
        result = rt_sem_take(lcd_sem, RT_WAITING_FOREVER);
        if (RT_EOK == result)
        {
            // 进行消息处理
            ProcessMessage((PCTRL_MSG)lcd_handler_def.buffer, lcd_handler_def.size);
        }
    }
}

 

那么这个信号量是从哪里发来的呢?在大彩串口屏提供的tft_cmd_queue.c文件中,有一个queue_find_cmd的函数,这个函数的作用就是将接收的队列进行整合的过程,在整合完毕,确认数据是大彩串口屏协议规格的时候,即是一个完整的数据帧,我们就可以在这个位置发送信号量,主任务接收到信号量,此时就完成了数据同步的过程,主任务将我自己定义的拷贝数组拿出来后,放到消息处理函数ProcessMessage里,即完成一帧数据的解析和应用。

 

/*我自己定义的,用来拷贝完整的数据帧*/
typedef struct
{
    qsize  size;
    qdata buffer[CMD_MAX_SIZE] ;
} lcd_handler ;
extern lcd_handler lcd_handler_def ;
/*大彩科技实现的*/
qsize queue_find_cmd(qdata *buffer, qsize buf_len)
{
    qsize cmd_size = 0;
    qdata _data = 0;

    while(queue_size() > 0)
    {
        // 取一个数据
        queue_pop(&_data);

        if(cmd_pos == 0 && _data != CMD_qhead) // 指令第一个字节必须是帧头,否则跳过
        {
            continue;
        }

        if(cmd_pos < buf_len) // 防止缓冲区溢出
            buffer[cmd_pos++] = _data;

        cmd_state = ((cmd_state << 8) | _data); // 拼接最后 4 个字节,组成一个 32 位整数

        // 最后 4 个字节与帧尾匹配,得到完整帧
        if(cmd_state == CMD_TAIL)
        {
            cmd_size = cmd_pos; // 指令字节长度
            cmd_state = 0;  // 重新检测帧尾巴
            cmd_pos = 0; // 复位指令指针

            #if(CRC16_ENABLE)

            // 去掉指令头尾 EE,尾 FFFCFFFF 共计 5 个字节,只计算数据部分 CRC
            if(!CheckCRC16(buffer + 1, cmd_size - 5)) //CRC 校验
                return 0;

            cmd_size -= 2;// 去掉 CRC16(2 字节)
            #endif
            /*发送队列*/
            lcd_handler_def.size = cmd_size ;
            memcpy(lcd_handler_def.buffer, buffer, cmd_size);
            /*给出一个信号量*/
            rt_sem_release(lcd_sem);
            queue_reset();
            return cmd_size;
        }
    }
    // 没有形成完整的一帧
    return 0;
}

 

由于我的项目涉及多个页面以及多个按钮控件的交互,所以按钮控件的处理函数用得是最多的:

 

/*!
 *  \brief  按钮控件通知
 *  \details  当按钮状态改变(或调用 GetControlValue)时,执行此函数
 *  \param screen_id 画面 ID
 *  \param control_id 控件 ID
 *  \param state 按钮状态:0 弹起,1 按下
 */
void NotifyButton(u16 screen_id, u16 control_id, u8  state)
{
    //TODO: 添加用户代码
  Menu_Select_Item(screen_id,control_id,state);
}

 

我是怎么来做的呢?根据之前写表驱动状态机的思维,将这个过程抽象成了一个框架结构,如下:

 

/*当前菜单*/
typedef enum
{
    WELCOME_PAGE = 0,
    MAIN_PAGE,
    SETTING_PAGE,
    BACKLIGHT_PAGE,
    VOLUME_PAGE,
    LANGUAGE_PAGE,
    TIMESET_PAGE,
    DB_UPDATE_PAGE,
    DATA_UP_PAGE,
    PASSWORD_PAGE,
    PASSWORD_START_PAGE,
    PROJECT_CONFIGUARE_PAGE,
    PROJECT_SELECT_PAGE,
    METAL_TEST_PAGE,
    NONMETAL_TEST_PAGE,
    USB_MODE_PAGE,
    RECOVERY_PAGE,
    DEVICE_TEST_PAGE,
    CREATE_NONMETAL_SAMPLE_LIB_PAGE1,
    CREATE_NONMETAL_SAMPLE_LIB_PAGE2,
    CREATE_NONMETAL_SAMPLE_LIB_PAGE3,
    CLEART_NONMETAL_SAMPLE_LIB_PAGE4,
    CREATE_NEW_ST_SAMPLE_LIB_PAGE,
    CLEAR_SAMPLE_LIB_PAGE,
    CLEAR_SAMPLE_LIB_CONFIRM_PAGE,
    CLEAR_DETECT_CONFIRM_PAGE
} OP_PAGE;

typedef void (*menu_op_func)(uint16_t, uint8_t);
typedef struct OP_STRUCT
{
    uint16_t op_menu ;     /*操作菜单*/
    menu_op_func opfun ;  /*带参数的操作方法*/
} OP_MENU_PAGE;

typedef struct
{
    /*界面操作游标*/
    uint16_t flow_cursor ;
} Cursor ;
extern Cursor Flow_Cursor ;


/*菜单初始化*/
void Menu_Init(void);
/*跳转到下一个菜单*/
void Menu_Jump(uint16_t screen_id);
/*菜单操作*/
void Menu_Select_Item(uint16_t screen_id, uint16_t control_id, uint8_t state);

 

然后一堆复制粘贴,就复用了以前写的一套代码:

 

基于事件型表驱动法菜单框架之小熊派简易气体探测器实战项目开发(上)

 

基于事件型表驱动法菜单框架之小熊派简易气体探测器实战项目开发(中)

 

TencentOS tiny 危险气体探测仪产品级开发重磅高质量更新(Flash 都快用完了!)

 

成功的将跳转表的思想运用到了串口屏多页面多按钮的交互菜单产品开发上:

 

Cursor Flow_Cursor ;

/*菜单操作表定义*/
static OP_MENU_PAGE g_opStruct[] =
{
    {WELCOME_PAGE,  NULL},           /*欢迎页面*/
    {MAIN_PAGE      , main_page_process},     /*主页面*/
  {SETTING_PAGE   , setting_page_process},   /*设置页面*/
  {BACKLIGHT_PAGE , backlight_page_process},  /*背光设置页面*/
  {VOLUME_PAGE  , volume_page_process},    /*音量设置页面*/
  {LANGUAGE_PAGE,NULL},
  {TIMESET_PAGE  , datetime_page_process},   /*日期时间设置页面*/
  {DB_UPDATE_PAGE,NULL},
  {DATA_UP_PAGE,NULL},
  {PASSWORD_PAGE,NULL},
  {PASSWORD_START_PAGE,NULL},
  {PROJECT_CONFIGUARE_PAGE,engineer_password_page_process}, /*工程配置页面密码输入*/
  {PROJECT_SELECT_PAGE,engineer_select_page_process}, /*工程选择页面*/
  {METAL_TEST_PAGE,metal_test_page_process},
  {NONMETAL_TEST_PAGE,nonmetal_test_page_process},
  {USB_MODE_PAGE,NULL},
  {RECOVERY_PAGE,recovery_page_process},
  {DEVICE_TEST_PAGE,NULL},
  {CREATE_NONMETAL_SAMPLE_LIB_PAGE1,NULL},
  {CREATE_NONMETAL_SAMPLE_LIB_PAGE2,NULL},
  {CREATE_NONMETAL_SAMPLE_LIB_PAGE3,NULL},
  {CLEART_NONMETAL_SAMPLE_LIB_PAGE4,NULL},
  {CREATE_NEW_ST_SAMPLE_LIB_PAGE,NULL},
  {CLEAR_SAMPLE_LIB_PAGE,NULL},
  {CLEAR_SAMPLE_LIB_CONFIRM_PAGE,NULL},
  {CLEAR_DETECT_CONFIRM_PAGE,NULL},
};


/*跳转到表所对应的页面*/
static int JUMP_Table(int16_t op, int16_t control_id, uint8_t state)
{
    assert(op >= sizeof(g_opStruct) / sizeof(g_opStruct[0]));
    assert(op < 0);
  if(g_opStruct[op].opfun != NULL)
   g_opStruct[op].opfun(control_id, state);
  else
   printf("current_page op is null!\n");
    return 0 ;
}

/*菜单初始化*/
void Menu_Init(void)
{
    current_screen_id = MAIN_PAGE ;
    Flow_Cursor.flow_cursor = current_screen_id ;
    SetScreen(Flow_Cursor.flow_cursor);
}

/*跳转到下一个菜单*/
void Menu_Jump(uint16_t screen_id)
{
    current_screen_id = screen_id ;
    Flow_Cursor.flow_cursor = current_screen_id ;
    SetScreen(Flow_Cursor.flow_cursor);
}

/*菜单选择项*/
void Menu_Select_Item(uint16_t screen_id, uint16_t control_id, uint8_t state)
{
    JUMP_Table(screen_id, control_id, state);
}

 

以背光调节为例,结合大彩科技的VisualTFT界面开发软件所画的 UI 如下:

 

 

根据大彩串口屏背光调节实现代码逻辑实现如下:

 

/*************按钮****************/
#define BACKLIGHT_SUB_BUTTON  1
#define BACKLIGHT_ADD_BUTTON  2
#define BACKLIGHT_BACK_BUTTON 8
/*************按钮****************/
#define BACKLIGHT_LEVEL    3
#define BACKLIGHT_LEVEL_TEXT  5

BackLight_Handler backlight_handler ;

// 背光调节
void Backlight_level_set(uint8_t level)
{
    uint8_t text_buf[20] = {0};

    switch(level)
    {
    case 0:
        SetBackLight(200);
        break ;

    case 1:
        SetBackLight(160);
        break ;

    case 2:
        SetBackLight(120);
        break ;

    case 3:
        SetBackLight(80);
        break ;

    case 4:
        SetBackLight(40);
        break ;

    case 5:
        SetBackLight(0);
        break ;

    default:
        break ;
    }

    para_stroge.Backligh_level = level ;
    sprintf((char *)text_buf, "当前亮度:%d", level);
    SetTextValue(BACKLIGHT_PAGE, BACKLIGHT_LEVEL_TEXT, text_buf);
    AnimationPlayFrame (BACKLIGHT_PAGE, BACKLIGHT_LEVEL, level);
}

/*背光页面初始化*/
void backlight_page_init(void)
{
    backlight_handler.level = para_stroge.Backligh_level ;
    Menu_Jump(BACKLIGHT_PAGE);
}

/*背光页面操作*/
void backlight_page_process(uint16_t control_id, uint8_t state)
{
    switch(control_id)
    {
    case BACKLIGHT_SUB_BUTTON:
        (backlight_handler.level > 0) ? (backlight_handler.level--) : (backlight_handler.level = 0);
        Backlight_level_set(backlight_handler.level);
        break ;

    case BACKLIGHT_ADD_BUTTON:
        (backlight_handler.level < 5) ? (backlight_handler.level++) : (backlight_handler.level = 5);
        Backlight_level_set(backlight_handler.level);
        break ;

    case BACKLIGHT_BACK_BUTTON:
        WriteUserFlash(0x00000000, 1, ¶_stroge.Backligh_level);
        Menu_Jump(SETTING_PAGE);
        break ;

    default:
        break ;
    }
}

 

然后此时我们需要在菜单框架里将backlight_page_process注册到对应的区域,这样在对应的界面才能对相应的按钮事件进行一一关联:

 

 

由于代码涉及了一些产品的核心需求,故不能完全开放,但与大彩串口屏多界面交互的思想就是状态机+表驱动,这是任何一个项目都可以应用的。