1. MSP432P4 串口通信工程实践:基于 EUSCI-A 模块的中断驱动收发与标准库对接

MSP432P4 系列微控制器是德州仪器(TI)面向低功耗、高性能嵌入式应用推出的 ARM Cortex-M4F 内核 MCU。其片上外设资源丰富,其中 EUSCI(Enhanced Universal Serial Communication Interface)模块是实现异步串行通信(UART)、同步 SPI 和 I²C 协议的核心硬件单元。在电赛及实际工程项目中,稳定、高效的串口通信能力是调试信息输出、传感器数据采集、人机交互乃至多设备组网的基础。本节将围绕 MSP432P4 的 EUSCI-A 模块,系统性地阐述其 UART 模式下的中断驱动收发机制,并完成与 C 标准库 printf / scanf 的无缝对接。所有实践均基于 TI 官方提供的 MSP432P401R LaunchPad 开发板,该板集成四路独立串口(EUSCI_A0–A3),其中 EUSCI_A0 通过板载 XDS110 调试器的虚拟 COM 口直接连接 PC,是开发与调试的首选通道。

1.1 MSP432P4 串口资源与 EUSCI-A 模块特性解析

MSP432P4 的串口功能由两个独立的增强型通用串行通信接口模块提供:EUSCI_A 和 EUSCI_B。二者在硬件架构上高度相似,但支持的协议略有侧重。EUSCI_A 模块专为异步 UART 和同步 SPI 协议优化,而 EUSCI_B 模块则同时支持同步 SPI 和 I²C 协议。对于纯粹的串口通信需求,EUSCI_A 是唯一且最直接的选择。

查阅 MSP432P401R 的数据手册(SLAS829B,第 904 页),EUSCI_A 在 UART 模式下具备以下关键特性,这些特性直接决定了其在工程中的配置逻辑与使用边界:

  • 数据帧格式灵活 :支持 7 位或 8 位数据位长度。这一特性在与某些工业设备或老式协议通信时至关重要,例如 Modbus RTU 常采用 8N1(8 数据位、无校验、1 停止位),而部分传感器可能要求 7E1(7 数据位、偶校验、1 停止位)。MSP432P4 通过寄存器 UCCTLW0 中的 UCD7 位进行选择。
  • 校验机制完备 :支持无校验(None)、奇校验(Odd)和偶校验(Even)三种模式。校验位的生成与验证由硬件自动完成,极大减轻了软件负担,提升了通信可靠性。在噪声较大的工业现场,启用偶校验是提升数据完整性的常用手段。
  • 波特率精度高 :采用双级分频机制——整数分频器(UCBRx)与小数分频器(UCBRFx)。这种设计使得在给定系统时钟频率下,能够计算出更接近目标波特率的分频系数,从而将波特率误差控制在极低水平(通常 < 0.5%),这是保证长距离、高速通信稳定性的物理基础。
  • 中断源丰富 :提供细粒度的中断控制,包括接收中断(RXIFG)、发送中断(TXIFG)、接收错误中断(RXERRIFG)、线路空闲中断(LINIFG)等。这使得软件可以针对不同事件做出精确响应,例如仅在接收到完整一帧数据后触发处理,而非轮询检测。

需要特别注意的是,EUSCI_A 模块的引脚复用(Pin Multiplexing)是通过 GPIO 模块的 SEL0 / SEL1 寄存器实现的。以最常用的 EUSCI_A0 为例,其默认功能引脚为 P1.2 (UCA0RXD)和 P1.3 (UCA0TXD)。在进行任何串口初始化之前,必须先将这两个引脚的 SEL0 SEL1 位正确配置,否则信号将无法被 EUSCI_A0 模块识别。这是一个典型的“先配置 GPIO 复用,再初始化外设”的硬件依赖关系,在工程实践中绝不可颠倒。

1.2 固件库 API 体系与核心函数语义

TI 为 MSP432P4 提供了官方的 DriverLib 库,该库以 C 语言函数封装了底层寄存器操作,是构建可靠应用的基石。DriverLib 的 API 设计遵循清晰的“配置-使能-操作”范式,所有与 EUSCI_A 相关的函数均定义在 eusci_a_uart.h 头文件中。理解每个函数的精确语义与参数约束,是避免运行时异常的关键。

1.2.1 初始化与使能函数族

串口通信的启动始于一个结构化的初始化过程,该过程分为三个明确的步骤,对应三个核心函数:

  • void MAP_UART_initModule(uint32_t baseAddress, const eUSCI_UART_initParam *param)
    此函数负责将 param 结构体中描述的全部通信参数(波特率、数据位、校验、停止位等)写入 EUSCI_A 模块的相应控制寄存器。 baseAddress 参数指定了操作的目标模块,如 EUSCI_A0_BASE EUSCI_A1_BASE 。该函数不涉及任何硬件使能操作,仅完成寄存器配置。

  • void MAP_UART_enableModule(uint32_t baseAddress)
    在完成寄存器配置后,此函数通过置位 UCCTLW0 寄存器中的 UCSWRST 位(软件复位位)来释放 EUSCI_A 模块,使其脱离复位状态并开始工作。这是一个不可逆的操作,必须在 initModule 之后调用。若在未初始化前调用 enableModule ,模块将处于未定义状态,可能导致通信失败。

  • void MAP_UART_enableInterrupt(uint32_t baseAddress, uint8_t mask)
    此函数用于开启指定的中断源。 mask 参数是一个位掩码,可组合多个中断标志,如 EUSCI_A_UART_RECEIVE_INTERRUPT (接收中断)或 EUSCI_A_UART_TRANSMIT_INTERRUPT (发送中断)。它仅修改中断使能寄存器( UCIE ),并不影响中断标志寄存器( UCIFG )的状态。中断的最终触发,取决于硬件事件的发生与中断使能位的共同作用。

1.2.2 数据收发函数族

在中断模式下,数据的收发主要通过以下两个函数完成,它们是中断服务程序(ISR)中的核心操作:

  • uint8_t MAP_UART_receiveData(uint32_t baseAddress)
    此函数从接收缓冲寄存器( UCAxRXBUF )中读取一个字节的数据。其关键行为在于: 读取操作本身会自动清除接收中断标志位(RXIFG) 。这是 MSP432P4 硬件的一个重要设计特性,意味着在 ISR 中执行 receiveData 后,无需再手动向 UCIFG 寄存器写入特定值来清零 RXIFG。若忽略此特性而额外添加清零代码,不仅冗余,还可能因时序问题导致中断丢失。

  • void MAP_UART_transmitData(uint32_t baseAddress, uint8_t transmitData)
    此函数将一个字节的数据写入发送缓冲寄存器( UCAxTXBUF )。当发送缓冲区为空时,该操作会立即触发发送,并置位发送中断标志(TXIFG)。在中断驱动模型中,此函数通常只在需要发送数据时被主程序调用,而实际的“发送完成”通知则由 TXIFG 中断提供。

1.2.3 中断控制函数族

除了在初始化阶段开启中断,DriverLib 还提供了对 NVIC(Nested Vectored Interrupt Controller)的直接控制函数,用于全局中断的开关:

  • void MAP_Interrupt_enableInterrupt(uint32_t interruptVector)
    此函数用于使能指定中断向量对应的 NVIC 通道。对于 EUSCI_A0,其向量名为 INT_EUSCIA0 。该函数是开启“总中断”的最后一步,它确保当 EUSCI_A0 模块产生 RXIFG 或 TXIFG 等有效中断请求时,CPU 能够响应并跳转至对应的 ISR。

  • void MAP_Interrupt_disableInterrupt(uint32_t interruptVector)
    作为 enableInterrupt 的反向操作,此函数用于禁用指定中断向量。在调试或需要临时屏蔽串口中断的场景下,此函数非常有用。

理解上述函数族的调用顺序与依赖关系,是构建健壮串口驱动的第一步。一个典型的、符合硬件时序的初始化序列应为: UART_initModule UART_enableModule UART_enableInterrupt Interrupt_enableInterrupt

1.3 串口硬件配置全流程:从时钟到中断服务

一个完整的串口通信功能的实现,是一个涉及系统时钟、GPIO、外设寄存器和中断控制器的多层级协同过程。下面将以 EUSCI_A0 为例,详细拆解这八个关键步骤,并阐明每一步背后的工程原理与技术考量。

1.3.1 系统时钟配置:为波特率精度奠基

串口通信的波特率(Baud Rate)并非一个独立参数,而是由系统主时钟(MCLK)经由 EUSCI_A 模块内部的分频器计算得出。因此, 精确、稳定的系统时钟是串口通信可靠性的根本前提 。MSP432P4 支持多种时钟源,包括内部 VLO、REFO、DCO,以及外部晶振(XT1、XT2)。在电赛等对性能有要求的场景中,普遍采用外部 48MHz 晶振(XT2)作为 MCLK 的源头,以获得最高的波特率计算精度和最低的抖动。

时钟树配置的核心在于 CS_setDCOFreq CS_initClockSignal 函数。首先, CS_setDCOFreq(CS_DCO_FREQUENCY_48MHZ) 将 DCO(Digitally Controlled Oscillator)配置为 48MHz 输出;随后, CS_initClockSignal(CS_MCLK, CS_CLOCKSOURCE_DCOCLK_SELECT, CS_CLOCK_DIVIDER_1) 将 DCOCLK 经过 1 分频后作为 MCLK 的输入。整个过程需确保 XT2 晶振已稳定起振,这通常通过查询 CS_getXT2DriveMode() 的返回值来确认。

选择 48MHz 作为 MCLK,其工程意义在于:它是一个高度合数(48 = 2⁴ × 3),能被绝大多数常用波特率(如 9600、115200、921600)整除或近似整除,从而最小化波特率误差。例如,对于 115200 波特率,48MHz 时钟下计算出的理想分频系数为 48,000,000 / 115200 ≈ 416.666…,利用 EUSCI_A 的整数+小数分频机制,可精确配置为 UCBRx = 416 , UCBRFx = 17 (对应 0.666…),最终误差仅为 0.0015%,远低于 UART 通信允许的最大 2% 误差阈值。

1.3.2 GPIO 引脚复用配置:建立物理连接通道

在时钟就绪后,必须将物理引脚与 EUSCI_A0 模块的功能绑定。MSP432P4 的 GPIO 引脚具有多重功能,其具体角色由 P1SEL0 P1SEL1 寄存器决定。对于 P1.2 P1.3 ,其 UART 功能对应的复用值为 0b10 (即 SEL0=1 , SEL1=0 )。

// 配置 P1.2 (UCA0RXD) 和 P1.3 (UCA0TXD) 为第二功能
GPIO_setAsPeripheralModuleFunctionInputPin(GPIO_PORT_P1, GPIO_PIN2, GPIO_PRIMARY_MODULE_FUNCTION);
GPIO_setAsPeripheralModuleFunctionOutputPin(GPIO_PORT_P1, GPIO_PIN3, GPIO_PRIMARY_MODULE_FUNCTION);

这段代码的本质,是向 P1SEL0 寄存器的 bit2 和 bit3 写入 1 ,并向 P1SEL1 的对应位写入 0 。若此步骤遗漏或配置错误, P1.2 P1.3 将保持为普通 GPIO 输入/输出模式,EUSCI_A0 模块无法在其上收发任何信号,此时无论软件如何配置,串口都将表现为“无响应”。

1.3.3 UART 参数结构体初始化:定义通信契约

DriverLib 的 eUSCI_UART_initParam 结构体是串口通信参数的“契约书”,它将抽象的通信协议要求映射为具体的寄存器值。一个典型配置如下:

eUSCI_UART_initParam param = {0};
param.selectClockSource = EUSCI_A_UART_CLOCKSOURCE_SMCLK; // 选择 SMCLK 作为时钟源
param.clockPrescalar = 31;                                 // 整数分频系数 UCBRx
param.firstModReg = 8;                                     // 小数分频系数 UCBRFx
param.secondModReg = 0;                                    // 调制寄存器 UCOS16 (0: oversampling, 1: standard)
param.parity = EUSCI_A_UART_NO_PARITY;                    // 无校验
param.msborLsbFirst = EUSCI_A_UART_LSB_FIRST;             // 低位在前
param.numberofStopBits = EUSCI_A_UART_ONE_STOP_BIT;       // 1 位停止位
param.uartMode = EUSCI_A_UART_MODE;                       // UART 模式
param.overSampling = EUSCI_A_UART_LOW_FREQUENCY_BAUDRATE;  // 低频波特率模式

其中, clockPrescalar firstModReg 是最关键的两个字段,它们的值必须根据所选时钟源频率和目标波特率精确计算。TI 官方提供了在线波特率计算器(位于 MSP432P4 SDK 的 UART 示例文档中),输入 48MHz 115200 ,即可得到 UCBRx=416 , UCBRFx=17 。然而,DriverLib 的 clockPrescalar 字段最大值为 255,因此对于 48MHz 时钟,必须将时钟源切换为 SMCLK (Sub-Main Clock),其默认频率为 MCLK/4 = 12MHz 。此时, 12,000,000 / 115200 ≈ 104.166... ,可配置为 clockPrescalar = 104 , firstModReg = 4 (对应 0.166…),完美匹配。

1.3.4 UART 模块初始化与使能:激活硬件引擎

完成参数结构体的填充后,即可调用 DriverLib 函数将配置写入硬件:

MAP_UART_initModule(EUSCI_A0_BASE, &param);     // 写入所有寄存器
MAP_UART_enableModule(EUSCI_A0_BASE);           // 清除 UCSWRST,启动模块

这两行代码是硬件初始化的“临门一脚”。 initModule 函数内部会依次配置 UCCTLW0 (控制寄存器)、 UCBRx (整数分频)、 UCBRFx (小数分频)等关键寄存器。 enableModule 则通过向 UCCTLW0 UCSWRST 位写 0 来完成模块的软复位释放。 必须严格遵守此先后顺序 。如果先调用 enableModule ,再调用 initModule ,由于模块已处于工作状态,后续对 UCCTLW0 的写入将被忽略,导致波特率等参数配置失效,通信必然失败。

1.3.5 中断使能与 NVIC 配置:构建事件响应骨架

要让 CPU 能够响应串口事件,必须在两个层面开启中断:

// 1. 在 EUSCI_A0 模块内,使能接收中断
MAP_UART_enableInterrupt(EUSCI_A0_BASE, EUSCI_A_UART_RECEIVE_INTERRUPT);

// 2. 在 NVIC 中,使能 EUSCI_A0 的中断向量
MAP_Interrupt_enableInterrupt(INT_EUSCIA0);

第一行代码设置了 UCIE 寄存器的 UCRXIE 位,表示当 UCIFG 寄存器的 UCRXIFG 位被硬件置位(即接收到一个字节)时,模块将向 NVIC 发出一个中断请求(IRQ)。第二行代码则是在 NVIC 层面,将这个 IRQ 的使能位( EN[0] )置位,允许 CPU 响应。这两个步骤缺一不可。若只做第一步,模块会产生 IRQ,但 CPU 会将其忽略;若只做第二步,NVIC 会等待 IRQ,但模块永远不会发出,结果同样是“静默”。

1.3.6 编写中断服务程序(ISR):实现数据闭环

中断服务程序是整个中断驱动模型的灵魂。对于 EUSCI_A0 的接收中断,其 ISR 必须完成“读取-处理-回传”这一闭环。一个简洁而健壮的 ISR 如下所示:

void EUSCIA0_IRQHandler(void)
{
    uint32_t status = MAP_UART_getInterruptStatus(EUSCI_A0_BASE,
                                                   EUSCI_A_UART_RECEIVE_INTERRUPT);

    if (status & EUSCI_A_UART_RECEIVE_INTERRUPT)
    {
        uint8_t rxData = MAP_UART_receiveData(EUSCI_A0_BASE); // 自动清除 RXIFG
        MAP_UART_transmitData(EUSCI_A0_BASE, rxData);         // 回传接收到的数据
    }
}

此 ISR 的精妙之处在于:
- 状态查询 :使用 UART_getInterruptStatus 查询中断状态,而非直接检查 UCIFG 。这是一种更安全的编程习惯,因为它避免了在多中断源共用一个向量时可能出现的竞争条件。
- 自动清零 receiveData 的调用不仅获取了数据,也自动清除了 RXIFG ,确保下一次接收能再次触发中断。
- 即时响应 :数据在接收到的瞬间即被回传,实现了最低延迟的“回环测试”,是验证硬件链路畅通的最直接方法。

1.3.7 主循环逻辑:维持系统心跳

在中断驱动模型中,主循环( main 函数的 while(1) )的角色被极大简化。它不再需要轮询 UCRXIFG 位,而是可以专注于其他任务,如传感器采样、算法计算或 LED 控制。一个典型的 main 函数结构如下:

int main(void)
{
    // 1. 禁用看门狗
    MAP_WDT_A_holdTimer();

    // 2. 配置系统时钟 (48MHz)
    initSystemClock();

    // 3. 配置 GPIO 复用
    initGPIO();

    // 4. 初始化并使能 UART
    initUART();

    // 5. 使能中断
    enableUARTInterrupts();

    // 6. 主循环:系统在此处“休眠”,等待中断唤醒
    while(1)
    {
        __sleep();
        __no_operation(); // 用于调试断点
    }
}

__sleep() 指令将 CPU 置于低功耗的 LPM0 模式,此时 CPU 核心暂停,但所有外设(包括 EUSCI_A0)仍在运行。一旦串口接收到数据并触发中断,CPU 会立即退出睡眠,执行 ISR,处理完后再回到 __sleep() 。这种“事件驱动”的编程范式,极大地降低了系统功耗,是电池供电设备的必备特性。

1.4 对接 C 标准库:重定向 printf scanf

在嵌入式开发中,直接使用 MAP_UART_transmitData MAP_UART_receiveData 进行单字节操作虽然高效,但对于复杂的调试信息输出(如打印变量值、字符串格式化)或用户交互(如命令行输入),其编程效率极低。C 标准库提供的 printf scanf 函数,凭借其强大的格式化能力和广泛的开发者认知,成为首选。然而,标准库的 printf / scanf 并不知道如何与 MSP432P4 的 EUSCI_A0 通信,这就需要进行“重定向”(Redirection)。

重定向的核心思想是: 为标准库提供一组底层 I/O 函数,使其将所有的字符输出/输入操作,委托给我们编写的、与硬件打交道的函数 。具体而言,需要实现两个函数:

  • _write printf 在内部调用此函数来输出每一个字符。
  • _read scanf 在内部调用此函数来读取每一个输入字符。

一个符合 MSP432P4 工程实践的重定向实现如下:

#include <sys/_stdint.h>
#include <unistd.h>

// _write 函数:重定向 printf 输出
int _write(int file, char *ptr, int len)
{
    int i;
    if (file == STDOUT_FILENO || file == STDERR_FILENO)
    {
        for (i = 0; i < len; i++)
        {
            // 等待发送缓冲区空闲
            while (!MAP_UART_getInterruptStatus(EUSCI_A0_BASE,
                                                EUSCI_A_UART_TRANSMIT_INTERRUPT));
            MAP_UART_transmitData(EUSCI_A0_BASE, *ptr++);
        }
        return len;
    }
    errno = EBADF;
    return -1;
}

// _read 函数:重定向 scanf 输入
int _read(int file, char *ptr, int len)
{
    int i;
    if (file == STDIN_FILENO)
    {
        for (i = 0; i < len; i++)
        {
            // 等待接收中断发生(此处为简化,实际应使用阻塞或超时)
            while (!MAP_UART_getInterruptStatus(EUSCI_A0_BASE,
                                                EUSCI_A_UART_RECEIVE_INTERRUPT));
            *ptr++ = MAP_UART_receiveData(EUSCI_A0_BASE);
        }
        return len;
    }
    errno = EBADF;
    return -1;
}

此实现的关键在于 _write 函数中的 while 循环。它通过不断查询 EUSCI_A_UART_TRANSMIT_INTERRUPT 状态,确保在调用 transmitData 之前,发送缓冲区( UCAxTXBUF )是空的。这是因为 transmitData 函数只是将数据写入缓冲区,而非等待数据被完全移出移位寄存器。如果缓冲区已满,写入操作会被丢弃,导致输出字符丢失。 _read 函数同理,它等待接收中断,确保有数据可读。

1.5 微库(MicroLIB)配置:规避半主机陷阱

在 Keil MDK-ARM 工具链中,标准 C 库(ARM C Library)默认启用了“半主机”(Semihosting)功能。半主机是一种调试机制,它允许目标代码在运行时通过调试器(如 J-Link)向主机(PC)发起系统调用,例如 printf 的输出会直接显示在 Keil 的 Debug Log 窗口中。然而,在真实硬件上,尤其是在没有调试器连接的脱机运行场景下,半主机调用会导致程序死锁在 BKPT 指令上,因为没有任何调试器来响应这个请求。

为了解决这个问题,必须禁用半主机,并使用一个更轻量、更适合裸机环境的 C 库—— 微库(MicroLIB) 。MicroLIB 是 ARM 提供的一个精简版 C 库,它移除了所有依赖于操作系统或调试器的特性(如半主机),并将 printf / scanf 的底层 I/O 完全交由用户实现,这正是我们前面编写的 _write _read 函数所承担的角色。

在 Keil uVision 中启用 MicroLIB 的步骤如下:
1. 打开项目选项(Options for Target)。
2. 切换到 “Target” 选项卡。
3. 在 “Code Generation” 区域,勾选 “Use MicroLIB” 复选框。

此配置是成功对接标准库的 必要前提 。若未勾选,即使 _write _read 函数编写得再完美,链接器也会优先链接半主机版本的 printf ,导致程序在 printf 处崩溃。这是一个在电赛准备中极易被忽视、却会导致整个调试流程瘫痪的关键配置点。

1.6 工程实践:完整的主程序与调试验证

将前述所有环节整合,一个可用于电赛快速验证的完整主程序框架如下。该框架封装了串口初始化,并在主循环中演示了 printf scanf 的典型用法。

#include "driverlib.h"
#include <stdio.h>
#include <string.h>

// 串口初始化封装函数
void initUART(uint32_t baudrate)
{
    // ... (省略时钟、GPIO、参数结构体配置,见前述章节)
    MAP_UART_initModule(EUSCI_A0_BASE, &param);
    MAP_UART_enableModule(EUSCI_A0_BASE);
    MAP_UART_enableInterrupt(EUSCI_A0_BASE, EUSCI_A_UART_RECEIVE_INTERRUPT);
    MAP_Interrupt_enableInterrupt(INT_EUSCIA0);
}

int main(void)
{
    MAP_WDT_A_holdTimer();
    initSystemClock();      // 48MHz
    initGPIO();             // P1.2/P1.3 复用
    initUART(115200);       // 初始化 UART,波特率 115200

    // 使用 printf 打印欢迎信息
    printf("\r\nMSP432P4 UART Demo Started!\r\n");
    printf("Enter your command: ");

    char inputBuffer[64];
    while(1)
    {
        // 使用 scanf 读取一行输入
        if (scanf("%63s", inputBuffer) == 1)
        {
            printf("You entered: %s\r\n", inputBuffer);
            // 可在此处添加命令解析逻辑
            printf("Enter next command: ");
        }
        __sleep();
    }
}

调试验证步骤
1. 硬件连接 :使用 USB 线将 LaunchPad 连接到 PC。Windows 设备管理器中会识别出一个名为 “Texas Instruments XDS110 Embedded Debug Probe (COMxx)” 的端口,记下 COMxx (如 COM14 )。
2. 串口调试助手配置 :打开任意串口调试工具(如 XCOM、SSCOM),选择对应的 COMxx 端口,波特率设置为 115200 ,数据位 8 ,停止位 1 ,无校验,流控 None
3. 下载与运行 :在 Keil 中编译、下载程序到 LaunchPad。
4. 交互测试 :在调试助手中输入任意字符串(如 HELLO ),并点击“发送”(确保勾选“发送新行”)。观察调试助手窗口,应能看到程序回显的 You entered: HELLO

若一切正常,说明从硬件引脚、时钟配置、寄存器初始化、中断使能到标准库重定向的整个链条均已打通。此时,你已拥有了一个功能完备、可直接用于电赛项目的串口通信基础框架。

在实际项目中,我曾遇到过一个棘手的问题:在使用 scanf 读取带空格的字符串(如 "SET TEMP 25" )时, %s 格式符只会读取到第一个空格前的部分( "SET" ),导致命令解析失败。解决方法是改用 fgets(inputBuffer, sizeof(inputBuffer), stdin) ,它能读取整行,包括空格,并自动在末尾添加 \0 。这个细节虽小,但在构建复杂的人机交互界面时,却是决定成败的关键。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐