1、1 Linux x86_64与i386区别之 —— 内存寻址 收藏 2 1 引子 毫无疑问,不管是32位,还是64位处理器,所有进程(执行的程序)都必须占用一定数量的内存,它或是用来存放从磁盘载入的程序代码,或是 存放取自用户输入的数据等等。不过进程对这些内存的管理方式因内存用途不一而不尽相同,有些内存是事先静态分配和统一回收的,而有些却是按需要动态分配和回收的。 对任何一个普通进程来讲,它都会涉及到5种不同的数据段。稍有编程知识的朋友都该能想到这几个数据段种包含有“程序代码段”、“程序数据段”、“程 序堆栈段”等。不错,这几种数据段都在其中,但除了以上几种数据段之外,进程还
2、另外包含两种数据段。下面我们来简单归纳一下进程对应的内存空间中所包含的 5种不同的数据区。 代码段:代码段是用来存放可执行文件的操作指令,也就是说是它是可执行程序在内存种的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作,而不允许写入(修改)操作——它是不可写的。 数据段:数据段用来存放可执行文件中已初始化全局变量,换句话说就是存放程序静态分配的变量和全局变量。 BSS段:BSS段包含了程序中未初始化全局变量,在内存中 bss段全部置零。 堆(heap):堆是用于存放进程运行中被动态分配的内存段,它大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配
3、的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。 栈:栈是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味这在数据 段中存放变量)。除此以外在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也回被存放回栈中。由于栈的先进先出特 点,所以栈特别方便用来保存/恢复调用现场。从这个意义上将我们可以把堆栈看成一个临时数据寄存、交换的内存区。 静态分配内存就是编译器在编译程序的时候根据源程序来分配内存. 动态分配内存就是在程序编译之后
4、 运行时调用运行时刻库函数来分配内存的. 静态分配由于是在程序运行之前,所以速度快, 效率高, 但是局限性大. 动态分配在程序运行时执行, 所以速度慢, 但灵活性高。 术语"BSS"已经有些年头了,它是block started by symbol的缩写。因为未初始化的变量没有对应的值,所以并不需要存储在可执行对象中。但是因为C标准强制规定未初始化的全局变量要被赋予特殊的默认值 (基本上是0值),所以内核要从可执行代码装入变量(未赋值的)到内存中,然后将零页映射到该片内存上,于是这些未初始化变量就被赋予了0值。这样做避免 了在目标文件中进行显式地初始化,减少空间浪费(来自《Linux内核开
5、发》)
我们在x86_64环境上运行以下经典程序:
#include
6、gment):%p\n",main); printf("____________________________\n"); int stack_var0=2; printf("Stack Location:\n"); printf("\tInitial end of stack:%p\n",&stack_var0); int stack_var1=3; printf("\tnew end of stack:%p\n",&stack_var1); printf("____
7、\n"); printf("Data Location:\n"); printf("\tAddress of data_var(Data Segment):%p\n",&data_var0); static int data_var1=4; printf("\tNew end of data_var(Data Segment):%p\n",&data_var1); printf("____________________________\n");
8、 printf("BSS Location:\n"); printf("\tAddress of bss_var:%p\n",&bss_var); printf("____________________________\n"); char *b = sbrk((ptrdiff_t)0); printf("Heap Location:\n"); printf("\tInitial end of heap:%p\n",b); brk(b+4); b=sbrk((pt
9、rdiff_t)0); printf("\tNew end of heap:%p\n",b); return 0; } 运行结果: [root@kollera updilogs]# ./memory below are addresses of types of process's mem Text location: Address of main(Code Segment):0x400568 ____________________________ Stack Location: Initial end
10、of stack:0x7fff0e0dc544 new end of stack:0x7fff0e0dc540 ____________________________ Data Location: Address of data_var(Data Segment):0x600bfc New end of data_var(Data Segment):0x600c00 ____________________________ BSS Location: Address of bss_var:0x600c14 __
11、 Heap Location: Initial end of heap:0xb059000 New end of heap:0xb059004 3 2 x86_64体系新变化 AMD x86_64的出现,给全新的64位的x86带来了很多结构上的变化: 1)64位整型数 在x86-64中,所有通用寄存器(GPRs)都从32位扩充到了64位,名字也发生了变化。8个通用寄存器(eax, ebx, ecx, edx, ebp, esp, esi, edi)在新的结构中被命名为rax,
12、rbx, rcx, rdx, rbp, rsp, rsi, rdi,它们都是64位的。呵呵,想当年,从16位扩充到32位时,同样也有一次名字的变化。所有算术逻辑操作、寄存器到内存的数据传输现在都能以64位 的整形类型进行操作。堆栈的压栈和弹出操作都以8字节的单位进行,而且指针类型也拥有了64位。 2)新增寄存器 在新的架构中,另外新增了8个通用寄存器:64位的r8, r9, r10, r11, r12, r13, r14, r15。这样就有利与编译器将函数参数、返回值等放在这些新增的GPR里面进行传递,从而提高了程序的运行速度。同时,128位的MMX寄存器也从原来的 8个增加到了1
13、6个。 3)增大的逻辑地址空间 目前在新的架构中,应用程序可以拥有的逻辑地址空间从4GB增加到了256TB(2^48),而且这一逻辑地址空间在未来可能增加到16EB (2^64,1EB=1024PB,1PB=1024TB,1TB=1024GB)。 4)增大的物理地址空间 目前的x86-64架构,可以支持的物理内存扩展到了1TB(2^40),当然,在未来该数字可以扩展到4PB(2^52)。相比于经过PAE技术扩展的i386的64GB物理内存,新的架构带来了不小的飞跃。 5)无缝使用SSE指令 新的架构借鉴和吸收了Intel的SSE、SSE2的核心指令,并在2
14、005年加入了SSE3。在这一新的架构下,可以不再需要x87浮点协处理器来完成浮点运算了。 6)NX位 跟PAE技术一样,新的x86-64架构也在页表项中增加了NX位,来帮助CPU判断该页包含的内容是否是可以执行的,从而避免借助“buffer overrun”导致的病毒攻击。 7)去除旧的机制 在新架构的“长模式(long mode)”下,很多在IA32中被提出,但确不经常被操作系统用到的一些机制不再被支持。这些机制包括段式地址变化机制(FS和GS仍然被保留),任务 转移门(TSS)机制,以及虚拟86模式。当然,出于向下兼容的考虑,x86-64在“传统模式”(Legac
15、y mode)下,仍然对这些机制进行了保留。 4 3 x86_64段式管理 x86的两种工作模式:实地址模式和虚地址模式(保护模式)。Linux主要工作在保护模式下。 在保护模式下,64位x86体系架构的虚地址空间可达2^48Byte,即256TB,这可比只能到达区区4GB的32位x86体系大多了。逻辑地 址到线性地址的转换由x86分段机制管理。段寄存器CS、DS、ES、SS、FS或GS各标识一个段。这些段寄存器作为段选择器,用来选择该段的描述符。 Linux中关于段描述符的宏定义集中在文件/arch/x86/include/asm/Segment.h中,我们先贴出部分代码: 3
16、2位的: #define GDT_ENTRY_KERNEL_BASE 12 /* 0x0000000c c=>1100*/ #define GDT_ENTRY_KERNEL_CS (GDT_ENTRY_KERNEL_BASE + 0) /* 0x0000000c c=>1100*/ #define GDT_ENTRY_KERNEL_DS (GDT_ENTRY_KERNEL_BASE + 1) /* 0x0000000d c=>1101*/ 64位的: #define GDT_ENTRY_KERNEL32_CS
17、 1 /* 0x00000001 */ #define GDT_ENTRY_KERNEL_CS 2 /* 0x00000002 */ #define GDT_ENTRY_KERNEL_DS 3 /* 0x00000003 */ #define __KERNEL32_CS (GDT_ENTRY_KERNEL32_CS * 8) /* 0x00000100 */ #define GDT_ENTRY_DEFAULT_USER32_CS 4 /* 0x00000004 */ #define GDT_ENTRY_
18、DEFAULT_USER_DS 5 /* 0x00000005 */ #define GDT_ENTRY_DEFAULT_USER_CS 6 /* 0x00000006 */ #define __USER32_CS (GDT_ENTRY_DEFAULT_USER32_CS * 8 + 3) /* 0x00000403 */ #define __USER32_DS __USER_DS 不管32位还是64位的:(我们只关心64位) #define __KERNEL_CS (GDT_ENTRY_KERNEL_CS * 8) /* 0x000002
19、00 */ #define __KERNEL_DS (GDT_ENTRY_KERNEL_DS * 8) /* 0x00000300 */ #define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS* 8 + 3) /* 0x00000503 */ #define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS* 8 + 3) /* 0x00000603 */ 看见没有,我们熟悉的__USER_CS,__USER_DS,__KERNEL_CS,和__KERNEL_DS,就是传说中
20、的段选择子。 我们看到,内核代码段的描述子存放在以0x200为基地址的内存单元中,占8个字节。同样,内核数据段、用户代码段、用户数据段分别存放在 以0x300、0x500、0x600为基地址的内存单元中。我们注意到,__USER_DS和__USER_CS的最低三位为3,也就是011,这正说明 其CPL位为11,代表用户模式,TI为0,代表GDT。 对于x86_64来说,虚拟地址由16位选择子和64位偏移量组成,段寄存器仅仅存放选择子。CPU的分段单元(SU)执行以下操作: [1] 先检查选择子的TI字段,以决定描述子对应的描述子保存在哪一个描述符表中。TI字段指明描述子是在GDT中(
21、在这种情况下,分段单元从gdtr寄存器中 得到GDT的线性基地址)还是在激活的LDT中(在这种情况下,分段单元从ldtr寄存器中得到LDT的线性基地址)。 [2] 从选择子的13位index字段计算描述子的地址,index字段的值乘以8(一个描述子的大小,其实就是屏蔽掉末尾那三位指示特权级的CPL和指示TI的字段),这个结果与gdtr或ldtr寄存器中的内容相加。 [3] 将对应的段描述子从内存拷贝到CPU的影子Cache中,这样,只有在选择子改变的情况下才会修改影子Cache中的内容。 [4] 把虚拟地址的偏移量与隐Cache中描述子Base字段的值相加就得到了线性地址。 例如,为了
22、对内核代码段寻址,内核只需要把__KERNEL_CS宏产生的选择子的值装进cs段寄存器即可。注意,与段相关的线性地址还是从 0开始,达到264 -1的寻址限长。这就意味着在用户态或内核态下的所有进程任然使用相同的虚拟地址,这就是传说中的“基本平坦模式”。 按照这个模式,虚拟地址跟线性地址数字一样,唯一的不同就是CS和DS装的内容不同,可能是KERNEL级别的选择子,也可能是USER级别 的选择子。 5 4 x86_64分页管理 虽然逻辑地址扩展到了64位,但是,现有的设计并没有完全用到这64位的空间(2^64=16EB),因为使用到如此大的空间,势必造成很大的系统开销。AMD6
23、4在设计的时候就决定在x86_64的第一阶段,只用这64位中的低48位来做页式地址转换,高16位(48-64位)将填充第 47位相同的内容(这种方式类似于符号扩展)。如果逻辑地址不符合此规定,系统将产生异常。符合此规定的地址称为canonical form,地址的范围分为两段:0 到 00007FFF-FFFFFFFF,以及FFFF8 000-0000 0000到FFFFFFFF-FFFFFFFF,总共为256TB。这种虚拟地址的分层结构,也为操作系统的设计带来了一定便利:可以取地址的上半段保留 做为操作系统的逻辑地址空间,而低地址部分做为装载应用程序的空间,而canonical form不允
24、许的地址空间则做为操作系统的标志、以及特权级的标识等。当然,这样的设计在未来地址进一步扩展的时候将成为一个新的问题。 采用64位地址空间的x86-86被称为是运行在“长模式”(long mode)下,该模式可以看成是对PAE模式的一个扩充。长模式允许使用三个不同的物理页面大小:4KB、2MB和1GB。在使用64位中的48位用来存 放地址时,与PAE模式下的三级页面映射机制不同的是,长模式下线性地址到物理地址的映射需要经过四级地址映射。在这四级地址映射机制中,原来PAE模式 下仅拥有4个表项的页目录指针表被扩展到512个表项。同时,在最末一级加入一级新的页面映射结构,该结构被称为第四级页表(
25、Page-Map Level 4 Table,PML4),它跟PAE模式下的页目录及页表(在长模式中,成为了页目录)一样,拥有512个表项。如果地址进一步扩充,如把64位寻址全部 用上,该页表就能够扩充到33,554,432个表项,或者干脆再加一层地址映射(PML5),当然,按照目前只用了48位的情况下,用到512个表项的 PML4就已经够用了。 可以想象,用到48位的x86-64虚拟地址的分配机制为: - 0-11(12)位:页内偏移; - 12-20(9)位:由PML4来映射; - 21-29(9)位:高一级页目录来映射(如果PS=1,则该页表项指向一个2M
26、B的页); - 30-38(9)位:再高一级的页目录来映射(如果PS=2,则该页表项指向一个1GB的页); - 39-47(9)位:页目录指针表来映射。 x86-64的长模式下,对16位以及32位代码进行了兼容,即使CPU上跑的是64位的操作系统,历史遗留的16位以及32位代码将都能够在该操作系统上运行。由于x86-64兼容IA32的指令,所以,这些代码在这种情况下运行,基本上没有性能损耗。 在传统模式(Legacy mode)下,x86-64的CPU的工作模式跟传统的IA32没有什么两样。 6 堆的管理 收藏 一般人喜欢把堆和栈来做对比,网上资料也很
27、多,这里我只分享一下我本人的理解。堆这个东西跟栈没有直接的关联,它只给程序员提供一个手工分配和释放的内存空间,仅此而已。 对于每个Unix进程来说,都拥有一个特殊的线性区,这个线性区就是所谓的堆(heap),堆用于满足进程的动态内存请求。内存描述符的start_brk与brk字段分别限定了这个区的开始地址和结束地址。 进程可以使用下面的C语言API来请求和释放动态内存: malloc(size) 请求size个字节的动态内存。如果分配成功,就返回所分配内存单元第一个字节的线性地址。 calloc(n,size) 请求含有n个大小为size的元素的一个
28、数组。如果分配成功,就把数组元素初始化为0,并返回第一个元素的线性地址。 realloc(ptr,size) 改变由前面的malloc()或calloc()分配的内存区字段的大小。 free(addr) 释放由malloc()或calloc()分配的起始地址为addr的线性区。 brk(addr) 直接修改堆的大小。addr参数指定current->mm->brk的新值,返回值是线性区新的结束地址(进程必须检查这个地址和所请求的地址值addr是否一致)。 sbrk(incr) 类似于brk(),不过其中的incr参数指定是增加还
29、是减少以字节为单位的堆大小。 brk()函数和以上列出的函数有所不同,因为它是唯一以系统调用的方式实现的函数,而其他所有的函数都是使用brk()和mmap()系统调用实现的C语言库函数。 当用户态的进程调用brk()系统调用时,内核执行sys_brk(addr)函数。该函数首先验证addr参数是否位干进程代码所在的线性区。如 果 是,则立即返回,因为堆不能与进程代码所在的线性区重叠: mm = current->mm; down_write(&mm->mmap_sem); if (addr < mm->end_code) { out:
30、 up_write(&mm->mmap_sem); return mm->brk; } 由于brk()系统调用作用于某一个非代码的线性区,它分配和释放完整的页 。因此,该函数把addr的值调整为PAGE_SIZE的倍数,然后把调整的结果与内存描述符的brk字段的值进行比较: newbrk = (addr + 0xfff) & 0xfffff000; oldbrk = (mm->brk + 0xfff) & 0xfffff000; if (oldbrk == newbrk) { mm->brk = a
31、ddr; goto out; } 如果进程请求缩小堆,则sys_brk()调用do_munmap()函数完成这项任务,然后返回: if (addr <= mm->brk) { if (!do_munmap(mm, newbrk, oldbrk-newbrk)) mm->brk = addr; goto out; } 如果进程请求扩大堆,则sys_brk()首先检查是否允许进程这样做。如果进程企图分配在其跟制范围之外的内存,函数并不多分配内存,只简单地返回mm->brk的原有
32、值: rlim = current->signal->rlim[RLIMIT_DATA].rlim_cur; if (rlim < RLIM_INFINITY && addr - mm->start_data > rlim) goto out; 然后,函数检查扩大后的堆是否和进程的其他线性区相重叠,如果是,不做任何事情就返回: if (find_vma_intersection(mm, oldbrk, newbrk+PAGE_SIZE)) goto out; 如果一切都顺利,则调用do_brk()函数。如果它返回ol
33、dbrk,则分配成功且sys_brt()函数返回addr的值;否则,返回旧的mm->brk值: if (do_brk(oldbrk, newbrk-oldbrk) == oldbrk) mm->brk = addr; goto out; do_brk()函数实际上是仅处理匿名线性区的do_mmap()的简化版。可以认为它的调用等价于: do_mmap(NULL, oldbrk, newbrk-oldbrk, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_FIXED|MAP_PRIVATE,
34、 0) 当然,do_brk()比do_mmap()稍快,因为前者假定线性区不映射磁盘上的文件,从而避免了检查线性区对象的几个字段。 7 创建和删除进程的地址空间 收藏 本博,我们重点关注fork()系统调用为子进程创建一个完整的新地址空间。相反,当进程结束时,内核撤消它的地址空间。我们重点来讨论Linux如何执行这两种操作。 8 1 创建进程的地址空间 回忆一下“进程的创建 —— do_fork()函数详解 ”博文:当创建一个新的进程时内核调用copy_mm()函数。这个函数通过建立新进程的所有页表和内存描述符来创建进程一的地址空间: static int c
35、opy_mm(unsigned long clone_flags, struct task_struct * tsk) { struct mm_struct * mm, *oldmm; int retval; tsk->min_flt = tsk->maj_flt = 0; tsk->nvcsw = tsk->nivcsw = 0; tsk->mm = NULL; tsk->active_mm = NULL; /* * Are we cloning a kernel thread? *
36、 * We need to steal a active VM for that.. */ oldmm = current->mm; if (!oldmm) return 0; if (clone_flags & CLONE_VM) { atomic_inc(&oldmm->mm_users); mm = oldmm; goto good_mm; } retval = -ENOMEM; mm = dup_mm(tsk);
37、if (!mm) goto fail_nomem; good_mm: tsk->mm = mm; tsk->active_mm = mm; return 0; fail_nomem: return retval; } 通常,每个进程都有自己的地址空间,但是轻进程可以通过调用clone()函数(设置了CLONE_VM标志)来创建。这些轻量级进程共享同一地址空间,也就是说,允许它们对同一组页进行寻址。 按照前面讲述的“写时复制 ”方法,传统的进程继承父进程的地址空间,只要页是只读的,就依然共享它们。当其中的一个进程
38、试图对某个页进行写时,此时,这个页才被复制一份。一段时间之后,所创建的子进程通常会因为缺页异常而获得与父进程不一样的完全属于自己的地址空间。 另一方面,轻量级的进程使用父进程的地址空间。Linux实现轻量级进程很简单,即不复制父进程地址空间。创建轻量级的进程(clone)比创建普通进程相应要快得多,而且只要父进程和子进程谨慎地协调它们的访问,就可以认为页的共享是有益的。 如果通过clone()系统调用已经创建了新进程,并且flag参数的CLONE_VM标志被设置,则copy_mm()函数把父进程(current)地址空间给子进程(tsk): if (clone_flags
39、 & CLONE_VM) { atomic_inc(&oldmm->mm_users); mm = oldmm; goto good_mm; } good_mm: tsk->mm = mm; tsk->active_mm = mm; return 0; 如果没有设置CLONE_VM标志,copy_mm()函数就必须创建一个新的地址空间(在进程请求一个地址之前,即使在地址空间内没有分配内存): mm = dup_mm(tsk); dup_mm()函数分配一个新的内存描述符,把它的地址存放
40、在新进程描述符tsk的mm字段中,并把current->mm的内容复制到tsk->mm中。然后改变新进程描述符的一些字段: static struct mm_struct *dup_mm(struct task_struct *tsk) { struct mm_struct *mm, *oldmm = current->mm; int err; if (!oldmm) return NULL; mm = allocate_mm(); if (!mm) goto fail_nomem;
41、 memcpy(mm, oldmm, sizeof(*mm)); if (!mm_init(mm) ) goto fail_nomem; if (init_new_context(tsk, mm)) goto fail_nocontext; err = dup_mmap(mm, oldmm); if (err) goto free_pt; mm->hiwater_rss = get_mm_rss(mm); mm->hiwater_vm = mm->total_v
42、m; return mm; free_pt: mmput(mm); fail_nomem: return NULL; fail_nocontext: free_mm_flags(mm); mm_free_pgd(mm); free_mm(mm); return NULL; } #define allocate_mm() (kmem_cache_alloc(mm_cachep, SLAB_KERNEL)) 函数首先使用allocate_mm()函数调用kmem_cache_alloc(
43、mm_cachep, SLAB_KERNEL)从slab中分配一个mm_struct结构,然后调用mm_init对其进行初始化: static struct mm_struct * mm_init(struct mm_struct * mm) { unsigned long mm_flags; atomic_set(&mm->mm_users, 1); atomic_set(&mm->mm_count, 1); init_rwsem(&mm->mmap_sem); INIT_LIST_HEAD(&mm->mmlist); m
44、m->core_waiters = 0; mm->nr_ptes = 0; set_mm_counter(mm, file_rss, 0); set_mm_counter(mm, anon_rss, 0); spin_lock_init(&mm->page_table_lock); rwlock_init(&mm->ioctx_list_lock); mm->ioctx_list = NULL; mm->free_area_cache = TASK_UNMAPPED_BASE; mm->cached_hole_si
45、ze = ~0UL; mm_flags = get_mm_flags(current->mm); if (mm_flags != MMF_DUMP_FILTER_DEFAULT) { if (unlikely(set_mm_flags(mm, mm_flags, 0) < 0)) goto fail_nomem; } if (likely(!mm_alloc_pgd(mm) )) { mm->def_flags = 0; mmu_notifier_mm_init(mm
46、); return mm; } if (mm_flags != MMF_DUMP_FILTER_DEFAULT) free_mm_flags(mm); fail_nomem: free_mm(mm); return NULL; } 回想一下,mm_alloc_pgd()调用pgd_alloc()宏为新进程分配一个全新的页全局目录:(/arch/i386/mm/Pgtable.c) static inline int mm_alloc_pgd(struct mm_struct * mm) {
47、mm->pgd = pgd_alloc(mm); if (unlikely(!mm->pgd)) return -ENOMEM; return 0; } pgd_t *pgd_alloc(struct mm_struct *mm) { int i; pgd_t *pgd = kmem_cache_alloc(pgd_cache, GFP_KERNEL); if (PTRS_PER_PMD == 1 || !pgd) return pgd; for (i = 0; i < USER
48、PTRS_PER_PGD; ++i) { pmd_t *pmd = kmem_cache_alloc(pmd_cache, GFP_KERNEL); if (!pmd) goto out_oom; set_pgd(&pgd[i], __pgd(1 + __pa(pmd))); } return pgd; out_oom: for (i--; i >= 0; i--) kmem_cache_free(pmd_cache, (void *)__va(pgd_v
49、al(pgd[i])-1)); kmem_cache_free(pgd_cache, pgd); return NULL; } #define USER_PTRS_PER_PGD (TASK_SIZE/PGDIR_SIZE) #define PGDIR_SIZE (1UL << PGDIR_SHIFT) #define PGDIR_SHIFT 22 #define TASK_SIZE (PAGE_OFFSET) /* User space process size: 3GB (default). */ 注意,执行完mm_alloc
50、pgd()函数之后,子进程的pgd和pmd有了(32位i386体系结构),但是pte是没有的,后面的工作需要dup_mmap()函数来完成,马上会谈到。 接着来,随后调用依赖于体系结构的init_new_context()函数:对于80x86处理器,该函数检查当前进程是否拥有定制的局部描述符表,如果是,init_new_context()复制一份current的局部描述符表并把它插入tsk的地址空间: int init_new_context(struct task_struct *tsk, struct mm_struct *mm) { struct mm_struct
©2010-2025 宁波自信网络信息技术有限公司 版权所有
客服电话:4009-655-100 投诉/维权电话:18658249818