Toggle navigation
首页
问答
文章
积分商城
专家
专区
更多专区...
文档中心
返回主站
搜索
提问
会员
中心
登录
注册
Kernel
RT-Thread一般讨论
opencpu
rtthread套娃移植分享
发布于 2020-10-13 17:17:51 浏览:1796
订阅该版
[tocm] 和大家分享下将基于rtthread的项目移植到其他平台的经验。 # 背景 最近做了一个物联网项目移植。原先的项目使用的硬件平台为stm32f401+sim800c(mcu + 2G modem),软件平台为rtthread 4.0.1。移植到的新平台为BC25(nb modem),软件平台为BC25 opencpu sdk,也跑了个RTOS,具体不详。BC25不支持rtthread,笔者也无法移植rtthread到BC25,因为BC25只提供了一套SDK接口,无源码,无芯片手册。 > opencpu简介 > 可能有些同学不了解opencpu,这里简单解释下。传统的单片机联网平台是mcu+modem,mcu通过AT命令与modem交互。modem也是有cpu的,而且由于其要运行网络协议栈,RAM和FLASH资源比普通单片机丰富。因此有些modem提供了opencpu功能,让客户的业务代码能跑在modem中,这样就省去了mcu及周边远器件。 BC25 sdk提供了线程、线程间通信、驱动等接口。不过和rtthread的接口相比,其操作系统接口很不完备。比如: - 获取信号量的接口,只支持两种方式:无堵塞(获取不到,立刻返回失败),死等(获取不到就一直等)。缺少折中方案:设置等待的时间,超时则返回失败。 - 最多可创建5个互斥量。。。 - 用户程序最多可使用8个线程,而且是在代码中通过宏列表写死。不支持动态创建线程。 直接使用BC25的SDK的话,就需要对原来的业务代码做很多改动。比如用到信号量的地方,不仅仅是将rt_sem_take换成Ql_OS_TakeSemaphore。对于用到超时返回特性的rt_sem_take,得做特殊的修改。再说写死的线程,笔者在原项目中写了一个通用的状态机模块,其内部动态创建线程以维护状态机的流转。并且多处使用了该状态机模块,因为是通用的嘛。而现在不能动态创建线程,就很麻烦。 # 假移植决定 经过一番权衡,笔者决定实现rtthread的内核接口,这样做有三大好处: - 解决了BC25 SDK接口不完备的问题。 - 业务层还是使用rtthread接口,所以业务代码改动量非常之少(主要改的是驱动代码)。 - 这样的方案灵活机动,如果下次又用另一家的opencpu了,还是不用动业务层。 所谓实现rtthread接口而不是移植rtthread,是笔者基于现有的SDK接口来实现rtthread接口,即心是BC25 SDK,壳是rtthread。因此标题为:套娃移植。 笔者认为这种另类的移植不算常见,有很多细节要处理,因此写此篇文章和大家分享交流。请注意,本篇文章是分享些移植经验,并不是完整的移植指南。 移植的内容分为三大类:内核接口,非常常用的驱动(pin,i2c),finsh。 内核接口细分如下: - 基本类型(如rt_uint32_t) - rtt的内核库(如rt_memset,rt_vsprintf,rt_malloc) - 线程接口(如rt_thread_create) - 线程间同步与通信、中断管理(rt_mutex_t,rt_sem_t,rt_event_t,rt_mq_t,rt_enter_critical,rt_hw_interrupt_disable) - 定时器(rt_timer_t) # 内核接口移植 接实现方法,分为三类: - 直接复制 - 简单替换 - 逻辑适配 ## 直接复制 基本类型(rt_uint32_t),错误码(RT_EOK)以及基本宏(RT_ALIGN)定义在rtdef.h这中,可直接把该文件复制过来,删除不需要的东西(如rt_device_ops,rt_device)。rtdef.h中还定义了线程、线程间通信、定时器这些模块的相关结构体,这些也删掉,具体原因会在后面说。 ## 简单替换 这个主要是针对rtt的内核库,比如rt_memset,其声明为: ``` void *rt_memset(void *s, int c, rt_ubase_t count) ``` BC25也提供了自己的C库,其声明为: ``` void* Ql_memset(void* dest, u8 value, u32 size) ``` 这就可以通过宏来一对一替换: ``` #define rt_memset(dst, value, size) Ql_memset(dst, value, size) ``` 能简单替换的内容不多,也就这么些: ``` #define rt_memset(dst, value, size) Ql_memset(dst, value, size) #define rt_memcpy(dst, src, size) Ql_memcpy(dst, src, size) #define rt_memcmp(dst, src, size) Ql_memcmp(dst, src, size) #define rt_memmove(dst, src, size) Ql_memmove(dst, src, size) #define rt_strcpy(dst, src) Ql_strcpy(dst, src) #define rt_strncpy(dst, src, size) Ql_strncpy(dst, src, size) #define rt_strcmp(s1, s2) Ql_strcmp(s1, s2) #define rt_strncmp(s1, s2, size) Ql_strncmp(s1, s2, size) #define rt_strchr(src, ch) Ql_strchr(src, ch) #define rt_strlen(str) Ql_strlen(str) #define rt_strstr(s1, s2) Ql_strstr(s1, s2) #define rt_vsprintf(s, fmt, arg) Ql_vsprintf(s, fmt, arg) #define rt_sprintf(s, fmt, ...) Ql_sprintf(s, fmt, ##__VA_ARGS__) #define rt_snprintf(s, size, fmt, ...) Ql_snprintf(s, size, fmt, ##__VA_ARGS__) #define rt_sscanf(s, fmt, ...) Ql_sscanf(s, fmt, ##__VA_ARGS__) ``` 除了用宏的方式,也可以用函数来封装。 ``` void *rt_malloc(rt_size_t size) { return Ql_MEM_Alloc(size); } ``` 起初笔者也是用宏来替换rt_malloc的,但是这样一来cJSON软件包的代码编译不过,因为其用函数指针来指向rt_malloc。而笔者定义的是带参数的宏,此处就不会替换,从而提示rt_malloc未被定义。 ``` int cJSON_hook_init(void) { cJSON_Hooks cJSON_hook; cJSON_hook.malloc_fn = (void *(*)(size_t sz))rt_malloc; cJSON_hook.free_fn = rt_free; cJSON_InitHooks(&cJSON_hook); return RT_EOK; } ``` # 逻辑适配 逻辑适配才是本次移植的主要工作,所以另起一章进行说明。 关于线程接口、线程间同步与通信、中断管理、定时器等模块,BC25 SDK也提供了相关接口,不过在功能、参数列表和返回值方面与rtthread接口肯定是不一致的,需要做一些适配工作。 rtthread中rt_mutex_t之类的内核结构体使用了面向对象的概念,继承关系如下: ![image.png](/uploads/20201013/1b6a34cab18bffb7691ed9c5c10e89a5.png) 不过本次移植,是使用BC25的接口来填充rtthread接口,用不到这层关系,只要在功能上保持一致即可。所以关于rt_mutex_t之类的类型定义,由笔者自行定义。这就是之前复制rtdef.h时要删掉它们的原因。 rtthread中的定义 ``` struct rt_mutex { struct rt_ipc_object parent; /**< inherit from ipc_object */ rt_uint16_t value; /**< value of mutex */ rt_uint8_t original_priority; /**< priority of last thread hold the mutex */ rt_uint8_t hold; /**< numbers of thread hold the mutex */ struct rt_thread *owner; /**< current owner of mutex */ }; ``` 笔者的定义 ``` struct rt_mutex { char name[RT_NAME_MAX]; rt_sem_t sem; rt_thread_t owner; rt_uint32_t hold; }; ``` 有些BC25的接口与rtthread比较相似,如定时器接口,适配起来很容易。有些BC25接口不完备,比如线程间同步的接口不能设置超时时间,不能动态创建线程,这些就需要费些工夫。下面笔者挑一些有代表性的来介绍适配方法。 ## 定时器 BC25创建定时器和启动定时器的接口如下: ``` typedef void(*Callback_Timer_OnTimer)(u32 timerId, void* param); s32 Ql_Timer_Register(u32 timerId, Callback_Timer_OnTimer callback_onTimer, void* param); s32 Ql_Timer_Start(u32 timerId, u32 interval, bool autoRepeat); ``` rttthread相关接口为: ``` rt_timer_t rt_timer_create(const char *name, void (*entry)(void *parameter), void *parameter, rt_tick_t timeout, rt_uint8_t flag); rt_err_t rt_timer_start(rt_timer_t timer); ``` 大体上是相似的,都是创建定时器传入回调函数和额外参数。哈哈,其实定时器接口肯定要这两个参数啦。不同之处为: - BC25通过ID来操作相关定时器,rtthread由模块创建并返回定时器对象,之后由该对象来操作相关定时器。这点上,rtthread接口更为易用,因为BC25需要防止ID冲突。 - BC25是在启动定时器是指定定时间隔和模式(周期还是单次),rtthread是在创建定时器时指定,不过之后也可以修改。这点上,笔者还是觉得rtthread好用,嗯,笔者真不是马屁精。 适配方法很简单,在rt_timer_create函数中,动态获取id(自增即可),创建rt_timer_t对象,并将timeout和flag保存在对象中。也要保存entry和parameter,因为BC25的回调函数形式与rtthread不一致,由timer_callback中转。 ``` static uint32_t timer_id_alloc(void) { static uint32_t id = 0x100; uint32_t ret; rt_enter_critical(); ret = id++; rt_exit_critical(); return ret; } static void timer_callback(u32 id, void* param) { rt_timer_t timer = (rt_timer_t)param; RT_ASSERT(timer->handle == id); timer->entry(timer->parameter); } rt_timer_t rt_timer_create(const char *name, void (*entry)(void *parameter), void *parameter, rt_tick_t timeout, rt_uint8_t flag) { int ret; rt_timer_t timer = (rt_timer_t)rt_malloc(sizeof(struct rt_timer)); RT_ASSERT(timer); rt_memset(timer, 0, sizeof(*timer)); rt_snprintf(timer->name, sizeof(timer->name), "%s", name); timer->handle = timer_id_alloc(); ret = Ql_Timer_Register(timer->handle, timer_callback, timer); if(ret != QL_RET_OK) { rt_free(timer); return RT_NULL; } timer->entry = entry; timer->parameter = parameter; timer->flag = flag; timer->timeout = timeout; return timer; } rt_err_t rt_timer_start(rt_timer_t timer) { int ret; ret = Ql_Timer_Start(timer->handle, rt_tick_to_millisecond(timer->timeout), (timer->flag & RT_TIMER_FLAG_PERIODIC) != 0); timer->flag |= RT_TIMER_FLAG_ACTIVATED; return ret == QL_RET_OK ? RT_EOK : -RT_ERROR; } ``` 顺便说下rt_enter_critical和rt_hw_interrupt_disable。前者是关调度器,后者是关中断。还记得BC25的线程都不能动态创建吗,更是不可能提供这些功能接口。巧妇难为无米之炊啊,笔者只能用BC25的互斥量(对,就是之前说的,最多可创建5个互斥量)来实现。 ``` void rt_enter_critical(void) { Ql_OS_TakeMutex(rtt_mutex); } void rt_exit_critical(void) { Ql_OS_GiveMutex(rtt_mutex); } rt_base_t rt_hw_interrupt_disable(void) { rt_enter_critical(); return 0; } void rt_hw_interrupt_enable(rt_base_t level) { rt_exit_critical(); } ``` 可能有的同学会不解,人家明明是要关调度器,你用互斥量有什么用。确实,这有一定的使用限制,那就是所有访问相关资源的地方,都要关调度器。比如说: 写ringbuffer时关调度器。 ``` rt_enter_critical(); rt_ringbuffer_put_force(&stream->recv_rb, stream->tmp_buf, ret); rt_exit_critical(); ``` 读ringbuffer时也关调度器。 ``` rt_enter_critical(); ret = rt_ringbuffer_getchar(&stream->recv_rb, &data); rt_exit_critical(); ``` 这样一来,调度器就是一个全局互斥量。关中断也是一样的原理。至于上述示例代码为什么不直接用rt_mutex,笔者说下自己关于何时用互斥量、何时关调度器的理解。如果是在访问资源的时间极短,关调度器比较合适;相反,比如通过i2c总线进行数据传输,尤其是硬件i2c,则应该用互斥量。因为在操作i2c的过程中,完全可以释放cpu资源给别的线程用。而且,访问不同的资源得使用不同的互斥量,因为操作i2c时不应该让spi资源也被锁定。 可能又有同学质疑:在中断函数里面怎么能使用互斥量呢。庆幸的是,业务代码就没用到真正的中断场景。BC25提供的大部分回调接口,比如串口、GPIO、定时器,都是在线程中进行回调,数据的缓存由BC25实现(比如串口)。这样也是合理的,享受不到相应的权力(底层的控制权限),不应该也无法履行相应的义务。最后再次声明,这是权宜之计,无奈之举啊。 ## 信号量 BC25的信号量接口与rtthread比较相似,唯独缺少超时功能,只能选择不等或者死等。 ``` u32 Ql_OS_TakeSemaphore(u32 semId, bool wait); rt_err_t rt_sem_take(rt_sem_t sem, rt_int32_t timeout); ``` rt_sem_trytake的实现很简单,就是不等。 ``` rt_err_t rt_sem_trytake(rt_sem_t sem) { rt_uint32_t ret = Ql_OS_TakeSemaphore(sem->handle, false); return ret == OS_SUCCESS ? RT_EOK : -RT_ERROR; } ``` 至于rt_sem_take,分三种情况。若是死等或者不等,直接通过rt_sem_trytake来调用BC25接口。若是带超时的等待,只能搞个循环尝试了,牺牲实时性。 ``` rt_err_t rt_sem_take(rt_sem_t sem, rt_int32_t timeout) { if((rt_tick_t)timeout == RT_WAITING_FOREVER) { rt_uint32_t ret = Ql_OS_TakeSemaphore(sem->handle, true); return ret == OS_SUCCESS ? RT_EOK : -RT_ERROR; } else if(timeout == 0) { return rt_sem_trytake(sem); } else { timeout = rt_tick_get() + timeout; rt_err_t err; do { err = rt_sem_trytake(sem); if(err == RT_EOK) { return RT_EOK; } rt_thread_delay(1); } while(rt_tick_get() < timeout); return -RT_ETIMEOUT; } } ``` ## 互斥量 BC25最多创建5个互斥量,这显然不够用。对了,它也没有超时版本。PS:BC25所有进程间同步与通信接口均无超时版本。所以这里打算重新设计互斥量模块,而不使用BC25的接口。之所以rt_enter_critical使用BC25的互斥量,是因为rt_enter_critical不存在超时场景,并且笔者设计的互斥量接口中还使用到了rt_enter_critical。 如何凭空创造互斥量呢,哈哈,显然不可能。笔者使用已实现的rt_sem来实现rt_mutex。互斥量与信号量本是用于两种不同的场景,不过信号量可以替代互斥量,而互斥量无法替代信号量。信号量常用的场景是用于发送通知,初始信号值为0,生产者调用rt_sem_release以让信号值加1,消费者调用rt_sem_take等待信号并减1。如果将初始信号值设置为1的话,那就可以用于互斥场景了。在访问资源前调用rt_sem_take,若此时信号值为1,则获取信号量,此后信号值为0。此时其他线程调用rt_sem_take将被堵塞。当访问完毕后,调用rt_sem_release恢复信号量值为1。 笔者最初的实现如下: ``` rt_mutex_t rt_mutex_create(const char *name, rt_uint8_t flag) { return rt_sem_create(name, 1, flag); } rt_err_t rt_mutex_delete(rt_mutex_t mutex) { return rt_sem_delete(mutex); } rt_err_t rt_mutex_take(rt_mutex_t mutex, rt_int32_t time) { return rt_sem_take(mutex, time); } rt_err_t rt_mutex_release(rt_mutex_t mutex) { return rt_sem_release(mutex); } ``` 简单测试下是没问题的,不过跑业务代码时发生了卡死。最终发现,这种实现不可重入。比如,函数A调用rt_mutex_take后调用函数B,函数B又调用了rt_mutex_take。处理方案:在获取互斥量时,若其已被上锁且持有者为当前线程,则直接放行。PS:此方案借(抄)鉴(袭)rtthread原接口的实现。 ``` rt_err_t rt_mutex_take(rt_mutex_t mutex, rt_int32_t timeout) { rt_err_t err = -RT_ERROR; rt_thread_t cur_thread = rt_thread_self(); RT_ASSERT(cur_thread); rt_enter_critical(); if(mutex->owner == cur_thread) { mutex->hold++; rt_exit_critical(); return RT_EOK; } rt_exit_critical(); err = rt_sem_take(mutex->sem, timeout); if(err != RT_EOK) { return err; } rt_enter_critical(); mutex->owner = cur_thread; mutex->hold = 1; rt_exit_critical(); return RT_EOK; } rt_err_t rt_mutex_release(rt_mutex_t mutex) { rt_thread_t cur_thread = rt_thread_self(); RT_ASSERT(cur_thread && mutex->owner == cur_thread); rt_enter_critical(); mutex->hold--; if(mutex->hold == 0) { mutex->owner = RT_NULL; rt_sem_release(mutex->sem); } rt_exit_critical(); return RT_EOK; } ``` ## 线程 笔者已吐槽多次,BC25的线程是在代码中写死的,像下面这样,proc_main_task、proc_ril_task是线程入口函数,第二个参数是线程ID。 ``` TASK_ITEM(proc_main_task, MAIN_THREAD_ID, 10*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) //main task TASK_ITEM(proc_ril_task, ril_task_id, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) //RIL task TASK_ITEM(proc_urc_task, urc_task_id, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) //URC task ``` 不过BC25提供了一个非常重要的线程接口,也仅仅提供了这一个接口:返回当前线程的ID。 ``` s32 Ql_OS_GetActiveTaskId(void); ``` 可以用此实现rt_thread_self。真是万幸啊,这不之前的rt_mutex_take的重入功能还用到它的嘛。 ``` rt_thread_t rt_thread_self(void) ``` 至于如何实现动态创建,待我慢慢道来。 BC25允许用户最多创建8个线程,笔者将它们纳入线程池。 线程对象的定义: ``` struct rt_thread { char name[RT_NAME_MAX]; int ql_id; void (*entry)(void *parameter); void *parameter; rt_uint32_t stack_size; rt_uint8_t priority; rt_bool_t in_use; rt_sem_t sem; }; ``` 线程池定义: ``` static struct rt_thread thread_lst[THREAD_NUM]; ``` 死写的线程列表: ``` TASK_ITEM(thread_entry, THREAD1_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) TASK_ITEM(thread_entry, THREAD2_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) TASK_ITEM(thread_entry, THREAD3_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) TASK_ITEM(thread_entry, THREAD4_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) TASK_ITEM(thread_entry, THREAD5_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) TASK_ITEM(thread_entry, THREAD6_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) TASK_ITEM(thread_entry, THREAD7_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) TASK_ITEM(thread_entry, THREAD8_ID, 5*1024, DEFAULT_VALUE1, DEFAULT_VALUE2) ``` 这8个线程的入口函数都指向同一个thread_entry。该函数通过id找到线程池中自己的对象,等待对象被激活。激活后,运行真正的线程入口函数。 ``` void thread_entry(int id) { rt_thread_t thread; /* * 等待本模块初始化,因为线程是写死的, * 可能在系统加载时就开始运行了。 */ wait_thread_init(); thread = get_thread(id); RT_ASSERT(thread); /* * 等待线程被激活。 * 即用户rt_thread_create并rt_thread_create。 */ RT_ASSERT(rt_sem_take(thread->sem, RT_WAITING_FOREVER) == RT_EOK); RT_ASSERT(thread->entry); thread->entry(thread->parameter); /* * 其实这里还可以做回收的,不过笔者没用到这个场景。 */ } ``` rt_thread_create从线程池中获取空闲的线程对象,标记为使用中(in_use),记录相关参数(主要是入口函数,入参),返回该对象。 ``` /* * stack_size和priority是预留参数,可用于后期优化。 */ static rt_thread_t alloc_thread(rt_uint32_t stack_size, rt_uint8_t priority) { rt_thread_t thread = RT_NULL; rt_enter_critical(); for(int i = 0; i < THREAD_NUM; i++) { rt_thread_t tmp = thread_lst + i; if(!tmp->in_use) { tmp->in_use = RT_TRUE; thread = tmp; break; } } rt_exit_critical(); return thread; } rt_thread_t rt_thread_create(const char *name, void (*entry)(void *parameter), void *parameter, rt_uint32_t stack_size, rt_uint8_t priority, rt_uint32_t tick) { rt_thread_t thread = alloc_thread(stack_size, priority); if(!thread) { rt_kprintf("No available thread for app_thread(name:%s, stack_size:%d, priority:%d)\r\n", name, stack_size, priority); return RT_NULL; } rt_snprintf(thread->name, sizeof(thread->name), "%s", name); thread->entry = entry; thread->parameter = parameter; return thread; } ``` 上述是最关键的实现。未实现的功能有: - 线程的释放与回收。这个笔者的项目中用不到,也就没做嘿嘿。 - 线程栈空间大小及优先级的设定。栈空间大小是在线程列表里面列写的,这倒可以视使用场景优化一下:在列表中设定不同空间大小的线程,alloc_thread选择刚刚满足需求的空闲线程。至于优先级,BC25不支持:(。 再次说明,本篇文章是分享些移植经验,并不是完整的移植指南。做这种系统级移植,得对系统有深刻的了解,至少得明白各接口的功能、使用场景,以及自己需要哪些接口(毕竟工具有限,也不是所有接口都能实现,比如真正的关中断)。所以,这种移植工作,因人而异,因项目而异。 先写这么多,关于rt_event_t,rt_mq_t,pin,i2c,请待下回分解。
6
条评论
默认排序
按发布时间排序
登录
注册新账号
关于作者
wenbodong
这家伙很懒,什么也没写!
文章
4
回答
44
被采纳
3
关注TA
发私信
相关文章
1
BSD的一些代码分析
2
RT-Thread文件系统
3
怎么文档,源码还是没有呀??
4
[ZT]嵌入式LwIP协议栈的内存管理
5
RTLinux/RTCore体系结构
6
RTLinux/RTCore局限性
7
怎样获取源码
8
[ZT]The lightest lightweight threads, Protothreads
9
[ZT]微内核操作系统及L4概述
10
关于操作系统对C++操作符的使用
推荐文章
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
FAL
rt-smart
ESP8266
I2C_IIC
WIZnet_W5500
ota在线升级
UART
PWM
cubemx
freemodbus
flash
packages_软件包
BSP
潘多拉开发板_Pandora
定时器
ADC
GD32
flashDB
socket
中断
Debug
编译报错
msh
SFUD
keil_MDK
rt_mq_消息队列_msg_queue
at_device
ulog
C++_cpp
本月问答贡献
踩姑娘的小蘑菇
7
个答案
3
次被采纳
张世争
8
个答案
2
次被采纳
rv666
5
个答案
2
次被采纳
用户名由3_15位
11
个答案
1
次被采纳
KunYi
6
个答案
1
次被采纳
本月文章贡献
程序员阿伟
6
篇文章
2
次点赞
hhart
3
篇文章
4
次点赞
大龄码农
1
篇文章
2
次点赞
ThinkCode
1
篇文章
1
次点赞
Betrayer
1
篇文章
1
次点赞
回到
顶部
发布
问题
投诉
建议
回到
底部