您的位置:首页 > 编程语言 > Go语言

随笔之GoldFish Kernel启动过程中arm汇编分析

2012-12-01 16:59 525 查看

随笔之GoldFish Kernel启动过程中arm汇编分析

一 分析

电子版下载地址:进入我的资源就能找到
http://download.csdn.net/detail/innost/4834459
本节介绍Kernel启动。此时Piggy已经将vimlinux解压,BL将执行权限传给了Kernel。
代码在arch/arm/kernel/head.S中。相关代码如下:
//将采用C/C++注释语句
/*
.section是GNU ASM的语法。格式如下:
.section name[,"flags"[,@type]] 其中,name是必须的,flags是可选。
"ax"表示:a为section is allocatable,x为executable。
*/
.section ".text.head", "ax"

//这个ENTRY(stext)有相当的含义。在kernel/vmlinux.ld.S中,也定义了一个ENTRY。在ld
//语法中,ENTRY是一个command,用来定义入口点。所以,这里就是kernel执行的入口点函数。
ENTRY(stext)
/*
MSR:是ARM汇编指令,用来将数据copy到status register寄存器中。cpsr_c表示要操作
CPSR寄存器的Control标志。
*/

msr cpsr_c, #PSR_F_BIT | PSR_I_BIT | SVC_MODE @ ensure svc mode
@ and irqs disabled

1.1 MSR设置I/F和CPU Mode

CPSR全称是Current Process Status Register,用来表示当前CPU的状态,也可用于控制。相关控制位如图1所示:


图1 CPSR控制位由图1可知:
q N/Z/C/V控制位用来表示负/零/进位/溢出,属于User Flags,即可在UserMode下操作。A
q I/F表示Interrupt和Fast Interrupt使能位。
q Mode用来控制CPU当前的模式。ARM CPU一共有7种模式。
根据上面的代码,首先将禁止I/F中断,并进入Supervisor模式,也就是OS运行的模式。图2为ARM CPU支持的CPU模式。


图2 ARM CPU支持的运行模式另外,MSR指令操作的格式如下:


图3 MSR指令格式其中最重要的是fields,目前支持:
q c:设置control bit。对应位为16。
q x:设置extension bit。对应位为17。
q s:设置status bit。对应位为18。
q f:设置flags field。对应位为19。


图4 MSR二进制格式直接看上面的解释,还不是很清楚,因为设置的是MSR指令本身的内容,具体对应到CPSR呢,则可通过下面的伪语句得到:


图5 MSR 设置说明从代码可知:
msr cpsr_c, #PSR_F_BIT | PSR_I_BIT | SVC_MODE
//上面代码将设置CPSR的0到第7位,刚好是控制I/F和设置CPU模式的。

1.2 ARM CP15协处理器控制

设置好CPU模式后,下面的工作就是获取CPU的信息。在ARM中,协处理(coprocessor)15中用于管理CPU信息和MMU相关的工作。CP15也是ARM中最重要的处理器,以后会经常碰到。
先看下面这条语句:
mrc p15, 0, r9, c0, c0 @ get processor id
MRC是ARM指令,用来从协处理对应的寄存器读取信息到CPU的寄存器,对应写协处理寄存器的指令是MCR。二者的语法格式(注意,是操作CP15的时候)如图6所示:


图6 MRC操作CP15的格式说明q Rd:本指令用得是R9,也就是协处理的信息会保存到R9中。
q CRn:MRC中协处理器的主要寄存器。此处用得是C0。标准写法是C0,C1一直到C15。
q CRm:附属信息。如果没有附属信息,则使用C0。
q opcode2,类似附属信息。根据CRn来决定是否需要。如果不指定,则使用0。
CP15有很重要的作用,可通过操作CP15的寄存器来控制它。如图7所示:


图7 CP15各个寄存器的作用先来看此处操作的C0寄存器。


opcode2在指令中默认是0,所以将取出Main ID register的信息。
得到的结果将怎么使用呢?来看下一句指令:
bl __lookup_processor_type @ r5=procinfo r9=cpuid
BL是ARM中的跳转指令,相当于调用函数吧。__lookup_processor_type用来得到CPU信息。注意,这个函数调用的参数是R9,R9的值是从CP15 C0寄存器读取出来的,而是是Main ID。下面看看此函数如何处理R9。

1.3 __lookup_procesoor_type分析

该函数在head-Common.S中定义。下面逐行分析它,这里会碰到几个重要的指令及其用法。
__lookup_processor_type:
//adr是一条伪指令,其作用是将3f标签的地址赋给R3。这个伪指令其实是可拆分成多条指令
//由于后面的3f是相对当前PC位置而言,所以R3实际上存储的是3f的物理地址。
adr r3, 3f//f是forward之意。标志3在此代码之后声明
/*
ldm是load multiple register的意思,它的作用是将[r3]对应的内存内容存储到r5,r6,
r7寄存器中。DA是Decrease After的意思。ARM汇编在这里有4种模式,DA,IA,DB,IB等
此处的ldmda,将把3F所在的内容依序传递给R7,R6,R5。每传递一次,R3递减4个字节。
*/
ldmda r3, {r5 - r7}
上面语句执行完后:
q R5=__proc_info_begin,这个值是虚地址。
q R6=__proc_info_end。
q R7=.。
以上几个值都是虚地址。__proc_info_begin/end是ld在链接时候指定的信息。


图8 arc/arm/kernel/vmlinx.lds.S文件从中可以看出,__proc_info_begin/end包含了代码中定义在.proc.info.init段的内容。如图9所示。


图9 proc-V7.s定义的proc.info.init的内容为什么是proc -v7.S文件呢,因为goldfish编译的就是这个文件。从图9可以看出,其实也就是定义了一个数据结构罢了。
接着来看代码
//r3指向3f的物理地址,r7指向虚拟地址,而现在只能访问物理地址,所以需要找到一个offset
sub r3, r3, r7 @ get offset between virt&phys
add r5, r5, r3 @ convert virt addresses to
add r6, r6, r3 @ physical address space
经过上面的换算,r5,r6现在都指向__proc_info_begin/end的物理地址了。
//ldmia将[r5]的内存信息存储到r3,r4中,每完成一次传输,r5自动加4.
1: ldmia r5, {r3, r4} @ value, mask
//下面将测试R9和mask之后的值是否是我们想要的r3的值。根据图9。应该是0x000f0000。
//在Main ID register中,这表明[16-19]位是都是1.
and r4, r4, r9 @ mask wanted bits
teq r3, r4
beq 2f //如果是我们想要的数据,则跳转到2f
//否则跳过一个PROC_INFO_SIZE,继续找,一般只有一个PROC_INFO结构体。
add r5, r5, #PROC_INFO_SZ @ sizeof(proc_info_list)
cmp r5, r6
blo 1b
//如果没找到,则设置R5寄存器为0
mov r5, #0 @ unknown processor
2: mov pc, lr //从函数返回
ENDPROC(__lookup_processor_type)

/*
* 提供一个C接口的lookup_process_type函数
*/
ENTRY(lookup_processor_type)
stmfd sp!, {r4 - r7, r9, lr}
mov r9, r0
bl __lookup_processor_type
mov r0, r5
ldmfd sp!, {r4 - r7, r9, pc}
ENDPROC(lookup_processor_type)

.long __proc_info_begin
.long __proc_info_end
3: .long .
.long __arch_info_begin
.long __arch_info_end
lookup_process_type其实比较简单,这里就不再多说。但图9的内容以后还要回过头来继续介绍。那里将初始化CPU MMU相关的内容。
//如果r5为空,则表示CPU信息获取是否,调用__error_p,退出整个启动
movs r10, r5 @ invalid processor (r5=0)?
beq __error_p @ yes, error 'p'

否则,将调用__lookup_machine_type获取机器信息。

1.4 __lookup_machine_type分析

该函数也是在head-comm.S中定义的。
__lookup_machine_type:
adr r3, 3b //b是backward的意思。标志3在此代码之前声明。
//r4,r5,r6分别指向 label 3,__arch_info_begin和__arch_info_end
ldmia r3, {r4, r5, r6}
sub r3, r3, r4
add r5, r5, r3
add r6, r6, r3
//以上将得到__arch_info_begin/end的物理地址

1: ldr r3, [r5, #MACHINFO_TYPE]
//比较r1和MACHINFO_TYPE是不是一致。注意,r1的值是BL传递给它的
teq r3, r1 @ matches loader number?
beq 2f @ found
add r5, r5, #SIZEOF_MACHINE_DESC @ next machine_desc
cmp r5, r6
blo 1b
mov r5, #0 @ unknown machine
2: mov pc, lr
ENDPROC(__lookup_machine_type)
这里涉及到另一个关键数据结构,也就是定义在.arch.info.init段中的。如图10所示:


图10 .arch.info.init段从图10可知,这个段其实对应了一个数据结构,即machine_desc.在我们的goldfish平台中,它是这么定义的:
[arch/arm/mach-goldfish/board-goldfish.c]
//还需要加上:
nr = MACH_TYPE_GOLDFISH
name = "Goldfish"
MACHINE_START(GOLDFISH, "Goldfish")
.phys_io = IO_START,
.io_pg_offst = ((IO_BASE) >> 18) & 0xfffc,
.boot_params = 0x00000100,
.map_io = goldfish_map_io,
.init_irq = goldfish_init_irq,
.init_machine = goldfish_init,
.timer = &goldfish_timer,
MACHINE_END
完整的machine_desc定义如图11所示:


图11 machine_desc定义在Goldfish中,nr为1441。详情可参考arch/arm/tools/machine-types.h。
另外,在BootLoader调用kernel之前,传递参数情况如图12所示:


图12 arch/arm/boot/head.S调用kernel前传递参数从图12可知:
q r1保存的是machine nr。
这部分代码属于BootLoader,相当复杂。以后再细说。
假设__lookup_machine_type一切正常
bl __lookup_machine_type @ r5=machinfo
movs r8, r5 @ invalid machine (r5=0)?
beq __error_a @ yes, error 'a'

1.5 __vet_atags分析

接下来的任务就是Kernel校验BL传递的启动参数了。这部分内容和BootLoader有较大关系。
bl __vet_atags
此处的核心概念就是ATAG_CORE/END之类的,由BootLoader往Kernel传递参数,主要是tag结构体
在arch/arm/include/asm/setup.h中。BL传递的是struct tag的链表,该链表以ATAG_CORE开头,以ATAG_NONE结尾。
#define ATAG_CORE 0x54410001
#define ATAG_NONE 0x00000000
struct tag_header {
__u32 size;
__u32 tag;
};
struct tag {
struct tag_header hdr; //首先是一个头,根据头部的tag来判断下面的union是哪个
union {
struct tag_core core;
struct tag_mem32 mem;
struct tag_videotext videotext;
struct tag_ramdisk ramdisk;
struct tag_initrd initrd;
struct tag_serialnr serialnr;
struct tag_revision revision;
struct tag_videolfb videolfb;
struct tag_cmdline cmdline;
struct tag_acorn acorn;
struct tag_memclk memclk;
} u;
};
你可以根据上面的信息自行分析__vet_atags函数。

1.6 __create_page_tables分析

下面的任务就是调用__create_page_tables创建page table。
bl __create_page_tables //调用__create_page_tables函数
此函数就在head.S中定义,代码如下:
__create_page_tables:
/*
pgtbl是head.S中定义的一个宏,见下面的分析
*/
pgtbl r4
pgtbl定义了一个宏,相关代码如下:
//TEXT_OFFSET是kernel镜像在内存中的偏移量。一般定义为0X8000,即32KB处
//PHYS_OFFSET:是内核镜像在内存中的起始物理地址。上面二者之和就是内核镜像在机器上的
//物理地址。Goldfish平台中,PHYS_OFFSET为0。
//PAGE_OFFSET是Kernel镜像在虚拟内存的起始地址,一般是3G处
#define KERNEL_RAM_VADDR (PAGE_OFFSET + TEXT_OFFSET)
#define KERNEL_RAM_PADDR (PHYS_OFFSET + TEXT_OFFSET)
.macro pgtbl, rd //此宏调用完毕后,r4的值就是0x4000,即16KB
ldr \rd, =(KERNEL_RAM_PADDR - 0x4000)
.endm
接着看代码。
mov r0, r4
mov r3, #0
add r6, r0, #0x4000
//STR将寄存器的值往内存中传送。r3为0,故内存的值被设置为0.每调用一次str,r0递增4
//r0是base address,其值可自动增减。由arm address mode格式控制
1: str r3, [r0], #4
str r3, [r0], #4
str r3, [r0], #4
str r3, [r0], #4
teq r0, r6
bne 1b //此循环调用完毕后,0x4000-0x8000的内存都被设置为0。此时r0=32KB

//r10存储的是图9中proc_info的第三个long,也就是mmuflas,用于设置MMU参数
ldr r7, [r10, #PROCINFO_MM_MMUFLAGS] @ mm_mmuflags

1.6.1 ARM MMU设置介绍

虽然上面最后一条语句是一个简单的ldr,但背后的内容却相当丰富,不把它搞清楚,后面的内容将解释不清。来看proc-V7中mm_mmuflags对应的值是什么
.long PMD_TYPE_SECT | \ // #define PMD_TYPE_SECT (2 << 0)
PMD_SECT_BUFFERABLE | \ //#define PMD_SECT_BUFFERABLE (1 << 2)
PMD_SECT_CACHEABLE | \//#define PMD_SECT_CACHEABLE (1 << 3)
PMD_SECT_AP_WRITE | \//#define PMD_SECT_AP_WRITE (1 << 10)
PMD_SECT_AP_READ //#define PMD_SECT_AP_READ (1 << 11)
上面代码中把对应PMD_SECT_XXX的值显示出来,可知它无非是定义了一个32位的常量,某些位置的值为1,某些位置的值为0。为什么要怎么做呢?先来看ARM MMU所支持的虚实地址转换机制。


图13 ARM MMU虚实地址转换由图13可知:
q 虚地址VA的[20-31]位和CP15 CR2的[14-31]位共同构成First Level地址。
q 从First Level地址将得到一个First Level Descripter,也就是图13中标明memory access的内容。
q FLD中不同字段表明其内容是段寻址还是页寻址。主要是根据前2位来判断。如果前2位是0b10则是段寻址。
结合图13和前面的代码:
q PMD_TYPE_SECT = 2<<0,刚好就是0b10
q C|B控制Cachable和Buffable的,对应为[2,3]位
q AP对应为Access Point,对应为[10,11]位。
另外,Domain是ARM CPU的一个重要概念,主要和权限有关。以后碰到再说。
至此,当ldr r7 xx执行完后,r7的值包含了section base address对应的[0-12]位的值。而section base address本身却还没有赋值。
接下来的代码就是为了构造一个FLD的值。根据图13,section base address应该是[20-31]位
//r6的值为当前PC值右移20位
mov r6, pc, lsr #20
orr r3, r7, r6, lsl #20 @ flags + kernel base
//此时,r3的值就是一个基于段寻址的FLD。把它存起来。位置是r4+r6<<2
str r3, [r4, r6, lsl #2]
现在r6存储的是段寻址的基地址,需要把这个值存储到对应表的位置,由于在表中,每一项是4个字节,所以这里需要乘以4,也就是lsl #2。
稍微解释下这里左移4的原因:
1 r4存储的是表的起始地址
2 r6存储的是offset
3 r3存储的是往r4[offset]的值
4 由于1个offset实际上是4个字节,所以真实存储的位置就是r4[4*offset] = r3

1.6.2 设置页表

当理解上面代码后,下面就是把kernel虚拟地址的位置存储到r4表中了
继续看代码
//立即数的计算比较难理解,网上也没有相关说法。不过,只要知道下面这段代码就是存储kernel
//虚拟地址到对应页表位置即可
add r0, r4, #(KERNEL_START & 0xff000000) >> 18
str r3, [r0, #(KERNEL_START & 0x00f00000) >> 18]!
ldr r6, =(KERNEL_END - 1)
add r0, r0, #4 //r0 = ro+4
add r6, r4, r6, lsr #18 //r6=r4+r6>>18
1: cmp r0, r6
add r3, r3, #1 << 20 //r3 += 1<<20,每次递增1M
//ls是condition code,表示小于等于,即只要r0<=r6,strls就会执行
strls r3, [r0], #4
bls 1b

//map物理地址前1M到对应位置
add r0, r4, #PAGE_OFFSET >> 18
orr r6, r7, #(PHYS_OFFSET & 0xff000000)
.if (PHYS_OFFSET & 0x00f00000)
orr r6, r6, #(PHYS_OFFSET & 0x00f00000)
.endif
str r6, [r0]

mov pc, lr
ENDPROC(__create_page_tables)
.ltorg

1.6.3 总结

建议大家仔细体会create_page_tables这段内容。虽然以后不太可能会使用它们,但把这段代码搞清楚还是一个比较有意思的过程。

1.7 剩余工作

回到head.S,最后还剩下几句代码:
//将__switch_data的位置存储到r13
ldr r13, __switch_data
//获取__enable_mmu标签的地址,并保存到lr中
adr lr, __enable_mmu
//r10存储的是__v7_proc_info的地址,#PROCINFO_INITFUNC是一个偏移量
//执行完下条语句后,pc指向__v7_proc_info的b __v7_setup,故下面这条语句就是
//执行__v7_setup函数
add pc, r10, #PROCINFO_INITFUNC
ENDPROC(stext)

1.7.1 __switch_data说明

__switch_data标签如下,主要存储了一些数据。
[head-common.S]
.type __switch_data, %object
__switch_data:
.long __mmap_switched
.long __data_loc @ r4
.long _data @ r5
.long __bss_start @ r6
.long _end @ r7
.long processor_id @ r4
.long __machine_arch_type @ r5
.long __atags_pointer @ r6
.long cr_alignment @ r7
.long init_thread_union + THREAD_START_SP @ sp
以后再讨论具体作用。

1.7.2 __v7_setup

先来看
add pc, r10, #PROCINFO_INITFUNC
实际上就是执行__v7_setup函数。代码在mm/proc-v7.S中。
adr r12, __v7_setup_stack @ the local stack
stmia r12, {r0-r5, r7, r9, r11, lr}
bl v7_flush_dcache_all
ldmia r12, {r0-r5, r7, r9, r11, lr}
mov r10, #0
dsb
#ifdef CONFIG_MMU //goldfish定义了这个配置项
mcr p15, 0, r10, c8, c7, 0 @ invalidate I + D TLBs
mcr p15, 0, r10, c2, c0, 2 @ TTB control register
orr r4, r4, #TTB_FLAGS
mcr p15, 0, r4, c2, c0, 1 @ load TTB1
mov r10, #0x1f @ domains 0, 1 = manager
mcr p15, 0, r10, c3, c0, 0 @ load domain access register
#endif
ldr r5, =0xff0aa1a8
ldr r6, =0x40e040e0
mcr p15, 0, r5, c10, c2, 0 @ write PRRR
mcr p15, 0, r6, c10, c2, 1 @ write NMRR
adr r5, v7_crval
ldmia r5, {r5, r6}
mrc p15, 0, r0, c1, c0, 0 @ read control register
bic r0, r0, r5 @ clear bits them
orr r0, r0, r6 @ set them
//最后一句,将lr赋值给pc。执行完后,将跳到__enable_mmu函数。
mov pc, lr @ return to head.S:__ret
ENDPROC(__v7_setup)
上面代码大多是执行ARM v7 CPU的MMU相关设置的,而其中的汇编语句到比较简单。这也是ARM MMU设置的核心内容。下面我们将结合ARM CPU Rerference简单介绍下这些设置的内容。
请务必从ARM官方网页上下载下面两个文档:
q DDI0344D_cortex_a8_r2p1_trm.pdf:介绍CORTEX A8相关内容
q DDI0406B_arm_architecture_reference_manual_errata_markup_10_0:最新的ARM架构参考手册
1. 如何看懂MMU设置并掌握理论知识以下面这个设置为例:
mcr p15, 0, r10, c8, c7, 0
打开参考文档DDI0344D_cortex_a8_r2p1_trm.pdf的第112页。从这一页开始,C15协处理器的各个寄存器的配置都有详细的说明。如图14所示


图14 C8寄存器的设置上图中,左边空白区域对应的是C8。可知,c8,c7,0的组合对应的是Invalidate unified TLB unlocked entries.详细说明在page3-99。
如果在此文档中碰到有不理解的内容,就需要参考DDI0406B_arm_architecture_reference_manual_errata_markup_10_0。该文档会介绍一些理论知识。
篇幅原因,我就不在这里啰嗦。已经告诉大家如何钓鱼了,请大家自己尝试!

1.7.3 __enable_mmu介绍

__v7_setup最后已经的mov pc, lr将使得CPU跳转到__enable_mmu处,其代码如下所示:
__enable_mmu:
#ifdef CONFIG_ALIGNMENT_TRAP
orr r0, r0, #CR_A
#else
bic r0, r0, #CR_A
#endif
#ifdef CONFIG_CPU_DCACHE_DISABLE
bic r0, r0, #CR_C
#endif
#ifdef CONFIG_CPU_BPREDICT_DISABLE
bic r0, r0, #CR_Z
#endif
#ifdef CONFIG_CPU_ICACHE_DISABLE
bic r0, r0, #CR_I
#endif
//设置domain的权限,请参考前面的书籍了解DOMAIN在ARM MMU中的意义
mov r5, #(domain_val(DOMAIN_USER, DOMAIN_MANAGER) | \
domain_val(DOMAIN_KERNEL, DOMAIN_MANAGER) | \
domain_val(DOMAIN_TABLE, DOMAIN_MANAGER) | \
domain_val(DOMAIN_IO, DOMAIN_CLIENT))
//请参考前面的方法,了解下面这两条语句的实际作用
mcr p15, 0, r5, c3, c0, 0 @ load domain access register
mcr p15, 0, r4, c2, c0, 0 @ load page table pointer
b __turn_mmu_on //跳转到__turn_mmu_on
ENDPROC(__enable_mmu)
简单看看__turn_mmu_on:
__turn_mmu_on:
mov r0, r0 //类似nop的空指令,浪费一点CPU时间,怕引起race condition发生
//c1,c0这两个控制MMU的设置
mcr p15, 0, r0, c1, c0, 0 @ write control reg
mrc p15, 0, r3, c0, c0, 0 @ read id reg
mov r3, r3
mov r3, r3
//此时,MMU就正式启动了
mov pc, r13 //r13指向__switch_data
ENDPROC(__turn_mmu_on)
MMU启动后,我们也无需管什么物理地址还是虚拟地址,直接去看对应地址的代码即可。如果您非对这个转换过程很感兴趣,建议您把那两个参考书好好瞅瞅。

1.7.4 __mmaped_switched介绍

__switch_data第一个定义的就是__mmaped_switched,PC将执行这里的指令:
__mmap_switched:
adr r3, __switch_data + 4

ldmia r3!, {r4, r5, r6, r7}
cmp r4, r5 @ Copy data segment if needed
1: cmpne r5, r6
ldrne fp, [r4], #4
strne fp, [r5], #4
bne 1b

mov fp, #0 @ Clear BSS (and zero fp)
1: cmp r6, r7
strcc fp, [r6],#4
bcc 1b

ldmia r3, {r4, r5, r6, r7, sp}
str r9, [r4] @ Save processor ID
str r1, [r5] @ Save machine type
str r2, [r6] @ Save atags pointer
bic r4, r0, #CR_A @ Clear 'A' bit
stmia r7, {r0, r4} @ Save control register values
//上面我就懒得废话了,下面这句代码相信各位都很了解。执行start_kernel函数。
b start_kernel
ENDPROC(__mmap_switched)

二总结

我觉得需要说明下为什么写这篇文章:
早在2010年7月的时候,我就看了那本鼎鼎大名的《ARM体系结构与编程》,这应该是第一本系统介绍ARM体系结构和编程的书。但是没看懂,全是枯燥的ARM CPU设置,纯教科书。
最近因为工作的原因,想把ARM这块重新捡起来,想起2年的痛苦,觉得应该换个思路。ARM也好,汇编也好,我们应该关注它的目的,而不是具体它是怎么实现的。即了解What to do比了解How to do更重要(仅我个人目的而言,前者重要。不过在某些追求细节的时候,后者重要。需要你自己去判断)。根据这个思路,我选择以Linux Kernel启动为分析对象,大致研究流程如下:
q 先花几天时间了解下ARM汇编的大概语句。
q 直接上代码分析。不过你得对Kernel启动的流程稍有了解。还好我在《深入理解Android卷I》写完后,花了点时间把这块整理了下。请参考/article/1361287.html
q 碰到不懂的汇编语句,就查参考手册。这些还只是针对一些没有背景知识的语句。
q 当碰到类似CP15操作的语句时,其背后往往包含了较多的CPU相关的知识,这时候就需要查阅前面提到的两本参考书籍,去真真正正了解ARM CPU运行的相关原理。
大概经过2周先痛苦挣扎,到后面豁然开朗的过程,后续的研究就非常非常流畅了。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: