《Linux内核深度解析》-读书笔记

《Linux内核深度解析》

作者 余华兵

image

1.1 到哪里读取引导程序

处理器上电时固定读取某个虚拟地址(如ARM64处理器,这个固定值为0),该物理地址一般存放了引导程序;综上所述,ARM64处理器到虚拟地址0取指令,就是到物理地址0取指令,也就是到NOR闪存的起始位置取指令。

1.2.2 标号reset

U-Boot分为SPL和正常的U-Boot程序两个部分;

  1. SPL:Secondary Program Loader——第二阶段程序加载器;
  2. SPL负责初始化内存和存储设备驱动,然后把正常的U-Boot镜像从存储设备读到内存中执行;U-Boot分为SPL和正常的U-Boot程序两个部分,如果想要编译为SPL,需要开启配置宏CONFIG_SPL_BUILD。SPL是“Secondary Program Loader”的简称,即第二阶段程序加载器,第二阶段是相对于处理器里面的只读存储器中的固化程序来说的,处理器启动时最先执行的是只读存储器中的固化程序。固化程序通过检测启动方式来加载第二阶段程序加载器。为什么需要第二阶段程序加载器?原因是:一些处理器内部集成的静态随机访问存储器比较小,无法装载一个完整的U-Boot镜像,此时需要第二阶段程序加载器,它主要负责初始化内存和

1.3.1 汇编语言部分

KVM(Kernel-based Virtual Machine)——基于内核的虚拟机;

  1. KVM直接在处理器上执行客户操作系统;
  2. KVM是内核的一个模块,把内核变成虚拟机监控程序;现在常用的虚拟机是基于内核的虚拟机(Kernel-based Virtual Machine, KVM),KVM的主要特点是直接在处理器上执行客户操作系统,因此虚拟机的执行速度很快。KVM是内核的一个模块,把内核变成虚拟机监控程序。如图1.5所示,宿主操作系统中的进程在异常级别0运行,内核在异常级别1运行,KVM模块可以穿越异常级别1和2;客户操作系统中的进程在异常级别0运行,内核在异常级别1运行。

*Arm内核入口_head最后执行了:
primary_switched——主要初始化了0号线程的栈顶地址和thread_info内容;

函数__primary_switched的执行流程如下。(1)把当前异常级别的栈指针寄存器设置为0号线程内核栈的顶部(init_thread_union +THREAD_SIZE)。

1.3.2 C语言部分

0号线程调用了内核初始化C语言部分入口:start_kernel

  1. 然后创建了1号线程,即init线程;
  2. 继续创建了2号线程,即ktheadd线程;内核初始化的C语言部分入口是函数start_kernel,函数start_kernel首先初始化基础设施,即初始化内核的各个子系统,然后调用函数rest_init。函数rest_init的执行流程如下。(1)创建1号线程,即init线程,线程函数是kernel_init。(2)创建2号线程,即kthreadd线程,负责创建内核线程。(3)0号线程最终变成空闲线程。

1.3.3 SMP系统的引导

SMP(Symmetric Multi-Processor, SMP):对称多处理器——即多个处理器地位平等;
但是在启动过程中:

  1. 0号处理器称为引导处理器,负责执行引导程序和初始化内核;
  2. 0号处理器初始化内核后,启动从处理器;
    2.1 引导处理器一般通过自旋表(spin-table)启动从处理器;
    2.2 方法:引导处理器从扁平设备树二进制文件中“cpu”节点的属性“enable-method”读取从处理器的启动方法;对称多处理器(Symmetric Multi-Processor, SMP)系统包含多个处理器,并且每个处理器的地位平等。在启动过程中,处理器的地位不是平等的,0号处理器称为引导处理器,负责执行引导程序和初始化内核;其他处理器称为从处理器,等待引导处理器完成初始化。引导处理器初始化内核以后,启动从处理器。

1.4 init进程

init进程是用户空间的第一个进程,负责启动用户程序;
以Linux系统常用的init程序sysvinit为例,它的主要工作如下:

  1. 启动配置文件"/etc/inittab"——用来指定要执行的程序以及在哪些运行级别执行这些程序;
  2. 这样可以将程序放到目录"/etc/rc.d/init.d"下,已达到设备启动自动运行程序脚本;怎么让一个程序在设备启动的时候自动启动?写一个启动脚本,放在目录“/etc/rc.d/init.d”下,然后在目录“/etc/rc.d/rc3.d”下创建一个软链接,指向这个启动脚本。假设程序的名称是“hello.elf”,启动脚本如下:

第2章 进程管理

Linux下,进程称为任务(task);
没有用户虚拟地址空间的进程称为内核线程;
共享同一个用户虚拟地址空间的所有用户线程组成一个线程组;没有用户虚拟地址空间的进程称为内核线程,共享用户虚拟地址空间的进程称为用户线程,通常在不会引起混淆的情况下把用户线程简称为线程。共享同一个用户虚拟地址空间的所有用户线程组成一个线程组。

*C标准库的进程术语 vs Linux内核的进程术语:

  1. 包含多个线程的进程(C库) vs 线程组(Linux,下同);
  2. 只有一个线程的进程 vs 进程或任务;
  3. 线程 vs 共享用户虚拟地址空间的进程;*

C标准库的进程术语和Linux内核的进程术语的对应关系如表2.1所示。

2.3 进程标识符

线程组:多个共享用户虚拟地址空间的进程组成一个线程组;
其中线程组中的主进程称为组长,线程组标识符就是组长的进程标识符;多个共享用户虚拟地址空间的进程组成一个线程组,线程组中的主进程称为组长,线程组标识符就是组长的进程标识符

Linux-多用户操作系统;
用户登录——创建一个会话;
用户退出——所有属于这个会话的进程都将终止;

当用户退出登录时,所有属于这个会话的进程都将被终止。

2.5.1 创建新进程

Linux内核中,

  1. 使用静态数据构造出0号内核线程,0号内核线程分叉生成1号内核线程和2号内核线程(kthreadd线程);
  2. 1号内核线程完成初始化后装载用户程序,变成1号进程;(init进程?)
  3. 其他内核线程是kthreadd线程分叉生成的;在Linux内核中,新进程是从一个已经存在的进程复制出来的。内核使用静态数据构造出0号内核线程,0号内核线程分叉生成1号内核线程和2号内核线程(kthreadd线程)。1号内核线程完成初始化以后装载用户程序,变成1号进程,其他进程都是1号进程或者它的子孙进程分叉生成的;其他内核线程是kthreadd线程分叉生成的。

创建新进程的主要工作由copy_process()完成,其中:
第2步,dup_task_struct()为新进程的进程描述符分配内存,把当前进程的进程描述符复制一份,为新进程分配内核栈;
ARM64架构下,进程描述符task_struct的成员stack指向内核栈,即:
task_struct的第一个成员 == thread_info;
task_struct.stack指向内核栈的首地址;
thread_info用来存放汇编代码需要访问的底层数据:
struct thrad_info {
unsigned long flags;
mm_segment_t addr_limit; //进程可以访问的地址空间的上限;
u64 ttbr0;
int preempt_count;
};

函数dup_task_struct为新进程的进程描述符分配内存,把当前进程的进程描述符复制一份,为新进程分配内核栈。

只有属于同一个线程组的线程之间才会共享文件系统信息;

只有属于同一个线程组的线程之间才会共享文件系统信息。

init_struct_pid是什么?
在内核初始化时,引导处理器为每个从处理器分叉生成一个空闲线程,使用进程号0,而全局变量init_struct_pid用来存放空闲线程的进程号;

pid等于init_struct_pid的地址,这是什么意思呢?在内核初始化时,引导处理器为每个从处理器分叉生成一个空闲线程(参考函数idle_threads_init),所有处理器的空闲线程使用进程号0,全局变量init_struct_pid存放空闲线程的进程号。

*通过clone(fork也是clone的简化版)创建进程有如下几种结果:

  1. 传入的clone_flags包含CLONE_THREAD,那么创建的是线程;
  2. 传入的clone_flags包含CLONE_PARENT,那么新创建的进程和当前进程时兄弟关系;
  3. 否则,新进程和当前进程时父子关系;*

第12~15行代码,如果是创建线程,那

控制组(cgroup)的进程数(PIDs)控制器:
如果进程p所属的控制组到控制组层级的根,其中有一个控制组的进程数量 >= 限制,那么不允许进程p使用fork和clone创建新进程;

控制组(cgroup)的进程数(PIDs)控制器:用来限制控制组及其子控制组中的进程使用fork和clone创建的新进程的数量,如果进程p所属的控制组到控制组层级的根,其中有一个控制组的进程数量大于或等于限制,那么不允许进程p使用fork和clone创建新进程。

进程号命名空间中的1号进程-init进程,忽略致命信号,不能被杀死;
用来领养孤儿进程;

如果新进程是1号进程,那么新进程是进程号命名空间中的孤儿进程领养者,忽略致命的信号,因为1号进程是不能杀死的。如果把1号进程杀死了,谁来领养孤儿进程呢?

2.5.2 装载程序

当调度器调度新进程时:

  1. 新进程从函数ret_from_fork开始执行,然后从系统调用fork返回用户空间,返回0;
  2. 接着新进程使用execve装载程序;当调度器调度新进程时,新进程从函数ret_from_fork开始执行,然后从系统调用fork返回用户空间,返回值是0。接着新进程使用系统调用execve装载程序。

*execve()装载程序:

  1. 最终调用do_execveat_common() ——> sched_exec();
  2. sched_exec()装载程序试一次很好的实现处理器负载均衡的机会:
    2.1 内核选择负载最轻的处理器,然后唤醒当前处理器上的迁移进程;
    2.2 当前进程睡眠等待迁移线程把自己迁移到目标处理器上;*

装载程序是一次很好的实现处理器负载均衡的机会,因为此时进程在内存和缓存中的数据是最少的。选择负载最轻的处理器,然后唤醒当前处理器上的迁移线程,当前进程睡眠等待迁移线程把自己迁移到目标处理器。

*Linux内核中,每种二进制格式都表示为下面数据结构的一个实例:
struct linux_binfmt;
每种二进制格式必须提供下面3各函数:

  1. load_binary——用来加载普通程序;
  2. load_shlib——用来加载共享库;
  3. core_dump——用来在进程异常退出时生成核心转储文件。*

每种二进制格式必须提供下面3个函数。

*装载elf文件时,如何获得程序的入口?

  1. 如果程序有解释器段,那么解释器程序的入口就是程序的入口;
  2. 否则就是目标程序自身的入口;*

得到程序的入口。如果程序有解释器段,那么把解释器程序中的所有可加载段映射到进程的用户虚拟地址空间,程序入口是解释器程序的入口,否则就是目标程序自身的入口。

当进程从用户模式切换到内核模式时,内核如何保存用户态的各种寄存器?
——内核会把用户模式的各种寄存器保存在内核栈底部的结构体pt_regs中,如
pt_regs->pc, pt_regs->sp;

调用函数start_thread设置结构体pt_regs中的程序计数器和栈指针寄存器。当进程从用户模式切换到内核模式时,内核把用户模式的各种寄存器保存在内核栈底部的结构体pt_regs中。

2.6 进程退出

进程默认关注子进程退出事件:
1。 如果父进程关注子进程退出事件,那么进程退出时释放各种资源,只留下一个空的进程描述符,变成僵尸进程;

  1. 发送信号SIGCHID通知父进程,父进程在查询进程终止的原因后回收子进程的进程描述符;进程默认关注子进程退出事件,如果不想关注,可以使用系统调用sigaction针对信号SIGCHLD设置标志SA_NOCLDWAIT(CLD是child的缩写),以指示子进程退出时不要变成僵尸进程,或者设置忽略信号SIGCHLD。

*父进程退出时需要给子进程寻找一个“领养者”顺序?

  1. 如果进程属于一个线程组,且该线程组内还有其他线程,那么任意选择一个线程;
  2. 选择最亲近的祖先进程;进程可以使用系统调用prctl(PR_SET_CHILD_SUBREAPER)把自己设置为“替补领养者”;
  3. 选择进程所属的进程号命名空间中的1号进程;*

父进程退出时需要给子进程寻找一个“领养者”,按照下面的顺序选择领养“孤儿”的进程

2.8 进程调度

Linux内核支持的调度策略:

  1. 限期进程——限期调度策略;
  2. 实时进程——先进先出调度和轮流调度;
  3. 普通进程——标准轮流分时和空闲调度;
    inux内核支持的调度策略如下。(1)限期进程使用限期调度策略(SCHED_DEADLINE)。(2)实时进程支持两种调度策略:先进先出调度(SCHED_FIFO)和轮流调度(SCHED_RR)。(3)普通进程支持两种调度策略:标准轮流分时(SCHED_NORMAL)和空闲(SCHED_IDLE)。以前普通进程还有一种调度策略,称为批量调度策略(SCHED_BATCH), Linux内核引入完全公平调度算法以后,批量调度策略被废弃了,等同于标准轮流分时策略。

轮流调度——进程有时间片,进程用完时间片后加入优先级对应运行队列的尾部,把处理器让给同优先级的其他实时进程;
标准轮流分时策略——使用完全公平调度算法,公平分配处理器时间;

轮流调度有时间片,进程用完时间片以后加入优先级对应运行队列的尾部,把处理器让给优先级相同的其他实时进程。标准轮流分时策略使用完全公平调度算法,把处理器时间公平地分配给每个进程。

2.8.2 进程优先级

普通进程的静态优先级时100 ~ 139,可以通过nice值修改(-20 ~ 19)普通进程的静态优先级是100~139,优先级数值越小,表示优先级越高,可通过修改nice值(即相对优先级,取值范围是−20~19)改变普通进程的优先级,优先级等于120加上nice值。

2.8.3 调度类

为了方便添加新的调度策略,Linux内核抽象了一个调度类sched_class;
为了方便添加新的调度策略,Linux内核抽象了一个调度类sched_class,

停机调度类——优先级最高的调度类;
停机进程——优先级最高的进程,可抢占所有其他进程,不可被抢占;
每个处理器上的迁移进程属于停机调度类,用来把进程从当前处理器迁移到其他处理器;
迁移进程-对外伪装成实时优先级为99的先进先出实时进程;(没有时间片,如果进程不主动退让处理器,那么它将一直霸占处理器)

停机调度类是优先级最高的调度类,停机进程(stop-task)是优先级最高的进程,可以抢占所有其他进程,其他进程不可以抢占停机进程。停机(stop是指stopmachine)的意思是使处理器停下来,做更紧急的事情。目前只有迁移线程属于停机调度类,每个处理器有一个迁移线程(名称是migration/<cpu_id>),用来把进程从当前处理器迁移到其他处理器,迁移线程对外伪装成实时优先级是99的先进先出实时进程。

完全公平调度算法:使用红黑树把进程按虚拟运行时间从小到大排序,每次调度时选择虚拟运行时间最小的进程;
虚拟运行时间(普通进程) = 实际运行时间 * nice0对应的权重 / 进程的权重;
进程的静态优先级越大,权重越大,相同实际运行时间下,虚拟运行时间越短;在红黑树中被调度器选中的概率越大;

完全公平调度算法使用红黑树把进程按虚拟运行时间从小到大排序,每次调度时选择虚拟运行时间最小的进程。

调度器选中进程后分配的时间片是多少?
——进程的时间片 = 调度周期 * (进程的权重 / 运行队列中所有进程的权重总和);
调度周期:在某个时间长度可以保证运行队列中的每个进程至少运行一次,这个时间长度称为调度周期;
如果运行队列中进程数 > 8,那么调度周期 = 调度最小粒度 * 进程数;
调度最小粒度:为了防止进程切换太频繁,进程被调度后应该至少运行一小段时间;默认0.75ms;

进程的时间片的计算公式如下:进程的时间片=(调度周期×进程的权重 / 运行队列中所有进程的权重总和)

空闲进程——每一个处理器上都有一个空闲线程,即0号线程;
空闲调度类:优先级最低,没有其他进程调度,才会调度空闲进程;

每个处理器上有一个空闲线程,即0号线程。空闲调度类的优先级最低,仅当没有其他进程可以调度的时候,才会调度空闲线程。

2.8.6 调度进程

调度进程的核心函数:static void __sched notrace __schedule(bool preempt);

  1. preempt ==true代表抢占调度,强制剥夺当前进程对处理器的使用权;
  2. preempt ==false表示主动调度,当前进程主动让出处理器‘
  3. __schedule的主要处理过程如下:
    3.1 调用pick_next_task以选择下一个进程;
    3.2 调用context_switch以切换进程;主动调度进程的函数是schedule(),它把主要工作委托给函数__schedule()。函数__schedule的主要处理过程如下。(1)调用pick_next_task以选择下一个进程。(2)调用context_switch以切换进程。

*函数pick_next_task按优先级依次调用停机、限期、实时、公平和空闲调度类的pick_next_task方法来选择下一个进程:

  1. 停机调度类用于选择下一个进程的函数:pick_next_task_stop;
  2. 限期调度类用于选择下一个进程的函数:pick_next_task_dl;
  3. 实时调度类:pick_next_task_rt,从根任务组在当前处理器上的实时运行队列开始,选择优先级最高的调度实体(进程?返回 : 递归在任务组中遍历优先级最高的进程);
  4. 公平调度类:pick_next_task_fair,从根任务组在当前处理器上的公平运行队列开始,选择虚拟运行时间最小的调度实体-即红黑树最左边的调度实体;
    ——(找到虚拟运行时间最短的进程? 返回 : 递归在任务组中遍历运行时间最短的进程)
    空闲调度类:pick_next_task_idle;*

函数pick_next_task针对公平调度类做了优化:如果当前进程属于空闲调度类或公平调度类,并且所有可运行的进程属于公平调度类,那么直接调用公平调度类的pick_next_task方法来选择下一个进程。如果公平调度类没有选中下一个进程,那么从空闲调度类选择下一个进程。一般情况是:从优先级最高的调度类开始,调用调度类的pick_next_task方法来选择下一个进程,如果选中了下一个进程,就调度这个进程,否则继续从优先级更低的调度类选择下一个进程。现在支持5种调度类,优先级从高到低依次是停机、限期、实时、公平和空闲。

*cpu_switch_to(x0-存放上一个进程描述符的地址, x1-下一个进程的描述符地址)切换通用寄存器的过程:(从进程prev切换到next)

  1. 进程prev把通用寄存器的值(X19~X28, fp, sp,lr)保存在进程描述符的成员thread.cpu_context中:stp x19, x20, [x8], #16
  2. 进程next从进程描述符成员thread.cpu_context恢复通用寄存器的值;
  3. 使用用户栈指针寄存器SP_EL0存放进程next的进程描述符成员thread_info的地址;*

图2.29描述了函数cpu_switch_to切换通用寄存器的过程,从进程prev切换到进程next。进程prev把通用寄存器的值保存在进程描述符的成员thread.cpu_context中,然后进程next从进程描述符的成员thread.cpu_context恢复通用寄存器的值,使用用户栈指针寄存器SP_EL0存放进程next的进程描述符的成员thread_info的地址。

2.8.7 调度时机

调度进程的时机有哪些?

  1. 进程在内核模式下主动调用schedule()函数;(等待信号量等资源时)
  2. 周期性地调度,抢占当前进程;(时钟中断-重新调度标志)
  3. 唤醒进程的时候,被唤醒的进程可能抢占当前进程;(被唤醒的进程调度策略-SCHED_NORMAL)
  4. 创建新进程的时候(fork/clone/vfork/kernel_thread),新进程可能抢占当前进程;调度进程的时机如下。

*内核中,3中主动调度进程的时机:

  1. 直接调用schedule()函数来调度进程;
  2. 调用有条件重调度函数cond_resched();(非抢占式内核中)
  3. 进程需要等待某个资源,如互斥锁或信号量,先把进程状态设置为睡眠状态,然后调用schedule();*

进程在用户模式下运行的时候,无法直接调用schedule()函数,只能通过系统调用进入内核模式,如果系统调用需要等待某个资源,例如互斥锁或信号量,就会把进程的状态设置为睡眠状态,然后调用schedule()函数来调度进程。进程也可以通过系统调用sched_yield()让出处理器,这种情况下进程不会睡眠。在内核中,有以下3种主动调度方式。

*对于不主动让出处理器的“流氓”进程,内核只能依靠周期性的时钟中断夺回处理器的控制权;

  1. 时钟中断程序周期调度scheduler_tick(),检查当前进程的执行时间有没有超过限额?
    1.1 如果超过限额,该函数就会给当前进程的thread_info结构体成员flags设置需要重新调度的标志位(_TIF_NEED_RESCHED);
    1.2 当时钟中断处理程序返回时,会检查这个标志位,如果设置了,调用schedule()函数调度进程;*

*对公平调度类(普通类),何时被唤醒的进程会抢占当前进程的处理器?

  1. 当前进程的调度策略时SCHED_IDLE,而被唤醒进程的调度策略为SCHED_NORMAL或者SCHED_BATCH,给当前进程设置需要重新调度的标志;
  2. 如果被唤醒的进程调度策略不是SCHED_NORMAL,那么不允许抢占当前进程;
  3. 若当前进程的虚拟运行时间 - 被唤醒进程的虚拟运行时间 > 虚拟唤醒粒度,那么允许抢占;*

第13行代码,因为第16行代码调用函数wakeup_preempt_entity来判断是否可以抢占,只能在属于同一个任务组的两个兄弟调度实体之间判断,所以函数find_matching_se需要为当前进程和被唤醒的进程找到两个兄弟调度实体。第16行代码,如果(当前进程的虚拟运行时间 − 被唤醒的进程的虚拟运行时间)大于虚拟唤醒粒度,那么允许抢占,给当前进程设置需要重新调度的标志。虚拟唤醒粒度是唤醒粒度根据当前进程的权重转换成的虚拟时间,全局变量sysctl_sched_wakeup_granularity存放唤醒粒度,单位是纳秒,默认值是106,即默认的唤醒粒度是1毫秒。如果开启了配置宏CONFIG_SCHED_DEBUG,可以通过文件“/proc/sys/kernel/sched_wakeup_granularity_ns”设置唤醒粒度。

空闲调度类“无条件抢占?”
——空闲调度类的优先级不是最低吗?

空闲调度类的check_preempt_curr方法是函数check_preempt_curr_idle,算法是:无条件抢占,给当前进程设置需要重新调度的标志。

*内核抢占:是指当进程在内核模式下运行的时候可以被其他进程抢占;

  1. 要解决的问题:如果进程在内核模式下不支持被强占,运行时间太长,会导致交互式进程等待的时间很长,响应很慢;
  2. 原理:每个进程的thread_info结构体thread_info有一个类型为int的成员preempt_count(抢占计数器),bit0-bit7代表抢占计数,bit8~bit15代表软中断计数... ——只要抢占计数器的值不为0,其他进程就不能内核抢占;*

内核抢占是指当进程在内核模式下运行的时候可以被其他进程抢占,需要打开配置宏CONFIG_PREEMPT。如果不支持内核抢占,当进程在内核模式下运行的时候,不会被其他进程抢占,带来的问题是:如果一个进程在内核模式下运行的时间很长,将导致交互式进程等待的时间很长,响应很慢,用户体验差。内核抢占就是为了解决这个问题。

2.8.8 带宽管理

进程带宽定义:
即指定在每个周期内所有实时进程的运行时间总和;带宽包含的两个参数是周期和运行时间,即指定在每个周期内所有实时进程的运行时间总和。

*公平调度类的带宽管理:(周期和限额)

  1. 在每个指定的周期内,允许一个任务组最多执行的时间限额;(带宽)
  2. 当任务组在一个周期内用完了带宽时,这个任务组将被节流,不允许被运行,直到下一个周期;*

可以使用周期和限额指定一个公平任务组的带宽。在每个指定的周期内,允许一个任务组最多执行多长时间(即限额)。当任务组在一个周期内用完了带宽时,这个任务组将会被节流,不允许继续运行,直到下一个周期。

*实时任务组和公平任务组的带宽管理差别:

  1. 实时任务组每个周期在每个处理器上的运行时间不超过限额;
  2. 公平任务组每个周期在所有处理器上的运行时间总和不超过限额;*

注意实时任务组和公平任务组的带宽管理差别:实时任务组每个周期在每个处理器上的运行时间不超过限额,公平任务组每个周期在所有处理器上的运行时间总和不超过限额。

2.9 SMP调度

SMP系统中,进程调度器的特性:

  1. 需要使每个处理器的负载尽可能均衡;
  2. 可以设置进程的处理器亲和性;
  3. 可以把进程从一个处理器迁移到另一个处理器;在SMP系统中,进程调度器必须支持以下特性。

2.9.1 进程的处理器亲和性

mount -t cpuset none /dev/cpuset

  1. 将none挂在/dev/cpuset文件目录下,;
  2. -t cpuset表明挂载在/dev/cpuset磁盘分区的文件系统是cpuset;
  3. 磁盘上并不存在这样一个文件系统,cpuset只是以文件系统的方式对外暴露接口;把cpuset伪文件系统挂载到目录“/dev/cpuset”下。

在/dev/cpuset/下创建一个名为abc的子cpuset;

创建cpuset,假设名称是“abc”。

*1. 把线程10加入cpuset控制组:cd /sys/fs/cgroup/cpuset/abc; echo 10 > tasks;

  1. 把线程10所在的线程组加入控制组:
    cd /sys/fs/cgroup/cpuset/abc; echo 10 > cgroup.procs;*

也可以把线程组加入控制组,指定线程组中任意一个线程的标识符,就会把线程组的所有线程加入控制组。假设把线程10所属的线程组加入控制组abc。

2.9.2 对调度器的扩展

阅读进度:2.9.2 对调度器的扩展在SMP系统上,调度类增加了以下方法:

2.9.5 公平调度类的处理器负载均衡

处理器体系架构划分:

  1. 非一致内存访问(NUMA):内存被划分成多个内存节点的多处理器系统,访问内存节点花费的时间取决于处理器和内存节点的距离;
  2. 对称多处理器(SMP):一致内存访问(UMA),即所有处理器访问内存花费的时间时相同的;目前多处理器系统有两种体系结构。

*处理器内部:

  1. 每个核拥有独立的一级缓存,所有核共享二级缓存;
  2. 一个处理器或者核包含多个硬件线程,硬件线程共享一级缓存和二级缓存;
  3. 进程迁移随着处理器拓扑层次的提升付出的代价更高:
    硬件线程(共享一级、二级缓存) < 核(共享二级缓存) < 处理器*

处理器内部的拓扑如下。(1)核(core):一个处理器包含多个核,每个核有独立的一级缓存,所有核共享二级缓存。(2)硬件线程:也称为逻辑处理器或者虚拟处理器,一个处理器或者核包含多个硬件线程,硬件线程共享一级缓存和二级缓存。MIPS处理器的叫法是同步多线程(Simultaneous Multi-Threading, SMT),英特尔对它的叫法是超线程。

2.10 进程的安全上下文

证书是访问对象所需权限的抽象;
主题提供自己权限的证书,客体提供访问自己所需权限的证书;
进程描述符task_struct 有两个成员和证书有关:
const struct cred_rcu *read_cred;
const struct cred_rcu *cred;证书是访问对象所需权限的抽象,主体提供自己权限的证书,客体提供访问自己所需权限的证书,根据主客体提供的证书和操作做安全性检查。证书用数据结构cred表示,进程描述符有两个成员和证书有关:

uid:真实用户标识符;——进程属于哪一个用户,即登录时使用的用户标识符;
gid:真实组标识符;

真实用户标识符和真实组标识符:标识了进程属于哪一个用户和哪一个组,即登录时使用的用户标识符和用户所属的第一个组标识符。

第3章 内存管理

虚拟内存管理——负责从进程的虚拟地址空间分配虚拟页;
sys_brk——扩大或收缩堆;
sys_munmap——释放虚拟页;
页分配器——负责分配物理页,当前使用的页分配器为:伙伴分配器;
块分配器——kmalloc()和kfree(),把页划分成小内存块;
引导内存分配器——在内核初始化阶段,页分配器还没准备好,用来帮助临时分配内存;
不连续内存分配器——vmalloc和vfree;
连续内存分配器(CMA)——用来给驱动程序预留连续内存;
内存碎片整理——当内存碎片化时,找不到连续的物理页,通过迁移的方式得到连续的物理页;
页回收:

  1. 匿名页——把数据换出到交换区,然后释放物理页;
  2. 文件页——把数据写回存储设备,然后释放物理页;
    内存耗尽杀手(OOM killer)——选择进程杀掉;
    页表缓存(TLB)——保存最近使用过的页表映射;
    虚拟内存管理负责从进程的虚拟地址空间分配虚拟页,sys_brk用来扩大或收缩堆,sys_mmap用来在内存映射区域分配虚拟页,sys_munmap用来释放虚拟页。内核使用延迟分配物理内存的策略,进程第一次访问虚拟页的时候,触发页错误异常,页错误异常处理程序从页分配器申请物理页,在进程的页表中把虚拟页映射到物理页。页分配器负责分配物理页,当前使用的页分配器是伙伴分配器。

3.2.2 用户虚拟地址空间布局

用户虚拟地址空间的内存映射区域:把文件区间映射到->虚拟地址空间;把文件区间映射到虚拟地址空间的内存映射区域。

*内存描述符解析:

  1. mm_users——共享同一个用户虚拟地址空间的进程的数量,也即线程组包含进程的数量;
  2. mm_count——内存描述符的引用计数;*

假设被借用的内存描述符所属的进程不属于线程组,那么内存描述符的成员mm_users不变,仍然是1,成员mm_count加1变成2。

*为了使缓冲区溢出攻击更加困哪,内核支持为内存映射区域、栈和堆选择随机的起始地址,且它们取决于下面两个因素:

  1. task_struct.personality == ADDR_NO_RANDOMIZE;
  2. 全局变量randomize_va_space ; 0-表示关闭虚拟地址空间随机化,1-使能内存映射取悦和栈起始地址随机化;2-内存映射区域、堆和栈;*

3.2.3 内核地址空间布局

内核地址空间布局:

  1. 线性映射区域:长度是内核虚拟地址空间的一半;和物理地址呈线性关系;
  2. vmemmap:对应稀疏物理内存的page结构体数组;
  3. 内核地址消毒剂(KASAN)——动态的内存错误检查工具。针对释放后使用和越界访问;内核使用page结构体描述一个物理页,内存的所有物理页对应一个page结构体数组。如果内存的物理地址空间不连续,存在很多空洞,称为稀疏内存。vmemmap区域是稀疏内存的page结构体数组的虚拟地址空间。

KASAN——内核地址消毒剂,是一个动态的内存错误检查工具;
[startAdrr, endAddr] = [内核虚拟地址空间的起始地址, 长度为内核虚拟地址空间长度的1/8]

KASAN影子区域的起始地址是内核虚拟地址空间的起始地址,长度是内核虚拟地址空间长度的1/8。内核地址消毒剂(Kernel Address SANitizer, KASAN)是一个动态的内存错误检查工具。它为发现释放后使用和越界访问这两类缺陷提供了快速和综合的解决方案。

3.3 物理地址空间

物理地址定义:处理器在系统总线上看到的地址;
外围设备和物理内存使用统一的物理地址空间;物理地址是处理器在系统总线上看到的地址

*# Arm架构定义了两种内存类型:

  1. 正常内存——包括物理内存和制度存储器(ROM);
  2. 设备内存——即分配给外围设备寄存器的物理地址区域;

内存的共享属性:

  1. 不可共享——指只能被处理器的一个核使用;
  2. 内部共享——是指一个处理器的所有核共享或者多个处理器共享;
  3. 外部共享——指处理器和其他观察者(如图形处理单元或DMA控制器)共享;

内存的缓存属性:

——定义访问时是否可以通过处理器的缓存;

对设备内存,共享属性总是外部共享,缓存属性总是不可缓存;

对于正常内存,可以设置共享属性和缓存属性。共享属性用来定义一个位置是否可以被多个核共享,分为不可共享、内部共享和外部共享。不可共享是指只被处理器的一个核使用,内部共享是指一个处理器的所有核共享或者多个处理器共享,外部共享是指处理器和其他观察者(比如图形处理单元或DMA控制器)共享。缓存属性用来定义访问时是否通过处理器的缓存。设备内存的共享属性总是外部共享,缓存属性总是不可缓存(即必须绕过处理器的缓存)。

3.4 内存映射

内存映射:文件映射、匿名映射;

  1. 共享映射:修改数据时映射相同区域的其他进程可以看见;
  2. 私有映射:第一次修改数据时会从数据源复制一个副本,然后修改副本,其他进程看不见;
  3. 匿名映射通常是私有映射,共享的匿名映射只可能出现在父进程和子进程之间;两个进程可以使用共享的文件映射实现共享内存。匿名映射通常是私有映射,共享的匿名映射只可能出现在父进程和子进程之间。在进程的虚拟地址空间中,代码段和数据段是私有的文件映射,未初始化数据段、堆和栈是私有的匿名映射。内存映射的原理如下。

3.4.1 应用编程接口

内核空间技巧:

  1. 使用remap_pfn_range:把内存的物理页映射到进程的虚拟地址空间,用来实现进程和内核共享内存;
  2. io_remap_pfn_range:把外设寄存器的物理地址映射到进程的虚拟地址空间,用来实现进程直接访问外设寄存器;remap_pfn_range把内存的物理页映射到进程的虚拟地址空间,这个函数的用处是实现进程和内核共享内存。

*glibc库内存分配器ptmalloc使用brk或mmap向内核以页为单位申请虚拟内存:

  1. 若应用程序申请的内存长度 < 128KB,则ptmalloc使用brk向内核申请虚拟内存;
  2. 否则ptmalloc使用mmap向内核申请虚拟内存;*

glibc库的内存分配器ptmalloc使用brk或mmap向内核以页为单位申请虚拟内存,然后把页划分成小内存块分配给应用程序。默认的阈值是128KB,如果应用程序申请的内存长度小于阈值,ptmalloc分配器使用brk向内核申请虚拟内存,否则ptmalloc分配器使用mmap向内核申请虚拟内存。

3.4.2 数据结构

进程的虚拟内存区域按照链表和红黑树排序:

  1. 进程(task_struct)的内存映射区域对象(mm_struct),它的mmap指向一个双向链表实例(vm_area_struct),链表按照虚拟内存区域的起始地址递增排序;
  2. mm_struct的mm_rb指向红黑树的根(rb_root),树也是按照起始地址排序;如图3.12所示,进程的虚拟内存区域按两种方法排序。

3.4.3 创建内存映射

sys_mmap调用注意事项:

  1. 检查传入参数“偏移”是否为页的整数倍,否则返回“-EINVAL”;
  2. 条件1满足后,调用sys_mmap_pgoff()->vm_mmapo_pgoff->do->mmap();
    检查偏移是不是页的整数倍,如果偏移不是页的整数倍,返回“-EINVAL”。

删除旧的虚拟内存区域:
do_munmap();

那么需要从旧的虚拟内存区域删除重叠的部分。

3.4.4 虚拟内存过量提交策略

如果所有进程提交的虚拟内存的总和超过物理内存的容量,内存管理子系统采用何种策略?

  1. 内存管理子系统支持3种虚拟内存过量提交策略:
    1.1 OVERCOMMIT_GUESS(0):猜测,估算可用内存数量;
    1.2 OVERCOMMIT_ALWAYS(1):总是允许过量提交;
    1.3 OVERCOMMIT_NEVER(2):不允许过量提交;
    默认策略是猜测,用户可以通过文件“/proc/sys/vm/overcommit_memory”修改策略。在创建新的内存映射时,调用函数__vm_enough_memory根据虚拟内存过量提交策略判断内存是否足够,主要代码如下:

*使用猜测的过量提交策略,估算可用物理内存算法如下:

  1. 空闲页加上文件页;
  2. 共享内存页不能释放,只能换出到交换区;
  3. 交换区的空闲页数可以用来分配相应物理内存;
  4. 可回收的内存缓存页,如dentry和inode;
  5. 可用的内存数量需要减去保留的页数;
  6. 如果进程没有系统管理权限,那么还需要减去为跟用户保留的页数;
    ——综上,如果计算出的可用内存页数 > 申请的页数,那么允许创建新的虚拟内存到物理内存的映射;*

第8行代码,如果使用猜测的过量提交策略,那么估算可用内存的数量,处理如下。

3.5 物理内存组织

物理内存体系结构:

  1. 非一致性内存访问(NUMA):访问一个内存节点花费的时间取决于处理器和内存节点的距离;
  2. 对称多处理器,即一致内存访问,所有处理器访问内存花费的时间是相同的;目前多处理器系统有两种体系结构。

3.5.3 三级结构

内存管理子系统的使用三级结果描述物理内存:

  1. 节点(node);——每块物理地址连续的内存是一个内存节点
  2. 区域(zone);
  3. 页(page);根据物理地址是否连续划分,每块物理地址连续的内存是一个内存节点。如图3.16所示,内存节点使用一个pglist_data结构体描述内存布局。内核定义了宏NODE_DATA(nid),它用来获取节点的pglist_data实例。对于平坦内存模型,只有一个pglist_data实例:contig_page_data。

*内存节点被划分为内存区域,内核定义的区域类型如下:

  1. DMA区域:映射到物理内存的0-16MB;
  2. DMA32区域(ZONE_DMA32);
  3. 普通区域(ZONE_NORMAL):直接映射到物理内存的16MB-896MB;
  4. 高端内存区域(ZONE_HIGHMEM): 位于内核地址空间的896MB-1GB,可以映射到物理内存自896MB地址后的所有地址空间;*

内存节点被划分为内存区域,内核定义的区域类型如下:

*内存节点使用pglist_data结构体描述内存布局:

  1. pglist_data.node_zones——内存节点所包含的内存区域数组;
  2. 成员node_mem_map指向页描述符数组,包含了该内存节点下的所有物理页;
    内联函数page_to_nid——用来得到物理页所属内存节点的编号;
    内联函数page_zonenum——用来得到物理页所属的内存区域的类型;*

每个物理页对应一个page结构体,称为页描述符,内存节点的pglist_data实例的成员node_mem_map指向该内存节点包含的所有物理页的页描述符组成的数组。结构体page的成员flags的布局如下:

3.6 引导内存分配器

内核初始化阶段,内核自身提供了临时的引导内存分配器;

  1. 用来初始化页分配器和块分配器;
  2. 把空闲页交给页分配器后,内核丢弃引导内存分配器;
    目前使用的引导内存分配器:memblock;在内核初始化的过程中需要分配内存,内核提供了临时的引导内存分配器,在页分配器和块分配器初始化完毕后,把空闲的物理页交给页分配器管理,丢弃引导内存分配器。

3.6.3 物理内存信息

内核初始化的时候,引导内存分配器如何获取内存的大小和物理地址范围?

  1. 驱动开发者为板卡编写设备树源文件(DTS),然后编译设备树二进制文件(DTB),接着把设备树二进制文件写到存储设备上;
  2. 设备启动时,引导程序把DTB文件读取到内存中,内核解析DTB文件后,就可以得到设备的内存起始地址和长度;。设备启动时,引导程序把设备树二进制文件从存储设备读到内存中,引导内核的时候把设备树二进制文件的起始地址传给内核,内核解析设备树二进制文件后得到硬件信息。

3.7.1 基本的伙伴分配器

伙伴分配器基本术语:

  1. 页块:连续的物理页;
  2. 阶:页的数量单位,2^n个连续页称为n阶页块;
  3. 满足以下条件的两个n阶页块称为伙伴(buddy):
    3.1 两个页块时相邻的;
    3.2 页块的第一页的物理页号必须是2^n的整数倍;
    3.3 如果合并成(n+1)阶页块,第一页的页号必须是2^n+1的整数倍;连续的物理页称为页块(page block)。阶(order)是伙伴分配器的一个术语,是页的数量单位,2n个连续页称为n阶页块。满足以下条件的两个n阶页块称为伙伴(buddy)。

每处理器每处理器页集合??

针对分配单页做了性能优化,为了减少处理器之间的锁竞争,在内存区域增加1个每处理器页集合。

3.7.2 分区的伙伴分配器

内存区域结构体成员解析:

  1. zone.free_area : struct free_area free_area[MAX_ORDER]——用来维护不同阶数的空闲页块链表;
  2. zone.free_area[i].list_head——第i阶空闲内存块链表头;
  3. zone.free_area[i].list_head——第i阶空闲页块的数量;分区的伙伴分配器专注于某个内存节点的某个区域。内存区域的结构体成员free_area用来维护空闲页块,数组下标对应页块的阶数。结构体free_area的成员free_list是空闲页块的链表(暂且忽略它是一个数组,3.7.3节将介绍), nr_free是空闲页块的数量。内存区域的结构体成员managed_pages是伙伴分配器管理的物理页的数量,不包括引导内存分配器分配的物理页。

*# 如果首选的内存节点和区域不能满足页分配需求,则可以从备用区域列表借用物理页:

  1. UMA系统:只有一个备用区域列表,按区域类型从高到低排序,如{普通区域, DMA区域};
  2. NUMA系统:两个备用区域列表:
    2.1 列表一包含所有内存节点的区域;该备用区域列表有两种排序方法:
    2.1.1 节点优先顺序:先按节点距离从小到大,节点里按区域由高到低;
    2.2.2 区域优先顺序:先按区域类型从高到低,区域里按节点距离从小到大排序;
    2.2 列表二只包含当前内存结点的区域;*

UMA系统只有一个备用区域列表,按区域类型从高到低排序。假设UMA系统包含普通区域和DMA区域,那么备用区域列表是:{普通区域,DMA区域}。NUMA系统的每个内存节点有两个备用区域列表:一个包含所有内存节点的区域,另一个只包含当前内存节点的区域。如果申请页时指定标志__GFP_THISNODE,要求只能从指定内存节点分配物理页,就需要使用指定内存节点的第二个备用区域列表。

*# 紧急保留内存:
每个内存区域有3个水线:

  1. 高水线;
  2. 低水线;(内存轻微不足)
  3. 最低水线;(该区域内存严重不足)

最低水线以下的内存——紧急保留内存

  1. 设置了进程标志位PF_MEMALLOC的进程可以使用;例:页回收内核线程kswapd;
  2. 如果申请页时设置了标志位__GFP_MEMALLOC,也可以使用;

最低水线以下的内存称为紧急保留内存,在内存严重不足的紧急情况下,给承诺“给我少量紧急保留内存使用,我可以释放更多的内存”的进程使用。

*伙伴分配器按内存区域分配算法:

  1. 申请页时,如果首选的内存区域的空闲页数 小于 该区域的低水线,则从备用内存区域借用物理页;
  2. 上一步失败后,唤醒所有目标内存节点的页回收内核线程kswapd以异步回收页,然后尝试最低水线。
    2.1 如果首选内存区域的空闲页数 小于 最低水线,就从备用的内存区域借用物理页;*

申请页时,第一次尝试使用低水线,如果首选的内存区域的空闲页数小于低水线,就从备用的内存区域借用物理页。如果第一次分配失败,那么唤醒所有目标内存节点的页回收内核线程kswapd以异步回收页,然后尝试使用最低水线。如果首选的内存区域的空闲页数小于最低水线,就从备用的内存区域借用物理页。

*为防止高区域类型过度借用低区域类型的物理页:

  1. 内存区域使用lowmem_reserve[MAX_NR_ZONES]存放保留页数;
  2. zone[i]->lowmem_reserve[j],表示区域类型i因该保留多少页不能借给区域类型j;
  3. i < j,表示j类型区域优先级高于i,此时lowmem_reserve[j] = 当前内存节点上从zone[i+1]到zone[j]区域伙伴分配器管理的页数总和;

为了防止高区域类型过度借用低区域类型的物理页,低区域类型需要采取防卫措施,保留一定数量的物理页。一个内存节点的某个区域类型从另一个内存节点的相同区域类型借用物理页,后者应该毫无保留地借用。

3.7.3 根据可移动性分组

内存碎片对用户程序无影响,但是对内核是问题:、

  1. 用户程序可以通过页表把连续的虚拟页映射到不连续的物理页;
  2. 但是内核使用直接映射的虚拟地址空间,即连续的内核虚拟页必须映射到连续的物理页;内存碎片对用户程序不是问题,因为用户程序可以通过页表把连续的虚拟页映射到不连续的物理页。但是内存碎片对内核是一个问题,因为内核使用直接映射的虚拟地址空间,连续的虚拟页必须映射到连续的物理页。内存碎片是伙伴分配器的一个弱点。

*为了预防内存碎片,内核根据可移动性把物理页分为:

  1. 不可移动页:直接映射到内核虚拟地址空间的页;
  2. 可移动页:使用页表映射的页属于这一类;
  3. 可回收页:不能移动,但可以回收,需要的时候数据可以重新获取;*

为了预防内存碎片,内核根据可移动性把物理页分为3种类型。

3.7.5 分配页

符合页:

  1. 设置了标志位:__GFP_COMP;
  2. 分配了一个阶数大于0的页块;
    此时也分配器会把页块组成符合页;
    复合页最常见的用处就是创建巨星页;如果设置了标志位__GFP_COMP并且分配了一个阶数大于0的页块,页分配器会把页块组成复合页(compound page)。复合页最常见的用处是创建巨型页。

所有分配页的函数最终都会调用到分区伙伴分配器的心脏:
__alloc_pages_nodemask(gfp_mask, order, zonelist, nodemask);

所有分配页的函数最终都会调用到函数__alloc_pages_nodemask,这个函数被称为分区的伙伴分配器的心脏。函数原型如下:

*伙伴分配器之执行快速路径算法:

  1. 使用低水线尝试第一次分配:
  2. 从节点回收没有映射到进程虚拟地址空间的文件页和块分配器申请的页,然后重新检查水线;
  3. 如果(区域的空闲页数 - 申请的页数) < 水线,则不能从该区域分配页;*

从节点回收没有映射到进程虚拟地址空间的文件页和块分配器申请的页,然后重新检查水线,如果(区域的空闲页数 − 申请的页数)还是小于水线,那么不能从这个区域分配页。


使用 小悦记 导出 | 2022年2月23日

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容