多道程序缓冲区协调操作 C++11多线程+OpenGL可视化实现

先上题目:

在这里插入图片描述

在这里插入图片描述

再上结果

在这里插入图片描述

分析

对于这道题第一想法大都会是生产-消费者模型。这里确实非常类似,区别主要就是Move操作,但是Move操作可以看成 Put 和 Get 操作结合。

这里首先讲解下生产-消费者模型:

生产者-消费者问题是典型的PV操作问题,假设系统中有一个比较大的缓冲池,生产者的任务是只要缓冲池未满就可以将生产出的产品放入其中,而消费者的任务是只要缓冲池未空就可以从缓冲池中拿走产品。缓冲池被占用时,任何进程都不能访问。

简单想法

那么我们的第一想法应该就是:

// 共享数据
#define BUFFER_SIZE 10
typedef struct{...} item;
item buffer[BUFFER_SIZE];
int in = out = counter = 0;
// 生产者进程
while(true){
	while(counter == BUFFER_SIZE) ;
	buffer[in] = item;
	in = (in+1)%BUFFER_SIZE;
	counter++;
}
// 消费者进程
while(true){
	while(counter == 0) ;
	item = buffer[out];
	out = (out+1)%BUFFER_SIZE;
	counter--;
}

上面的代码引用自哈工大操作系统的多线程讲解。第一次看到就觉得太精简漂亮了。
然而如果你没有汇编基础或者有基础但是没注意的话,就发现不了其中的猫腻!

问题及解决

问题就在于我们的程序一般都会编译成汇编语言,然后一般对应着编译成机器码。

比如: counter++; 就不是这么方便(暂时不考虑编译器优化)

它会转化为对应的汇编代码伪代码:

reg = counter;
reg++;
counter = reg;

即需要先将内存的值移动到寄存器,再修改寄存器,最后把寄存器的值保存到内存。

而每一个进程都有其对应的PCB,它会保存进程自己的寄存器备份。

所以一个可能的执行序列为:

P.reg = counter;	// 假设 counter 初始值为5
P.reg++;			// 6
C.reg = counter;	// 5
C.reg--;			// 4
counter = P.reg;	// 6
counter = C.reg;	// 4

所以counter的值最终变成了4!!!而不是5。

那么我们应该怎么办!一个简单方法是我执行生产者进程时,直接关闭中断就行了(即让操作系统不要跳过来跳过去的),执行完后你再跳呗。关中断可是需要权限的(内核态才行,要不然你忘了开回去不就完了)。即需要中断进入内核态!(理解需要一点操作系统知识)

停!这么麻烦,自然需要用到库,那么我们就需要 C++11 。C++新标准,跨平台,使用方便!!

C++11 thread使用

一个简单的想法就是用C++11的锁。

// 共享数据
#define BUFFER_SIZE 10
typedef struct{
	...
} item;
item buffer[BUFFER_SIZE];
std::mutex mtx; // 互斥量,保护产品缓冲区
int in = out = counter = 0;
// 生产者进程
while(true){
	while(counter == BUFFER_SIZE) ;
	std::lock_guard<std::mutex> lock(mtx); // 锁上
    buffer[in] = item;
	in = (in+1)%BUFFER_SIZE;
	counter++;
	// 自动解锁
}
// 消费者进程
while(true){
	while(counter == 0) ;
	std::lock_guard<std::mutex> lock(mtx);
    item = buffer[out];
	out = (out+1)%BUFFER_SIZE;
	counter--;
}

进一步改进

上面解法有没有问题??笔者其实之前就觉得可以了,但是看了看操作系统书籍后发现问题了。自己的代码是"忙等待"的!!

(还有一个隐患!因为我们在判断 counter 时没有加锁!这可能在多个生产进程或者多个消费进程时,出现问题!)

忙等状态:
当一个进程正处在某临界区内,任何试图进入其临界区的进程都必须进入代码连续循环,陷入忙等状态。连续测试一个变量直到某个值出现为止,称为忙等。
(没有进入临界区的正在等待的某进程不断的在测试循环代码段中的变量的值,占着处理机而不释放,这是一种忙等状态~)-> 这个时候应该释放处理机让给其他进程

让权等待:
当进程不能进入自己的临界区时,应立即释放处理机,以免进程陷入“忙等”状态~(受惠的是其他进程)

(参考自:https://blog.csdn.net/liuchuo/article/details/51986201

(我知道为啥我的风扇呼呼的了)

信号量与PV原语

那该怎么解决呢?事情似乎越来越复杂了!这里真的不得不佩服计算机的大神们!

https://baike.baidu.com/item/艾兹格·迪科斯彻/5029407?fr=aladdin

大家学数据结构都听过 Dijkstra 算法吧!这位大神还提出过信号量和PV原语!

这个大杀器可以解决很多经典的多线程/进程问题。现在我们先忘掉前面的那些东西,抽象出两个原语(不会被系统中断打断)。

PV操作:一种实现进程互斥与同步的有效方法,包含P操作与V操作。
	
P操作:使 S=S-1 ,若 S>=0 ,则该进程继续执行,否则排入等待队列。

V操作:使 S=S+1 ,若 S>0 ,唤醒等待队列中的一个进程。

临界资源:同一时刻只允许一个进程访问的资源,与上面所说的 S 有关联。

对于生产消费者问题直接可以这样解决:

在这里插入图片描述

核心代码

首先用实现c++11信号量,因为C++11只提供了互斥量(锁)和条件变量。
所以我们通过这两个配合实现 semaphore 信号量。

class semaphore {
    int count;
    std::mutex mtk;
    std::condition_variable cv;

public:
    explicit semaphore(int value = 1) : count(value) {}

    void wait() {
        std::unique_lock<std::mutex> lck(mtk);
        if (--count < 0)//资源不足挂起线程
            cv.wait(lck);
    }

    void signal() {
        std::unique_lock<std::mutex> lck(mtk);
        if (++count <= 0)//有线程挂起,唤醒一个
            cv.notify_one();
    }
};

然后就是生产消费者的代码

struct ItemRepository {
    int index;
    int BUFFER_SIZE; // Item buffer size.
    object **buffer; // 产品缓冲区
    size_t out = 0; // 消费者读取产品位置.
    size_t in = 0; // 生产者写入产品位置.
    size_t counter = 0; // 当前容量
    semaphore *mtxL;
    semaphore *emptyL;
    semaphore *fullL;

	// 省略构造析构函数
}
void ProduceItem(ItemRepository *ir, object *item) {
    ir->emptyL->wait();
    ir->mtxL->wait();
    ir->buffer[ir->in] = item;
    ir->in = (ir->in + 1) % ir->BUFFER_SIZE;
    ir->counter++;
    ir->mtxL->signal();
    ir->fullL->signal();
}
object *ConsumeItem(ItemRepository *ir) {
    ir->fullL->wait();
    ir->mtxL->wait();
    auto *item = ir->buffer[ir->out];
    ir->buffer[ir->out] = nullptr;
    ir->out = (ir->out + 1) % ir->BUFFER_SIZE;
    ir->counter--;
    ir->mtxL->signal();
    ir->emptyL->signal();
    return item; // 返回产品.
}
void MoveItem(ItemRepository *in, ItemRepository *out) {
    ProduceItem(out, ConsumeItem(in));
}

完整代码

因为用到了OpenGL所以需要配置OpenGL,用的是旧版OpenGL,需要配置glut。
在windows上Visual Studio非常容易配置,如果是clion(笔者使用的)就需要配置
CMakeLists.txt,这里给出参考配置。(VS用户请忽略,网上参考glut配置)

cmake_minimum_required(VERSION 3.15)
project(OpenGLGame)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -D FREEGLUT_STATIC")

set(OPENGL_FILE F:/freeglut)
INCLUDE_DIRECTORIES(${OPENGL_FILE}/include)
link_directories("${OPENGL_FILE}/lib")

add_executable(OpenGLGame mul.h main.cpp semaphore.h)

target_link_libraries(OpenGLGame freeglut_static opengl32 winmm gdi32 glu32.lib)
  • semaphore.h
#ifndef OPENGLGAME_SEMAPHORE_H
#define OPENGLGAME_SEMAPHORE_H

#include <thread>
#include <mutex>
#include <condition_variable>

class semaphore {
    int count;
    std::mutex mtk;
    std::condition_variable cv;

public:
    explicit semaphore(int value = 1) : count(value) {}

    void wait() {
        std::unique_lock<std::mutex> lck(mtk);
        if (--count < 0)//资源不足挂起线程
            cv.wait(lck);
    }

    void signal() {
        std::unique_lock<std::mutex> lck(mtk);
        if (++count <= 0)//有线程挂起,唤醒一个
            cv.notify_one();
    }
};

#endif //OPENGLGAME_SEMAPHORE_H
  • mul.h
#ifndef OPENGLGAME_MUL_H
#define OPENGLGAME_MUL_H

#include "semaphore.h"

// 产品模型
struct object {
    float r{}, g{}, b{};
    int alli{}, rei{};

    explicit object(int index) : alli(index) {
        r = rand() % 10 / 10.0;
        g = rand() % 10 / 10.0;
        b = rand() % 10 / 10.0;
    }

    object() = default;
};

struct ItemRepository {
    int index;
    int BUFFER_SIZE; // Item buffer size.
    object **buffer; // 产品缓冲区
    size_t out = 0; // 消费者读取产品位置.
    size_t in = 0; // 生产者写入产品位置.
    size_t counter = 0; // 当前容量
    semaphore *mtxL;
    semaphore *emptyL;
    semaphore *fullL;

    ItemRepository(int index, int bufferSize) : index(index), BUFFER_SIZE(bufferSize) {
        buffer = new object *[BUFFER_SIZE]();
        emptyL = new semaphore(bufferSize);
        fullL = new semaphore(0);
        mtxL = new semaphore();
    }

    virtual ~ItemRepository() {
        delete[]buffer;
        delete mtxL;
        delete emptyL;
        delete fullL;
    }
};

void ProduceItem(ItemRepository *ir, object *item) {
    ir->emptyL->wait();
    ir->mtxL->wait();
    ir->buffer[ir->in] = item;
    item->rei = ir->in;
    ir->in = (ir->in + 1) % ir->BUFFER_SIZE;
    ir->counter++;
    ir->mtxL->signal();
    ir->fullL->signal();
}

object *ConsumeItem(ItemRepository *ir) {
    ir->fullL->wait();
    ir->mtxL->wait();
    auto *item = ir->buffer[ir->out];
    ir->buffer[ir->out] = nullptr;
    item->rei = ir->out;
    ir->out = (ir->out + 1) % ir->BUFFER_SIZE;
    ir->counter--;
    ir->mtxL->signal();
    ir->emptyL->signal();
    return item; // 返回产品.
}

void MoveItem(ItemRepository *in, ItemRepository *out) {
    ProduceItem(out, ConsumeItem(in));
}

void putTask(ItemRepository *gItemRepository2, ItemRepository *gItemRepository, const float *idle) {
    static int i;
    while (true) {
        int idlei = *idle;
        std::this_thread::sleep_for(std::chrono::milliseconds(idlei));
        ProduceItem(gItemRepository, new object(i++)); // 循环生产 kItemsToProduce 个产品.
    }
}

void getTask(ItemRepository *gItemRepository, ItemRepository *gItemRepository2, const float *idle) {
    while (true) {
        int idlei = *idle;
        std::this_thread::sleep_for(std::chrono::milliseconds(idlei));
        delete ConsumeItem(gItemRepository); // 消费一个产品.
    }
}

void moveTask(ItemRepository *inRepository, ItemRepository *outRepository, const float *idle) {
    while (true) { // just move it
        int idlei = *idle;
        std::this_thread::sleep_for(std::chrono::milliseconds(idlei));
        MoveItem(inRepository, outRepository); // 消费一个产品.
    }
}

#endif //OPENGLGAME_MUL_H
  • main.cpp
#include <GL/glut.h>
#include <iostream>
#include <vector>
#include <ctime>
#include "mul.h"

static float myratio;  // angle绕y轴的旋转角,ratio窗口高宽比
static float x = 0.0f, y = 0.0f, z = 5.0f;  //相机位置
static float lx = 0.0f, ly = 0.0f, lz = -1.0f;  //视线方向,初始设为沿着Z轴负方向

const int WIDTH = 1000;
const int HEIGHT = 1000;

bool mouseDown = false;
float xrot = 0.0f, yrot = 0.0f;
float xdiff = 0.0f, ydiff = 0.0f;

const float zoom = 0.1f;
const int width = 200;
const int height = 200;

int index; // 显示列表
bool colorflag;

struct bufferObj {
    ItemRepository *ir;
    float x, y, z;
    float r{}, g{}, b{};

    bufferObj(ItemRepository *ir, float x, float y, float z) : ir(ir), x(x), y(y), z(z) {
        if (!colorflag) srand((unsigned int) time(0)), colorflag = true;
        r = rand() % 10 / 10.0;
        g = rand() % 10 / 10.0;
        b = rand() % 10 / 10.0;
    }

    bufferObj(ItemRepository *ir, float x, float y, float z, float r, float g, float b)
            : ir(ir), x(x), y(y), z(z), r(r), g(g), b(b) {}
};

std::vector<bufferObj *> ghd;
std::vector<std::thread *> vt;
std::vector<float> vSpeed{500, 1210, 1800, 2190, 2720, 2580, 3010};

// 总任务配置区
void initGhd() {
//    // 添加框,即缓冲区,为了显示好看,尽量奇数
//    // 缓冲区标号、大小,缓冲区摆放位置
//    ghd.push_back(new bufferObj(new ItemRepository(1, 9), -10 * zoom, 0, 0));
//    ghd.push_back(new bufferObj(new ItemRepository(2, 3), 10 * zoom, 7 * zoom, 0));
//    ghd.push_back(new bufferObj(new ItemRepository(3, 3), 10 * zoom, 0 * zoom, 0));
//    ghd.push_back(new bufferObj(new ItemRepository(4, 3), 10 * zoom, -7 * zoom, 0));
//    // 任务,即启动箭头任务
//    // 任务名、输入缓冲区,输出缓冲区,速度
//    vt.push_back(new std::thread(putTask, nullptr, ghd[0]->ir, &vSpeed[0]));
//    vt.push_back(new std::thread(moveTask, ghd[0]->ir, ghd[1]->ir, &vSpeed[1]));
//    vt.push_back(new std::thread(moveTask, ghd[0]->ir, ghd[2]->ir, &vSpeed[2]));
//    vt.push_back(new std::thread(moveTask, ghd[0]->ir, ghd[3]->ir, &vSpeed[3]));
//    vt.push_back(new std::thread(getTask, ghd[1]->ir, nullptr, &vSpeed[4]));
//    vt.push_back(new std::thread(getTask, ghd[2]->ir, nullptr, &vSpeed[5]));
//    vt.push_back(new std::thread(getTask, ghd[3]->ir, nullptr, &vSpeed[6]));
//    for (auto &item:vt) item->detach();
    // 添加框,即缓冲区,为了显示好看,尽量奇数
    // 缓冲区标号、大小,缓冲区摆放位置
    ghd.push_back(new bufferObj(new ItemRepository(1, 9), -10 * zoom, 0, 0));
    ghd.push_back(new bufferObj(new ItemRepository(2, 5), 10 * zoom, 6 * zoom, 0));
    ghd.push_back(new bufferObj(new ItemRepository(3, 5), 10 * zoom, -6 * zoom, 0));
    // 任务,即启动箭头任务
    // 任务名、输入缓冲区,输出缓冲区,速度
    vt.push_back(new std::thread(putTask, nullptr, ghd[0]->ir, &vSpeed[0]));
    vt.push_back(new std::thread(moveTask, ghd[0]->ir, ghd[1]->ir, &vSpeed[1]));
    vt.push_back(new std::thread(moveTask, ghd[0]->ir, ghd[2]->ir, &vSpeed[2]));
    vt.push_back(new std::thread(getTask, ghd[1]->ir, nullptr, &vSpeed[3]));
    vt.push_back(new std::thread(getTask, ghd[2]->ir, nullptr, &vSpeed[4]));
    for (auto &item:vt) item->detach();
}

/**
 * 定义观察方式
 */
void changeSize(int w, int h) {
    //除以0的情况
    if (h == 0) h = 1;
    myratio = 1.0f * w / h;
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    //设置视口为整个窗口大小
    glViewport(0, 0, w, h);
    //设置可视空间
    gluPerspective(45, myratio, 1, 1000);

    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    gluLookAt(x, y, z, x + lx, y + ly, z + lz, 0.0f, 1.0f, 0.0f);
}

/**
 * 视野漫游函数
 */
void orientMe(float directionx, float directiony) {
    x += directionx * 0.1;
    y += directiony * 0.1;
    glLoadIdentity();
    gluLookAt(x, y, z, x + lx, y + ly, z + lz, 0.0f, 1.0f, 0.0f);
}

/**
 * 视野漫游函数
 */
void moveMeFlat(int direction) {
    z += direction * (lz) * 0.1;
    glLoadIdentity();
    gluLookAt(x, y, z, x + lx, y + ly, z + lz, 0.0f, 1.0f, 0.0f);
}

/**
 * 鼠标事件
*/
void mouse(int button, int state, int x, int y) {
    if (button == GLUT_LEFT_BUTTON && state == GLUT_DOWN) {
        mouseDown = true;
        xdiff = x - yrot;
        ydiff = -y + xrot;
    } else
        mouseDown = false;
}

/**
 * 鼠标移动事件
 */
void mouseMotion(int x, int y) {
    if (mouseDown) {
        yrot = x - xdiff;
        xrot = y + ydiff;
        glutPostRedisplay();
    }
}

/**
 * 加入按键控制
 */
void processSpecialKeys(int key, int x, int y) {
    switch (key) {
        case GLUT_KEY_UP:
            orientMe(0, 1);
            break;
        case GLUT_KEY_DOWN:
            orientMe(0, -1);
            break;
        case GLUT_KEY_LEFT:
            orientMe(-1, 0);
            break;
        case GLUT_KEY_RIGHT:
            orientMe(1, 0);
            break;
        case GLUT_KEY_PAGE_DOWN:
            moveMeFlat(-1);
            break;
        case GLUT_KEY_PAGE_UP:
            moveMeFlat(1);
            break;
        default:
            break;
    }
}

void processNormalKeys(unsigned char key, int x, int y) {
    switch (key) {
        case 'q':
            vSpeed[0] *= 0.9;
            break;
        case 'w':
            vSpeed[1] *= 0.9;
            break;
        case 'e':
            vSpeed[2] *= 0.9;
            break;
        case 'r':
            vSpeed[3] *= 0.9;
            break;
        case 't':
            vSpeed[4] *= 0.9;
            break;

        case 'a':
            vSpeed[0] *= 1.1;
            break;
        case 's':
            vSpeed[1] *= 1.1;
            break;
        case 'd':
            vSpeed[2] *= 1.1;
            break;
        case 'f':
            vSpeed[3] *= 1.1;
            break;
        case 'g':
            vSpeed[4] *= 1.1;
            break;
        default:
            break;
    }

    char str[80];
    sprintf(str, "Now Speed:\n\tPut:%.2f\n\tMove1:%.2f\n\tMove2:%.2f\n\tGet1:%.2f\n\tGet2:%.2f\n\n",
            1000.0f / vSpeed[0], 1000.0f / vSpeed[1], 1000.0f / vSpeed[2], 1000.0f / vSpeed[3], 1000.0f / vSpeed[4]);
//    TextOut(10, 10, str);
    system("cls");
    std::cout << str;
}

void drawSphere(ItemRepository *ir, object *ob, int i) {
    glColor3f(ob->r, ob->g, ob->b);
    glPushMatrix();
    glTranslatef(0, (i - int(ir->BUFFER_SIZE) / 2) * zoom * 2, zoom);
    glutSolidSphere(zoom, 100, 100);
    glPopMatrix();
}

void drawArrow() {
    glTranslatef(0, 0, zoom);
    glRotatef(90, 0.0f, 1.0f, 0.0f);
    glutSolidCone(zoom, zoom * 2, 100, 100);
}

void myDisplay() {
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glLoadIdentity();
    gluLookAt(x, y, z, x + lx, y + ly, z + lz, 0.0f, 1.0f, 0.0f);

    // 实现鼠标旋转的核心
    glRotatef(xrot, 1.0f, 0.0f, 0.0f);
    glRotatef(yrot, 0.0f, 1.0f, 0.0f);

    // 绘制球
    for (auto &item:ghd) {
        glPushMatrix();
        glTranslatef(item->x, item->y, item->z);
        for (int i = 0; i < item->ir->BUFFER_SIZE; i++) {
            object *ob = item->ir->buffer[i];
            if (ob) drawSphere(item->ir, item->ir->buffer[i], ob->rei);
        }

        glColor3f(item->r, item->g, item->b);
        glPushMatrix();
        glTranslatef(-5 * zoom, (int(item->ir->in) - int(item->ir->BUFFER_SIZE) / 2) * zoom * 2, 0);
        drawArrow();
        glColor3f(item->r * 0.5, item->g * 0.5, item->b * 0.5);
        glPopMatrix();
        glTranslatef(5 * zoom, (int(item->ir->out) - int(item->ir->BUFFER_SIZE) / 2) * zoom * 2, 0);
        drawArrow();
        glPopMatrix();
    }

    // 最先画坐标和框
    glPushMatrix();
    glCallList(index);
    glPopMatrix();
    glFlush();
    glutSwapBuffers();
}

void myIdle(int i) {
    for (auto &item:ghd) item->ir->mtxL->wait();
    myDisplay();
    for (auto &item:ghd) item->ir->mtxL->signal();
    glutTimerFunc(20, myIdle, 1);
}

void init() {
    initGhd();
    glEnable(GL_DEPTH_TEST);
    glClearColor(0.93f, 0.93f, 0.93f, 0.0f);

    // 显示列表
    index = glGenLists(1);//glGenLists()唯一的标识一个显示列表
    glNewList(index, GL_COMPILE);//用于对显示列表进行定界。第一个参数是一个整形索引值,由glGenLists()指定

    // 框
    glColor3f(0, 1, 0);
    for (auto &item:ghd) {
        glPushMatrix();
        glTranslatef(item->x, item->y, item->z + zoom);
        glScalef(1, item->ir->BUFFER_SIZE, 1);
        glLineWidth(2);
        glutWireCube(2 * zoom);
        glPopMatrix();
    }

    // 再画线
    glPushMatrix();
    glTranslatef(-width * zoom, 0, 0);
    glColor4f(1, 0, 0, 0);
    for (int i = 0; i < 2 * width; i += 4) {
        glBegin(GL_LINES);
        glVertex3f(0, -height * zoom, 0);
        glVertex3f(0, height * zoom, 0);
        glEnd();
        glTranslatef(zoom * 4, 0, 0);
    }
    glPopMatrix();

    glPushMatrix();
    glTranslatef(0, -height * zoom, 0);
    glColor4f(0, 0, 1, 0);
    for (int i = 0; i < 2 * height; i += 4) {
        glBegin(GL_LINES);
        glVertex3f(-width * zoom, 0, 0);
        glVertex3f(width * zoom, 0, 0);
        glEnd();
        glTranslatef(0, zoom * 4, 0);
    }
    glPopMatrix();

    // 先画平面
    glPushMatrix();
    glTranslatef(0, 0, -0.2 * zoom);
    glColor4f(1, 1, 1, 1);
    glRectf(-width * zoom, -height * zoom, width * zoom, height * zoom);
    glPopMatrix();
    glEndList();
}

int main(int argc, char *argv[]) {
    system("cls");
    std::cout << "Welcone\n\tq,w,e,r,t ---- Accelerated \n\ta,s,d,f,g ---- decelerated\n";
    std::cout << "\tyou can also use mouse to control field";

    glutInit(&argc, argv);
    glutInitDisplayMode(GLUT_DEPTH | GLUT_DOUBLE | GLUT_RGBA);
    glutInitWindowPosition(100, 100);
    glutInitWindowSize(WIDTH, HEIGHT);
    glutCreateWindow("Demo");  // 改了窗口标题

    glutDisplayFunc(myDisplay);
//    glutIdleFunc(myIdle);  // 表示在CPU空闲的时间调用某一函数
    glutTimerFunc(20, myIdle, 1);
    glutSpecialFunc(processSpecialKeys);  // 按键
    glutKeyboardFunc(processNormalKeys);
    glutReshapeFunc(changeSize);
    glutMouseFunc(mouse);
    glutMotionFunc(mouseMotion);

    init();
    glutMainLoop();
    return 0;
}

笔者比较菜,希望大神可以对有问题以及可以优化的地方提出来。也欢迎指出不足和吐槽。希望帮到大家。

猜你喜欢

转载自blog.csdn.net/qq_40515692/article/details/105831189
今日推荐