处理器MCU硬件单元处理最小的单位就是页,所以内核分配,虚拟地址和物理地址的映射关系最小单元都是页,页由页表(hash结构)进行管理,数据对齐也是按照页的大小对齐的。

 

一、Linux系统启动 

(前置:这里使用的linux版本是4.8,x86体系)linux的启动过程有点像是小鱼吃大鱼,最后吃成一个胖子;计算机中的PC寄存器是用来指示下个执行程序。最开始的时候,pc寄存器都是指向0xfffffff0。这个程序是指向BIOS的POST程序的。POST全称是Power On Self Test,意思是上电自检。过程包括内存检查,系统总线检查等。POST过程结束,就进入到了自举过程,自举过程把MBR(Master Boot Record主引导扇区)加载到内存中,并且执行它;这个主引导扇区是第一个扇区的前512字节。Master Boot Record过程是为了后面一个过程准备的。它主要做的是读入GRUB stage2(GRand Unified Bootloader)所在的扇区,并且执行它。GRUB stage2 主要将系统切换到保护模式,设置C运行环境等。然后进入到x86/boot/header.S中执行,在这里面你能找到main的函数入口;这个对应到x86/boot/main.c的main函数。这个main函数执行到最后会进入go_to_protected_mode();进入到pm.c的这个函数的定义,除了初始化一些逻辑以外,主要是protected_mode_jump。下面就进入到boot/pmjump.S的protected_mode_jump

29 protected_mode_jump:
30        movl    %edx, %esi              # Pointer to boot_params table
31
32        xorl    %ebx, %ebx
33        movw    %cs, %bx                # 将实模式的代码段放入 bx
34        shll    $4, %ebx                # 转换为线性地址
35        addl    %ebx, 2f                # 将 in_pm32 的实模式地址转换为线性地址
36
37        movw    $__BOOT_DS, %cx        # ds 段选择子
38        movw    $__BOOT_TSS, %di        # tss 段选择子
39
40        movl    %cr0, %edx
41        orb    $X86_CR0_PE, %dl        # Protected mode
42        movl    %edx, %cr0              # 将 cr0 的0位置0是进入保护模式的标志
43        jmp    1f                      # Short jump to serialize on 386/486
44 1:
45        # 下面这段作用是跳转到 in_pm32,由于已经在保护模式,所以需要考虑段的问题
46        # Transition to 32-bit mode
47        .byte  0x66, 0xea              # ljmpl opcode
48 2:      .long  in_pm32                # offset
49        .word  __BOOT_CS              # segment
50
51        .size  protected_mode_jump, .-protected_mode_jump
52
53        .code32
54        .type  in_pm32, @function
55 in_pm32:        # 下面的注释挺清楚,就不翻译了
56        # Set up data segments for flat 32-bit mode
57        movl    %ecx, %ds
58        movl    %ecx, %es
59        movl    %ecx, %fs
60        movl    %ecx, %gs
61        movl    %ecx, %ss
62        # The 32-bit code sets up its own stack, but this way we do have
63        # a valid stack if some debugging hack wants to use it.
64        addl    %ebx, %esp
65
66        # Set up TR to make Intel VT happy
67        ltr    %di                    # 这个比较有意思
68
69        # Clear registers to allow for future extensions to the
70        # 32-bit boot protocol
71        xorl    %ecx, %ecx
72        xorl    %edx, %edx
73        xorl    %ebx, %ebx
74        xorl    %ebp, %ebp
75        xorl    %edi, %edi
76
77        # Set up LDTR to make Intel VT happy
78        lldt    %cx                    # 又是一个骗 CPU 的东西

79        # eax 是 protected_mode_jump 的第一个参数,即 header.S 中定义的 
            boot_params.hdr.code32_start,即 vmlinux 的入口地址
80        jmpl    *%eax                  # Jump to the 32-bit entrypoint
81
82        .size  in_pm32, .-in_pm32

最后的jmpl(80行汇编代码)就跳转到arch/x86/kernel/head_32.S的startup_32

ENTRY(initial_code).long i386_start_kernel

进入到arch/x86/kernel/head32.c

asmlinkage __visible void __init i386_start_kernel(void)
{
    cr4_init_shadow();
    sanitize_boot_params(&boot_params);

    x86_early_init_platform_quirks();

    /* Call the subarch specific early setup function */
    switch (boot_params.hdr.hardware_subarch) {
    case X86_SUBARCH_INTEL_MID:
        x86_intel_mid_early_setup();
        break;
    case X86_SUBARCH_CE4100:
        x86_ce4100_early_setup();
        break;
    default:
        i386_default_early_setup();
        break;
    }

    start_kernel();
}

这里最后是调用了start_kernel,这里的start_kernel是与操作系统无关的init/main.c里面了。start_kernel是过了引导阶段,进入到了内核启动阶段的入口。函数在init/main.c中。set_task_stack_end_magic(&init_task);这个函数是设置操作系统的第一个进程init。这个init_task变量是怎么来的呢?从init/init_task.c中初始化的。struct task_struct init_task = INIT_TASK(init_task);EXPORT_SYMBOL(init_task);而这个INIT_TASK的初始化在init/init_task.h:

#define INIT_TASK(tsk)    \
{                                    \
    .state        = 0,                        \
    .stack        = init_stack,                    \
    .usage        = ATOMIC_INIT(2),                \
    .flags        = PF_KTHREAD,                    \
    .prio        = MAX_PRIO-20,                    \
    .static_prio    = MAX_PRIO-20,                    \
    .normal_prio    = MAX_PRIO-20,                    \
     ...

这里使用的是gcc的结构体初始化方式,这个结构体是根据task_struct结构进行初始化的。 再回到set_task_stack_end_magic

void set_task_stack_end_magic(struct task_struct *tsk)
{
    unsigned long *stackend;

    stackend = end_of_stack(tsk);
    *stackend = STACK_END_MAGIC;    /* for overflow detection */
}

这个end_of_stack 在include/linux/sched.h中,它的意思是获取栈边界地址,然后把栈底地址设置为STACK_END_MAGIC,这个作为栈溢出的标记。 每个进程创建的时候,系统会为这个进程创建2个页大小的内核栈,这个内核栈底下是thread_info结构,高位是栈。

这里的STACK_END_MAGIC就是设置在thread_info结构的上面;比如如果你写了一个无限循环,导致栈使用不断增长了,那么,一旦把这个标记未修改了,就导致了栈溢出的错误。smp_setup_processor_id();

smp_setup_processor_id();   // 设置smp模型的处理器id

smp模型指的是对称多处理模型(Symmetric Multi-Processor),与它对应的是NUMA非一致存储访问结构(Non-Uniform Memory Access)和MPP 海量并行处理结构(Massive Parallel Processing)。它们的区别分别在于,SMP指的是多个CPU之间是平等关系,共享全部总线,内存和I/O等。但是这个结构扩展性不好,往往CPU数量多了之后,很容易遇到抢占资源的问题。NUMA结构则是把CPU分模块,每个模块具有独立的内存,I/O插槽等。各个模块之间通过互联模块进行数据交互,但是这样,就表示了有的内存数据在这个CPU模块中,那么处理这个数据当然最好是选择当前的CPU模块,这样每个CPU实际上地位就不一致了。所以叫做非一致的存储访问结构,而MPP呢,则是由多个SMP服务器通过互联网方式连接起来。支持SMP模型的CPU有AMD/AMD64。而支持NUMA的有X86等。而这里的代码,smp_setup_process_id在普通情况下是空实现,在不同的体系,比如arch/arm/kernel/setup.c, line 586就有对应的逻辑了。

debug_objects_early_init();这个函数的实际代码在lib/debugobject.c

oid __init debug_objects_early_init(void)
{
    int i;

    for (i = 0; i < ODEBUG_HASH_SIZE; i++)
        raw_spin_lock_init(&obj_hash[i].lock);

    for (i = 0; i < ODEBUG_POOL_SIZE; i++)
        hlist_add_head(&obj_static_pool[i].node, &obj_pool);
}

可以看到,它主要是用来对obj_hash,obj_static_pool这两个全局变量进行初始化设置,这两个全局变量在进行调试的时候会使用到。 

boot_init_stack_canary();这个函数是做什么的呢?我们要说堆栈溢出漏洞,它的意思就是动态分配的堆中,不按照本来分配的大小进行设置,而是使用某种方法,设置变量分配大小之外的数据。甚至设置到了函数栈的数据了,那么这个时候就可能会被调用到注入的某个函数中了。​那么和前面的end_magic逻辑一样,我们在堆和栈的中介处设置一个标记位(叫做canary word)。当这个位被修改的时候,我们就知道了,这个时候存在堆栈溢出,就进行错误处理。那么这个标记位的值是怎么样子的,就是使用这个函数。这个也和CPU架构有关系了,比如在x86的系统中,是随机产生的。
 

二、内核进程与轻量级进程

系统内核所有的进程都来源于一个根节点的内核主进程,其他子进程都是通过fork()创建的(对于内核而言都是进程或者内核线程,没有进程和线程的区分而言):Linux pstree 指令

该树型结构的扩展方式与新进程的创建方式密切相关。UNIX操作系统中有两种创建新进程的机制,分别是fork和exec。
(1) fork可以创建当前进程的一个副本,父进程和子进程只有PID(进程ID)不同。在该系统调用执行之后,系统中有两个进程,都执行同样的操作。父进程内存的内容将被复制,至少从程序的角度来看是这样。Linux使用了一种众所周知的技术来使fork操作更高效,该技术称为写时复制(copy on write),主要的原理是将内存复制操作延迟到父进程或子进程向某内存页面写入数据之前,在只读访问的情况下父进程和子进程可以共用同一内存页。例如,使用fork的一种可能的情况是,用户打开另一个浏览器窗口。如果选中了对应的选项,浏 览器将执行fork,复制其代码,接下来子进程中将启动适当的操作建立新窗口。
(2) exec将一个新程序加载到当前进程的内存中并执行。旧程序的内存页将刷出,其内容将替换为新的数据。然后开始执行新程序。
进程并不是内核支持的唯一一种程序执行形式。除了重量级进程(有时也称为UNIX进程)之外, 还有一种形式是线程(有时也称为轻量级进程,对于内核都是进程,这里只是对于用户而言称作线程,其实就是clone()出来的子进程)。线程也已经出现相当长的一段时间,本质上一个进 程可能由若干线程组成,这些线程共享同样的数据和资源,但可能执行程序中不同的代码路径。线程 概念已经完全集成到许多现代编程语言中,例如Java。简而言之,进程可以看作一个正在执行的程序, 而线程则是与主程序并行运行的程序函数或例程。该特性是有用的,例如在浏览器需要并行加载若干图像时。通常浏览器只好执行几次fork和exec调用,以此创建若干并行的进程实例。这些进程负责加载图像,并使用某种通信机制将接收的数据提供给主程序。在使用线程时,这种情况更容易处理一些。浏览器定义了一个例程来加载图像,可以将例程作为线程启动,使用参数不同的多个线程即可。由于线程和主程序共享同样的地址空间,主程序自动就可以访问接收到的数据。因此除了为防止线程访问同一内存区而采取的互斥机制外,就不需要什么通信了。下图说明了有和没有线程的程序之间的差别。Linux用clone方法创建线程。其工作方式类似于fork,但启用了精确的检查,以确认哪些资源与父进程共享、哪些资源为线程独立创建。这种细粒度的资源分配扩展了一般的线程概念,在一定程度上允许线程与进程之间的连续转换。

在内核层面都叫做进程,但是有些进程是clone出来的,对应的是用户空间的线程,或者多并发fork出来子进程来并发执行多核cpu。对于linux top指令可以展示多有的进程信息:

参数说明:
d : 监控内容刷新的时间间隔。
p : 只监控指定PID的进程。
H : 显示进程下的线程,一般结合p参数使用,查看某个进程下的线程,例如: top -Hp 8080。
c : 切换显示模式,只显示名称,或者显示完整的路径与名称。
s : 安全模式,交谈式指令无法使用, 例如k指令。
i : 不显示任何闲置或无用的进程,也可以理解成只显示正在执行的进程。
n : 限定监控内容刷新的次数,完成后将会退出 top 视图。例如 top -n 5,默认3秒刷新一次,那么在15s后,自动退出top视图。

对于top指令的参数我们可以-HP来查看所有的子进程信息(或者说是线程信息)【 可以看到tomcat 21313进程服务启动了很多线程,也就是java线程,每一个java线程对应一个内核子进程。】:

当然我们查看内核的子父进程的结构,最好使用pstree指令:

-a:显示每个程序的完整指令,包含路径,参数或是常驻服务的标示;
-c:不使用精简标示法;
-G:使用VT100终端机的列绘图字符;
-h:列出树状图时,特别标明现在执行的程序;
-H<程序识别码>:此参数的效果和指定"-h"参数类似,但特别标明指定的程序;
-l:采用长列格式显示树状图;
-n:用程序识别码排序。预设是以程序名称来排序;
-p:显示程序识别码;
-u:显示用户名称;
-U:使用UTF-8列绘图字符;
-V:显示版本信息。

// 打印所有进程及其线程
pstree -p 
// 打印某个进程的线程数
pstree -p {pid} | wc -l

 

在这里说明下,其实父进程对应的子进程也就是clone出来,共用进程空间的子进程数量是有限制的,由系统配置文件/etc/security/limits.d/20-nproc.conf来配置的。

$ cat /etc/security/limits.d/20-nproc.conf  
# Default limit for number of user's processes to prevent
# accidental fork bombs.
# See rhbz #432903 for reasoning.
*          soft    nproc     1024   // 将此处修改成unlimited或者其他数值
root       soft    nproc     unlimited

linux 系统运行的进程信息也会同步到系统伪文件进行记录 /proc/{pid}/status,每一个进程都有伪文件记录。

cat /proc/{pid}/status #其中Threads后面跟的就是线程数。
 
#或者通过 
 
ls /proc/{pid}/task | wc -l

使用pstree 加上-p:同时列出每个进程的PID;-u: 同时列出每个进程的所属账号名称;这两个参数实现子父进程树展示,同时展示进程id信息,最为明了。

可以看到所有的进程都是依附在systemd这个进程下面,它的进程PID是1,因为它是由Linux内核主动调用的一个进程。可以从中看出来进程所属的用户,每个进程的pid,而且,25707这个进程有5个子进程,分别为25778,25779,25780,25782,25787;25778这个进程也有几个子线程,分别为,25781,25783,25784,27547,27548。 也可以执行pstree指定pid或者-p(相同用户进程合并到树上展示), pstree -p 2500:

二、内存管理

Linux系统内存管理主要是基于段管理和内存页管理,系统将内存划为最小颗粒(数据页)进行管理,每一个数据页是4KB,对于一个4G内存的系统而言,那么就是1000*1000=1百万个数据页。如果基于字节或者每4个字节进行管理,那么将会是极大的映射或者寻址空间,那么效率会是一个很大的问题。

基于段管理一方面系统在管理内存的时候确实是按照不同的功能区域进行存储的,一方面也是把内存的空间划分不能功能区,在功能区上又会基于页来寻址,所以这也是分层来管理内存。

底层的硬件指令集提供了段寄存器,这是存储内存段地址的寄存器,通过段寄存器的映射将内存映射到连续线性的地址片区,然后再通过页面page_table来查询具体的地址页。最后通过偏移地址来具体访问物理地址。

说明下上面不同的段寄存器:

DS数据段寄存器,用来存储数据的,它对应MOV指令,将对应的数据区某物理地址的数据进行计算;ES对针对带有目的地址的段寄存器,将计算完成数据放回目的地址中

SS栈寄存器,是存储线程栈的寄存器,线程切换调度的时候,是要存储对应执行栈的;IP是指向栈顶的指令寄存器,是控制指令执行过程的寄存器

CS代码寄存器,记录所有的代码指令区的段寄存器,寻找具体函数代码需要依托该段寄存器

FS标志位段寄存器,该寄存器没有直接作为系统硬开发使用,而是作为系统线程空间协同与存储使用

GS全局段寄存器,该寄存器没有直接作为系统硬开发使用,作为系统功能辅助的寄存器

对于寻址分别从用户空间的内存块到内核块变换时,会有系统从用户态切换到内核态,会涉及到段寄存器重新切换的问题,也就是切换用户态的时候会保存之前的段地址信息以及指令寄存器信息。

对于Linux系统分配内存机制主要就是伙伴算法:就是申请一个小块,然后不够的话再去申请的时候就是*2大小,一次累次分别是*2^2大小;然后申请的内存块全部挂在双向链表上。每一块在链表节点上都有使用还是未使用的标记,以及使用的页序号。

 对于已经使用的每一个块,当然分配的时候一直都是按照*2^2的倍率来分配的,但是未必能完全使用到,那么空余的会按照回收的时候进行合并。

三、Linux命名空间

 命名空间提供了虚拟化的一种轻量级形式,使得我们可以从不同的方面来查看运行系统的全局属
性。该机制类似于Solaris中的zone或FreeBSD中的jail。对该概念做一般概述之后,下面将讨论命名空间框架所提供的基础设施。
如果提供Web主机的供应商打算向用户提供Linux计算机的全部访问权限,包括root权限在内。传统上,这需要为每个用户准备一台计算机,代价太高。 使用KVM或VMWare提供的虚拟化环境是一种解决问题的方法,但资源分配做得不是非常好。计算机的各个用户都需要一个独立的内核,以及一份完全安装好的配套的用户层应用。
命名空间提供了一种不同的解决方案,所需资源较少。在虚拟化的系统中,一台物理计算机可以
运行多个内核,可能是并行的多个不同的操作系统。而命名空间则只使用一个内核在一台物理计算机上运作,前述的所有全局资源都通过命名空间抽象起来。这使得可以将一组进程放置到容器中,各个容器彼此隔离。隔离可以使容器的成员与其他容器毫无关系。但也可以通过允许容器进行一定的共享,来降低容器之间的分隔。例如,容器可以设置为使用自身的PID集合,但仍然与其他容器共享部分文件系统。本质上,命名空间建立了系统的不同视图。此前的每一项全局资源都必须包装到容器数据结构中,只有资源和包含资源的命名空间构成的二元组仍然是全局唯一的。 
考虑系统上有3个不同命名空间的情况。命名空间可以组织为层次,我会在这里讨论这种情况。 一个命名空间是父命名空间,衍生了两个子命名空间。假定容器用于虚拟主机配置中,其中的每个容器必须看起来像是单独的一台Linux计算机。因此其中每一个都有自身的init进程,PID为0,其他进程的PID以递增次序分配。两个子命名空间都有PID为0的init进程,以及PID分别为2和3的两个进程。由于相同的PID在系统中出现多次,PID号不是全局唯一的。

 在内核版本2.6.24开发期间,内核也开始对网络子系统采用命名空间。这 对该子系统增加了一些额外的复杂性,因为该子系统的所有属性在此前的版本中都是“全局”的,而 现在需要按命名空间来管理,例如,可用网卡的数量。对特定的网络设备来说,如果它在一个命名空 间中可见,在另一个命名空间中就不一定是可见的。

四、IO模块

对于网卡驱动是将网络数据进行转换然后运行对应包协议,网卡驱动识别不同得数据包,加载对应的协议包进行拆解包,将数据通过IO中断(中断包含了端口信息),交给内核IO接口模块处理。

Linux内核将IO中断做了封装,封装成了套接字对应,其中包含读写文件的文件对象,也就是说内核将网络IO封装了虚拟的文件对象集合。一个进程创建socket监听,有网络连接来了,就会创建套接字连接对象,然后从套接字对象中拿到读取数据的文件和发送数据的文件对象进行数据收发。接着再去创建新的套接字连接对象,这是内核进程顺序处理的逻辑。当然内核进程可以通过并发的形式进行处理套接字连接,父进程监听,然后一旦有连接创建新的套接字连接对象,fork()新的进程去处理套接字数据的收发。早期的Linux其实对这个做了封装也就是select(早期的IO多路复用换句话说就是IO多路并发),父进程创建套接字监听,然后新来的套接字连接就会放在select对象里面,每一个对象都包含独立的读写文件fd;由于linux进程最大允许的文件句柄数量是1024,那么select支持的最大连接数量也就是1024,如果承载的连接数量更对,那么只能创建子进程来创建另外的select进行存储承载。后来linux对这块做了提升有了poll与epoll,poll就是将所以文件盘符句柄放在poll对象里面,同时poll还有事件字段,然后交给内核监听的时候,会转换成链表,内核放开了盘符数量的限制(本质上应该是有限制的,只是去掉了文件盘符总数的限制)。然后内核线程需要循环扫描链表的poll事件,因为有连接数据的话会将event置位。【注意:这里讲的select和poll内核进程一直在监听端口,把监听的连接对象的文件句柄信息丢到队列中;IO事件过来也是交给内核父进程来处理文件句柄队列的事件标志位和塞对应数据到对应文件句柄中;然后用户子进程来循环处理这个句柄队列,无论是循环select还是循环poll数组都是用户的子进程来循环的】。对于epoll,创建的连接对象(包括句柄信息)全部放在红黑树里面,而且这个红黑树存储在nmap内存块中,这个内存块是内存映射区域,内核和用户进程均可以访问。所以IO事件回来时候,不需要用户子进程从内核态切换到用户态,而是直接IO事件来了就会将红黑树对应的连接的数据文件句柄放到一个双向的链表中,然后用户子进程去遍历这个链表即可。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐