QQ登录

只需一步,快速开始

 找回密码
 注册

QQ登录

只需一步,快速开始

查看: 7474|回复: 3

ucosii for lpc2210新手移植总结

[复制链接]
发表于 2005-6-27 22:27:00 | 显示全部楼层 |阅读模式
Jabber: [email protected]
Email: [email protected]

Abstract:
lpc2210特性简介;
让ARMCC编译的ucosii for LPC2210在SkyEye上运行起来;
把整个工程改造成GCC版本;
关于ucosii本身的一些要点;
调试手段。

作为一个新手,抱着入门的目的,我最近做的工作主要有两部分:
1)        修改和完善SkyEye对LPC221x的支持,略微修改一个已经移植到LPC221x上的ucosii工程(compiled by armcc ),让它也能在SkyEye上面顺利运行起来;
2)        在原有的代码基础上,重新组织整个工程,用GNU arm-tools重新编译,使得生成后的目标也能在SkyEye上模拟。

一、        lpc2210特性简介

这里重点关心lpc2210的独特之处。

体系结构:                        ARMV
厂商:                                Philips
CPU核:                        ARM7TDMI-S
支持ARM指令集:        ARM 32位指令集 和        Thumb 16位指令集

需要说明的是ARM7TDMI-S中的最后一个字母,代表“可综合”的意思,对于应用程序员来说是透明的,编程模型比起7TDMI来没有什么不同。

存储器分布:
0x4000 0000 – 0x4000 1FFF         16KB片内SRAM
0x8000 0000 – 0xE000 0000         External Memory
0xE000 0000 – 0xF000 0000                VPB 外设
0xF000 0000 – 0xFFFF FFFF         AHB 外设

External Memory的配置和开发板有关。

内存重新映射:
1)启动时刻的控制:
芯片有两个引脚被称为BOOT[1],启动时刻这两个引脚的状态决定了开始从什么地方读取第一条指令。
BOOT[1]/MEMAP[1]                                        0x00000000 ~ 0x0000003C 映射自:
00                                                                  0x7FFFE000 ~ 0x7FFFE03C
          01                                                                0x00000000 ~ 0x0000003C
          10                                                                0x40000000 ~ 0x4000003C
          11                                                                        0x80000000 ~ 0x8000003C
2)运行时刻的控制
透过对MEMMAP(0xE01FC040)的控制。
复位时刻,MEMMAP[1]由BOOT[1]的状态决定。
运行时可以写MEMMAP。
这种形式的内存重映射是SkyEye目前没有支持的,要在SkyEye里边运行依靠这个特性的程序,一个折中的办法就是配置两块内存,然后自己在ucosii代码里边手动做内存块的复制。

中断控制器相关:
ARM体系结构规定,遇到IRQ中断的时候,CPU尽量完成当前指令,然后BL到0x00000018处,读取一条指令,同时切换到Svc状态。
如果要写出通用的程序来,应该在这个地方放置到IRQ_Handler的跳转(LDR pc, #IRQ_Handler ),在IRQ_Handler里边读取中断状态控制器,区分中断源,作出相应的处理。

LPC2210的中断控制器是ARM Prime Cell,它有自己的中断处理流程。
首先,ARM PrimeCell中断控制器支持32个变量,即32个external Interrupt Requests
这32个IRQ中,可以抽出16个分配成Vector IRQ,对于Vector IRQ,有自己的优先级,有自己的VICAddr(handler地址),哪个IRQ号对应哪个Vector IRQ,完全是动态的,取决于程序员的分配;

然后,除去Vector IRQ,还有FIQ,也是从IRQ中抽出来的,它有自己独特的regs bank,响应中断更快,由于这个没有应用到,这里不多讨论,不过机制还是比较简单的;

1.How to raise a int:
外设产生中断,或者程序里边可以主动写VICSoftInt的相应位,两种效果都一样;

2.中断之后怎么跳转到IntHandler:
(注意这是Int控制器的行为)如果对应的IRQ Num已经被declare作为VectorInt中的一个,那么对应的VICVectAddrN寄存器值被写入VICVectAddr寄存器;
(这是ARM体系结构的行为)切换处理器模式,跳转到0x00000018;
0x00000018这个地方放着指令,ldr  pc, [pc, #-4080],读取VICVectAddr中存放的中断处理程序的入口地址,跳转;
在原来的SkyEye对LPC系列的模拟中,没有模拟PrimeCell的行为,但是处理器模式switch和跳转动作是有的,一个折中的办法是在0x18这个地方放跳转到IRQHandler的指令,然后在IRQHandler里边读VICIRQStauts或者VICRawIntr的状态,获得中断号,即改成一个通用的实现。

3.进入IRQHandler之后,要清除中断状态,否则后边的中断进不来
办法是对VicVectAddr写,一般写0,这时VICRawIntr, VICFIQStatus, VICIRQStatus对应位被清除。

时钟和串口相关:
这两个外设都是运行ucosii的基本条件,但是它们沿袭LPC系列的特征,所以不用做太大改动。我们只需要把它们的中断源挂到中断控制器对应的Slot就可以了。


二、        让ARMCC编译的程序在SkyEye上运行起来
编译器和调试器的问题:
ARMCC是ARM公司放出的官方编译器,可以输出bin,也可以输出.axf或者.elf格式,但ARMCC对标准的elf格式作出了扩展,所以elf包含的调试信息不能被基于GDB的SkyEye正确识别,所以所有的常规调试手段都用不上了:包括断点,C语言的单步等等。
但是调试的办法还是有的,由于SkyEye支持log功能,所以我们可以获得指令执行的流程,以及每一条指令的时刻的CPU状态,配合arm-elf-objdump的反汇编代码,结合原来的C代码,还是能够做一些底层的调试。

从开发板上的ucosii到SkyEye上运行的ucosii:
这里的SkyEye,指配置到模拟LPC2210的SkyEye。
1)        对ucosii的改动
1.1        去掉一些外设初始化代码,比如内存控制模块,PLL等等。
In arch/target.c, line 293
#ifndef __SKYEYE
    //wait for frequency is locked, needless by SkyEye. Rmked by linxz.
    while((PLLSTAT & (1 << 10)) == 0);
#endif

line 323:

/* 设置串行口 */
#ifndef __SKYEYE
    InitialiseUART0(115200);                                            //for SkyEye, disable it. by Linxz
#endif

1.2        手动模拟内存映射。
In arch/target.c line 243:
#ifdef __SKYEYE
    //add by linxz 05-3-30
    //here remap 0x00000000 to 0x80000000 to access int vectors
    //for skyeye without mem remap facility, we should copy it manually.
    //vectors range 0x0 - 0x3c
    while ( dest <= 0x3c)
    {
        *(int *)dest = *(int *)src;     //word by word
        dest += 4;
        src += 4;
    }
#endif  //__SKYEYE

要注意复制的内存是Vectors的32Bytes加上额外的32Bytes一共是64Bytes。

1.3        初始化代码中打开Timer0的中断,自己编写Timer0_Handler(),注意清除中断的时机。
2)        对SkyEye的改动。
这部分工作主要集中在skyeye/sim/arm/skyeye_mach_lpc.c,和skyeye/sim/arm/lpc.h中。
2.1添加一些寄存器的定义到lpc_io结构中,虽然有的寄存器在SkyEye的模拟下是没有用的,但是至少可以避免运行时不时出来一堆write_io error的错。
补全后的结构如下:
typedef struct lpc_io {
        ARMword         syscon;                 /* System control */
        ARMword         sysflg;                 /* System status flags */
        lpc_pll_t               pll;            
        lpc_timer_t     timer[2];
        lpc_vic_t               vic;
        ARMword                 pinsel0;        /*Pin Select Register*/
        ARMword                 pinsel1;
        ARMword                 pinsel2;
        ARMword                 bcfg[4];        /*BCFG Extend Mem control*/
        ARMword                 vpbdiv;         /*VPB Divider control*/
        int                     tc_prescale;
        lpc_uart_t      uart[2];                 /* Receive data register */
        ARMword         mmcr;                   /*Memory mapping control register*/
        ARMword         vibdiv;

        /*real time regs*/
        ARMword         sec;
        ARMword         min;
        ARMword         hour;
        ARMword         dom;
        ARMword         dow;
        ARMword         doy;
        ARMword         month;
        ARMword         year;
        ARMword         preint;
        ARMword         prefrac;
        ARMword         ccr;
        /*mam accelerator module*/
        ARMword         mamcr;
        ARMword         mamtim;

} lpc_io_t;
这里不得不提到,原来的实现,寄存器的名字全部采用简单的写法,基本很难对照datasheet找到是哪个…于是重写了Vector Control regs的定义,如下:
typedef struct vic{
        ARMword IRQStatus;
        ARMword FIQStatus;
        ARMword RawIntr;
        ARMword IntSelect;
        ARMword IntEnable;
        ARMword IntEnClr;
        ARMword SoftInt;
        ARMword SoftIntClear;
        ARMword Protection;
        ARMword Vect_Addr;
        ARMword DefVectAddr;
        ARMword VectAddr[15];
        ARMword VectCntl[15];
} lpc_vic_t;

当然,要在lpc_io_read/write_word()和lpc_io_reset里边加上对应的读写操作,毕竟时模拟嘛,也不能什么都不做。
lpc_io_reset把timer的pr(prescale)寄存器的初值改变,就能调整模拟时刻时钟中断来的快慢。为了保持和硬件一致,我还是设为0了。

2.2 lpc_io_do_cycle(),这里主要是模拟Timer0的动作
        /*lpc io_do_cycle*/
void lpc_io_do_cycle(ARMul_State *state)
{
        int t;
        io.timer[0].pc++;
        io.timer[1].pc++;
        //add by linxz
        //printf("SKYEYE:Timer0 PC:%d, TC:%d", io.timer[0].pc, io.timer[0].tc);
        //printf(",MR0:%d,PR:%d,RISR:%d,IER:%d,ISLR:%d,ISR:%d\n", io.timer[0].mr0, io.timer[0].pr, io.vic.RawIntr, io.vic.IntEnable, io.vic.IntSelect, io.vic.IRQStatus);
        if (!(io.vic.RawIntr & IRQ_TC0)) {               //no timer0 int now
                if (io.timer[0].pc >=  io.timer[0].pr+1) {
                //      if (io.timer[0].pc >=  5000+1) {
                        io.timer[0].tc++;
                        io.timer[0].pc = 0;
                if(io.timer[0].tc == io.timer[0].mr0){
        //              if(io.timer[0].tc == 20){
                                io.vic.RawIntr |= IRQ_TC0;
                                io.timer[0].tc = 0;
                                //add by linxz
                                //printf("\r\nI\r\n");
                        }
                        lpc_update_int(state);
                }
        }
        if(io.timer[0].pc == 0){
                if (!(io.vic.RawIntr & IRQ_UART0)) {
                fd_set rfds;
        struct timeval tv;
        FD_ZERO(&rfds);
        FD_SET(skyeye_config.uart.fd_in, &rfds);
      tv.tv_sec = 0;
      tv.tv_usec = 0;

      if (select(skyeye_config.uart.fd_in+1, &rfds, NULL, NULL, &tv) == 1) {
                                unsigned char buf;
                                int n, i;
              n = read(skyeye_config.uart.fd_in, &buf,  sizeof(buf));
                                   //printf("SKYEYE:get input is %c\n",buf);
               if (n) {
                                                io.uart[0].rbr = buf;
                                                io.uart[0].lsr |= 0x1;
                io.vic.RawIntr |= IRQ_UART0;
                lpc_update_int(state);
               }
                        }
        }/* if (rcr > 0 && ...*/
        }
}

2.3 lpc_io_update(),添加一些代码,模拟ARM Prime Cell在中断到来时的动作。主要是根据IRQStatus的中断源置位,找出分配的Slot号,从而找到对应的VectAddr,把它复制到VectAddr寄存器,供0x0000 00018这个位置的指令读取。

In Line 172:
        //here only deals some important int:
        //uart0 and timer0, other peripheral's int reqs are ignored.
        //added and rmked by linxz

        //UART0, Int src: 6
        if(io.vic.IRQStatus &IRQ_UART0){
                for ( i = 0; i<=15; i++ )
                {      
                        if ( ((io.vic.VectCntl & 0xf) == 6 ) && (io.vic.VectCntl & 0x20) )
                                break;
                }
                if ( ((io.vic.VectCntl & 0xf) == 6 ) && (io.vic.VectCntl & 0x20) )
                        io.vic.Vect_Addr = io.vic.VectAddr;
        }

        //TIMER0, Int src: 4
        if(io.vic.IRQStatus &IRQ_TC0){
                for ( i = 0; i<=15; i++ )
                {
                        if ( ((io.vic.VectCntl & 0xf) == 4 ) && (io.vic.VectCntl & 0x20) )
                                break;
                }
                if ( ((io.vic.VectCntl & 0xf) == 4 ) && (io.vic.VectCntl & 0x20) )
                {
                        io.vic.Vect_Addr = io.vic.VectAddr;
                        //printf("VicVect load vectaddr%d:%08x", i, io.vic.Vect_Addr);
                }
        }

2.4 清除中断,采用了对VectAddr写的机制,这个也一并模拟。
                主要是找出优先级最高的中断位来,清除掉。
Lpc_io_write_word(), line 658:

        case 0xfffff030: /* VAR */
                //io.vic.Vect_Addr = data;
                //rmk by linxz, write VAR with any data will clear current int states
                //FIQ interrupt
                //FIXME:clear all bits of FIQStatus?
                if ( io.vic.FIQStatus )
                {
                        io.vic.FIQStatus = 0;
                        break;
                }
                //find the current IRQ number: which has the highest priority.
                mask = 1;
                nHighestIRQ = 0xffff;
                for ( i = 0; i<=15; i++ )
                {
                        nIRQNum = io.vic.VectCntl & 0xf;
                        if ( (nIRQNum<<mask) & io.vic.IRQStatus )
                        {
                                if ( nIRQNum < nHighestIRQ )
                                        nHighestIRQ = nIRQNum;
                        }
                }
                //If there's at least one IRQ now, clean status and raw
                //status register.
                if ( nHighestIRQ != 0xffff )
                {
                        io.vic.IRQStatus &= ~( nHighestIRQ << mask );
                                                io.vic.RawIntr &= ~( nHighestIRQ << mask);
                }
                break;


经历了这些工作之后,重编SkyEye,并且把armcc编译好的elf弄到SkyEye上面模拟,运行完全没有问题。只是不能调试。

三、        把整个工程改造成GCC版本。
首先把所有的源文件全部弄到linux下面,组织成下面几个目录。
Arch/
        放置和体系结构有关的文件
        IRQ.S  Os_cpu_a.S  Os_cpu_c.c  STACK.S  Startup.S  target.c
Kernel/
        放置操作系统内核实现
        OS_CORE.c  OS_MBOX.c  OS_MUTEX.c  OS_SEM.c   OS_TIME.c   
OS_FLAG.c  OS_MEM.c   OS_Q.c      OS_TASK.c     uCOS_II.c
Include/
        放置Headers
IRQ.INC   includes.h  os_cfg.h  queue.h   uart0.h config.h lpc2294.h   os_cpu.h  target.h  ucos_ii.h
        App/
                放置基于ucosii的应用
                QUEUE.c  UART0.c  main.c

        特别说明,QUEUE.c等等提供了队列的实现,在串口应用中会用到。
       
在GCC的规则里边,.C表示cpp source file,.c表示c source file,所以应该把不符合规则的文件名字做修改,否则按照cpp编译的模块,导出的符号会被编译器修饰,这样的符号如果被汇编代码引用,链接的时候会有一些麻烦。

同样,.s表示不被预处理的汇编代码,而.S表示被C的预处理程序处理的汇编代码,这样的汇编代码里可以用的符号有#define, //,/**/等等,能够提供一些方便。


第二步,按照arm-elf-as的语法重写汇编部分的代码。
还好,各种assembler支持的汇编语法差距并不大,只需要做一些不大的改动就可以了。

声明段属性:
.text  .data  .bss等等
默认的名字会有默认的属性,比如.text应该是“rx”等等

数据对齐:
.align        2/4

声明标号:
symbol:
           xxx
           xxx

定义数据:
        连续空间 .space N 以byte为单位
        常量         .word 0x0000000

        条件汇编
        .ifdef
        .else
        .endif

关于汇编代码引用C代码的符号:
不用声明,对于全局变量或者函数名字直接用,链接时刻自然会解析好;

导出汇编代码里边的符号供其他文件引用:
.global Symbol

宏的定义
.macro MyMacro Para1, Para2,…
(引用参数,用/Para1这样)
.endm

StackSvc是Software Interrupt用到的堆栈;
StackIrq是IRQ处理时用到的堆栈;
原来的代码里边关于UsrStackSpace有些问题,我觉得它声明得过小,堆栈总是越界,我把它的尺寸加大了。

第三步,修改C代码适应GCC
GCC的C语法支持内嵌汇编,用法举例:
__asm__(“LDR r1, r2/nLDRr2,r3”)
需要注意的是不能在内嵌汇编里边用宏,因为它是一个字符串常量,如果在内嵌汇编里边用了宏,传递给AS的无法解析的符号全部被默认为0,而没有任何错误提示。

Armcc里边支持__swi关键字,用于声明一个不实现的函数,调用这个函数将引起软中断。GCC不支持这个,但是可以用宏实现,如:
include/os_cpu.h:#define  OS_TASK_SW()       __asm__("swi 0x00")
include/os_cpu.h:#define _OSStartHighRdy()      __asm__("swi 0x01")

还有就是include后边的文件名是大小写敏感的,这和很多windows下面的c编译器不一样。

最后一个问题是关于main()的问题,写习惯了应用的程序员往往容易随手把内核入口直接声明成main(),可是编译器会在main()自动包上一层运行时库的实现,比如__main()什么的,而且这个行为因GCC的版本而异,所以相当麻烦。
往往因为这个问题引起链接的失败,比如报告找不到初始化的构造函数列表等等。
所以最好把内核的入口不用main命名,用mymain,startkernel等等,什么都可以。

第四步,写Makefile和ld script
Makefile分为三类:
1.        各个目录下面的子Makefile,它们的主要作用是生成一个当前目录下面所有的.c和.S列表,由字符的替换得到对应的.o文件列表,然后定义”all”目标依赖于这些.o文件。在子Makefile的最后,都包含了工程主目录下面的rules.make;
2.        Rules.make,定义了生成.o文件的默认规则,就是写出了编译的具体命令行,包括各个编译开关等等;
3.        工程主目录下面的Makefile,进入各个子目录,驱动各个子Makefile,编译各个目录下面的源代码文件,然后生成一个所有的.o文件的名字列表,进行链接工作。

关于Makefile的资料很多,不再详细描述,只是有几个比较有用的函数,提一下:
wildcard 替换式的展开;
patsubst  替换字符;
foreach   循环枚举。
注意函数的参数之间,参数和括号之间都不能有空格,这让写惯了C代码的程序员不太习惯。

Cc用到的几个开关:
-Idir   +include dir before default search path
-Wall   Turn on all warning options
-Wno-trigraphs ...........(?)
-mapcs-32 Generate the code for CPU with 32-bit program counter.
-mtune This option is similiar to -mcpu, specified the actual target cpu type.
        ps:-mcpu也提供-arm7tdmi选项的,我觉得无所谓
-mshort-load-byte: alias for  -malignment-traps 后者是什么意思?

Ld用到的几个开关:
simple example:ld -o <output> /lib/crt0.o -lc
链接到crt0.o和libc.a ( come from the standard search path )

-T script file: Use script file as the linker script. This script replaces the
                linker's default script file.
                就是说指定.lds....
                god,还得研究lds的写法

-X              discard all local symbols(?)

--start-group archives 一系列.a文件
                -lgcc -lc  这里的含义是加入libgcc.a libc.a
       
接下来的工作是写ld script,也就是链接描述文件,在该文件里边,我们将指定各个段如何放置。本例的lds很简明,几乎最简单的lds了:
OUTPUT_ARCH(arm)
ENTRY(reset)
SECTIONS
{
        . = 0x80000000;
        .text :
        {
                                arch/Startup.o(*.text)
                *(.text)
        }

        . = 0x81000000;

        .data : {*(.data)}
               
                .rodata : {*(.rodata)}

        .bss : {*(.bss)}
}

这里的.text .data .bss是搜集各个.o的.text .data .bss组装起来
注意等号前面后边的空格,冒号前后的空格,都是必须的,否则过不了

关于ENTRY的问题,我的认识是,这里指定的ENTRY是仅仅是elf-header里的,SkyEye加载的时候,会根据Skyeye.conf里边内存块的配置选择入口点。
既然我配置的Skyeye执行入口是0x80000000,那么我必须得把Startup.o强制配置在整个.text段的最前面,即从0x80000000开始。
如不这样写,各个.o装配的顺序好像是和在传递给ld的参数中顺序一致的,比较难以控制。

另外一个问题是容易忽视定位.rodata段,当然ld是不会报告任何错误的,运行的时候会发现所有需要初始化的变量、数组全部未能获得正确的值。原因就是因为lds没有定位.rodata段。

四、        关于ucosii本身的一些要点。

关于移植的问题,Labrosse已经在书里边论述得很清楚了,在LPC2210这里要注意的就是处理器状态的合理分配问题;

高优先级的任务如果不是由于中断,优先级被降低,或者是主动OSTimeDly(),是不会主动进行任务切换的;

通过swi实现任务切换的动作,主要流程如下:
call swi->fetch software int->enter svc mode->jump to software interrupt
->analyse swi num->0 or 1, process(ctx switch or highrdy)->other pass to
swi exception handler-->dispatch swi num...-->ret

注意os_cfg.h里边的配置,任务数什么的,创建Task的时候,优先级分配要恰当,最高的几个和最低的几个都是系统保留的;

五、        调试手段
前后弄了这么久,做过斗争的错误从少写了一行反汇编代码到GDB自己的bug(GDB 5.3未能正确定位我的mymain符号,差一个偏移量),积累了一些基本方法。

Elf格式GDB不支持的时候,利用SkyEye的log file的指令执行流程,加上全文查找,反汇编,源代码找错误,或者往UART的寄存器里边写字符来调试,一次一个,无论是C还是汇编都可以。

SkyEye能断点或者单步,事情就轻松多了,善于利用b, si, ni, x等等命令,可以很快定位错误,何况还有条件断点等等高级功能。

在跳转到ucosii的入口之前,由于流程都是顺序的,所以应该是读、写寄存器的问题,或者有一些在硬件板子上能过的特殊写法,比如死循环等待什么东西等等。

跳转到ucosii入口之后,能够执行第一个任务一次,一切换就死掉,要跟踪看看切换的函数OS_TASK_SW等等,由于任务切换涉及中断和处理器状态的切换,在C和汇编之间来回跳转,比较复杂,所以也是最后一个难题。跟踪的时候时刻注意SP指向的空间到底是什么地方,如果都指向了代码空间,或者在Usr Mode里边一直指着SvcStack等等,要考虑是不是状态没有正确地被切换,或者堆栈的声明,类型、大小设置是不是出了问题。其次是看看Timer的中断是不是一直在来,OSTimeTick()正确被调用了。

如果能够反复执行第一个任务,说明没有任务切换,通过OSTimeDly,低优先级的任务却并没有进入就绪态。这时可以看看ucosii的一些全局变量,比如OsSwCtr,OSRdyGrp,OSRdyTbl的状态,到底是否随着OSTimeTick的调用,任务就绪表被正确改写了。就绪表初始化是否正确也是关注的焦点。

分析到这一步,已经到内核了,以后出问题的可能比较小了。

Appendix: skyeye.conf for lpc2210
cpu: arm7tdmi
#--------------------------------------------------------------------------------
# below is the machine(develop board) config info
# machine(develop board) maybe at91 or ep7312
mach: lpc2210
#-------------------------------------------------------------------------------
mem_bank: map=M, type=RW, addr=0x00000000, size=0x00000040
mem_bank: map=M, type=RW, addr=0x40000000, size=0x00200000
mem_bank: map=M, type=RW, addr=0x80000000, size=0x00200000, boot=yes
mem_bank: map=M, type=RW, addr=0x81000000, size=0x00080000
mem_bank: map=I, type=RW, addr=0xe0000000, size=0x20000000
发表于 2005-6-28 08:35:34 | 显示全部楼层
欢迎大家把使用SkyEye,开发OS on SkyEye的经历发表出来!
回复

使用道具 举报

发表于 2005-9-23 14:19:27 | 显示全部楼层
厉害。我也正在尝试使自己手上 for AT91rm9200 的linux内核能在skyeye下跑起来。从楼主的做法中学习到了一些东西。非常感谢。
回复

使用道具 举报

发表于 2006-11-20 11:59:08 | 显示全部楼层
版主,我给你发邮件拉哈,你真狠啊,厉害,我弄不出来,就是在skyeye上跑ucos的lpc2200板都没成功
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

GMT+8, 2024-5-12 06:13 , Processed in 0.102715 second(s), 15 queries .

© 2021 Powered by Discuz! X3.5.

快速回复 返回顶部 返回列表