kernel base

环境配置

  1. 下载bochs2.6.2

  2. 安装依赖

    1
    2
    3
    4
    sudo apt-get install build-essential
    sudo apt-get install xorg-dev
    sudo apt-get install bison
    sudo apt-get install libgtk2.0-dev
  3. 编译

    1
    2
    3
    4
    5
    6
    7
    8
    ./configure \
    --prefix=/home/sakura/kernel_learn/bochs-2.6.2 \
    --enable-debugger \
    --enable-disasm \
    --enable-iodebug \
    --enable-x86-debugger \
    --with-x \
    --with-x11

    找到Makefile文件LIBS =这句最后面添加上-lpthread

    1
    LIBS =  -lm -lgtk-x11-2.0 -lgdk-x11-2.0 -lpangocairo-1.0 -latk-1.0 -lcairo -lgdk_pixbuf-2.0 -lgio-2.0 -lpangoft2-1.0 -lpango-1.0 -lgobject-2.0 -lglib-2.0 -lfontconfig -lfreetype -lpthread
    1
    sudo make && make install
  4. 编写配置文件,放在/home/sakura/kernel_learn/bochs-2.6.2/bin路径下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    # Configuration file for Bochs
    # 设置Bochs在运行过程中能够使用的内存: 32 MB
    megs: 32

    # 设置真实机器的BIOS和VGA BIOS
    # 修改成你们对应的地址

    romimage: file=/home/sakura/kernel_learn/bochs-2.6.2/share/bochs/BIOS-bochs-latest
    vgaromimage: file=/home/sakura/kernel_learn/bochs-2.6.2/share/bochs/VGABIOS-lgpl-latest

    # 设置Bochs所使用的磁盘
    # 设置启动盘符
    boot: disk

    # 设置日志文件的输出
    log: bochs.out

    # 开启或关闭某些功能,修改成你们对应的地址
    mouse: enabled=0
    keyboard:keymap=/home/sakura/kernel_learn/bochs-2.6.2/share/bochs/keymaps/x11-pc-us.map

    # 硬盘设置
    ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14

    # 增加gdb支持,这里添加会报错,暂时不需要
    # gdbstub: enabled=1, port=1234, text_base=0, data_base=0, bss_base=0
  5. 测试,步骤如图

  6. 增加硬盘,修改配置文件

    1
    sudo ./bximage -hd -mode="flat" -size=60 -q hd60M.img
    1
    2
    //增加这句
    ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63

  7. 再次启动测试

    1
    sudo ./bochs -f bochsrc.disk

编写BootLoader

基础知识

  1. 实模式
    Intel 8086有20条地址线,其可表示的内存范围是1M,也就是0x00000-0xfffff,实模式下的1MB内存布局如下,其中0~0x9FFFF处是DRAM,即动态随机访问内存,我们所装的物理内存就是DRAM,如DDR、DDR2等。顶部的0xF0000~0xFFFFF,这64KB的内存是ROM。

    所以CPU根据不同的地址映射,访问到不同的设备

  2. BIOS
    BIOS即输入输出系统,是按下主机键之后第一个运行的软件,其主要工作有

  • 调用检测、初始化硬件功能
  • 建立中断向量表(IVT)
    • 用于外设访问
  • 校验启动盘中位于0盘0道1扇区的内容
    • 如果此扇区末尾的两个字节分别是魔数 0x55 和 0xaa,BIOS便认为此扇区中确实存在可执行的程序,也就是MBR
    • 注意CHS方式的磁盘并没有0扇区,所以它就是磁盘上最开始的那个扇区
  • 加载MBR(主引导程序)到0x7c00,并跳转过去。
    • MBR的大小是512字节


实模式下CS等段寄存器里保存段基址,CPU加电之后,cs:ip 寄存器被强制初始化为 0xF000:0xFFF0,其访问0xF000 << 4 + 0xFFF0,也就是0xFFFF0,这就是BIOS的入口地址。
这个起始地址距离1MB内存只有16字节大小,所以这里肯定不是真正实现BIOS的地方,这里肯定只是一个类似于函数索引表的跳板,跳转到真正执行BIOS的地方。

  1. 分段地址管理
  • 总览

  • 段寄存器


  • 段描述符和描述符表


  • 寻址

  1. 分页存储管理

MBR

  1. 首先启动BIOS并让其加载一个简单的打印程序到内存的0x7c00执行
    显卡的内存布局
    1
    2
    3
    4
    5
    起始	结束	大小	用途
    C0000 C7FFF 32KB 显示适配器BIOS
    B8000 BFFFF 32KB 用于文本模式显示适配器
    B0000 B7FFF 32KB 用于黑白显示适配器
    A0000 AFFFF 64KB 用于彩色显示适配器
    操作显卡显示文本
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    ;主引导程序 
    ;------------------------------------------------------------
    SECTION MBR vstart=0x7c00
    mov ax,cs
    mov ds,ax
    mov es,ax
    mov ss,ax
    mov fs,ax
    mov sp,0x7c00

    ; 清屏 利用0x06号功能,上卷全部行,则可清屏。
    ; -----------------------------------------------------------
    ;INT 0x10 功能号:0x06 功能描述:上卷窗口
    ;------------------------------------------------------
    ;输入:
    ;AH 功能号= 0x06
    ;AL = 上卷的行数(如果为0,表示全部)
    ;BH = 上卷行属性
    ;(CL,CH) = 窗口左上角的(X,Y)位置
    ;(DL,DH) = 窗口右下角的(X,Y)位置
    ;无返回值:
    mov ax, 0x600
    mov bx, 0x700
    mov cx, 0 ; 左上角: (0, 0)
    mov dx, 0x184f ; 右下角: (80,25),
    ; VGA文本模式中,一行只能容纳80个字符,共25行。
    ; 下标从0开始,所以0x18=24,0x4f=79
    int 0x10 ; int 0x10

    ;;;;;;;;; 下面这三行代码是获取光标位置 ;;;;;;;;;
    ;.get_cursor获取当前光标位置,在光标位置处打印字符.
    mov ah, 3 ; 输入: 3号子功能是获取光标位置,需要存入ah寄存器
    mov bh, 0 ; bh寄存器存储的是待获取光标的页号

    int 0x10 ; 输出: ch=光标开始行,cl=光标结束行
    ; dh=光标所在行号,dl=光标所在列号

    ;;;;;;;;; 获取光标位置结束 ;;;;;;;;;;;;;;;;

    ;;;;;;;;; 打印字符串 ;;;;;;;;;;;
    ;还是用10h中断,不过这次是调用13号子功能打印字符串
    mov ax, message
    mov bp, ax ; es:bp 为串首地址, es此时同cs一致,
    ; 开头时已经为sreg初始化

    ; 光标位置要用到dx寄存器中内容,cx中的光标位置可忽略
    mov cx, 5 ; cx 为串长度,不包括结束符0的字符个数
    mov ax, 0x1301 ; 子功能号13是显示字符及属性,要存入ah寄存器,
    ; al设置写字符方式 ah=01: 显示字符串,光标跟随移动
    mov bx, 0x2 ; bh存储要显示的页号,此处是第0页,
    ; bl中是字符属性, 属性黑底绿字(bl = 02h)
    int 0x10 ; 执行BIOS 0x10 号中断
    ;;;;;;;;; 打字字符串结束 ;;;;;;;;;;;;;;;

    jmp $ ; 使程序悬停在此

    message db "1 MBR"
    times 510-($-$$) db 0
    db 0x55,0xaa
  • $代表当前行的地址,$$代表当前section的地址。
  • vstart=0x7c00代表编译这个section的入口地址为0x7c00
  • int 10是关于打印显示的BIOS例程,通过配置好参数就可以使用int 10来调用它来打印。从地址空间可以看出实际执行的代码在bios里
  • 因为MBR要有512字节,且最后两个字节是0x55,0xaa,所以前面使用510-($-$$)个字节来填充
  1. 编译并写入

    1
    2
    3
    4
    5
    6
    7
    8
    9
    sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ nasm -o mbr.bin mbr.S
    sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ dd if=mbr.bin of=hd60M.img bs=512 count=1 conv=notrunc
    dd: failed to open 'hd60M.img': Permission denied
    sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ ls
    bochs bochs.out bochsrc.disk bxcommit bximage hd60M.img mbr.bin mbr.S
    sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ sudo dd if=mbr.bin of=hd60M.img bs=512 count=1 conv=notrunc
    1+0 records in
    1+0 records out
    512 bytes copied, 0.00164969 s, 310 kB/s




    如图0x7c31就是我们死循环的那一行地址。
    这样我们就简单的让BIOS加载了一个程序到内存并执行,这个程序就是MBR。

  2. 完善MBR
    MBR只有512字节,无法实现对内核的加载,MBR需要从硬盘加载loader到内存,然后用loader加载内核。

  • 编写MBR,其效果是读硬盘然后将loader加载到0x900处

注意vstart的前几句对段寄存器的赋值,因为跳转到mbr会将cs寄存器赋值为0,然后用其初始化其他几个段寄存器,就是将其都置为0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
;主引导程序 
;------------------------------------------------------------
%include "boot.inc"
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800
mov gs,ax

;清屏
;利用0x06号功能,上卷全部行,则可清屏。
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0600h
mov bx, 0700h
mov cx, 0 ; 左上角: (0, 0)
mov dx, 184fh ; 右下角: (80,25),
; 因为VGA文本模式中,一行只能容纳80个字符,共25行。
; 下标从0开始,所以0x18=24,0x4f=79
int 10h ; int 10h

; 输出字符串:MBR
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4

mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4

mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4 ;A表示绿色背景闪烁,4表示前景色为红色

mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4

mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4
; 寄存器传三个参数
mov eax,LOADER_START_SECTOR ; 起始扇区LBA地址
mov bx,LOADER_BASE_ADDR ; 写入的地址
mov cx,1 ; 待读入的扇区数,这里是简单的loader故一个扇区足够
call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)

jmp LOADER_BASE_ADDR

;-------------------------------------------------------------------------------
;功能:读取硬盘n个扇区
rd_disk_m_16:
;-------------------------------------------------------------------------------
; eax=LBA扇区号
; ebx=将数据写入的内存地址
; ecx=读入的扇区数
mov esi,eax ;备份eax
mov di,cx ;备份cx
;读写硬盘:
;第1步:选择通道,往该通道的sector count寄存器中写入待操作的扇区数
;因为bochs配置文件中虚拟硬盘属于ata0,是Primary通道,所以sector count寄存器由0x1f2端口访问
mov dx,0x1f2
mov al,cl
out dx,al ;读取的扇区数
;out 往端口中写数据
;in 从端口中读数据

mov eax,esi ;恢复ax

;第2步:将LBA地址写入三个LBA寄存器和device寄存器的低4位

;LBA地址7~0位写入端口0x1f3
mov dx,0x1f3
out dx,al

;LBA地址15~8位写入端口0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al

;LBA地址23~16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al

shr eax,cl
and al,0x0f ; lba第24~27位
or al,0xe0 ; 设置7~4位为1110,表示lba模式
mov dx,0x1f6
out dx,al

;第3步:向command寄存器写入读命令,0x20
mov dx,0x1f7 ;要写入的端口
mov al,0x20 ;要写入的数据
out dx,al

;第4步:检测硬盘状态,读取该通道上的status寄存器,判断硬盘工作是否完成
.not_ready:
;同一端口,写时表示写入命令字,读时表示读入硬盘状态
nop
in al,dx
and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备好,继续等。

;第5步:从0x1f0端口读数据
mov ax, di
mov dx, 256
mul dx
mov cx, ax ; di为要读取的扇区数,一个扇区有512字节,每次读入一个字,
; 共需di*512/2次,所以di*256
mov dx, 0x1f0
.go_on_read: ; 循环写入bx指向的内存
in ax,dx
mov [bx],ax
add bx,2
loop .go_on_read
ret

times 510-($-$$) db 0
db 0x55,0xaa

boot.inc

1
2
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2
  • 写一个简单的loader,实现bios->mbr->loader的跳转执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    %include "boot.inc"
    section loader vstart=LOADER_BASE_ADDR

    mov byte [gs:0x00],'2'
    mov byte [gs:0x01],0xA4

    mov byte [gs:0x02],' '
    mov byte [gs:0x03],0xA4

    mov byte [gs:0x04],'L'
    mov byte [gs:0x05],0xA4

    mov byte [gs:0x06],'O'
    mov byte [gs:0x07],0xA4

    mov byte [gs:0x08],'A'
    mov byte [gs:0x09],0xA4

    mov byte [gs:0x0a],'D'
    mov byte [gs:0x0b],0xA4

    mov byte [gs:0x0c],'E'
    mov byte [gs:0x0d],0xA4

    mov byte [gs:0x0e],'R'
    mov byte [gs:0x0f],0xA4

    jmp $
  • 测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    akura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ nasm -I include/ -o mbr.bin mbr.S
    sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ sudo dd if=mbr.bin of=hd60M.img bs=512 count=1 conv=notrunc
    1+0 records in
    1+0 records out
    512 bytes copied, 0.000347796 s, 1.5 MB/s
    ...
    //注意这里写到第二个扇区,seek=2
    sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ nasm -I include/ -o loader.bin loader.S
    sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ sudo dd if=loader.bin of=hd60M.img bs=512 count=1 seek=2 conv=notrunc
    0+1 records in
    0+1 records out
    98 bytes copied, 0.000230929 s, 424 kB/s

  • 总结
    总结一下MBR的功能,其实就是从硬盘加载loader程序到一个可用的内存地址,然后jmp LOADER_BASE_ADDR到该地址执行loader

loader-进入保护模式

CPU加电之后其实是运行在实模式下的,实模式可访问的地址空间只有1MB,且有很多缺陷,所以我们的loader需要先启动保护模式,开启分段机制。
关于分段存储管理的理论基础见上。

  • 实模式的安全缺陷总结:

    • 操作系统和用户属于同一特权级
    • 用户程序引用的地址都是指向真实的物理地址
    • 用户程序可以自由修改段基址,自由访问所有内存
  • loader的主要功能如下:

    • 建立分段机制


    • 通过LGDT来给GDTR寄存器赋值,其保存GDT的地址
      lgdt的指令格式是:lgdt48位内存数据。
    • 打开A20地址线
    • 打开CR0寄存器的PE位

  • 修改boot.inc,定义一些符号/宏

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    ;--------------------- loader 和 kernel---------------------

    LOADER_BASE_ADDR equ 0x900
    LOADER_START_SECTOR equ 0x2

    ;-------------------- gdt 描述符属性 ----------------------
    DESC_G_4K equ 1_00000000000000000000000b ;描述符的G位为4k粒度,以二进制表示,下划线可去掉
    DESC_D_32 equ 1_0000000000000000000000b
    DESC_L equ 0_000000000000000000000b ;64位代码标记,此处标记为0便可
    DESC_AVL equ 0_00000000000000000000b ;CPU不用此位,暂置为0
    DESC_LIMIT_CODE2 equ 1111_0000000000000000b ;段界限,需要设置为0xFFFFF
    DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
    DESC_LIMIT_VIDEO2 equ 0000_000000000000000b
    DESC_P equ 1_000000000000000b
    DESC_DPL_0 equ 00_0000000000000b
    DESC_DPL_1 equ 01_0000000000000b
    DESC_DPL_2 equ 10_0000000000000b
    DESC_DPL_3 equ 11_0000000000000b
    DESC_S_CODE equ 1_000000000000b
    DESC_S_DATA equ DESC_S_CODE
    DESC_S_sys equ 0_000000000000b
    DESC_TYPE_CODE equ 1000_00000000b ;x=1,c=0,r=0,a=0 代码段是可执行的,非一致性,不可读,已访问位a清0.
    DESC_TYPE_DATA equ 0010_00000000b ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写,已访问位a清0.

    DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00 ;定义代码段的高四字节,(0x00 << 24)表示"段基址的24~31"字段,该字段位于段描述符高四字节24~31位,平坦模式段基址为0,所以这里用0填充,最后的0x00也是
    DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
    DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b

    ;-------------- 选择子属性 ---------------
    RPL0 equ 00b
    RPL1 equ 01b
    RPL2 equ 10b
    RPL3 equ 11b
    TI_GDT equ 000b
    TI_LDT equ 100b
  • 修改loader.S

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
       %include "boot.inc"
    section loader vstart=LOADER_BASE_ADDR
    LOADER_STACK_TOP equ LOADER_BASE_ADDR
    jmp loader_start ; 此处的物理地址是:

    ;构建gdt及其内部的描述符
    GDT_BASE: dd 0x00000000
    dd 0x00000000

    CODE_DESC: dd 0x0000FFFF
    dd DESC_CODE_HIGH4

    DATA_STACK_DESC: dd 0x0000FFFF
    dd DESC_DATA_HIGH4

    VIDEO_DESC: dd 0x80000007 ;limit=(0xbffff-0xb8000)/4k=0x7
    dd DESC_VIDEO_HIGH4 ; 此时dpl已改为0

    GDT_SIZE equ $ - GDT_BASE
    GDT_LIMIT equ GDT_SIZE - 1
    times 60 dq 0 ; 此处预留60个描述符的slot
    SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
    SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
    SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上

    ;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址

    gdt_ptr dw GDT_LIMIT
    dd GDT_BASE
    loadermsg db '2 loader in real.'

    loader_start:

    ;------------------------------------------------------------
    ;INT 0x10 功能号:0x13 功能描述:打印字符串
    ;------------------------------------------------------------
    ;输入:
    ;AH 子功能号=13H
    ;BH = 页码
    ;BL = 属性(若AL=00H或01H)
    ;CX=字符串长度
    ;(DH、DL)=坐标(行、列)
    ;ES:BP=字符串地址
    ;AL=显示输出方式
    ; 0——字符串中只含显示字符,其显示属性在BL中。显示后,光标位置不变
    ; 1——字符串中只含显示字符,其显示属性在BL中。显示后,光标位置改变
    ; 2——字符串中含显示字符和显示属性。显示后,光标位置不变
    ; 3——字符串中含显示字符和显示属性。显示后,光标位置改变
    ;无返回值
    mov sp, LOADER_BASE_ADDR
    mov bp, loadermsg ; ES:BP = 字符串地址
    mov cx, 17 ; CX = 字符串长度
    mov ax, 0x1301 ; AH = 13, AL = 01h
    mov bx, 0x001f ; 页号为0(BH = 0) 蓝底粉红字(BL = 1fh)
    mov dx, 0x1800 ;
    int 0x10 ; 10h 号中断

    ;---------------------------------------- 准备进入保护模式 ------------------------------------------
    ;1 打开A20
    ;2 加载gdt
    ;3 将cr0的pe位置1


    ;----------------- 打开A20 ----------------
    in al,0x92
    or al,0000_0010B
    out 0x92,al

    ;----------------- 加载GDT ----------------
    lgdt [gdt_ptr]


    ;----------------- cr0第0位置1 ----------------
    mov eax, cr0
    or eax, 0x00000001
    mov cr0, eax

    ;jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
    jmp SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
    ; 这将导致之前做的预测失效,从而起到了刷新的作用。

    [bits 32]
    p_mode_start:
    mov ax, SELECTOR_DATA
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov esp,LOADER_STACK_TOP
    mov ax, SELECTOR_VIDEO
    mov gs, ax

    mov byte [gs:160], 'P'

    jmp $
  • 修改mbr.S
    因为现在要读入的是4个扇区

    1
    2
    3
    4
    ...
    52 mov cx, 4 ; 带读入的扇区数
    53 call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)
    ...
  • 测试

    1
    2
    3
    4
    5
    nasm -I include/ -o mbr.bin mbr.S
    sudo dd if=mbr.bin of=hd60M.img bs=512 count=1 conv=notrunc
    nasm -I include/ -o loader.bin loader.S
    sudo dd if=loader.bin of=hd60M.img bs=512 count=2 seek=2 conv=notrunc
    sudo ./bochs -f bochsrc.disk


  • 内存段的保护

loader-启用分页机制

由于分段时使用的线性地址会被直接用作物理地址,所以为了实现虚拟地址这一层抽象,需要提供分页机制。

其实关于分页,要记住的最重要的就是,这个东西是为了把线性地址转物理地址的时候再加一层抽象,让不同进程相同的线性地址被转成不同的物理地址,所以不同的进程,它的页表其实是不同的。

  1. 准备好页目录表及页表


    只有第 1231 位才是物理 地址,这才 20 位。按理说 32 位地址应该用 32 位来表示啊,否则不就误差严重了吗。是这样的,因为页目录项和页表项中的都是物理页地址,标准页大小是 4KB,故地址都是 4K 的倍数,也就是地址的低 12 位是 0,所以只需要记录物理地址高 20 位就可以啦。这样省出来的 12 位(第 011 位)可以用来添加其 他属性,下面对这些属性从低到高逐位介绍。

    页目录表的位置,我们就放在物理地址 0x100000 处。为了让页表和页目录表紧凑一些(这不是必须的),咱们让页表紧挨着页目录表。页目录本身占 4KB,所以第一个页表的物理地址是 0x101000。

    • 将第0和第786项页目录项都指向第一个页表
    • 将最后一个目录项指向页目录表自己的地址,这其实是出于一个自映射的目的,将最高页表项指向页目录表自己的基地址,就可以用0xfffffxxx这样的虚拟地址来访问每个页目录表项了:因为高20位都是1,所以会先根据前10位来选到最高的页目录项,其指向页目录表基地址,然后再根据后10位又选到了最高的页目录项,其指向页目录表基地址,最后xxx是12位,可以在4KB的偏移里随意访问,这样就可以访问每个页目录表项
    • 创建第一个页表,紧邻页目录表,里面有1024个页表项,但只使用256个页表项,每个页表项表示4KB内存,其将表示最低的1MB内存
    • 创建代表内核空间的其他页目录项

      这个768很好算,其实是这样的,首先每个页目录项代表4MB,从3GB开始是内核空间,所以3GB/4MB=786,从786开始都是内核地址空间。
  2. 将页表的物理地址写入控制寄存器cr3
    控制寄存器 cr3 用于存储页表物理地址,所以cr3寄存器又称为页目录基址寄存器(Page Directory Base Register,PDBR)。

在将PG位置1之前,系统都是在内存分段机制下工作,段部件输出的线性地址便直接是物理地址,也就意味着在第2步中,cr3寄存器中的页表地址是真实的物理地址。

  1. 寄存器cr0的PG位置1
  2. 测试
  • loader.S
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
       %include "boot.inc"
    section loader vstart=LOADER_BASE_ADDR
    LOADER_STACK_TOP equ LOADER_BASE_ADDR

    ;构建gdt及其内部的描述符
    GDT_BASE: dd 0x00000000
    dd 0x00000000

    CODE_DESC: dd 0x0000FFFF
    dd DESC_CODE_HIGH4

    DATA_STACK_DESC: dd 0x0000FFFF
    dd DESC_DATA_HIGH4

    VIDEO_DESC: dd 0x80000007 ; limit=(0xbffff-0xb8000)/4k=0x7
    dd DESC_VIDEO_HIGH4 ; 此时dpl为0

    GDT_SIZE equ $ - GDT_BASE
    GDT_LIMIT equ GDT_SIZE - 1
    times 60 dq 0 ; 此处预留60个描述符的空位(slot)
    SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
    SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
    SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上

    ; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
    ; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
    ; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
    total_mem_bytes dd 0
    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

    ;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
    gdt_ptr dw GDT_LIMIT
    dd GDT_BASE

    ;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
    ards_buf times 244 db 0
    ards_nr dw 0 ;用于记录ards结构体数量

    loader_start:

    ;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------

    xor ebx, ebx ;第一次调用时,ebx值要为0
    mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
    mov di, ards_buf ;ards结构缓冲区
    .e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
    mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
    mov ecx, 20 ;ARDS地址范围描述符结构大小是20字节
    int 0x15
    jc .e820_failed_so_try_e801 ;若cf位为1则有错误发生,尝试0xe801子功能
    add di, cx ;使di增加20字节指向缓冲区中新的ARDS结构位置
    inc word [ards_nr] ;记录ARDS数量
    cmp ebx, 0 ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
    jnz .e820_mem_get_loop

    ;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
    mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是ARDS的数量
    mov ebx, ards_buf
    xor edx, edx ;edx为最大的内存容量,在此先清0
    .find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
    mov eax, [ebx] ;base_add_low
    add eax, [ebx+8] ;length_low
    add ebx, 20 ;指向缓冲区中下一个ARDS结构
    cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
    jge .next_ards
    mov edx, eax ;edx为总内存大小
    .next_ards:
    loop .find_max_mem_area
    jmp .mem_get_ok

    ;------ int 15h ax = E801h 获取内存大小,最大支持4G ------
    ; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
    ; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
    .e820_failed_so_try_e801:
    mov ax,0xe801
    int 0x15
    jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法

    ;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
    mov cx,0x400 ;cx和ax值一样,cx用做乘数
    mul cx
    shl edx,16
    and eax,0x0000FFFF
    or edx,eax
    add edx, 0x100000 ;ax只是15MB,故要加1MB
    mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份

    ;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
    xor eax,eax
    mov ax,bx
    mov ecx, 0x10000 ;0x10000十进制为64KB
    mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
    add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
    mov edx,esi ;edx为总内存大小
    jmp .mem_get_ok

    ;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
    .e801_failed_so_try88:
    ;int 15后,ax存入的是以kb为单位的内存容量
    mov ah, 0x88
    int 0x15
    jc .error_hlt
    and eax,0x0000FFFF

    ;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
    mov cx, 0x400 ;0x400等于1024,将ax中的内存容量换为以byte为单位
    mul cx
    shl edx, 16 ;把dx移到高16位
    or edx, eax ;把积的低16位组合到edx,为32位的积
    add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

    .mem_get_ok:
    mov [total_mem_bytes], edx ;将内存换为byte单位后存入total_mem_bytes处。


    ;----------------- 准备进入保护模式 -------------------
    ;1 打开A20
    ;2 加载gdt
    ;3 将cr0的pe位置1

    ;----------------- 打开A20 ----------------
    in al,0x92
    or al,0000_0010B
    out 0x92,al

    ;----------------- 加载GDT ----------------
    lgdt [gdt_ptr]

    ;----------------- cr0第0位置1 ----------------
    mov eax, cr0
    or eax, 0x00000001
    mov cr0, eax

    jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
    ; 这将导致之前做的预测失效,从而起到了刷新的作用。
    .error_hlt: ;出错则挂起
    hlt

    [bits 32]
    p_mode_start:
    mov ax, SELECTOR_DATA
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov esp,LOADER_STACK_TOP
    mov ax, SELECTOR_VIDEO
    mov gs, ax

    ; 创建页目录及页表并初始化页内存位图
    call setup_page

    ;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
    sgdt [gdt_ptr] ; 存储到原来gdt所有的位置

    ;将gdt描述符中视频段描述符中的段基址+0xc0000000
    mov ebx, [gdt_ptr + 2]
    or dword [ebx + 0x18 + 4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故0x18。
    ;段描述符的高4字节的最高位是段基址的31~24位

    ;将gdt的基址加上0xc0000000使其成为内核所在的高地址
    add dword [gdt_ptr + 2], 0xc0000000

    add esp, 0xc0000000 ; 将栈指针同样映射到内核地址

    ; 把页目录地址赋给cr3
    mov eax, PAGE_DIR_TABLE_POS
    mov cr3, eax

    ; 打开cr0的pg位(第31位)
    mov eax, cr0
    or eax, 0x80000000
    mov cr0, eax

    ;在开启分页后,用gdt新的地址重新加载
    lgdt [gdt_ptr] ; 重新加载

    mov byte [gs:160], 'V' ;视频段段基址已经被更新,用字符v表示virtual addr

    jmp $

    ;------------- 创建页目录及页表 ---------------
    setup_page:
    ;先把页目录占用的空间逐字节清0
    mov ecx, 4096
    mov esi, 0
    .clear_page_dir:
    mov byte [PAGE_DIR_TABLE_POS + esi], 0
    inc esi
    loop .clear_page_dir

    ;开始创建页目录项(PDE)
    .create_pde: ; 创建Page Directory Entry
    mov eax, PAGE_DIR_TABLE_POS
    add eax, 0x1000 ; 此时eax为第一个页表的位置及属性
    mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址。

    ; 下面将页目录项0和0xc00都存为第一个页表的地址,
    ; 一个页表可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,
    ; 这是为将地址映射为内核地址做准备
    or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
    mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)
    mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 一个页表项占用4字节,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
    ; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
    sub eax, 0x1000
    mov [PAGE_DIR_TABLE_POS + 4092], eax ; 使最后一个目录项指向页目录表自己的地址

    ;下面创建页表项(PTE)
    mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256
    mov esi, 0
    mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1
    .create_pte: ; 创建Page Table Entry
    mov [ebx+esi*4],edx ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
    add edx,4096
    inc esi
    loop .create_pte

    ;创建内核其它页表的PDE
    mov eax, PAGE_DIR_TABLE_POS
    add eax, 0x2000 ; 此时eax为第二个页表的位置
    or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性US,RW和P位都为1
    mov ebx, PAGE_DIR_TABLE_POS
    mov ecx, 254 ; 范围为第769~1022的所有目录项数量
    mov esi, 769
    .create_kernel_pde:
    mov [ebx+esi*4], eax
    inc esi
    add eax, 0x1000
    loop .create_kernel_pde
    ret
  • boot.inc
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    ;-------------	 loader和kernel   ----------

    LOADER_BASE_ADDR equ 0x900
    LOADER_START_SECTOR equ 0x2
    KERNEL_BIN_BASE_ADDR equ 0x70000
    KERNEL_IMAGE_BASE_ADDR equ 0x1500
    KERNEL_START_SECTOR equ 0x9

    PAGE_DIR_TABLE_POS equ 0x100000

    ;-------------- gdt描述符属性 -------------
    DESC_G_4K equ 1_00000000000000000000000b
    DESC_D_32 equ 1_0000000000000000000000b
    DESC_L equ 0_000000000000000000000b ; 64位代码标记,此处标记为0便可。
    DESC_AVL equ 0_00000000000000000000b ; cpu不用此位,暂置为0
    DESC_LIMIT_CODE2 equ 1111_0000000000000000b
    DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
    DESC_LIMIT_VIDEO2 equ 0000_000000000000000b
    DESC_P equ 1_000000000000000b
    DESC_DPL_0 equ 00_0000000000000b
    DESC_DPL_1 equ 01_0000000000000b
    DESC_DPL_2 equ 10_0000000000000b
    DESC_DPL_3 equ 11_0000000000000b
    DESC_S_CODE equ 1_000000000000b
    DESC_S_DATA equ DESC_S_CODE
    DESC_S_sys equ 0_000000000000b
    DESC_TYPE_CODE equ 1000_00000000b ;x=1,c=0,r=0,a=0 代码段是可执行的,非依从的,不可读的,已访问位a清0.
    DESC_TYPE_DATA equ 0010_00000000b ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写的,已访问位a清0.

    DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
    DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
    DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b

    ;-------------- 选择子属性 ---------------
    RPL0 equ 00b
    RPL1 equ 01b
    RPL2 equ 10b
    RPL3 equ 11b
    TI_GDT equ 000b
    TI_LDT equ 100b


    ;---------------- 页表相关属性 --------------
    PG_P equ 1b
    PG_RW_R equ 00b
    PG_RW_W equ 10b
    PG_US_S equ 000b
    PG_US_U equ 100b
  • mbr.S
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    ;主引导程序 
    ;------------------------------------------------------------
    %include "boot.inc"
    SECTION MBR vstart=0x7c00
    mov ax,cs
    mov ds,ax
    mov es,ax
    mov ss,ax
    mov fs,ax
    mov sp,0x7c00
    mov ax,0xb800
    mov gs,ax

    ; 清屏
    ;利用0x06号功能,上卷全部行,则可清屏。
    ; -----------------------------------------------------------
    ;INT 0x10 功能号:0x06 功能描述:上卷窗口
    ;------------------------------------------------------
    ;输入:
    ;AH 功能号= 0x06
    ;AL = 上卷的行数(如果为0,表示全部)
    ;BH = 上卷行属性
    ;(CL,CH) = 窗口左上角的(X,Y)位置
    ;(DL,DH) = 窗口右下角的(X,Y)位置
    ;无返回值:
    mov ax, 0600h
    mov bx, 0700h
    mov cx, 0 ; 左上角: (0, 0)
    mov dx, 184fh ; 右下角: (80,25),
    ; 因为VGA文本模式中,一行只能容纳80个字符,共25行。
    ; 下标从0开始,所以0x18=24,0x4f=79
    int 10h ; int 10h

    ; 输出字符串:MBR
    mov byte [gs:0x00],'1'
    mov byte [gs:0x01],0xA4

    mov byte [gs:0x02],' '
    mov byte [gs:0x03],0xA4

    mov byte [gs:0x04],'M'
    mov byte [gs:0x05],0xA4 ;A表示绿色背景闪烁,4表示前景色为红色

    mov byte [gs:0x06],'B'
    mov byte [gs:0x07],0xA4

    mov byte [gs:0x08],'R'
    mov byte [gs:0x09],0xA4

    mov eax,LOADER_START_SECTOR ; 起始扇区lba地址
    mov bx,LOADER_BASE_ADDR ; 写入的地址
    mov cx,4 ; 待读入的扇区数
    call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)

    jmp LOADER_BASE_ADDR + 0x300

    ;-------------------------------------------------------------------------------
    ;功能:读取硬盘n个扇区
    rd_disk_m_16:
    ;-------------------------------------------------------------------------------
    ; eax=LBA扇区号
    ; ebx=将数据写入的内存地址
    ; ecx=读入的扇区数
    mov esi,eax ;备份eax
    mov di,cx ;备份cx
    ;读写硬盘:
    ;第1步:设置要读取的扇区数
    mov dx,0x1f2
    mov al,cl
    out dx,al ;读取的扇区数

    mov eax,esi ;恢复ax

    ;第2步:将LBA地址存入0x1f3 ~ 0x1f6

    ;LBA地址7~0位写入端口0x1f3
    mov dx,0x1f3
    out dx,al

    ;LBA地址15~8位写入端口0x1f4
    mov cl,8
    shr eax,cl
    mov dx,0x1f4
    out dx,al

    ;LBA地址23~16位写入端口0x1f5
    shr eax,cl
    mov dx,0x1f5
    out dx,al

    shr eax,cl
    and al,0x0f ;lba第24~27位
    or al,0xe0 ; 设置7~4位为1110,表示lba模式
    mov dx,0x1f6
    out dx,al

    ;第3步:向0x1f7端口写入读命令,0x20
    mov dx,0x1f7
    mov al,0x20
    out dx,al

    ;第4步:检测硬盘状态
    .not_ready:
    ;同一端口,写时表示写入命令字,读时表示读入硬盘状态
    nop
    in al,dx
    and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
    cmp al,0x08
    jnz .not_ready ;若未准备好,继续等。

    ;第5步:从0x1f0端口读数据
    mov ax, di
    mov dx, 256
    mul dx
    mov cx, ax ; di为要读取的扇区数,一个扇区有512字节,每次读入一个字,
    ; 共需di*512/2次,所以di*256
    mov dx, 0x1f0
    .go_on_read:
    in ax,dx
    mov [bx],ax
    add bx,2
    loop .go_on_read
    ret

    times 510-($-$$) db 0
    db 0x55,0xaa
  • 编译和写入硬盘
    1
    2
    3
    4
    nasm -I include/ -o loader.bin loader.S
    nasm -I include/ -o mbr.bin mbr.S
    sudo dd if=loader.bin of=hd60M.img bs=512 count=3 seek=2 conv=notrunc
    sudo dd if=mbr.bin of=hd60M.img bs=512 count=1 conv=notrunc

这里需要注意的是,现在loader.bin的大小是1189字节了,2个扇区不够了,所以写入的count为3.

1
2
3
4
5
6
7
<bochs:2> info tab
cr3: 0x000000100000
0x00000000-0x000fffff -> 0x000000000000-0x0000000fffff
0xc0000000-0xc00fffff -> 0x000000000000-0x0000000fffff
0xffc00000-0xffc00fff -> 0x000000101000-0x000000101fff
0xfff00000-0xffffefff -> 0x000000101000-0x0000001fffff
0xfffff000-0xffffffff -> 0x000000100000-0x000000100fff


可以看出虚拟地址已经和物理地址映射好了,3G的虚拟地址被映射到0x0的物理地址处了。

loader-加载内核

对于编译出来的obj文件,其是没有被重定位的,也就是符号(变量名,函数名)的地址尚未确定。

1
2
3
4
5
6
7
8
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ gcc -m32 -c -o kernel/main.o kernel/main.c
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ cd kernel/
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin/kernel$ ls
main.c main.o
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin/kernel$ file main.o
main.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin/kernel$ nm main.o
00000000 T main

经过链接之后

1
2
3
4
5
6
7
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ ld kernel/main.o -m elf_i386 -Ttext 0xc0001500 -e main -o kernel/kernel.bin
...
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ nm kernel/kernel.bin
c000253c R __bss_start
c000253c R _edata
c000253c R _end
c0001500 T main

内核其实也是一个elf文件,要加载一个elf文件,我们就要先理解elf文件的格式

elf文件格式

可执行程序的elf文件包括elf文件头/程序头/段体/节头/节体等

  • elf文件头






  • program header

程序头主要保存segment信息

内核加载步骤

  • 加载内核:需要把内核文件加载到内存缓冲区。

    • 这是因为elf在载入内存不是需要对每个段都进行加载,所以要先读出到缓冲区
  • 初始化内核:需要在分页后,将加载进来的elf内核文件安置到相应的虚拟内存地址,然后跳过去执行,从此loader的工作结束。

    • 根据elf文件头
      • 找到程序头表在文件里的偏移,记录程序头表地址
      • 读出程序头表中的program header数量
      • 读出程序头表中的每个program header表项的大小,即e_phentsize
    • 根据程序头表
      • 从程序头表基地址,循环读取每个程序头
      • 读取p_filesz,找到本段在文件中的大小
      • 读取p_offset,找到在文件中的起始偏移
      • 读取p_vaddr,找到写入到内存的起始虚拟地址
  • 最终的内存布局(物理内存角度)

  • 最终的loader.S和boot.inc

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
       %include "boot.inc"
    section loader vstart=LOADER_BASE_ADDR
    ;构建gdt及其内部的描述符
    GDT_BASE: dd 0x00000000
    dd 0x00000000

    CODE_DESC: dd 0x0000FFFF
    dd DESC_CODE_HIGH4

    DATA_STACK_DESC: dd 0x0000FFFF
    dd DESC_DATA_HIGH4

    VIDEO_DESC: dd 0x80000007 ; limit=(0xbffff-0xb8000)/4k=0x7
    dd DESC_VIDEO_HIGH4 ; 此时dpl为0

    GDT_SIZE equ $ - GDT_BASE
    GDT_LIMIT equ GDT_SIZE - 1
    times 60 dq 0 ; 此处预留60个描述符的空位(slot)
    SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
    SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
    SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上

    ; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
    ; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
    ; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
    total_mem_bytes dd 0
    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

    ;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
    gdt_ptr dw GDT_LIMIT
    dd GDT_BASE

    ;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
    ards_buf times 244 db 0
    ards_nr dw 0 ;用于记录ards结构体数量

    loader_start:

    ;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------

    xor ebx, ebx ;第一次调用时,ebx值要为0
    mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
    mov di, ards_buf ;ards结构缓冲区
    .e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
    mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
    mov ecx, 20 ;ARDS地址范围描述符结构大小是20字节
    int 0x15
    jc .e820_failed_so_try_e801 ;若cf位为1则有错误发生,尝试0xe801子功能
    add di, cx ;使di增加20字节指向缓冲区中新的ARDS结构位置
    inc word [ards_nr] ;记录ARDS数量
    cmp ebx, 0 ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
    jnz .e820_mem_get_loop

    ;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
    mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是ARDS的数量
    mov ebx, ards_buf
    xor edx, edx ;edx为最大的内存容量,在此先清0
    .find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
    mov eax, [ebx] ;base_add_low
    add eax, [ebx+8] ;length_low
    add ebx, 20 ;指向缓冲区中下一个ARDS结构
    cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
    jge .next_ards
    mov edx, eax ;edx为总内存大小
    .next_ards:
    loop .find_max_mem_area
    jmp .mem_get_ok

    ;------ int 15h ax = E801h 获取内存大小,最大支持4G ------
    ; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
    ; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
    .e820_failed_so_try_e801:
    mov ax,0xe801
    int 0x15
    jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法

    ;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
    mov cx,0x400 ;cx和ax值一样,cx用做乘数
    mul cx
    shl edx,16
    and eax,0x0000FFFF
    or edx,eax
    add edx, 0x100000 ;ax只是15MB,故要加1MB
    mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份

    ;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
    xor eax,eax
    mov ax,bx
    mov ecx, 0x10000 ;0x10000十进制为64KB
    mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
    add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
    mov edx,esi ;edx为总内存大小
    jmp .mem_get_ok

    ;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
    .e801_failed_so_try88:
    ;int 15后,ax存入的是以kb为单位的内存容量
    mov ah, 0x88
    int 0x15
    jc .error_hlt
    and eax,0x0000FFFF

    ;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
    mov cx, 0x400 ;0x400等于1024,将ax中的内存容量换为以byte为单位
    mul cx
    shl edx, 16 ;把dx移到高16位
    or edx, eax ;把积的低16位组合到edx,为32位的积
    add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

    .mem_get_ok:
    mov [total_mem_bytes], edx ;将内存换为byte单位后存入total_mem_bytes处。


    ;----------------- 准备进入保护模式 -------------------
    ;1 打开A20
    ;2 加载gdt
    ;3 将cr0的pe位置1

    ;----------------- 打开A20 ----------------
    in al,0x92
    or al,0000_0010B
    out 0x92,al

    ;----------------- 加载GDT ----------------
    lgdt [gdt_ptr]

    ;----------------- cr0第0位置1 ----------------
    mov eax, cr0
    or eax, 0x00000001
    mov cr0, eax

    jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
    ; 这将导致之前做的预测失效,从而起到了刷新的作用。
    .error_hlt: ;出错则挂起
    hlt

    [bits 32]
    p_mode_start:
    mov ax, SELECTOR_DATA
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov esp,LOADER_STACK_TOP
    mov ax, SELECTOR_VIDEO
    mov gs, ax

    ; ------------------------- 加载kernel ----------------------
    mov eax, KERNEL_START_SECTOR ; kernel.bin所在的扇区号
    mov ebx, KERNEL_BIN_BASE_ADDR ; 从磁盘读出后,写入到ebx指定的地址
    mov ecx, 200 ; 读入的扇区数

    call rd_disk_m_32

    ; 创建页目录及页表并初始化页内存位图
    call setup_page

    ;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
    sgdt [gdt_ptr] ; 存储到原来gdt所有的位置

    ;将gdt描述符中视频段描述符中的段基址+0xc0000000
    mov ebx, [gdt_ptr + 2]
    or dword [ebx + 0x18 + 4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故0x18。
    ;段描述符的高4字节的最高位是段基址的31~24位

    ;将gdt的基址加上0xc0000000使其成为内核所在的高地址
    add dword [gdt_ptr + 2], 0xc0000000

    add esp, 0xc0000000 ; 将栈指针同样映射到内核地址

    ; 把页目录地址赋给cr3
    mov eax, PAGE_DIR_TABLE_POS
    mov cr3, eax

    ; 打开cr0的pg位(第31位)
    mov eax, cr0
    or eax, 0x80000000
    mov cr0, eax

    ;在开启分页后,用gdt新的地址重新加载
    lgdt [gdt_ptr] ; 重新加载

    ;;;;;;;;;;;;;;;;;;;;;;;;;;;; 此时不刷新流水线也没问题 ;;;;;;;;;;;;;;;;;;;;;;;;
    ;由于一直处在32位下,原则上不需要强制刷新,经过实际测试没有以下这两句也没问题.
    ;但以防万一,还是加上啦,免得将来出来莫句奇妙的问题.
    jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新gdt
    enter_kernel:
    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    call kernel_init
    mov esp, 0xc009f000
    jmp KERNEL_ENTRY_POINT ; 用地址0x1500访问测试,结果ok


    ;----------------- 将kernel.bin中的segment拷贝到编译的地址 -----------
    kernel_init:
    xor eax, eax
    xor ebx, ebx ;ebx记录程序头表地址
    xor ecx, ecx ;cx记录程序头表中的program header数量
    xor edx, edx ;dx 记录program header尺寸,即e_phentsize

    mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移文件42字节处的属性是e_phentsize,表示program header大小
    mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件开始部分28字节的地方是e_phoff,表示第1 个program header在文件中的偏移量
    add ebx, KERNEL_BIN_BASE_ADDR
    mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
    .each_segment:
    cmp byte [ebx + 0], PT_NULL ; 若p_type等于 PT_NULL,说明此program header未使用。
    je .PTNULL

    ;为函数memcpy压入参数,参数是从右往左依然压入.函数原型类似于 memcpy(dst,src,size)
    push dword [ebx + 16] ; program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数:size
    mov eax, [ebx + 4] ; 距程序头偏移量为4字节的位置是p_offset
    add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址
    push eax ; 压入函数memcpy的第二个参数:源地址
    push dword [ebx + 8] ; 压入函数memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr,这就是目的地址
    call mem_cpy ; 调用mem_cpy完成段复制
    add esp,12 ; 清理栈中压入的三个参数
    .PTNULL:
    add ebx, edx ; edx为program header大小,即e_phentsize,在此ebx指向下一个program header
    loop .each_segment
    ret

    ;---------- 逐字节拷贝 mem_cpy(dst,src,size) ------------
    ;输入:栈中三个参数(dst,src,size)
    ;输出:无
    ;---------------------------------------------------------
    mem_cpy:
    cld
    push ebp
    mov ebp, esp
    push ecx ; rep指令用到了ecx,但ecx对于外层段的循环还有用,故先入栈备份
    mov edi, [ebp + 8] ; dst
    mov esi, [ebp + 12] ; src
    mov ecx, [ebp + 16] ; size
    rep movsb ; 逐字节拷贝

    ;恢复环境
    pop ecx
    pop ebp
    ret


    ;------------- 创建页目录及页表 ---------------
    setup_page:
    ;先把页目录占用的空间逐字节清0
    mov ecx, 4096
    mov esi, 0
    .clear_page_dir:
    mov byte [PAGE_DIR_TABLE_POS + esi], 0
    inc esi
    loop .clear_page_dir

    ;开始创建页目录项(PDE)
    .create_pde: ; 创建Page Directory Entry
    mov eax, PAGE_DIR_TABLE_POS
    add eax, 0x1000 ; 此时eax为第一个页表的位置及属性
    mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址。

    ; 下面将页目录项0和0xc00都存为第一个页表的地址,
    ; 一个页表可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,
    ; 这是为将地址映射为内核地址做准备
    or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
    mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(3)
    mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 一个页表项占用4字节,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
    ; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
    sub eax, 0x1000
    mov [PAGE_DIR_TABLE_POS + 4092], eax ; 使最后一个目录项指向页目录表自己的地址

    ;下面创建页表项(PTE)
    mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256
    mov esi, 0
    mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1
    .create_pte: ; 创建Page Table Entry
    mov [ebx+esi*4],edx ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
    add edx,4096
    inc esi
    loop .create_pte

    ;创建内核其它页表的PDE
    mov eax, PAGE_DIR_TABLE_POS
    add eax, 0x2000 ; 此时eax为第二个页表的位置
    or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为0
    mov ebx, PAGE_DIR_TABLE_POS
    mov ecx, 254 ; 范围为第769~1022的所有目录项数量
    mov esi, 769
    .create_kernel_pde:
    mov [ebx+esi*4], eax
    inc esi
    add eax, 0x1000
    loop .create_kernel_pde
    ret


    ;-------------------------------------------------------------------------------
    ;功能:读取硬盘n个扇区
    rd_disk_m_32:
    ;-------------------------------------------------------------------------------
    ; eax=LBA扇区号
    ; ebx=将数据写入的内存地址
    ; ecx=读入的扇区数
    mov esi,eax ; 备份eax
    mov di,cx ; 备份扇区数到di
    ;读写硬盘:
    ;第1步:设置要读取的扇区数
    mov dx,0x1f2
    mov al,cl
    out dx,al ;读取的扇区数

    mov eax,esi ;恢复ax

    ;第2步:将LBA地址存入0x1f3 ~ 0x1f6

    ;LBA地址7~0位写入端口0x1f3
    mov dx,0x1f3
    out dx,al

    ;LBA地址15~8位写入端口0x1f4
    mov cl,8
    shr eax,cl
    mov dx,0x1f4
    out dx,al

    ;LBA地址23~16位写入端口0x1f5
    shr eax,cl
    mov dx,0x1f5
    out dx,al

    shr eax,cl
    and al,0x0f ;lba第24~27位
    or al,0xe0 ; 设置7~4位为1110,表示lba模式
    mov dx,0x1f6
    out dx,al

    ;第3步:向0x1f7端口写入读命令,0x20
    mov dx,0x1f7
    mov al,0x20
    out dx,al

    ;;;;;;; 至此,硬盘控制器便从指定的lba地址(eax)处,读出连续的cx个扇区,下面检查硬盘状态,不忙就能把这cx个扇区的数据读出来

    ;第4步:检测硬盘状态
    .not_ready: ;测试0x1f7端口(status寄存器)的的BSY位
    ;同一端口,写时表示写入命令字,读时表示读入硬盘状态
    nop
    in al,dx
    and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
    cmp al,0x08
    jnz .not_ready ;若未准备好,继续等。

    ;第5步:从0x1f0端口读数据
    mov ax, di ;以下从硬盘端口读数据用insw指令更快捷,不过尽可能多的演示命令使用,
    ;在此先用这种方法,在后面内容会用到insw和outsw等

    mov dx, 256 ;di为要读取的扇区数,一个扇区有512字节,每次读入一个字,共需di*512/2次,所以di*256
    mul dx
    mov cx, ax
    mov dx, 0x1f0
    .go_on_read:
    in ax,dx
    mov [ebx], ax
    add ebx, 2
    ; 由于在实模式下偏移地址为16位,所以用bx只会访问到0~FFFFh的偏移。
    ; loader的栈指针为0x900,bx为指向的数据输出缓冲区,且为16位,
    ; 超过0xffff后,bx部分会从0开始,所以当要读取的扇区数过大,待写入的地址超过bx的范围时,
    ; 从硬盘上读出的数据会把0x0000~0xffff的覆盖,
    ; 造成栈被破坏,所以ret返回时,返回地址被破坏了,已经不是之前正确的地址,
    ; 故程序出会错,不知道会跑到哪里去。
    ; 所以改为ebx代替bx指向缓冲区,这样生成的机器码前面会有0x66和0x67来反转。
    ; 0X66用于反转默认的操作数大小! 0X67用于反转默认的寻址方式.
    ; cpu处于16位模式时,会理所当然的认为操作数和寻址都是16位,处于32位模式时,
    ; 也会认为要执行的指令是32位.
    ; 当我们在其中任意模式下用了另外模式的寻址方式或操作数大小(姑且认为16位模式用16位字节操作数,
    ; 32位模式下用32字节的操作数)时,编译器会在指令前帮我们加上0x66或0x67,
    ; 临时改变当前cpu模式到另外的模式下.
    ; 假设当前运行在16位模式,遇到0X66时,操作数大小变为32位.
    ; 假设当前运行在32位模式,遇到0X66时,操作数大小变为16位.
    ; 假设当前运行在16位模式,遇到0X67时,寻址方式变为32位寻址
    ; 假设当前运行在32位模式,遇到0X67时,寻址方式变为16位寻址.

    loop .go_on_read
    ret
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    ;-------------	 loader和kernel   ----------

    LOADER_BASE_ADDR equ 0x900
    LOADER_STACK_TOP equ LOADER_BASE_ADDR
    LOADER_START_SECTOR equ 0x2

    KERNEL_BIN_BASE_ADDR equ 0x70000
    KERNEL_START_SECTOR equ 0x9
    KERNEL_ENTRY_POINT equ 0xc0001500

    ;------------- 页表配置 ----------------
    PAGE_DIR_TABLE_POS equ 0x100000

    ;-------------- gdt描述符属性 -----------
    DESC_G_4K equ 1_00000000000000000000000b
    DESC_D_32 equ 1_0000000000000000000000b
    DESC_L equ 0_000000000000000000000b ; 64位代码标记,此处标记为0便可。
    DESC_AVL equ 0_00000000000000000000b ; cpu不用此位,暂置为0
    DESC_LIMIT_CODE2 equ 1111_0000000000000000b
    DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
    DESC_LIMIT_VIDEO2 equ 0000_000000000000000b
    DESC_P equ 1_000000000000000b
    DESC_DPL_0 equ 00_0000000000000b
    DESC_DPL_1 equ 01_0000000000000b
    DESC_DPL_2 equ 10_0000000000000b
    DESC_DPL_3 equ 11_0000000000000b
    DESC_S_CODE equ 1_000000000000b
    DESC_S_DATA equ DESC_S_CODE
    DESC_S_sys equ 0_000000000000b
    DESC_TYPE_CODE equ 1000_00000000b ;x=1,c=0,r=0,a=0 代码段是可执行的,非依从的,不可读的,已访问位a清0.
    DESC_TYPE_DATA equ 0010_00000000b ;x=0,e=0,w=1,a=0 数据段是不可执行的,向上扩展的,可写的,已访问位a清0.

    DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
    DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
    DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b

    ;-------------- 选择子属性 ---------------
    RPL0 equ 00b
    RPL1 equ 01b
    RPL2 equ 10b
    RPL3 equ 11b
    TI_GDT equ 000b
    TI_LDT equ 100b


    ;---------------- 页表相关属性 --------------
    PG_P equ 1b
    PG_RW_R equ 00b
    PG_RW_W equ 10b
    PG_US_S equ 000b
    PG_US_U equ 100b


    ;------------- program type 定义 --------------
    PT_NULL equ 0
  • 编译并运行

    1
    2
    3
    4
    5
    6
    7
    8
    nasm -I include/ -o mbr.bin mbr.S
    sudo dd if=mbr.bin of=hd60M.img bs=512 count=1 conv=notrunc
    nasm -I include/ -o loader.bin loader.S
    sudo dd if=loader.bin of=hd60M.img bs=512 count=4 seek=2 conv=notrunc
    gcc -m32 -c -o kernel/main.o kernel/main.c
    ld kernel/main.o -m elf_i386 -Ttext 0xc0001500 -e main -o kernel.bin
    sudo dd if=kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc
    sudo ./bochs -f bochsrc.disk




    最终可以看出确实加载了内核,并跳转到一个死循环执行了。

特权级

特权级转移

任务是由处理器执行的,任务在特权级变换时,本质上是处理器的当前特权级在变换,由一个特权级变成了另外一个特权级。

特权级转移分为两类,一类是由中断门、调用门等手段实现低特权级转向高特权级,另一类则相反,是由调用返回指令从高特权级返回到低特权级,这是唯一一种能让处理器降低特权级的情况。

  1. 对于第1种特权级由低到高的情况

    • 由于不知道目标特权级对应的栈地址在哪里,所以要提前把目标栈的地址记录在某个地方,当处理器向高特权级转移时再从中取出来加载到SS和ESP中以更新栈,这个保存的地方就是TSS。处理器会自动地从TSS中找到对应的高特权级栈地址,这一点对开发人员是透明的,只需要在TSS中记录好高特权级的栈地址便可。
  2. 对于第2种由高特权返回到低特权级的情况

    • 处理器是不需要在TSS中去寻找低特权级目标栈的。当处理器由低向高特权级转移时,它自动地把当时低特权级的栈地址(SS 和 ESP)压入了转移后的高特权级所在的栈中。
    • 所以,当用返回指令如retf或 iret从高特权级向低特权级返回时,处理器可以从当前使用的高特权级的栈中获取低特权级的栈段选择子及偏移量。由高特权级返回低特权级的过程称为“向外层转移”。

CPL和DPL

计算机特权级的标签体现在DPL、CPL和RPL

  • CPL

    • 位于CS寄存器中选择子低2位的值不仅称为请求特权级,又称为处理器的当前特权级,也就是说处理器的当前特权级是CS.RPL。
    • 在CPU中运行的是指令,其运行过程中的指令总会属于某个代码段,该代码段的特权级,也就是代码段描述符中的 DPL,便是当前CPU所处的特权级,这个特权级称为当前特权级,即CPL(Current Privilege Level),它表示处理器正在执行的代码的特权级别。除一致性代码段外,转移后的目标代码段的DPL是将来处理器的当前特权级CPL。
    • 在任意时刻,当前特权级CPL保存在CS选择子中的RPL部分
  • DPL

    • DPL,即Descriptor Privilege Level,描述符特权级
  • 门描述符

    • 用于处理器从低特权级转移到高特权级
    • 门描述符所对应的代码段的DPL <= CPL <= 门描述符的DPL

RPL

如果仅通过CPL和DPL(满足CPL<=DPL)来判断能否访问数据,如果通过门描述符进入了0特权级,则就可以访问任意数据,因为对于被访问的数据来说,请求访问的是最高特权级。

如图,通过调用门后,是操作系统通过用户提供的数据段选择子在读取磁盘和写入数据,本意应该是写入用户缓冲区,但如果用户提供的是内核数据段,则写入了内核缓冲区,这就出问题了。

所以数据必须知道请求访问数据的真正请求对象的特权级是多少。

  • RPL
    • Request Privilege Level,即实际请求者的特权级,而不是代理请求者的特权级
    • 在请求某特权级为DPL级别的资源时,参与特权检查的不只是CPL,还要加上RPL。CPL和RPL的特权必须同时大于等于受访者的特权DPL。
    • 下面这个例子很生动

总结


当用户程序请求操作系统服务,如果需要提交选择子作为参数,为安全起见,操作系统会把选择子中的RPL改为用户程序的CPL,原理实际上是用户请求系统服务时肯定会进行一个特权级的变更,这就涉及到保存上下文和栈切换,这时就可以从栈里获取到真实的用户程序的CPL了。

事实上上面说的这些乱七八糟的特权级和门,在平坦模型下都很简单,因为平坦模型只需要两个特权级为3的用户数据/代码段描述符即可访问4GB用户数据/代码。

中断

汇编基础

函数调用约定

  • 参数的传递方式
    • 寄存器传递还是栈传递
  • 参数的传递顺序
    • 从左到右还是从右到左
  • 寄存器环境保存
    • 调用者保存寄存器环境,还是被调用者保存
    • 保存哪些寄存器
  • 栈清理

这里介绍x86平台上的两种调用约定:

  • cdecl(C declaration)
    • 参数从右往左入栈
    • 参数在栈中传递
    • eax/ecx/edx寄存器由调用者保存,其余由被调用者保存,返回值存储在eax里
    • 调用者清理栈空间
  • stdcall
    • 和cdecl基本一致,只是由被调用者清理栈空间

看一个stdcall的例子

1
2
int subtract(int a, int b); //被调用者
int sub = subtract (3,2); //主调用者
  • 主调用者:
    1
    2
    3
    4
    ; 从右到左将参数入栈 
    push 2 ;压入参数b
    push 3 ;压入参数a
    call subtract ;调用函数subtract
  • 被调用者
    1
    2
    3
    4
    5
    6
    7
    push ebp ;压入ebp备份
    mov ebp,esp ;将esp赋值给ebp
    mov eax,[ebp+0x8] ;用 ebp 作为基址来访问栈中参数,偏移8字节处为第1个参数a
    add eax,[ebp+0xc] ;偏移0xc字节处是第2个参数b,参数 a 和 b 相加后存入 eax
    mov esp,ebp ;为防止中间有入栈操作,用ebp恢复esp
    pop ebp ;将ebp恢复
    ret 8 ;数字8表示返回后使esp+8 ;函数返回时由被调函数清理了栈中参数

系统调用(syscall)传递参数的方式(x86)

当输入的参数小于等于5个时,Linux用寄存器传递参数。当参数个数大于5个时,把参数按照顺序放入连续的内存区域,并将该区域的首地址放到ebx寄存器。

eax寄存器用来存储子功能号

5个参数存放在以下寄存器中,传送参数的顺序如下。

  • ebx存储第1个参数。
  • ecx存储第2个参数。
  • edx存储第3个参数。
  • esi存储第4个参数。
  • edi存储第5个参数。

中断的分类

中断就是发生了某种事件需要通知CPU处理,把中断按事件来源分类,来自CPU外部的中断就称为外部中断,来自CPU内部的中断称为内部中断。

  • 外部中断(按是否导致宕机)
    • 可屏蔽中断
    • 不可屏蔽中断
  • 内部中断(按中断是否正常)
    • 软中断
    • 异常

外部中断

CPU需要获得每个外部设备的中断信号,所以CPU中有两条接收中断的信号线,外部设备的中断信息都通过信号线通知CPU。

CPU收到中断后,得知道发生了什么事情才能执行相应的处理办法。这是通过中断向量表或中断描述 符表(中断向量表是实模式下的中断处理程序数组,在保护模式下已经被中断描述符表代替)来实现的。

导致宕机的各种原因分配一个中断向量号就足够了,所以不可屏蔽中断的中断向量号为2。

内部中断

  • 软中断
    • 软中断,就是由软件主动发起的中断,因为它来自于软件,所以称之为软中断。由于该中断是软件运行中主动发起的,所以它是主观上的,并不是客观上的某种内部错误。
    • int 8位立即数(0-255)
      • 8位立即数可表示256种中断,这与处理器所支持的中断数是相吻合的。
  • 异常
    • int3(注意没有空格)
      • bochs调试程序时,实际上就是调试器fork了一个子进程,子进程用于运行被调试的程序。调试器中经常要设置断点,其原理就是父进程修改了子进程的指令,将其用int3指令替换,从而子进程调用了int3指令触发中断。用此指令实现调试的原理是int3指令的机器码是0xcc,断点本质上是指令的地址,调试器(父进程)将被调试进程(子进程)断点起始地址的第1个字节备份好之后在原地将该指令的第1字节修改为0xcc。这样指令执行到断点处时,会去执行机器码为0xcc的int3指令,该指令会触发3号中断,从而会去执行3号中断对应的中断处理程序,由于中断处理程序在运行时也要用到寄存器,为了保存所调试进程的现场,该中断处理程序必须先将当前的寄存器和相关内存单元压栈保存(提醒,当前寄存器和相关内存都属于那个被调试的进程),用户在查看寄存器和变量时就是从栈中获取的。当恢复执行所调试的进程时,中断处理程序需要将之前备份的1字节还原至断点处,然后恢复各寄存器和内存单元的值,修改返回地址为断点地址,用iret指令退出中断,返回到用户进程继续执行。
      • 可以参考这篇,十分钟实现一个简单调试器
    • into
      • 这是中断溢出指令,它所触发的中断向量号是4。不过,能否引发4号中断是要看eflags标志寄存器中的OF位是否为1,如果是1才会引发中断
    • bound
      • 这是检查数组索引越界指令,它可以触发5号中断,用于检查数组的索引下标是否在上下边界之内。
    • ud2
      • 未定义指令,这会触发第6号中断。该指令表示指令无效,CPU无法识别。主动使用它发起中断,常用于软件测试中,无实际用途。
    • 异常按照轻重程度,可以分为以下三种。
      • Fault,也称为故障。这种错误是可以被修复的一种类型,属于最轻的一种异常,它给软件一次“改过自新”的机会。当发生此类异常时CPU将机器状态恢复到异常之前的状态,之后调用中断处理程序时,CPU将返回地址依然指向导致fault异常的那条指令。通常中断处理程序中会将此问题修复,待中断处理程序返回后便能重试。最典型的例子就是操作系统课程中所说的缺页异常page fault,话说Linux的虚拟内存就是基于page fault的,这充分说明这种异常是极易被修复的,甚至是有益的。
      • Trap,也称为陷阱,这一名称很形象地说明软件掉进了CPU设下的陷阱,导致停了下来。此异常通常用在调试中,比如int3指令便引发此类异常,为了让中断处理程序返回后能够继续向下执行,CPU将中断处理程序的返回地址指向导致异常指令的下一个指令地址。
      • Abort,也称为终止,从名字上看,这是最严重的异常类型,一旦出现,由于错误无法修复,程序将无法继续运行,操作系统为了自保,只能将此程序从进程表中去掉。导致此异常的错误通常是硬件错误,或者某些系统数据结构出错。某些异常会有单独的错误码,即error code,进入中断时CPU会把它们压在栈中,这是在压入eip之后做的。

中断向量

中断向量和中断向量表(IVT)

中断机制的本质是来了一个中断信号后,调用相应的中断处理程序。

所以,CPU不管有多少种类型的中断,为了统一中断管理,把来自外部设备、内部指令的各种中断类型统统归结为一种管理方式,即为每个中断信号分配一个整数,用此整数作为中断的ID,而这个整数就是所谓的中断向量,然后用此ID作为中断描述符表中的索引,这样就能找到对应的表项,进而从中找到对应的中断处理程序。

中断向量的作用和选择子类似,它们都用来在描述符表中索引一个描述符,只不过选择子用于在GDT或LDT中检索段描述符,而中断向量专用于中断描述符表。

异常和不可屏蔽中断的中断向量号是由CPU自动提供的,而来自外部设备的可屏蔽中断号是由中断代理提供的,软中断是由软件提供的

一个中断源就会产生一个中断向量,每个中断向量都对应中断描述符表中的一个门描述符,任何中断 源都通过中断向量对应到中断描述符表中的门描述符,通过该门描述符就找到了对应的中断处理程序。可见,中断发生后,采取什么样的动作是由中断处理程序决定的。

在实模式下,IVT位于地址0~0x3ff,它是实模式下用于存储中断处理程序入口的表。

由于实模式下功能有限,运行机制比较“死板”,所以它的位置是固定的,必须位于最低端。

已知0~0x3ff共1024个字节,又知IVT可容纳256个中断向量,所以每个中断向量用4字节描述。

中断描述符表



  1. 任务门
    任务门和任务状态段(Task Status Segment,TSS)是Intel处理器在硬件一级提供的任务切换机制,所以任务门需要和TSS配合在一起使用,在任务门中记录的是TSS选择子,偏移量未使用。任务门可以存在于全局描述符表GDT、局部描述符表LDT、中断描述符表IDT中。描述符中任务门的type值为二进制0101,其结构如图7-2所示。顺便说一句大多数操作系统(包括Linux)都未用TSS实现任务切换,咱们这里也不讨论啦。
  2. 中断门
    中断门包含了中断处理程序所在段的段选择子段内偏移地址。当通过此方式进入中断后,标志寄存器eflags中的IF位自动置0,也就是在进入中断后,自动把中断关闭,避免中断嵌套。Linux就是利用中断门实现的系统调用,就是那个著名的int0x80。中断门只允许存在于IDT中。描述符中中断门的type值为二进制1110,其结构如图7-3所示。
  3. 陷阱门
    陷阱门和中断门非常相似,区别是由陷阱门进入中断后,标志寄存器eflags中的IF位不会自动置0。陷阱门只允许存在于IDT中。描述符中陷阱门的type值为二进制1111。其结构如图7-4所示。
  4. 调用门
    调用门是提供给用户进程进入特权0级的方式,其DPL为3。调用门中记录例程的地址,它不能用int指令调用,只能用call和jmp指令。调用门可以安装在GDT和LDT中。描述符中调用门的type值为二进制1100。其结构如图7-5所示。

这里提到了关于eflags的if标志位,if代表的是中断启用flag,如果为0就不接受中断。
但是中断可以无视eflags中的IF位,可以这么理解:

(1)首先,只要是导致运行错误的中断类型都会无视IF位,不受IF位的管束,如 NMI、异常。

(2)其次,由于int n型的软中断用于实现系统调用功能,不能因为IF位为 0 就不顾用户请求,所以为了用户功能正常,软中断必须也无视IF位。

总结:只要中断关系到“正常”运行,就不受IF位影响。

对比中断向量表,中断描述符表有两个区别。

  1. 中断描述符表地址不限制,在哪里都可以。
  2. 中断描述符表中的每个描述符用8字节描述。

IDTR(Interrupt Descriptor Table Register)寄存器保存中断描述符表的地址。


通过lidt 48位内存数据, 前16位是IDT表界限,后32位是IDT线性基地址。

处理器只支持256个中断,即0~255,中断描述符中其余的描述符不可用。

中断处理

中断处理的过程和权限检查

  1. 根据中断向量号定位中断描述符。
  2. 处理器进行特权级检查
    • 由于中断是通过中断向量号通知到处理器的,中断向量号只是个整数,其中并没有 RPL,所以在对由中断引起的特权级转移做特权级检查中,并不涉及到 RPL。
    • 如果是由软中断int n、int3和into引发的中断
      • 目标代码段DPL(门框) <= CPL <= 门描述符DPL(门槛)
      • 解释一下,之所以特权级要小于目标代码段的DPL,是因为如果当前特权级已经大于门处理程序的特权级了,那也就等于是高特权级向低特权级转移,这是不允许的。
      • 之所以特权级要大于门描述符DPL,是因为指定的门描述符可能不开放给用户程序使用,只是限定给内核的,所以这就是个门槛。
      • 所以之所以叫门,就是因为要特权级能超过门槛,但是不超过门框。
    • 若中断是由外部设备和异常引起的,只直接检查CPL和目标代码段的 DPL
  3. 执行中断处理程序
    • 特权级检查通过后,将门描述符目标代码段选择子加载到代码段寄存器CS中,把门描述符中中断处理程序的偏移地址加载到EIP,开始执行中断处理程序。

中断处理程序执行时的栈变化和上下文保存

  • 特权级由低到高

  • 特权级不变

  • 从中断中返回

    • 注意ERROR_CODE需要中断处理程序自己去处理,即手动弹出
    • iret
      • 在执行iret时,sp应指向eip_old,此指令依次从栈中还原ip和寄存器环境,切换回之前的特权级,即中断门结束。

错误码

错误码其实也是保存了一个描述符的选择子,只是它可以选择的表有很多种;

根据TI来决定是LDT还是GDT,根据IDT决定是否是选择中断描述符表。

EXT表示EXTernal event,即外部事件,用来指明中断源是否来自处理器外部,如果中断源是不可屏蔽中断NMI或外部设备,EXT为1,否则为0。

实现中断

先将预先实现好的打印函数放在lib/kernel目录下,将函数声明放在print.h里,如果需要使用就导入print.h并链接好即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ tree
.
├── bochs
├── bochs.out
├── bochsrc.disk
├── boot
│   ├── include
│   │   └── boot.inc
│   ├── loader.S
│   └── mbr.S
├── bxcommit
├── bximage
├── hd60M.img
├── kernel
│   ├── main.c
│   └── main.o
├── kernel.bin
├── lib
│   ├── kernel
│   │   ├── print.h
│   │   ├── print.o
│   │   └── print.S
│   └── user
├── loader.bin
└── mbr.bin

先测试一下效果

1
2
3
4
5
nasm -f elf -o lib/kernel/print.o lib/kernel/print.S
gcc -m32 -I lib/kernel/ -c -o kernel/main.o kernel/main.c
ld kernel/main.o lib/kernel/print.o -m elf_i386 -Ttext 0xc0001500 -e main -o kernel.bin
sudo dd if=kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc
sudo ./bochs -f bochsrc.disk

  • init_all
    • idt_init
      • idt_desc_init
        • 准备中断处理函数
        • 准备中断描述符表
    • 加载idt
  1. 首先定义中断处理函数,因为这里每个中断处理函数都只是简单的打印出”interrupt occur!”,所以我使用一个宏来实现这个功能。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    [bits 32]
    %define ERROR_CODE nop ; 若在相关的异常中cpu已经自动压入了错误码,为保持栈中格式统一,这里不做操作.
    %define ZERO push 0 ; 若在相关的异常中cpu没有压入错误码,为了统一栈中格式,就手工压入一个0

    extern put_str ;声明外部函数

    section .data
    intr_str db "interrupt occur!", 0xa, 0
    global intr_entry_table
    intr_entry_table:

    %macro VECTOR 2
    section .text
    intr%1entry: ; 每个中断处理程序都要压入中断向量号,所以一个中断类型一个中断处理程序,自己知道自己的中断向量号是多少
    %2
    push intr_str
    call put_str
    add esp,4 ; 跳过参数

    add esp,4 ; 跨过error_code
    iret ; 从中断返回,32位下等同指令iretd

    section .data
    dd intr%1entry ; 存储各个中断入口程序的地址,形成intr_entry_table数组
    %endmacro

    VECTOR 0x00,ZERO
    VECTOR 0x01,ZERO
    VECTOR 0x02,ZERO
    VECTOR 0x03,ZERO
    VECTOR 0x04,ZERO
    VECTOR 0x05,ZERO
    VECTOR 0x06,ZERO
    VECTOR 0x07,ZERO
    VECTOR 0x08,ERROR_CODE
    VECTOR 0x09,ZERO
    VECTOR 0x0a,ERROR_CODE
    VECTOR 0x0b,ERROR_CODE
    VECTOR 0x0c,ZERO
    VECTOR 0x0d,ERROR_CODE
    VECTOR 0x0e,ERROR_CODE
    VECTOR 0x0f,ZERO
    VECTOR 0x10,ZERO
    VECTOR 0x11,ERROR_CODE
    VECTOR 0x12,ZERO
    VECTOR 0x13,ZERO
    VECTOR 0x14,ZERO
    VECTOR 0x15,ZERO
    VECTOR 0x16,ZERO
    VECTOR 0x17,ZERO
    VECTOR 0x18,ERROR_CODE
    VECTOR 0x19,ZERO
    VECTOR 0x1a,ERROR_CODE
    VECTOR 0x1b,ERROR_CODE
    VECTOR 0x1c,ZERO
    VECTOR 0x1d,ERROR_CODE
    VECTOR 0x1e,ERROR_CODE
    VECTOR 0x1f,ZERO
    VECTOR 0x20,ZERO

    编译器会将属性相同的section合并到同一个大的segment中,所以最终我们可以得到一个函数指针数组intr_entry_table

    1
    2
    3
    4
    intr_entry_table:
    dd intr00entry(中断处理函数0的地址)
    dd intr01entry
    ...

    汇编里label:这种形式,label代表的就是一个地址。

  2. 创建中断描述符表IDT

中断描述符表就是如图的中断描述符连续放在一起,每个四字节。

于是我们可以创建一个结构体数组来表示一个中断描述符表,并依次给每个字段赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "interrupt.h"
#include "stdint.h"
#include "global.h"
#include "print.h"

#define IDT_DESC_CNT 0x21 // 目前总共支持的中断数

/*中断门描述符结构体*/
struct gate_desc {
uint16_t func_offset_low_word;
uint16_t selector;
uint8_t dcount; //此项为双字计数字段,是门描述符中的第4字节。此项固定值,不用考虑
uint8_t attribute;
uint16_t func_offset_high_word;
};

// 静态函数声明,非必须
static void make_idt_desc(struct gate_desc* p_gdesc, uint8_t attr, intr_handler function);
static struct gate_desc idt[IDT_DESC_CNT]; // idt是中断描述符表,本质上就是个中断门描述符数组

extern intr_handler intr_entry_table[IDT_DESC_CNT]; // 声明引用定义在kernel.S中的中断处理函数入口数组


/* 创建中断门描述符 */
static void make_idt_desc(struct gate_desc* p_gdesc, uint8_t attr, intr_handler function) {
p_gdesc->func_offset_low_word = (uint32_t)function & 0x0000FFFF;
p_gdesc->selector = SELECTOR_K_CODE;
p_gdesc->dcount = 0;
p_gdesc->attribute = attr;
p_gdesc->func_offset_high_word = ((uint32_t)function & 0xFFFF0000) >> 16;
}

/*初始化中断描述符表*/
static void idt_desc_init(void) {
int i;
for (i = 0; i < IDT_DESC_CNT; i++) {
make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]);
}
put_str(" idt_desc_init done\n");
}
  1. 加载idt

通过汇编lidt 48位数据来将我们初始化好的中断门描述符数组的地址加载到idt寄存器里,这样中段描述符表的基地址就有了。

1
2
3
/* 加载idt */
uint64_t idt_operand = ((sizeof(idt) - 1) | ((uint64_t)(uint32_t)idt << 16));
asm volatile("lidt %0" : : "m" (idt_operand));

内存管理系统

makefile

https://seisman.github.io/how-to-write-makefile/

实现assert和字符串处理函数

1
2
3
4
5
uint32_t strlen(const char *str) {
const char *p = str;
while (*p++);
return p - 1 - str;
}

这里要注意*p解引用得到'\0'的时候while循环终止,但是p++还是会执行,所以指向的是'\0'+1的位置。

位图bitmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#define BITMAP_MASK 1

struct bitmap {
uint8_t *bits;//整体上以字节为单位,细节上以位为单位
uint32_t btmp_bytes_len;
};

void bitmap_init(struct bitmap *btmp);

bool bitmap_scan_test(struct bitmap *btmp, uint32_t bit_idx);

int bitmap_scan(struct bitmap *btmp, uint32_t cnt);

void bitmap_set(struct bitmap *btmp, uint32_t bit_idx, int8_t value);

//初始化bitmap为全0
void bitmap_init(struct bitmap *btmp) {
memset(btmp->bits, 0, btmp->btmp_bytes_len);
}

//判断bit_idx位是否为1
bool bitmap_scan_test(struct bitmap *btmp, uint32_t bit_idx) {//bit_idx从0开始计数
assert(bit_idx < btmp->btmp_bytes_len * 8);
uint32_t byte_idx = bit_idx / 8;
uint32_t bit_odd = bit_idx % 8;
return btmp->bits[byte_idx] & (BITMAP_MASK << bit_odd);
}

//申请连续cnt个位,如果成功则返回起始下标,失败返回-1
int bitmap_scan(struct bitmap *btmp, uint32_t cnt) {
uint32_t idx_byte = 0; //用于记录空闲位所在的字节
//先逐字节比较
while ((0xff == btmp->bits[idx_byte]) && (idx_byte < btmp->btmp_bytes_len)) {
//若该字节全1,则代表没有空闲位,到下一字节继续找
idx_byte++;
}
assert(idx_byte < btmp->btmp_bytes_len);
if (idx_byte == btmp->btmp_bytes_len) {//若该内存池找不到可用空间
return -1;
}
//若在位图数组范围内的某字节内找到了空闲位,在该字节内逐位对比,返回空闲位的索引
int idx_bit = 0;
// 和btmp->bits[idx_byte]这个字节逐位对比,即从第0位开始在字节内依次比较是不是1,如果不是就停止,即遇到空闲位就停止。
while ((uint8_t) (BITMAP_MASK << idx_bit) & btmp->bits[idx_byte]) {
idx_bit++;
}
int bit_idx_start = idx_byte * 8 + idx_bit;//空闲位在位图的下标
if (cnt == 1) {
return bit_idx_start;
}
uint32_t bit_left = (btmp->btmp_bytes_len * 8 - bit_idx_start);//记录还有多少位可以判断
//从下一位开始继续判断
uint32_t next_bit = bit_idx_start + 1;
uint32_t count = 1;//记录找到的空闲位的个数
bit_idx_start = -1;//先将其置为-1,如果找不到连续的位就返回
// 这个实现其实有个越界
// 例如,bit_left初始是7,next_bit初始是2
// 循环共循环7次,每次循环结束的时候next_bit加一,所以next_bit在最后一次开始
// 时值为2+6=8,这就越界了,因为一共有8位,next_bit为8,就代表第9位了。
//0 1 2 3 4 5 6 7
//1 0 | 1 1 1 1 1 1
//所以应该是uint32_t bit_left = (btmp->btmp_bytes_len * 8 - bit_idx_start) - 1
while (bit_left-- > 0) {
if (!(bitmap_scan_test(btmp, next_bit))) {//若next_bit为0
count++;
} else {
count = 0;
}
if (count == cnt) {//若找到连续的cnt个空位
bit_idx_start = next_bit - cnt + 1;
break;
}
next_bit++;
}
return bit_idx_start;
}

//将位图btmp的bit_idx位设置为value
void bitmap_set(struct bitmap *btmp, uint32_t bit_idx, int8_t value) {
assert(value == 0 || value == 1);
uint32_t byte_idx = bit_idx / 8;
uint32_t bit_odd = bit_idx % 8;
if (value) {
btmp->bits[byte_idx] |= (BITMAP_MASK << bit_odd);
} else {
btmp->bits[byte_idx] &= ~(BITMAP_MASK << bit_odd);
}
}

int main() {
struct bitmap bm;
bm.btmp_bytes_len = 1;
bitmap_init(&bm);
bm.bits[0] |= 0xfd;
bitmap_scan(&bm, 2);
}

内存池的规划


内核也通过内存管理系统申请内存,为此,它也要有个虚拟地址池,当它申请内存时,从内核自己的虚拟地址池中分配虚 拟地址,再从内核物理内存池(内核专用)中分配物理内存,然后在内核自己的页表将这两种地址建立好映射关系。

对用户进程来说,它向内存管理系统,即操作系统,申请内存时,操作系统先从用户进程自己的虚拟地址池中分配空闲虚拟地址,然后再从用户物理内存池(所有用户进程共享)中分配空闲的物理内存,然 后在该用户进程自己的页表将这两种地址建立好映射关系。

为方便管理,虚拟地址池中的地址单位也是4KB,这样虚拟地址便于和物理地址做完整页的映射。

  • 内存池的实现
    • 定义好内核虚拟地址池内核/用户物理地址池两种数据结构
    • 初始化其bitmap保存的地址,以及其从哪里开始分配内核堆虚拟内存/内核/用户堆物理内存
      • 从总的物理内存里除去被页表和页目录表以及内核占用的内存,就是剩下可用来分配的物理内存。
      • 从3GB开始是内核虚拟地址空间,其首1MB字节保存内核,再往上就是可以分配的堆虚拟地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#define BITMAP_MASK 1

struct bitmap {
uint8_t *bits;//整体上以字节为单位,细节上以位为单位
uint32_t btmp_bytes_len;
};

void bitmap_init(struct bitmap *btmp);

bool bitmap_scan_test(struct bitmap *btmp, uint32_t bit_idx);

int bitmap_scan(struct bitmap *btmp, uint32_t cnt);

void bitmap_set(struct bitmap *btmp, uint32_t bit_idx, int8_t value);


struct virtual_addr {//虚拟地址池,用于虚拟地址管理
struct bitmap vaddr_bitmap;//虚拟地址用到的位图结构
uint32_t vaddr_start; //虚拟地址起始地址
};

void mem_init();

#define PG_SIZE 4096 //表示页的尺寸

/************************ 位图地址 *****************************
因为 0xc009f000 是内核主线程栈顶,0xc009e000 是内核主线程的 pcb。
一个页框大小(4KB)的位图可表示 128MB 内存(4KB*8*4KB=128MB),位图位置安排在地址 0xc009a000,
这样本系统最大支持 4 个页框的位图,即 512MB */

#define MEM_BITMAP_BASE 0xc009a000

/* 0xc0000000是内核从虚拟地址3G起。
0x100000 意指跨过低端 1MB 内存,使虚拟地址在逻辑上连续 */

//在 loader 中我们已经通过设置页表把虚拟地址 0xc0000000~0xc00fffff 映射到了物理地址
//0x00000000~ 0x000fffff(低端 1MB 的内存),故我们为了让虚拟地址连续,
//将堆的起始虚拟地址设为 0xc0100000

#define K_HEAP_START 0xc0100000

/* 内存池结构,生成两个实例用于管理内核内存池和用户内存池 */
struct pool {
struct bitmap pool_bitmap;//本内存池用到的位图结构,用于管理物理内存
uint32_t phy_addr_start; //本内存池所管理的物理内存的起始地址
uint32_t pool_size;//本内存池字节容量
};

struct pool kernel_pool, user_pool;//生成内核内存池和用户内存池
struct virtual_addr kernel_vaddr;//此结构用来给内核分配虚拟地址

//初始化内存池
static void mem_pool_init(uint32_t all_mem) {
puts("mem pool init start\n");
uint32_t page_table_size = PG_SIZE * 256;//记录页目录表和页表占用的字节大小
//页表大小=1页的页目录表+0/768页目录项指向同一个页表 + 769-1022个页目录项共254个页表,
// 共要256个页框,因为1023页目录项指向页目录表自己,所以不重复算空间
uint32_t used_mem = page_table_size + 0x100000;//低端1MB内存包括bootloader和kernel等都保存在这里,所以我们认为它已经被使用了
uint32_t free_mem = all_mem - used_mem;
uint16_t all_free_pages = free_mem / PG_SIZE;
uint16_t kernel_free_pages = all_free_pages / 2;
uint16_t user_free_pages = all_free_pages - kernel_free_pages;

//为简化位图操作,余数不处理
uint32_t kbm_length = kernel_free_pages / 8; //kernel bitmap的长度,位图中的一位表示一页,以字节为单位
uint32_t ubm_length = user_free_pages / 8;
uint32_t kp_start = used_mem;//内核内存池的起始地址
uint32_t up_start = kp_start + kernel_free_pages * PG_SIZE;//用户内存池的起始地址

kernel_pool.phy_addr_start = kp_start;
user_pool.phy_addr_start = up_start;
kernel_pool.pool_size = kernel_free_pages * PG_SIZE;
user_pool.pool_size = user_free_pages * PG_SIZE;

kernel_pool.pool_bitmap.btmp_bytes_len = kbm_length;
user_pool.pool_bitmap.btmp_bytes_len = ubm_length;
/********* 内核内存池和用户内存池位图 *********** * 位图是全局的数据,长度不固定。
* 全局或静态的数组需要在编译时知道其长度,
* 而我们需要根据总内存大小算出需要多少字节,
* 所以改为指定一块内存来生成位图。
* ************************************************/
// 内核使用的最高地址是 0xc009f000,这是主线程的栈地址
//(内核的大小预计为 70KB 左右) // 32MB内存占用的位图是2KB
//内核内存池的位图先定在 MEM_BITMAP_BASE(0xc009a000)处
kernel_pool.pool_bitmap.bits = static_cast<uint8_t *>((void *) MEM_BITMAP_BASE);
user_pool.pool_bitmap.bits = static_cast<uint8_t *>((void *) (MEM_BITMAP_BASE + kbm_length));
puts("\n");
puts("\n");
puts("kernel_pool bitmap_start:");
printf("%p\n", kernel_pool.pool_bitmap.bits);
puts("kernel_pool phy_addr_start:");
printf("%p\n", kernel_pool.phy_addr_start);

puts("user_pool bitmap_start:");
printf("%p\n", user_pool.pool_bitmap.bits);
puts("user_pool phy_addr_start:");
printf("%p\n", user_pool.phy_addr_start);
//将位图置为0
bitmap_init(&kernel_pool.pool_bitmap);
bitmap_init(&user_pool.pool_bitmap);

/* 下面初始化内核虚拟地址的位图,按实际物理内存大小生成数组。*/
kernel_vaddr.vaddr_bitmap.btmp_bytes_len = kbm_length;// 用于维护内核堆的虚拟地址,所以要和内核内存池大小一致
kernel_vaddr.vaddr_bitmap.bits = static_cast<uint8_t *>((void *) (MEM_BITMAP_BASE + kbm_length + ubm_length));

kernel_vaddr.vaddr_start = K_HEAP_START;
bitmap_init(&kernel_vaddr.vaddr_bitmap);
puts("mem_pool init done\n");
}

void mem_init() {
puts("mem_init start\n");
// uint32_t mem_bytes_total = *(uint32_t *) (0xb00);
mem_pool_init(33554432);
puts("mem_init done\n");
}

int main() {
mem_init();
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ tree
.
├── bochs
├── bochs.out
├── bochsrc.disk
├── boot
│   ├── include
│   │   └── boot.inc
│   ├── loader.S
│   └── mbr.S
├── build
│   ├── bitmap.o
│   ├── debug.o
│   ├── init.o
│   ├── interrupt.o
│   ├── kernel.bin
│   ├── kernel.map
│   ├── kernel.o
│   ├── main.o
│   ├── memory.o
│   ├── print.o
│   ├── string.o
│   └── timer.o
├── bxcommit
├── bximage
├── device
│   ├── timer.c
│   └── timer.h
├── hd60M.img
├── kernel
│   ├── debug.c
│   ├── debug.h
│   ├── global.h
│   ├── init.c
│   ├── init.h
│   ├── interrupt.c
│   ├── interrupt.h
│   ├── kernel.S
│   ├── main.c
│   ├── memory.c
│   └── memory.h
├── lib
│   ├── kernel
│   │   ├── bitmap.c
│   │   ├── bitmap.h
│   │   ├── io.h
│   │   ├── print.h
│   │   └── print.S
│   ├── stdint.h
│   ├── string.c
│   ├── string.h
│   └── user
└── makefile

分配页内存

页表的作用是将虚拟地址转换成物理地址,此工作表面虚幻,但内心真实,其 转换过程中涉及访问的页目录表、页目录项及页表项,都是通过真实物理地址访问的,否则若用虚拟地址访问它们的话,会陷入转换的死循环中不可自拔。

我们的主要目的是整页分配,比如分配n个页,步骤如下

  • 分配虚拟地址
    • 找到空闲的连续的虚拟地址,并分配出来,对于内核虚拟地址空间,可分配内存的起点是0xc0100000(因为0xc0000000-0xc0100000之间是分配给内核的)
  • 连续分配物理页并建立好页表项(pte),直到分配完指定的页数n,可分配内存的起点是0x200000(包括内核和给内核的页表)
    • 找到空闲的物理页,不需要连续
    • 该页建立一个虚拟地址和物理页的映射,即页表项
    • 虚拟地址指向下一页
  • 注意只有pte被建立,才能说这块内存已经被分配出去了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#define PDE_IDX(addr) ((addr & 0xffc00000) >> 22) //取得addr的高10位,页目录表中对应的pde的index
#define PTE_IDX(addr) ((addr & 0x003ff000) >> 12) //取得addr的中间10位,页表中对应的pte的index

enum pool_flags {
PF_KERNEL = 1, //内核内存池
PF_USER = 2 //用户内存池
};

#define PG_P_1 1
#define PG_P_0 0
#define PG_RW_R 0
#define PG_RW_W 2
#define PG_US_S 0 // U/S 属性位值,系统级
#define PG_US_U 4 // U/S 属性位值,用户级

//在pf所属的虚拟内存池里申请pg_cnt个虚拟页,如果成功则返回虚拟页的起始地址,否则返回NULL
static void *vaddr_get(enum pool_flags pf, uint32_t pg_cnt) {
int vaddr_start = 0, bit_idx_start = -1;
uint32_t cnt = 0;
if (pf == PF_KERNEL) {
bit_idx_start = bitmap_scan(&kernel_vaddr.vaddr_bitmap, pg_cnt);//通过bitmap_scan函数去找连续的空闲内存页
if (bit_idx_start == -1) {
return NULL;
}
while (cnt < pg_cnt) {
bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 1);
}
vaddr_start = kernel_vaddr.vaddr_start + bit_idx_start * PG_SIZE;
} else {
//用户内存池,以后补充
}
return (void *) vaddr_start;
}

//得到虚拟地址vaddr对应的pte指针,其是一个能访问到pte的值的地址
uint32_t *pte_addr(uint32_t vaddr) {
// 高10位全1,因为自映射,所以其取的页表地址,还是页目录表基地址,
// 中间10位用vaddr的高10位,所以其取的物理块地址,是原本的页表基地址
// 低12位用vaddr的中间10位,寻址到pte项,取内容,这里左移2是因为是12位,按字节,而10位是按index,需乘以4
uint32_t *pte = (uint32_t *) (0xffc00000 + ((vaddr & 0xffc00000) >> 10) + (PTE_IDX(vaddr) << 2));
return pte;
}

// 得到虚拟地址vaddr对应的pde的指针,其是一个能访问到pde的值的地址
uint32_t *pde_addr(uint32_t vaddr) {
uint32_t *pde = (uint32_t *) (0xfffff000 + (PDE_IDX(vaddr) << 2));
return pde;
}

// 在m_pool指向的物理内存池分配一个物理页
// 成功则返回页框的物理地址,失败则返回NULL
static void *palloc(struct pool *m_pool) {
//扫描或设置位图要保证原子操作
int bit_idx = bitmap_scan(&m_pool->pool_bitmap, 1);//找一个物理页
if (bit_idx == -1) {
return NULL;
}
bitmap_set(&m_pool->pool_bitmap, bit_idx, 1);//将此位置为1,表示已经分配
uint32_t page_phyaddr = ((bit_idx * PG_SIZE)) + m_pool->phy_addr_start;
return (void *) page_phyaddr;
}

//有了虚拟地址和物理地址,我们需要用页表项来建立两者之间的映射关系,注意在创建页表项之前,要先创建页目录项
static void page_table_add(void *_vaddr, void *_page_phyaddr) {
uint32_t vaddr = (uint32_t) _vaddr;
uint32_t page_phyaddr = (uint32_t) _page_phyaddr;
uint32_t *pde = pde_addr(vaddr);
uint32_t *pte = pte_addr(vaddr);
//访问页目录项,判断P位,如果为1,代表该表已经存在,注意内核的页目录是我们在loader的时候就已经创建好了的
if (*pde & 0x00000001) {
// 页表项需不存在,才有创建的必要
assert(!(*pte & 0x00000001));
if (!(*pte & 0x00000001)) {
*pte = (page_phyaddr | PG_US_U, PG_RW_W | PG_P_1);
}
}
//如果页目录项不存在,那么我们要先创建页目录项和对应的页表
//所以就先分配一个4kb的物理地址出来,用来保存页表
//将这个地址写入页目录表里的页目录项
else {
uint32_t pde_phyaddr = (uint32_t) palloc(&kernel_pool);
*pde = (pde_phyaddr | PG_US_U | PG_RW_W | PG_P_1);
/* 分配到的物理页地址 pde_phyaddr 对应的物理内存清 0,
* 避免里面的陈旧数据变成了页表项,从而让页表混乱。*/
//注意,memset这种函数的参数都是虚拟地址,所以不能直接用pde_phyaddr,而是用其对应的虚拟地址
//pde_phyaddr代表的是页表的基地址,所以用之前找页表项的方法就行,这里省事一下,直接把低12位置0就得到了。
memset((void *) ((int) pte & 0xfffff000), 0, PG_SIZE);
//判断p位是否为0,为0才能写入
assert(!(*pte & 0x00000001));
*pte = (page_phyaddr | PG_US_U | PG_RW_W | PG_P_1);
}
}

// 组合起来,分配pg_cnt个页,如果成功则返回起始虚拟地址,失败则返回NULL
//通过vaddr_get在虚拟内存池申请虚拟内存
//通过palloc在物理内存池中申请物理页
//通过page_table_add将以上得到的虚拟地址和物理地址在页表里完成映射
void *malloc_page(enum pool_flags pf, uint32_t pg_cnt) {
void *vaddr_start = vaddr_get(pf, pg_cnt);//在pf所属的虚拟内存池里找到连续的内存
if (vaddr_start == NULL) {
return NULL;
}
uint32_t vaddr = (uint32_t) vaddr_start, cnt = pg_cnt;
struct pool *mem_pool = pf & PF_KERNEL ? &kernel_pool : &user_pool;//找到pf所属的物理内存池
while (cnt-- > 0) {
void *page_phyaddr = palloc(mem_pool);
if (page_phyaddr == NULL) { //失败时要将已经申请的虚拟地址和物理页全部回滚
return NULL;
}
page_table_add((void *) vaddr, page_phyaddr);
vaddr += PG_SIZE;
}
return vaddr_start;
}

//从内核物理地址池申请一页内存
void *get_kernel_pages(uint32_t pg_cnt) {
void *vaddr = malloc_page(PF_KERNEL, pg_cnt);
if (vaddr != NULL) {
memset(vaddr, 0, pg_cnt * PG_SIZE);
}
return vaddr;
}

测试一下

1
2
3
void* addr = get_kernel_pages(3);
put_str("\n get_kernel_page start vaddr is ");
put_int((uint32_t)addr);



线程

执行流

如果有四个任务A、B、C、D,对于单任务操作系统,它只能依次执行,A执行完才能执行B,以此类推。

如果是多任务操作系统,则由软件模拟出来一个任务调度器。

任务调度器就是操作系统中用于把任务轮流调度上处理器运行的一个软件模块,它是操作系统的一部分。调度器在内核中维护一个任务表(也称进程表、线程表或调度表),然后按照一定的算法,从任务表中选择一个任务,然后把该任务放到处理器上运行,当任务运行的时间片到期后,再从任务表中找另外一个任务放到处理器上运行,周而复始,让任务表中的所有任务都有机会运行。正是因为有了调度器,多任务操作系统才能得以实现,它是多任务系统的核心,它的好坏直接影响了系统的效率。

任务并行这是用软件来切换任务模拟出来的假象,处理器只知道加电后按照程序计数器中的地址不断地执行下去, 在不断执行的过程中,我们把程序计数器中的下一条指令地址所组成的执行轨迹称为程序的控制执行流。

执行流对应于代码,大到可以是整个程序文件,即进程,小到可以是一个功能独立的代码块,即函数,而线程本质上就是函数。

执行流是独立的,它的独立性体现在每个执行流都有自己的栈、一套自己的寄存器映像和内存资源,这是Intel处理器在硬件上规定的,其实这正是执行流的上下文环境。

所以要切换任务就要保存上下文环境,这就引入了一定的性能开销。

说了这么多,我们软件中所做的任务切换,本质上就是改变了处理器中程序计数器的指向,即改变了处理器的“执行流”。

任务只是人为划分的、逻辑上的概念,人们把一个个的执行单元称为任务,我们所说的执行单元就是这些彼此独立的执行流,因此,独立的执行流成了调度器的调度单元,并使之成为了处理器的基本执行单位。

线程的作用


在线程中调用函数是让所运行的函数能够以调度单元的身份独立上处理器运行,当函数可以独立运行时,就会有更大的好处,那就是可以让程序中的多个函数(执行流)以并行(伪并行,实际并发)的方式运行。

进程和线程的关系

程序是指静态的、存储在文件系统上、尚未运行的指令代码,它是实际运行时程序的映像。

进程是指正在运行的程序,即进行中的程序,程序必须在获得运行所需要的各类资源后才能成为进程,资源包括进程所使用的栈,使用的寄存器等

对于处理器来说,线程是一种控制流集合,集合中至少包含一条执行流,执行流之间是相互独立的,但它们共享进程的所有资源,它们是处理器的执行单位,或者称为调度单位,它们就是线程。

按照进程中线程数量划分,进程分为单线程进程和多线程进程两种。我们平时所写的程序,如果其中未“显式”创建线程,它就属于单线程进程。

线程仅仅是个执行流。

因为线程必然属于某一进程,线程要运行必须要有相应的资源,而进程就是这个资源的提供者,因此线程存在于进程之中

任务其实就是执行流,要么是大的执行流,单线程的进程,要么是小的执行流线程。
进程采用多个执行流和其他进程抢处理器资源,这样就节省了单个进程的总执行时间。

进程拥有整个地址空间,其中包括各种资源,而进程中的所有线程共享同一个地址空间,原因很简单,因为这个地址空间中有线程运行所需要的资源。

由于各个进程都拥有自己的虚拟地址空间,正常情况下它们彼此无法访问到对方的内部,因为进程之间的安全性是由操作系统的分页机制来保证的,只要操作系统不要把相同的物理页分配给多个进程就行了。

但进程内的线程可都是共享这同一地址空间的,也就意味着任意一个线程都可以去访问同一进程内其他线程的数据。

强调下,只有线程才具备能动性,它才是处理器的执行单元,因此它是调度器眼中的调度单位。

进程只是个资源整合体,它将进程中所有线程运行时用到资源收集在一起,供进程中的所有线程使用,真正上处理器上运行的其实都叫线程,进程中的线程才是一个个的执行实体、执行流,因此,经调度器送上处理器执行的程序都是线程。

进程/线程的状态

把上述需要等待外界条件的状态称为“阻塞态”,把外界条件成立时,进程可以随时准备运行的状态称为“就绪态”,把正在处理器上运行的进程的状态称为“运行态”。

PCB(程序控制块)


PCB中的栈是0特权级下的内核栈,而不是一般的用户栈。通常PCB以页为单位划分。

线程的两种实现方式

  • 内核来实现线程,用户通过系统调用来使用线程。
  • 用户实现线程,进程自己维护线程表

在用户进程中实现线程有以下优点。

  • 线程的调度算法是由用户程序自己实现的,可以根据实现应用情况为某些线程加权调度。
  • 将线程的寄存器映像装载到CPU时,可以在用户空间完成,即不用陷入到内核态,这样就免去了进入内核时的入栈及出栈操作。

用户级线程也会有以下缺点。

  • 进程中的某个线程若出现了阻塞(通常是由于系统调用造成的),操作系统不知道进程中存在线程,它以为此进程是传统型进程(单线程进程),因此会将整个进程挂起,即进程中的全部线程都无法运行。
  • 线程未在内核空间中实现,因此对于操作系统来说,调度器的调度单元是整个进程,并不是进程中的线程,所以时钟中断只能影响进程一级的执行流。当时钟中断发生后,操作系统的调度器只能感知到进程一级的调度实体,它要么把处理器交给进程A,要么交给进程B,绝不可能交给进程中的某个线程,也就是说,要想让操作系统调度,操作系统得知道它的存在才行,但由于线程在用户空间中实现,线程属于进程自己的“家务事”,操作系统根本不知道它的存在。这就导致了:如果在用户空间中实现线程,但凡进程中的某个线程开始在处理器上执行后,只要该线程不主动让出处理器,此进程中的其他线程都没机会运行。也就是说,没有保险的机制使线程运行“适时”,即避免单一线程过度使用处理器,而其他线程没有调度的机会。这只能凭借开发人员“人为”地在线程中调用类似pthread_yield或pthread_exit之类的方法使线程发扬“高风亮节”让出处理器使用权,此类方法通过回调方式触发进程内的线程调度器,让调度器有机会选择进程内的其他线程上处理器运行。重复强调:这里所说的“线程让出处理器使用权”,不是将整个进程的处理器使用权通过操作系统调度器交给其他进程,而是将控制权交给此进程自己的线程调度器,由自己的调度器将处理器使用权交给此进程中的下一个线程。
  • 最后,线程在用户空间实现,和在内核空间实现相比,只是在内部调度时少了陷入内核的代价,确实相当于提速,但由于整个进程占据处理器的时间片是有限的,这有限的时间片还要再分给内部的线程,所以每个线程执行的时间片非常非常短暂,再加上进程内线程调度器维护线程表、运行调度算法的时间片消耗,反而抵销了内部调度带来的提速。

在内核空间中实现线程,这里所说的“实现线程”是指由内核提供原生线程机制,用户进程中不再单独实现。个人觉得,线程由内核来实现,进程才真正得到了较大幅度的提速,这是最大的优点,这体现在以下方面。

  • 相比在用户空间中实现线程,内核提供的线程相当于让进程多占了处理器资源,比如系统中运行有进程A和一传统型进程B,此时进程A中显式创建了3个线程,这样一来,进程A加上主线程便有了4个线程,加上进程B,内核调度器眼中便有了5个独立的执行流,尽管其中4个都属于进程A,但对调度器来说这4个线程和进程一样被调度,因此调度器调度完一圈后,进程A使用了80%的处理器资源,这才是真正的提速。
  • 另一方面的优点是当进程中的某一线程阻塞后,由于线程是由内核空间实现的,操作系统认识线程,所以就只会阻塞这一个线程,此线程所在进程内的其他线程将不受影响,这又相当于提速了。

缺点是用户进程需要通过系统调用陷入内核,这多少增加了一些现场保护的栈操作,这还是会消耗一些处理器时间,但和上面的大幅度提速相比,这不算什么大事。

在内核中实现线程

回顾一下中断处理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
[bits 32]
%define ERROR_CODE nop ; 若在相关的异常中cpu已经自动压入了错误码,为保持栈中格式统一,这里不做操作.
%define ZERO push 0 ; 若在相关的异常中cpu没有压入错误码,为了统一栈中格式,就手工压入一个0

extern idt_table ;idt_table是C中注册的中断处理程序数组

section .data
global intr_entry_table
intr_entry_table:

%macro VECTOR 2
section .text
intr%1entry: ; 每个中断处理程序都要压入中断向量号,所以一个中断类型一个中断处理程序,自己知道自己的中断向量号是多少

%2 ; 中断若有错误码会压在eip后面
; 以下是保存上下文环境
push ds
push es
push fs
push gs
pushad ; PUSHAD指令压入32位寄存器,其入栈顺序是: EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI

; 如果是从片上进入的中断,除了往从片上发送EOI外,还要往主片上发送EOI
mov al,0x20 ; 中断结束命令EOI
out 0xa0,al ; 向从片发送
out 0x20,al ; 向主片发送

push %1 ; 不管idt_table中的目标程序是否需要参数,都一律压入中断向量号,调试时很方便
call [idt_table + %1*4] ; 调用idt_table中的C版本中断处理函数
jmp intr_exit

section .data
dd intr%1entry ; 存储各个中断入口程序的地址,形成intr_entry_table数组
%endmacro

section .text
global intr_exit
intr_exit:
; 以下是恢复上下文环境
add esp, 4 ; 跳过中断号
popad
pop gs
pop fs
pop es
pop ds
add esp, 4 ; 跳过error_code
iretd
  1. 实现PCB里的栈,主要是中断栈和线程栈,其数据结构如下
  • 线程栈的作用主要是体现在其eip字段上,这个字段保存的是函数地址,可以通过它来进行控制流跳转
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
//thread.h
typedef void thread_func(void *);

//进程或者线程的状态
enum task_status {
TASK_RUNNING,
TASK_READY,
TASK_BLOCKED,
TASK_WAITING,
TASK_HANGING,
TASK_DIED
};

//中断栈intr_stack
//用于中断发生时保护程序(线程或者进程)的上下文环境:
//进程或者线程被外部中断或者软中断打断时,会按照此结构压入上下文寄存器
//intr_exit中的出栈操作是此结构的逆操作
//初始时此栈在线程自己的内核栈中位置固定,所在页的最顶端
struct intr_stack {
uint32_t vec_no; //kernel.S宏VECTOR中push %1压入的中断号
uint32_t edi;
uint32_t esi;
uint32_t ebp;
uint32_t esp_dummp; //虽然pushad会把esp也压入,但是esp是不断变化的,所以会被popad忽略
uint32_t ebx;
uint32_t edx;
uint32_t ecx;
uint32_t eax;
uint32_t gs;
uint32_t fs;
uint32_t es;
uint32_t ds;
//以下是cpu从低特权级进入高特权级时压入
uint32_t err_code; //error_code会被压入到eip之后
void (*eip)(void);

uint32_t cs;
uint32_t eflags;
void *esp;
uint32_t ss;
};

//线程栈 thread_stack
//线程自己的栈,用于存储线程中待执行的函数
//此结构在线程自己的内核栈中位置不固定,仅用在switch_to时保存线程环境
//实际位置取决于实际运行情况
struct thread_stack {
uint32_t ebp;
uint32_t ebx;
uint32_t edi;
uint32_t esi;

//线程第一次执行时,eip指向待调用的函数kernel_thread,其他时候,eip指向的是swich_to的返回地址
void (*eip)(thread_func *func, void *func_arg);

//以下仅供第一次被调度上cpu时使用
void (*unused_retaddr);
thread_func *function; //由kernel_thread所调用的函数名
void *func_arg;//由kernel_thread所调用的函数所需的参数
};

//进程或者线程的pcb,程序控制块
struct task_struct {
uint32_t *self_kstack; //各线程都用自己的内核栈
enum task_status status;
uint8_t priority;//线程优先级
char name[16];
uint32_t stack_magic;//栈的边界标记,用于检测栈的溢出
};


static void kernel_thread(thread_func *function, void *func_arg) {
function(func_arg);
}
  1. 线程PCB分配,初始化PCB,并运行线程
  • thread_start
    • get_kernel_pages
      • 不论是内核还是用户进程的pcb,都处于内核地址空间,所以分配一页内核空间
    • init_thread
      • 清空分配出来的页内存,防止有垃圾数据残留
      • 初始化线程基本信息,包括线程名、线程状态,线程在内核态使用的栈地址等,这里的栈地址就是我们分配出来的一页内存的最高地址,此后我们叫它内核栈
    • thread_create
      • 在内核栈里预留出顶部的空间保存inst_stack,线程进入中断后,会通过此栈来保存上下文
      • 在内核栈里分配空间保存thread_stack,线程的目的就是并发的运行控制流,这里线程执行的控制流单元我们认为是一个函数,所以我们需要能运行这个函数,thread_stack里保存kernel_thread函数的地址和真正要执行的函数的地址和它的参数,以及一些寄存器信息。kernel_thread就是一层wrapper,用来调用线程真正要执行的函数
    • 将esp指向thread_stack的最低处,依次pop ebp,ebx,edi,esi使之前初始化的0弹入到相应的寄存器里,ret把栈顶的数据,即kthread_stack->eip所赋的值kernel_thread装入到eip,跳转过去执行。
    • 此时我们将执行到eip指向的函数,线程初始化时其值为kernel_thread函数的地址,这个函数的定义如下,它就是调用function(func_arg)
      1
      2
      3
      static void kernel_thread(thread_func *function, void *func_arg) {
      function(func_arg);
      }
    • 还是解释下最后一点,当跳转到kernel_thread之后,就等价于我们在执行一个正常的函数,那么这个函数要使用参数就要通过访问ebp+0x8(放返回地址)+0x?,所以为了占位置,我们有一个字段unused_retaddr,因为我们并不会从kernel_threa函数返回,我们会一直执行function,所以这个地址从不会被用到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
void MyFun1(int x);
typedef void (*FunType)(int);
void CallMyFun(FunType fp,int x);

int main()
{
CallMyFun(MyFun1,10);
}

void CallMyFun(FunType fp,int x) //参数fp的类型是FunType。
{
fp(x);//通过fp的指针执行传递进来的函数,注意fp所指的函数是有一个参数的
}
void MyFun1(int x)
{
printf("函数MyFun1中输出:%d\n",x);
}
...
0000000100000f30 _CallMyFun:
; {
100000f30: 55 pushq %rbp
100000f31: 48 89 e5 movq %rsp, %rbp
100000f34: 48 83 ec 10 subq $16, %rsp
100000f38: 48 89 7d f8 movq %rdi, -8(%rbp)
100000f3c: 89 75 f4 movl %esi, -12(%rbp)
; fp(x);//通过fp的指针执行传递进来的函数,注意fp所指的函数是有一个参数的
100000f3f: 48 8b 45 f8 movq -8(%rbp), %rax
100000f43: 8b 7d f4 movl -12(%rbp), %edi
100000f46: ff d0 callq *%rax
; }
100000f48: 48 83 c4 10 addq $16, %rsp
100000f4c: 5d popq %rbp
100000f4d: c3 retq
100000f4e: 66 90 nop
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//thread.c
//...
//初始化线程栈thread_stack
//将待执行的函数和参数放到thread_stack中相应的位置
void thread_create(struct task_struct *pthread, thread_func function, void *func_arg) {
//先预留中断使用栈的空间
pthread->self_kstack -= sizeof(struct intr_stack);
//再留出线程栈空间
pthread->self_kstack -= sizeof(struct thread_stack);

struct thread_stack *kthread_stack = (struct thread_stack *) pthread->self_kstack;
kthread_stack->eip = kernel_thread;
kthread_stack->function = function;
kthread_stack->func_arg = func_arg;
kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
}

//初始化线程基本信息
void init_thread(struct task_struct *pthread, char *name, int prio) {
memset(pthread, 0, sizeof(*pthread));
strcpy(pthread->name, name);
pthread->status = TASK_RUNNING;
pthread->priority = prio;
//self_kstack是线程自己在内核态下使用的栈顶地址
pthread->self_kstack = (uint32_t *) ((uint32_t) pthread + PG_SIZE);
pthread->stack_magic = 0x19870916;//随意定义的magic number
}

//创建优先级为prio的线程,线程名为name,线程所执行的函数是function(func_arg)
struct task_struct *thread_start(char *name, int prio, thread_func function, void *func_arg) {
//pcb都位于内核空间,包括用户进程的pcb也在内核空间
struct task_struct *thread = static_cast<task_struct *>(get_kernel_pages(1));
init_thread(thread, name, prio);
thread_create(thread, function, func_arg);
// 将esp指向thread->self_kstack,即线程栈的最低处(低地址)
// 依次pop ebp,ebx,edi,esi使之前初始化的0弹入到相应的寄存器里
// ret把栈顶的数据,即kthread_stack->eip所赋的值kernel_thread装入到eip,跳转过去执行
asm volatile ("movl %0, %%esp; pop %%ebp; pop %%ebx; pop %%edi; pop %%esi; ret": : "g" (thread->self_kstack) : "memory");
return thread;
}

内核数据结构,双向链表

linux内核双向链表的经典实现

Linux中双向链表的使用思想:它是将双向链表节点嵌套在其它的结构体中;在遍历链表的时候,根据双链表节点的指针获取”它所在结构体的指针”,从而再获取数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
//list.h
#define offset(struct_type, member) (int)(&((struct_type*)0)->member)
#define elem2entry(struct_type, struct_member_name, elem_ptr) (struct_type*)((int)elem_ptr - offset(struct_type, struct_member_name))

//定义链表节点
struct list_elem {
struct list_elem *prev;//前驱
struct list_elem *next;//后继
};

//链表结构
struct list {
//head不保存元素,第一个元素是head.next
struct list_elem head;
//tail
struct list_elem tail;
};

typedef bool (function)(struct list_elem *, int arg);

void list_init(struct list *);

void list_insert_before(struct list_elem *before, struct list_elem *elem);

void list_push(struct list *plist, struct list_elem *elem);

void list_remove(struct list_elem *pelem);

struct list_elem *list_pop(struct list *plist);

bool elem_find(struct list *plist, struct list_elem *obj_elem);

bool list_empty(struct list *plist);

struct list_elem *list_traversal(struct list *plist, function func, int arg);

uint32_t list_len(struct list *plist);

/* 初始化双向链表 list */
void list_init(struct list *list) {
list->head.prev = NULL;
list->head.next = &list->tail;
list->tail.next = NULL;
list->tail.prev = &list->head;
}

/* 把链表元素 elem 插入在元素 before 之前 */
void list_insert_before(struct list_elem *before, struct list_elem *elem) {
enum intr_status old_status = intr_disable();
before->prev->next = elem;
elem->prev = before->prev;

elem->next = before;
before->prev = elem;
intr_set_status(old_status);
}

/* 添加元素到列表队首,类似栈 push 操作 */
void list_push(struct list *plist, struct list_elem *elem) {
list_insert_before(plist->head.next, elem);
}

/* 追加元素到链表队尾,类似队列的先进先出操作 */
void list_append(struct list *plist, struct list_elem *elem) {
list_insert_before(&plist->tail, elem);
}

/* 使元素 pelem 脱离链表 */
void list_remove(struct list_elem *pelem) {
enum intr_status old_status = intr_disable();
pelem->prev->next = pelem->next;
pelem->next->prev = pelem->prev;
intr_set_status(old_status);
}

/* 将链表第一个元素弹出并返回,类似栈的pop操作 */
struct list_elem *list_pop(struct list *plist) {
struct list_elem *elem = plist->head.next;
list_remove(elem);
return elem;
}

/* 从链表中查找元素 obj_elem,成功时返回 true,失败时返回 false */
bool elem_find(struct list *plist, struct list_elem *obj_elem) {
struct list_elem *elem = plist->head.next;
while (elem != &plist->tail) {
if (elem == obj_elem) {
return true;
}
elem = elem->next;
}
return false;
}

/* 判断链表是否为空,空时返回 true,否则返回 false */
bool list_empty(struct list *plist) {
if (plist->head.next == &plist->tail) {
return true;
} else {
return false;
}
}

/* 返回链表的长度 */
uint32_t list_len(struct list *plist) {
struct list_elem *elem = plist->head.next;
uint32_t length = 0;
while (elem->next != &plist->tail) {
length++;
elem = elem->next;
}
return length;
}

/* 把列表 plist 中的每个元素 elem 和 arg 传给回调函数 func,
* arg给func用来判断elem是否符合条件.
* 本函数的功能是遍历列表内所有元素,逐个判断是否有符合条件的元素。
* * 找到符合条件的元素返回元素指针,否则返回 NULL */

struct list_elem *list_traversal(struct list *plist, function func, int arg) {
struct list_elem *elem = plist->head.next;
if (list_empty(plist)) {
return NULL;
}
while (elem != &plist->tail) {
if (func(elem, arg)) {
return elem;
}
elem = elem->next;
}
return NULL;
}

简单优先级调度的准备

  • 为了支持多线程调度,我们需要修改pcb的定义。

    • general_tag: 类型是struct list_elem,也就是general_tag是双向链表中的结点。它是线程的标签,当线程被加入到就绪队列 thread_ready_list或其他等待队列中时,就把该线程PCB中 general_tag的地址加入队列。

    • all_list_tag: 的类型也是struct list_elem,它专用于线程被加入全部线程队列时使用。

    • pgdir: 是任务自己的页表。线程与进程的最大区别就是进程独享自己的地址空间,即进程有自己的页表,而线程共享所在进程的地址空间,即线程无页表。如果该任务为线程,pgdir则为NULL。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      //thread.h
      /* 进程或线程的pcb,程序控制块 */
      struct task_struct {
      uint32_t* self_kstack; // 各内核线程都用自己的内核栈
      enum task_status status;
      char name[16];
      uint8_t priority;
      uint8_t ticks; // 每次在处理器上执行的时间嘀嗒数

      /* 此任务自上cpu运行后至今占用了多少cpu嘀嗒数,
      * 也就是此任务执行了多久*/
      uint32_t elapsed_ticks;

      /* general_tag的作用是用于线程在一般的队列中的结点 */
      struct list_elem general_tag;

      /* all_list_tag的作用是用于线程队列thread_all_list中的结点 */
      struct list_elem all_list_tag;

      uint32_t* pgdir; // 进程自己页表的虚拟地址
      uint32_t stack_magic; // 用这串数字做栈的边界标记,用于检测栈的溢出
      };
  • 区分主线程和其他线程

    • 定义就绪队列和所有任务队列,以及主线程pcb
    • init_thread
      • 区分main_thread和其他线程,区别在于main_thread在init的时候已经在运行了,它的状态是TASK_RUNNING,而其他线程则是TASK_READY,等待被调度到CPU执行。
    • thread_start和之前的逻辑区别不大,主要增加的部分在于
      • 增加对时间片和页表的初始化
      • 在start里把当前thread加到就绪队列和所有任务队列里
      • 移除start里的跳转到function执行
    • 定义make_main_thread函数,其作用是
      • 因为main线程早已运行,咱们在loader.S中进入内核时的mov esp,0xc009f000,就是为其预留了tcb,地址为0xc009e000,因此不需要通过get_kernel_page另分配一页,直接将esp&0xfffff000找到当前页的基地址就是pcb的地址。
      • 通过init_thread来初始化主线程pcb的基本信息即可。
      • main函数是当前线程,当前线程不在thread_ready_list中,所以只将其加在thread_all_list中.
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        76
        77
        78
        79
        80
        81
        82
        83
        84
        85
        86
        87
        88
        struct task_struct *main_thread; //主线程PCB
        struct list thread_ready_list; //就绪队列
        struct list thread_all_list;//所有任务队列
        static struct list_elem *thread_tag;//用于保存队列中的线程节点

        extern void switch_to(struct task_struct *cur, struct task_struct *next);

        //获取当前线程的pcb指针
        struct task_struct *running_thread() {
        uint32_t esp;
        asm("mov %%esp, %%0" : "=g"(esp));
        //取esp的整数部分,即pcb的起始地址
        return (struct task_struct *) (esp & 0xfffff000);
        }


        static void kernel_thread(thread_func *function, void *func_arg) {
        /* 执行function前要开中断,避免后面的时钟中断被屏蔽,而无法调度其它线程 */
        intr_enable();
        function(func_arg);
        }

        /* 初始化线程基本信息 */
        void init_thread(struct task_struct* pthread, char* name, int prio) {
        memset(pthread, 0, sizeof(*pthread));
        strcpy(pthread->name, name);

        if (pthread == main_thread) {
        /* 由于把main函数也封装成一个线程,并且它一直是运行的,故将其直接设为TASK_RUNNING */
        pthread->status = TASK_RUNNING;
        } else {
        pthread->status = TASK_READY;
        }

        /* self_kstack是线程自己在内核态下使用的栈顶地址 */
        pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE);
        pthread->priority = prio;
        pthread->ticks = prio;
        pthread->elapsed_ticks = 0;
        pthread->pgdir = NULL;
        pthread->stack_magic = 0x19870916; // 自定义的魔数
        }

        //初始化线程栈thread_stack
        //将待执行的函数和参数放到thread_stack中相应的位置
        void thread_create(struct task_struct *pthread, thread_func function, void *func_arg) {
        //先预留中断使用栈的空间
        pthread->self_kstack -= sizeof(struct intr_stack);
        //再留出线程栈空间
        pthread->self_kstack -= sizeof(struct thread_stack);

        struct thread_stack *kthread_stack = (struct thread_stack *) pthread->self_kstack;
        kthread_stack->eip = kernel_thread;
        kthread_stack->function = function;
        kthread_stack->func_arg = func_arg;
        kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
        }


        //创建优先级为prio的线程,线程名为name,线程所执行的函数是function(func_arg)
        struct task_struct *thread_start(char *name, int prio, thread_func function, void *func_arg) {
        //pcb都位于内核空间,包括用户进程的pcb也在内核空间
        struct task_struct *thread = static_cast<task_struct *>(get_kernel_pages(1));
        init_thread(thread, name, prio);
        thread_create(thread, function, func_arg);
        // 确保之前不在队列中
        assert(!elem_find(&thread_ready_list, &thread->general_tag));
        // 加入就绪线程队列
        list_append(&thread_ready_list, &thread->general_tag);
        // 确保之前不在队列里
        assert(!elem_find(&thread_all_list, &thread->all_list_tag));
        // 加入全部线程队列
        list_append(&thread_all_list, &thread->all_list_tag);
        return thread;
        }

        //将kernel中的main函数完善为主线程
        static void make_main_thread(void) {
        /* 因为main线程早已运行,咱们在loader.S中进入内核时的mov esp,0xc009f000,
        就是为其预留了tcb,地址为0xc009e000,因此不需要通过get_kernel_page另分配一页*/
        main_thread = running_thread();
        init_thread(main_thread, "main", 31);

        /* main函数是当前线程,当前线程不在thread_ready_list中,
        * 所以只将其加在thread_all_list中. */
        assert(!elem_find(&thread_all_list, &main_thread->all_list_tag));
        list_append(&thread_all_list, &main_thread->all_list_tag);
        }

任务调度器和任务切换

调度器的工作就是根据任务的状态将其从处理器上换上换下,调度器主要任务就是读写就绪队列,增删里面的结点,结点是线程PCB中的general_tag,相当于”线程的PCB。

线程每次在处理器上的执行时间是由其ticks决定的,我们在初始化线程的时候,已经将线程PCB中的ticks赋值为prio,优先级越高,ticks越大。每发生一次时钟中断,时钟中断的处理程序便将当前运行线程的ticks减1。

当ticks为0时,时钟的中断处理程序调用调度器schedule,这说明时间片到期了,将其ticks重新赋值为它的优先级prio,将其状态由TASK_RUNNING置为TASK_READY,并将其加入到就绪队列的末尾,让调度器选择另一个线程上处理器。

调度器是从就绪队列thread_ready_list中“取出”上处理器运行的线程,所有待执行的线程都在thread_ready_list中,让候选线程按顺序一个一个地执行,咱们就是按先进先出的顺序始终调度队头的线程。

调度器按照队列先进先出的顺序,把就绪队列中的第1个结点作为下一个要运行的新线程,将该线程的状态置为TASK_RUNNING,之后通过函数switch_to将新线程的寄存器环境恢复,这样新线程便开始执行。

因此,完整的调度过程需要三部分的配合。

  • 时钟中断处理函数
  • 调度器schedule
  • 任务切换函数 switch_to

时钟中断处理函数

时钟中断函数主要的逻辑如下:

  • 检测栈是否溢出,因为内核栈是放在PCB上面的,栈不断向下增长,就可能覆盖掉PCB里保存的基本信息。
  • 此线程总共占用的cpu时间加一
  • 从内核第一次处理时间中断后开始至今的滴哒数加一
  • 如果进程时间片用完就调度新的进程上CPU
  • 否则,当前进程的时间片减一
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    uint32_t ticks; //ticks是内核自中断开启以来总共的嘀嗒数

    /* 时钟的中断处理函数 */
    static void intr_timer_handler(void) {
    struct task_struct* cur_thread = running_thread();

    ASSERT(cur_thread->stack_magic == 0x19870916); // 检查栈是否溢出

    cur_thread->elapsed_ticks++; // 记录此线程占用的cpu时间嘀
    ticks++; //从内核第一次处理时间中断后开始至今的滴哒数,内核态和用户态总共的嘀哒数

    if (cur_thread->ticks == 0) { // 若进程时间片用完就开始调度新的进程上cpu
    schedule();
    } else { // 将当前进程的时间片-1
    cur_thread->ticks--;
    }
    }
    ...
    /* 初始化PIT8253 */
    void timer_init() {
    put_str("timer_init start\n");
    /* 设置8253的定时周期,也就是发中断的周期 */
    frequency_set(CONTRER0_PORT, COUNTER0_NO, READ_WRITE_LATCH, COUNTER_MODE, COUNTER0_VALUE);
    register_handler(0x20, intr_timer_handler);
    put_str("timer_init done\n");
    }
    ...
    /* 在中断处理程序数组第vector_no个元素中注册安装中断处理程序function */
    void register_handler(uint8_t vector_no, intr_handler function) {
    /* idt_table数组中的函数是在进入中断后根据中断向量号调用的,
    * 见kernel/kernel.S的call [idt_table + %1*4] */
    idt_table[vector_no] = function;
    }

实现调度器schedule

  • schedule可能在两种情况下被调用,一种是正在运行的进程的时间片用完,另外是进程可能需要一些资源,需要堵塞到这个资源被给了才能运行。

    • 如果此线程只是cpu时间片到了,将其加入到就绪队列尾,并重置时间片为priority的值

    • 若此线程需要某事件发生后才能继续上cpu运行,不需要将其加入队列,因为当前线程不在就绪队列中。

    • 将就绪队列中的第一个就绪线程弹出,准备将其调度上cpu.

    • 注意切换线程使用的是switch_to函数,它的参数是当前线程和将要执行的线程的PCB(即task_struct)的地址,但是我们的pop返回的是一个list_elem *,它保存的是PCB里general_tag的地址,而不是PCB的首地址。

    • 但是因为general_tag是结构体task_struct里的一个字段,所以它在这个结构体里的偏移是始终固定的,就可以直接减去偏移拿到首地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//thread.c
//实现任务调度
void schedule() {

ASSERT(intr_get_status() == INTR_OFF);

struct task_struct* cur = running_thread();
if (cur->status == TASK_RUNNING) { // 若此线程只是cpu时间片到了,将其加入到就绪队列尾
ASSERT(!elem_find(&thread_ready_list, &cur->general_tag));
list_append(&thread_ready_list, &cur->general_tag);
cur->ticks = cur->priority; // 重新将当前线程的ticks再重置为其priority;
cur->status = TASK_READY;
} else {
/* 若此线程需要某事件发生后才能继续上cpu运行,
不需要将其加入队列,因为当前线程不在就绪队列中。*/
}

ASSERT(!list_empty(&thread_ready_list));
thread_tag = NULL; // thread_tag清空
/* 将thread_ready_list队列中的第一个就绪线程弹出,准备将其调度上cpu. */
thread_tag = list_pop(&thread_ready_list);
struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);
next->status = TASK_RUNNING;
switch_to(cur, next);
}
1
2
3
4
5
//list.h
#define offset(struct_type,member) (int)(&((struct_type*)0)->member)

#define elem2entry(struct_type, struct_member_name, elem_ptr) \
(struct_type*)((int)elem_ptr - offset(struct_type, struct_member_name))

实现任务切换函数switch_to

程序所做的完整工作可以分为两部分,一部分是“重要工作”,这由操作系统代码来完成,另一部分是“普通工作”,这由用户代码完成。

实际上,完整的程序就也因此分为两部分,一部分是做重要工作的内核级码,另一部分就是做普通工作的用户级代码。所以,“完整的程序=用户代码+内核代码”。而这个完整的程序就是我们所说的任务,也就是线程或进程,也就是说,任务在执行过程中会执行用户代码和内核代码,当处理器处于低特权级下执行用户代码时我们称之为用户态,当处理器进入高特权级执行到内核代码时,我们称之为内核态,当处理器从用户代码所在的低特权级过渡到内核代码所在的高特权级时,这称为陷入内核。

因此一定要清楚,无论是执行用户代码,还是执行内核代码,这些代码都属于这个完整的程序,即属于当前任务,并不是说当前任务由用户态进入内核态后当前任务就切换成内核了,这样理解是不对的。任务与任务的区别在于执行流一整套的上下文资源,这包括寄存器映像、地址空间、IO位图等,在将来介绍任务状态段TSS之后,您就会了解这套上下文资源恰恰就是TSS结构中的内容,拥有这些资源才称得上是任务。

因此,处理器只有被新的上下文资源重新装载后,当前任务才被替换为新的任务,这才叫任务切换。 当任务进入内核态时,其上下文资源并未完全替换,只是执行了“更厉害”的代码。这有点像咱们在游戏中打怪,用不同的武器打不同的怪,游戏的人物角色始终没有变。

每个任务都有个执行流,这都是事先规划好的执行路径,按道理应该是从头执行到结束。

不过实际的情况是执行流经常被临时改道,突然就执行了规划外的指令,这在多任务系统中是很正常的,因为操作系统是由中断驱动的,每一次中断都将使处理器放下手头的工作转去执行中断处理程序。

为了在中断处理完成后能够恢复任务原有的执行路径,必须在执行流被改变前,将任务的上下文保护好。执行流被改变后,在其后续的执行过程中还可能会再次发生被改变“流向”的情况,也就是说随着执行的深入,这种改变的深度很可能是多层的。

如果希望将来能够返回到本层的执行流,依然要在改变前保护好本层的上下文

总之,凡是涉及到执行流的改变,不管被改变了几层,为了将来能够恢复到本层继续执行,必须在改变发生前将本层执行流的上下文保护好。因此,执行流被改变了几层就要做几次上下文保护。

(1)上下文保护的第一部分负责保存任务进入中断前的全部寄存器,目的是能让任务恢复到中断前。

(2)上下文保护的第二部分负责保存这4个寄存器:esi、edi、ebx和ebp,目的是让任务恢复执行在任务切换发生时剩下尚未执行的内核代码,保证顺利走到退出中断的出口,利用第一部分保护的寄存器环境彻底恢复任务。(其实这4个寄存器主要是用来恢复主调函数的环境,只是当前我们在讨论内核函数

最终switch_to的实现逻辑如下

  • 保存当前线程的上下文环境
    • 被调函数压栈保存4个寄存器:esi、edi、ebx和ebp
    • 取switch_to的参数cur的值,cur保存当前线程的PCB的地址,因为self_kstack是task_struct(PCB)中的第一个字段,所以PCB的地址也就是self_kstack的地址
    • 将当前栈顶指针esp保存到当前线程PCB中的self_kstack成员中
  • 恢复下一个线程的环境
    • 得到栈中的参数next,pcb的第一个成员是self_kstack成员,用来记录0级栈顶指针,用来上cpu时恢复0级栈,0级栈中保存了进程或线程所有信息,包括3级栈指针
    • 所以我们从self_kstack中取出之前保存的内核栈的esp,赋值给当前esp,从而从当前栈,切换到了内核栈
    • ret返回到返回地址,如果此时的next线程之前尚未执行过,马上开始的是第一次执行,此时栈顶的值是函数kernel_thread的地址,这是由thread_create函数设置的,执行ret指令后处理器将去执行函数kernel_thread。如果next之前已经执行过了,这次是再次将其调度到处理器的话,此时栈顶的值是由调用函数switch_to的主调函数schedule留下的,这会继续执行schedule后面的流程。而switch_to是schedule最后一句代码,因此执行流程马上回到schedule的调用者intr_timer_handler中。schedule同样也是intr_timer_handler中最后一句代码,因此会完成intr_timer_handler,回到kernel.S中的jmpintr_exit,从而恢复任务的全部寄存器映像,之后通过iretd指令退出中断,任务被完全彻底地恢复。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//switch.S
[bits 32]
section .text
global switch_to
switch_to:
;栈中此处是返回地址
push esi
push edi
push ebx
push ebp

mov eax, [esp + 20] ; 得到栈中的参数cur, cur = [esp+20]
mov [eax], esp ; 保存栈顶指针esp. task_struct的self_kstack字段,
; self_kstack在task_struct中的偏移为0,
; 所以直接往thread开头处存4字节便可。
;------------------ 以上是备份当前线程的环境,下面是恢复下一个线程的环境 ----------------
mov eax, [esp + 24] ; 得到栈中的参数next, next = [esp+24]
mov esp, [eax] ; pcb的第一个成员是self_kstack成员,用来记录0级栈顶指针,
; 用来上cpu时恢复0级栈,0级栈中保存了进程或线程所有信息,包括3级栈指针
pop ebp
pop ebx
pop edi
pop esi
ret ; 返回到上面switch_to下面的那句注释的返回地址,
; 未由中断进入,第一次执行时会返回到kernel_thread

启用线程调度用运行

1
2
3
4
5
6
7
8
9
10
//thread.c
/* 初始化线程环境 */
void thread_init(void) {
put_str("thread_init start\n");
list_init(&thread_ready_list);
list_init(&thread_all_list);
/* 将当前main函数创建为线程 */
make_main_thread();
put_str("thread_init done\n");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//main.c
#include "print.h"
#include "init.h"
#include "thread.h"
#include "interrupt.h"

void k_thread_a(void*);
void k_thread_b(void*);
int main(void) {
put_str("I am kernel\n");
init_all();

thread_start("k_thread_a", 31, k_thread_a, "argA ");
thread_start("k_thread_b", 8, k_thread_b, "argB ");

intr_enable(); // 打开中断,使时钟中断起作用
while(1) {
put_str("Main ");
};
return 0;
}

/* 在线程中运行的函数 */
void k_thread_a(void* arg) {
/* 用void*来通用表示参数,被调用的函数知道自己需要什么类型的参数,自己转换再用 */
char* para = arg;
while(1) {
put_str(para);
}
}

/* 在线程中运行的函数 */
void k_thread_b(void* arg) {
/* 用void*来通用表示参数,被调用的函数知道自己需要什么类型的参数,自己转换再用 */
char* para = arg;
while(1) {
put_str(para);
}
}

直接执行会崩溃,这其实是因为显存打印的时候,线程对进程内全局资源的条件竞争造成的,具体不述。

我还是先记录一下整个main函数从启动到执行的过程

调试方法是先用nm找到符号地址,然后lb下断即可

1
2
3
4
5
6
sakura@ubuntu:~/kernel_learn/bochs-2.6.2/bin$ nm build/kernel.bin
c0001ef7 t intr0x20entry
c0001ab1 t intr_timer_handler
c0002f60 T schedule
c00032b0 T switch_to
c0002ce7 t kernel_thread

首先内核启动就一直在执行main函数,我们通过make_main_thread调用init_thread在为内核进程预留的PCB地址0xc009e000创建一个task_struct,设置其时间片,然后将其加入thread_all_list队列里。

然后开启两个新线程k_thread_a和k_thread_b,然后打开中断。

然后当中断开启,则每次时钟滴答一次,就进入一次时钟中断,我们根据intr0x20entry的符号地址下断。

main函数循环打印”Main”,并在时间片用完的时候被调度下CPU。

我们先观察一下进入中断时候的寄存器保存情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(0) [0x000000001ef9] 0008:c0001ef9 (unk. ctxt): push ds                   ; 1e
<bochs:4> reg
eax: 0x00000000 0
ecx: 0xc00032f7 -1073728777
edx: 0xc0101028 -1072689112
ebx: 0x00070094 458900
esp: 0xc009efc0 -1073090624
ebp: 0xc009efe8 -1073090584
esi: 0x00070000 458752
edi: 0x00000000 0
eip: 0xc0001ef9
...
<bochs:5> x/10wx 0xc009e000
[bochs]:
0xc009e000 <bogus+ 0>: 0xc009f000

可见直接压栈,而不是保存到0xc009e000处。

然后我们再看一下内核栈顶是怎么变化的,这其实发生在switch_to上。

此时就被调度下CPU,进入switch_to,它先保存一些寄存器到栈上,用于返回,然后将此时的栈顶保存到0xc009e000即kstack上,这就保存好了main的上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
(0) [0x0000000032b0] 0008:c00032b0 (unk. ctxt): push esi                  ; 56
<bochs:38> x/10wx 0xc009e000
[bochs]:
0xc009e000 <bogus+ 0>: 0xc009f000 0x00000001 0x6e69616d 0x00000000
0xc009e010 <bogus+ 16>: 0x00000000 0x00000000 0x00001f1f 0x00000020
0xc009e020 <bogus+ 32>: 0xc0101020 0xc000557c
<bochs:39> s
Next at t=18216965
(0) [0x0000000032b1] 0008:c00032b1 (unk. ctxt): push edi ; 57
<bochs:40>
Next at t=18216966
(0) [0x0000000032b2] 0008:c00032b2 (unk. ctxt): push ebx ; 53
<bochs:41>
Next at t=18216967
(0) [0x0000000032b3] 0008:c00032b3 (unk. ctxt): push ebp ; 55
<bochs:42>
Next at t=18216968
(0) [0x0000000032b4] 0008:c00032b4 (unk. ctxt): mov eax, dword ptr ss:[esp+20] ; 8b442414
<bochs:43>
Next at t=18216969
(0) [0x0000000032b8] 0008:c00032b8 (unk. ctxt): mov dword ptr ds:[eax], esp ; 8920
...
<bochs:46> x/10wx 0xc009e000
[bochs]:
0xc009e000 <bogus+ 0>: 0xc009ef04 0x00000001 0x6e69616d 0x00000000
0xc009e010 <bogus+ 16>: 0x00000000 0x00000000 0x00001f1f 0x00000020
0xc009e020 <bogus+ 32>: 0xc0101020 0xc000557c
<bochs:47> reg
eax: 0xc009e000 -1073094656
ecx: 0x00000061 97
edx: 0xc0005574 -1073719948
ebx: 0xc00032f8 -1073728776
esp: 0xc009ef04 -1073090812
ebp: 0xc009ef40 -1073090752
esi: 0x00070000 458752
edi: 0x00000000 0
eip: 0xc00032ba

然后我们要调度thread_a上CPU,这就要先把a的内核栈换上esp,然后pop一系列寄存器,恢复thread_a的上下文环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
(0) [0x0000000032be] 0008:c00032be (unk. ctxt): mov esp, dword ptr ds:[eax] ; 8b20

<bochs:50> reg
eax: 0xc0100000 -1072693248
ecx: 0x00000061 97
edx: 0xc0005574 -1073719948
ebx: 0xc00032f8 -1073728776
esp: 0xc0100e50 -1072689584
...
<bochs:51> x/10wx 0xc0100000
[bochs]:
0xc0100000 <bogus+ 0>: 0xc0100e50 0x00000000 0x68745f6b 0x64616572
0xc0100010 <bogus+ 16>: 0x0000615f

0xc0100000就是a的PCB表,它的第一个成员就是kstack,即内核栈指针。

1
2
0xc0100e50 <bogus+      32>:	|0x00000000	0x00000000	0x00000000	0x00000000|->ebp ebx edi esi
0xc0100e60 <bogus+ 48>: 0xc0002ce7->返回地址,此时是kernel_thread的地址

进入kernel_thread看看

1
2
3
4
5
6
7
(0) [0x000000002ce7] 0008:c0002ce7 (unk. ctxt): push ebp                  ; 55
...
<bochs:60> print-stack
Stack address size 4
| STACK 0xc0100e64 [0x00000000]-->不使用的返回地址
| STACK 0xc0100e68 [0xc000156f]-->function地址
| STACK 0xc0100e6c [0xc00032d5]-->function参数地址

而这个function地址就是线程a要执行的函数k_thread_a了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void kernel_thread(thread_func* function, void* func_arg) {
/* 执行function前要开中断,避免后面的时钟中断被屏蔽,而无法调度其它线程 */
intr_enable();
function(func_arg);
}
...
c000156f T k_thread_a
...
(0) [0x000000002cf5] 0008:c0002cf5 (unk. ctxt): push dword ptr ss:[ebp+12] ; ff750c
<bochs:91>
Next at t=18217008
(0) [0x000000002cf8] 0008:c0002cf8 (unk. ctxt): mov eax, dword ptr ss:[ebp+8] ; 8b4508
<bochs:92>
Next at t=18217009
(0) [0x000000002cfb] 0008:c0002cfb (unk. ctxt): call eax ; ffd0
...
(0) [0x00000000156f] 0008:c000156f (unk. ctxt): push ebp ; 55

这样就完成了一次线程调度。

同步机制-锁

之前我们提到了,因为不同的线程竞争显存,导致了条件竞争崩溃。

一个“可能是问题”的“少字符”现象,它是由于对公共资源“显存”未实现互斥访问造成的。另一个就是GP异常,它是对公共资源“光标寄存器”未实现互斥访问造成 的。这两个现场过程就是竞争条件。处理器执行指令过程中,即使发生了中断,也会先把当前的指令执行完再处理中断,不会出现指令只执行部分的情况。因此,单条指令的执行具有原子性。


所以我们如果在put_str前后先关中断,再开中断,让put_str整体变成一个原子操作(即不会被打断),就不会出现这样的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void k_thread_a(void*);
void k_thread_b(void*);
int main(void) {
put_str("I am kernel\n");
init_all();

thread_start("k_thread_a", 31, k_thread_a, "argA ");
thread_start("k_thread_b", 8, k_thread_b, "argB ");

intr_enable(); // 打开中断,使时钟中断起作用
while(1) {
intr_disable();
put_str("Main ");
intr_enable();
};
return 0;
}

/* 在线程中运行的函数 */
void k_thread_a(void* arg) {
/* 用void*来通用表示参数,被调用的函数知道自己需要什么类型的参数,自己转换再用 */
char* para = arg;
while(1) {
intr_disable();
put_str(para);
intr_enable();
}
}

/* 在线程中运行的函数 */
void k_thread_b(void* arg) {
/* 用void*来通用表示参数,被调用的函数知道自己需要什么类型的参数,自己转换再用 */
char* para = arg;
while(1) {
intr_disable();
put_str(para);
intr_enable();
}
}

但这样直接在put_str前后加代码只是权宜之计,这就引出了锁的概念。

代码中的临界区、互斥、竞争条件

  • 公共资源
    • 可以是公共内存、公共文件、公共硬件等,总之是被所有任务共享的一套资源。
  • 临界区
    • 程序要想使用某些资源,必然通过一些指令去访问这些资源,若多个任务都访问同一公共资源,那么各任务中访问公共资源的指令代码组成的区域就称为临界区。怕有同学看得不仔细,强调一下,临界区是指程序中那些访问公共资源的指令代码,即临界区是指令,并不是受访的静态公共资源。
  • 互斥
    • 互斥也可称为排他,是指某一时刻公共资源只能被 1 个任务独享,即不允许多个任务同时出现在自己的临界区中。公共资源在任意时刻只能被一个任务访问,即只能有一个任务在自己的临界区中执行,其他任务想访问公 共资源时,必须等待当前公共资源的访问者完全执行完他自己的临界区代码后(使用完资源后)再开始访问。
  • 竞争条件
    • 竞争条件是指多个任务以非互斥的方式同时进入临界区,大家对公共资源的访问是以竞争的方式并行进行的,因此公共资源的最终状态依赖于这些任务的临界区中的微操作执行次序。

如上我们测试的,关中断是实现互斥最简单的方法,没有之一。

虽然关中断可以实现互斥,但关中断的操作应尽量靠近临界区,这样才更高效,毕竟临界区中的代码才用于访问公共资源,而访问公共资源的时候才需要互斥、排他,各任务临界区之外的代码并不会和其他任务有冲突。关中断操作离临界区越远,多任务调度越低效。

信号量

我们的锁是用信号量来实现的,在计算机中,信号量就是个0以上的整数值,当为0时表示已无可用信号,或者说条件不再允许,因此它表示某种信号的累积“量”,故称为信号量。

虽然信号量听上去和信号相似,像是某种通信机制,但其实它和通信无关,信号量是种同步机制。

既然信号量是计数值,必然要有对计数增减的方法。

增加操作up包括两个微操作。

  1. 将信号量的值加1。
  2. 唤醒在此信号量上等待的线程。

减少操作down包括三个子操作。

  1. 判断信号量是否大于0。
  2. 若信号量大于0,则将信号量减1。
  3. 若信号量等于0,当前线程将自己阻塞,以在此信号量上等待。

信号量是个全局共享变量,up和down又都是读写这个全局变量的操作,而且它们都包含一系列的子操作,因此它们必须都是原子操作。

信号量的初值代表是信号资源的累积量,也就是剩余量,若初值为1的话,它的取值就只能为0和1,这便称为二元信号量,我们可以利用二元信号量来实现锁。

在二元信号量中,down操作就是获得锁,up操作就是释放锁。我们可以让线程通过锁进入临界区,可以借此保证只有一个线程可以进入临界区,从而做到互斥。大致流程为:

  1. 线程A进入临界区前先通过down操作获得锁(我们有强制通过锁进入临界区的手段),此时信号量的值便为0。
  2. 后续线程B再进入临界区时也通过down操作获得锁,由于信号量为0,线程B便在此信号量上等待,也就是相当于线程B进入了睡眠态。
  3. 当线程A从临界区出来后执行up操作释放锁,此时信号量的值重新变成1,之后线程A将线程B唤醒。
  4. 线程B醒来后获得了锁,进入临界区。

线程的阻塞与唤醒

我们用函数 thread_block 实现了线程阻塞,用函数 thread_unblock 实现了线程唤醒。

  • thread_block(task_status stat)
    • 将当前线程的状态置为stat,从而实现线程的阻塞
    • stat 取值范围是 TASK_BLOCKED、TASK_WAITING 和 TASK_HANGING
    • 请求schedule将自己换下CPU
    • 在调用 schedule 之后,下面的中断状态恢复代码 intr_set_status(old_status)本次便没机会执行了,只有在当前线程被唤醒后才会被执行到。
  • thread_unblock(pthread)
    • 参数pthread指向的是目前已经被阻塞,又希望被唤醒的线程
    • 通过list_push将这个被堵塞的线程重新添加到就绪队列最前面,从而保证其被优先调度
    • 最后将线程的status置为TASK_READY。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 当前线程将自己阻塞,标志其状态为stat. */
void thread_block(enum task_status stat) {
/* stat取值为TASK_BLOCKED,TASK_WAITING,TASK_HANGING,也就是只有这三种状态才不会被调度*/
ASSERT(((stat == TASK_BLOCKED) || (stat == TASK_WAITING) || (stat == TASK_HANGING)));
enum intr_status old_status = intr_disable();
struct task_struct* cur_thread = running_thread();
cur_thread->status = stat; // 置其状态为stat
schedule(); // 将当前线程换下处理器
/* 待当前线程被解除阻塞后才继续运行下面的intr_set_status */
intr_set_status(old_status);
}

/* 将线程pthread解除阻塞 */
void thread_unblock(struct task_struct* pthread) {
enum intr_status old_status = intr_disable();
ASSERT(((pthread->status == TASK_BLOCKED) || (pthread->status == TASK_WAITING) || (pthread->status == TASK_HANGING)));
if (pthread->status != TASK_READY) {
ASSERT(!elem_find(&thread_ready_list, &pthread->general_tag));
if (elem_find(&thread_ready_list, &pthread->general_tag)) {
PANIC("thread_unblock: blocked thread in ready_list\n");
}
list_push(&thread_ready_list, &pthread->general_tag); // 放到队列的最前面,使其尽快得到调度
pthread->status = TASK_READY;
}
intr_set_status(old_status);
}

锁的实现

  • struct semaphore
    • 信号量要有初值,因此该结构中包含了成员value,对信号量进行down操作时,若信号量值为0就会阻塞线程
    • waiters,用它来记录在此信号量上等待(阻塞)的所有线程。
  • struct lock
    • 谁成功申请了锁,就应该记录锁被谁持有,这是用成员holder记录的,表示锁的持有者。
    • 锁结构中必须包含一个信号量成员,这里就是semaphore,它就是信号量结构体struct semaphore实例。将来此信号量的初值会被赋值为1,也就是用二元信号量实现锁。
    • holder_repeat_nr用来累积锁的持有者重复申请锁的次数,释放锁的时候会参考此变量的值。原因是一般情况下我们应该在进入临界区之前加锁,但有时候可能持有了某临界区的锁后,在未释放锁之前,有可能会再次调用重复申请此锁的函数,这样一来,内外层函数在释放锁时会对同一个锁释放两次,为了避免这种情况的发生,用此变量来累积重复申请的次数,释放锁时会根据变量holder_repeat_nr的值来执行具体动作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//sync.h
/* 信号量结构 */
struct semaphore {
uint8_t value;
struct list waiters;
};

/* 锁结构 */
struct lock {
struct task_struct* holder; // 锁的持有者
struct semaphore semaphore; // 用二元信号量实现锁
uint32_t holder_repeat_nr; // 锁的持有者重复申请锁的次数
};

void sema_init(struct semaphore* psema, uint8_t value);
void sema_down(struct semaphore* psema);
void sema_up(struct semaphore* psema);
void lock_init(struct lock* plock);
void lock_acquire(struct lock* plock);
void lock_release(struct lock* plock);
  • sema_init
    • 初始化信号量
    • 为信号量赋初值
    • 初始化信号量的等待队列
  • sema_down
    • 信号量down操作
    • 关中断来保证原子操作
    • 当信号量的值为1时,down操作才会成功返回,否则就在该信号上阻塞。这里通过while(psema->value == 0)判断信号量是否为0,如果为0,就进入while的循环体做两件事。
      • 将自己添加到该信号量的等待队列中。
      • 将自己阻塞,状态为TASK_BLOCKED
      • 这里之所以用while(psema->value == 0)判断而不是if(psema->value == 0)是因为锁本身也是公共的资源,想要获得锁的线程不止一个,当阻塞的线程被唤醒后,也只是再次获得了去竞争锁的机会而已。
    • 恢复之前的中断状态
  • sema_up
    • 关中断,保证原子操作
    • sema_up是使信号量加1,这表示有信号资源可用了,也就是其他线程可以申请锁了
    • 从信号量的等待队列里弹出队首线程,并通过thread_unblock(thread_blocked)将此线程唤醒,提醒一下,所谓的唤醒并不是指马上就运行,而是重新加入到就绪队列,将来可以参与调度,运行是将来的事。而且当前是在关中断的情况下,所以调度器并不会被触发。因此不用担心线程已经加到就绪队列中,但value的值还没变成1会导致出错。
  • lock_acquire(plock)
    • plock是要获取的锁,函数功能是获取锁plock
    • 有时候,线程可能会嵌套申请同一把锁,这种情况下再申请锁,就会形成死锁,即自己在等待自己释放锁
    • 因此,在函数开头先判断自己是否已经是该锁的持有者,即代码if(plock->holder != running_thread())。如果持有者已经是自己,就将变量holder_repeat_nr++,除此之外什么都不做,然后函数返回。
    • 如果自己尚未持有此锁的话,通过sema_down(&plock->semaphore)将锁的信号量减1
    • 成功后将当前线程记为锁的持有者,即 plock->holder = running_thread(),然后将holder_repeat_nr置为 1,表示第1次申请了该锁。
  • lock_release(plock)
    • plock指向待释放的锁,函数功能是释放锁 plock,当前线程应该是锁的持有者
    • 如果持有者的变量holder_repeat_nr 大于 1,这说明自已多次申请该锁,此时还不能真正将锁释放,因此只是将holder_repeat_nr–,随后返回。
    • 如果锁持有者的变量holder_repeat_nr为 1,说明现在可以释放锁了,通过代码 plock->holder = NULL将持有者置空,随后将holder_repeat_nr置为0
    • 最后通过sema_up(&plock->semaphore)将信号量加1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
//sync.c

/* 初始化信号量 */
void sema_init(struct semaphore* psema, uint8_t value) {
psema->value = value; // 为信号量赋初值
list_init(&psema->waiters); //初始化信号量的等待队列
}

/* 初始化锁plock */
void lock_init(struct lock* plock) {
plock->holder = NULL;
plock->holder_repeat_nr = 0;
sema_init(&plock->semaphore, 1); // 信号量初值为1
}

/* 信号量down操作 */
void sema_down(struct semaphore* psema) {
/* 关中断来保证原子操作 */
enum intr_status old_status = intr_disable();
while(psema->value == 0) { // 若value为0,表示已经被别人持有
ASSERT(!elem_find(&psema->waiters, &running_thread()->general_tag));
/* 当前线程不应该已在信号量的waiters队列中 */
if (elem_find(&psema->waiters, &running_thread()->general_tag)) {
PANIC("sema_down: thread blocked has been in waiters_list\n");
}
/* 若信号量的值等于0,则当前线程把自己加入该锁的等待队列,然后阻塞自己 */
list_append(&psema->waiters, &running_thread()->general_tag);
thread_block(TASK_BLOCKED); // 阻塞线程,直到被唤醒
}
/* 若value为1或被唤醒后,会执行下面的代码,也就是获得了锁。*/
psema->value--;
ASSERT(psema->value == 0);
/* 恢复之前的中断状态 */
intr_set_status(old_status);
}

/* 信号量的up操作 */
void sema_up(struct semaphore* psema) {
/* 关中断,保证原子操作 */
enum intr_status old_status = intr_disable();
ASSERT(psema->value == 0);
if (!list_empty(&psema->waiters)) {
struct task_struct* thread_blocked = elem2entry(struct task_struct, general_tag, list_pop(&psema->waiters));
thread_unblock(thread_blocked);
}
psema->value++;
ASSERT(psema->value == 1);
/* 恢复之前的中断状态 */
intr_set_status(old_status);
}

/* 获取锁plock */
void lock_acquire(struct lock* plock) {
/* 排除曾经自己已经持有锁但还未将其释放的情况。*/
if (plock->holder != running_thread()) {
sema_down(&plock->semaphore); // 对信号量P操作,原子操作
plock->holder = running_thread();
ASSERT(plock->holder_repeat_nr == 0);
plock->holder_repeat_nr = 1;
} else {
plock->holder_repeat_nr++;
}
}

/* 释放锁plock */
void lock_release(struct lock* plock) {
ASSERT(plock->holder == running_thread());
if (plock->holder_repeat_nr > 1) {
plock->holder_repeat_nr--;
return;
}
ASSERT(plock->holder_repeat_nr == 1);

plock->holder = NULL; // 把锁的持有者置空放在V操作之前
plock->holder_repeat_nr = 0;
sema_up(&plock->semaphore); // 信号量的V操作,也是原子操作
}

最后我们用我们实现的锁重写输出函数,让它变成原子的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include "console.h"
#include "print.h"
#include "stdint.h"
#include "sync.h"
#include "thread.h"
static struct lock console_lock; // 控制台锁

/* 初始化终端 */
void console_init() {
lock_init(&console_lock);
}

/* 获取终端 */
void console_acquire() {
lock_acquire(&console_lock);
}

/* 释放终端 */
void console_release() {
lock_release(&console_lock);
}

/* 终端中输出字符串 */
void console_put_str(char* str) {
console_acquire();
put_str(str);
console_release();
}

/* 终端中输出字符 */
void console_put_char(uint8_t char_asci) {
console_acquire();
put_char(char_asci);
console_release();
}

/* 终端中输出16进制整数 */
void console_put_int(uint32_t num) {
console_acquire();
put_int(num);
console_release();
}

用户进程