Windows环境 SDL跨平台多媒体库简单入门

SDL(Simple DirectMedia Layer)是一套开放源码代码的跨平台多媒体开发库,使用C语言编写。

SDL提供了多种控制图像、声音、输入输出的函数,让开发者只要用相同或相似的代码就可以开发出跨平台(Linux、Windows、MacOS等)的应用程序软件。

目前SDL多用于开发游戏、模拟器、媒体播放器等多媒体应用领域。

本篇博客学习SDL主要用与辅助学习FFmpeg开发,所以只会记录下与FFmpeg相关的知识点。

SDL将功能分成下列数个子系统(subsystem):(红色项为本博客介绍与使用)

  • SDL_INIT_TIMER:定时器
  • SDL_INIT_AUDIO:音频
  • SDL_INIT_VIDEO:视频
  • SDL_INIT_JOYSTICK:摇杆
  • SDL_INIT_HAPTIC:触摸屏
  • SDL_INIT_GAMECONTROLLER:游戏控制器
  • SDL_INIT_EVENTS:事件
  • SDL_INIT_EVERYTHING:包含上述所有选项

注意:自己电脑需要有qtcreator环境和Visual Studio 2017及以上开发环境!


目录

一、下载与搭建开发环境

1.下载

2.环境搭建

二、案例讲解

1.window显示

2.SDL事件

3.SDL多线程

4.SDL flv播放器

5.SDL 播放pcm文件


一、下载与搭建开发环境

官网:Simple DirectMedia Layer - Homepage

官方文档:SDL2/FrontPage - SDL Wiki

API文档:SDL2/CategoryAPI - SDL Wiki

1.下载

https://github.com/libsdl-org/SDL/releases/icon-default.png?t=O83Ahttps://github.com/libsdl-org/SDL/releases/

2.环境搭建

打开QT新建一个非QT项目

选择32位MinGW编译器

将下载好的SDL解压后拷贝到自己的项目路径下,即与main.c同级路径

在.pro文件中添加把SDL库添加进来

win32 {
INCLUDEPATH += $$PWD/SDL2-2.30.8/include
LIBS += $$PWD/SDL2-2.30.8/lib/x86/SDL2.lib
}

最后在main函数中加入代码测试是否搭建成功

#include <stdio.h>

#include <SDL.h>

#undef main    // 忽略main函数
int main()
{
    printf("Hello World!\n");

    SDL_Window *window = NULL;

    // 初始化SDL
    SDL_Init(SDL_INIT_VIDEO);

    // 创建一个窗体,创建后就会显示
    window = SDL_CreateWindow("MySdlWindow",              // 窗体标题
                              SDL_WINDOWPOS_UNDEFINED,    // 窗口x位置,这里参数是不关心窗口位置
                              SDL_WINDOWPOS_UNDEFINED,    // 窗口y位置,这里参数是不关心窗口位置
                              640,                        // 宽
                              480,                        // 高
                              SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE);    // 一些设置,这里是使用opengl渲染和窗口可设置大小

    if (!window) {
        printf("Can't create window, err:%s\n", SDL_GetError());
        return -1;
    }

    // 延时3秒
    SDL_Delay(3000);

    // 销毁窗体
    SDL_DestroyWindow(window);

    // 释放资源
    SDL_Quit();;

    return 0;
}

编译后,将 ...\SDL2-2.30.8\lib\x86\SDL2.dll文件拷贝到编译路径的debug文件夹和外面

运行测试:

二、案例讲解

1.window显示

(1).SDL视频显示函数介绍

SDL_Init()                                初始化SDL系统

SDL_CreateWindow()             创建窗口SDL_Window

SDL_CreateRenderer()           创建渲染器SDL_Renderer

SDL_CreateTexture()              创建纹理SDL_Texture

SDL_UpdateTexture()             设置纹理的数据

SDL_RenderCopy()                将纹理的数据拷贝给渲染器

SDL_Delay()                           工具函数,用于延时

SDL_Quit()                              退出SDL系统

(2).SDL数据结构介绍

  • SDL_Window                  代表了一个 “窗口”
  • SDL_Renderer                代表了一个 “渲染器”
  • SDL_Texture                   代表了一个 “纹理”
  • SDL_Rect                        一个简单的矩形结构

存储RGB和存储纹理的区别:

比如一个从左到右由红色渐变到蓝色的矩形,用RGB存储的话就需要把矩形中每个点的具体颜色值存储下来;而纹理知识一些描述信息,比如记录了矩形的大小、起始颜色、终止颜色等信息,显卡可以通过这些信息推算出矩形块的详细信息。

所以相对于存储RGB,存储纹理占用的内存要少很多。

  • 一个窗口可以有多个render(渲染器)
  • render用来渲染纹理
  • render用来显示纹理

(3).案例

#include <stdio.h>
#include <SDL.h>

#undef main
int main()
{
    int run = 1;
    SDL_Window *window = NULL;
    SDL_Renderer *renderer = NULL;  // 渲染器
    SDL_Texture *texture = NULL;    // 纹理
    SDL_Rect rect;                  // 矩形,原点在左上角
    rect.w = 50;
    rect.h = 50;

    // 初始化函数,可以确定希望激活的子系统
    SDL_Init(SDL_INIT_VIDEO);

    // 创建窗口
    window = SDL_CreateWindow("Windows",
                              SDL_WINDOWPOS_UNDEFINED,
                              SDL_WINDOWPOS_UNDEFINED,
                              640,
                              480,
                              SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE);

    if (!window) {
        printf("Can't create window, err:%s\n", SDL_GetError());
        return -1;
    }

    // 基于窗口 创建 渲染器
    renderer = SDL_CreateRenderer(window, -1, 0);
    if (!renderer) {
        return -1;
    }

    // 基于渲染器 创建 纹理
    texture = SDL_CreateTexture(renderer,
                                SDL_PIXELFORMAT_RGB888,
                                SDL_TEXTUREACCESS_TARGET,
                                640,
                                480);
    if (!texture) {
        return -1;
    }

    int show_count = 0;
    while (run) {
        rect.x = rand() % 600;
        rect.y = rand() % 400;

        // 设置渲染目标为纹理
        SDL_SetRenderTarget(renderer, texture);
        // 纹理背景为黑色
        SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
        // 清屏
        SDL_RenderClear(renderer);

        // 绘制一个长方形
        SDL_RenderDrawRect(renderer, &rect);
        // 长方形颜色为白色
        SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
        SDL_RenderFillRect(renderer, &rect);

        // 恢复默认,渲染目标为窗口
        SDL_SetRenderTarget(renderer, NULL);
        // 拷贝纹理到CPU
        SDL_RenderCopy(renderer, texture, NULL, NULL);

        // 输出到目标窗口上
        SDL_RenderPresent(renderer);
        SDL_Delay(300);
        if (30 < show_count++) {
            run = 0;    // 结束运行
        }
    }

    // 销毁
    SDL_DestroyTexture(texture);
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);

    // 释放资源
    SDL_Quit();;

    return 0;
}

2.SDL事件

(1).SDL函数介绍

  • SDL_WaitEvent()                         等待一个事件
  • SDL_PushEvent()                       发送一个事件
  • SDL_PumpEvents()                    将硬件设备产生的事件放入事件队列,用于读取事件,在调用该函数之前,必须调用SDL_PumpEvents搜集键盘事件等;
  • SDL_PeepEvents()                     从事件队列提取一个事件

(2).SDL数据结构介绍

  • SDL_Event                                        代表一个事件

(3).案例

#include <stdio.h>
#include <SDL.h>

#define FF_QUIT_EVENT   (SDL_USEREVENT + 1)     // 用户自定义事件

#undef main
int main()
{
    int run = 1;
    SDL_Window *window = NULL;
    SDL_Renderer *renderer = NULL;  // 渲染器

    // 初始化函数
    SDL_Init(SDL_INIT_VIDEO);

    // 创建窗口
    window = SDL_CreateWindow("Windows",
                              SDL_WINDOWPOS_UNDEFINED,
                              SDL_WINDOWPOS_UNDEFINED,
                              640,
                              480,
                              SDL_WINDOW_SHOWN | SDL_WINDOW_BORDERLESS);

    if (!window) {
        printf("Can't create window, err:%s\n", SDL_GetError());
        return -1;
    }

    // 基于窗口 创建 渲染器
    renderer = SDL_CreateRenderer(window, -1, 0);
    if (!renderer) {
        return -1;
    }

    // 设置主窗体的背景色
    SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255);
    // 清屏
    SDL_RenderClear(renderer);
    // 输出到窗口上
    SDL_RenderPresent(renderer);


    SDL_Event event;   
    int exit = 1;
    while (exit) {
        // 等待一个事件
        SDL_WaitEvent(&event);
        switch (event.type) {
        case SDL_KEYDOWN:   // 键盘按下事件
            switch (event.key.keysym.sym) {
            case SDLK_a:
                printf("key down a\n");
                break;
            case SDLK_b:
                printf("key donw b\n");
                break;
            case SDLK_c:
                printf("key down c\n");
                break;
            case SDLK_q:
                printf("key down q and push quit evnet\n");
                SDL_Event event_q;
                event_q.type = FF_QUIT_EVENT;
                SDL_PushEvent(&event_q);
                break;
            default:
                printf("key down 0x%x\n", event.key.keysym.sym);
            }
            break;
        case SDL_MOUSEBUTTONDOWN:   // 鼠标按下事件
            if (SDL_BUTTON_LEFT == event.button.button) {   // 鼠标左键
                printf("mouse donw left\n");
            } else if (SDL_BUTTON_RIGHT == event.button.button) {   // 鼠标右键
                printf("mouse donw right\n");
            } else if (SDL_BUTTON_MIDDLE == event.button.button) {
                printf("mouse donw middle\n");
            } else {
                printf("mouse down %d\n", event.button.button);
            }
            break;
        case SDL_MOUSEMOTION:   // 鼠标移动事件
            printf("mouse move (%d, %d)\n", event.button.x, event.button.y);
            break;
        case FF_QUIT_EVENT:
            printf("receive quit event\n");
            exit = 0;
            break;
        }
    }


    // 销毁
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);

    // 释放资源
    SDL_Quit();

    return 0;
}

3.SDL多线程

(1).SDL函数介绍

  • SDL_CreateThread()                                                           创建线程
  • SDL_WaitThread()                                                               线程等待
  • SDL_CreateMutex()  / SDL_DestroyMutex()                       创建与销毁互斥锁
  • SDL_LockMutex() / SDL_UnlockMutex()                            锁定互斥和解锁互斥
  • SDL_CreateCond() / SDL_DestoryCond()                          创建与销毁条件变量(信号量)
  • SDL_CondWait() / SDL_CondSignal()                                条件变量(信号量)等待或通知

(2).SDL数据结构介绍

  • SDL_Mutex                互斥锁
  • SDL_cond                条件变量(互斥锁)

(3).案例

#include <stdio.h>
#include <SDL.h>

SDL_mutex *s_lock = NULL;
SDL_cond *s_cond = NULL;

int thread_work(void *arg) {
    SDL_LockMutex(s_lock);
    printf("        <=====thread_work sleep\n");
    sleep(5);
    printf("        <=====thread_work wait\n");

    // 释放s_lock资源,并等待signal。之所以释放s_lock是让别的线程能够获取到s_lock
    SDL_CondWait(s_cond, s_lock);
    printf("        <=====thread_work receive signal, continue todo ~_~!!!\n");
    printf("        <=====thread_work end\n");

    SDL_UnlockMutex(s_lock);

    return 0;
}

#undef main
int main()
{
    s_lock = SDL_CreateMutex();
    s_cond = SDL_CreateCond();

    SDL_Thread *t = SDL_CreateThread(thread_work, "thread_work", NULL);
    if (!t) {
        printf("%s\n", SDL_GetError());
        return -1;
    }

    sleep(1);
    printf("main execute =====>\n");


    SDL_LockMutex(s_lock);      // 获取锁
    printf("main SDL_LockMutex(s_lock) before =====>\n");


    SDL_CondSignal(s_cond);     // 发送信号,唤醒等待的线程
    printf("main ready send signal =====>\n");
    printf("main SDL_CondSignal(s_cond) before =====>\n");


    SDL_UnlockMutex(s_lock);    // 释放锁,让其他线程可以拿到锁
    printf("main SDL_UnlockMutex(s_lock) before =====>\n");

    SDL_WaitThread(t, NULL);
    SDL_DestroyMutex(s_lock);
    SDL_DestroyCond(s_cond);

    return 0;
}

4.SDL flv播放器

集合前面三点的知识点构成如下示例代码;

需要准备一个flv视频,可通过如下命令提取;格式需要是yuv420p;

ffmpeg -i xxx.mp4 -t 10 -pix_fmt yuv420p -s 320x240 yuv420p_320x240.yuv
#include <stdio.h>
#include <SDL.h>

// 自定义消息类型
#define REFRESH_EVENT   (SDL_USEREVENT + 1)     // 请求画面刷新事件
#define QUIT_EVENT      (SDL_USEREVENT + 2)     // 退出事件

// 定义分辨率
#define YUV_WIDTH   320
#define YUV_HEIGHT  240

// 定义YUV格式
#define YUV_FORMAT  SDL_PIXELFORMAT_IYUV

int s_thread_exit = 0;  // 退出标志,1退出

// 刷新视频画面
int refresh_video_timer(void *data) {
    while (!s_thread_exit) {
        SDL_Event event;
        event.type = REFRESH_EVENT;
        SDL_PushEvent(&event);
        SDL_Delay(40);
    }

    s_thread_exit = 0;

    // 推送退出事件
    SDL_Event event;
    event.type = QUIT_EVENT;
    SDL_PushEvent(&event);

    return 0;
}

#undef main
int main()
{
    // 初始化 SDL
    if (SDL_Init(SDL_INIT_VIDEO)) {
        fprintf(stderr, "Could not initialize SDL - %s\n", SDL_GetError());
        return -1;
    }

    SDL_Event event;                    // 事件
    SDL_Rect rect;                      // 矩形
    SDL_Window *window = NULL;          // 窗口
    SDL_Renderer *renderer = NULL;      // 渲染
    SDL_Texture *texture = NULL;        // 纹理
    SDL_Thread *timer_thread = NULL;    // 请求刷新线程
    uint32_t pixformat = YUV_FORMAT;    // YUV420P, 既是SDL_PIXELFORMAT_IYUV

    // 分辨率
    // YUV的分辨率
    int video_width = YUV_WIDTH;
    int video_height = YUV_HEIGHT;
    // 显示窗口的分辨率
    int win_width = YUV_WIDTH;
    int win_height = YUV_WIDTH;

    // YUV文件句柄
    FILE *video_fd = NULL;
    const char *yuv_path = "yuv420p_320x240.yuv";

    size_t video_buff_len = 0;
    uint8_t *video_buf = NULL;

    // 测试的视频文件是YUV420P格式的
    uint32_t y_frame_len = video_width * video_height;
    uint32_t u_frame_len = video_width * video_height / 4;
    uint32_t v_frame_len = video_width * video_height / 4;
    uint32_t yuv_frame_len = y_frame_len + u_frame_len + v_frame_len;

    // 创建窗口
    window = SDL_CreateWindow("Simplest YUV Player",
                              SDL_WINDOWPOS_UNDEFINED,
                              SDL_WINDOWPOS_UNDEFINED,
                              video_width, video_height,
                              SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE);
    if (!window) {
        fprintf(stderr, "SDL: could not create window, err: %s\n", SDL_GetError());
        goto _FAIL;
    }

    // 基于窗口创建渲染器
    renderer = SDL_CreateRenderer(window, -1, 0);
    // 基于渲染器创建纹理
    texture = SDL_CreateTexture(renderer,
                                pixformat,
                                SDL_TEXTUREACCESS_STREAMING,
                                video_width,
                                video_height);
    // 分配空间
    video_buf = (uint8_t *)malloc(yuv_frame_len);
    if (!video_buf) {
        fprintf(stderr, "Failed to alloce yuv frame space!\n");
        // todo...
    }

    // 打开YUV文件
    video_fd = fopen(yuv_path, "rb");
    if (!video_fd) {
        fprintf(stderr, "Failed to open yuv file!\n");
        goto _FAIL;
    }

    // 创建请求刷新线程
    timer_thread = SDL_CreateThread(refresh_video_timer, NULL, NULL);

    while (1) {
        // 收取SDL系统里面的事件
        SDL_WaitEvent(&event);

        if (REFRESH_EVENT == event.type) {  // 画面刷新事件
            video_buff_len = fread(video_buf, 1, yuv_frame_len, video_fd);
            if (0 >= video_buff_len) {
                //fprintf(stderr, "Failed to read data from yuv file!\n");
                // 要么读取失败,要么读取完毕!
                goto _FAIL;
            }

            // 设置纹理的数据      参数二矩形传,更新的是一整个画面
            SDL_UpdateTexture(texture, NULL, video_buf, video_width);

            // 显示区域,可以通过修改w和h进行缩放
            rect.x = 0;
            rect.y = 0;
            float w_ratio = win_width * 1.0 / video_width;
            float h_ratio = win_height *1.0 / video_height;
            rect.w = video_width * w_ratio;
            rect.h = video_height * h_ratio;

            // 清除当前显示
            SDL_RenderClear(renderer);
            // 将纹理的数据拷贝给渲染器
            SDL_RenderCopy(renderer, texture, NULL, &rect);
            // 显示
            SDL_RenderPresent(renderer);

        } else if (SDL_WINDOWEVENT == event.type) {
            // 通过这里可以实时获取到窗口大小
            SDL_GetWindowSize(window, &win_width, &win_height);

        } else if (SDL_QUIT == event.type) {
            s_thread_exit = 1;

        } else if (QUIT_EVENT == event.type) {
            break;
        }
    }

_FAIL:
    s_thread_exit = 1;      // 保证线程可以正常退出
    // 释放资源
    if (timer_thread) {
        SDL_WaitThread(timer_thread, NULL);     // 等待线程退出
    }
    if (video_buf) {
        free(video_buf);
    }
    if (video_fd) {
        fclose(video_fd);
    }
    if (texture) {
        SDL_DestroyTexture(texture);
    }
    if (renderer) {
        SDL_DestroyRenderer(renderer);
    }
    if (window) {
        SDL_DestroyWindow(window);
    }

    // 释放资源
    SDL_Quit();

    return 0;
}

5.SDL 播放pcm文件

(1).SDL函数介绍

int SDLCALL SDL_OpenAudio(SDL_AudioSpec * desired, SDL_AudioSpec * obtained);

desired:期望的参数;

obtained:返回的实际音频设备的参数,一般情况下设置为NULL;

typedef struct SDL_AudioSpec
{
    int freq;                   /**< 音频采样率 */
    SDL_AudioFormat format;     /**< 音频数据格式 */
    Uint8 channels;             /**< 声道数:1 单声道,2 立体声 */
    Uint8 silence;              /**< 设置静音的值,因为声音采样是有符号的,所以0当然就是这个值 */
    Uint16 samples;             /**< 音频缓冲区中的采样个数,要求必须是2的n次方 */
    Uint16 padding;             /**< 考虑到兼容性的一个参数 */
    Uint32 size;                /**< 音频缓冲区的大小,以字节为单位 */
    SDL_AudioCallback callback; /**< 填充音频缓冲区的回调函数 (NULL to use SDL_QueueAudio()). */
    void *userdata;             /**< 用户自定义的数据 */
} SDL_AudioSpec;
// 回调函数
typedef void (SDLCALL * SDL_AudioCallback) (void *userdata, Uint8 * stream, int len);

userdata:SDL_AudioSpec结构体中的用户自定义数据,一般情况下可以不用;

stream:该指针指向需要填充的音频缓冲区;

len:音频缓冲区的大小(以字节为单位)

// 播放音频数据
void SDLCALL SDL_PauseAudio(int pause_on);

pause_on:设置为0的时候即可开始播放音频数据。设置为1的时候,将会播放静音的值。

(2).案例

#include <stdio.h>
#include <SDL.h>


// 每次读取2帧数据,以1024个采样点为一帧,2通道,16bit采样点为例
#define PCM_BUFFER_SIZE (1024 * 2 * 2 * 2)  // 第一个2是2帧数据,第二个2是两通道,第三个2是16bit,即两字节

// 音频PCM数据缓存
static Uint8 *s_audio_buf = NULL;
// 目前读取的位置
static Uint8 *s_audio_pos = NULL;
// 缓存结束位置
static Uint8 *s_audio_end = NULL;


// 音频设备回调函数
void fill_audio_pcm(void *udata, Uint8 *stream, int len) {
    SDL_memset(stream, 0, len);

    // 数据读取完毕
    if (s_audio_pos >= s_audio_end) {
        return;
    }

    // 数据够了就读取预设长度,数据不够就只读取部分数据
    int remain_buffer_len = s_audio_end - s_audio_pos;
    len = (len < remain_buffer_len) ? len : remain_buffer_len;

    // 拷贝数据到stream,并调整音量
    SDL_MixAudio(stream, s_audio_pos, len, SDL_MIX_MAXVOLUME/8);    // /8 主要用于调小点音量,最大值128
    printf("len = %d\n", len);

    // 移动缓存指针
    s_audio_pos += len;
}


/*  提取PCM文件
    ffmpeg -i xxx.mp4 -t 10 -codec:a pcm_s16le -ar 44100 -ac 2 -f s16le 44100_16bit_2ch.pcm

    测试PCM文件
    ffplay -ar 44100 -ac 2 -f s16le 44100_16bit_2ch.pcm
*/
#undef main
int main(int argc, char *argv[]) {
    int ret = -1;
    FILE *audio_fd = NULL;
    SDL_AudioSpec spec;
    const char *path = "44100_16bit_2ch.pcm";

    // 每次缓存的长度
    size_t read_buffer_len = 0;

    // SDL初始化
    if (SDL_Init(SDL_INIT_AUDIO)) { // 支持AUDIO
        fprintf(stderr, "Could not initialize SDL - %s\n", SDL_GetError());
        return ret;
    }


    // 打开PCM文件
    audio_fd = fopen(path, "rb");
    if (!audio_fd) {
        fprintf(stderr, "Failed to open pcm file!\n");
        goto _FAIL;
    }


    s_audio_buf = (uint8_t *)malloc(PCM_BUFFER_SIZE);

    // 音频参数设置
    spec.freq = 44100;              // 采样频率
    spec.format = AUDIO_S16SYS;     // 采用点格式
    spec.channels = 2;              // 2通道
    spec.silence = 0;
    spec.samples = 1024;            // 每次读取的采用数量,由此值确定多久触发一次回调函数
    spec.callback = fill_audio_pcm; // 回调函数
    spec.userdata = NULL;

    // 打开音频设备
    if (SDL_OpenAudio(&spec, NULL)) {
        fprintf(stderr, "Failed to open audio device, %s\n", SDL_GetError());
        goto _FAIL;
    }


    // play audio
    SDL_PauseAudio(0);

    int data_count = 0;
    while (1) {
        // 从文件读取PCM数据
        read_buffer_len = fread(s_audio_buf, 1, PCM_BUFFER_SIZE, audio_fd);
        if (0 == read_buffer_len) {
            break;
        }

        data_count += read_buffer_len;  // 统计读取的数据总字节
        printf("now playing %10d bytes data.\n", data_count);
        s_audio_end = s_audio_buf + read_buffer_len;    // 更新buffer的结束位置
        s_audio_pos = s_audio_buf;      // 更新buffer的起始位置

        // the main thread wait for a monent
        while (s_audio_pos < s_audio_end) {
            SDL_Delay(2);   // 等待PCM数据消耗
            /*
             * 延时时间计算:每次读取的采用数量 / 采样频率
             *              spec.samples / spec.freq
             * 在这里的计算是:1024 / 44100 = 0.023 (约等于23毫秒)
             *
             * 所以建议延时时间设置为最终计算结果的整数倍,否则会出现断音效果!!!
             */
        }
    }
    printf("play PCM finish\n");
    // 关闭音频设备
    SDL_CloseAudio();


_FAIL:
    // release some resources
    if (s_audio_buf) {
        free(s_audio_buf);
    }

    if (audio_fd) {
        fclose(audio_fd);
    }

    // quit SDL
    SDL_Quit();

    return 0;
}

完!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

cpp_learners

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值