从play_mp3例程出发理解ESP32-ADF的使用方法

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/zhejfl/article/details/86477866

1、背景

最近在搞ESP32的音乐播放,对ESP32软件体系之一ADF开始学习。记录些东西。水平有限,求路过者不吝赐教。

1.1 参考资料

ADF文档 https://docs.espressif.com/projects/esp-adf/en/latest/get-started/index.html

2、play_mp3例程出发理解ADF的一般步骤和概念

音频播放的一般流程:获取音频流(音频输入流)-->音频流加工处理---->音频输出流

2.1 概念自解

Element(元素):ADF API提供的一种方法便于开发音频应用。将音频的一般流程对象化。如Codecs,Streams或Filters。

Pipeline(管道):通过组合Elements加入到Pipeline来开发应用程序。

这就是play_mp3这个例程中组成音频管道的元素。

通常音频数据通过输入流(Stream)来获取,由CodecFilter来处理,最后也是由输出流(Stream)来输出。

音频管道的作用是控制音频数据流以及把音频元素Elements和各自的ringbuffer连接起来。不管是连接还是启动元素都是按顺序来进行的,从前一个元素那里检索数据并把它传给下一个元素。当然也要获取各个元素的事件,处理事件或发送给更高层

Event: 通过Event来建立管道Pipeline中各音频元素Elements之间的通信。这是围绕FreeRtos的队列来建立的。Event通过listeners来监视传入的msg并通过回调函数来通知。

2.2 音频播放一般步骤

[ 1 ] Start audio codec chip //启动音频编解码芯片
[ 2 ] Create audio pipeline, add all elements to pipeline, and subscribe pipeline event
      //创建音频pipeline,将所有elements添加入pipeline,并订阅pipeline事件
    [2.1] Create mp3 decoder to decode mp3 file and set custom read callback
      //创建mp3解码器去解码MP3文件并设置用户读文件回调函数
    [2.2] Create i2s stream to write data to codec chip
      //创建写入到编解码芯片的i2s数据流
    [2.3] Register all elements to audio pipeline
      //注册所有元素elements到音频管道pipeline
    [2.4] Link it together [mp3_music_read_cb]-->mp3_decoder-->i2s_stream-->[codec_chip]
      //将所有元素链接起来,ringbuffer创建了
[ 3 ] Setup event listener
      //设置事件监听器
    [3.1] Listening event from all elements of pipeline
      //监听来自管道中所有元素的事件
[ 4 ] Start audio_pipeline
      //启动音频管道
[ 5 ] Stop audio_pipeline
      //关闭音频管道

接下去就应该具体步骤具体分析了。 

2.2.1启动音频编解码芯片

每个人板子上的音频编解码芯片可能各有不同,但ADF提供的抽象接口层API<components/audio_hal.c>提供应用程序与特定音频板子硬件驱动之间的接口。

通过数据接口来配置ADC/DAC信号转变的采样率,以及数据宽度、I2C数据流参数以及ADC/DAC的信号通道选择。HAL接口也提供了初始化音频板子的API以及控制音量的API。

#define AUDIO_HAL_ES8388_DEFAULT(){                     \
        .adc_input  = AUDIO_HAL_ADC_INPUT_LINE1,        \
        .dac_output = AUDIO_HAL_DAC_OUTPUT_ALL,         \
        .codec_mode = AUDIO_HAL_CODEC_MODE_BOTH,        \
        .i2s_iface = {                                  \
            .mode = AUDIO_HAL_MODE_SLAVE,               \
            .fmt = AUDIO_HAL_I2S_NORMAL,                \
            .samples = AUDIO_HAL_48K_SAMPLES,           \
            .bits = AUDIO_HAL_BIT_LENGTH_16BITS,        \
        },                                              \
};

audio_hal_codec_config_t audio_hal_codec_cfg = AUDIO_HAL_ES8388_DEFAULT(); 
audio_hal_handle_t hal = audio_hal_init(&audio_hal_codec_cfg, 0);//初始化多媒体编解码驱动
audio_hal_ctrl_codec(hal, AUDIO_HAL_CODEC_MODE_DECODE, AUDIO_HAL_CTRL_START);//启动/停止编解码启动

上述ES8388芯片的配置包括ADC、DAC通道、编解码模式、i2s接口配置(如I2S从模式、标准模式、采样率48k、位宽16bit)。

/**
 * @brief Configure media hal for initialization of audio codec chip
 */
typedef struct {
    audio_hal_adc_input_t adc_input;       /*!< set adc channel */
    audio_hal_dac_output_t dac_output;     /*!< set dac channel */
    audio_hal_codec_mode_t codec_mode;     /*!< select codec mode: adc, dac or both */
    audio_hal_codec_i2s_iface_t i2s_iface; /*!< set I2S interface configuration */
} audio_hal_codec_config_t;

关于ES8388,可看文章。水平有限,持续改进。

当然,我们在启动音频编解码芯片时也可以调用ADF提供的芯片的API来实现。

2.2.2 创建音频pipeline,将所有elements添加入pipeline,并订阅pipeline事件

2.2.2.1、创建音频管道,参数为音频管道ringbuffer的大小


struct audio_pipeline {
    audio_element_list_t        el_list;
    ringbuf_list_t              rb_list;
    audio_element_state_t       state;
    xSemaphoreHandle            lock;
    bool                        linked;
    audio_event_iface_handle_t  listener;
};

typedef struct audio_pipeline *audio_pipeline_handle_t;

audio_pipeline_handle_t audio_pipeline_init(audio_pipeline_cfg_t *config)
/**
 * @brief Audio Pipeline configurations
 */
typedef struct audio_pipeline_cfg {
    int rb_size;        /*!< Audio Pipeline ringbuffer size */
} audio_pipeline_cfg_t;

audio_pipeline_handle_t audio_pipeline_init(audio_pipeline_cfg_t *config)
{
    audio_pipeline_handle_t pipeline;
    bool _success =
        (
            (pipeline       = audio_calloc(1, sizeof(struct audio_pipeline)))   &&
            (pipeline->lock = mutex_create())
        );

    AUDIO_MEM_CHECK(TAG, _success, return NULL);
    STAILQ_INIT(&pipeline->el_list);
    STAILQ_INIT(&pipeline->rb_list);

    pipeline->state = AEL_STATE_INIT;
    return pipeline;
}

这个函数初始化了audio_pipeline_handle_t类型的对象,音频管道的主要职责是控制音频数据流stream以及用ringbuffer连接各音频元素elements。它按顺序并启动音频元素,负责从前一个元素检索数据然后传递给后面一个元素,还可从每个元素获取事件、处理事件或将其传递到更高层。

这个有个疑问这个config用在哪里了??

2.2.2.2.创建各元素Elements,且各个元素初始化有各自的API。

 mp3_decoder_cfg_t mp3_cfg = DEFAULT_MP3_DECODER_CONFIG();
 mp3_decoder = mp3_decoder_init(&mp3_cfg);
 audio_element_set_read_cb(mp3_decoder, mp3_music_read_cb, NULL);

 i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT();
 i2s_cfg.type = AUDIO_STREAM_WRITER;
 i2s_cfg.i2s_config.sample_rate = 48000;  //和编解码的采样率保持一致
 i2s_stream_writer = i2s_stream_init(&i2s_cfg);

这里用到了mp3_decoder 和 i2s_stream两个Elements,每个Element实际上是一个Task。初始化配置参数中都有对任务栈大小、任务优先级、任务运行的core、以及输出ringbuffer和Element使用的buffer的大小

无论是哪个元素Element初始化,其中肯定包含Element的初始化,这是不用说的。看看Element的初始化过程

/**
 * @brief Audio Element configurations
 *        Each Element at startup will be a self-running task.
 *        These tasks will execute the callback open -> [loop: read -> process -> write] -> close
 *        These callback functions are provided by the user corresponding to this configuration.
 *
 */
typedef struct {
    io_func             open;             /*!< Open callback function */
    io_func             seek;             /*!< Seek callback function */
    process_func        process;          /*!< Process callback function */
    io_func             close;            /*!< Close callback function */
    io_func             destroy;          /*!< Destroy callback function */
    stream_func         read;             /*!< Read callback function */
    stream_func         write;            /*!< Write callback function */
    int                 buffer_len;       /*!< Buffer length use for an Element */
    int                 task_stack;       /*!< Element task stack */
    int                 task_prio;        /*!< Element task priority (based on freeRTOS priority) */
    int                 task_core;        /*!< Element task running in core (0 or 1) */
    int                 out_rb_size;      /*!< Output ringbuffer size */
    void                *data;            /*!< User context */
    char                *tag;             /*!< Element tag */
    bool                enable_multi_io;  /*!< Enable multi input and output ringbuffer */
} audio_element_cfg_t;
audio_element_handle_taudio_element_init(audio_element_cfg_t *config)

根据config初始化音频元素Element,在config参数中如上所述:包括open/seek/process/close/destroy/read/write的回调函数,Element的任务栈大小、优先级、运行在哪个core、用户上下文参数、输出rinbuffer的大小。

其实每个启动的Element都是一个自运行的任务,这些任务都会执行open-->loop[read-->process-->write]-->close的回调函数。回调函数由应用程序来配置。 Stream、Coder等初始化函数都是针对各自特点对Element的初始化函数的进一步封装。

audio_element_handle_ti2s_stream_init(i2s_stream_cfg_t *config)

根据confg.type 是AUDIO_STREAM_READER还是AUDIO_STREAM_WRITER,来创建一个音频元素Element来流出数据从I2S到另一个Element或者创建一个音频元素Element从另一个elements获得数据发送到I2S。

以stream的初始化源码来说,流程如下

typedef struct i2s_stream {
    audio_stream_type_t type;
    i2s_stream_cfg_t    config;
    bool                is_open;
} i2s_stream_t;

audio_element_handle_t i2s_stream_init(i2s_stream_cfg_t *config)
{

..........
el = audio_element_init(&cfg);         //元素初始化
audio_element_setdata(el, i2s);        //设置上下文参数;将i2s赋值给el中

audio_element_info_t info;
audio_element_getinfo(el, &info);      //获取音频元素信息 

info.sample_rates = config->i2s_config.sample_rate;
info.channels = config->i2s_config.channel_format < I2S_CHANNEL_FMT_ONLY_RIGHT ? 2 : 1;
info.bits = config->i2s_config.bits_per_sample;  
audio_element_setinfo(el, &info);      //设置音频元素信息

i2s_driver_install(i2s->config.i2s_port, &i2s->config.i2s_config, 0, NULL);    //IDF的I2S驱动安装

i2s_set_pin(i2s->config.i2s_port, &i2s->config.i2s_pin_config);
SET_PERI_REG_BITS(PIN_CTRL, CLK_OUT1, 0, CLK_OUT1_S);
PIN_FUNC_SELECT(PERIPHS_IO_MUX_GPIO0_U, FUNC_GPIO0_CLK_OUT1); 
}

这上面有Element的初始化以及以后的设置,以及I2S的驱动安装以及管脚设置

/**********************************************************************************************/

以MP3_decocde元素初始化的参数结构来说

/**
 * @brief      Mp3 Decoder configuration
 */
typedef struct {
    int                     out_rb_size;    /*!< Size of output ringbuffer */
    int                     task_stack;     /*!< Task stack size */
    int                     task_core;      /*!< CPU core number (0 or 1) where decoder task in running */
    int                     task_prio;      /*!< Task priority (based on freeRTOS priority) */
} mp3_decoder_cfg_t;
 mp3_decoder = mp3_decoder_init(&mp3_cfg);

这句代码应该是启动了一个任务。

audio_element_set_read_cb(mp3_decoder, mp3_music_read_cb, NULL);   //读取文件
/**
 * @brief     This API allows the application to set a read callback for the first audio_element in the pipeline for
 *            allowing the pipeline to interface with other systems. The callback is invoked every time the audio
 *            element requires data to be processed.
 *
 * @param[in]  el        The audio element handle
 * @param[in]  fn        Callback read function. The callback function should return number of bytes read or -1
 *                       in case of error in reading. Note that the callback function may decide to block and
 *                       that may block the entire pipeline.
 * @param[in]  context   An optional context which will be passed to callback function on every invocation
 *
 * @return
 *     - ESP_OK
 *     - ESP_FAIL
 */
typedef enum {
    IO_TYPE_RB = 1, /* I/O through ringbuffer */
    IO_TYPE_CB,     /* I/O through callback */
} io_type_t;

esp_err_t audio_element_set_read_cb(audio_element_handle_t el, stream_func fn, void *context)
{
    if (el) {
        el->in.read_cb.cb = fn;
        el->in.read_cb.ctx = context;
        el->read_type = IO_TYPE_CB;
        return ESP_OK;
    }
    return ESP_FAIL;
}

 audio_element_set_read_cb()这个接口必不可少,重要******************

这个API接口允许应用程序为管道中的第一个audio_element设置一个读回调,这个读回调提供和其他系统相联系的接口。当每次音频元素需要待处理的数据,这个函数被调用。

参数:el-需要待处理数据音频元素句柄,pipiline的第一个元素;

fn-回调函数----返回读取的字节数或-1(error)

context---每次调用可传递给回调函数的参数。

这个API内部实际对句柄结构输入成员in赋值以及读取类型时从回调函数中读取

这里的回调函数如下所示

extern const uint8_t adf_music_mp3_start[] asm("_binary_adf_music_mp3_start");
extern const uint8_t adf_music_mp3_end[]   asm("_binary_adf_music_mp3_end");
int mp3_music_read_cb(audio_element_handle_t el, char *buf, int len, TickType_t wait_time, void *ctx)
{
    static int mp3_pos;
    int read_size = adf_music_mp3_end - adf_music_mp3_start - mp3_pos;
    if (read_size == 0) {
        return AEL_IO_DONE;
    } else if (len < read_size) {
        read_size = len;
    }
    memcpy(buf, adf_music_mp3_start + mp3_pos, read_size);
    mp3_pos += read_size;
    return read_size;
}

/**************这样就有了起始***************/

2.2.2.3注册所有Elements到Pipeline中,并把Elements给链接起来

 audio_pipeline_register(pipeline, mp3_decoder, "mp3");
 audio_pipeline_register(pipeline, i2s_stream_writer, "i2s");
 audio_pipeline_link(pipeline, (const char *[]) {"mp3", "i2s"}, 2);

为音频管道注册一个Element,任何一个Element可以注册多次,但是对于一个管道而言“name”是唯一的。也就是说音频管道中用name来识别element

audio_pipeline_link()在调用这个函数前,加入到pipeline中音频参数实际还没有连接起来。调用之后确定了连接的顺序。同时也订阅了所有element的事件。在这个函数的源码中,还创建连接ringbuffer.

first Element -(set output ringbuffer)-->ringbuffer1--(set input ringbuffer)->second Element--      ----->ringbuffern- set input ringbuffer->last Element.

注册可以多种Elements组合,在连接的时候选择一种Elements组合连接起来,也可以在断开再组成其他的Elements组合,则使得使用pipeline非常灵活。

2.2.3 设置事件监听器,开始监听

/**
 * Event interface configurations
 */
typedef struct {
    int                 internal_queue_size;        /*!< It's optional, Queue size for event `internal_queue` */
    int                 external_queue_size;        /*!< It's optional, Queue size for event `external_queue` */
    int                 queue_set_size;             /*!< It's optional, QueueSet size for event `queue_set`*/
    on_event_iface_func on_cmd;                     /*!< Function callback for listener when any event arrived */
    void                *context;                   /*!< Context will pass to callback function */
    TickType_t          wait_time;                  /*!< Timeout to check for event queue */
    int                 type;                       /*!< it will pass to audio_event_iface_msg_t source_type (To know where it came from) */
} audio_event_iface_cfg_t;
#define AUDIO_EVENT_IFACE_DEFAULT_CFG() {                   \
    .internal_queue_size = DEFAULT_AUDIO_EVENT_IFACE_SIZE,  \
    .external_queue_size = DEFAULT_AUDIO_EVENT_IFACE_SIZE,  \
    .queue_set_size = DEFAULT_AUDIO_EVENT_IFACE_SIZE,       \
    .on_cmd = NULL,                                         \
    .context = NULL,                                        \
    .wait_time = portMAX_DELAY,                             \
    .type = 0,                                              \
} 
audio_event_iface_cfg_t evt_cfg = AUDIO_EVENT_IFACE_DEFAULT_CFG();
audio_event_iface_handle_t evt = audio_event_iface_init(&evt_cfg);  //初始化音频事件
audio_pipeline_set_listener(pipeline, evt);//为音频管道pipeline设置事件监听器evt,所有来自pipeline的事件都可以由evt这个监听器所监听

 先看看audio_event_iface_init()函数的初始化音频事件。

audio_event_iface_handle_t audio_event_iface_init(audio_event_iface_cfg_t *config)
{
    audio_event_iface_handle_t evt = calloc(1, sizeof(struct audio_event_iface));
    AUDIO_MEM_CHECK(TAG, evt, return NULL);
    evt->queue_set_size   = config->queue_set_size;
    evt->internal_queue_size = config->internal_queue_size;
    evt->external_queue_size = config->external_queue_size;
    evt->context = config->context;
    evt->on_cmd = config->on_cmd;
    evt->type = config->type;
    if (evt->queue_set_size) {
        evt->queue_set = xQueueCreateSet(evt->queue_set_size);
    }
    if (evt->internal_queue_size) {
        evt->internal_queue = xQueueCreate(evt->internal_queue_size, sizeof(audio_event_iface_msg_t));
        AUDIO_MEM_CHECK(TAG, evt->internal_queue, goto _event_iface_init_failed);
    }
    if (evt->external_queue_size) {
        evt->external_queue = xQueueCreate(evt->external_queue_size, sizeof(audio_event_iface_msg_t));
        AUDIO_MEM_CHECK(TAG, evt->external_queue, goto _event_iface_init_failed);
    } else {
        ESP_LOGD(TAG, "This emiiter have no queue set,%p", evt);
    }

    STAILQ_INIT(&evt->listening_queues);
    return evt;
_event_iface_init_failed:
    if (evt->internal_queue) {
        vQueueDelete(evt->internal_queue);
    }
    if (evt->external_queue) {
        vQueueDelete(evt->external_queue);
    }
    return NULL;
}

audio_pipeline_set_listener() 源码中做到如果当前管道pipeline已经有了监听器,则从监听器中把这管道移除。这个函数的核心还是audio_element_msg_set_listener()。就是将管道中的elements一一添加到监听器中监听事件。

/****************************************************************************************************************************/

音频事件初始化接口实际上是对audio_event_iface_handle_t结构体变量的配置,从config配置参数中获得队列集大小、内部队列大小、外部队列大小、上下文变量、on_cmd函数、类型;申请队列集、内部队列、外部队列,加入队列集。

这是on_cmd函数,run接口最终处理 

static esp_err_t process_peripheral_event(audio_event_iface_msg_t *msg, void *context)
{
    esp_periph_handle_t periph_evt = (esp_periph_handle_t) msg->source;
    esp_periph_handle_t periph;
    STAILQ_FOREACH(periph, &g_esp_periph_obj->periph_list, entries) {
        if (periph->periph_id == periph_evt->periph_id
            && periph_evt->state == PERIPH_STATE_RUNNING
            && periph_evt->run
            && !periph_evt->disabled) {
            return periph_evt->run(periph_evt, msg);
        }
    }
    return ESP_OK;
}

2.2.4 启动音频管道

 audio_pipeline_run(pipeline);

启动音频管道。调用这个函数后,就会为管道中的所有Elements创建Tasks

音频事件

3、事件监听处理

事件监听涉及到事件的发送和时间的接收。

3.1 发送

触发发送一个带消息的事件到内部队列中。

esp_err_t audio_event_iface_cmd(audio_event_iface_handle_t evt, audio_event_iface_msg_t *msg)
{
    if (evt->internal_queue && (xQueueSend(evt->internal_queue, (void *)msg, 0) != pdPASS)) {
        ESP_LOGD(TAG, "There are no space to dispatch queue");
        return ESP_FAIL;
    }
    return ESP_OK;
}

也可以放在中断函数中发送事件,接口如下

esp_err_t audio_event_iface_cmd_from_isr(audio_event_iface_handle_t evt, audio_event_iface_msg_t *msg)

 也可以把消息发送给外部队列中

esp_err_t audio_event_iface_sendout(audio_event_iface_handle_t evt, audio_event_iface_msg_t *msg)
{
    if (evt->external_queue) {
        if (xQueueSend(evt->external_queue, (void *)msg, 0) != pdPASS) {
            ESP_LOGD(TAG, "There is no space in external queue");
            return ESP_FAIL;
        }
    }
    return ESP_OK;
}

3.2 接收

等待接收内部队列事件,并on_cmd中处理,以外设为例,在外设的__run 中运行

esp_err_t audio_event_iface_waiting_cmd_msg(audio_event_iface_handle_t evt)
{
    audio_event_iface_msg_t msg;
    if (evt->internal_queue && (xQueueReceive(evt->internal_queue, (void *)&msg, evt->wait_time) == pdTRUE)) {
        if (evt->on_cmd && evt->on_cmd((void *)&msg, evt->context) != ESP_OK) {
            return ESP_FAIL;
        }
    }
    return ESP_OK;
}

另一中,接收队列集的数据,接下去处理。

esp_err_t audio_event_iface_listen(audio_event_iface_handle_t evt, audio_event_iface_msg_t *msg, TickType_t wait_time)
{
    if (!evt) {
        return ESP_FAIL;
    }
    if (audio_event_iface_read(evt, msg, wait_time) != ESP_OK) {
        return ESP_FAIL;
    }
    return ESP_OK;
}

esp_err_t audio_event_iface_read(audio_event_iface_handle_t evt, audio_event_iface_msg_t *msg, TickType_t wait_time)
{
    if (evt->queue_set) {
        QueueSetMemberHandle_t active_queue;
        active_queue = xQueueSelectFromSet(evt->queue_set, wait_time);
        if (active_queue) {
            if (xQueueReceive(active_queue, msg, 0) == pdTRUE) {
                return ESP_OK;
            }
        }
    }
    return ESP_FAIL;
}

3.3 汇总

以外设BUTTON事件收发为例:

事件初始化后,在按键中断服务程序中发送内部列队消息事件,在事件Task中等待接收内部队列消息,在button的run函数中发送外部队列消息,在外部循环中接收队列集队列数据。

在音频元素事件收发为例:

音频元素动作命令在内部队列中传输;音频元素状态报告在外部队列中传输。

4、小节

esp_image: Image length 1241280 doesn't fit in partition length 1200000[0m

实际生成的映像的大小为1241280和分区表中的额200000大小不配,因此启动失败。

1313) cpu_start: Pro cpu start user code[0m
[0;32mI (1318) spiram: Adding pool of 4096K of external SPI memory to heap allocator[0m
[0;32mI (332) cpu_start: Starting scheduler on PRO CPU.[0m
[0;32mI (0) cpu_start: Starting scheduler on APP CPU.[0m
[0;32mI (334) spiram: Reserving pool of 32K of internal memory for DMA/internal allocations[0m
[0;32mI (334) PLAY_MP3_FLASH: [ 1 ] Start audio codec chip[0m
[0;32mI (364) PLAY_MP3_FLASH: [ 2 ] Create audio pipeline, add all elements to pipeline, 

[2019-02-20 19:40:38.206]# RECV ASCII>
and subscribe pipeline event[0m
[0;32mI (364) PLAY_MP3_FLASH: [2.1] Create mp3 decoder to decode mp3 file and set custom read callback[0m
[0;32mI (374) PLAY_MP3_FLASH: [2.2] Create i2s stream to write data to codec chip[0m
[0;32mI (384) PLAY_MP3_FLASH: [2.3] Register all elements to audio pipeline[0m
[0;32mI (384) PLAY_MP3_FLASH: [2.4] Link it together [mp3_music_read_cb]-->mp3_decoder-->i2s_stream-->[codec_chip][0m
[0;32mI (404) PLAY_MP3_FLASH: [ 3 ] Setup event listener[0m
[0;32mI (404) 

[2019-02-20 19:40:38.264]# RECV ASCII>
PLAY_MP3_FLASH: [3.1] Listening event from all elements of pipeline[0m
[0;32mI (414) PLAY_MP3_FLASH: [ 4 ] Start audio_pipeline[0m


[2019-02-20 19:40:38.335]# RECV ASCII>
[0;32mI (494) PLAY_MP3_FLASH: [ * ] Receive music info from mp3 decoder, sample_rates=44100, bits=16, ch=2[0m


[2019-02-20 19:40:45.073]# RECV ASCII>
[0;32mI (7224) PLAY_MP3_FLASH: [ 5 ] Stop audio_pipeline[0m
[0;33mW (7224) AUDIO_PIPELINE: There are no listener registered[0m


 

猜你喜欢

转载自blog.csdn.net/zhejfl/article/details/86477866