`
pleasetojava
  • 浏览: 732774 次
  • 性别: Icon_minigender_2
  • 来自: 上海
文章分类
社区版块
存档分类
最新评论

[十月往昔]——Linux内核中的内存管理浅谈

 
阅读更多

为什么要叫做“十月往昔”呢,是为了纪念我的原博客,http://www.casual0402.cn。

不知道为什么,突然想来一个新的开始——而那个博客存活至今刚好十个月,也有十个月里的文档。

十月往昔,总有一些觉得珍贵的,所以搬迁到这里来。

而这篇文章是在09.04.20-09.04.21里写的。

终归是一家之谈。

Jason Lee

————————————–cut-line

1 。基本框架 (此处主要谈页式内存管理)

4G 是一个比较敏感的字眼,早些日子,大多数机器(或者说操作系统)支持的内存上限都是这个数字。为什么呢?

之所以说是早些日子,因为现在 64 位的计算机已经很多了,而对于 32 位的计算机而言,页式管理是这么进行的,逻辑地址格式如下:

0 11 位:页内偏移 OFFSET

12 21 位:页面表偏移 PT

22 31 位:页面目录偏移 PGD

寻址过程如下:

1 )操作系统从寄存器 CR3 获得当前页面目录指针(基地址);

2 )基地址+页面目录偏移 -> 页面表指针(基地址);

3 )页面表指针+页面表偏移 -> 内存页基址;

4 )内存页基址+页内偏移 -> 具体物理内存单元。

显然, 12 位的页内偏移可以寻址 4K ,所以一张内存页为 4K ;而总共可寻内存为 4G 2^10 * 2^10 * 2^12 ;因此在 32 位机器上内存上限一般为 4G

而操作系统是需要支持不同的平台的,比如 32 位,比如 64 位等。所以, linux 统一使用页式三层映射: PGD PMD PT OFFSET

PAE 是地址扩充功能( Physical Address Extension )的缩写,如果将内存管理设置为 PAE 模式,这时候就需要三层映射了。

三层映射架构是如何实现双层映射的? linux 在暗地里“弄虚作假”了一番,有点类似领导让 linux 给三层映射一个重要位置,但是在 32 位计算机的地盘里就“阳奉阴违”了,只给三层映射一个有名无权的虚职。那么这个虚职是怎么实现的呢?

首先,开启了 PAE 模式的计算机是真切需要三层映射的,所以它不会给三层映射虚职,而是需要三层映射机制去做实事的;而 32 位计算机如果没有开启 PAE 模式,那么它是不需要三层映射的,双层映射是它更喜欢的。所以,首先是判断什么情况下给三层映射虚职——

从第一段的注释说明我们可以知道 Linux x86 的页式映射机制在编译时可以选择使用传统的双层映射和新的 PAE 模式下的三层映射。而从接下来的代码可以知道,如果对 CONFIG_X86_PAE 进行了预处理,即开启了 PAE 模式,那么就使用 pgtable-3level.h ,并且对 X86 PAE caches 进行初始化,而如果没有,则包含 pgtable-2level.h ,即使用双层映射。

pgtable-2level.h 实现的双层映射:

11 行到 14 行的注释我们可以知道这里并没有让 PMD 实际存在。 PGDIR_SHIFT PGD 的偏移量——这里的偏移量是指位于 32 位中的几位,显然是 22 位,即第 23 位。而

PTRS_PER_PGD pointers per PGD ,即每个 PGD 位段能表示的指针。这里是 1024 ,显然需要 10 位,那么 PGD 就是从位 22 到位 31 ,即第 23 位到第 32 位。

于是很显然我们可以了解到 PMD 在这里是虚设的,挂了个虚职。因为 PTRS_PER_PMD 1 ,那么占用的是 0 位,因为 2^0 = 1

到这里,我们知道什么人的地盘上给三层映射挂虚职,怎么设置这个虚职的。而三层映射如果真干起了实事,本质其实和双层映射差不多,只不过多了几个位而已。

————————————–cut-line

1. 数据结构和函数

众所周知, linux 下有许多与 ANSI C 不同的数据类型,比如 pid_t ;这些类型实际上是通过一层或者若干层的 typedef 定义而实现的,这样做的一个主要原因是为了可移植性的实现,而这样做的影响是看类型即可以很直观地知道用于何处,比如 pid_t 显然是一个进程 id 的类型;另外一个影响便是,编译内核需要使用相应的 gcc 编译器。

那么,在内存管理 (1) 中提到的 PGD PMD PT 等是什么呢?在 include/asm-i386/page.h 中有如下代码:

在开启了 PAE 模式的情况下, pgd_t pmd_t 都是长整形变量,而 pte_t 分为 pte_low pte_high 两个部分。 PTE 是指 page table entry ,即某个具体的页表项,指向一张具体的内存页。但是一个内存页并不需要 32 位全部使用,因为每张内存页大小都为 4KB ,所以从地址 0 开始,每间隔 4KB 为一张内存页。所以,内存页的首地址的低 12 位都为 0 ,我们只需要高 20 位来指向一个内存页基址,低 12 位用来设置页面状态和权限。另外,还有一个宏用来读取 pte_t 类型的成员。

而没有开启 PAE 模式的情况如下:

有了 PMD 等结构后就有地方存储地址信息了,那么如何获取这些信息呢?见如下几个宏:

54 行到 56 行是读取成员变量的宏,而 58 行到 61 行则是进行类型转换。这里出现了一个 pgprot ,展开为 page protection ,页面保护。 pgprot 对应着上文提到的页面状态和权限,从而实现页面的保护机制:


具体的 pgprot_t /include/asm-i386/pgtable.h 中定义:

显然, pgprot_t 的位设置都是在低 12 位,而 PTE 的指针部分是高 20 位,共同构成了 32 位。那么,二者是如何构成 32 位的页面表表项呢?我们自然而然想到了 20 位左移 12 位再与 pgprot_t 的低 12 位相或,在 pgtable.h 中是由宏 mk_pte 来完成的:


而我们自然又遇到了 __mk_pte 。那么 __mk_pte 是什么呢?在 /include/asm-i386/pgtable-2level.h 中它一个宏:


以上为 63 行单行。而在 /include/asm-i386/page.h 中对 PAGE_SHIFT 进行了宏定义:


所以实现的是将内存页面编号左移 12 位再与保护字段 pgprot 相或得到了 pte 页面表项。另外在上述中出现了 __pte() ,它的原型为: 58#define __pte(x) ((pte_t) { (x) } ) ,即进行类型转换。而 pgprot_val(pgprot) 的原型为: 56#define pgprot_val(x) ((x).pgprot) ,与 52typedef struct { unsigned long pgprot; } pgprot_t; 相对应则易知是获得某个 pgprot_t 类型变量的成员变量 pgprot

最后就剩下一个 mem_map 了。我们先来了解一下 /include/linux/mm.h 中的 page 结构。

首先,先看一段前置说明:

简略说下,就是 page 结构是与物理内存页相联系的,从而进行状态跟踪;其次,最经常访问的结构体内的成员字段应该保持在 16 位或者更大的单条缓冲线上——显然,这样有利于高速访问。接着来看 page 结构体的定义:

当我们看到最后一行( 182 行)的时候会有种恍然大悟的感觉—— mem_map_t 。于是我们就会联想 mem_map 是这么一个类型的变量。

实际上, mem_map 是一个全局变量(目前为止是),而且是一个指向 page 结构数组的指针;系统在初始化时根据物理内存的大小创建该数组。每一个数组元素都对应一张物理内存页。从软件方面来讲,页面表项的高 20 位是物理页面的编号,即 mem_map 数组的索引下标,通过该下标可以访问到与物理页面对应的 page 结构。而从硬件方面来讲,页面表项的高 20 位再与 12 0 结合则构成了 32 位,即每张物理页面的基址。

mem_map 映射着全部的物理内存页,而其本身则分为不同的区,比如 ZONE_DMA ZONE_NORMAL ZONE_HIGHMEM 等。其中 ZONE_DMA 是供 DMA 使用的; ZONE_HIGHMEM 是用于处理物理地址超过 1G 的存储空间。

事实上,三个管理区是这么分配的: 0 16MB 分配给 ZONE_DMA 16 896MB 分配给 ZONE_NORMAL ,最后, 896MB 以上的分配给 ZONE_HIGHMEM 。那么,为什么要这么分配呢?这是由于某些硬件只能特定地访问 0 16MB 来执行 DMA 模式;有些机器的配置使得物理内存页面无法总是保持被内核地址映射,这时需要使用 ZONE_HIGHMEM 进行动态映射;而其余的就是可以被正常映射的。

那么,为什么这里是 896MB 呢,而不是上文提的 1GB ?这是由于内核不仅为 highmem 预留了空间,也为 fixmap vmalloc 预留了虚存空间。

OK ,那内核中的虚拟地址是什么?虚拟地址其实就是逻辑地址——与物理地址相对应。

我们不妨来看看物理地址和内核中虚拟地址在内核空间的关系:

pa 表示 physical address ,即物理地址,而 va 表示虚拟地址 virtual address 。这里,我们不得不去看看 __PAGE_OFFSET

前置注释有一堆,而宏定义只有一行。在 32 位机器上,通过 linux 内核的页式映射可以实现 4GB 的逻辑地址(虚拟地址)。而在 4G 字节中, 0xC0000000 0xFFFFFFFF 的这 1G 最高的逻辑地址用于内核本身,称之为“内核空间”;而较低的 3G 字节空间为用户空间。注意,这里的是虚的、逻辑地址。

于是我们知道了 __PAGE_OFFSET 是用户空间和内核空间在虚地址上的分界。然而,物理地址始终是从 0×00000000 开始的;所以对于内核空间来说, pa va 就相差了一个 PAGE_OFFSET 。而同时, PAGE_OFFSET 也代表着用户空间的上限。

到这里,我们了解了内核空间只能“线性映射” 1GB“ 的物理地址,如果没有 ZONE_HIGHMEM 来管理高于 1GB 的物理地址,那么这些内存就会浪费掉了。于是系统初始化时预留了 128MB 的虚存来用于将来可能的映射。以上是对于 x86 体系结构而言,对于其它体系,物理内存可以全部被映射, ZONE_HIGHMEM 为空。

现在回到内存管理区。 /include/linux/mmzone.h 中有如下数据结构用于管理区:

(代码有点长,分段来看)

这里的前置注释说明了三个管理区的分布。

由注释我们可以知道这是用来控制 SMP 使用的,仅允许单 CPU 工作。

free_pages 表示着该区目前拥有的空闲页数。

由前置注释可知这是为了保留一些低端内存。我们在这里又遇到了一个新的数据类型:

这里 free_area[MAX_ORDER] 是一组队列,用于分配不连续的内存块。队列的实现是通过 free_area_t 类型中的成员 struct list_head free_list ,可参加 list.h

一些管理区信息如下:

112 表示的是该管理区所在的存储节点; 113 显然是一张内存映射表; 114 是该管理区的物理起始地址,而 115 表示的是在 mem_map 中的起始下标。显然这些都可以直接从变量名看出来。

120 表示的是管理区的名字, 121 表示的是管理区的大小, 122 表示的是管理区实用大小。

当多 CPU 引入之后, NUMA(Non-Uniform Memory Architecture) 结构体系出现了,即非匀质存储结构。于是,每个 CPU 都有自己的物理地址,并且有一个公共的物存模块。这样有时候会出现 CPU 请求的内存块无法在自己管辖的物理地址模块获得,也不能手伸太长去其它 CPU 管理的模块,那么就需要到公共模块请求。同时,新的物理页面管理机制也进行了修正。

NUMA 下,我们称 CPU 请求的一片连续物理内存页为 node (节点)。而且,此时的 mem_map 不再是全局变量,而是从属于具体节点;管理区也不再高高在上,也是被节点所拥有,每个存储节点至少有两个管理区。从而在 zone_struct 上便有了 pglist_data 数据结构,在 /include/linux/mmzone.h 定义:

首先看看 158 struct page *node_mem_map ,由于每个节点有一片的内存页,这里的 node_mem_map 便是用来映射表示它们的( page 结构数组);接着看首行, 155 zone_t node_zones[MAX_NR_ZONES] 是该节点所拥有的管理区,同时在 zone_struct 也有一行 struct pglist_data *zone_pgdat ,指向所属节点 pglist_data 数据结构。

————————————–cut-line – 以上数据结构用于物理内存页面管理 –2009-04-20

————————————–cut-line

(续)数据结构和函数

现在开始接触的是用于虚存管理的数据结构和函数。

通常,一个进程所需要使用的虚存空间是离散的各个区间,而区间的数据结构是 /include/linux/mm.h 中定义的:

45 行是定义了一个指向 mm_struct 结构体的指针,该结构体稍后了解。 vm_start vm_end 是这一段 vm_area 的开始和结束位置,然而 vm_end 是该 vm_area 之后的第一个地址,不属于本 vm_area

51 行定义了一个指向 vm_area_struct 结构体的指针 vm_next 。这是由于进程使用的区间是离散的,所以各个区间需要形成链表来保持联系,这里的 vm_next 便是指向下一片 vm_area 的;该链表是按地址排序的。

53 行的 pgprot_t vm_page_prot 显然是本 vm_area 的保护信息, pgprot_t 在之前有谈过。

54 行的 vm_flags 是本 vm_area 的标志,如下:

80 83 行分别表示页是否可以被读、写、执行和共享。

85 88 行表示可以对 80 83 行的标志进行设置。

95 行表示该页含可执行代码。

96 行表示该页被锁。

其它标志均有注释。

在这里一般会有个疑惑,一个 vm_area 可能包含很多个内存页,为什么只有一个 vm_page_prot vm_flags 呢?这是因为同一片 vm_area 的所有页面都必须保持相同的保护信息和状态标志。

现在回到 vm_area_struct

56 行是 rb_node_t vm_rb; rb_node_t 是红黑树 (red-black tree) 节点类型。红黑树的结构如下:

之所以使用红黑树是因为使用链表搜索的话每次都要从头开始,会影响效率。

63 64 行为共享内存中的前后区间:

显然可见 vm_ops 是一个指针,可以执行操作函数作用在该 vm_area 上。其中 open close 用于打开、关闭虚存空间。而当请求页面不在内存中调用 nopage

vm_area_struct 后面的成员都有注释。

————————————–cut-line

在了解 vm_area_struct 的开始,我们提到了 mm_struct

207 行的 mmap 指向虚存区间链表。

208 行是指向红黑树。

209 行的 mmap_cache 指向最后一次使用的虚存区间,因为虚存区间有若干个内存页,下一次请求的内存页很可能还在该区间。

210 行的 pgd 显然是进程的页面目录,当内核调度一个进程运行时,将该指针转换为物理地址并写入控制寄存器 CR3

211 行的 mm_users 表示用户空间中有多少用户。而 212 行的 mm_count 表示该 mm_count 结构的被引用数。

213 map_count 表示 vm_area 的个数。

214 215 是一些状态控制,进行诸如锁定等状态控制。

217 行是 mm_struct 链表。

余下部分用途较显然。

mm_users mm_count 我们可以知道一个 mm_struct 允许被多个进程引用,但是一个进程只能使用一个 mm_struct 结构。

至此,我们了解到以下几点。

1 。虚存方面是由 vm_area_struct mm_struct 进行处理的。 32 位的计算机可以形成 4G 的虚存空间,其中 3 4G 的虚存空间用作内核空间,其余用作用户空间。 mm_struct 是用户空间抽象,位于虚存管理的高层。而 vm_area_struct 则是从属于 mm_struct 。一个进程允许有多个 vma ,这些虚存区间构成链表以及红黑树,在 vma 个数较少的时候使用链表操作,个数多的时候使用红黑树操作。 mm_struct 中的 mmap 指向 vma 链表,而 map_count 则指示有多少个 vma 。当一个进程进入运行时,进程所对应的 mm_struct 中的 pgd (页面目录)被写入控制寄存器 CR3 ,于是页式映射机制的源头 CR3 就有内容了。

2 。在 CR3 被设置以后,便可以进行页式映射了。负责将虚拟地址映射为物理地址的内存管理单元从 CR3 读出数据,然后结合 pgd 等内容完成映射。

此外,如果要通过进程的虚拟地址找到所属区间以及相应的 vma 结构可以使用 find_vma

首先通过查找 mmap_cache ,如果不是,则在链表中或者红黑树中搜索。如果返回 0 ,表示还没有创建 vma ,这时候就需要创建一个新的虚存区间结构。

————————————–cut-line

1 。越界访问

页式映射将虚拟地址转换成物理地址,并不是每次映射都是成功的,以下是几种失败的情况:

1 )映射过程中遇到 pgd 或者 pte 等项为空,映射没有建立

2 )物理页面不在内存中

3 )权限不符

于是就有相应的错误处理程序 /arch/i386/mm/fault.c 中的 do_page_fault()

由前置注释可知,错误码第 0 位为 0 表示页面不存在, 1 表示权限不符;第 1 位为 0 表示为读访问引起的错误, 1 表示写访问引起错误;第 2 位为 0 表示错误发生在内核态, 1 表示在用户态。

该页面错误处理机制需要两个参数,一个是 regs 指向错误前现场, error_code 如上。

这两行是获得导致映射失败的线性地址,它存储在 CR2 中,由汇编语言实现。

接着首先是处理在内核空间发生的非权限不符错误:

由前置注释可知 if 条件的判断保证了错误发生在内核空间,而且不是权限不符错误。这种错误转向 vmalloc_fault 处理,该处理机制也在内部定义。

接着处理的是中断或者进程映射未建立的情况:

在这段代码之下是一段有关于堆栈越界的处理。当用尽了本进程的堆栈空间后,如果再执行进栈操作,由于堆栈是从上往下延伸的,所以一般情况下会把数据写到 (%esp-4) 位置,如果是 32 字节操作则是 (%esp-32) 了。


查找虚存区间。

如果没有找到:

转向 bad_area 处理。

如果找到,且地址大于 vma 起始地址(非堆栈)则转向:

而如果是堆栈,那么 VM_GROWSDOWN 标记为 1 ,当向下越界时,如果超过 %esp-32 那么就转向 bad_area 否则扩充堆栈,调用 expand_stack()

但是并不是无限制地扩充堆栈的,每个进程都有限制,如果超过就跳转到 bad_area 。如果允许扩充,转向 good_area 继续完成新增页面对物理内存的映射。

具体的处理机制见 /arch/i386/mm/fault.c

分享到:
评论
1 楼 anttu 2012-03-24  
打开i此页面 耗我1G多内存,尼玛你是不是置病毒了?

相关推荐

    运用科技手段监控 落实安全措施到位——谈太仓裕沁庭项目施工电梯司机指纹人脸识别控制系统的应用.pdf

    回顾往昔,2012年武汉“9·13”施工电梯事故的惨痛教训至今让人记忆犹新,该事件中操作不当导致的人员伤亡给整个建筑行业敲响了警钟。事故的背后暴露出一个共同的问题:非专属司机的操作隐患。在正常施工过程中,...

    教师资格考试、教师招聘考试资料——细数往昔之教育法律法规时间考点.pdf

    教师资格考试、教师招聘考试资料——细数往昔之教育法律法规时间考点.pdf

    《大堰河——我的保姆》教案.docx

    本文将根据《大堰河——我的保姆》教案的相关内容,深入分析这首诗的创作背景、人物关系、情感内核以及教学方法。 首先,《大堰河——我的保姆》是艾青以自己亲身经历为蓝本创作的一首诗。在这首诗中,艾青与...

    专题资料(2021-2022年)《忆往昔,展未来,共发展》——十七大演讲稿443.doc

    教育资料

    辛弃疾——《夜行黄沙道中,》精选.doc

    而当词人行至“旧时茅店社林边,路转溪头忽见”时,一个熟悉的场景猝然出现在眼前,这种突如其来的喜悦和惊喜,不仅为词作增添了生机与活力,也表达了词人在战乱岁月中对往昔宁静生活的怀念。 辛弃疾的词,往往富有...

    浅谈古典诗词中的虚和实.docx

    李煜的《虞美人》中,“春花秋月何时了,往事知多少”便是将四季更替与人生过往相联系,实的景物唤起虚的记忆,表达出诗人对往昔岁月的无限留恋和感慨。 虚实结合的艺术表现手法不仅体现了古典诗词的艺术特色,也...

    jdk-8u181-linux-and-linux.zip

    首先,我们关注的是标题中的"jdk-8u181-linux-and-linux.zip",这表明这个压缩包包含了适用于两种操作系统的JDK版本——Windows和Linux。其中,"u181"是JDK 1.8的一个更新版本号,意味着它包含了自1.8发布以来的若干...

    纳兰性德词赏析——最美诗词.doc

    词中通过残雪、冷月等意象,营造出一种寂寞孤独的氛围,进而引出“知君何事泪纵横”,表达出词人在无尽的惆怅中回忆往昔,感叹人生的无奈。整首词情感深沉,语言优美,展现了纳兰性德词作的特色——情真意切,意蕴...

    浅谈元散曲之蛤蜊味.docx

    在这个时期,很多知识分子失去了往昔的政治地位和社会影响力,转而在文学创作中寻求心灵的慰藉和自我价值的实现。元散曲的兴起和发展正是这一背景下文化表达方式转变的具体体现。 #### “蛤蜊味”的形成原因 元...

    七月你好——小清新文艺风工作总结报告ppt模板.zip

    随着七月的到来,我们站在半年的分界点,回顾往昔,展望未来。对于那些需要向团队、领导甚至客户展现自己或团队上半年工作成果的人来说,一份精美的工作总结报告至关重要。它不仅是对成果的展示,更是对个人或团队...

    诵读欣赏——词二首相见欢浣溪沙学习教案.pptx

    在中国古典文学的宝库中,诗词一直占据了极为重要的位置。它们以精炼的语言和深邃的情感,折射出作者的思想感情以及对自然和人生的独特体验。本篇学习教案主要围绕两首相见欢浣溪沙——李煜的《相见欢》和晏殊的...

    晏几道——《临江仙》.doc

    在中国古典文学的宝库中,宋代词作以其独特的韵味和深邃的内涵占有重要地位,其中晏几道的《临江仙》更是婉约词派的典范之作。通过梦后酒醒的场景,晏几道将个人情感与自然景物巧妙融合,以独特的艺术手法,展现了深...

    linux下读写日志文件

    从执行体程序库中的CLLogger类可知,通过缓存要写入文件中的数据,能够提高读写磁盘的性能。编写一个文件操作的封装类,调用该类的写操作接口时,数据要首先写到缓存,然后再根据策略写到文件中。调用该类的读操作...

    晏几道——《鹧鸪天》.doc

    《鹧鸪天》中所展现出的情感变化,反映了晏几道对美好往昔的怀旧和对现实命运的无奈。他性格中的独立和不羁,以及对世态炎凉的超然态度,都深深影响了他的文学创作。 在艺术风格上,晏几道的词作受到五代词人,尤其...

    陈与义——《临江仙》精选.doc

    他在“二十余年如一梦,此身虽在堪惊”中表达了对时代巨变的感慨,以及对于个人命运的深切思考。此时的陈与义,不再是那个满怀壮志的青年,而是一个经历了战乱,目睹了国家兴衰的诗人。他在“闲登小阁看新晴”中表现...

    李白——《梁园吟》精选.doc

    在游历大梁(今河南开封)和宋州(今河南商丘)的过程中,李白目睹了曾经辉煌一时的梁园(梁苑)与宋平公的平台,这些历史遗迹与它们往昔的繁荣形成鲜明对比,触发了李白的灵感与情感。 黄河,作为北方的巨川,见证...

    晏几道——《鹧鸪天》精选.doc

    《鹧鸪天》的第二首,晏几道则是在回忆和怀念中展现出了对往昔欢愉时光的无限眷恋。在这首词中,宴会上的热闹场景被生动地刻画出来:彩袖轻舞、杨柳月下,还有那悠扬的歌声,这一切美好的画面都勾起了词人对过去的...

    李清照——《菩萨蛮》精选.doc

    李清照,中国宋代杰出的女词人,以她的《如梦令》、《声声慢》等作品留名青史,而《苏幕遮》和《菩萨蛮》则是其词作中具有代表性的作品,展现了其深邃的艺术风格和复杂的情感世界。这两首词,一首写秋意愁思,一首写...

    大学音乐——贝多芬《月光曲》音乐赏析.docx

    在这样的节奏下,贝多芬细腻地刻画出了他内心的涟漪,既有对往昔美好时光的眷恋,也有对现实境遇的忧郁。这种深沉而哲思的音乐语言,让人仿佛听到了贝多芬心灵的呼唤,感受到他与贵族女孩之间的爱情故事,那种甜蜜与...

    填词指要[上卷]-非凡课件——语文PPT课件平台!.doc

    在浩瀚的中国古典诗词海洋中,小令是其中一颗璀璨的明珠,它以简短的篇幅和深邃的意境,传达出诗人的情感和对生活的观照。《填词指要[上卷]》作为一份专业指导文献,为我们揭开了小令这一独特诗歌形式的神秘面纱,让...

Global site tag (gtag.js) - Google Analytics