返回列表 发新帖我要提问本帖赏金: 50.00元(功能说明)

[APM32F1] 基于APM32 SDK编程实现的USB Joystick摇杆手把手入门

[复制链接]
 楼主| lilijin1995 发表于 2022-10-13 14:43 | 显示全部楼层 |阅读模式
<
本帖最后由 lilijin1995 于 2022-10-13 14:53 编辑

#申请原创#
@21小跑堂



@21小跑堂
第一部分、硬件概述
1.1 实物概图
cfcdbfc10fe96333f4be111d86ded02542c09585.jpg
1.2 Gamepad原理图

Gamepad原理图如图所示,如看不清可打开Doc目录下的PDF文档查阅  

SCH_MINIGPV103_0-MINIGPV103_2022-10-13.png


第二部分、软件工具2.1 软件概述


在 /Software 目录下是常用的工具软件:

  • Dt2_4:配置USB设备Report描述符的工具;
  • USBHID调试助手/呀呀USB: USB调试工具,相当于串口调试助手功能;
  • BUSHound:总线调试工具;
  • USBlyzer:一款专业的USB协议分析软件
  • MDK:常用编译器;
  • STM32CubeMX:代码生成工具;

第三部分、实战训练3.1 实例Eg1_KeyTest

我们想要测试一下按键的按下功能,主要是测试使用APM32F10x_SDK的库函数开发测试GPIO 输入模式的使用;

3.1.1硬件设计   

如下图是我们评估板的原理图 ,原理图是基于STM32F103的,不过APM32F103软硬件上完全兼容STM32F103,所以这里我们直接使用原理图,可以看到SW1~SW9分别有以下对应:

  1. #define         SW1         GPIO_ReadInputBit(GPIOA,GPIO_PIN_0)
  2. #define         SW2         GPIO_ReadInputBit(GPIOB,GPIO_PIN_9)
  3. #define         SW3         GPIO_ReadInputBit(GPIOB,GPIO_PIN_8)
  4. #define         SW4         GPIO_ReadInputBit(GPIOB,GPIO_PIN_7)
  5. #define         SW5         GPIO_ReadInputBit(GPIOB,GPIO_PIN_6)
  6. #define         SW6         GPIO_ReadInputBit(GPIOA,GPIO_PIN_15)
  7. #define         SW7         GPIO_ReadInputBit(GPIOB,GPIO_PIN_3)
  8. #define         SW8         GPIO_ReadInputBit(GPIOB,GPIO_PIN_4)
  9. #define         SW9         GPIO_ReadInputBit(GPIOB,GPIO_PIN_5)

我们只要配置8个GPIO作为输入去检测按键信号;  

3.1.2 软件设计

首先关于新建工程,我们直接使用官方的Examples下面的GPIO例子,将输出改成输入模式,初始化代码如下:

  1. /*!
  2.   * [url=home.php?mod=space&uid=247401]@brief[/url]      Board_KeyGPIOInit
  3.   *
  4.   * @param       None
  5.   *
  6.   * @retval      None
  7.   */
  8. void Board_KeyGPIOInit(void)
  9. {
  10.      GPIO_Config_T gpioConfigStruct;


  11.      RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_GPIOA|RCM_APB2_PERIPH_GPIOB|RCM_APB2_PERIPH_AFIO);
  12.      //PB3复位后是JTDO功能,这里需要禁用JTAG以实现PB3作为上拉输入模式 ,并且AFIO时钟也要使能
  13.      GPIO_ConfigPinRemap(GPIO_REMAP_SWJ_JTAGDISABLE);

  14.      gpioConfigStruct.mode = GPIO_MODE_IN_PU;
  15.      gpioConfigStruct.pin = GPIO_PIN_3|GPIO_PIN_4|GPIO_PIN_5|GPIO_PIN_6
  16.                             |GPIO_PIN_7|GPIO_PIN_8|GPIO_PIN_9;
  17.      gpioConfigStruct.speed = GPIO_SPEED_50MHz;

  18.      GPIO_Config(GPIOB, &gpioConfigStruct);

  19.      gpioConfigStruct.pin = GPIO_PIN_15;
  20.      GPIO_Config(GPIOA, &gpioConfigStruct);

  21.      gpioConfigStruct.mode = GPIO_MODE_IN_PD;
  22.      gpioConfigStruct.pin = GPIO_PIN_0;
  23.      GPIO_Config(GPIOA, &gpioConfigStruct);
  24. }

需要留意的是PB3复位后是JTDO功能(这点参考规格书可以确认),这里需要禁用JTAG以实现PB3作为上拉输入模式;由于Up主一开始也没有留意,直接配置,然后发现PB7一直处于按下,低电平状态,后来才通过查看规格书中引脚图得知PB3复位后是JTDO功能;这个故事告诉我们一个道理:数据手册要经常看;

另外我们发现RCM_APB2_PERIPH_AFIO时钟也需要使能,GPIO_ConfigPinRemap这个函数我们也是只禁用了JTAG;

初始化完成之后我们需要写一个测试程序以测试按键是否按下;

  1. void Board_ButtonScan(void)
  2. {
  3.      if(SW1==BIT_SET)
  4.      {
  5.          printf("SW1 Down\r\n");
  6.      }
  7.      if(SW2==BIT_RESET)
  8.      {
  9.          printf("SW2 Down\r\n");
  10.      }
  11.      if(SW3==BIT_RESET)
  12.      {
  13.          printf("SW3 Down\r\n");
  14.      }
  15.      if(SW4==BIT_RESET)
  16.      {
  17.          printf("SW4 Down\r\n");
  18.      }
  19.      if(SW5==BIT_RESET)
  20.      {
  21.          printf("SW5 Down\r\n");
  22.      }
  23.      if(SW6==BIT_RESET)
  24.      {
  25.          printf("SW6 Down\r\n");
  26.      }
  27.      if(SW7==BIT_RESET)
  28.      {
  29.          printf("SW7 Down\r\n");
  30.      }
  31.      if(SW8==BIT_RESET)
  32.      {
  33.          printf("SW8 Down\r\n");
  34.      }
  35.      if(SW9==BIT_RESET)
  36.      {
  37.          printf("SW9 Down\r\n");
  38.      }
  39. }

最后在main函数中的while循环里调用并延迟一会;

  1. /*!
  2.   * [url=home.php?mod=space&uid=247401]@brief[/url]       Main program
  3.   *
  4.   * @param       None
  5.   *
  6.   * @retval      None
  7.   *
  8.   */
  9. int main(void)
  10. {
  11.      Board_KeyGPIOInit();
  12.      Board_UartPrintInit();
  13.      while (1)
  14.      {
  15.          Board_ButtonScan();
  16.          Delay();

  17.      }
  18. }

  19. /*!
  20.   * @brief       Main program
  21.   *
  22.   * @param       None
  23.   *
  24.   * @retval      None
  25.   *
  26.   */
  27. void Delay(void)
  28. {
  29.      volatile uint32_t delay = 0xfffff;

  30.      while(delay--);
  31. }

3.1.3 下载验证

我们通过我们自有的仿真器模块WCH-link(DAP模式)把程序下载进去即可,仿真器需要选择CMSIS-DAP Debugger;

KQFvlZSeENmBLu3.png


这里用到的wch-link,我们是在这里购买的:https://item.taobao.com/item.htm?id=671288574690

wch-link支持DAP(ARM)和RV(wch RISC-V)模式,并且支持Usb转TTL串口;

我们把我们评估板上的H1的GND和TX分别接到wch-link的GND和RX;打开串口调试助手可以看到如下现象:


3.1.4 入门视频

本节的入门视频链接如下:

【MINIGPA103 USBHID评估板】重新出发基于APM32F103CxT6实现评估板KEY按键输入测试 摇杆鼠标游戏手柄键盘设备哔哩哔哩bilibili



3.2 实例Eg2_JtickAdc

本节我们将通过配置ADC DMA模式去对摇杆电位器的电压进行采样;

3.2.1硬件设计   

如下图是我们评估板的原理图 ,可以看到VRX1和VRY1分别有以下对应PA1和PA2,这是ADC的ch1和ch2;


3.2.2 软件设计

在上一节的基础上,我们初始ADC和DMA,初始化代码如下:

  1. /*!
  2.   * @brief      Board_KeyGPIOInit
  3.   *
  4.   * @param       None
  5.   *
  6.   * @retval      None
  7.   */
  8. void Board_JoystickADCInit(void)
  9. {
  10.      GPIO_Config_T configStruct;
  11.      ADC_Config_T ADC_configStruct;
  12.      DMA_Config_T DMA_ConfigStruct;


  13.      RCM_ConfigADCCLK(RCM_PCLK2_DIV_6); /* 6分频 72/6=12MHZ ADCCLK不能超过14MHZ*/
  14.      RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_GPIOA); /* 使能GPIO时钟 */
  15.      RCM_EnableAHBPeriphClock(RCM_AHB_PERIPH_DMA1);     /* 使能DMA1时钟 */
  16.      RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_ADC1);   /* 使能ADC1时钟 */

  17.      NVIC_EnableIRQRequest(ADC1_2_IRQn, 0, 0);   

  18.      configStruct.pin = GPIO_PIN_1|GPIO_PIN_2;
  19.      configStruct.mode = GPIO_MODE_ANALOG;
  20.      GPIO_Config(GPIOA, &configStruct);

  21.      DMA_Reset(DMA1_Channel1); /* 复位DMA1通道1 */

  22.      DMA_ConfigStruct.peripheralBaseAddr = ADC1_DR_Address;         /* DMA通道外设基地址 */
  23.      DMA_ConfigStruct.memoryBaseAddr = (uint32_t)dma_buffer;        /* DMA通道ADC数据存储器 */
  24.      DMA_ConfigStruct.dir = DMA_DIR_PERIPHERAL_SRC;                 /* 指定外设为源地址 */
  25.      DMA_ConfigStruct.bufferSize = 2;                               /* DMA缓冲区大小(根据ADC采集通道数量修改) */
  26.      DMA_ConfigStruct.peripheralInc = DMA_PERIPHERAL_INC_DISABLE;   /* 当前外设寄存器地址不变(即不自增) */  
  27.      DMA_ConfigStruct.memoryInc = DMA_MEMORY_INC_ENABLE;            /*  当前存储器地址:Disable不变,Enable递增(用于多通道采集) */
  28.      DMA_ConfigStruct.peripheralDataSize = DMA_PERIPHERAL_DATA_SIZE_HALFWORD; /* 外设数据宽度16位 */
  29.      DMA_ConfigStruct.memoryDataSize = DMA_MEMORY_DATA_SIZE_HALFWORD;         /* 存储器数据宽度16位 */
  30.      DMA_ConfigStruct.loopMode = DMA_MODE_CIRCULAR; /* DMA通道操作模式位环形缓冲模式 */
  31.      DMA_ConfigStruct.priority = DMA_PRIORITY_HIGH; /* DMA通道优先级高 */
  32.      DMA_ConfigStruct.M2M = DMA_M2MEN_DISABLE;      /* 禁止DMA通道存储器到存储器传输 */
  33.      DMA_Config(DMA1_Channel1, &DMA_ConfigStruct);

  34.      //DMA_EnableInterrupt(DMA1_Channel1, DMA_INT_TC);
  35.      DMA_Enable(DMA1_Channel1);



  36.      ADC_Reset(ADC1); /* 复位ADC1 */
  37.      /** ADC1 Configuration */
  38.      ADC_configStruct.mode = ADC_MODE_INDEPENDENT;               /* ADC1工作在独立模式 */
  39.      ADC_configStruct.scanConvMode = ENABLE;                     /* 使能扫描 */
  40.      ADC_configStruct.continuosConvMode = ENABLE;                /* 使能ADC连续转换模式 轮询方式使用*/
  41. //  ADC_configStruct.continuosConvMode = DISABLE;               /* 不使能ADC连续转换模式 中断方式使用*/
  42.      ADC_configStruct.externalTrigConv = ADC_EXT_TRIG_CONV_None; /* 软件控制转换 */
  43.      ADC_configStruct.dataAlign = ADC_DATA_ALIGN_RIGHT;          /* 转换数据右对齐 */
  44.      ADC_configStruct.nbrOfChannel = 2;                          /* 顺序进行规则转换的ADC通道的数目 */
  45.      ADC_Config(ADC1, &ADC_configStruct);                        /* 初始化ADC1寄存器 */

  46.      /* 设置指定ADC的规则组通道,设置它们的转化顺序和采样时间 */
  47.      ADC_ConfigRegularChannel(ADC1, ADC_CHANNEL_1, 1, ADC_SAMPLETIME_13CYCLES5); /* ADC1选择通道10 采样顺序1 采样时间13.5个周期 */
  48.      ADC_ConfigRegularChannel(ADC1, ADC_CHANNEL_2, 2, ADC_SAMPLETIME_13CYCLES5); /* ADC1选择通道11 采样顺序2 采样时间13.5个周期 */


  49. //  ADC_EnableInterrupt(ADC1, ADC_INT_EOC); /* 使能ADC转换完成中断 */
  50.      ADC_EnableDMA(ADC1); /* 使能ADC的DMA支持 */
  51.      ADC_Enable(ADC1);    /* 使能ADC1 */

  52.      ADC_ResetCalibration(ADC1);                  /* 复位ADC1的校准寄存器 */
  53.      while(ADC_ReadResetCalibrationStatus(ADC1)); /* 等待ADC1复位校准完成 */
  54.      ADC_StartCalibration(ADC1);                  /* 开始ADC1校准 */
  55.      while(ADC_ReadCalibrationStartFlag(ADC1));   /* 等待ADC1校准完成 */

  56.      ADC_EnableSoftwareStartConv(ADC1); /* 启动ADC1转换 */



  57. }

配置完成之后我们需要写一个测试程序以测试ADC DMA的采样;

  1. /*!
  2.   * @brief       Main program
  3.   *
  4.   * @param       None
  5.   *
  6.   * @retval      None
  7.   *
  8.   */
  9. int main(void)
  10. {
  11.      Board_Init();
  12.      while (1)
  13.      {
  14.          /* 以下采用轮询方式等待转换完成 */
  15.          while(!ADC_ReadStatusFlag(ADC1, ADC_FLAG_EOC)); /* 使用此行代码必须使能连续转换模式 */
  16.          printf("ADC1采样数据:\r\n");
  17.          for (uint8_t i = 0; i < 2; i++) {
  18.              printf("ADC_CHANNEL_%d:%d\r\n", i, dma_buffer);
  19.          }
  20.          printf("\r\n");            
  21.          Delay();

  22.      }

最后在main函数中的while循环里调用并延迟一会;

  1. /*!
  2.   * @brief       Main program
  3.   *
  4.   * @param       None
  5.   *
  6.   * @retval      None
  7.   *
  8.   */
  9. int main(void)
  10. {
  11.      Board_KeyGPIOInit();
  12.      Board_UartPrintInit();
  13.      while (1)
  14.      {
  15.          Board_ButtonScan();
  16.          Delay();

  17.      }
  18. }

  19. /*!
  20.   * @brief       Main program
  21.   *
  22.   * @param       None
  23.   *
  24.   * @retval      None
  25.   *
  26.   */
  27. void Delay(void)
  28. {
  29.      volatile uint32_t delay = 0xfffff;

  30.      while(delay--);
  31. }

3.2.3 下载验证

请参考视频;

3.2.4 入门视频

本节的入门视频链接如下:

【MINIGPA103 USBHID评估板】基于APM32F103CxT6实现评估板ADC DMA对摇杆电位器进行采样测试 摇杆鼠标游戏手柄键盘设备哔哩哔哩bilibili

3.3 实例Eg3_USB_HID_Joystick

前两节我们把基本的外设以及调试OK,现在我们开始USB的学习,本节需要具备一定的USB设备开发知识;关于Usb的学习,这里推荐两个学习视频和一个学习网站:

  • USB技术应用与开发:
    https://www.bilibili.com/video/BV1sy4y1n7d9/?spm_id_from=333.33.header_right.fav_list.click&vd_source=2bbde87de845d5220b1d8ba075c12fb0
  • CherryUSB设备协议栈教程:
    https://www.bilibili.com/video/BV1Ef4y1t73d/?spm_id_from=333.33.header_right.fav_list.click&vd_source=2bbde87de845d5220b1d8ba075c12fb0
  • USB中文网:
    https://www.usbzh.com/

我们主要做USB HID开发,一般我们需要了解一些标准请求,还有HID类的请求;其中标准请求主要是主机获取设备描述符、配置描述符、接口描述符、端点描述符、字符串描述符的过程,如果是HID,还有HID描述符的过程 ,以及报表描述符的过程;

3.2.1硬件设计   

请参考原理图;

3.2.2 软件设计

这一节,主要是USB的代码;主要对USBD_InitParam_T这个USB初始化参数结构体的初始化

  1. /*!
  2.   * @brief       HID mouse init
  3.   *
  4.   * @param       None
  5.   *
  6.   * @retval      None
  7.   */
  8. void HidMouse_Init(void)
  9. {
  10.      USBD_InitParam_T usbParam;
  11.      
  12.      Get_SerialNum();

  13.      USBD_InitParamStructInit(&usbParam);

  14.      usbParam.classReqHandler = USBD_ClassHandler;
  15.      usbParam.stdReqExceptionHandler = HidMouse_ReportDescriptor;

  16.      usbParam.resetHandler = HidMouse_Reset;
  17.      usbParam.inEpHandler = HidMouse_EPHandler;
  18.      usbParam.pDeviceDesc = (USBD_Descriptor_T *)&g_deviceDescriptor;
  19.      usbParam.pConfigurationDesc = (USBD_Descriptor_T *)&g_configDescriptor;
  20.      
  21.      
  22.      usbParam.pStringDesc = (USBD_Descriptor_T *)g_stringDescriptor;
  23.      usbParam.pStdReqCallback = &s_stdCallback;

  24.      USBD_Init(&usbParam);
  25. }

首先是USBD_ClassHandler,我们不做任何修改

  1. /*!
  2.   * @brief       USB HID Class request handler
  3.   *
  4.   * @param       reqData : point to USBD_DevReqData_T structure
  5.   *
  6.   * @retval      None
  7.   */
  8. void USBD_ClassHandler(USBD_DevReqData_T* reqData)
  9. {
  10.      switch (reqData->byte.bRequest)
  11.      {
  12.          case HID_CLASS_REQ_SET_IDLE:
  13.              s_hidIdleState = reqData->byte.wValue[1];
  14.              USBD_CtrlInData(NULL, 0);
  15.              break;

  16.          case HID_CLASS_REQ_GET_IDLE:
  17.              USBD_CtrlInData(&s_hidIdleState, 1);
  18.              break;

  19.          case HID_CLASS_REQ_SET_PROTOCOL:
  20.              s_hidProtocol = reqData->byte.wValue[0];
  21.              USBD_CtrlInData(NULL, 0);
  22.              break;

  23.          case HID_CLASS_REQ_GET_PROTOCOL:
  24.              USBD_CtrlInData(&s_hidProtocol, 1);
  25.              break;

  26.          default:
  27.              break;
  28.      }
  29. }

接着是HidMouse_ReportDescriptor,主要是对获取HID描述符与报表描述符

  1. /*!
  2.   * @brief       Standard request Report HID Descriptor
  3.   *
  4.   * @param       reqData:    Standard request data
  5.   *
  6.   * @retval      None
  7.   */
  8. void HidMouse_ReportDescriptor(USBD_DevReqData_T *reqData)
  9. {
  10.      uint8_t len;

  11.      if((reqData->byte.bRequest == USBD_GET_DESCRIPTOR) &&
  12.          (reqData->byte.bmRequestType.bit.recipient == USBD_RECIPIENT_INTERFACE) &&
  13.          (reqData->byte.bmRequestType.bit.type == USBD_REQ_TYPE_STANDARD))
  14.      {
  15.          if(reqData->byte.wValue[1] == 0x21)
  16.          {
  17.              len = USB_MIN(reqData->byte.wLength[0], 9);
  18.              USBD_CtrlInData((uint8_t *)&g_configDescriptor.pDesc[0x12], len);
  19.          }
  20.          else if(reqData->byte.wValue[1] == 0x22)
  21.          {
  22.              len = USB_MIN(reqData->byte.wLength[0], g_ReportDescriptor.size);
  23.              USBD_CtrlInData((uint8_t *)g_ReportDescriptor.pDesc, len);
  24.          }
  25.      }
  26.      else
  27.      {
  28.          USBD_SetEPTxRxStatus(USBD_EP_0, USBD_EP_STATUS_STALL, USBD_EP_STATUS_STALL);
  29.      }
  30. }

再有,HidMouse_Reset和HidMouse_EPHandler,前者是配置打开端点1,后者是清除USB缓存;

  1. /*!
  2.   * @brief       Reset
  3.   *
  4.   * @param       None
  5.   *
  6.   * @retval      None
  7.   */
  8. void HidMouse_Reset(void)
  9. {
  10.      USBD_EPConfig_T epConfig;

  11.      s_usbConfigStatus = 0;

  12.      /* Endpoint 1 IN */
  13.      epConfig.epNum = USBD_EP_1;
  14.      epConfig.epType = USBD_EP_TYPE_INTERRUPT;
  15.      epConfig.epBufAddr = USB_EP1_TX_ADDR;
  16.      epConfig.maxPackSize = 4;
  17.      epConfig.epStatus = USBD_EP_STATUS_NAK;
  18.      USBD_OpenInEP(&epConfig);

  19.      USBD_SetEPRxStatus(USBD_EP_1, USBD_EP_STATUS_DISABLE);
  20. }
  21. /*!
  22.   * @brief       Endpoint handler
  23.   *
  24.   * @param       ep:     Endpoint number
  25.   *
  26.   * @param       dir:    Direction.0: Out; 1: In
  27.   *
  28.   * @retval      None
  29.   */
  30. void HidMouse_EPHandler(uint8_t ep)
  31. {
  32.      s_statusEP = 1;
  33. }

还有最关键的g_deviceDescriptor与g_configDescriptor,以及g_stringDescriptor,是获取设备描述符,配置描述符以及字符串描述符;

  1. /* Device descriptor */
  2. USBD_Descriptor_T g_deviceDescriptor = {s_hidMouseDeviceDescriptor, HID_MOUSE_DEVICE_DESCRIPTOR_SIZE};
  3. /* Config descriptor */
  4. USBD_Descriptor_T g_configDescriptor = {s_hidMouseConfigDescriptor, USB_CUSTOM_HID_CONFIG_DESC_SIZ};
  5. /* String descriptor */
  6. USBD_Descriptor_T g_stringDescriptor[SRTING_DESC_NUM] =
  7. {
  8.      {s_hidMouseLandIDString, HID_MOUSE_LANGID_STRING_SIZE},
  9.      {s_hidMouseVendorString, HID_MOUSE_VENDOR_STRING_SIZE},
  10.      {s_hidMouseProductString, HID_MOUSE_PRODUCT_STRING_SIZE},
  11.      {s_hidMouseSerialString, HID_MOUSE_SERIAL_STRING_SIZE}
  12. };

这些描述符的获取比较重要,亲直接参考我们的视频讲解;

最后是s_stdCallback,是USB初始完成的标志;

  1. /*!
  2.   * @brief       Standard request set configuration call back
  3.   *
  4.   * @param       None
  5.   *
  6.   * @retval      None
  7.   */
  8. void HidMouse_SetConfigCallBack(void)
  9. {
  10.      s_usbConfigStatus = 1;
  11. }
  12. /** @defgroup USB_HID_Mouse_Variables Variables
  13.    @{
  14.    */

  15. USBD_StdReqCallback_T s_stdCallback =
  16. {
  17.      NULL,
  18.      NULL,
  19.      NULL,
  20.      NULL,
  21.      NULL,
  22.      HidMouse_SetConfigCallBack,
  23.      NULL,
  24.      NULL,
  25.      NULL,
  26.      NULL,
  27. };

3.2.3 下载验证

我们通过我们自有的仿真器模块WCH-link(DAP模式)把程序下载进去即可,可以得到一个Joystick;


3.2.4 入门视频

本节的入门视频链接如下:






  

打赏榜单

21小跑堂 打赏了 50.00 元 2022-10-14
理由:恭喜通过原创审核

评论

文章篇幅较长,有图有文有视频,整体内容较为丰富。但是文章代码占用篇幅太大,对代码和硬件的原理讲解过少,文字内容较为贫瘠。  发表于 2022-10-14 10:59
WuKaiLi 发表于 2022-10-18 17:32 | 显示全部楼层
支持一波
您需要登录后才可以回帖 登录 | 注册

本版积分规则

56

主题

165

帖子

8

粉丝
快速回复 在线客服 返回列表 返回顶部

56

主题

165

帖子

8

粉丝
快速回复 在线客服 返回列表 返回顶部