ffmpeg源码分析 (三)

例子

    该例子的功能是将mp4文件转换成yuv数据以及h264裸流。

#include<stdio.h>
#include <stdlib.h>
#include <iostream>
#include "config.h"
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/mem.h>
#include <libavutil/imgutils.h>
#include <libavutil/pixfmt.h>
#include <libswscale/swscale.h>
}
;

using namespace std;

int main(void) {

    FILE *f_yuv = fopen("output.yuv", "wb+");
    FILE *f_h264 = fopen("output.h264", "wb+");

    //初始化avcodec
    avcodec_register_all();
    //初始化 demuxer
    av_register_all();
    //创建一个用于demuxer的结构体
    AVFormatContext *av_format_context = avformat_alloc_context();

    char source[] = "/Users/yxwang/Downloads/test.mp4";

    if (avformat_open_input(&av_format_context, source, NULL, NULL) != 0) {
        cout << "打开文件失败" << endl;
        return -1;
    }

    //需要关闭尝试是否需要手动获取视频文件信息
    if (avformat_find_stream_info(av_format_context, NULL) < 0) { //获取视频文件信息
        cout << "Couldn't find stream information." << endl;
        return -1;
    }

    int videoindex = -1;
    for (int i = 0; i < av_format_context->nb_streams; i++) {
        if (av_format_context->streams[i]->codec->codec_type
                == AVMEDIA_TYPE_VIDEO) {
            videoindex = i;
            break;
        }
    }

    if (videoindex == -1) {
        cout << "Didn't find a video stream." << endl;
        return -1;
    }

    AVCodecContext *pCodecCtx = av_format_context->streams[videoindex]->codec;
    AVCodec *pCodec = avcodec_find_decoder(pCodecCtx->codec_id); //查找decoder
    if (pCodec == NULL) {
        printf("Codec not found.\n");
        return -1;
    }

    if (avcodec_open2(pCodecCtx, pCodec, NULL) != 0) {
        printf("Can not open codec.\n");
        return -1;
    }

    uint8_t *out_buffer = (uint8_t *) av_malloc(
            av_image_get_buffer_size(AV_PIX_FMT_YUV420P, pCodecCtx->width,
                    pCodecCtx->height, 1));

    AVFrame *decodeFrame = av_frame_alloc();
    AVFrame *pFrameYUV = av_frame_alloc();

    av_image_fill_arrays(pFrameYUV->data, pFrameYUV->linesize, out_buffer,
            AV_PIX_FMT_YUV420P, pCodecCtx->width, pCodecCtx->height, 1);

    AVPacket* packet = (AVPacket *) av_malloc(sizeof(AVPacket));
    //Output Info-----------------------------
    printf("--------------- File Information ----------------\n");
    av_dump_format(av_format_context, 0, source, 0);
    printf("-------------------------------------------------\n");
    SwsContext *img_convert_ctx = sws_getContext(pCodecCtx->width,
            pCodecCtx->height, pCodecCtx->pix_fmt, pCodecCtx->width,
            pCodecCtx->height, AV_PIX_FMT_YUV420P, SWS_BICUBIC, NULL, NULL,
            NULL);
    while (av_read_frame(av_format_context, packet) >= 0) { //读取一帧压缩数据
        if (packet->stream_index == videoindex) {
            fwrite(packet->data, 1, packet->size, f_h264); //把H264数据写入fp_h264文件
            if (avcodec_send_packet(pCodecCtx, packet) < 0) { //解码一帧压缩数据
                printf("Decode Error.\n");
                return -1;
            }

            while (avcodec_receive_frame(pCodecCtx, decodeFrame) == 0) {
                sws_scale(img_convert_ctx,
                        (const unsigned char* const *) decodeFrame->data,
                        decodeFrame->linesize, 0, pCodecCtx->height,
                        pFrameYUV->data, pFrameYUV->linesize);
                int y_size = pCodecCtx->width * pCodecCtx->height;
                fwrite(pFrameYUV->data[0], 1, y_size, f_yuv);    //Y
                fwrite(pFrameYUV->data[1], 1, y_size / 4, f_yuv);  //U
                fwrite(pFrameYUV->data[2], 1, y_size / 4, f_yuv);  //V

            }
        }
        av_free_packet(packet);
    }

    fclose(f_yuv);
    fclose(f_h264);

    sws_freeContext(img_convert_ctx);
    av_frame_free(&pFrameYUV);
    av_frame_free(&decodeFrame);
    avcodec_close(pCodecCtx);

    //内部会调用avformat_free_context
    avformat_close_input(&av_format_context);

    return EXIT_SUCCESS;
}

深入分析

    那么正题来了,我们已经在前面的章节分析过了avcodec_register_all 以及 av_register_all两个函数。并且也已经知道使用avformat_alloc_context来创建一个AVFormatContext 是所有和解封装封装相关的基础操作(ffmpeg源码分析 (二))。

avformat_open_input

    第一个需要研究的函数就是avformat_open_input了,该方法定义在了avformat.h中

/**
 * Open an input stream and read the header. The codecs are not opened.
 * The stream must be closed with avformat_close_input().
 * 打开一个输入流,并读取它的头
 * @param ps 可以传入空指针,这个时候方法会自动创建一个AVFormatContext并且放入ps中
 * @param url URL of the stream to open. 流地址
 * @param fmt 如果不为空,那么强制使用指定的输入格式,否则ffmpeg会去自动发现格式
 * @param options  A dictionary filled with AVFormatContext and demuxer-private options.
 *                 On return this parameter will be destroyed and replaced with a dict containing
 *                 options that were not found. May be NULL.
 *
 * @return 0 on success, a negative AVERROR on failure.
 *
 * @note If you want to use custom IO, preallocate the format context and set its pb field.
 */
int avformat_open_input(AVFormatContext **ps, const char *url, AVInputFormat *fmt, AVDictionary **options);

    该方法算是核心方法了,实现来说还是比较复杂的,这里有一张古时候的调用流程图,在当前版本的ffmpeg基本也是这个流程。

    

这里还有一张当前的调用流程图

 

 

实现写在了 avformat/utils.c中. 

int avformat_open_input(AVFormatContext **ps, const char *filename,
                        AVInputFormat *fmt, AVDictionary **options)
{
    AVFormatContext *s = *ps;
    int i, ret = 0;
    AVDictionary *tmp = NULL;
    ID3v2ExtraMeta *id3v2_extra_meta = NULL;

    if (!s && !(s = avformat_alloc_context())) //如果AVFormatContext未空,那么新创建一个
        return AVERROR(ENOMEM);
    if (!s->av_class) {
        av_log(NULL, AV_LOG_ERROR, "Input context has not been properly allocated by avformat_alloc_context() and is not NULL either\n");
        return AVERROR(EINVAL);
    }
    if (fmt) //如果fmt不为空,那么直接指定AVInputFormat
        s->iformat = fmt;

    if (options)//将option拷贝到 tmp中
        av_dict_copy(&tmp, *options, 0);

    if (s->pb) // must be before any goto fail 设置flag 用户自己设置了AVIOContext
        s->flags |= AVFMT_FLAG_CUSTOM_IO;

    if ((ret = av_opt_set_dict(s, &tmp)) < 0)
        goto fail;

    if (!(s->url = av_strdup(filename ? filename : ""))) {
        ret = AVERROR(ENOMEM);
        goto fail;
    }

#if FF_API_FORMAT_FILENAME
FF_DISABLE_DEPRECATION_WARNINGS
    av_strlcpy(s->filename, filename ? filename : "", sizeof(s->filename));
FF_ENABLE_DEPRECATION_WARNINGS
   ...............

      avformat_open_input方法的实现很长,不过其中包含了非常多的保护性代码,比如上面的代码,都是在做一些安全性保护,以及变量初始化。

init_input

    这是avformat_open_input中核心方法,主要作用是打开输入的视频数据并且探测视频的格式.

/* Open input file and probe the format if necessary. */
static int init_input(AVFormatContext *s, const char *filename,
                      AVDictionary **options)
{
    int ret;
    AVProbeData pd = { filename, NULL, 0 };
    int score = AVPROBE_SCORE_RETRY; //得分 

    if (s->pb) { //自定义AVIOContext的情况,一般发生在从内存中读取数据,这个时候需要自定义AVIOContext直接输入
        s->flags |= AVFMT_FLAG_CUSTOM_IO;
        if (!s->iformat) //如果没有自己设置iformat,那么使用av_probe_input_buffer2推测AVInputFormat
            return av_probe_input_buffer2(s->pb, &s->iformat, filename,
                                         s, 0, s->format_probesize);
        else if (s->iformat->flags & AVFMT_NOFILE)
            av_log(s, AV_LOG_WARNING, "Custom AVIOContext makes no sense and "
                                      "will be ignored with AVFMT_NOFILE format.\n");
        return 0; //指定了iformat直接返回
    }

    //如果没有设置AVInputFormat,那么使用av_probe_input_format2来判断文件格式。
    //如果找到了大于预设值score的分数,那么直接返回分数
    if ((s->iformat && s->iformat->flags & AVFMT_NOFILE) ||
        (!s->iformat && (s->iformat = av_probe_input_format2(&pd, 0, &score))))
        return score;
    //如果没有判断出来,那么就需要通过io_open真正打开文件,再去判断AVInputFormat,这个方法的实现我们后些说
    if ((ret = s->io_open(s, &s->pb, filename, AVIO_FLAG_READ | s->avio_flags, options)) < 0)
        return ret;

    if (s->iformat)
        return 0;
    return av_probe_input_buffer2(s->pb, &s->iformat, filename,
                                 s, 0, s->format_probesize);
}

    在函数的开头的score变量是一个判决AVInputFormat的分数的门限值,如果最后得到的AVInputFormat的分数低于该门限值,就认为没有找到合适的AVInputFormat。FFmpeg内部判断封装格式的原理实际上是对每种AVInputFormat给出一个分数,满分是100分,越有可能正确的AVInputFormat给出的分数就越高。最后选择分数最高的AVInputFormat作为推测结果。

av_probe_input_format2

/**
 * Guess the file format.
 *
 * @param pd        data to be probed 存储输入数据信息的AVProbeData结构体。
 * @param is_opened Whether the file is already opened; determines whether
 *                  demuxers with or without AVFMT_NOFILE are probed. 文件是否打开。
 * @param score_max A probe score larger that this is required to accept a
 *                  detection, the variable is set to the actual detection
 *                  score afterwards.
 *                  If the score is <= AVPROBE_SCORE_MAX / 4 it is recommended
 *                  to retry with a larger probe buffer.判决AVInputFormat的门限值。只有某格式判决分数大于该 
 *                                                      门限值的时候,函数才会返回该封装格式,否则返回NULL。
 */
AVInputFormat *av_probe_input_format2(AVProbeData *pd, int is_opened, int *score_max);

    该函数用于根据输入数据查找合适的AVInputFormat.

    其中涉及到一个AVProbeData的结构体,从Init_input上我们可以找打它的构造

             AVProbeData pd = { filename, NULL, 0 };

    实际上就是用来存储视频数据信息的一个结构体,具体定义如下

/**
 * This structure contains the data a format has to probe a file.
 */
typedef struct AVProbeData {
    const char *filename;  //文件路径
    unsigned char *buf; /**< Buffer must have AVPROBE_PADDING_SIZE of extra allocated bytes filled with zero. 用于存放推测的媒体数据,但是最后还需要填充AVPROBE_PADDING_SIZE个0(实际就是32个) */
    int buf_size;       /**< Size of buf except extra allocated bytes buffer长度,不包括填充的0的长度 */
    const char *mime_type; /**< mime_type, when known. 存放的推测媒体数据的mime_type*/
} AVProbeData;

    回到 av_probe_input_format2 这个函数的定义如下

AVInputFormat *av_probe_input_format2(AVProbeData *pd, int is_opened, int *score_max)
{
    int score_ret;
    AVInputFormat *fmt = av_probe_input_format3(pd, is_opened, &score_ret);
    if (score_ret > *score_max) {
        *score_max = score_ret;
        return fmt;
    } else
        return NULL;
}

    方法比较简单,实际上就是进一步去调用av_probe_input_format3去查找AVInputFormat,同时还要返回最大得分。通过和阈值得分比价,如果大于阈值得分,那么返回查找到的AVInputFormat会被返回,否则返回null。

 

av_probe_input_format3

    层层递进,不愧是ffmpeg的核心方法,复杂程度也是杠杠得!在分析代码之前可以先了解一些知识:

    ID3,一般是位于一个mp3文件的开头或末尾的若干字节内,附加了关于该mp3的歌手,标题,专辑名称,年代,风格等信息,该信息就被称为ID3信息,ID3信息分为两个版本,v1和v2版。

    定义没啥好说的,直接来看实现,代码还是比较长的。

AVInputFormat *av_probe_input_format3(AVProbeData *pd, int is_opened,
                                      int *score_ret/*最匹配格式的分数,需要改方法填入值*/)
{
    AVProbeData lpd = *pd;
    AVInputFormat *fmt1 = NULL, *fmt;
    int score, score_max = 0;
    void *i = 0;
    const static uint8_t zerobuffer[AVPROBE_PADDING_SIZE];
    enum nodat {
        NO_ID3,
        ID3_ALMOST_GREATER_PROBE,
        ID3_GREATER_PROBE,
        ID3_GREATER_MAX_PROBE,
    } nodat = NO_ID3;

    if (!lpd.buf)
        lpd.buf = (unsigned char *) zerobuffer;
    //这一段是用来检查是否有ID3信息的,并且移动指针跳,使lpd.buf移动到数据地址
    if (lpd.buf_size > 10 && ff_id3v2_match(lpd.buf, ID3v2_DEFAULT_MAGIC)) {
        int id3len = ff_id3v2_tag_len(lpd.buf);
        if (lpd.buf_size > id3len + 16) {
            if (lpd.buf_size < 2LL*id3len + 16)
                nodat = ID3_ALMOST_GREATER_PROBE;
            lpd.buf      += id3len;
            lpd.buf_size -= id3len;
        } else if (id3len >= PROBE_BUF_MAX) {
            nodat = ID3_GREATER_MAX_PROBE;
        } else
            nodat = ID3_GREATER_PROBE;
    }

    fmt = NULL;
    //遍历所有的 AVIputFormat
    while ((fmt1 = av_demuxer_iterate(&i))) {
        if (!is_opened == !(fmt1->flags & AVFMT_NOFILE) && strcmp(fmt1->name, "image2"))
            continue;
        score = 0;
        if (fmt1->read_probe) {
            score = fmt1->read_probe(&lpd);
            if (score)
                av_log(NULL, AV_LOG_TRACE, "Probing %s score:%d size:%d\n", fmt1->name, score, lpd.buf_size);
            if (fmt1->extensions && av_match_ext(lpd.filename, fmt1->extensions)) {
                switch (nodat) {
                case NO_ID3:
                    score = FFMAX(score, 1);
                    break;
                case ID3_GREATER_PROBE:
                case ID3_ALMOST_GREATER_PROBE:
                    score = FFMAX(score, AVPROBE_SCORE_EXTENSION / 2 - 1);
                    break;
                case ID3_GREATER_MAX_PROBE:
                    score = FFMAX(score, AVPROBE_SCORE_EXTENSION);
                    break;
                }
            }
        } else if (fmt1->extensions) {
            if (av_match_ext(lpd.filename, fmt1->extensions))
                score = AVPROBE_SCORE_EXTENSION;
        }
        if (av_match_name(lpd.mime_type, fmt1->mime_type)) {
            if (AVPROBE_SCORE_MIME > score) {
                av_log(NULL, AV_LOG_DEBUG, "Probing %s score:%d increased to %d due to MIME type\n", fmt1->name, score, AVPROBE_SCORE_MIME);
                score = AVPROBE_SCORE_MIME;
            }
        }
        if (score > score_max) {
            score_max = score;
            fmt       = fmt1;
        } else if (score == score_max)
            fmt = NULL;
    }
    if (nodat == ID3_GREATER_PROBE)
        score_max = FFMIN(AVPROBE_SCORE_EXTENSION / 2 - 1, score_max);
    *score_ret = score_max;

    return fmt;
}

    该方法最重要的就是循环中的内容,使用av_demuxer_iterate来遍历所有的demuxer(也就是AVInputFormat)。

    如果当前AVInputFormat定义了read_probe方法,就是用该方法来匹配数据,并且返回一个所得分。这里我们不去分析每个AVInputFormat到底是如何去计算分数的(需要的时候可以自己去看相关的类型)。

    av_match_ext用来比对文件的后缀名和AVInputFormat的后缀名是否相同。

    另外还会使用av_match_name()比较输入媒体的mime_type,如果匹配,那么得分就是75分。

    基本逻辑就是这样,其中av_match_ext 和 av_match_name其实是很基础的代码,实际上不涉及到多媒体逻辑,只是字符串比较而已,看一下代码一下就能明白,所以这里也不多介绍。

 

av_probe_input_buffer2

 av_probe_input_buffer2(),它根据输入的媒体数据推测该媒体数据的AVInputFormat,声明位于libavformat\avformat.h

/**
 * Probe a bytestream to determine the input format. Each time a probe returns
 * with a score that is too low, the probe buffer size is increased and another
 * attempt is made. When the maximum probe size is reached, the input format
 * with the highest score is returned.
 *
 * @param pb the bytestream to probe 用于读取数据的AVIOContext
 * @param fmt the input format is put here 推测出来的AVInputFormat
 * @param url the url of the stream 输入媒体的路径
 * @param logctx the log context 日志
 * @param offset the offset within the bytestream to probe from 开始推测AVInputFormat的偏移量。
 * @param max_probe_size the maximum probe buffer size (zero for default) 用于推测格式的媒体数据的最大值。0表示数据大长度
 * @return the score in case of success, a negative value corresponding to an
 *         the maximal score is AVPROBE_SCORE_MAX  推测后返回匹配分数
 * AVERROR code otherwise
 */
int av_probe_input_buffer2(AVIOContext *pb, AVInputFormat **fmt,
                           const char *url, void *logctx,
                           unsigned int offset, unsigned int max_probe_size);

    实现在avformat.c中

int av_probe_input_buffer2(AVIOContext *pb, AVInputFormat **fmt,
                          const char *filename, void *logctx,
                          unsigned int offset, unsigned int max_probe_size)
{
    AVProbeData pd = { filename ? filename : "" };
    uint8_t *buf = NULL;
    int ret = 0, probe_size, buf_offset = 0;
    int score = 0;
    int ret2;

    //如果没有设置最大读取数据长度,那么设置成默认值,约1M
    if (!max_probe_size)
        max_probe_size = PROBE_BUF_MAX;
    else if (max_probe_size < PROBE_BUF_MIN) {
        av_log(logctx, AV_LOG_ERROR,
               "Specified probe size value %u cannot be < %u\n", max_probe_size, PROBE_BUF_MIN);
        return AVERROR(EINVAL);
    }

    if (offset >= max_probe_size)
        return AVERROR(EINVAL);

    if (pb->av_class) {
        uint8_t *mime_type_opt = NULL;
        char *semi;
        av_opt_get(pb, "mime_type", AV_OPT_SEARCH_CHILDREN, &mime_type_opt);
        pd.mime_type = (const char *)mime_type_opt;
        semi = pd.mime_type ? strchr(pd.mime_type, ';') : NULL;
        if (semi) {
            *semi = '\0';
        }
    }
#if 0
    if (!*fmt && pb->av_class && av_opt_get(pb, "mime_type", AV_OPT_SEARCH_CHILDREN, &mime_type) >= 0 && mime_type) {
        if (!av_strcasecmp(mime_type, "audio/aacp")) {
            *fmt = av_find_input_format("aac");
        }
        av_freep(&mime_type);
    }
#endif
    //这个for循环是精髓,它增量式读取媒体数据进行判断,如果判断出来了,那就直接返回,否则读取更多数据送入判断
    for (probe_size = PROBE_BUF_MIN; probe_size <= max_probe_size && !*fmt;
         probe_size = FFMIN(probe_size << 1,
                            FFMAX(max_probe_size, probe_size + 1))) {
        score = probe_size < max_probe_size ? AVPROBE_SCORE_RETRY : 0;

        /* Read probe data. */
        //分配空间,用于读取数据
        if ((ret = av_reallocp(&buf, probe_size + AVPROBE_PADDING_SIZE)) < 0)
            goto fail;
        //读取指定大小的数据
        if ((ret = avio_read(pb, buf + buf_offset,
                             probe_size - buf_offset)) < 0) {
            /* Fail if error was not end of file, otherwise, lower score. */
            if (ret != AVERROR_EOF)
                goto fail;

            score = 0;
            ret   = 0;          /* error was end of file, nothing read */
        }
        buf_offset += ret;
        if (buf_offset < offset)
            continue;
        pd.buf_size = buf_offset - offset;
        pd.buf = &buf[offset];

        memset(pd.buf + pd.buf_size, 0, AVPROBE_PADDING_SIZE);

        /* Guess file format. */
        //最终的数据判断实际上还是调用我们上面介绍的av_probe_input_format2
        *fmt = av_probe_input_format2(&pd, 1, &score);
        if (*fmt) {
            /* This can only be true in the last iteration. */
            if (score <= AVPROBE_SCORE_RETRY) {
                av_log(logctx, AV_LOG_WARNING,
                       "Format %s detected only with low score of %d, "
                       "misdetection possible!\n", (*fmt)->name, score);
            } else
                av_log(logctx, AV_LOG_DEBUG,
                       "Format %s probed with size=%d and score=%d\n",
                       (*fmt)->name, probe_size, score);
#if 0
            FILE *f = fopen("probestat.tmp", "ab");
            fprintf(f, "probe_size:%d format:%s score:%d filename:%s\n", probe_size, (*fmt)->name, score, filename);
            fclose(f);
#endif
        }
    }

    if (!*fmt)
        ret = AVERROR_INVALIDDATA;

fail:
    /* Rewind. Reuse probe buffer to avoid seeking. */
    ret2 = ffio_rewind_with_probe_data(pb, &buf, buf_offset);
    if (ret >= 0)
        ret = ret2;

    av_freep(&pd.mime_type);
    return ret < 0 ? ret : score;
}

 

    再回到前面一些,是不是已经忘了我们到底再分析什么了?我们正在分析    avformat_open_input 这个方法,并且还是刚说完第一步init_input而已。

int avformat_open_input(AVFormatContext **ps, const char *filename,
                        AVInputFormat *fmt, AVDictionary **options)
{
......
//打开文件并判断数据类型
if ((ret = init_input(s, filename, &tmp)) < 0)
        goto fail;
    s->probe_score = ret;
    //如果 AVIOContext设置了协议白名单,并且AVFormatContext自己没设置,那么拷贝过去
    if (!s->protocol_whitelist && s->pb && s->pb->protocol_whitelist) {
        s->protocol_whitelist = av_strdup(s->pb->protocol_whitelist);
        if (!s->protocol_whitelist) {
            ret = AVERROR(ENOMEM);
            goto fail;
        }
    }
    //黑名单,处理逻辑和白名单相同
    if (!s->protocol_blacklist && s->pb && s->pb->protocol_blacklist) {
        s->protocol_blacklist = av_strdup(s->pb->protocol_blacklist);
        if (!s->protocol_blacklist) {
            ret = AVERROR(ENOMEM);
            goto fail;
        }
    }

    //如果AVFormatContext设置了格式白名单,那么就用当前匹配出来的格式和白名单对比,如果不在白名单中,那么就报错
    if (s->format_whitelist && av_match_list(s->iformat->name, s->format_whitelist, ',') <= 0) {
        av_log(s, AV_LOG_ERROR, "Format not on whitelist \'%s\'\n", s->format_whitelist);
        ret = AVERROR(EINVAL);
        goto fail;
    }
    //跳过打开文件时的初始字节,在encoding中没有效果,在decoding中用户自己设置
    avio_skip(s->pb, s->skip_initial_bytes);
.....
    //读取id3v2
    if (s->pb)
        ff_id3v2_read_dict(s->pb, &s->internal->id3v2_meta, ID3v2_DEFAULT_MAGIC,&id3v2_extra_meta);

.....
}

    关于协议的白名单和协议黑名单,我们在之后讲解.

read_header()

    该方法用于读取多媒体数据文件头,根据视音频流创建相应的AVStream,不同的AVInputFormat使用会用不同的读取方法,所以该方法会在每个自己的demuxer中自己定义。并且理论上需要调用avformat_new_stream来创建 AVStream(但是我看了flv格式的read_header方法,发现并没有调用avformat_new_stream来创建AVStream,而是被推迟了)。avformat_new_stream方法的作用就是初始化AVFormatContext中的AVSteam,分配空间,但是不会填入值。关于AVStream的创建会补充到ffmpeg源码分析 (二)

参考文档

    https://blog.csdn.net/leixiaohua1020/article/details/44064715

转载于:https://my.oschina.net/zzxzzg/blog/1841011

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值