真象还原 --内核/中断 study(2)

真象还原 --内核/中断 study(2)

H_Haozi Lv3

A 前情提要

在此之前,第一部分我们完成了基础的环境配置,bochs配置,以及MBR,loader的的基础编写,成功的进入了保护模式并且开启了内存分页功能

这一部分主要开启对内核,中断进军

ps:如果参考本系列文章来实操,需要结合《操作系统真象还原》一起观看,否则会缺失很多细节


B. 加载内核

在之前的学习中,我们基本都是通过汇编来与及其对话,想想以前的驱动开发都是直接用C语言等语言来编写,然后操作系统来帮我们撑腰,但现在我们要用 C 语言写脱离操作系统的程序,必须要自己准备程序的入口地址,同时也不能用C标准库,因为系统调用的中断处理程序我们还没有准备

B.1 用C语言写内核

这里我们现在之前的目录基础上创建一个目录kernel(以后与内核相关的文件都放在这个目录下面),然后来实现我们自己的第一个程序来体验编译过程

这里首先介绍gcc编译器的基础使用:

gcc -c -o kernel/main.o kernel/main.c
-c表示编译,汇编到目标代码(不链接)
-o表是将输出的文件以指定的文件名来存储,有同名文件存在的时候就覆盖掉

首先我们创建第一个内核的文件:main.c

1
2
3
4
5
6
/***** /home/mouse/OS_mouse/tool/bochs/mouse/kernel/main.c  *****/
int main()
{
while(1);
return 0;
}

在这个文件路径下执行指令

1
2
3
sudo apt-get install gcc # (如果没安装的话)

gcc -c -o main.o main.c

然后就可以看到main.o文件,但是目前只是一个目标文件(待重定位文件)
重定位指的是文件里面所用的符号还没有安排地址,这些符号的地址 需要将来与其他目标文件“组成”一个可执行文件时再重新定位编排地址

在 Linux 下用于链接的程序是 ld,链接有一个好处,可以指定最终生成的可执行文件的起始虚拟地址。它是用-Ttext 参数来指定的,所以咱们可以执行以下命令完成链接

1
ld main.o -Ttext 0xc0001500 -e main -o kernel.bin

-Ttext用来地址初始虚拟地址是0xc0001500,这个地址是设计好的,在后面的加载内核中会具体了解
-e用来指定程序的起始地址(这里不仅仅可以以数字形式的地址,也可以是符号名),比如这里是main,用来知道程序从哪里开始执行(当然,如果主函数改成void __start(void) 那么就可以一气呵成不要这个指令了)
-o用来指定输出的文件位置和名称

然后我们的目录下就出现了kernel.bin文件了

使用过gcc的人可能会问:直接使用gcc一口气编译链接生成的文件和我们分两步产生的bin文件有什么区别?

首先如果直接编译链接,生成的文件可能有4000字节+,但是手动的大概有1700字节+,因为编译器在编译过程中为咱们引用了别的代码,这就是 c 运行库的功劳,目的是在调用 main 函数前做初始化环境等工作(比如说__start入口符号等)

ok呀,这里也是大概举个main函数的例子来引入


B.2 elf文件简介

之前我们是BIOS 调用mbr(0x7c00),mbr调用loader(0x900),并且这些地址都是固定的,不灵活,调用方要提前与被调用方约定,那肯定会有更灵活的方法来让加载地址不那么固定吧

兄弟,有的有的,最简单的办法就是在程序文件中专门腾出个空间来写入这些程序的入口地址(主调程序再将信息读出来,然后加载到相应的入口地址,最后跳转即可),当然这里还可以添加分配内存什么的,这也就是文件头的由来

在原先的纯二进制文件中添加新的文件头,就形成了一个新的文件格式,其中,程序头可以自定义,只要我们按照自己定义的格式去解析就行


  • 在Windows下,一个可执行文件的格式是PE(注意,EXE只是拓展名,是文件名的一部分,并不是格式)
  • 在Linux下,一个可执行文件的格式是ELF,是指通过编译链接的二进制可执行文件,该文件可以直接运行

为了避免混淆,和书作者一样采用与 ELF 规范相同的命名方式,本节中所说的目标文件即指各种类型符合 ELF规范的文件

至于如何弄清文件格式的本质,大家可以具体看看书中的解释,这里同样不过过多说明(因为也不是几句话能说明白的)

ELF 文件格式依然分为文件头和文件体两部分,其中文件头很复杂,具体内容可以参考linux系统下的/usr/include/elf.h定义(最权威的)

这里还是通过书中的实例来学习,这里原作者提供了一个sh脚本来方便使用(利用xxd指令逐字节输出文件的内容),主要是避免每次复杂的键入,方便使用,这里也直接列出(添加了中文注释,方便理解),我们来分析之前写的”内核”,也就是kernel.bin文件

1
2
3
4
5
6
7
8
9
10
11
#/home/mouse/OS_mouse/tool/bochs/mouse/xxd.sh
#usage: sh xxd.sh 文件 起始地址 长度
xxd -u -a -g 1 -s $2 -l $3 $1

# 参数解释:
# -u 使用大写十六进制字母(默认小写)
# -a 自动跳过:用单个'*'替换空行(默认关闭)
# -g 1 每1个字节用空格分隔(默认正常模式为2字节)
# -s 从指定偏移量开始(参数$2)
# -l 读取指定长度(参数$3)
# $1 输入文件名

然后我们就可以来分析文件了

1
sh xxd.sh kernel/kernel.bin 0 300  #逐字节查看文件0~300地址的内容,注意执行的位置是在脚本所在路径

结果如下:

00000000: 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 .ELF…………
00000010: 02 00 3E 00 01 00 00 00 00 15 00 C0 00 00 00 00 ..>………….
00000020: 40 00 00 00 00 00 00 00 B0 16 00 00 00 00 00 00 @……………
00000030: 00 00 00 00 40 00 38 00 02 00 40 00 07 00 04 00 ....@.8...@…..
00000040: 01 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 …………….
00000050: 00 00 00 C0 00 00 00 00 00 00 00 C0 00 00 00 00 …………….
00000060: 40 15 00 00 00 00 00 00 40 15 00 00 00 00 00 00 @.......@…….
00000070: 00 00 20 00 00 00 00 00 51 E5 74 64 06 00 00 00 .. …..Q.td….
00000080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 …………….
00000090: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 …………….
000000a0: 00 00 00 00 00 00 00 00 10 00 00 00 00 00 00 00 …………….
000000b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 …………….
*
00000120: 00 00 00 00 00 00 00 00 00 00 00 00 …………

其中,加粗的部分是文件头(rlf header部分),第二部分特殊标记的部分是程序头表(program header table),这和书中是不一样的,因为书中的系统是32位系统,文件头通常是52字节,而我的是64位系统,所以通常是64字节,当然是多少位在文件头中也有说明(比如0x04: 02,表示是64位ELF)

下面主要通过表格总结这两部分的具体内容:

ELF Header 结构(64-bit)

偏移 字段 说明
0x00-0x03 7F 45 4C 46 ELF 魔数(\x7FELF
0x04 02 64 位 ELF(01 是 32 位)
0x05 01 小端序(02 是大端序)
0x10-0x11 02 00 可执行文件(ET_EXEC
0x12-0x13 3E 00 x86-64 架构(0x3E
0x18-0x1F 00 15 00 C0… 程序入口地址(0xC0001500
0x20-0x27 40 00 00 00… Program Header Table 偏移(0x40
0x28-0x2F B0 16 00 00… Section Header Table 偏移(0x16B0
0x36-0x37 38 00 Program Header 大小(56 字节)
0x38-0x39 07 00 Program Header 数量(7 个)

Program Header 条目结构(64-bit)

偏移 字段 说明
0x40-0x43 01 00 00 00 PT_LOAD(可加载段)
0x44-0x47 05 00 00 00 Flags(R+X,可读可执行)
0x48-0x4F 00 00 00 00… 文件偏移(0x0
0x50-0x57 00 00 00 C0… 虚拟地址(0xC0000000
0x58-0x5F 00 00 00 C0… 物理地址(通常同虚拟地址)
0x60-0x67 40 15 00 00… 段大小(0x1540 字节)
0x68-0x6F 40 15 00 00… 内存大小(0x1540 字节)
0x70-0x77 00 00 20 00… 对齐(0x200000

B.3 将内核载入内存

已经期待了很久将内核载入内存

我们的内核文件是 kernel.bin,这个文件是由 loader 将其从硬盘上读出并加载到内存中的,到此,接力棒传到了最后一个选手的手里,也就是说我们现在需要将kernel.bin文件定入硬盘

先回忆一下布局: 0扇区存放着MBR 1扇区空着的 2扇区写入的loader文件(1.3kb),而我们的一个扇区是512字节,所以这里差不多会占用三个扇区,所以2~4扇区也不能使用了,所以这里我们原则要写入5扇区了,但是为了loader可能会有拓展,以及分开更安心,所以这里选择第9个扇区

开始操作:

1
2
# 因为内核文件的大小会一直改变,但是每次修改count也会很麻烦,所以这里直接修改成200(因为将来的内核大小不会超过100kb),同时dd指令也会自己判断写入的数据量
dd if=/home/mouse/OS_mouse/tool/bochs/mouse/kernel/kernel.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc

加上之前的编译,链接,可能指令会很长,所以将这个指令联合之前的指令都写进一个脚本,方便后面执行使用(同时,如果后面有文件名修改,也同步修改脚本即可),当然,这个脚本不是那么完善,比如不支持多文件,或错误提示等,后面会再添加更新的

1
2
3
# /home/mouse/OS_mouse/tool/bochs/mouse/kernel/kernel_start.sh
#编译 链接 写入
gcc -c -o main.o main.c && ldmain.o -Ttext 0xc0001500 -e main -o kernel.bin && dd if=/home/mouse/OS_mouse/tool/bochs/mouse/kernel/kernel.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc

这样我们在kernel文件夹下执行下面的执行便可以一气呵成完成编译链接写入了

1
sh kernel_start.sh

  • 最后这里还要修改loader.S文件

主要修改两个地方,

  1. 加载内核 :将内核加载到内存缓冲区,这里选择在分页前开始加载
  2. 初始化内核:在分页后,将加载寄哪里的elf内核文件放到对应的虚拟内存地址,然后跳过去执行,至此loader的工作到此结束

首先进行第一步,加载内核,这里就涉及到我应该加载到什么地方?

首先我们的内核很小,在低端1MB中安身即可,这里首先要考虑之前的的其它重要数据来分配,我们最终选择0x70000~0x9fbff的堵住开安身(问就是书作者决定的),差不多有190kb,完全够我们的内核使用

内核被加载到内存后,loader 还要通过分析其 elf 结构将其展开到新的位置,所以说,内核在内存中有两份拷贝,一份是 elf 格式的原文件 kernel.bin,另一份是 loader 解析 elf 格式的 kernel.bin 后在内存中生成的内核映像(也就是将程序中的各种段 segment 复制到内存后的程序体),这个映像才是真正运行的内核

其实既然决定好位置了,我们先在boot.inc文件中添加新的符号

1
2
3
4
5
6
7
8
9
;/home/mouse/OS_mouse/tool/bochs/mouse/boot.inc
;--------------------- loader & kernel -------------------------
KERNEL_START_SECTOR equ 0x9 ;kernel.bin 所在的扇区号 9
KERNEL_BIN_BASE_ADDR equ 0x70000 ;从磁盘读出后写入的内存地址
KERNEL_ENTRY_POINT equ 0xc0001500 ;虚拟地址入口

;--------------------- elf -------------------------------------
PT_NULL equ 0 ; ELF 程序头类型:未使用
;略......

下面给出loader中添加的加载内核的代码,因为之前的代码其实已经有点长了,如果全部直接贴到这里显得浪费长度,也不好理解,所以后面的所有代码都是截取对应的函数来列举,当然,在文章末尾会列出本篇文章中用的所有文件的完整版

1
2
3
4
5
6
7
8
9
10
11
12
; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S
; 略......
;------------------------------- 加载kernel------------------------------------

mov eax,KERNEL_START_SECTOR ;kernel.bin所在的扇区号(9)
mov ebx,KERNEL_BIN_BASE_ADDR ;记录写入的地址
mov ecx,200 ;读入的扇区数
call rd_disk_m_32 ;eax、ebx、ecx 是函数 rd_disk_m_32 的三个参数,用于从硬盘上读取文件,与MBR中读取差不多


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

下面是初始化内核的代码:
需要注意的是因为我是64系统下的,所以编译的结果elf也是64位,而我们的操作系统需要的是32位,所以这里修改修改我们之前的编译过程,即添加-m32参数

这里重新给出脚本的内容,注意在mbr.S文件里面有一行mov cx,4,是用来选择读入扇区的大小的,这里要修改成7,防止loader文件后续继续变大被截短了

1
2
3
4
5
6
7
8
# /home/mouse/OS_mouse/tool/bochs/mouse/kernel/kernel_start.sh

BASE_DIR="/home/mouse/OS_mouse/tool/bochs/mouse/kernel"

cd $BASE_DIR

#编译 链接 写入32位版本
gcc -m32 -c -o main.o main.c && ld -m elf_i386 main.o -Ttext 0xc0001500 -e main -o kernel.bin && dd if=$BASE_DIR/kernel.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc

同理给出loader文件的脚本吧,否则每次输入很多指令也挺烦的,后期会创建一个boot文件夹(位于/mouse/boot),然后将include文件也移动到这个位置,目前还是直接在mouse文件夹下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# /home/mouse/OS_mouse/tool/bochs/mouse/loader_mbr_start.sh
#!/bin/bash


# 设置基础路径变量
BASE_DIR="/home/mouse/OS_mouse/tool/bochs/mouse"
HD_IMG="/home/mouse/OS_mouse/tool/bochs/hd60M.img"

# 进入工作目录
cd $BASE_DIR

# 编译
nasm -I include/ -o mbr.bin mbr.S
nasm -I include/ -o loader.bin loader.S

# 写入,注意这里是7
dd if=$BASE_DIR/mbr.bin of=$HD_IMG bs=512 count=1 seek=0 conv=notrunc
dd if=$BASE_DIR/loader.bin of=$HD_IMG bs=512 count=7 seek=2 conv=notrunc


同时之前给出的表格elf的内容也是32位的,会与下面写内核的具体内容不同,所以这里也更新一遍表格如下(例子为新编译的文件)


ELF Header 结构(32-bit)

偏移 字段 说明
0x00-0x03 7F 45 4C 46 ELF 魔数(\x7FELF
0x04 01 32 位 ELF(02 是 64 位)
0x05 01 小端序(02 是大端序)
0x10-0x11 02 00 可执行文件(ET_EXEC
0x12-0x13 03 00 x86 架构(0x03
0x18-0x1B 00 15 00 C0 程序入口地址(0xC0001500
0x1C-0x1F 34 00 00 00 Program Header Table 偏移(0x34
0x20-0x23 60 06 00 00 Section Header Table 偏移(0x660
0x2A-0x2B 20 00 Program Header 大小(32 字节)
0x2C-0x2D 07 00 Program Header 数量(7 个)

Program Header 条目结构(32-bit)

偏移 字段 说明
0x34-0x37 01 00 00 00 PT_LOAD(可加载段)
0x38-0x3B 00 10 00 C0 虚拟地址(0xC0001000
0x3C-0x3F 00 10 00 C0 物理地址(通常同虚拟地址)
0x40-0x43 3C 05 00 00 段大小(0x53C 字节)
0x44-0x47 3C 05 00 00 内存大小(0x53C 字节)
0x48-0x4B 05 00 00 00 Flags(R+X,可读可执行)
0x4C-0x4F 00 10 00 00 对齐(0x1000

回归正题,下面是初始化内核的代码:

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
;----------------------将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,表示第一个program header的偏移量

add ebx,KERNEL_BIN_BASE_ADDR
mov cx,[KERNEL_BIN_BASE_ADDR +44] ;偏移44位记录e_phum,表示有几个program header

.each_segment:
cmp byte [ebx + 0],PT_NULL ;若p_type == PT_NULL,则program header未使用
je .PT_NULL ;则跳转到 .PT_NULL

;函数 memcpy(dst,src,size) 为函数 memcpy 压入参数,参数是从右往左依然压入
push dword [ebx + 16] ;偏移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] ;偏移8位记录p_vaddr 是压入函数 memcpy 的第一个参数:目的地址
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 ;清除方向标志(DF=0),确保字符串操作正向移动(esi/edi递增
push ebp ;保存调用者的栈基址
mov ebp,esp ;建立新的栈帧(ebp指向当前栈顶)
push ecx ;rep 指令用到了 ecx 但 ecx 对于外层段的循环还有用,所以先入栈备份

mov edi, [ebp + 8] ; 加载目标地址(dst)
mov esi, [ebp + 12] ; 加载源地址(src)
mov ecx, [ebp + 16] ; 加载要复制的字节数(size)
rep movsb ; 逐字节拷贝,执行ecx次

pop ecx ; 恢复ecx
pop ebp ; 恢复调用者的栈基址
ret ; 返回

函数 kernel_init 的作用是将 kernel.bin 中的段(segment)拷贝到各段自己被编译的虚拟地址处,将这些段单独提取到内存中,这就是平时所说的内存中的程序映像,其它的部分可以具体参考书中内容


最后还有一点点添加的地方,也就是调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S
; 略.....
;在开启分页后,用 gdt 新的地址重新加载
lgdt [gdt_ptr] ;重新加载

jmp dword SELECTOR_CODE:enter_kernel ; 刷新流水线,虽然已经是32位了,不用刷新,但还是添加上,防止出现莫名的问题

enter_kernel:
;初始化gs寄存器,显存相关
mov ax, SELECTOR_VIDEO ;这里要初始化gs寄存器,其实我尝试将它放在之前的位置初始化也可以打印成功好像
mov gs, ax
mov byte [gs:160], 'M' ;视频段段基址已经被更新,用字符 M 表示 mouse 其实也是验证真的执行没

call kernel_init ;初始化内核

mov esp,0xc009f000 ;新的栈起始地址0xc0000900

jmp KERNEL_ENTRY_POINT ;用地址 0x1500 访问测试,指为0xc0001500,也就是ld链接的虚拟地址的main函数
; 略.....

boot.inc的代码仅仅添加了4行这里就不打出来了,main.c也只是一个示例,这里也就不列出来了

  • 下面是loader.S的具体代码,有需要可以展开看
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
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
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
; /home/mouse/OS_mouse/tool/bochs/mouse/loader.S

%include "boot.inc"

section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start

; 构建gdt及其内部的描述符,拆分为上4字节,下4字节

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 50 dq 0 ; 此处预留 50 个描述符的空位(为什么不是60?因为我开头调用jmp,使得total_mem_bytes无法刚好是0xb00) times 是 nasm 提供的伪指令,用来重复执行 times 后面表达式(编译器执行)

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 ; 同上

times 0x200 - ($ -$$) db 0 ;填充0,使得total_mem_bytes 的节内偏移一定为0x200,地址一定为0xb00

total_mem_bytes dd 0
; total_mem_bytes 用于保存内存容量,以字节为单位,这个位置比价好记
; 当前偏移loader.bin文件头0x200 字节
; loader.bin加载地址为 0x900
; 所以 total_mem_bytes 内存地址为 0xb00
; 将来在内核中我们会引用这个地址,同时等会验证内存容量的时候,GDB调试也可以通过读取这个地址的内容来查看大小

;以下是 gdt 的指针,前 2 字节是 gdt 界限,后 4 字节是 gdt 起始地址
align 4 ;强制对齐,用来解GDT base = 0xc0000903的问题
gdt_ptr:
dw GDT_LIMIT
dd GDT_BASE
loadermsg db 'Mosue'

;人工对齐计算大小:total_mem_bytes(4) + gdt_ptr(6) + ards_buf(239) + ards_nr(2) + loadermsg(5) = 256字节
ards_buf times 244 db 0 ;填充对齐使用
ards_nr dw 0 ;用来记录ARDS结构体数量


loader_start:

;----------------------0xe820 获取内存方法----------------------
;int 15h eax = 0000E820h , edx = 534D4150h ("SMAP") 获取内存布局
xor ebx,ebx ;第一次调用,ebx的值设置为0
mov edx,0x534D4150 ;赋值一次,以后循环的时候不会变化
mov di,ards_buf ;ards结构体缓冲区
.e820_mem_get_loop: ;循环或得每个ARDS结构体
mov eax,0x0000e820 ;执行int 0x15 之后,eax的值会变成0x534D4150,所以每次执行int之前都要更新为子功能号(即0x0000e820)
mov ecx,20 ;ARDS地址范围描述符的结构大小为20字节
int 0x15 ;执行中断
jc .e820_failed_so_try_e801 ;如果cf位为1,则说明有错误发生,那么尝试使用0xe801功能
add di,cx ;使di增加字节指向缓冲区中新的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的数量(cx寄存器)
mov ebx,ards_buf
xor edx,edx ;edx即为最大内存容量,在此先请0
.find_max_mem_area:
mov eax,[ebx] ;base_add_low
add eax,[ebx+8] ;length_low
add ebx,20 ;指向下一个ARDS结构
cmp edx,eax ;比较当前最大值(edx)和新计算的值(eax)
jge .next_ards ;冒泡思想找出最大,edx寄存器始终是最大的内存容量,如果edx>=eax,则跳过更新
mov edx,eax ;否则更新edx,edx 为总内存大小
.next_ards:
loop .find_max_mem_area ;直到(cx--)清零
jmp .mem_get_ok ;获取内存成功

;----------------------int 15h ax = 0xe801h 获取内存方法 最大支持4G----------------------
;返回后ax,cx只一样,kb为单位,bx,dx值一样,以64kb为单位
;在ax和cx寄存器中的为低15MB,bx和dx中为16MB到4GB
.e820_failed_so_try_e801:
mov ax,0xe801 ;选择0xe801方法
int 0x15 ;调用中断
jc .e801_failed_so_try_e88 ;如果调用失败,尝试使用0x88方法

;1.先计算低15MB的内存数量,并将kb转换为byte为单位
mov cx,0x400 ;cx与ax一样,cx用作乘数
mul cx ;dx:ax = 1024*ax
shl edx,16 ;edx << 16
and eax,0x0000FFFF ;eax &= 0x0000FFFF
or edx,eax ;edx = edx | eax 得到完整的地址
add edx,0x100000 ;ax只是15MB,所以要添加1MB
mov esi,edx ;先把低15MB的内存容量存取esi寄存器备份

;2.将16MB以上的内存转换为byte为单位
xor eax,eax ;将eax的值清零
mov ax,bx
mov ecx,0x10000 ;0x10000:64kb
mul ecx ;32位乘法,默认被乘数为eax,积为64位
;高32为存入edx,低32位存入eax,但是这个方法只能检测4GB,所以32位eax就够(edx一定为0),所以直接相加eax即可
add esi,eax ;将15MB以下的结果和低32位的结果相加
mov edx,esi ;edx即为总内存大小
jmp .mem_get_ok ;获取内存成功

;----------------------int 15h ah = 0x88 获取内存方法 最大支持64MB----------------------
;返回后ax,以kb为单位的内存容量,这里同样转换为byte,注意0x88子功能只会返回1MB以上的内容,所以最后要添加1MB
.e801_failed_so_try_e88:
mov ah,0x88 ;0x88方法
int 0x15 ;进入中断
jc .error_hlt ;如果调用失败,进入error_hlt
and eax,0x0000FFFF ;取低16位
mov cx,0x400 ;1024
mul cx ;dx:ax = cx*ax
shl edx ,16 ;edx << 16
or edx,eax ;edx = edx | eax
add edx,0x100000 ;添加1MB,edx就是最终内存了
jmp .mem_get_ok ;获取内存成功 其实可以不用跳转,因为下一个指令就是了

.mem_get_ok: ;获取内存成功
mov [total_mem_bytes],edx ;将内存大小(byte)写入total_mem_bytes保存
jmp .print_int ;打印个mouse

.error_hlt: ;失败情况下
jmp $


;------------------------------------------------------------
;INT 0x10 功能号:0x13 功能描述:打印字符串
;------------------------------------------------------------
;输入:
;AH 子功能号=13H
;BH = 页码
;BL = 属性(若 AL=00H 或 01H)
;CX=字符串长度
;(DH、 DL)=坐标(行 列、 )
;ES:BP=字符串地址
;AL=显示输出方式
; 0—字符串中只含显示字符,其显示属性在 BL 中
;显示后,光标位置不变
; 1—字符串中只含显示字符,其显示属性在 BL 中
;显示后,光标位置改变
; 2—字符串中含显示字符和显示属性。显示后,光标位置不变
; 3—字符串中含显示字符和显示属性。显示后,光标位置改变
;无返回值
.print_int:
mov sp, LOADER_BASE_ADDR
mov bp, loadermsg ; ES:BP = 字符串地址
mov cx, 5 ; 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

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

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

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

jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线

;----------------------------- 保护模式 -----------------------------

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

;------------------------------- 加载kernel------------------------------------

mov eax,KERNEL_START_SECTOR ;kernel.bin所在的扇区号(9)
mov ebx,KERNEL_BIN_BASE_ADDR ;记录写入的地址
mov ecx,200 ;读入的扇区数
call rd_disk_m_32 ;eax、ebx、ecx 是函数 rd_disk_m_32 的三个参数,用于从硬盘上读取文件,与MBR中读取差不多


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
;mov ebx, [gdt_ptr + 2]
;add ebx, 0xc0000000
;mov [gdt_ptr + 2], ebx
movzx eax, word [gdt_ptr + 0] ; 取 limit (但其实只是顺序安全)
mov ebx, dword [gdt_ptr + 2] ; 取 base
add ebx, 0xc0000000
mov [gdt_ptr + 2], ebx


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

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

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

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

jmp dword SELECTOR_CODE:enter_kernel ; 刷新流水线,虽然已经是32位了,不用刷新,但还是添加上,防止出现莫名的问题

enter_kernel:

call kernel_init ;初始化内核
mov esp,0xc009f000 ;新的栈起始地址0xc0000900

;初始化gs寄存器,显存相关
mov ax, SELECTOR_VIDEO ;这里要初始化gs寄存器,其实我尝试将它放在之前的位置初始化也可以打印成功好像
mov gs, ax
mov byte [gs:160], 'M' ;视频段段基址已经被更新,用字符 M 表示 mouse 其实也是验证是否初始化成功

jmp KERNEL_ENTRY_POINT ;用地址 0x1500 访问测试,指为0xc0001500,也就是ld链接的虚拟地址的main函数



;-----------------------------创建页目录和页表---------------------------
; 页目录占用4kb,也就是4096字节(0x1000),PAGE_DIR_TABLE_POS是页目录的物理地址(0x100000)
;循环清零
setup_page:
mov ecx,4096
mov esi,0 ;初始化gs寄存器,显存相关
mov ax, SELECTOR_VIDEO ;这里要初始化gs寄存器,其实我尝试将它放在之前的位置初始化也可以打印成功好像
mov gs, ax
mov byte [gs:160], 'M' ;视频段段基址已经被更新,用字符 M 表示 mouse 其实也是验证真的执行没
.clear_page_dir: ;清理页目录
mov byte [PAGE_DIR_TABLE_POS + esi],0 ;清零
inc esi ;偏移一个字节,计数+1
loop .clear_page_dir ;不断循环

;开始创建页目录(PDE)
.creat_pde: ; 创建 Page Directory Entry
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;此时的eax为第一个页表的位置和属性(偏移4kb)
mov ebx,eax ;此处为ebx复制,是为了.creat_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 个目录项写入第一个页表的位置(0x101000)及属性(7)
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;0XC00是第768个页表占用的目录项(一个页表项占用4字节),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
.creat_pte: ; 创建 Page Table Entry
mov [ebx+esi*4],edx ; 此时的edx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
add edx,4096 ; 4kb
inc esi ; 计数+1
loop .creat_pte ; 循环

;创建内核其它页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;此时eax为第二个页表的位置,偏移8kb
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


;----------------------将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,表示第一个program header的偏移量

add ebx,KERNEL_BIN_BASE_ADDR
mov cx,[KERNEL_BIN_BASE_ADDR +44] ;偏移44位记录e_phum,表示有几个program header

.each_segment:
cmp byte [ebx + 0],PT_NULL ;若p_type == PT_NULL,则program header未使用
je .PTNULL ;则跳转到 .PT_NULL

;函数 memcpy(dst,src,size) 为函数 memcpy 压入参数,参数是从右往左依然压入
push dword [ebx + 16] ;偏移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] ;偏移8位记录p_vaddr 是压入函数 memcpy 的第一个参数:目的地址
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 ;清除方向标志(DF=0),确保字符串操作正向移动(esi/edi递增
push ebp ;保存调用者的栈基址
mov ebp,esp ;建立新的栈帧(ebp指向当前栈顶)
push ecx ;rep 指令用到了 ecx 但 ecx 对于外层段的循环还有用,所以先入栈备份

mov edi, [ebp + 8] ; 加载目标地址(dst)
mov esi, [ebp + 12] ; 加载源地址(src)
mov ecx, [ebp + 16] ; 加载要复制的字节数(size)
rep movsb ; 逐字节拷贝,执行ecx次

pop ecx ; 恢复ecx
pop ebp ; 恢复调用者的栈基址
ret ; 返回


;----------------------------------------------------
; 功能:在32位模式下读取硬盘的n个扇区
; 参数:
; eax = LBA扇区号
; ebx = 将数据写入的内存地址
; ecx = 读入的扇区数
;----------------------------------------------------

rd_disk_m_32:
pushad ; 保存所有通用寄存器
push ecx ; 保存ecx(扇区数)
push eax ; 保存eax(LBA地址)

; 第1步:设置读取的扇区数
mov dx, 0x1f2
mov al, cl ; 读取的扇区数
out dx, al

; 第2步:将LBA地址存入0x1f3 ~ 0x1f6
pop eax ; 恢复eax(LBA地址)

; 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

; LBA地址24~27位(高4位)
shr eax, cl
and al, 0x0f ; 只保留低4位
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端口读取数据
pop ecx ; 恢复ecx(扇区数)
mov dx, 0x1f0
.read_sector:
push ecx ; 保存剩余扇区数
mov ecx, 256 ; 每个扇区256次inw操作(512字节/2字节)
.read_word:
in ax, dx ; 从端口读取一个字(2字节)
mov [ebx], ax ; 存储到内存
add ebx, 2 ; 内存地址增加2字节
loop .read_word ; 循环读取整个扇区
pop ecx ; 恢复剩余扇区数
loop .read_sector ; 读取下一个扇区

popad ; 恢复所有通用寄存器
ret

最后就是验证是否成功执行loader,首先我将之前的字符V更改成了M,大家可以在屏幕上看到,其次是如何观察是否进入了main函数
这里验证方式有很多,比如说往一个特定地址写入一个特殊值,然后我们通过bochs调试读取,或者通过操作显存来显示字符,我这里选择后者,所以稍微修改一下main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/***** /home/mouse/OS_mouse/tool/bochs/mouse/kernel/main.c *****/
int main()
{
volatile char *video = (volatile char *)0xB8000;
char *str = "Mouse OS";
unsigned char attribute = 0x07; //灰字黑底
int i;
for (i = 0; str[i] != '\0'; i++) // 从屏幕左上角开始依次写入每个字符及其属性
{
video[i * 2] = str[i];
video[i * 2 + 1] = attribute;
}
while (1); // 防止 main 退出
return 0;
}

然后重新编译写入即可,然后就可以在屏幕的第一行看见字符串”Mouse OS”

1
2
sh loader_mbr_start.sh
sh kernel/kernel_start.sh

加载内核就到此结束,下面来看看保护模式下最闪亮的内容—特权


C. 特权级的深入浅出

CPU 既是大脑,又是警察,它负责维护计算机内的安全。它将程序拥有的权利分为 4 个等级,这就是保护模式下特权级的由来。
特权级按照权力从大到小分为 0、1、2、3 级,没错,数字越小,权力越大,0 级特权能力最大,3 级特权能力最小。0 级特权是我们操作系统内核所在的特权级,计算机在启动之初就以 0 级特权运行


C.1 TSS 简介

TSS,即 Task State Segment,意为任务状态段,它是处理器在硬件上原生支持多任务的一种实现方式,TSS 是一种数据结构,它用于存储任务的环境

TSS 是硬件支持的系统数据结构,它和 GDT 等一样,由软件填写其内容,由硬件使用。GDT 也要加载到寄存器 GDTR 中才能被处理器找到,TSS 也是一样,它是由 TR(Task Register)寄存器加载的,每次处理器执行不同任务时,将 TR 寄存器加载不同任务的 TSS 就成了。至于怎么加载以及相关工作原理,目前咱们用不到,还是放在后面说比较合适。

正是由于处理器提供了硬件方面的框架,所以很多工作都是“自动”完成的,虽然操作系统看上去是底层的技术,但其实也属于“应用型”开发。


C.2 CPL 和 DPL 简介

  • CPL

前情提要,x86 访问内存的机制是“段基址:偏移地址”,无论是实模式,还是保护模式,都要遵循此方式,在实模式下,段基址直接写在段寄存器中,而在保护模式下,段寄存器中的不再是段基址,而是段选择子,通过该选择子从 GDT 或 LDT 中找到相应的段描述符,从该描述符中获取段的起始地址。

代码段描述符中的 DPL,便是当前 CPU 所处的特权级,这个特权级称为当前特权级,即 CPL(Current Privilege Level),它表示处理器正在执行的代码的特权级别。除一致性代码段外,转移后的目标代码据段的 DPL 是将来处理器的当前特权级 CPL。

总之,代码是资源的请求者,代码段寄存器 CS 所指向的是处理器中当前运行的指令,所以代码段寄存器 CS 中选择子的 RPL 位称为当前特权级 CPL

处理器的当前特权级 CPL 放在 CS.RPL 中

  • DPL

DPL,即 Descriptor Privilege Level,描述符特权级,这下您清楚为什么 DPL 字段在段描述符中占 2位的原因了吧,两位能表示 4 个组合,00b、01b、10b、11b,所有特权级都齐了

/略……


C.3 ps: 略一下….等我具体理解了在补充这一部分

这里书中还介绍了调用门,RPL 的前世今生以及IO 特权级,这里还是主要记录与代码强相关的内容,所以暂且少写一点,详情可以翻阅书


D. 完善内核–打印功能

之前终于完成内核,但是似乎还是太简陋了,只是实现了打印字符串,所以这里开始,完善内核


D.1 函数调用约定简介

因为我们需要汇编和C语言结合编程,所以如果互相调用了怎么办,这里主要了解一下函数调用规约

这里还是通过书中的例子来帮助理解:

1
2
3
4
5
6
7
8
9
10
11
12
int fun1(int a,int b)
{
return a+b;
}

int main()
{
int a =1;
int b =2;
int c = fun1(1,2)
return 0;
}

上面就是一个我们常见的用法,但是计算机有事如何来确认参数1,2是在哪嘞,这是一个参数存储问题

  • 首先,每个进程都有自己的栈,这就是每个内存自己的专用内存空间
  • 其次,保存参数的内存地址不用再花精力维护,已经有栈机制来维护地址变化了,参数在栈中的位置可以通过栈顶的偏移量来得到

参数存储的问题解决了,我们决定在进程自己的栈空间中保存参数,那调用函数后谁负责回收所占用的栈空间,同时如果参数很多的时候,我们又该以什么顺序传递

在c语言中,我们不用考虑这个问题,因为编译器默默承受了这些事情,我们只用负责使用

而在汇编中,我们写一个函数,再调用一下,自己写的当然可以知道顺序,比如我们之前的mem_cpy函数,我们只用根据自己设置好的顺序,来按顺序调用即可:

1
2
3
mov edi, [ebp + 8]      ; 加载目标地址(dst) 
mov esi, [ebp + 12] ; 加载源地址(src)
mov ecx, [ebp + 16] ; 加载要复制的字节数(size)

但是假设函数并不是你自己写的,那么我们就需要双方提前商量好传入参数的顺序和由谁来负责清理栈空间
在高级语言中,这两个问题是通过调用约定来解决的,调用约定就是调用方和被调用方就以上问题达成一致解决方案的约定,双方按照这种约定合作就不会发生问题

因为C 语言遵循的调用约定是 cdecl,所以我们自然要遵守 cdecl 约定,下面主要介绍一下cdecl约定:


  • cdecl约定

cdecl 调用约定最大的亮点是它允许函数中参数的数量不固定的,我们熟识的 printf 函数,它能够支持变长参数,就是利用此 cdecl 调用约定的性质设计出来的,它的原理是利用字符串参数 format 中的’%’来匹配栈中的参数,以后咱们在动手实现 printf 函数时会体验到这一优势

用之前的int fun1(int a,int b)函数来举个例子

1
2
3
4
5
6
int fun1(int a,int b)
{
return a+b;
}

int c = fun1(1,2)

主调用者

1
2
3
4
5
;从右向左将参数入栈
push 2 ;压入参数 b
push 1 ;压入参数 a
call fun1 ;调用函数 fun1
add esp,8 ;回收(清理)栈空间

被调用者

1
2
3
4
5
6
7
8
9
10
push ebp                ;压入 ebp 备份
mov ebp,esp ;将 esp 赋值给 ebp
;用 ebp 作为基址来访问栈中参数
mov eax,[ebp+0x8] ;偏移 8 字节处为第 1 个参数 a
add eax,[ebp+0xc] ;偏移 0xc 字节处是第 2 个参数 b
;参数 a 和 b 相加后存入 eax
mov esp,ebp ;为防止中间有入栈操作,用 ebp 恢复 esp
;本句在此例子中可有可无,属于通用代码
pop ebp ;将 ebp 恢复
ret

ok呀,大概就这样就实现了双方的约定,下面来看看如何混合编程


D.2 函汇编语言和 C 语言混合编程

开门见山,汇编语言和 C 语言混合编程可分为两大类。

  • 单独的汇编代码文件与单独的 C 语言文件分别编译成目标文件后,一起链接成可执行程序。
  • 在 C 语言中嵌入汇编代码,直接编译生成可执行程序。

我们这里主要使用第一种,分开编程,第二种又称为内联汇编,在一些长汇编确实不适合,这个后面会专门说明

大概了解一下什么是Linux系统调用:

系统调用是 Linux 内核提供的一套子程序,它和 Windows 的动态链接库 dll 文件的功能一样,用来实现一系列在用户态不能或不易实现的功能,比如最常见的读写硬盘文件,只有操作系统有权限去访问硬件,用户程序是没有权限的,用户程序只能向操作系统寻求帮助,故系统调用是供用户程序来使用的,操作系统权利至高无上,不需要使用自己对外发布的功能接口,即系统调用

系统调用的入口只有一个,即第 0x80 号中断 为什么系统调用只有一个入口呢?
中断的实现是要用到中断描述符表,表中很多中断项(号)是被预留的,不能强占,所以 Linux 就选了一个可用的中断号作为所有系统调用的统一入口,具体的子功能在寄存器 eax 中单独指定

BIOS 中断走的是中断向量表,所以有很多中断号给它用,而系统调用走的是中断描述符表中
的一项而已,所以只用了第 0x80 项中断

这里书中有个系统调用write的例子,大家如果感兴趣可以去看看,我这里就不写出来了


这里说说C语言和汇编如何互相调用,举个例子:

1
2
3
4
5
6
7
8
9
10
/**** /home/mouse/OS_mouse/tool/bochs/mouse/drafts/c_with_s_c.c  ****/

extern void asm_print(char*,int);
void c_print(char* str)
{
int len = 0;
while (str[len]) /* 遇到 '\0' 停止 */
len++;
asm_print(str, len);
}
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
;/home/mouse/OS_mouse/tool/bochs/mouse/drafts/c_with_s_s.S
section .data
str: db "asm_print says hello world!", 0xa, 0
;0xa 是换行符,0 是手工加上的字符串结束符\0 的 ASCII 码
str_len equ $-str
section .text
extern c_print ;声明c文件的函数
global _start
_start:
;;;;;;;;;;;; 调用 c 代码中的函数 c_print ;;;;;;;;;;;
push str ;传入参数
call c_print ;调用 c 函数
add esp,4 ;回收栈空间

;;;;;;;;;;;;;;;;;;; 退出程序 ;;;;;;;;;;;;;;;;;;;;
mov eax,1 ;第 1 号子功能是 exit 系统调用
int 0x80 ;发起中断,通知 Linux 完成请求的功能

global asm_print ;相当于 asm_print(str,size)
asm_print:
push ebp ;备份 ebp
mov ebp,esp
mov eax,4 ;第 4 号子功能是 write 系统调用
mov ebx, 1 ;此项固定为文件描述符 1,标准输出(stdout)指向屏幕
mov ecx, [ebp+8] ;第 1 个参数
mov edx, [ebp+12] ;第 2 个参数
int 0x80 ;发起中断,通知 Linux 完成请求的功能
pop ebp ;恢复 ebp
ret

汇编中的__start执行,然后调用c文件的c_print函数,然后c_print函数再调用汇编的asm_print函数,然后再进行系统调用(0x80中断的4功能),然后输出到屏幕上

  • 在汇编代码中导出符号供外部引用是用的关键字 global,引用外部文件的符号是用的关键字extern
  • 在 C 代码中只要将符号定义为全局便可以被外部引用(一般情况下无需用额外关键字修饰,具体请参考 C 语言手册),引用外部符号时用 extern 声明即可

下面我们编译链接试试,验证一下结果:

1
2
3
4
5
6
7
8
#32位,注意就是在自己终端上运行就行,64位兼容32位,直接用就行
gcc -m32 -c c_with_s_c.c -o c_with_s_c.o
nasm -f elf c_with_s_s.S -o c_with_s_s.o #指定elf格式
# 链接两个目标文件,生成最终的可执行程序
ld -m elf_i386 c_with_s_c.o c_with_s_s.o -o final_program_32

chmod 777 final_program_32 #添加权限,为了方便我就直接给777了
./final_program_32 #运行

最后可以看到屏幕输出asm_print says hello world!,证明成功了


D.3实现自己的打印函数

一直以来,我们在往屏幕上输出文本时,要么利用 BIOS 中断,要么利用系统调用,这些都是依赖别人的方法。咱们还用过一个稍微有点独立的方法,就是直接写显存,但这貌似又没什么技术含量。如今我们要写一个打印函数了

所以下面来和显卡打打交道

  • 显卡的端口控制

首先呀,显卡肯定不只能打印文本(mov 一些ASCII码写进去),这是因为我们在默认的文本模式下。显卡当然还有显示各种绚丽的图片,这都是显卡的功能,但目前我们还是在学习步骤,所以还是在80*25的文本模式下操作即可

下面书中介绍了显卡的具体的寄存器表,建议大家去翻书或者网上查看,这里就不列出了

这里还是直接通过例子来操作:

  • 实现单个字符打印

在此之前,为了开发方便,我们要学习linux内核一样定义一些数据类型,首先创建一个目录lib,然后再添加两个目录kernel user分别是内核和用户使用的库文件,然后添加一个stdint.h文件

然后打印函数在/lib/kernel/print.S中实现,其中put_char函数就是我们等会要实现的函数(与汇编打交道当然纯汇编比较方便),当然,为了方便查看文件路径,我在每个示例代码的最上面都会添加文件所在的路径,方便查看

下面是这个打印函数的核心处理流程:

(1)备份寄存器现场。
(2)获取光标坐标值,光标坐标值是下一个可打印字符的位置。
(3)获取待打印的字符。
(4)判断字符是否为控制字符,若是回车符、换行符、退格符三种控制字符之一,则进入>相应的处理
流程。否则,其余字符都被粗暴地认为是可见字符,进入输出流程处理。
(5)判断是否需要滚屏。
(6)更新光标坐标值,使其指向下一个打印字符的位置。
(7)恢复寄存器现场,退出。

书中将这个实现分成了三部分,因为我都把注释写到代码里了,所以这里代码直接展示完整的吧

  • 备份,然后获取光标位置,获取字符,判断应该怎么处理,然后跳转到相应的函数
  • 完善对应的函数
  • 判断滚屏,更新光标位置,恢复现场并退出
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
;/home/mouse/OS_mouse/tool/bochs/mouse/lib/kernel/print.S

;----------------------------定义视频段的选择子---------------------------------
;一般要放在配置文件中,这里偷懒一下(因为只有三行)
TI_GDT equ 0 ;描述符表指示符​:0:使用全局描述符表;1:则表示使用局部描述符表​
RPL0 equ 0 ;请求特权级​:表示选择子的特权级。0是最高特权级(操作系统内核),还有1、2、3(用户通常为3)
SELECTOR_VIDEO equ (0X0003<<3) + TI_GDT + RPL0 ;​段选择子​:一个具体的值,用于在GDT中索引一个段描述符(0x0003表示全局描述符表的第四个描述符,也就是显存段的)


[bits 32]
section .text

;----------------------------- put_char -----------------------
;功能: 将栈中的一个字符写入光标所在处
;--------------------------------------------------------------

global put_char ;声明为外部可调用
put_char:
pushad ;备份32位寄存器环境 push all double,指压入所有双字节长的寄存器(32个字节)
;需保证gs为正确的视频段选择子,为保险,每次打印都为其赋值
mov ax,SELECTOR_VIDEO ;不能直接将立即数送入段寄存器
mov gs,ax

;------------------------------ 获取当前光标位置 ------------------------------
;先或获得高八位
mov dx,0x03d4 ;索引寄存器
mov al,0x0e ;用于提供光标位置的高八位
out dx,al ;将索引 0xe 写入索引寄存器
mov dx,0x03d5 ;通过读写0x3d5来获得或设置光标位置
in al,dx ;得到了光标位置的高八位,读入al
mov ah,al ;在写入ah寄存器(in指令要求源操作数和目标数必须一样都是8位/16位,所以这里多移动一次)

;再获取低 8 位
mov dx, 0x03d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
in al, dx

mov bx,ax ;将光标存入 bx(常用于bx位基址寻址)

;--------------------- 获取待打印字符并判断类型->跳转相关函数 --------------------

mov ecx ,[esp+36] ;返回地址占 4 字节,pushad占 32 字节 所以共36字节,然后就可以获得待打印的字符

cmp cl,0xd ;CR(回车)是0x0d
jz .is_carriage_return
cmp cl, 0xa ;LF(换行)是0x0a,这里二者都处理成回车换行(CRLF)
jz .is_line_feed

cmp cl,0x8 ;BS(backspace 退格)
jz .is_backspace
jmp .put_other ;其余字符

;--------------------------------- 相关函数的实现 -------------------------------
;----- 退格 -----
.is_backspace:
;这里退格之后,还添加了空格或者空字符0,这样就会覆盖后面的字符
dec bx ;bx--(光标--)
shl bx,1 ;bx<<1(相当于乘2,即转换为显存中的字节偏移量)

mov byte [gs:bx],0x20 ;填充字节为0或者空格(0x20是空格)
inc bx ;bx++ 写入属性
mov byte [gs:bx], 0x07 ;黑底白字
shr bx,1 ;bx>>1,恢复光标值
jmp .set_cursor ;更新光标位置

;----- 其它字符 -----
.put_other:
shl bx,1 ;bx<<1(相当于乘2,即转换为显存中的字节偏移量)

mov byte [gs:bx],cl ;填充字节为0或者空格(0x20是空格)
inc bx ;bx++ 写入属性
mov byte [gs:bx], 0x07 ;黑底白字
shr bx,1 ;bx>>1,恢复光标值
inc bx ;bx++,下一个光标值
cmp bx, 2000 ;如果小于2000,表示未写到显存的末尾,则去设置光标值
;如果超过2000,表示需要换行
jl .set_cursor ;更新光标位置(如果小于2000)

;----- 回车换行 -----
.is_line_feed: ;换行(\n)
.is_carriage_return: ;回车(\r) 光标移动到行首,这里统一都写成回车换行
xor dx,dx ;dx是被除数的高16位,清零
mov ax,bx ;ax是被除数的低16位
mov si,80 ;每行是80字符
div si ;计算行位置
sub bx,dx ;光标值减去除 80 的余数便是取整

.is_carriage_return_end: ;回车符处理结束
add bx,80 ;光标移动到下一行
cmp bx,2000 ;检查是否超出屏幕范围(80x25=2000)
.is_line_feed_end:
jl .set_cursor ;如果bx<2000则跳转到设置光标位置

;----- 滚屏 -----
;这里有两种方案,第一种是是要设置起始地址寄存器,优点是可以缓存16KB个字符,屏幕外的文本也可以很快的找回
; 第二种是固定屏幕,,直接搬运,优点是不用设置起始地址寄存器,缺点是只能缓存2000个字符
;这里书中介绍的是第二种,因为简单方便,易于理解

;第二种滚屏方法:
;1.将第 1~24 行的内容整块搬到第 0~23 行,也就是把第 0 行的数据覆盖。
;2.再将第 24 行,也就是最后一行的字符用空格覆盖,这样它看上去是一个新的空行。
;3.把光标移到第 24 行也就是最后一行行首
;搬运1~24行
.roll_screen: ;若超出屏幕大小,开始滚屏
cld
mov ecx,960 ;要搬运2000-80个字符,1920*2=3840字节
;一次搬运4字节,也就是960次
mov esi,0xc00b80a0 ;第1行行首---复制起始地址
mov edi,0xc00b8000 ;第0行行首---复制目标地址
rep movsd ;逐4字节复制(32)

mov ebx, 3840 ;最后一行首字符的第一个字节的偏移1920*2
mov ecx, 80 ;一行80个字符,一次一个字符(2字节),要移动80次
;清理最后一行
.cls:
mov word [gs:ebx],0x0720 ;0x0720是黑底白字的空格键
add ebx,2 ;一次一个字符(字+属性)
loop .cls ;循环清理80次
mov bx,1920 ;设置光标为1920,也就是最后一行的首字符

;----- 设置光标 -----
.set_cursor:
;将光标设置为bx值
;---- 设置高8位 ----
mov dx, 0x03d4 ;索引寄存器
mov al, 0x0e ;用于提供光标位置的高 8 位
out dx, al
mov dx, 0x03d5 ;通过读写数据端口 0x3d5 来获得或设置光标位置

mov al, bh
out dx, al

;---- 设置低8位 ----
mov dx, 0x03d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
mov al, bl
out dx, al
;函数结尾
.put_char_done:
popad ;恢复寄存器环境
ret ;返回

然后是外部调用肯定是引用头文件更加方便,所以同样创建一个头文件

1
2
3
4
5
6
7
8
9
// ;/home/mouse/OS_mouse/tool/bochs/mouse/lib/kernel/print.h

#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"

void put_char(uint8_t char_asci); //打印一个字符

#endif

然后就是主函数了,这里选择打印一个长字符串,注意要一个字符一个字符打印,因为我们还没实现字符串的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/***** /home/mouse/OS_mouse/tool/bochs/mouse/kernel/main.c *****/
#include "print.h"
int main()
{
put_char('M');
put_char('o');
put_char('u');
put_char('s');
put_char('e');
put_char(' ');
put_char('p');
put_char('u');
put_char('t');
put_char('c');
put_char('h');
put_char('a');
put_char('r');
put_char('\r');
put_char('\n');
while(1); //防止退出
return 0;
}

最后编译链接启动,这里我修改一下脚本,方便一键启动,当然后面肯定会用makefile,因为shell脚本还是不够方便

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# /home/mouse/OS_mouse/tool/bochs/mouse/kernel/kernel_start.sh
#!/bin/bash

BASE_DIR="/home/mouse/OS_mouse/tool/bochs/mouse/kernel" #内核文件
LIB_DIR="/home/mouse/OS_mouse/tool/bochs/mouse/lib/kernel" #内核库函数路径

cd $BASE_DIR #进入相关路径

# 编译主文件和外部文件
gcc -m32 -I$LIB_DIR -c -o main.o main.c
nasm -f elf -o print.o $LIB_DIR/print.S

# 链接所有目标文件
ld -m elf_i386 main.o print.o -Ttext 0xc0001500 -e main -o kernel.bin

# 写入磁盘映像
dd if=$BASE_DIR/kernel.bin of=/home/mouse/OS_mouse/tool/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc

在虚拟机运行bochs之后就可以看到屏幕上多了一个字符串 Mosue putchar,那么证明我们写的代码没有问题,并且发现光标在这个字符串的后两行(因为我们输入了\r\n,所以跳转了两次回车换行),也符合我们的代码


那么下面就来实现打印字符串吧,这里直接将put_str函数添加到put_char函数之前即可,因为前面有,就不贴完整代码了:

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
;略......
[bits 32]
section .text
;----------------------------- put_str -----------------------
;功能: 通过put_char打印以0字符结尾的字符串
;输入:栈中参数为打印的字符串
;输出:无
;--------------------------------------------------------------
global put_str ;声明为外部可调用
put_str:
;由于本函数中只用到了 ebx 和 ecx,只备份这两个寄存器
push ebx
push ecx
xor ecx,ecx ;清零
mov ebx,[esp+12] ;备份的2个寄存器+返回地址(8+2)
.goon:
mov cl,[ebx]
cmp cl,0 ;如果处理到了字符串末尾('0'),则跳转到结束处返回
jz .str_over
push ecx ;为put_char函数传递参数
call put_char
add esp,4 ;回收参数所占用的空间
inc ebx ;让ebx指向下一个字符
jmp .goon ;循环判断字符串直到末尾
.str_over:
pop ebx ;还原寄存器
pop ecx
ret ;返回
;----------------------------- put_char -----------------------
;功能: 将栈中的一个字符写入光标所在处
;--------------------------------------------------------------
;略......

然后就是添加头文件:

1
2
3
4
// ;/home/mouse/OS_mouse/tool/bochs/mouse/lib/kernel/print.h
/****略****/
void put_str(char* essage); //打印一个字符串
/****略****/

最后修改一下main.c,然后就可以执行了:

1
2
3
4
5
6
7
8
/***** /home/mouse/OS_mouse/tool/bochs/mouse/kernel/main.c *****/
#include "print.h"
int main()
{
put_str("Mouse_put_str\r\b");
while(1); //防止退出
return 0;
}
1
2
3
4
mouse@ubuntu:~/OS_mouse/tool/bochs/mouse/kernel$ sh kernel_start.sh 
#记录了5+1 的读入
#记录了5+1 的写出
#2916 bytes (2.9 kB, 2.8 KiB) copied, 0.000288471 s, 10.1 MB/s

然后运行之后,如果再屏幕上看见字符串Mouse_put_str,并且光标在字符串这一行的末尾则现象正确(先换行,然后又退了一格)


最后一步,实现打印整数(pot_int),为最后的pirntf实现打基础,同时这里结束后也要学习什么是内联汇编(C和汇编联合编译)

下面还是给出需要添加的代码:

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
;/home/mouse/OS_mouse/tool/bochs/mouse/lib/kernel/print.S
;略......................
[bits 32]
section .text
put_int_buffer dq 0 ; 定义 8 字节缓冲区用于数字到字符的转换

;----------------------------- put_int -----------------------
;功能: 将小端字节序的数字变成对应的 ASCII 后,倒置
;输入:栈中参数为待打印的数字
;输出:在屏幕上打印十六进制数字,并不会打印前缀 0x
; 如打印十进制 15 时,只会直接打印 f,不会是 0xf
;--------------------------------------------------------------
global put_int ;声明为外部可调用
put_int:
pushad ; 保存所有通用寄存器状态
mov ebp, esp ; 设置基址指针为当前栈顶
mov eax, [ebp+4 * 9] ; 获取栈中参数(返回地址4字节 + pushad的8个4字节)
mov edx, eax ; 复制参数到EDX用于处理
mov edi, 7 ; 设置缓冲区起始偏移(指向最高字节位置)
mov ecx, 8 ; 循环计数器:32位数字对应8个十六进制位
mov ebx, put_int_buffer ; EBX指向转换缓冲区基地址

; 将32位数字按十六进制从低位到高位处理
.16based_4bits: ; 处理每个4位十六进制数字
and edx, 0x0000000F ; 取当前最低4位
cmp edx, 9 ; 判断是数字(0-9)还是字母(A-F)
jg .is_A2F ; 大于9则为字母
add edx, '0' ; 转换为数字字符ASCII
jmp .store
.is_A2F:
sub edx, 10 ; 计算字母偏移量(A-F对应10-15)
add edx, 'A' ; 转换为字母字符ASCII

; 将字符存储到缓冲区(高位字符在低地址)
.store:
mov [ebx+edi], dl ; 存储转换后的字符
dec edi ; 前移缓冲区位置(向低地址)
shr eax, 4 ; 右移4位处理下一个十六进制位
mov edx, eax ; 更新EDX为剩余未处理部分
loop .16based_4bits ; 循环处理所有8个十六进制位

; 准备打印:跳过高位连续的'0'
.ready_to_print:
inc edi ; 调整EDI到第一个字符位置(之前为-1)
.skip_prefix_0:
cmp edi, 8 ; 检查是否已处理完所有字符
je .full0 ; 如果全是0则跳转到全0处理
.go_on_skip:
mov cl, [put_int_buffer+edi] ; 读取当前字符
inc edi ; 指向下一个字符
cmp cl, '0' ; 检查是否为'0'
je .skip_prefix_0 ; 如果是'0'则继续跳过
dec edi ; 回退到第一个非0字符位置
jmp .put_each_num ; 开始打印非0部分
.full0:
mov cl, '0' ; 全0情况只打印单个'0'
.put_each_num:
push ecx ; 压栈字符参数供put_char使用
call put_char ; 调用字符打印函数
add esp, 4 ; 清理栈空间
inc edi ; 指向缓冲区下一个字符
mov cl, [put_int_buffer+edi] ; 读取下一个字符
cmp edi, 8 ; 检查是否已打印所有字符
jl .put_each_num ; 继续打印直到结束
popad ; 恢复所有通用寄存器
ret ; 函数返回
;略......................
1
2
3
4
5
6
7
8
9
10
11
// ;/home/mouse/OS_mouse/tool/bochs/mouse/lib/kernel/print.h

#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"

void put_char(uint8_t char_asci); //打印一个字符
void put_str(char* essage); //打印一个字符串
void put_int(uint32_t num); //打印一个整数,16进制

#endif
1
2
3
4
5
6
7
8
9
10
11
12
/***** /home/mouse/OS_mouse/tool/bochs/mouse/kernel/main.c *****/
#include "print.h"
int main()
{
put_str("Mouse_put_str\r\b");
put_int(1);
put_str(" ");
put_int(12);
put_int(0x0000f);
while(1); //防止退出
return 0;
}

然后还是执行sh脚本,屏幕上应该会有1 CF显示,那么说明整数打印也没问题啦


D.4 内联汇编简介

之前介绍过了一种汇编方法,就是 C 代码和汇编代码分别编译,最后通过链接的方式结合在
一起形成可执行文件。
另一种方式就是在 C 代码中直接嵌入汇编语言,这就是内联汇编

这里主要列出内联汇编的部分知识点,具体使用可以参考例子,或者网上查阅相关资料

D4.1 汇编语言 AT&T 语法简介

我们在大学所学习的汇编语言大多数都是 Intel 语法,Linux 内核中的汇编代码一般都
是 AT&T 语法,下面列出表格指出主要的不同点:

特性 AT&T 语法 Intel 语法
操作数顺序 opcode src, dest opcode dest, src
寄存器前缀 % (例如 %eax) 无前缀 (例如 eax)
立即数前缀 $ (例如 $0x80) 无前缀 (例如 0x8080h)
内存操作数 disp(base, index, scale) [base + index*scale + disp]
(例如 4(%ebx)) (例如 [ebx+4])
操作码后缀 使用后缀指明大小 在内存操作数前加修饰
(例如 b-字节, w-字, l-双字) (例如 byte ptr)
远调用/跳转 lcall, ljmp call far, jmp far

D4.2 基本内联汇编

基本内联汇编是最简单的内联形式,格式为:
asm [volatile] ("assembly code") 其中voliatile的意思是不要修改我写的汇编代码,防止编译器优化

“assembly code”是咱们所写的汇编代码,它必须位于圆括号中,而且必须用双引号引起来。这>是格式要求,只要满足了这个格式 asm [volatile] (“”),assembly code 甚至可以为空。
下面说下 assembly code 的规则。
(1)指令必须用双引号引起来,无论双引号中是一条指令或多条指令。
(2)一对双引号不能跨行,如果跨行需要在结尾用反斜杠’'转义。
(3)指令之间用分号’;’、换行符’\n’或换行符加制表符’\n’’\t’分隔。
提醒一下,即使是指令分布在多个双引号中,gcc 最终也要把它们合并到一起来处理,合并之后,指令间必须要有分隔符。所以,当指令在多个双引号中时,除最后一个双引号外,其余双引号中的代码最后一定要有分隔符,这和其他编程语言中表示代码结束的分隔符是一样的,如:

1
2
asm("movl $9,%eax;""pushl %eax") 正确
asm("movl $9,%eax""pushl %eax") 错误

内联汇编中,咱们要注意操作数的顺序啦,现在是和 Intel 反着的

下面举一个完整的演示例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// home/mouse/OS_mouse/tool/bochs/mouse/drafts/inlineASM.c
//调用系统调用 write 打印机字符串
char * str="hello,world\n";
int count =0;
void main()
{
asm("pusha; \
movl $4,%eax; \
movl $1,%ebx; \
movl str,%ecx; \
movl $12,%edx; \
int $0x80; \
mov %eax,count; \
popa \
");
}

然后编译运行,便可以在中断看到打印的hello,world

1
2
gcc -m32  -o inlineASM.bin inlineASM.c 
./inlineASM.bin

D4.2 拓展内联汇编

很明显,之前的基础内联汇编并没有使用c语言里面的变量,而在实际中,使用变量是不可避免的,所以这里内联汇编的格式也发生了变化:
asm [volatile] (“assembly code”:output : input : clobber/modify)

和前面的基本内联汇编相比,扩展内联汇编在圆括号中变成了 4 部分,多了 output、input 和clobber/modify 三项。其中的每一部分都可以省略,甚至包括 assembly code。省略的部分要保留冒号分隔符来占位,如果省略的是后面的一个或多个连续的部分,分隔符也不用保留,比如省略了 clobber/modify,不需要保留 input 后面的冒号。

output:output 用来指定汇编代码的数据如何输出给 C 代码使用
格式: “操作数修饰符约束名”(C 变量名)
input:input 用来指定 C 中数据如何输入给汇编使用
格式: “[操作数修饰符] 约束名”(C 变量名)
clobber/modify:汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据的破坏,这样 gcc 就知道哪些寄存器或内存需要提前保护起来

这里还是直接举个例子,更加容易理解:
分别是参数约束和内存约束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// home/mouse/OS_mouse/tool/bochs/mouse/drafts/inlineASM.c
// 拓展内联汇编
#include <stdio.h>
void main()
{
//参数约束:两数相加
int in_a = 1, in_b = 2, out_sum;
asm("addl %%ebx, %%eax":"=a"(out_sum):"a"(in_a),"b"(in_b));
printf("sum is %d\n",out_sum);

//内存约束:变量 in_b 用 in_a 的值替换。in_b 最终变成 1。
in_a = 1, in_b = 2;
printf("in_b is %d\n",in_b);
asm("movb %b0, %1;"::"a"(in_a),"m"(in_b));
printf("in_b now is %d\n", in_b);
}

然后还是编译输出查看结果:

1
2
gcc -m32  -o reg_constraint.bin reg_constraint.c
./reg_constraint.bin

屏幕上如果输出如下,那么就成功运行啦
sum is 3
in_b is 2
in_b now is 1

下面来说说什么是占位符,占位符分为序号占位符和名称占位符两种
这里还是举几个例子,就不细说概念了

asm("addl %%ebx, %%eax":"=a"(out_sum):"a"(in_a),"b"(in_b));
等价于
asm("addl %2, %1":"=a"(out_sum):"a"(in_a),"b"(in_b));

其中
"=a"(out_sum)序号为0%0对应的是eax
"a"(in_a)序号为1%1对应的是eax
"b"(in_b)序号为2%2对应的是ebx

下面还有名称占位符,等用到的时候我再加解释,大家可以自行去书中了解


E. 中断

在学习中断之前,我提前来规划一下以后编译使用的Makefile,因为以后的文件肯定会慢慢增多,所以再用以前的指令或者sh脚本肯定不方便,那肯定有专门的文件来干这个事吧,那就是Makefile文件了


E.1 Makefile

这里书中是学到内存管理才介绍的,但为了编译文件方便,这里提前介绍一下,并创建我们的makefile文件

emm,至于语法和概念大家自行查找吧,因为具体内容很多地方都会讲,比如之前学习imx6u也会说明,这里列出后面会使用的文件内容,方便一会写中断等文件后编译

这里我选择分层级创建makefile文件,也就是在内核的文件夹下创建一个,然后分别在/lib/kernel/lib/user等路径下都添加一个(如果没有,可以参考我给的路径创建一个,后期会用到),以后如果添加文件就只用在对应的目录下进行操作即可,这里大部分我都写了注释,大家可以参考参考……

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
# =================================================================================
# Makefile 内核构建文件 /home/mouse/OS_mouse/tool/bochs/mouse/kernel/Makefile
# =================================================================================

# .PHONY 标明伪目标,它代表了一个需要被执行的动作或任务,而非一个需要被生成的文件
# 可以在本文件所在目录下 使用make all 、 make clean 等命令执行一系列任务
.PHONY: all kernel user clean img dirs

# ================================ 基础路径 =======================================
# 其中的 ":=" 表示立即展开使用,即右边的变量值会被立刻赋值,"$" 可以引用之前创建的变量
# =================================================================================
MOUSE_DIR := ..
BUILD_DIR := $(MOUSE_DIR)/build
BUILD_KERNEL_DIR := $(BUILD_DIR)/kernel
BUILD_USER_DIR := $(BUILD_DIR)/user
IMG_PATH := /home/mouse/OS_mouse/tool/bochs/hd60M.img

# ================================ 编译工具 =======================================
CC := gcc
ASM := nasm
LD := ld

# C编译器标志,这里指出输出32位,编译器不识别/使用内建函数,禁用栈保护机制,指定当前为独立式环境(标准库不存在),
# -I 分别包含需要的头文件路径(共用库,内核库,用户库,内核,设备)
CFLAGS := -m32 -fno-builtin -fno-stack-protector -ffreestanding \
-I$(MOUSE_DIR)/lib -I$(MOUSE_DIR)/lib/kernel -I$(MOUSE_DIR)/lib/user \
-I$(MOUSE_DIR)/kernel -I$(MOUSE_DIR)/device -c
ASMFLAGS := -f elf
LDFLAGS := -m elf_i386 -Ttext 0xc0001500 -e main

# ===================== 包含子Makefile,导入子模块源文件 ===============================
include $(MOUSE_DIR)/lib/kernel/Makefile
include $(MOUSE_DIR)/lib/user/Makefile
include $(MOUSE_DIR)/device/Makefile
include $(MOUSE_DIR)/lib/Makefile

# ============================== 本目录源文件 ===========================================
KERNEL_SRCS := main.c init.c interrupt.c debug.c memory.c
KERNEL_ASMS := kernel.S

# ========================== 为各模块添加前缀路径 =======================================
# $(addprefix <prefix>,<names>)是一个内置函数,能将路径prefix添加到names前
# ======================================================================================
KERNEL_SRCS := $(addprefix $(MOUSE_DIR)/kernel/,$(KERNEL_SRCS))
KERNEL_ASMS := $(addprefix $(MOUSE_DIR)/kernel/,$(KERNEL_ASMS))

LIB_KERNEL_SRCS := $(addprefix $(MOUSE_DIR)/lib/kernel/,$(LIB_KERNEL_SRCS))
LIB_KERNEL_ASMS := $(addprefix $(MOUSE_DIR)/lib/kernel/,$(LIB_KERNEL_ASMS))

LIB_USER_SRCS := $(addprefix $(MOUSE_DIR)/lib/user/,$(LIB_USER_SRCS))
LIB_USER_ASMS := $(addprefix $(MOUSE_DIR)/lib/user/,$(LIB_USER_ASMS))

DEVICE_SRCS := $(addprefix $(MOUSE_DIR)/device/,$(DEVICE_SRCS))
DEVICE_ASMS := $(addprefix $(MOUSE_DIR)/device/,$(DEVICE_ASMS))

LIB_SRCS := $(addprefix $(MOUSE_DIR)/lib/,$(LIB_SRCS))
LIB_ASMS := $(addprefix $(MOUSE_DIR)/lib/,$(LIB_ASMS))

# ========================== 汇总全部源文件 ================================================
ALL_C_SRCS := $(KERNEL_SRCS) $(LIB_KERNEL_SRCS) $(LIB_USER_SRCS) $(DEVICE_SRCS) $(LIB_SRCS)
ALL_ASMS := $(KERNEL_ASMS) $(LIB_KERNEL_ASMS) $(LIB_USER_ASMS) $(DEVICE_ASMS) $(LIB_ASMS)

# ========================== 生成对应的 .o 文件路径 ========================================
# $(filter <pattern...>,<text>):这个函数用于从 <text>中筛选出符合模式 <pattern>的单词
# $(patsubst <pattern>,<replacement>,<text>):将 <text>中所有匹配 <pattern>的单词替换为
# <replacement>的形式,<pattern>中可以使用通配符 %
# 通过这个函数,将生成的.o文件分别生成到build路径下的不同路径(这里除了用户,都生成到/build/kernel)
# =========================================================================================
OBJS_KERNEL := \
$(patsubst $(MOUSE_DIR)/%.c,$(BUILD_KERNEL_DIR)/%.o,$(filter $(MOUSE_DIR)/%.c,$(ALL_C_SRCS))) \
$(patsubst $(MOUSE_DIR)/%.S,$(BUILD_KERNEL_DIR)/%.o,$(filter $(MOUSE_DIR)/%.S,$(ALL_ASMS)))

OBJS_USER := \
$(patsubst $(MOUSE_DIR)/lib/user/%.c,$(BUILD_USER_DIR)/%.o,$(filter $(MOUSE_DIR)/lib/user/%.c,$(ALL_C_SRCS))) \
$(patsubst $(MOUSE_DIR)/lib/user/%.S,$(BUILD_USER_DIR)/%.o,$(filter $(MOUSE_DIR)/lib/user/%.S,$(ALL_ASMS)))

KERNEL_BIN := $(BUILD_KERNEL_DIR)/kernel.bin

# ==================================== 主要规则 ==========================================
all: dirs kernel user

# 以@开头的指令,终端只会显示命令的输出,不显示命令本身--这里创建build文件夹
dirs:
@echo "Creating build directories..."
@mkdir -p $(BUILD_KERNEL_DIR) $(BUILD_USER_DIR)

kernel: $(KERNEL_BIN)
user: $(OBJS_USER)

$(KERNEL_BIN): $(OBJS_KERNEL) $(OBJS_USER)
@echo "Linking kernel object files to generate kernel.bin..."
$(LD) $(LDFLAGS) -o $@ $(filter $(BUILD_KERNEL_DIR)/%.o,$^) $(filter $(BUILD_USER_DIR)/%.o,$^)
@echo "Kernel linking completed!"

# ================================= 编译规则 ============================================
$(BUILD_KERNEL_DIR)/%.o: $(MOUSE_DIR)/%.c | dirs
@echo "Compiling C: $<"
@mkdir -p $(@D)
$(CC) $(CFLAGS) -o $@ $<

$(BUILD_KERNEL_DIR)/%.o: $(MOUSE_DIR)/%.S | dirs
@echo "Assembling: $<"
@mkdir -p $(@D)
$(ASM) $(ASMFLAGS) -o $@ $<

$(BUILD_USER_DIR)/%.o: $(MOUSE_DIR)/lib/user/%.c | dirs
@echo "Compiling user C: $<"
@mkdir -p $(@D)
$(CC) $(CFLAGS) -o $@ $<

$(BUILD_USER_DIR)/%.o: $(MOUSE_DIR)/lib/user/%.S | dirs
@echo "Assembling user S: $<"
@mkdir -p $(@D)
$(ASM) $(ASMFLAGS) -o $@ $<

# ================================== 镜像生成 ==========================================
img: $(KERNEL_BIN)
@echo "Writing kernel image to disk..."
dd if=$(KERNEL_BIN) of=$(IMG_PATH) bs=512 count=200 seek=9 conv=notrunc
@echo "Kernel image writing completed!"

# ================================== 清理规则 ==========================================
clean:
@echo "Cleaning build files..."
-rm -f $(BUILD_KERNEL_DIR)/*.o $(BUILD_KERNEL_DIR)/kernel.bin
-rm -f $(BUILD_USER_DIR)/*.o
@echo "Cleanup completed!"

1
2
3
4
5
6
7
# =================================================================================
# Makefile 用户库构建文件 /home/mouse/OS_mouse/tool/bochs/mouse/lib/user/Makefile
# =================================================================================
LIB_USER_DIR := lib/user
LIB_USER_SRCS :=
LIB_USER_ASMS :=
LIB_USER_INC := $(LIB_USER_DIR)
1
2
3
4
5
6
7
# =================================================================================
# Makefile 内核库构建文件 /home/mouse/OS_mouse/tool/bochs/mouse/kernel/Makefile
# =================================================================================
LIB_KERNEL_DIR := lib/kernel
LIB_KERNEL_ASMS := print.S
LIB_KERNEL_SRCS :=
LIB_KERNEL_INC := $(LIB_KERNEL_DIR)
1
2
3
4
5
6
7
8
9
10
# =================================================================================
# Makefile Device驱动构建文件 /home/mouse/OS_mouse/tool/bochs/mouse/device/Makefile
# =================================================================================

DEVICE_ASMS := #timer.S
DEVICE_ASMS +=

DEVICE_SRCS +=

DEVICE_INC := $(DEVICE_DIR)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# =================================================================================
# Makefile Lib通用库构建文件 /home/mouse/OS_mouse/tool/bochs/mouse/lib/Makefile
# =================================================================================

# 通用库汇编源文件定义
LIB_ASMS := # 暂无汇编文件
# LIB_ASMS += example.S # 如需添加汇编文件,在此处取消注释并添加

# 通用库C源文件定义
LIB_SRCS := #string.c # 字符串处理函数
LIB_SRCS += # 这里添加文件
# LIB_SRCS += stdio.c # 标准IO函数(如需添加取消注释)
# LIB_SRCS += memory.c # 内存操作函数(如需添加取消注释)

# 头文件目录路径
LIB_INC := $(LIB_DIR)

这样以后如果要添加编译的内核库文件,就只用在对应的子目录的 Makefile 里面添加文件即可(注意在主文件夹添加文件也要修改Makefile,因为没有使用自动扫描)
同时这里的编译为相对路径,只有写入磁盘是绝对路径,这样即便你把文件发送给别人,那么也可以直接使用
为了目录简洁,这里还引入一个新的目录build,将以后编译生成的文件都放到这里面

Makefile就先说到这里,下面继续学习中断


E.2 中断分类

这里的中断和之前stm32,或者说linux中断都是差不多的,这里还是简要说明,同时,这里全部都是以单核CPU为例子


  • 外部中断
  1. INTR(INTeRrupt):可屏蔽中断,CPU收到信号后可以缓慢处理,甚至不处理,因为它并不会影响CPU运行,同时也可以通过eflags寄存器的 IF 位 将这些中断全部屏蔽,对于这些每个中断源都可以获得一个中断向量号(有限的)
  2. NMI(Non Maskable Interrupt):不可屏蔽中断,说明发生了致命的错误,比如内存读写错误,电源掉电,总线奇偶校验失误等等,并且只会被分配一个中断向量号,因为其实软件工程师也解决不了这些问题

同时,这里简要说明一下中断的上半部和下半部,其实可以理解成上半部是表示得到中断信息(获得标志位)了,但是如果要处理的话可能会花费一点时间,这个时候如果有其它重要的事情也要处理的话,可能会影响别人的进程,所以这里将具体处理的部分可以放到下半部(执行


  • 内部中断
  1. 软中断,就是由软件主动发起的中断,由于该中断是软件运行中主动发起的,所以它是主观上的,并不是客观上的某种内部错误

  2. 异常,是指令执行期间 CPU 内部产生的错误引起的,由于是运行时错误,所以它不受标志寄存器 eflags 中的 IF 位影响,无法向用户隐瞒,只要中断关系到“正常”运行,就不受 IF 位影响,这里依照错误的轻重程度,分为三种:Fault(故障),Trap(陷阱),Abort(终止)

下面是发起中断的相关指令:除了第一个指令外,都可以称作异常,因为它们既具备软中断的“主动”行为,又具备异常的“错误”结果

  • int 8 位立即数,进行系统调用,可以表示256(8位)种中断;
  • int3,注意这里面没有空格,这里指的是调试断点指令,其所触发的中断向量号是3
  • into,中断溢出指令,所触发的中断向量号是 4,能否引发 4 号中断是要看 eflags 标志寄存器中的 OF 位是否为 1,如果是 1 才会引发中断,否则该指令悄悄地什么都不做
  • bound,检查数组索引越界指令,触发 5 号中断,该指令格式是bound 16/32 位寄存器,16/32 位内存,目的操作数是用寄存器来存储的,其内容是待检测的数组下标值。源操作数是内存,其内容是数组下标的下边界和上边界。当执行 bound 指令时,若下标处于数组索引的范围之外,则会触发 5 号中断
  • ud2,未定义指令,这会触发第 6 号中断,该指令表示指令无效,CPU 无法识别。主动使用它发起中断,常用于软件测试中,无实际用途

中断机制的本质是来了一个中断信号后,调用相应的中断处理程序。所以,CPU 不管有多少种类型的中断,为了统一中断管理,把来自外部设备、内部指令的各种中断类型统统归结为一种管理方式,即为每个中断信号分配一个整数,用此整数作为中断的 ID,而这个整数就是所谓的中断向量,然后用此 ID 作为中断描述符表中的索引,这样就能找到对应的表项,进而从中找到对应的中断处理程序。中断向量的作用和选择子类似,它们都用来在描述符表中索引一个描述符,只不过选择子用于在 GDT或 LDT 中检索段描述符,而中断向量专用于中断描述符表,其中没有 RPL 字段

异常和不可屏蔽中断的中断向量号是由 CPU 自动提供的,而来自外部设备的可屏蔽中断号是由中断代理提供的(咱们这里的中断代理是 8259A)软中断是由软件提供


E.3 中断描述符表

中断描述符表(Interrupt Descriptor Table,IDT)是保护模式下用于存储中断处理程序入口的表
实模式下用于存储中断处理程序入口的表叫中断向量表(Interrupt Vector Table,IVT),stm32也是中断向量表

同时,中断描述符表里面不仅仅有中断描述符,还有门描述符:

  • 任务门:任务门和任务状态段(Task Status Segment,TSS)是 Intel 处理器在硬件一级提供的任务切换机制,所以任务门需要和 TSS 配合在一起使用,在任务门中记录的是 TSS 选择子,偏移量未使用,但是大多数操作系统都未使用TSS,所以这里不多介绍

  • 中断门:中断门包含了中断处理程序所在段的段选择子和段内偏移地址。当通过此方式进入中断后,标志寄存器 eflags 中的 IF 位自动置 0,也就是在进入中断后,自动把中断关闭,避免中断嵌套。Linux 就是利用中断门实现的系统调用,就是那个著名的 int 0x80。

  • 陷阱门:陷阱门和中断门非常相似,区别是由陷阱门进入中断后,标志寄存器 eflags 中的 IF 位不会自动置 0

  • 调用门:调用门是提供给用户进程进入特权 0 级的方式,它不能用int 指令调用,只能用 call 和 jmp 指令

对比中断向量表,中断描述符表有两个区别:
(1)中断描述符表地址不限制,在哪里都可以,而中断向量表固定特殊地址(比如Flash存储器的起始位置)
(2)中断描述符表中的每个描述符用 8 字节描述


E.4 中断处理过程及保护

保护也就是特权级检查,完整的中断过程分为 CPU 外和 CPU 内两部分

  1. CPU 外:外部设备的中断由中断代理芯片接收,处理后将该中断的中断向量号发送到 CPU
  2. CPU 内:CPU 执行该中断向量号对应的中断处理程序

CPU 外这部分的处理过程,在后面咱们讲述中断代理芯片 Intel 8259A 时大家就会了解,这部分内容属于硬件范畴

/….略


E.5 可编程中断控制器 8259A

本节将介绍可屏蔽中断的代理—可编程中断控制器 8259A

8259A 用于管理和控制可屏蔽中断,它表现在屏蔽外设中断,对它们实行优先级判决,向 CPU 提供中断向量号等功能。而它称为可编程的原因,就是可以通过编程的方式来设置以上的功能

Intel 处理器共支持 256 个中断,但 8259A 只可以管理 8 个中断,不知道 Intel 这是要闹哪样,所以它为了多支持一些中断设备,提供了另一个解决方案,将多个 8259A 组合,官方术语就是级联。有了级联这种组合后,每一个 8259A 就被称为 1 片。若采用级联方式,即多片 8259A 芯片串连在一起,最多可级联 9 个,也就是最多支持 64 个中断。n 片 8259A 通过级联可支持 7n+1 个中断源,级联时只能有一片 8259A为主片 master,其余的均为从片 slave。来自从片的中断只能传递给主片,再由主片向上传递给 CPU,也就是说只有主片才会向 CPU 发送 INT 中断信号

我们主要要干两件事,也就是构建中断处理框架的流程
(1) 构造好IDT(中断描述符表)
(2) 提供中断向量号

还是实例才能更好的记住,下面来介绍如何编程:

对它的编程就是对它进行初始化,设置主片与从片的级联方式,指定起始中断向量号以及设置各种工作模式

其实,在开机之后的实模式下,BIOS 也对它光顾过,8259A 的 IRQ0~7已经被 BIOS 分配了 0x8~0xf 的中断向量号。而在保护模式下,中断向量号已经为 0x8~0xf 的范围已经被 CPU 占了,分配给了各种异常,咱们还得重新为 8259A 芯片上的 IRQ 接口们分配中断向量号
中断向量号是逻辑上的东西,它在物理上是 8259A 上的 IRQ 接口号。8259A 上 IRQ 号的排列顺序是固定的,但其对应的中断向量号是不固定的,这其实是一种由硬件到软件的映射,通过设置 8259A,可以将 IRQ 接口映射到不同的中断向量号

其中具体的控制方法请大家移动到书中看,书中介绍的非常详细

E.6 编写中断处理程序

这里实现第一个中断处理程序,写一个简陋的时钟中断,再逐渐丰富它

Intel 8259A 芯片位于主板上的南桥芯片中,咱们不需要像网卡、硬盘那样单独安装才能用,也不需要为它的各个 IR 引脚指定连接的外部设备,这一切都安排好啦,比如主片 IR0 引脚上就是时钟中断,这已经由内部电路实现了,咱们只需要直接操作 8259A 就行,不用担心这些外部设备是否连接上了 8259A

下面是这次的中断启动流程框架:

int_all(用来初始化所有的设备及数据结构) –> idt_init(它用来初始化中断相关的内容) –>pic_init(初始化可编程中断控制器 8259A) && idt_desc_init(初始化中断描述符表 IDT) –> 加载IDT(现在便准备好了打开中断的条件)

下面来开始写代码吧(文件路径为:/home/mouse/OS_mouse/tool/bochs/mouse/kernel/kernel.S),这里面还使用了新的内容:宏,具体内容在代码中我会打出注释
这里我直接给出所有编写的文件,大家可以参考书中内容来阅读,我写了一小部分在注释中

1
2
3
4
5
6
7
8
9
10
11
/***** /home/mouse/OS_mouse/tool/bochs/mouse/kernel/main.c *****/
#include "print.h"
#include "init.h"
int main()
{
put_str("I am kernel!\n");
init_all(); //初始化所有中断
asm volatile("sti") ; //暂时打开中断
while(1); //防止退出
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
// /home/mouse/OS_mouse/tool/bochs/mouse/kernel/init.c
#include "init.h"
#include "print.h"
#include "interrupt.h"

//初始化所有模块
void init_all()
{
put_str("init_all\n");
idt_init();
}
1
2
3
4
5
6
7
8
// /home/mouse/OS_mouse/tool/bochs/mouse/kernel/init.h

#ifndef __INIT_H
#define __INIT_H

void init_all(); //初始化所有模块

#endif
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
// /home/mouse/OS_mouse/tool/bochs/mouse/kernel/interrupt.c

#include "interrupt.h"
#include "stdint.h"
#include "global.h"
#include "print.h" //put_str
#include "io.h" //io操作,联合汇编


#define IDT_DESC_CNT 0x21 // 目前总共支持的中断数
#define PIC_M_CTRL 0x20 // 主片的控制端口是 0x20
#define PIC_M_DATA 0x21 // 主片的数据端口是 0x21
#define PIC_S_CTRL 0xa0 // 从片的控制端口是 0xa0
#define PIC_S_DATA 0xa1 // 从片的数据端口是 0xa1


// 中断门描述符结构体
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;
};

// 静态函数声明,非必须
// intr_handler 是自己定义的(interrupt.h) typedef void* intr_handler;
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中的中断处理函数入口函数


/* 初始化可编程中断控制器 8259A */
static void pic_init(void)
{
/*初始化主片 */
outb (PIC_M_CTRL, 0x11); // ICW1: 边沿触发,级联 8259, 需要 ICW4
outb (PIC_M_DATA, 0x20); // ICW2: 起始中断向量号为 0x20
//也就是 IR[0-7] 为 0x20 ~ 0x27
outb (PIC_M_DATA, 0x04); // ICW3: IR2 接从片
outb (PIC_M_DATA, 0x01); // ICW4: 8086 模式, 正常 EOI
/*初始化从片 */
outb (PIC_S_CTRL, 0x11); // ICW1: 边沿触发,级联 8259, 需要 ICW4
outb (PIC_S_DATA, 0x28); // ICW2: 起始中断向量号为 0x28
// 也就是 IR[8-15]为 0x28 ~ 0x2F
outb (PIC_S_DATA, 0x02); // ICW3: 设置从片连接到主片的 IR2 引脚
outb (PIC_S_DATA, 0x01); // ICW4: 8086 模式, 正常 EOI


/*打开主片上 IR0,也就是目前只接受时钟产生的中断 */
outb (PIC_M_DATA, 0xfe);
outb (PIC_S_DATA, 0xff);
put_str(" pic_init done\n");
}

/*创建中断门描述符*/
/*
* 参数: 中断门描述符的指针,中断描述符内的属性,中断描述符内对应的中断处理函数
*
*/
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; //定义在global.h : 指向内核数据段的选择子
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");
}

/*完成有关中断的所有初始化工作*/
void idt_init()
{
put_str("idt_init start\n");
idt_desc_init(); //初始化中断描述符表
pic_init(); //初始化8259A

//加载idt
uint64_t idt_operand = ((sizeof(idt) - 1) | ((uint64_t)((uint32_t)idt << 16)));
asm volatile("lidt %0" ::""(idt_operand));
put_str("idt_init done\n");
}

1
2
3
4
5
6
7
8
9
// /home/mouse/OS_mouse/tool/bochs/mouse/kernel/interrupt.h

#ifndef __INTERRUPUT_H
#define __INTERRUPUT_H

typedef void* intr_handler;
void idt_init();

#endif
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
// /home/mouse/OS_mouse/tool/bochs/mouse/kernel/global.h

#ifndef __KERNEL_GLOBAL_H
#define __KERNEL_GLOBAL_H
#include "stdint.h"

#define RPL0 1
#define RPL1 1
#define RPL2 2
#define RPL3 3

#define TI_GDT 0
#define TI_LDT 1

#define SELECTOR_K_CODE ((1 << 3 ) + (TI_GDT << 2) + RPL0)
#define SELECTOR_K_DATA ((2 << 3 ) + (TI_GDT << 2) + RPL0)
#define SELECTOR_K_STACK SELECTOR_K_DATA
#define SELECTOR_K_GS ((3 << 3 ) + (TI_GDT << 2) + RPL0)

/*------------- IDT 描述符属性 ----------------------*/

#define IDT_DESC_P 1
#define IDT_DESC_DPL0 0
#define IDT_DESC_DPL3 3
#define IDT_DESC_32_TYPE 0xE // 32 位的门
#define IDT_DESC_16_TYPE 0x6 // 16 位的门,不会用到
// 定义它只为和 32 位门区分
#define IDT_DESC_ATTR_DPL0 ((IDT_DESC_P << 7) + (IDT_DESC_DPL0 << 5) + IDT_DESC_32_TYPE)
#define IDT_DESC_ATTR_DPL3 ((IDT_DESC_P << 7) + (IDT_DESC_DPL3 << 5) + IDT_DESC_32_TYPE)

#endif
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
; /home/mouse/OS_mouse/tool/bochs/mouse/kernel/kernel.S

[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 ;定义了一个以空字符(NULL)结尾的字符串常量
global intr_entry_table ;声明可以外部调用

;下面是宏的使用格式:
; %macro 宏名字 参数个数 ;然后这里用%1,%2...来代替变量
;…
;宏代码体
;…
;%endmacro
;
;
intr_entry_table:
%macro VECTOR 2 ;定义VECTOR的宏,两个参数,宏开始
section .text
intr%1entry:: ;每个中断处理程序都要压入中断向量号--中断入口标签
;所以一个中断类型一个中断处理函数
;自己知道自己的中断向量号是多少
%2 ;第二个参数,ZERO,统一栈格式
push intr_str ;压入字符串
call put_str ;调用打印函数
add esp,4 ;跳过参数

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

add esp,4 ;跳过error_code

iret ;从中断返回,32 位下等同指令 iretd

section .data
dd intr%1entry: ;存储各个中断入口程序的地址
;形成 intr_entry_table
%endmacro ;宏结束

;这里将所有中断都指向打印字符串
;第 1 个参数 0x1e 是中断向量号,第二个参数主要是用来占位,表示是否自动压入错误码(ERROR_CODE)
VECTOR 0x00,ZERO ; 除法错误
VECTOR 0x01,ZERO ; 调试异常
VECTOR 0x02,ZERO ; 非屏蔽中断 (NMI)
VECTOR 0x03,ZERO ; 断点 (INT3)
VECTOR 0x04,ZERO ; 溢出 (INTO)
VECTOR 0x05,ZERO ; 边界范围超出 (BOUND)
VECTOR 0x06,ZERO ; 无效操作码 (UD2)
VECTOR 0x07,ZERO ; 设备不可用
VECTOR 0x08,ERROR_CODE ; 双重错误
VECTOR 0x09,ZERO ; 协处理器段超限
VECTOR 0x0a,ERROR_CODE ; 无效TSS
VECTOR 0x0b,ERROR_CODE ; 段不存在
VECTOR 0x0c,ZERO ; 栈段错误
VECTOR 0x0d,ERROR_CODE ; 通用保护错误
VECTOR 0x0e,ERROR_CODE ; 页错误
VECTOR 0x0f,ZERO ; 保留
VECTOR 0x10,ZERO ; x87浮点异常
VECTOR 0x11,ERROR_CODE ; 对齐检查
VECTOR 0x12,ZERO ; 机器检查
VECTOR 0x13,ZERO ; SIMD浮点异常
VECTOR 0x14,ZERO ; 虚拟化异常
VECTOR 0x15,ZERO ; 控制保护异常
VECTOR 0x16,ZERO ; 保留
VECTOR 0x17,ZERO ; 保留
VECTOR 0x18,ERROR_CODE ; 超线程技术异常
VECTOR 0x19,ZERO ; VMM通信异常
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 ; 通常用于系统调用或定时器中断
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
/******************机器模式 ******************* 
b -- 输出寄存器 QImode 名称,即寄存器中的最低 8 位:[a-d]l
w -- 输出寄存器 HImode 名称,即寄存器中 2 个字节的部分,如[a-d]x
HImode
"Half-Integer"模式,表示一个两字节的整数
QImode
"Quarter-Integer"模式,表示一个一字节的整数
******************************************************/

#ifndef __LIB_IO_H
#define __LIB_IO_H
#include "stdint.h"

/*这里使用static(静态) inline(内嵌)的意思就是为了让执行更快*/

/* 向端口port 写入一个字节*/
static inline void outb(uint16_t port ,uint8_t data)
{
/*对端口指定 N 表示 0~255, d 表示用 dx 存储端口号, %b0 表示对应 al,%w1 表示对应 dx */
asm volatile("outb %b0 ,%w1" :: "a" (data),"Nd" (port));
}

/* 将addr 处其实的word_cnt个字写入端口port*/
static inline void outsw(uint16_t port, const void* addr, uint32_t word_cnt)
{

/* +表示此限制即做输入,又做输出
ooutsw 是把 ds:esi 处的 16 位的内容写入 port 端口,我们在设置段描述符时
已经将 ds,es,ss 段的选择子都设置为相同的值了,此时不用担心数据错乱 */
asm volatile ("cld; rep outsw" : "+S" (addr), "+c" (word_cnt) : "d" (port));
}

/* 将从端口 port 读入的一个字节返回 */
static inline uint8_t inb(uint16_t port)
{
uint8_t data;
asm volatile ("inb %w1, %b0" : "=a" (data) : "Nd" (port));
return data;
}

/* 将从端口 port 读入的 word_cnt 个字写入 addr */
static inline void insw(uint16_t port, void* addr, uint32_t word_cnt)
{
asm volatile ("cld; rep insw" : "+D" (addr), "+c" (word_cnt) : "d" (port) : "memory");
}

#endif

大家可以通过main函数入手,一步一步看函数引用,这里大体介绍一下:

/mouse/kernel:
kernel.S文件里面添加了中断处理程序
global.h文件里面添加了常用的宏(IDT描述符相关的的宏)
interrupt.c文件里面添加了:初始化中断控制器8259A/中断描述符等
interrupt.h文件里面原始中断初始化函数的定义
init.c文件里面定义了主函数调用的初始化函数
init.h文件里面添加定义

/mouse/lib/kernel
io.h文件里面添加了对IO口的操作,通过汇编实现(为了快速调用展开)

1
2
make        #编译
make img #写入磁盘

最后通过前面写的Makefile文件来运行,会发现屏幕上移一直打印`nterrupt occur!``
这是因为我们开启了时钟产生的中断,然后进入kernel.S里面的汇编函数,打印对应内容
当然这一节主要是学习中断是如何构建的,如何编程中断控制器8259A,大家可以仔细阅读书中的解释,那里写的十分详细


E.7 改进中断处理程序

现在可以看到我们的中断处理函数是在kernel.S文件中,并且使用汇编语言,这样子的函数肯定不适合我们后期使用修改,所以以后我们用C语言书写中断处理函数,然后再汇编中调用即可
方案如下:在汇编版本的 intrXXentry 中调用 C 语言版本的中断处理函数。这样汇编中的 intrXXentry 就如其名一样,真正变成了中断的 entry,即入口,然后在C语言中建立一个中断处理数组idt_table,数组元素是C语言中断处理函数的地址,供汇编代码中的intrXXentry调用

然后这里要考虑在汇编中要如何方便的调用C语言的中断处理函数的地址
因为我们是模拟32位的操作系统,所以C语言里面的数组也是32位的,也就是4个字节,那么相对数组首地址(idt_table)的偏移就是4*n(第n个元素),比如数组中的第二个地址就可以表示为idt_table+1*4,注意这里的第二个的n是1,而中断向量号也是从0开始的,所以就可以表示为idt_table+中断向量号*4

下面主要修改两个文件interrupt.ckernel.S,为了方便查看/复制,这里还是选择给出完整代码
同时,如果大家使用的是vscode可以选择添加一个.vscode文件夹,然后写一个配置文件c_cpp_properties.json,这样就可以包含其它头文件,方便代码编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"configurations": [
{
"name": "Linux",
"includePath": [
"${workspaceFolder}/**",
"/home/mouse/OS_mouse/tool/bochs/mouse/lib/kernel",
"/home/mouse/OS_mouse/tool/bochs/mouse/lib/user",
"/home/mouse/OS_mouse/tool/bochs/mouse/lib",
"/home/mouse/OS_mouse/tool/bochs/mouse/kernel"
],
"defines": [],
"compilerPath": "/usr/bin/gcc",
"cStandard": "c11",
"cppStandard": "c++17",
"intelliSenseMode": "linux-gcc-x64"
}
],
"version": 4 //
}
interrupt.c: 点击查看更多
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
// /home/mouse/OS_mouse/tool/bochs/mouse/kernel/interrupt.c

#include "interrupt.h"
#include "stdint.h"
#include "global.h"
#include "print.h" //put_str
#include "io.h" //io操作,联合汇编


#define IDT_DESC_CNT 0x33 // 目前总共支持的中断数
#define PIC_M_CTRL 0x20 // 主片的控制端口是 0x20
#define PIC_M_DATA 0x21 // 主片的数据端口是 0x21
#define PIC_S_CTRL 0xa0 // 从片的控制端口是 0xa0
#define PIC_S_DATA 0xa1 // 从片的数据端口是 0xa1


// 中断门描述符结构体
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;
};

// 静态函数声明,非必须
// intr_handler 是自己定义的(interrupt.h) typedef void* intr_handler;
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 exception_init(void); //完成一般中断处理函数注册及异常名称注册
static void general_intr_handler(uint8_t vec_nr); //通用的中断处理函数,一般在异常出现时处理
const char* intr_name[IDT_DESC_CNT]; //用于保留异常的名字
intr_handler idt_table[IDT_DESC_CNT]; //中断处理函数程序数组,在kernel.S 中定义的 intrXXentry是中断处理函数的入口,最终调用 idt_table 里面的函数


/* 初始化可编程中断控制器 8259A */
static void pic_init(void)
{
/*初始化主片 */
outb (PIC_M_CTRL, 0x11); // ICW1: 边沿触发,级联 8259, 需要 ICW4
outb (PIC_M_DATA, 0x20); // ICW2: 起始中断向量号为 0x20
//也就是 IR[0-7] 为 0x20 ~ 0x27
outb (PIC_M_DATA, 0x04); // ICW3: IR2 接从片
outb (PIC_M_DATA, 0x01); // ICW4: 8086 模式, 正常 EOI
/*初始化从片 */
outb (PIC_S_CTRL, 0x11); // ICW1: 边沿触发,级联 8259, 需要 ICW4
outb (PIC_S_DATA, 0x28); // ICW2: 起始中断向量号为 0x28
// 也就是 IR[8-15]为 0x28 ~ 0x2F
outb (PIC_S_DATA, 0x02); // ICW3: 设置从片连接到主片的 IR2 引脚
outb (PIC_S_DATA, 0x01); // ICW4: 8086 模式, 正常 EOI


/*打开主片上 IR0,也就是目前只接受时钟产生的中断 */
outb (PIC_M_DATA, 0xfe);
outb (PIC_S_DATA, 0xff);
put_str(" pic_init done\n");
}

/*创建中断门描述符*/
/*
* 参数: 中断门描述符的指针,中断描述符内的属性,中断描述符内对应的中断处理函数
*
*/
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; //定义在global.h : 指向内核数据段的选择子
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");
}

//添加部分
//通用的中断处理函数,一般在异常出现时处理
static void general_intr_handler(uint8_t vec_nr)
{
if(vec_nr == 0x27 || vec_nr == 0x2f) //IRQ7s和IRQ15会产生伪中断,无需处理,0x2f是从片8259A上的最后一个IRQ引脚,保留项
{
return;
}
put_str("int vector : 0x");
put_int(vec_nr);
put_str("\n");
}

//页中断,我当时调试使用的,大家可以不用管
static void page_fault_handler(uint8_t vec_nr)
{
if(vec_nr == 0x27 || vec_nr == 0x2f) //IRQ7s和IRQ15会产生伪中断,无需处理,0x2f是从片8259A上的最后一个IRQ引脚,保留项
{
return;
}
put_str("int vector : 0x");
put_int(vec_nr);
put_str("\n");

uint32_t faulting_address;
asm volatile ("mov %%cr2, %0" : "=r" (faulting_address)); // 读取CR2
put_str("Page fault at address: 0x");
put_int(faulting_address); // 十六进制打印
put_str("\n");
}


//完成一般中断处理函数注册及异常名称注册
static void exception_init(void)
{
int i;
for(i=0;i<IDT_DESC_CNT;i++)
{
/* idt_table 数组中的函数是在进入中断后根据中断向量号调用的 */
/* 见 kernel/kernel.S 的 call [idt_table + %1*4] */
idt_table[i] = general_intr_handler; // 默认为 general_intr_handler ,以后会由 register_handler 来注册具体处理函数
intr_name[i] = "unknown"; // 先统一赋值为 unknow
}
idt_table[14] = page_fault_handler; //页错误处理函数
intr_name[0] = "#DE Divide Error";
intr_name[1] = "#DB Debug Exception";
intr_name[2] = "NMI Interrupt";
intr_name[3] = "#BP Breakpoint Exception";
intr_name[4] = "#OF Overflow Exception";
intr_name[5] = "#BR BOUND Range Exceeded Exception";
intr_name[6] = "#UD Invalid Opcode Exception";
intr_name[7] = "#NM Device Not Available Exception";
intr_name[8] = "#DF Double Fault Exception";
intr_name[9] = "Coprocessor Segment Overrun";
intr_name[10] = "#TS Invalid TSS Exception";
intr_name[11] = "#NP Segment Not Present";
intr_name[12] = "#SS Stack Fault Exception";
intr_name[13] = "#GP General Protection Exception";
intr_name[14] = "#PF Page-Fault Exception";
// intr_name[15] 第 15 项是 intel 保留项,未使用
intr_name[16] = "#MF x87 FPU Floating-Point Error";
intr_name[17] = "#AC Alignment Check Exception";
intr_name[18] = "#MC Machine-Check Exception";
intr_name[19] = "#XF SIMD Floating-Point Exception";
}

/*完成有关中断的所有初始化工作*/
void idt_init()
{
put_str("idt_init start\n");
idt_desc_init(); //初始化中断描述符表
exception_init(); //初始化异常名并注册通常的中断处理函数
pic_init(); //初始化8259A

//加载idt
uint64_t idt_operand = ((sizeof(idt) - 1) | ((uint64_t)((uint32_t)idt << 16)));
asm volatile("lidt %0" ::""(idt_operand));
put_str("idt_init done\n");
}
kernel.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
; /home/mouse/OS_mouse/tool/bochs/mouse/kernel/kernel.S

[bits 32]
%define ERROR_CODE nop ;若在相关的异常中 CPU 已经自动压入了
;错误码,为保持栈中格式统一,这里不做操作
%define ZERO push 0 ;若在相关的异常中 CPU 没有压入错误码
;为了统一栈中格式,就手工压入一个 0

extern put_str ;声明外部函数
extern idt_table ;声明c中注册的中断处理程序数组

section .data
global intr_entry_table ;声明可以外部调用

;下面是宏的使用格式:
; %macro 宏名字 参数个数 ;然后这里用%1,%2...来代替变量
;…
;宏代码体
;…
;%endmacro
;
;

intr_entry_table:
%macro VECTOR 2 ;定义VECTOR的宏,两个参数,宏开始
section .text
intr%1entry: ;每个中断处理程序都要压入中断向量号--中断入口标签,具体值也就是中断向量号
;所以一个中断类型一个中断处理函数
;自己知道自己的中断向量号是多少
%2 ;第二个参数,ZERO,统一栈格式
;保存上下文环境
push ds
push es
push fs
push gs
pushad ;压入32位寄存器,入栈顺序是:EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI,其中EAX 最先入栈

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

push %1 ;不管idt_tableA目标重新是否需要参数,一律压入中断向量号,调试很方便

call [idt_table + %1*4] ;调用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

;这里将中断都指向对应的函数[idt_table + %1*4]
;第 1 个参数 0x1e 是中断向量号,第二个参数主要是用来占位,表示是否自动压入错误码(ERROR_CODE)
VECTOR 0X00,ZERO ; 除法错误异常
VECTOR 0x01,ZERO ; 调试异常
VECTOR 0x02,ZERO ; 非屏蔽中断 (NMI)
VECTOR 0x03,ZERO ; 断点 (INT3)
VECTOR 0x04,ZERO ; 溢出 (INTO)
VECTOR 0x05,ZERO ; 边界范围超出 (BOUND)
VECTOR 0x06,ZERO ; 无效操作码 (UD2)
VECTOR 0x07,ZERO ; 设备不可用
VECTOR 0x08,ERROR_CODE ; 双重错误
VECTOR 0x09,ZERO ; 协处理器段超限
VECTOR 0x0a,ERROR_CODE ; 无效TSS
VECTOR 0x0b,ERROR_CODE ; 段不存在
VECTOR 0x0c,ZERO ; 栈段错误
VECTOR 0x0d,ERROR_CODE ; 通用保护错误
VECTOR 0x0e,ERROR_CODE ; 页错误
VECTOR 0x0f,ZERO ; 保留
VECTOR 0x10,ZERO ; x87浮点异常
VECTOR 0x11,ERROR_CODE ; 对齐检查
VECTOR 0x12,ZERO ; 机器检查
VECTOR 0x13,ZERO ; SIMD浮点异常
VECTOR 0x14,ZERO ; 虚拟化异常
VECTOR 0x15,ZERO ; 控制保护异常
VECTOR 0x16,ZERO ; 保留
VECTOR 0x17,ZERO ; 保留
VECTOR 0x18,ERROR_CODE ; 超线程技术异常
VECTOR 0x19,ZERO ; VMM通信异常
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 ; 通常用于系统调用或定时器中断,这里用来测试中断

这里我有遇到一个小错误,那就是第一次写的时候在kernel.S文件中没有添加VECTOR 0x00,ZERO,使得描述符表不完整,然后就会一直中断报错(页错误),也是在这个实验结果下发现的,因为这次修改之后,添加了中断号的显示,正常显示应该是0x20,但是我的显示是0XE,也就是页错误

后面书中还具体讲了调试,也就是查看这个中断的具体除法流程,包括压栈出栈,大家可以去书中阅读


E.8253可编程计数器/定时器 简介

计算机中的时钟,大致上可分为两大类:内部时钟和外部时钟

内部时钟是指处理器中内部元件,如运算器、控制器的工作时序,主要用于控制、同步内部工作过程的步调。内部时钟是由晶体振荡器产生的,简称晶振,它位于主板上,其频率经过分频之后就是主板的外频,处理器和南北桥之间的通信就基于外频,通常是ns级别的,内部定时是无法改变的

外部时钟是指处理器与外部设备或外部设备之间通信时采用的一种时序,比如 IO 接口和处理器之间在 A/D 转换时的工作时序、两个串口设备之间进行数据传输时也要事先同步时钟等。外部设备的速度对于处理器来说就很慢了,所以其时钟的时间单位粒度较大,一般是毫秒(ms)级或秒(s)级的

对于外部定时,我们有两种实现方式:

  • 软件实现

int cycle_cnt = 90000;
while(cycle_cnt-- > 0);

通过执行空循环达成一定的延时,但这样会浪费处理器的时钟周期

  • 硬件实现

这类实现定时的硬件叫做定时器,计时器的功能就是定时发信号。当到达了所计数的时间,计数器可以自动发一个输出信号,可以用该信号向处理器发出中断,和软件定时相比,硬件定时器不占用处理器,因为它是独立运行的,当然还分为可编程或不可编程定时器,下面主要介绍8253:

硬件定时器一般有两种计时的方式:倒计时、正计时,而8253是倒计时,下面主要说一下怎么让定时器定时,具体的原理比如各种工作模式大家可以移步书中阅读

(1)GATE 为高电平,即 GATE 为 1,这是由硬件来控制的。
(2)计数初值已写入计数器中的减法计数器,这是由软件 out 指令控制的。

当这两个条件具备后,计数器将在下一个时钟信号 CLK 的下降沿开始计数

下面来说说8256的初始化步骤:

  1. 往控制字寄存器端口 0x43 中写入控制字
  2. 在所指定使用的计数器端口中写入计数初值 (计数初值寄存器是 16 位,高 8 位和低 8 位可单独使用,所以初值是 8 位或 16 位皆可。若初值是 8 位,直接往计数器端口写入即可。若初值为 16 位,必须分两次来写入,先写低 8 位,再写高 8 位)

下面来实操一下,让中断引脚的频率加快一点,之前的默认频率为18.206hz,也就是1s 18次,现在我们将它修改成1s发送100次,也就是100HZ

这个属于设备的代码,我们新建一个文件夹/mouse/device,然后在这里写代码吧,在之前的Makefile部分我已经给出了对应Makefile文件的内容,注意将里面对timer.c的注释删掉

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
// /home/mouse/OS_mouse/tool/bochs/mouse/device/timer.c
#include "timer.h"
#include "io.h"
#include "print.h"

#define IRQ0_FREQUENCY 100 //100HZ
#define INPUT_FREQUENCY 1193180 //计数器0的工作脉冲信号频率
#define COUNTER0_VALUE INPUT_FREQUENCY / IRQ0_FREQUENCY //通过宏来计算初值
#define CONTRER0_PORT 0x40 //端口2
#define COUNTER0_NO 0 //计数器0
#define COUNTER_MODE 2 //工作模式 方式2
#define READ_WRITE_LATCH 3 //读写方式,3表示先读写低8位,再读写高8位
#define PIT_CONTROL_PORT 0x43 //控制字寄存器的端口

// 把操作的计数器 counter_no、 读写锁属性 rwl、 计数器模式 counter_mode 写入模式控制寄存器并赋予初始值 counter_value
static void frequency_set(uint8_t counter_port,\
uint8_t counter_no,\
uint8_t rwl,\
uint8_t counter_mode,\
uint16_t counter_value)
{
//往控制字寄存器端口 0x43 中写入控制字
outb(PIT_CONTROL_PORT, (uint8_t)(counter_no << 6 | rwl << 4 | counter_mode << 1));
//先写入 counter_value 的低 8 位
outb(counter_port, (uint8_t)counter_value);
//再写入 counter_value 的高 8 位
outb(counter_port, (uint8_t)counter_value >> 8);
}


//初始化 PIT8253
void timer_init()
{
put_str("timer_init start\n");
//设置 8253 的定时周期,也就是发中断的周期
frequency_set(CONTRER0_PORT, \
COUNTER0_NO, \
READ_WRITE_LATCH,\
COUNTER_MODE, \
COUNTER0_VALUE);
put_str("timer_init done\n");
}
1
2
3
4
5
6
7
8
9
// /home/mouse/OS_mouse/tool/bochs/mouse/device/timer.h

#ifndef __TIMER_H
#define __TIMER_H

//定时器PIT8253的初始化
void timer_init();

#endif

最后在init.c文件中调用timer_init即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
// /home/mouse/OS_mouse/tool/bochs/mouse/kernel/init.c
#include "init.h"
#include "print.h"
#include "interrupt.h"
#include "timer.h"

//初始化所有模块
void init_all()
{
put_str("init_all\n");
idt_init(); //中断
timer_init(); //定时器设备 PIT
}

然后 make make img 运行即可,但是这里我们好像看不出来定时器中断是否真的加速了咳咳
大家可以选择在中断函数中添加一个静态变量,然后每多少次中断更改一次数值,来验证代码是否正确


F 内存管理前的小结

这次我们学习了如何内联汇编,实现了基本的打印函数,还有对中断的初始化,最后学习了怎么修改定时器,在这个章节,内联汇编语法显得很重要,同时我们还引入了Makefile文件,比起sh脚本它管理一个大的项目更加有优势,当然,这次的概念也非常多,比如说用户特权,中断描述符等等,还得找个时间好好阅读以下书中的介绍,以及查阅相关资料

这里主要遇到的麻烦可能就是中断初始化那一块的内容,因为涉及大量的操作
学无止境,下一章就是非常重要的内存管理了~


参考文献

  1. [操作系统真象还原 (郑纲) (Z-Library)] — 大家可以自己在网上查找相关资源

留言

有问题请指出,你可以选择以下方式:

  1. 在下方评论区留言
  2. 邮箱留言
  • Title: 真象还原 --内核/中断 study(2)
  • Author: H_Haozi
  • Created at : 2025-10-24 14:13:00
  • Updated at : 2025-10-31 15:31:31
  • Link: https://redefine.ohevan.com/2025/10/24/os_elephant_two/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments