Toggle navigation
首页
问答
文章
积分商城
专家
专区
更多专区...
文档中心
返回主站
搜索
提问
会员
中心
登录
注册
RT-Thread
nes模拟器
利用rt-thread的QENU虚拟机完成NES的适配
1.00
发布于 2024-08-02 00:19:53 浏览:706
订阅该版
[tocm] # 利用rt-thread的QENU虚拟机完成NES的适配 ## **前言** rt-thread stuido所附带的QEMU是一个功能强大的工具,它模拟的VX-A9甚至带有显示屏,能够完成很多GUI功能。 任天堂娱乐系统(英文版名:Nintendo Entertainment System,NES),俗称“黑白机”,伴随很多80后度过了快乐的童年。本文详细述说NES适配到QENU的记录过程。 ## ** 正文** 源代码获取和编译: 从fork之后的git下载到packages里 ```c git clone https://gitee.com/RT-Thread-Mirror/nes.git ``` 在配置中使能NES模块:  编译结果情况:  很显然,InfoNES_Mapper_00.cpp不通过,检查相应的代码内容:  这个C文件没有包含任何头文件,当然不能解析MapperInit变量啦! 对比发现InfoNES_Mapper_xx.cpp都是这种不包含任何头文件的方式,那它们只能被当成头文件被别人包含了(虽然它们不是.h/.hpp类型的文件!!!) 通过搜索,可以在文件InfoNES_Mapper.c里找到证据:  由此可见,上述的文件根本不能单独编译,NES下的SConscript文件出问题啦!  第10行的方式有问题,将Sconscript内容修改如下: ```python from building import * group = [] if not GetDepend(['PKG_USING_NES']): Return('group') cwd = GetCurrentDir() path = [cwd] src = Glob('src/*.c') src += Glob('port/nes_embed.c') src += Glob('port/nes_file_port.c') path += [cwd + '/inc'] path += [cwd + '/src'] path += [cwd + '/port'] if not GetDepend(['PKG_NES_DFS']): src += Glob('games/*.c') group = DefineGroup('nes', src, depend = ['PKG_USING_NES'], CPPPATH = path) Return('group') ``` 再次编译通过! QEMU的调试配置如下: ```python H:\RT-ThreadStudio-workspace\QEMU-VEXPRESS-A9>D:/RT-ThreadStudio/repo/Extract/Debugger_Support_Packages/RealThread/QEMU/4.2.0.4/qemu-system-arm.exe -M vexpress-a9 -sd sd.bin -S -s -kernel Debug/rtthread.elf ``` 调试执行时有如下报错:  这是因为系统没有声音输入设备导致的:  现在增加一个声音输入设备(这里选蓝牙):  再次启动QEMU,能够趟过了那些错误,不过QEMU的图形界面上没有任何内容😓 😓!!  当然不会有任何图像啦,因为nes压根没有启动😓!而这个启动函数为nesmain位于文件nes_embed.c  现在制作一个sd.bin文件,复制四个nes文件到sd.bin文件   鉴于此,在nes_embed.c里增加对nesmain的调用:  再次编译有如下报错:  通过分析,梳理出这个几个函数的信息:  由此,修改Sconsript文件如下: ```python from building import * group = [] if not GetDepend(['PKG_USING_NES']): Return('group') cwd = GetCurrentDir() path = [cwd] src = Glob('src/*.c') src += Glob('port/nes_embed.c') src += Glob('port/nes_file_port.c') src += Glob('port/nes_lcd_port.c') src += Glob('port/nes_key_port.c') src += Glob('port/nes_sound_port.c') path += [cwd + '/inc'] path += [cwd + '/src'] path += [cwd + '/port'] if not GetDepend(['PKG_NES_DFS']): src += Glob('games/*.c') group = DefineGroup('nes', src, depend = ['PKG_USING_NES'], CPPPATH = path) Return('group') ``` 但是编译报错😬:   注释掉31行的头文件,能够编译通过😘。 但是执行时发现死机😭:    很显然,lcd设备没有打开,现在去开启lcd设备,可以参考LVGL的适配过程。 使能GUI引擎:  可以看出,系统将lcd,key之类的设备也一并编译了:  再次启动系统,可以发现多了lcd设备:  执行nes命令启动模拟器:  出现了播放的画面!! 🤞 😘 😘 😘 🤞 等等,这个画面是一个什么样的鬼玩意噢😅,表现为: 1)画面有重影 2)动画播放速度过快(此处不好展示动画) 经验分析,重影的可能原因: a) LCD驱动设定的分辨率跟NES运行的环境不一致 b) LCD驱动设定的颜色深度不合适 检查nes_lcd_port.c文件的InfoNES_LoadFrame函数,有如下的代码:  在文件InforNES.h里做定义:  调试跟踪,有如下发现:  可以看出两层 for循环的作用: 将WorkFrame的内容(估计是NES游戏画面的像素内容)搬移到一个同等大小的矩阵区域  可以看出,实际的LCD设备的颜色深度为16 !!! 这与上图54-56行的代码不对应,原有的代码将LCD当成了24bit的颜色深度,应当修改为:  再次执行,QENU模拟器的显示屏画面如下:  可以看出,颜色明显变得真实了,还能比较清晰的辨别出画面中的文字信息!!! 好消息,这是一个不少的收获!! 断点调试发现,这个InfoNES_LoadFrame一直在运行,事实上它就是一个画面刷新函数!!!! 这个画面刷新函数的调用路线为: InfoNES_Main --> InfoNES_Cycle --> InfoNES_HSync --> InfoNES_LoadFrame 现在的问题是:显示屏(qemu的模拟器显示屏)上为什么会出现两个同样的画面?而且在图象画面上还出现了其他的内容(图中红色框内)?  如果仔细观察,会发现那个画面外的东西不就是游戏里的那个主角精灵的图象嘛!!!!可以打开正常的游戏画面对比一下(电脑里安装了NES模拟器,加载SuperMario.nes即可)  仔细对比代码,发现InfoNES.h文件的两个宏被修改了(之前排查显示分辨率所改)  现在把它改回去:  再次执行之后,那个精灵画面进入到背景中了(图象中白云上就是游戏精灵)!!  现在,画面就剩最后一个问题:为什么有两幅一样的画面? 为此,在RTT上启动LVGL组件,通过对比来分析根源。LVGL的demo显示界面如下:  画面很正常,由此证明NES的双画面是NES组件本身的问题。 为便于对比分析,将显示驱动的分辨率调整为512*250:  执行NES的画面(此时利用debug断点做暂停)如下:  对比左右两幅图片,可以发现它们还是有细微的差别(云彩的形状不相同),它们是两帧不同的画面!! 是否为NES刷新过快导致的? 为此,在函数InfoNES_LoadFrame里执行显示刷新之后立即延时20毫秒。  这里说明延时设定为20毫秒的来源: 由于技术的时代限制,当年NES产品推出时使用的为显像管显示器(CRT全称阴极射线显示管),其刷新频率为50Hz,周期为: `T = 1/50 = 0.02s = 20ms` ### 补充说明: 阴极射线管由威廉·克鲁克斯首创,他后继的很多科学家先后利用和改进了这种装置,并先后利用它完成了电子发现,晶体衍射实验等等物理学开天辟地级别的大发现。 此后,阴极射线管也在电子电气上被广泛使用。雷达出现后也是用这种特化后的阴极射线管来达成雷达波的信息判读。 二次世界大战后,由这种射线管改良后的器件被广泛应用于一个家喻户晓的民生设备------电视机(其核心元件俗称“显像管”) 再次运行测试,发现还是显示两副画面,但是游戏动作播放速度正常了!!! 由此可见通过在InfoNES_LoadFrame函数增加延时可以调整动画播放速度。 发散想一下,如果我调节这个延时时间可不是变成了一个**作弊器**? 以下是修改后的函数代码 ```cpp void InfoNES_LoadFrame() { /* * Transfer the contents of work frame on the screen * */ //#include
struct rt_device_rect_info rect_info; WORD *wColor = WorkFrame; if (!nes_lcd_is_init) { lcd_device = rt_device_find("lcd"); RT_ASSERT(lcd_device != RT_NULL); if (rt_device_open(lcd_device, RT_DEVICE_OFLAG_RDWR) == RT_EOK) rt_device_control(lcd_device, RTGRAPHIC_CTRL_GET_INFO, &info); nes_lcd_is_init = 1; } rect_info.x = 128; rect_info.y = 5; rect_info.width = NES_DISP_WIDTH; rect_info.height = NES_DISP_HEIGHT; for (int y = rect_info.y; y < rect_info.y + rect_info.height; y ++) { for (int x = rect_info.x; x < rect_info.x + rect_info.width; x ++) { wColor ++; /* Exchange 16-bit to 24-bit RGB565 to RGB888*/ #if 0 info.framebuffer[3 * (x + y * info.width)] = ((*wColor & 0xf800) >> 10) << 3; info.framebuffer[3 * (x + y * info.width) + 1] = ((*wColor & 0x07e0) >> 5) << 3; info.framebuffer[3 * (x + y * info.width) + 2] = (*wColor & 0x001f) << 3; #else /**< 如果显示屏为16bit/像素 */ info.framebuffer[(x + y * info.width)] = *wColor; #endif } } rt_device_control(lcd_device, RTGRAPHIC_CTRL_RECT_UPDATE, &rect_info); rt_thread_mdelay(40); } ``` 结构体rect_info用于显示屏的实际刷新,wColor指向了指针WorkFrame,搜索这个变量,有如下信息:  宏PKG_NES_DOUBLE_FRAMEBUFFER被定义在文件rtconfig.h  可以肯定,NES开启了双显示缓冲区模式(很多显示处理利用这种双缓存轮流刷数据的方法(可以搜索乒乓操作)来改善动画的连续性)。而在数据的刷新却没有处理好,因而导致这个现象!!! 现在取消其双显示缓冲器模式,测试一下效果。这个可以直接通过配置改变:  编译后测试发现,还是分成了左右两幅图像!!! 给显示屏增加边缘外框作标志  明明只画了一根竖线,却显示了两根!!! 同时还发现,在45行加断点再单步调试时,左右两根竖线是同步向下增加的  再次仔细分析InfoNES_LoadFrame函数:   赋值语句等号两端的类型不相同😬 😬,这才是显示两幅图象的根本原因!!!! 现在适当修改为: ```cpp void InfoNES_LoadFrame() { /* * Transfer the contents of work frame on the screen * */ if (!nes_lcd_is_init) { lcd_device = rt_device_find("lcd"); RT_ASSERT(lcd_device != RT_NULL); if (rt_device_open(lcd_device, RT_DEVICE_OFLAG_RDWR) == RT_EOK) rt_device_control(lcd_device, RTGRAPHIC_CTRL_GET_INFO, &info); nes_lcd_is_init = 1; } // struct rt_device_rect_info rect_info = { /**< 将显示区域移动到显示屏的中央位置 */ .x = (info.width - NES_DISP_WIDTH)/2, .y = (info.height - NES_DISP_HEIGHT)/2, .width = NES_DISP_WIDTH, .height = NES_DISP_HEIGHT, }; WORD *wFrameBuffer = (WORD*)(info.framebuffer); WORD *wColor = WorkFrame; // for (int y = rect_info.y; y < rect_info.y + rect_info.height; y ++) { for (int x = rect_info.x; x < rect_info.x + rect_info.width; x ++) { /**< 如果显示屏为16bit/像素 */ wFrameBuffer[(x + y * info.width)] = *wColor; wColor++; } } rt_device_control(lcd_device, RTGRAPHIC_CTRL_RECT_UPDATE, &rect_info); rt_thread_mdelay(lcd_flush_period); } ``` 运行之后,这个能在显示屏上显示一幅完整的画面了:  ## 复盘: 适配过程中,这个两幅画面的bug耗费了我相当多的时间。至此回顾这种情况的原因是:由两幅画面总是联想到是不是多缓冲区处理导致的问题,进而一直将火力输出在“代码在哪里出现了多次复制像素数据了”。而一直忽略了一个细节:明明比较精细的单幅画面(可以观察PC模拟器的画面输出),这两幅画面中却变得很粗糙,要是留意这个图像被轮廓化的细节那就能很快做出正确的方向判断。* 仔细观察,可以发现其颜色不正确!!! 很明显,这是由于NES输出的颜色比特次序与显示屏自身的颜色次序不一致所致。修改显示屏的颜色跟显示屏一致即可!  可见函数drv_clcd_control将颜色比特次序设置为RGB565 进一步调整InfoNES_LoadFrame函数的代码,详情如下:  可见函数drv_clcd_control将颜色比特次序设置为RGB565 进一步调整InfoNES_LoadFrame函数的代码,详情如下: ```cpp void InfoNES_LoadFrame() { /* * Transfer the contents of work frame on the screen * */ if (!nes_lcd_is_init) { lcd_device = rt_device_find("lcd"); RT_ASSERT(lcd_device != RT_NULL); if (rt_device_open(lcd_device, RT_DEVICE_OFLAG_RDWR) == RT_EOK) rt_device_control(lcd_device, RTGRAPHIC_CTRL_GET_INFO, &info); nes_lcd_is_init = 1; } // struct rt_device_rect_info rect_info = { /**< 将显示区域移动到显示屏的中央位置 */ .x = (info.width - NES_DISP_WIDTH)/2, .y = (info.height - NES_DISP_HEIGHT)/2, .width = NES_DISP_WIDTH, .height = NES_DISP_HEIGHT, }; WORD *wFrameBuffer = (WORD*)(info.framebuffer); WORD *wColor = WorkFrame; WORD red,green,blue; // for (int y = rect_info.y; y < rect_info.y + rect_info.height; y ++) { for (int x = rect_info.x; x < rect_info.x + rect_info.width; x ++) { /**< 将NES系统的RGB565格式的像素数据转换成本地显示屏对应的格式,注意:NES颜色是RGB555,而本地显示屏却是RGB565格式的 */ red = (*wColor & 0x7C00) >> 10; green = (*wColor & 0x03E0) >> 5; blue = (*wColor & 0x001F); wFrameBuffer[(x + y * info.width)] = (red << 11) | (green << 6) | (blue); wColor++; } } rt_device_control(lcd_device, RTGRAPHIC_CTRL_RECT_UPDATE, &rect_info); rt_thread_mdelay(lcd_flush_period); } ``` 执行效果如下:  图中左边为电脑上的NES模拟器,右边是QEMU模拟器 显示部分完美解决😘 😘 😘!!! 现在去做最后一步的工作:设法将键盘响应连接到NES 找到文件drv_keyboard.c文件  将宏DBG_LEVL修改为DBG_LOG 程序运行后,点击键盘向上的方向键,终端有如下的打印输出  对应的函数keyboard_report_event代码: ```cpp static void keyboard_report_event(void * device, rt_uint32_t flag, rt_uint8_t data, enum key_value_t press) { struct rtgui_event_kbd key_event; rt_uint16_t i = 0, mod = 0, find_key = 0; for(i = 0; i < sizeof(map)/sizeof(map[0]); i++) { if (map[i].data == data) { LOG_D("KEY info:"); if (flag & KBD_CAPS_LOCK) { LOG_D("CAPS:LOCK"); } else { LOG_D("CAPS:UNLOCK"); } if (flag & KBD_LEFT_SHIFT) { mod |= RTGUI_KMOD_LSHIFT; LOG_D("SHIFT:LEFT"); } else if (flag & KBD_RIGHT_SHIFT) { mod |= RTGUI_KMOD_RSHIFT; LOG_D("SHIFT:RIGHT"); } else { LOG_D("SHIFT:NULL"); } if (flag & KBD_LEFT_CTRL) { mod |= RTGUI_KMOD_LCTRL; LOG_D("CTRL:LEFT"); } else if (flag & KBD_RIGHT_CTRL) { mod |= RTGUI_KMOD_RCTRL; LOG_D("CTRL:RIGHT"); } else { LOG_D("CTRL:NULL"); } LOG_D("flag:0x%08x value:0x%x key:%s status:%s", \ flag, data, map[i].normal_key, press ==0 ? "UP" : "DOWN"); find_key = 1; break; } } if (find_key == 0) { LOG_D("flag:0x%08x value:0x%x key:%s status:%s", \ flag, data, "UNKNOWN", press ==0 ? "UP" : "DOWN"); return; } key_event.parent.sender = RT_NULL; key_event.parent.type = RTGUI_EVENT_KBD; key_event.type = (press == 0 ? RTGUI_KEYUP : RTGUI_KEYDOWN); key_event.key = map[i].key; key_event.mod = mod; key_event.unicode = map[i].unicode; rtgui_server_post_event(&key_event.parent, sizeof(key_event)); } ``` NES系统里,读取操纵杆(键盘)状态的函数为InfoNES_PadState,位于文件nes_key_port.c ```cpp void InfoNES_PadState(DWORD *pdwPad1, DWORD *pdwPad2, DWORD *pdwSystem) { // 低位---------------------->高位 //pdwPad1 : A键 B键 选择 开始 上 下 左 右 //pdwPad2 : A键 B键 选择 开始 上 下 左 右 //pdwSystem : 退出 确认 取消 上 下 左 右 NULL } ``` 现在要在这个函数里完成电脑按键与NES操纵杆的绑定,并且分别对pdwPad1,pdwPad2,pdwSystem进行赋值。 操纵杆1的绑定关系:  操纵杆2的绑定关系:  系统按键的绑定关系:  以下是改动后的nes_key_port.c文件 ```cpp /* * Copyright (c) 2006-2020, RT-Thread Development Team * * SPDX-License-Identifier: Apache-2.0 * * Change Logs: * Date Author Notes * 2020-12-14 Ghazigq the first version */ #include
#include
#include
typedef struct { BYTE code; BYTE bit; BYTE status; }padConfig; static padConfig myPadState[3*8] = { /** pdwPad1 */ { .code = 0x7b, .bit = 0, .status = 0, }, { .code = 0x79, .bit = 1, .status = 0, }, { .code = 0x70, .bit = 2, .status = 0, }, { .code = 0x71, .bit = 3, .status = 0, }, { .code = 0x75, .bit = 4, .status = 0, }, { .code = 0x72, .bit = 5, .status = 0, }, { .code = 0x6b, .bit = 6, .status = 0, }, { .code = 0x74, .bit = 7, .status = 0, }, /** pdwPad2 */ { .code = 0x3c, .bit = 0, .status = 0, }, { .code = 0x43, .bit = 1, .status = 0, }, { .code = 0x3b, .bit = 2, .status = 0, }, { .code = 0x42, .bit = 3, .status = 0, }, { .code = 0x1d, .bit = 4, .status = 0, }, { .code = 0x1b, .bit = 5, .status = 0, }, { .code = 0x1c, .bit = 6, .status = 0, }, { .code = 0x23, .bit = 7, .status = 0, }, /** pdwSystem */ { .code = 0x66, .bit = 0, .status = 0, }, { .code = 0x5a, .bit = 1, .status = 0, }, { .code = 0x76, .bit = 2, .status = 0, }, { .code = 0x6c, .bit = 3, .status = 0, }, { .code = 0x69, .bit = 4, .status = 0, }, { .code = 0x71, .bit = 5, .status = 0, }, { .code = 0x7a, .bit = 6, .status = 0, }, { .code = 0x00, .bit = 7, .status = 0, }, }; void NesKeyPadFlush(uint8_t code, uint8_t pressed) { for(uint32_t i = 0; i < sizeof(myPadState)/sizeof(padConfig); i++) { if(code == myPadState[i].code) { myPadState[i].status = (pressed) ? 1 : 0; break; } } } /*===================================================================*/ /* */ /* InfoNES_PadState() : Get a joypad state */ /* */ /*===================================================================*/ void InfoNES_PadState(DWORD *pdwPad1, DWORD *pdwPad2, DWORD *pdwSystem) { // 低位---------------------->高位 //pdwPad1 : A键 B键 选择 开始 上 下 左 右 //pdwPad2 : A键 B键 选择 开始 上 下 左 右 //pdwSystem : 退出 确认 取消 上 下 左 右 NULL *pdwPad1 = (myPadState[ 0].status << 0) | (myPadState[ 1].status << 1) | (myPadState[ 2].status << 2) | (myPadState[ 3].status << 3) | (myPadState[ 4].status << 4) | (myPadState[ 5].status << 5) | (myPadState[ 6].status << 6) | (myPadState[ 7].status << 7); // *pdwPad2 = (myPadState[ 8].status << 0) | (myPadState[ 9].status << 1) | (myPadState[10].status << 2) | (myPadState[11].status << 3) | (myPadState[12].status << 4) | (myPadState[13].status << 5) | (myPadState[14].status << 6) | (myPadState[15].status << 7); // *pdwSystem = (myPadState[16].status << 0) | (myPadState[17].status << 1) | (myPadState[18].status << 2) | (myPadState[19].status << 3) | (myPadState[20].status << 4) | (myPadState[21].status << 5) | (myPadState[22].status << 6) | (myPadState[23].status << 7); } ``` 对应的,改动之后的代码能够控制精灵左右移动 却不能控制精灵做跳跃动作。 查明表面原因: 代码中1号操纵杆所绑定的键位码不能正常传递,将1,2号操纵杆互换之后就能控制NES里的精灵了,改动后的InfoNES_PadState函数的内容如下: ```cpp void InfoNES_PadState(DWORD *pdwPad1, DWORD *pdwPad2, DWORD *pdwSystem) { // 低位---------------------->高位 //pdwPad1 : A键 B键 选择 开始 上 下 左 右 //pdwPad2 : A键 B键 选择 开始 上 下 左 右 //pdwSystem : 退出 确认 取消 上 下 左 右 NULL *pdwPad2 = (myPadState[ 0].status << 0) | (myPadState[ 1].status << 1) | (myPadState[ 2].status << 2) | (myPadState[ 3].status << 3) | (myPadState[ 4].status << 4) | (myPadState[ 5].status << 5) | (myPadState[ 6].status << 6) | (myPadState[ 7].status << 7); // *pdwPad1 = (myPadState[ 8].status << 0) | (myPadState[ 9].status << 1) | (myPadState[10].status << 2) | (myPadState[11].status << 3) | (myPadState[12].status << 4) | (myPadState[13].status << 5) | (myPadState[14].status << 6) | (myPadState[15].status << 7); // *pdwSystem = (myPadState[16].status << 0) | (myPadState[17].status << 1) | (myPadState[18].status << 2) | (myPadState[19].status << 3) | (myPadState[20].status << 4) | (myPadState[21].status << 5) | (myPadState[22].status << 6) | (myPadState[23].status << 7); } ``` 至此,游戏中的精灵主角可以正常控制了!! 😘 😘 ## 附带解决的时间消耗列表: 
4
条评论
默认排序
按发布时间排序
登录
注册新账号
关于作者
大龄码农
这家伙很懒,什么也没写!
文章
3
回答
1
被采纳
0
关注TA
发私信
相关文章
1
RT-THREAD在STM32H747平台上移植lwip
2
正点原子miniSTM32开发板读写sdcard
3
反馈rtt串口驱动对低功耗串口lpuart1不兼容的问题
4
Keil MDK 移植 RT-Thread Nano
5
RT1061/1052 带 RTT + LWIP和LPSPI,有什么坑要注意吗?
6
RT thread HID 如何收发数据
7
求一份基于RTT系统封装好的STM32F1系列的FLASH操作程序
8
RT-Thread修改项目名称之后不能下载
9
rt-studio编译c++
10
有木有移植rt-thread(nano)到riscv 32位MCU上
推荐文章
1
RT-Thread应用项目汇总
2
玩转RT-Thread系列教程
3
国产MCU移植系列教程汇总,欢迎查看!
4
机器人操作系统 (ROS2) 和 RT-Thread 通信
5
五分钟玩转RT-Thread新社区
6
【技术三千问】之《玩转ART-Pi》,看这篇就够了!干货汇总
7
关于STM32H7开发板上使用SDIO接口驱动SD卡挂载文件系统的问题总结
8
STM32的“GPU”——DMA2D实例详解
9
RT-Thread隐藏的宝藏之completion
10
【ART-PI】RT-Thread 开启RTC 与 Alarm组件
热门标签
RT-Thread Studio
串口
Env
LWIP
SPI
AT
Bootloader
Hardfault
CAN总线
FinSH
ART-Pi
USB
DMA
文件系统
RT-Thread
SCons
RT-Thread Nano
线程
MQTT
STM32
RTC
rt-smart
FAL
cubemx
I2C_IIC
ESP8266
UART
WIZnet_W5500
ota在线升级
PWM
BSP
flash
freemodbus
packages_软件包
潘多拉开发板_Pandora
GD32
定时器
ADC
flashDB
编译报错
socket
中断
rt_mq_消息队列_msg_queue
keil_MDK
Debug
SFUD
ulog
msh
C++_cpp
MicroPython
本月问答贡献
lchnu
5
个答案
3
次被采纳
三世执戟
9
个答案
2
次被采纳
张世争
1
个答案
2
次被采纳
a1012112796
14
个答案
1
次被采纳
聚散无由
5
个答案
1
次被采纳
本月文章贡献
jinchanchan
12
篇文章
14
次点赞
ssdd45555
3
篇文章
2
次点赞
lvdongchina
2
篇文章
1
次点赞
聚散无由
1
篇文章
4
次点赞
RTT_逍遥
1
篇文章
3
次点赞
回到
顶部
发布
问题
投诉
建议
回到
底部