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

(前置:这里使用的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 指令

在内核层面都叫做进程,但是有些进程是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的倍率来分配的,但是未必能完全使用到,那么空余的会按照回收的时候进行合并。

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

对于网卡驱动是将网络数据进行转换然后运行对应包协议,网卡驱动识别不同得数据包,加载对应的协议包进行拆解包,将数据通过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事件来了就会将红黑树对应的连接的数据文件句柄放到一个双向的链表中,然后用户子进程去遍历这个链表即可。
