第三篇、CubeMX | FreeModbus 从机 DMA 发送串口数据(无OS,裸机移植)
·
文章目录
一、写在前面
1.1 文章回顾
该文章对第二篇、使用DMA优化FreeModbus从机数据接收(无OS,裸机移植)的所提出的代码弊端进行优化实现
1.2 FreeModbus从机 数据发送优化版例程下载
1. 芯片:STM32F103C8T6
2. 串口引脚:USART1_TX(PB6) 、USART1_RX(PB7)
3. RS485控制引脚:DIR1(PB8)
4. 采用DMA+接收空闲中断+发送完成中断的串口数据的接收(中断量最少、效率高)
5. 使用STM32CubeMX生成的Hal库程序
6. 未使用RTOS
二、生成CubeMX工程
使用【第二篇、使用DMA优化FreeModbus从机数据接收(无OS,裸机移植)】工程基础上,进行以下改动
1、使能DMA

三、FreeModbus文件修改
1、portserial.c
1、修改vMBPortSerialEnable函数实现
2、新增函数xMBPortSerialPutFrame,帧发送函数,替代原字符发送函数
3、新增DMA1_Channel4_IRQHandler和HAL_UART_TxCpltCallback函数,处理帧发送结束
/*
* FreeModbus Libary: BARE Port
* Copyright (C) 2006 Christian Walter <wolti@sil.at>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
*
* File: $Id$
*/
#include "port.h"
/* ----------------------- Modbus includes ----------------------------------*/
#include "mb.h"
#include "mbport.h"
/* ----------------------- serial port --------------------------------------*/
extern UART_HandleTypeDef huart1;
extern DMA_HandleTypeDef hdma_usart1_rx; // DMA 句柄
extern DMA_HandleTypeDef hdma_usart1_tx; // 发送 DMA 句柄
#define SLAVE_PORT huart1
#define rs485_dir_recv() HAL_GPIO_WritePin(DIR1_GPIO_Port,DIR1_Pin,GPIO_PIN_RESET)
#define rs485_dir_send() HAL_GPIO_WritePin(DIR1_GPIO_Port,DIR1_Pin,GPIO_PIN_SET)
volatile uint8_t ucLocalSlaveAddress = 1; //本机modbus从地址
#define MB_SER_PDU_SIZE_MAX 256 /*!< Modbus RTU 帧的最大尺寸 */
extern volatile UCHAR ucRTUBuf[MB_SER_PDU_SIZE_MAX]; // 用于存储接收的 Modbus RTU 数据的缓冲区
extern volatile USHORT usRcvBufferPos; // 接收缓冲区中已存储数据的位置
extern volatile USHORT usSndBufferCount;
/* ----------------------- static functions ---------------------------------*/
static void prvvUARTTxReadyISR( void );
/* ----------------------- Start implementation -----------------------------*/
void
vMBPortSerialEnable( BOOL xRxEnable, BOOL xTxEnable )
{
/* If xRXEnable enable serial receive interrupts. If xTxENable enable
* transmitter empty interrupts.
*/
if (xRxEnable)
{
rs485_dir_recv();
if (HAL_UART_Receive_DMA(&SLAVE_PORT, (uint8_t *)ucRTUBuf, MB_SER_PDU_SIZE_MAX) != HAL_OK)
{
// 处理错误
}
__HAL_UART_ENABLE_IT(&SLAVE_PORT, UART_IT_IDLE); // 使能空闲中断
}
else
{
rs485_dir_send();
HAL_UART_DMAStop(&SLAVE_PORT); // 停止 DMA 接收
__HAL_UART_DISABLE_IT(&SLAVE_PORT, UART_IT_IDLE); // 禁止空闲中断
}
if (xTxEnable)
{
__HAL_UART_ENABLE_IT(&SLAVE_PORT, UART_IT_TC); // 使能帧发送完成中断
}
else
{
__HAL_UART_DISABLE_IT(&SLAVE_PORT, UART_IT_TC); // 禁止帧发送完成中断
}
}
BOOL
xMBPortSerialInit( UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity )
{
SLAVE_PORT.Instance = USART1;
SLAVE_PORT.Init.BaudRate = ulBaudRate;
SLAVE_PORT.Init.StopBits = UART_STOPBITS_1;
SLAVE_PORT.Init.Mode = UART_MODE_TX_RX;
SLAVE_PORT.Init.HwFlowCtl = UART_HWCONTROL_NONE;
SLAVE_PORT.Init.OverSampling = UART_OVERSAMPLING_16;
switch(eParity)
{
// 奇校验
case MB_PAR_ODD:
SLAVE_PORT.Init.Parity = UART_PARITY_ODD;
SLAVE_PORT.Init.WordLength = UART_WORDLENGTH_9B; // 带奇偶校验数据位为9bits
break;
// 偶校验
case MB_PAR_EVEN:
SLAVE_PORT.Init.Parity = UART_PARITY_EVEN;
SLAVE_PORT.Init.WordLength = UART_WORDLENGTH_9B; // 带奇偶校验数据位为9bits
break;
// 无校验
default:
SLAVE_PORT.Init.Parity = UART_PARITY_NONE;
SLAVE_PORT.Init.WordLength = UART_WORDLENGTH_8B; // 无奇偶校验数据位为8bits
break;
}
// 初始化 DMA 接收
SLAVE_PORT.hdmarx = &hdma_usart1_rx;
hdma_usart1_rx.Instance = DMA1_Channel5; // 根据实际情况修改
hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_usart1_rx.Init.Mode = DMA_NORMAL;
hdma_usart1_rx.Init.Priority = DMA_PRIORITY_LOW;
if (HAL_DMA_Init(&hdma_usart1_rx) != HAL_OK)
{
return FALSE;
}
__HAL_LINKDMA(&SLAVE_PORT, hdmarx, hdma_usart1_rx);
//初始化 DMA 发送
SLAVE_PORT.hdmatx = &hdma_usart1_tx;
hdma_usart1_tx.Instance = DMA1_Channel4; // 根据实际情况修改
hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_usart1_tx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_usart1_tx.Init.MemInc = DMA_MINC_ENABLE;
hdma_usart1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_usart1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_usart1_tx.Init.Mode = DMA_CIRCULAR;
hdma_usart1_tx.Init.Priority = DMA_PRIORITY_LOW;
if (HAL_DMA_Init(&hdma_usart1_tx) != HAL_OK)
{
return FALSE;
}
__HAL_LINKDMA(&SLAVE_PORT, hdmatx, hdma_usart1_tx);
return HAL_UART_Init(&SLAVE_PORT) == HAL_OK ? TRUE : FALSE;
}
BOOL
xMBPortSerialRestart(void)
{
HAL_UART_DMAStop(&SLAVE_PORT);
vMBPortSerialEnable(TRUE, FALSE);
return TRUE;
}
BOOL xMBPortSerialPutFrame(UCHAR *pucFrame, USHORT usLength)
{
// 1. 参数合法性检查(避免缓冲区溢出或无效长度)
if (pucFrame == NULL || usLength == 0 || usLength > MB_SER_PDU_SIZE_MAX)
{
return FALSE;
}
// 2. 检查串口状态:确保当前未处于接收/发送中(避免冲突)
if (HAL_UART_GetState(&SLAVE_PORT) != HAL_UART_STATE_READY)
{
return FALSE;
}
// 3. 重置DMA通道(关键修复点)
if (HAL_UART_DMAStop(&SLAVE_PORT) != HAL_OK)
return FALSE;
// 4. 启动 DMA 批量发送
if (HAL_UART_Transmit_DMA(&SLAVE_PORT, (uint8_t *)pucFrame, usLength) != HAL_OK)
{
// DMA 发送启动失败,重置状态
return FALSE;
}
return TRUE;
}
BOOL
xMBPortSerialPutByte( CHAR ucByte )
{
/* Put a byte in the UARTs transmit buffer. This function is called
* by the protocol stack if pxMBFrameCBTransmitterEmpty( ) has been
* called. */
USART1->DR = ucByte;
return TRUE;
}
/* Create an interrupt handler for the transmit buffer empty interrupt
* (or an equivalent) for your target processor. This function should then
* call pxMBFrameCBTransmitterEmpty( ) which tells the protocol stack that
* a new character can be sent. The protocol stack will then call
* xMBPortSerialPutByte( ) to send the character.
*/
static void prvvUARTTxReadyISR( void )
{
pxMBFrameCBTransmitterEmpty( );
}
/*
* Add your serial port interrupt handler
*
*/
// 定义结构体来记录不同类型中断的次数
typedef struct {
uint32_t tc_irq_count; //发送完成中断
uint32_t idle_irq_count; // 空闲中断次数
uint32_t txe_irq_count; // 发送空中断次数
uint32_t rxne_irq_count; // 接收缓冲区非空中断次数
uint32_t error_irq_count; // 错误中断次数
uint32_t dma_irq_count;
} UART1_IRQ_Counters;
// 全局变量,用于存储中断计数
UART1_IRQ_Counters uart1_irq_counters = {0, 0, 0, 0};
// 原有的中断处理函数,添加中断计数逻辑
void USART1_IRQHandler(void)
{
if (__HAL_UART_GET_FLAG(&SLAVE_PORT, UART_FLAG_IDLE)) // 空闲中断标记被置位
{
uart1_irq_counters.idle_irq_count++; // 空闲中断次数加 1
__HAL_UART_CLEAR_IDLEFLAG(&SLAVE_PORT); // 清除空闲中断标记
usRcvBufferPos = MB_SER_PDU_SIZE_MAX - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 计算接收到的数据长度
// 将接收到的从机地址存储到指定位置
if (ucRTUBuf[0] == ucLocalSlaveAddress || ucRTUBuf[0] == MB_ADDRESS_BROADCAST)
{
xMBPortEventPost(EV_FRAME_RECEIVED); // 通知协议栈有新帧接收
}
else
{
// 停止 DMA 接收
HAL_UART_DMAStop(&SLAVE_PORT);
vMBPortSerialEnable(TRUE, FALSE);
}
}
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart == &SLAVE_PORT)
{
HAL_UART_DMAStop(&SLAVE_PORT);
while (__HAL_UART_GET_FLAG(&SLAVE_PORT, UART_FLAG_TC) == RESET);
__HAL_UART_CLEAR_FLAG(&SLAVE_PORT, UART_FLAG_TC);
rs485_dir_recv();
__HAL_UART_DISABLE_IT(&SLAVE_PORT, UART_IT_TC);
usSndBufferCount = 0;
pxMBFrameCBTransmitterEmpty();
}
}
void DMA1_Channel4_IRQHandler()
{
HAL_DMA_IRQHandler(&hdma_usart1_tx);
uart1_irq_counters.dma_irq_count ++;
}
2、mbrtu.c
1、修改xMBRTUTransmitFSM函数,增加状态STATE_TX_WAIT,等待DMA发送结束,该函数被中断回调函数调用,以及触发发送器时调用。
2、修改eMBRTUSend函数,直接调用状态机进行发送数据
3、pucSndBufferCur不再使用volatile修饰,因为DMA不接受这种类型的内存
/*
* FreeModbus Libary: A portable Modbus implementation for Modbus ASCII/RTU.
* Copyright (c) 2006-2018 Christian Walter <cwalter@embedded-solutions.at>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. The name of the author may not be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
/* ----------------------- System includes ----------------------------------*/
#include "stdlib.h"
#include "string.h"
/* ----------------------- Platform includes --------------------------------*/
#include "port.h"
/* ----------------------- Modbus includes ----------------------------------*/
#include "mb.h"
#include "mbrtu.h"
#include "mbframe.h"
#include "mbcrc.h"
#include "mbport.h"
/* ----------------------- Defines ------------------------------------------*/
#define MB_SER_PDU_SIZE_MIN 4 /*!< Modbus RTU 帧的最小尺寸 */
#define MB_SER_PDU_SIZE_MAX 256 /*!< Modbus RTU 帧的最大尺寸 */
#define MB_SER_PDU_SIZE_CRC 2 /*!< PDU 中 CRC 字段的大小 */
#define MB_SER_PDU_ADDR_OFF 0 /*!< 从机地址在串行 PDU 中的偏移量 */
#define MB_SER_PDU_PDU_OFF 1 /*!< Modbus-PDU 在串行 PDU 中的偏移量 */
/* ----------------------- Type definitions ---------------------------------*/
typedef enum
{
STATE_RX_INIT, /*!< 接收器处于初始状态 */
STATE_RX_IDLE, /*!< 接收器处于空闲状态 */
STATE_RX_RCV, /*!< 正在接收帧 */
STATE_RX_ERROR /*!< 如果帧无效 */
} eMBRcvState;
typedef enum
{
STATE_TX_IDLE, /*!< 发送器处于空闲状态 */
STATE_TX_XMIT, /*!< 发送器处于传输状态 */
STATE_TX_WAIT /*!< 发送器等待DMA发送结束*/
} eMBSndState;
/* ----------------------- Static variables ---------------------------------*/
volatile eMBSndState eSndState; // 发送器状态,volatile 确保在多线程或中断环境下能正确访问
static volatile eMBRcvState eRcvState; // 接收器状态,volatile 确保在多线程或中断环境下能正确访问
volatile UCHAR ucRTUBuf[MB_SER_PDU_SIZE_MAX]; // 用于存储接收的 Modbus RTU 数据的缓冲区
static UCHAR *pucSndBufferCur; // 指向发送缓冲区当前位置的指针
volatile USHORT usSndBufferCount; // 发送缓冲区中剩余要发送的字节数
volatile USHORT usRcvBufferPos; // 接收缓冲区中已存储数据的位置
/* ----------------------- Start implementation -----------------------------*/
eMBErrorCode
eMBRTUInit( UCHAR ucSlaveAddress, UCHAR ucPort, ULONG ulBaudRate, eMBParity eParity )
{
eMBErrorCode eStatus = MB_ENOERR;
( void )ucSlaveAddress;
ENTER_CRITICAL_SECTION( );
/* Modbus RTU uses 8 Databits. */
if( xMBPortSerialInit( ucPort, ulBaudRate, 8, eParity ) != TRUE )
{
eStatus = MB_EPORTERR;
}
else
{
}
EXIT_CRITICAL_SECTION( );
return eStatus;
}
void
eMBRTUStart( void )
{
ENTER_CRITICAL_SECTION( ); // 进入临界区
eRcvState = STATE_RX_IDLE; // 设置接收器状态为初始状态
vMBPortSerialEnable( TRUE, FALSE ); // 使能串口接收,禁用串口发送
EXIT_CRITICAL_SECTION( ); // 退出临界区
}
void
eMBRTUStop( void )
{
ENTER_CRITICAL_SECTION( ); // 进入临界区
vMBPortSerialEnable( FALSE, FALSE ); // 禁用串口接收和发送
EXIT_CRITICAL_SECTION( ); // 退出临界区
}
eMBErrorCode
eMBRTUReceive( UCHAR * pucRcvAddress, UCHAR ** pucFrame, USHORT * pusLength )
{
BOOL xFrameReceived = FALSE; // 标记是否接收到有效帧
eMBErrorCode eStatus = MB_ENOERR; // 错误状态码,初始设为无错误
ENTER_CRITICAL_SECTION( ); // 进入临界区
assert( usRcvBufferPos < MB_SER_PDU_SIZE_MAX ); // 断言确保接收缓冲区位置在有效范围内
/* 长度和 CRC 检查 */
if( ( usRcvBufferPos >= MB_SER_PDU_SIZE_MIN )
&& ( usMBCRC16( ( UCHAR * ) ucRTUBuf, usRcvBufferPos ) == 0 ) )
{
/* 保存地址字段。所有帧都传递到上层,
* 是否使用该帧的决策在那里进行。
*/
*pucRcvAddress = ucRTUBuf[MB_SER_PDU_ADDR_OFF]; // 将接收到的从机地址存储到指定位置
/* Modbus-PDU 的总长度是 Modbus 串行线路 PDU 减去
* 地址字段大小和 CRC 校验和。
*/
*pusLength = ( USHORT )( usRcvBufferPos - MB_SER_PDU_PDU_OFF - MB_SER_PDU_SIZE_CRC ); // 计算有效数据长度
/* 将 Modbus PDU 的起始位置返回给调用者。 */
*pucFrame = ( UCHAR * ) & ucRTUBuf[MB_SER_PDU_PDU_OFF]; // 设置指向有效数据的指针
xFrameReceived = TRUE; // 标记接收到有效帧
}
else
{
eStatus = MB_EIO; // 如果帧无效,设置错误状态为输入输出错误
}
EXIT_CRITICAL_SECTION( ); // 退出临界区
return eStatus; // 返回错误状态码
}
eMBErrorCode
eMBRTUSend( UCHAR ucSlaveAddress, const UCHAR * pucFrame, USHORT usLength )
{
eMBErrorCode eStatus = MB_ENOERR; // 错误状态码,初始设为无错误
USHORT usCRC16; // 用于存储 CRC16 校验和
ENTER_CRITICAL_SECTION( ); // 进入临界区
/* 检查接收器是否仍处于空闲状态。如果不是,说明我们处理接收到的帧太慢,
* 并且主机在网络上发送了另一个帧。我们必须中止发送该帧。
*/
if( eRcvState == STATE_RX_IDLE )
{
/* Modbus-PDU 之前的第一个字节是从机地址。 */
pucSndBufferCur = ( UCHAR * ) pucFrame - 1; // 设置发送缓冲区指针指向从机地址位置
usSndBufferCount = 1; // 初始化发送缓冲区计数为 1(从机地址占 1 字节)
/* 现在将 Modbus-PDU 复制到 Modbus 串行线路 PDU 中。 */
pucSndBufferCur[MB_SER_PDU_ADDR_OFF] = ucSlaveAddress; // 将从机地址存储到发送缓冲区
usSndBufferCount += usLength; // 更新发送缓冲区计数为从机地址和有效数据长度之和
/* 计算 Modbus 串行线路 PDU 的 CRC16 校验和。 */
usCRC16 = usMBCRC16( ( UCHAR * ) pucSndBufferCur, usSndBufferCount ); // 计算 CRC16 校验和
ucRTUBuf[usSndBufferCount++] = ( UCHAR )( usCRC16 & 0xFF ); // 将 CRC16 低字节存储到缓冲区
ucRTUBuf[usSndBufferCount++] = ( UCHAR )( usCRC16 >> 8 ); // 将 CRC16 高字节存储到缓冲区
/* 激活发送器。 */
eSndState = STATE_TX_XMIT; // 设置发送器状态为传输状态
xMBRTUTransmitFSM();
}
else
{
eStatus = MB_EIO; // 如果接收器不处于空闲状态,设置错误状态为输入输出错误
}
EXIT_CRITICAL_SECTION( ); // 退出临界区
return eStatus; // 返回错误状态码
}
BOOL
xMBRTUReceiveFSM( void )
{
BOOL xTaskNeedSwitch = FALSE; // 标记是否需要任务切换
assert( eSndState == STATE_TX_IDLE ); // 断言确保发送器处于空闲状态
switch ( eRcvState )
{
case STATE_RX_INIT:
eRcvState = STATE_RX_IDLE; // 直接切换到空闲状态
break;
/* 在错误状态下,我们等待直到损坏的帧中的所有字符都被传输。 */
case STATE_RX_ERROR:
eRcvState = STATE_RX_IDLE; // 直接切换到空闲状态
break;
case STATE_RX_IDLE:
// 无需处理,等待空闲中断触发接收完成事件
break;
case STATE_RX_RCV:
// 无需处理,等待空闲中断触发接收完成事件
break;
}
return xTaskNeedSwitch; // 返回是否需要任务切换的标记
}
BOOL
xMBRTUTransmitFSM( void )
{
BOOL xNeedPoll = FALSE; // 标记是否需要轮询
static uint32_t ulTxTimeout = 0;
assert( eRcvState == STATE_RX_IDLE ); // 断言确保接收器处于空闲状态
switch ( eSndState )
{
/* 如果发送器处于空闲状态,我们不应该收到发送器事件。 */
case STATE_TX_IDLE:
/* 启用接收器/禁用发送器。 */
vMBPortSerialEnable( TRUE, FALSE ); // 使能串口接收,禁用串口发送
break;
case STATE_TX_XMIT:
// 启动 DMA 批量发送
vMBPortSerialEnable( FALSE, TRUE ); // 禁用串口接收,使能串口发送
if (xMBPortSerialPutFrame(pucSndBufferCur, usSndBufferCount) == TRUE)
{
eSndState = STATE_TX_WAIT; // 切换到等待发送完成状态
}
else
{
// 发送启动失败,恢复空闲状态
vMBPortSerialEnable(TRUE, FALSE);
eSndState = STATE_TX_IDLE;
}
break;
case STATE_TX_WAIT:
if(usSndBufferCount == 0)
{
xNeedPoll = xMBPortEventPost( EV_FRAME_SENT ); // 发送帧发送完成事件
vMBPortSerialEnable( TRUE, FALSE ); // 使能串口接收,禁用串口发送
eSndState = STATE_TX_IDLE; // 设置发送器状态为空闲状态
ulTxTimeout = 0;
}else if(ulTxTimeout++ > 100000) // 约100ms超时(根据系统时钟调整)
{
// 超时重置
xMBPortSerialRestart();
eSndState = STATE_TX_IDLE;
ulTxTimeout = 0;
}
break;
}
return xNeedPoll; // 返回是否需要轮询的标记
}
四、测试验证
uart1_irq_counters结构体记录了系统进入中断的次数,可以看到每发送一帧数据,系统进入两次串口空闲中断,大大减少了中断次数。
更多推荐


所有评论(0)