拓冰建站拓冰建站
首页 / 资讯中心 / 正文

RT-Thread下HC32F460 SPI驱动移植实战:从框架解析到DMA优化

1. 项目缘起为什么要在RT-Thread上折腾HC32F460的SPI最近在做一个需要高速数据交换的嵌入式项目主控选用了华大半导体的HC32F460这是一颗基于Cortex-M4内核主频高达200MHz的MCU性能相当不错。操作系统自然选择了国内嵌入式领域的老朋友——RT-Thread。项目里需要挂载一个SPI接口的Flash芯片和一个TFT屏幕本以为RT-Thread的BSP板级支持包里会有现成的驱动结果发现官方BSP对HC32F460的SPI支持还比较基础很多高级功能和应用场景需要自己动手移植和适配。这其实是一个挺典型的场景芯片厂商提供了完善的硬件和底层库RT-Thread提供了优秀的操作系统框架和丰富的软件包生态但两者之间的“粘合剂”——也就是针对具体板卡和外设的驱动往往需要开发者自己来“搅拌”一下。SPISerial Peripheral Interface作为嵌入式领域最常用的总线协议之一其驱动移植的成败直接关系到外设能否正常工作。网上关于STM32的SPI驱动文章汗牛充栋但针对HC32F460这类国产芯片在RT-Thread下的详细教程却不多见。这次我就把自己从零开始在RT-Thread上为HC32F460移植SPI驱动的完整过程、踩过的坑以及一些关键思考记录下来希望能给遇到类似问题的朋友一个清晰的参考。2. HC32F460 SPI硬件外设与RT-Thread驱动框架初探在动手写代码之前我们必须先搞清楚两件事手里的“兵器”HC32F460的SPI硬件是什么样的以及我们要遵循的“招式套路”RT-Thread的SPI设备驱动框架是什么。2.1 HC32F460的SPI硬件特性解析HC32F460系列通常包含多个SPISerial Peripheral Interface模块可能命名为SPI1、SPI2等它们的功能是高度可配置的。我们需要仔细阅读数据手册关注以下几个核心点工作模式支持主模式Master和从模式Slave。我们的应用场景如驱动Flash和屏幕几乎都是主模式。时钟极性与相位CPOL CPHA这决定了SPI的四种通信模式Mode 0-3。这是SPI通信中最容易出错的地方之一必须与外设器件的数据手册要求严格匹配。HC32F460的SPI控制器可以灵活配置CPOL和CPHA。数据帧格式数据位宽通常为8位或16位、MSB/LSB先行顺序。大部分SPI器件是8位、MSB先行。时钟频率作为主机可以配置通信的SCK时钟频率。HC32F460的SPI时钟源通常来自PCLK外设时钟通过分频器设置。需要根据外设支持的最高速率和实际布线情况长线可能引入信号完整性问题来设定一个合适的值。片选CS管理硬件SPI控制器通常提供硬件片选NSS引脚。但在实际项目中特别是需要连接多个SPI从设备时我们更常用普通的GPIO来模拟片选信号软件片选这样控制起来更加灵活。HC32F460的硬件NSS功能需要评估其是否满足我们的多设备切换时序要求。FIFO与中断/DMA为了提高效率SPI模块通常内置了发送和接收FIFO缓冲区并支持中断和DMA传输。在RT-Thread这样的实时操作系统中利用DMA进行大数据量传输可以极大解放CPU减少任务调度延迟。2.2 RT-Thread SPI设备驱动框架概览RT-Thread提供了一套名为“设备驱动框架”的抽象层。对于SPI其核心是struct rt_spi_device和一组标准的操作函数rt_spi_transfer_message,rt_spi_send_then_send, 等。我们的目标就是实现一个符合这个框架的SPI总线驱动struct rt_spi_bus及其操作函数集struct rt_spi_ops。简单来说我们需要完成以下接口的“填空”configure: 根据struct rt_spi_configuration参数模式、频率等来配置底层硬件SPI控制器。xfer: 这是最核心的函数负责执行一次SPI消息传输。消息结构体struct rt_spi_message包含了发送/接收缓冲区、长度、片选控制等所有信息。这个函数需要阻塞地或利用RTOS同步机制完成本次传输。框架的好处是一旦总线驱动完成上层应用如使用SFUD软件包驱动SPI Flash或使用GUI引擎驱动SPI屏幕就可以通过统一的API如rt_spi_transfer_message来访问SPI设备无需关心底层是HC32F460还是STM32。3. 移植实战从零构建HC32F460的SPI总线驱动理论清晰后我们开始动手。假设我们使用HC32F460的SPI1模块。3.1 工程准备与底层引脚配置首先确保你的RT-Thread工程已经包含了对HC32F460的基本支持通常通过rtconfig.h中的宏定义和相应的drv_common.c/h实现。然后我们创建两个关键文件drv_spi.c和drv_spi.h。第一步引脚复用配置根据你的硬件原理图找到SPI1对应的SCK、MISO、MOSI引脚。例如可能是PA5、PA6、PA7。我们需要将这些引脚配置为SPI复用功能。这里通常直接调用华大提供的底层库函数例如DDL库中的GPIO_SetFunc。关键点务必同时配置引脚的速率和上下拉模式。对于高速SPI比如几十MHz建议将引脚输出速率设置为最高。对于开漏输出的MISO线可能需要使能内部上拉电阻。// 示例代码片段 (drv_spi.c) static void spi1_gpio_init(void) { /* 使能GPIOA时钟 */ Sysctrl_SetPeripheralGate(SysctrlPeripheralGpio, TRUE); /* 使能SPI1时钟 */ Sysctrl_SetPeripheralGate(SysctrlPeripheralSpi1, TRUE); /* 配置PA5为SPI1_SCK 推挽输出高速 */ GPIO_SetFunc(GPIO_PORT_A, GPIO_PIN_5, GPIO_FUNC_11_SPI1_SCK); GPIO_SetPull(GPIO_PORT_A, GPIO_PIN_5, GPIO_PullUp); GPIO_SetMode(GPIO_PORT_A, GPIO_PIN_5, GPIO_Mode_Out_PP); GPIO_SetDrivingCurrent(GPIO_PORT_A, GPIO_PIN_5, GPIO_DrivingCurrent_Level3); /* 配置PA6为SPI1_MISO 浮空输入或上拉高速 */ GPIO_SetFunc(GPIO_PORT_A, GPIO_PIN_6, GPIO_FUNC_11_SPI1_MISO); GPIO_SetPull(GPIO_PORT_A, GPIO_PIN_6, GPIO_PullUp); GPIO_SetMode(GPIO_PORT_A, GPIO_PIN_6, GPIO_Mode_In_Floating); /* 配置PA7为SPI1_MOSI 推挽输出高速 */ GPIO_SetFunc(GPIO_PORT_A, GPIO_PIN_7, GPIO_FUNC_11_SPI1_MOSI); GPIO_SetPull(GPIO_PORT_A, GPIO_PIN_7, GPIO_PullUp); GPIO_SetMode(GPIO_PORT_A, GPIO_PIN_7, GPIO_Mode_Out_PP); GPIO_SetDrivingCurrent(GPIO_PORT_A, GPIO_PIN_7, GPIO_DrivingCurrent_Level3); }第二步实现configure函数这个函数负责将RT-Thread的通用SPI配置参数翻译成HC32F460 SPI寄存器的具体设置。static rt_err_t spi_configure(struct rt_spi_device *device, struct rt_spi_configuration *configuration) { RT_ASSERT(device ! RT_NULL); RT_ASSERT(configuration ! RT_NULL); struct hc32_spi *spi_drv rt_container_of(device-bus, struct hc32_spi, spi_bus); stc_spi_init_t spi_init; /* 获取SPI外设基地址例如 SPI1 */ M4_SPI_TypeDef *SPIx spi_drv-spi_instance; /* 1. 设置模式 (CPOL, CPHA) */ switch (configuration-mode RT_SPI_MODE_3) { case RT_SPI_MODE_0: /* CPOL0, CPHA0 */ spi_init.enClkPhase SpiClkPhase_FirstEdge; spi_init.enClkPolarity SpiClkPolarity_Low; break; case RT_SPI_MODE_1: /* CPOL0, CPHA1 */ spi_init.enClkPhase SpiClkPhase_SecondEdge; spi_init.enClkPolarity SpiClkPolarity_Low; break; case RT_SPI_MODE_2: /* CPOL1, CPHA0 */ spi_init.enClkPhase SpiClkPhase_FirstEdge; spi_init.enClkPolarity SpiClkPolarity_High; break; case RT_SPI_MODE_3: /* CPOL1, CPHA1 */ spi_init.enClkPhase SpiClkPhase_SecondEdge; spi_init.enClkPolarity SpiClkPolarity_High; break; default: return -RT_EINVAL; } /* 2. 设置数据位宽 */ if (configuration-data_width 8) { spi_init.enDataWidth SpiDataWidth_8bit; SPI_SetTransmitBitWidth(SPIx, SpiTransmitBitWidth_8bit); } else if (configuration-data_width 16) { spi_init.enDataWidth SpiDataWidth_16bit; SPI_SetTransmitBitWidth(SPIx, SpiTransmitBitWidth_16bit); } else { return -RT_EINVAL; } /* 3. 设置MSB/LSB先行 */ spi_init.enFirstBit (configuration-mode RT_SPI_MSB) ? SpiFirstBit_Msb : SpiFirstBit_Lsb; /* 4. 计算并设置波特率分频 */ /* 先获取SPI外设时钟源频率例如PCLK1 */ uint32_t spi_clock SystemCoreClock / 2; // 假设PCLK1 HCLK/2需根据实际时钟树确认 uint32_t max_hz configuration-max_hz; uint32_t div 0; uint32_t baudrate_prescaler; /* HC32F460 SPI分频通常为 2, 4, 8, 16, 32, 64, 128, 256 */ if (max_hz spi_clock / 2) { div 2; } else if (max_hz spi_clock / 4) { div 4; } else if (max_hz spi_clock / 8) { div 8; } else if (max_hz spi_clock / 16) { div 16; } else if (max_hz spi_clock / 32) { div 32; } else if (max_hz spi_clock / 64) { div 64; } else if (max_hz spi_clock / 128) { div 128; } else { div 256; // 最低速率 } /* 将分频系数映射到具体的寄存器枚举值 */ baudrate_prescaler _get_baudrate_prescaler(div); // 这是一个需要实现的映射函数 spi_init.enBaudratePrescaler baudrate_prescaler; /* 5. 主模式配置 */ spi_init.enSpiMode SpiMode_Master; spi_init.enWireMode SpiWireMode_4Wire; // 标准四线模式SCK, MOSI, MISO, NSS /* 注意我们通常使用软件片选所以这里关于NSS的硬件管理可以禁用或配置为软件模式 */ spi_init.enNssMode SpiNssMode_Soft; // 软件管理NSS片选 /* 6. 初始化SPI硬件 */ SPI_Init(SPIx, spi_init); /* 使能SPI */ SPI_Cmd(SPIx, Enable); return RT_EOK; }注意上面的_get_baudrate_prescaler函数需要你根据华大库的枚举定义来实现。另外关于时钟源spi_clock的计算必须根据你项目中实际的时钟树配置来写这是最容易出错的地方之一。3.2 核心挑战实现高效可靠的xfer传输函数xfer函数是驱动的心脏它接收一个rt_spi_message消息。这个消息可能很复杂它可能只发送、只接收、或者同时收发它可能包含多个struct rt_spi_message通过链表连接它还控制了片选CS的拉低和拉高。我们的策略是处理片选如果消息的cs_take标志为真在传输开始前拉低对应的GPIO软件片选。如果cs_release为真在传输结束后拉高GPIO。处理数据根据消息的长度和缓冲区循环调用HC32F460的SPI数据发送/接收函数完成数据传输。处理连续传输如果消息链表中有下一个消息message-next继续处理。这里有一个关键细节SPI是全双工的主机发送一个字节的同时也会收到一个字节。即使你只想发送send_buf有效recv_buf为NULL也必须从SPI数据寄存器DR中读取一个“哑元”数据来清空接收缓冲区否则可能会卡住。反之如果只想接收也需要发送一些虚拟数据通常是0xFF或0x00来产生时钟。static rt_uint32_t spi_xfer(struct rt_spi_device *device, struct rt_spi_message *message) { RT_ASSERT(device ! RT_NULL); RT_ASSERT(message ! RT_NULL); struct hc32_spi *spi_drv rt_container_of(device-bus, struct hc32_spi, spi_bus); M4_SPI_TypeDef *SPIx spi_drv-spi_instance; rt_size_t message_length message-length; const rt_uint8_t *send_buf message-send_buf; rt_uint8_t *recv_buf message-recv_buf; rt_size_t index 0; /* 1. 片选控制 (软件片选) */ if (message-cs_take (device-cs_pin ! PIN_NONE)) { rt_pin_write(device-cs_pin, PIN_LOW); /* 此处可加一个小延时确保片选稳定特别是对时序敏感的器件 */ rt_thread_delay(1); // 延时1个tick具体时间需根据实际情况调整 } /* 2. 数据传输循环 */ while (index message_length) { rt_uint8_t data 0xFF; // 默认发送0xFF适用于大部分SPI从设备如Flash的读操作 if (send_buf ! RT_NULL) { data send_buf[index]; } /* 等待发送缓冲区空 */ while (RESET SPI_GetStatus(SPIx, SpiFlagTxEmpty)); /* 写入数据启动传输 */ SPI_SendData(SPIx, data); /* 等待接收缓冲区非空 */ while (RESET SPI_GetStatus(SPIx, SpiFlagRxFull)); /* 读取接收到的数据 */ rt_uint8_t recv_data SPI_ReceiveData(SPIx); /* 如果需要接收则保存数据 */ if (recv_buf ! RT_NULL) { recv_buf[index] recv_data; } /* 如果不需要接收则丢弃recv_data */ index; } /* 3. 等待最后一字节传输完成确保所有数据移出 */ while (RESET SPI_GetStatus(SPIx, SpiFlagTransComplete)); /* 4. 片选释放 */ if (message-cs_release (device-cs_pin ! PIN_NONE)) { rt_pin_write(device-cs_pin, PIN_HIGH); } /* 返回成功传输的长度 */ return index; }提示上面的while循环轮询标志位的方式是“阻塞式”的在RTOS中会长时间占用CPU。对于短数据或低速率传输可以接受。但对于高速或大数据量传输强烈建议使用DMA配合信号量Semaphore或完成量Completion来实现非阻塞传输。这能显著提高系统整体响应能力。实现DMA传输是驱动优化的关键一步步骤会复杂很多需要配置DMA通道、中断并在传输完成后通过回调函数通知xfer函数。3.3 驱动注册与集成到RT-Thread实现了configure和xfer后我们需要将它们填充到一个rt_spi_ops结构体中然后创建并注册一个rt_spi_bus。/* SPI操作函数集 */ static struct rt_spi_ops hc32_spi_ops { .configure spi_configure, .xfer spi_xfer, }; /* SPI总线初始化函数 */ int rt_hw_spi_init(void) { rt_err_t result; /* 初始化GPIO */ spi1_gpio_init(); /* 初始化驱动数据结构体 */ static struct hc32_spi spi1_drv; spi1_drv.spi_instance (M4_SPI_TypeDef *)M4_SPI1; // SPI1硬件地址 /* 注册SPI总线总线名为 spi1 */ result rt_spi_bus_register(spi1_drv.spi_bus, spi1, hc32_spi_ops); if (result ! RT_EOK) { rt_kprintf(spi1 bus register failed.\n); return result; } rt_kprintf(spi1 bus register success.\n); return RT_EOK; } /* 使用INIT_BOARD_EXPORT等自动初始化机制在系统启动时调用rt_hw_spi_init */ INIT_BOARD_EXPORT(rt_hw_spi_init);至此一个最基本的、使用轮询方式的HC32F460 SPI总线驱动就完成了。接下来就可以在应用层创建具体的SPI设备了。4. 应用层对接以挂载SPI Flash为例总线驱动spi1好比一条公路而具体的SPI设备如W25Q128 Flash芯片就像跑在公路上的汽车。我们需要为这辆“汽车”注册一个“车牌”设备实例。4.1 创建SPI设备实例假设Flash芯片连接在SPI1总线上使用引脚PB0作为软件片选CS。#define W25Q_SPI_CS_PIN GET_PIN(B, 0) int spi_flash_init(void) { rt_err_t res; static struct rt_spi_device spi_dev_w25q; /* 配置片选引脚为输出模式默认高电平不选中 */ rt_pin_mode(W25Q_SPI_CS_PIN, PIN_MODE_OUTPUT); rt_pin_write(W25Q_SPI_CS_PIN, PIN_HIGH); /* 在SPI总线“spi1”上挂载一个设备命名为“spi10” */ res rt_spi_bus_attach_device(spi_dev_w25q, spi10, spi1, (void *)W25Q_SPI_CS_PIN); if (res ! RT_EOK) { rt_kprintf(Failed to attach spi flash device on spi1 bus.\n); return res; } /* 配置SPI设备参数模式0 8位数据最大时钟40MHz */ struct rt_spi_configuration cfg; cfg.mode RT_SPI_MODE_0 | RT_SPI_MSB; // 模式0, MSB先行 cfg.data_width 8; cfg.max_hz 40 * 1000 * 1000; // 40MHz不要超过Flash芯片支持的最大频率 cfg.reserved 0; res rt_spi_configure(spi_dev_w25q, cfg); if (res ! RT_EOK) { rt_kprintf(Failed to configure spi flash device.\n); return res; } rt_kprintf(SPI Flash device [spi10] attached and configured successfully.\n); return RT_EOK; } INIT_COMPONENT_EXPORT(spi_flash_init);4.2 使用SFUD软件包驱动FlashRT-Thread有一个优秀的通用SPI Flash驱动软件包——SFUD (Serial Flash Universal Driver)。它支持市面上绝大多数SPI Flash芯片。注册好SPI设备后使用SFUD就非常简单了。首先在RT-Thread的包管理器env工具或Studio中使能SFUD软件包。然后在应用代码中#include rtthread.h #include rtdevice.h #include spi_flash_sfud.h int sfud_demo(void) { /* 通过设备名“spi10”获取SFUD Flash设备对象 */ sfud_flash_t flash sfud_get_device(SFUD_FLASH_DEVICE_NAME); if (flash RT_NULL) { rt_kprintf(Get SFUD flash device failed.\n); return -1; } /* 现在可以使用SFUD的标准API进行操作了 */ rt_kprintf(Flash ID: 0x%06X\n, flash-chip.chip_id); rt_kprintf(Flash Size: %d bytes (%d MB)\n, flash-chip.capacity, flash-chip.capacity / 1024 / 1024); /* 示例擦除第0个扇区4KB */ if (sfud_erase(flash, 0, 4096) SFUD_SUCCESS) { rt_kprintf(Erase sector 0 success.\n); } /* 示例写入数据 */ char write_buf[] Hello, RT-Thread HC32F460!; if (sfud_write(flash, 0, sizeof(write_buf), write_buf) SFUD_SUCCESS) { rt_kprintf(Write data success.\n); } /* 示例读取数据 */ char read_buf[50] {0}; if (sfud_read(flash, 0, sizeof(read_buf), read_buf) SFUD_SUCCESS) { rt_kprintf(Read data: %s\n, read_buf); } return 0; } MSH_CMD_EXPORT(sfud_demo, SFUD demo);将上述代码编译下载在RT-Thread的MSH命令行中运行sfud_demo如果一切顺利你就能看到Flash的ID、容量信息以及读写测试的结果。这标志着你的SPI总线驱动工作正常并且成功对接了上层应用。5. 深度优化与高级功能实现基础功能跑通只是第一步要让驱动稳定、高效地用于实际项目还需要进行一系列优化。5.1 实现DMA传输以提升性能轮询方式在传输几十KB以上的数据时会长时间独占CPU。使用DMA可以将CPU解放出来。HC32F460的SPI和DMA配合需要以下步骤初始化DMA通道为SPI的发送TX和接收RX分别配置一个DMA通道或使用双缓冲模式。设置传输方向、外设地址SPI数据寄存器、内存地址、数据宽度、传输长度等。修改xfer函数在xfer函数中不再使用while循环轮询而是配置DMA传输参数内存地址、长度。启动DMA传输。调用rt_sem_take或rt_completion_wait等待一个信号量或完成量。DMA传输完成中断服务程序ISR中释放这个信号量或完成完成量。xfer函数等到信号量后返回成功。处理半满/全满中断对于大数据量可以结合DMA的半传输完成和传输完成中断实现双缓冲Ping-Pong Buffer机制实现数据流的无缝连续传输这对驱动SPI TFT屏幕刷屏尤其有用。注意DMA传输需要仔细处理缓存一致性问题Cache Coherency。如果CPU有CacheHC32F460的M4内核有可选指令/数据CacheDMA操作的内存区域必须是非缓存Non-Cacheable的或者在进行DMA读写前后需要调用SCB_CleanDCache_by_Addr或SCB_InvalidateDCache_by_Addr等函数来清洗/无效化Cache。这是嵌入式开发中一个高级且易错的点。5.2 支持多设备管理与软件片选优化一个SPI总线挂载多个设备是常态。我们的驱动通过rt_spi_bus_attach_device时传入的(void *)cs_pin参数来管理软件片选。在xfer函数的开头和结尾操作这个引脚。优化点片选延时有些老式或低速器件需要在片选有效后等待一段时间t_CS2SCLK才能发送第一个时钟。可以在cs_take拉低片选后插入一个微秒级的忙等待rt_hw_us_delay。总线忙状态虽然SPI协议本身没有定义总线忙状态但在多线程环境下一个设备正在传输时另一个设备不应该发起传输。可以在struct hc32_spi中增加一个rt_mutex_t互斥锁在xfer函数开始处加锁结束时解锁实现简单的总线仲裁。动态配置每次传输前configure函数都会被调用如果配置与上次不同。确保在切换不同配置的SPI设备时硬件寄存器被正确重置和配置。有些器件对时钟空闲状态非常敏感配置切换不当会导致通信失败。5.3 调试技巧与常见问题排查驱动开发过程就是与问题斗争的过程。以下是一些实用的调试方法逻辑分析仪是神器这是调试SPI问题最直接的工具。连接SCK、MOSI、MISO、CS四根线可以清晰地看到时序、数据、模式是否符合预期。可以检查CPOL/CPHA是否正确。数据位是否正确MSB/LSB顺序。片选时序。时钟频率是否稳定。简化测试先抛开RT-Thread和驱动框架写一个最简单的裸机程序用轮询方式发送固定的数据如0xAA, 0x55用逻辑分析仪或示波器看波形。确保底层硬件操作是正确的。分步验证第一步验证GPIO配置和SPI初始化是否正确能否进入主模式。第二步验证单字节发送/接收轮询是否正常。第三步验证在RT-Thread的xfer函数中单次传输是否正常。第四步验证SFUD的识别和读写。第五步引入DMA传输。常见问题通信完全无反应检查电源、接线、引脚复用配置、时钟使能。用万用表量一下SCK是否有波形。能发不能收或数据错位99%是CPOL/CPHA模式设置错误。仔细对照主从设备的数据手册。逻辑分析仪可以一目了然。高速时数据出错可能是时钟频率过高信号质量差需要加终端电阻或缩短走线。也可能是DMA缓存一致性问题。SFUD无法识别Flash首先用逻辑分析仪抓取SFUD发送JEDEC ID命令0x9F的波形看Flash是否回复了正确的ID。检查片选信号是否正常。检查SPI模式Mode 0/3是否匹配。有些Flash需要先释放Deep Power Down模式。6. 从SPI到其他总线驱动框架的统一思想成功移植SPI驱动后你会发现RT-Thread的设备驱动框架思想是一以贯之的。无论是I2C、UART还是SDIO其核心逻辑都是类似的实现一个符合rt_xxx_ops标准的底层硬件操作函数集configure,xfer,control等。将这些操作注册到一个rt_xxx_bus或rt_xxx_device上。在应用层通过统一的设备API如rt_device_read/write,rt_i2c_transfer进行访问。这种分层和抽象的设计极大地提高了代码的复用性和可移植性。当你下次需要为HC32F460移植I2C驱动时完全可以参照SPI的流程研究硬件I2C寄存器、实现i2c_xfer函数、处理起始终止条件、ACK/NACK、最后注册到I2C总线框架。掌握了这个套路你就具备了在RT-Thread上驾驭各种MCU外设的能力。整个移植过程从理解硬件、吃透框架、实现核心函数、到调试优化、最终对接应用是一个典型的嵌入式驱动开发闭环。它考验的不仅是编码能力更是对硬件协议、操作系统机制和调试方法的综合掌握。希望这篇基于HC32F460和RT-Thread的SPI驱动移植实录能为你点亮一盏灯让你在遇到类似问题时能够更加从容地拆解和解决。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门