跳到主要内容

第六章 UART串口通信

在掌握RTOS的基础后,后续章节的代码都将在RTOS环境下编写。为便于理解,课程将采用“先介绍裸机配置,再讲解RTOS下的实现”的对比方式进行学习。

6.1使用CubeMX配置串口

UART是单片机与外部主机通信常用的接口。对比STC89C52RC仅有一个串口,STM32F103VCT6拥有5个串口。通过查看Kingst32开发板原理图了解到UART1对应PA9(TX)和PA10(RX)引脚。根据电路图,这两个引脚已经连接至USB转串口芯片CH340N,用于实现USB转串口通信,如图6-1所示。

图6-1 UART串口通信

(注:UART 仅支持异步通信,而 USART 是功能更强的超集,支持同步和异步两种模式。在同步模式下,USART 需要额外时钟引脚(CK)进行数据同步,此模式通常用于智能卡等特定协议;而绝大多数常规应用(如本章)仅使用其异步功能(即当作UART使用)。以芯片 STM32F103VCT6 为例,其拥有 3 个 USART 和 2 个 UART 外设,在仅进行异步通信时,可等同于 5 个 UART 使用。)

在CubeMX中配置USART1的步骤如下:

  1. 选择接口与模式:在左侧 Connectivity菜单下选择 USART1,将 Mode设置为 Asynchronous(异步模式)。
  2. 配置参数:在 Parameter Settings选项卡中,设置如下通信参数: 1)Baud Rate(波特率): 115200 2)Word Length(字长): 8 Bits 3)Parity(校验位): None 4)Stop Bits(停止位): 1
  3. 使能中断:在 NVIC Settings选项卡中,使能 USART1 global interrupt。
  4. 生成代码:点击 Generate Code生成MDK-ARM工程。

配置界面如图6-2所示。

图6-1 CubeMX配置UART

6.2使用HAL库函数发送和接收定长数据

虽然HAL库提供了定长阻塞式串口收发函数,但是在实际项目中因灵活性差、效率低而应用有限,通常仅用于系统初始化等简单场景。本书将先通过定长阻塞式收发,进阶到掌握基于串口空闲中断模式接收不定长数据的接收和处理,也会简要介绍几种更高级的处理方式。

打开本节工程文件,在main.c中,huart1是一个UART_HandleTypeDef类型的全局变量,用于存储 USART1的配置参数和状态信息。函数说明如下:

HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout) 为HAL库提供的阻塞式发送定长数据函数,作用:阻塞式串口数据发送。 参数说明:

  1. huart:指向 UART 实例的句柄(如&huart1);
  2. pData:待发送数据的缓冲区地址;
  3. Size:发送的字节数;
  4. Timeout:超时时间(毫秒),若发送超时则返回错误。 注意:函数执行期间会阻塞程序,直到发送完成或超时。

HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout)为HAL库提供的阻塞式接收定长数据函数,作用:阻塞式串口数据接收。 参数说明:

  1. huart:指向 UART 实例的句柄(如&huart1);
  2. pData:接收数据存储的缓冲区地址;
  3. Size:期望接收的字节数;
  4. Timeout:超时时间(毫秒),超时后立即返回错误。

注意:函数会阻塞直至收齐指定字节数或超时。

示例代码:发送并接收 100 字节


UART_HandleTypeDef huart1;
MX_USART1_UART_Init();
int main ()
{
while(1)
{
HAL_UART_Transmit(&huart1,sendData,100,100);//发送100个字节,设置超时100ms
HAL_UART_Receive(&huart1,reciveData,100,100);//接收100个字节,设置超时100ms
}
}

以上函数为阻塞模式收发定长数据,他的应用场景比较有限,比如上电后通过串口打印欢迎信息、版本号、初始化参数等。此时系统任务单一,阻塞几十毫秒无关紧要。还有一些没有仿真器的场合比如做linux开发,通过printf函数进行串口打印信息是最常用的调试方式。STM32的HAL库也可以将printf重定向到串口,但是由于STM32的在线仿真功能足够强大,关于printf串口打印重定向就不再赘述。

6.3使用HAL库函数中断收发定长数据

在双向通信场景中,当通信双方都可能主动发数据传输,使用基础的阻塞式收发函数很容易导致数据丢失,这是因为在发送数据期间无法及时响应接收请求,造成接收缓冲区溢出。HAL库提供了中断方式的串口通信函数,能够实现非阻塞的数据收发,有效解决上述问题。

HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size)为HAL库提供的中断式定长数据发送函数,作用:串口中断发送函数。 参数说明:

  1. huart:UART句柄指针,指定操作的串口
  2. pData:待发送数据的缓冲区地址
  3. Size:发送数据的字节数

HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)为HAL库提供的中断式定长数据接收函数,作用:串口中断接收函数。

参数说明:

  1. huart:UART句柄指针,指定操作的串口
  2. pData:接收数据存储的缓冲区地址
  3. Size:期望接收的字节数

值得注意的是,如果想正常使用这两个函数,务必在CubeMX配置过程中,勾选全局的中断使能。

首先由于采用中断模式,就不需要超时这一参数。此外和51单片机开发不同,STM32的HAL库函数已经全面接管了中断处理执行程序,只要数据发送或者接收还没有完全完成,每次单字节发送或者接收完成,HAL库函数会自动处理发送或者接收下一个字节的程序代码。一直到定长的数据发送或者接收完成后,HAL库会自动调用相应的回调函数。作为用户,只需要将回调函数完成即可,程序代码如下所示。

UART_HandleTypeDef huart1;
uint8_t sendData[10]={1,2,3,4,5,6,7,8,9,0};
uint8_t reciveData[10];
int main ()
{
MX_USART1_UART_Init();
HAL_UART_Receive_IT(&huart1,reciveData,100);//启动接收100个字符
while(1)
{
HAL_UART_Transmit_IT(&huart1,sendData,100);//发送100个字符
HAL_Delay(1000);
//
}
}
/****
发送完成回调函数
处理发送完成后续任务
***/
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
if(huart->Instance == USART1) { // 判断触发的外设实例
//发送完成
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); // 自定义操作,仅示范
}
}
/****
接收完成回调函数
处理接收完成后续任务
***/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if(huart->Instance == USART1) { // 判断触发的外设实例
//处理接收数据
//
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); // 自定义操作,仅示范
HAL_UART_Receive_IT(&huart1,reciveData,100);//继续接收100个字符
}
}

从程序代码可以看出,用户只需要再回调函数实现后执行相应的处理逻辑,以下为HAL库函数中断收发定长数据回调函数。

HAL_UART_TxCpltCallback():发送完成回调函数 HAL_UART_RxCpltCallback():接收完成回调函数

6.4HAL库空闲中断回调函数接收不定长的数据

在实际通信中,发送方可以方便的使用sizeof运算符确定待发送数据的固定长度。然而,接收方面临的挑战在于数据长度往往是未知或不固定的。解决这一问题的常见实践依赖于串口通信的一个特性:数据帧内字节间隔极短,而数据帧之间的间隔则明显较长,如图6-2所示。因此接收程序可通过监测字节间的时间间隔来判断一帧数据的边界,从而动态确定本次接收的数据长度。掌握处理不定长数据的方法,是串口应用从“玩具”到“产品”的关键一步。

图6-2 串口通信数据帧

在传统51单片机开发中,常采用"软件超时+状态机"的方式实现不定长串口数据接收。该方法虽然通用性强,但需要应用程序持续进行状态监控和超时判断,占用较多CPU资源。

相比之下,STM32的HAL库提供了硬件级的解决方案HAL_UARTEx_ReceiveToIdle_IT函数。该功能利用USART外设的空闲检测硬件,当监测到串口线路空闲时间超过一个字符帧的传输时长(如9600波特率下约1.04ms)时,自动触发中断并调用用户回调函数。这种硬件辅助的方式大大简化了编程模型,提高了代码执行效率。

值得用户区分的是,每接收一个字节都会触发串口接收中断,HAL库自动将数据存入用户缓冲区。而只有当串口线路保持空闲状态的时间超过一个字符帧的传输时长时,才会触发串口空闲中断。

HAL_UARTEx_ReceiveToIdle_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)为HAL库提供的空闲中断接收函数,用空闲中断来接收不定长的串口数据帧。

参数说明:

  1. huart:UART句柄指针,指定操作的串口;
  2. pData:接收数据存储的缓冲区首地址;
  3. Size:缓冲区大小,应大于预期最长帧长度。

HAL_UARTEx_RxEventCallback():接收完成回调函数。这个函数一方面通过UART_HandleTypeDef来确定触发的中断源,还有Size来传递缓存了多少个字节数据。

#include "stdio.h"
#include "string.h"

UART_HandleTypeDef huart1;
uint8_t sendData[10]={1,2,3,4,5,6,7,8,9,0};
uint8_t reciveData[100];
int main ()
{
MX_USART1_UART_Init();
HAL_UARTEx_ReceiveToIdle_IT(&huart1,reciveData,100);
while(1)
{
//
}
}
/****
接收未完成,总线空闲中断回调函数
处理不定长接收完成后续任务
***/
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if(huart->Instance == USART1) {
if (strcmp((char *)reciveData, "LED1ON") == 0) {
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin,GPIO_PIN_RESET);
}else if (strcmp((char *)reciveData, "LED1OFF") == 0) {
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin,GPIO_PIN_SET);
}else{
printf("error!");
//HAL_UART_Transmit_IT(&huart1,(uint8_t *)"ERROR",5);
}
HAL_UARTEx_ReceiveToIdle_IT(&huart1,reciveData,10);
}
}

空闲中断接收机制在原理上与软件超时判断类似,但凭借硬件自动检测实现了更高的效率和可靠性。然而,STM32标准空闲中断的触发阈值固定为1个字节的传输时间,在高速通信场景下(如921600波特率,超时仅约10.8μs),此间隔可能过短,容易受到短暂干扰而误触发。

针对此限制,ST公司在STM32F0/F3/F7/G0/G4/L4/L5等新一代系列中引入了可配置的接收超时中断(Receiver Timeout Interrupt)。该功能允许用户自定义超时时长(如5ms),既保留了硬件自动检测的高效性,又通过灵活的超时设置兼顾了不同速率通信的可靠性需求。

对于广泛应用的STM32F1系列,虽不具备可配置超时中断,但可通过"空闲中断+双缓冲区"架构有效应对高速通信。此方案利用双缓冲区交替进行数据接收与处理,有效规避了固定超时阈值的限制。而对于更严苛的高速大数据量场景,还可采用DMA(Direct Memory Access,直接存储器访问)技术实现外设与内存间的直接数据搬运,极大提升系统吞吐能力。

6.5FreeRTOS 串口接收任务

在 FreeRTOS 中,代码应遵循以下处理方式。以下是程序框架的简要说明:

创建一个专用于串口接收处理的任务,其优先级设为 1,高于系统中其他任务。该任务在循环中等待来自空闲中断回调函数的“任务通知”。一旦收到通知,即表示有数据到达,任务便开始处理串口接收到的数据;若无通知,则持续阻塞等待。

在空闲中断回调函数中,仅需向串口处理任务发送一个任务通知,以指示空闲中断已触发。该通知将唤醒串口任务进行后续数据处理。

main.c程序核心代码如下:


StaticTask_t usart1ReciveTaskTCB;
StackType_t usart1ReciveTaskStack[ USART1RECIVE_STACK_SIZE ];
TaskHandle_t usart1ReciveTaskHandle = NULL;

int main ()
{
usart1ReciveTaskHandle = xTaskCreateStatic(
usart1ReciveTaskCode, /* Function that implements the task. */
"usart1ReciveTaskCode", /* Text name for the task. */
USART1RECIVE_STACK_SIZE, /* Number of indexes in the xStack array. */
NULL, /* Parameter passed into the task. */
1,/* Priority at which the task is created. */
usart1ReciveTaskStack, /* Array to use as the task's stack. */
&usart1ReciveTaskTCB ); /* Variable to hold the task's data structure. */
osKernelStart();
while(1)
{

}
}

usart1ReciveTask.c程序核心代码如下:


#include "usart1ReciveTask.h"
#include "string.h"
#include "main.h"

extern UART_HandleTypeDef huart1;
extern TaskHandle_t usart1ReciveTaskHandle;
uint8_t reciveData[100];//接收缓存
/****空闲中断回调函数***/
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if(huart->Instance == USART1) {
// ISR 中发送通知
vTaskNotifyGiveFromISR(usart1ReciveTaskHandle, &xHigherPriorityTaskWoken);
HAL_UARTEx_ReceiveToIdle_IT(&huart1,reciveData,100);//继续接收,打开空闲中断
portYIELD_FROM_ISR(xHigherPriorityTaskWoken); // 必要时切换上下文,实时行更好
}
}
/***
串口接收处理任务函数
***/
void usart1ReciveTaskCode( void * pvParameters )
{
HAL_UARTEx_ReceiveToIdle_IT(&huart1,reciveData,100);//开始接收,打开空闲中断
for( ;; )
{
uint32_t ulNotifiedValue = ulTaskNotifyTake(pdTRUE, portMAX_DELAY);//等待任务通知
if (ulNotifiedValue > 0) {
// 处理通知
if (strcmp((char *)reciveData, "LED1 ON") == 0) {
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin,GPIO_PIN_RESET); //LED1打开
}else if (strcmp((char *)reciveData, "LED1 OFF") == 0) {
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin,GPIO_PIN_SET); //LED1关闭
}else{
HAL_UART_Transmit_IT(&huart1,(uint8_t *)"error\n\r",7);
}
}
}
}

这段代码实现了上位机与单片机的命令交互机制:上位机发送指令,单片机接收后通过字符串比对进行解析,若指令匹配则执行相应的操作。

该实现基于 FreeRTOS 的任务通知机制。在 FreeRTOS 中,每个创建的任务都拥有独立的任务通知组,用于接收来自其他任务或中断服务函数的事件通知。等待通知的任务可以进入阻塞状态,从而释放 CPU 资源,直到通知到达后被唤醒。

C语言标准库函数strcmp(),可以实现字符串比对。当两个字符串完全相同的时,该函数返回0,可以实现精确的命令匹配与解析。

关键函数说明:

void vTaskNotifyGiveFromISR( TaskHandle_t xTaskToNotify, BaseType_t *pxHigherPriorityTaskWoken ):用于在中断服务函数中向指定任务发送通知。该函数接收两个参数:

  1. xTaskToNotify:目标任务句柄,指定接收通知的任务
  2. pxHigherPriorityTaskWoken:指向标志位的指针,若通知唤醒了更高优先级的任务,该值会被设为 pdTRUE,此时应在中断退出前调用 portYIELD_FROM_ISR()触发任务切换。

uint32_t ulTaskNotifyTake(BaseType_t xClearCountOnExit, TickType_t xTicksToWait ):任务中用于等待通知的函数。当任务尚未收到通知时,任务将保持阻塞状态。关键参数:

  1. xClearCountOnExit:指定在退出时是否清空任务通知值
  2. xTicksToWait:设置最大等待时间

返回值为任务被阻塞时累计的通知计数值,如果使用pdTRUE,通知值会被清零,返回值就是清零前的总值。 portYIELD_FROM_ISR(xHigherPriorityTaskWoken):这个函数的核心作用是在中断服务程序(ISR)中请求一次任务切换,当更高级任务发生时,可以提高任务的实时性,是否增加这个函数对于系统的差异如图6-3所示。

6.6FreeRTOS任务调度机制

FreeRTOS的核心机制是任务状态与调度,而FreeRTOS的调度器是基于优先级和状态来决定的。

一、任务状态

一个任务在任何时刻都处于以下几种状态之一:

  1. 就绪(Ready):任务准备运行,但当前没有在运行(因为有一个更高优先级的任务正在运行,或者同优先级任务的时间片还没轮到它)。
  2. 运行(Running):任务正在CPU上执行。
  3. 阻塞(Blocked):任务正在等待某个事件,例如延时、信号量、队列、通知等,不会参与调度。
  4. 挂起(Suspended):任务被挂起,不会参与调度,直到被恢复。

二、阻塞分析

当任务调用ulTaskNotifyTake或者xTaskNotifyWait时,如果任务的通知值没有被增加,任务就会进入阻塞状态,直到通知到来或者超时。在6.5小节的任务通知机制中,串口处理任务调用ulTaskNotifyTake(pdTRUE, portMAX_DELAY),意味着:

  1. 如果任务的通知值为0(没有通知),任务将进入阻塞状态,等待通知。
  2. 当通知到来(中断回调函数调用vTaskNotifyGiveFromISR),任务的通知值增加,任务解除阻塞,进入就绪状态。
  3. 如果任务的优先级是当前就绪任务中最高的,那么调度器就会让它进入运行状态。 阻塞等待就是任务主动让出CPU,等待事件发生,此时任务不处于就绪状态。

三、中断服务程序(ISR)发送通知步骤

  1. 在串口空闲中断中,调用vTaskNotifyGiveFromISR,这会增加任务的 通知值,并检测任务是否在等待通知。如果是,则解除阻塞该任务。
  2. 然后,使用portYIELD_FROM_ISR判断是否需要立即上下文切换。如 果被解除阻塞的任务的优先级高于当前被中断的任务(进中断前正在运行的任务),则xHigherPriorityTaskWoken为pdTRUE,那么portYIELD_FROM_ISR会触发上下文切换,使得高优先级任务立即运行。

四、FreeRTOS串口接收任务流程

  1. 串口接收数据,接收完整帧后触发空闲中断。
  2. 中断服务程序发送通知给串口处理任务,并使任务从阻塞状态变为就绪状态。
  3. 如果串口处理任务的优先级足够高,那么中断退出后立即运行串口处理任务。 在RTOS中,应当优先使用阻塞等待方式,让任务在无事可做时主动让出CPU,提高系统效率和实时性。

6.7FreeRTOS的二值信号量与可重入函数

假设在嵌入式系统中有两个任务,一个按键任务和一个串口接收数据处理任务。按键任务在检测到按键按下时,会通过串口1向上位机发送事件;接收处理任务则负责接收上位机下发的命令,解析命令后也会给上位机发送一个执行结果的回应。这两个任务共享同一个串口硬件,如果“同时”使用串口发送程序,就会导致数据帧混乱,因为串口发送数据需要一定时间,在发送完成前不能被另一个任务中断。

为了避免这种冲突,程序必须对串口访问进行同步控制。FreeRTOS提供的信号量(Semaphore)机制非常适合实现这种互斥访问。信号量是一种同步工具,用于控制多个任务对共享资源的访问,确保同一时刻只有一个任务可以访问该资源。

具体实现方法如下:

  1. 创建一个二值信号量(互斥锁),用于保护串口资源。
  2. 每个任务在需要使用串口发送数据前,必须先获取该信号量。如果信号量已被占用(被其他任务获取),则当前任务进入阻塞状态,等待信号量被释放。
  3. 当任务成功获取信号量后,即可安全地使用串口发送数据。在数据发送完成后,任务必须释放信号量,以便其他等待的任务可以获取信号量并使用串口。

这种机制保证了串口数据的完整性和发送的可靠性,避免了多个任务同时操作串口导致的数据冲突。

下面是一段示例代码,展示如何创建信号量以及如何在任务中使用信号量保护串口操作:

extern SemaphoreHandle_t xSemaphoreUsart1;
extern StaticSemaphore_t xMutexBufferUsart1;

int main ()
{
xSemaphoreUsart1 = xSemaphoreCreateBinaryStatic( &xMutexBufferUsart1 );
if( xSemaphoreGive( xSemaphoreUsart1 ) != pdTRUE )
{
// We would expect this call to fail because we cannot give
// a semaphore without first "taking" it!
}
/****省略其他*/
while(1)
{
}

}

在主程序中,需要创建两个变量:SemaphoreHandle_t类型的信号量句柄和StaticSemaphore_t类型的信号量缓存。信号量缓存用于存储信号量的内部数据,而信号量句柄则用于后续对信号量的操作。

使用xSemaphoreCreateBinaryStatic()函数可以创建一个静态信号量。创建时需要将信号量缓存的地址作为参数传递给该函数。如果创建成功,函数将返回信号量的句柄;如果创建失败,则返回NULL。

信号量创建后,初始状态为“不可用”。为了使得后续任务可以获取该信号量,我们需要调用xSemaphoreGive()函数将信号量设置为“可用”状态。这样,当任务第一次请求信号量时,就可以立即获得并开始使用共享资源。

#include "usart.h"
#include "usart1ReciveTask.h"

extern UART_HandleTypeDef huart1;
extern TaskHandle_t usart1ReciveTaskHandle;
uint8_t reciveData[100];//接收缓存

SemaphoreHandle_t xSemaphoreUsart1 = NULL;
StaticSemaphore_t xMutexBufferUsart1;
/***
串口发送函数
***/
HAL_StatusTypeDef usart1SendData(UART_HandleTypeDef *huart, const uint8_t *pData,uint16_t Size,uint32_t Timeout)
{
BaseType_t xSemaphoreStatus;
HAL_StatusTypeDef halStatus = HAL_ERROR;

/* 带合理超时的信号量获取(避免永久阻塞)*/
xSemaphoreStatus = xSemaphoreTake(xSemaphoreUsart1, pdMS_TO_TICKS(100));
if(xSemaphoreStatus != pdTRUE) {
return HAL_TIMEOUT; // 信号量获取超时
}

/* 实际发送(检查huart和pData有效性)*/
if((huart != NULL) && (pData != NULL)) {
halStatus = HAL_UART_Transmit(huart, pData, Size, Timeout);
}

/* 无论发送成功与否都释放信号量 */
xSemaphoreGive(xSemaphoreUsart1);
return halStatus;
}

先介绍一个HAL库的数据类型:HAL_StatusTypeDef,它是HAL库中定义的一个枚举类型,用于表示HAL库函数的执行状态。它通常包含以下几个值:

  1. HAL_OK: 操作成功
  2. HAL_ERROR: 操作错误
  3. HAL_BUSY: 外设繁忙
  4. HAL_TIMEOUT: 操作超时

这个枚举体可以从相应的头文件中找到。定义如下:

typedef enum
{
HAL_OK = 0x00,
HAL_ERROR = 0x01,
HAL_BUSY = 0x02,
HAL_TIMEOUT = 0x03} HAL_StatusTypeDef;

在FreeRTOS多任务环境中,多个任务可能同时调用同一个函数,因此函数的重入性(Reentrancy)至关重要。可重入函数是指在同时被多个任务调用时,不会出现数据混乱的函数。要保证函数的可重入性,需满足以下条件:

  1. 不操作未保护的全局或静态变量;
  2. 对共享资源(如硬件外设)进行互斥保护;
  3. 不调用其他不可重入的函数。

本代码中的usart1SendData函数通过信号量机制对串口1进行互斥访问,确保了在任何时刻最多只有一个任务能使用串口发送数据。函数内部未使用全局变量(除信号量外,而信号量本身就是用于同步的),且对共享资源(串口)进行了保护,因此该函数是可重入的。信号量获取函数xSemaphoreTake设置了合理的超时时间(100个系统节拍),避免了任务因无法获取信号量而无限期阻塞。若获取成功,则任务获得串口的使用权;若超时,则函数返回HAL_TIMEOUT,从而保证系统的实时性和可靠性。此外,函数在发送完成后无论成功与否都会释放信号量,确保信号量不会被长期占用,从而避免了资源泄漏。通过这种方式,我们有效地实现了串口发送的线程安全,确保了多任务环境下串口通信的可靠性。

6.8课后练习题

  1. 扩充字符串命令,实现串口命令打开继电器。
  2. 按键按下触发的事件通过串口上报给PC