思路
对于装有操作系统OS的x86系统来说,正常用户模式是没有办法自定义中断的,用户模式提供的软中断只有系统调用,比如int 80h和syscall
,且无法自定义中断号和中断处理函数。但是我们确实可以编写硬中断的处理函数,也就是ISR,中断服务程序,只不过需要内核编程(Linux)或者内核驱动编写(Windows),比如Windows下,利用WDK驱动开发包,绑定IRQ,使用IoConnectIntteruptEx
函数可以注册中断服务程序,然后用户态使用DeviceControl
函数和驱动通信,进一步调用中断程序。
但是这不是这篇博客的内容,这篇博客主要在裸机上实现自定义中断,具体来说就是自定义IDT中断描述符表,大概步骤如下:
-
编写引导文件boot.asm
-
需要重置磁盘控制器,然后加载内核kernel到指定物理地址处
-
进入保护模式,需要三件事:定义配置GDT并加载描述符到GDTR寄存器;打开A20地址线;设置CR0寄存器PE位;然后长跳到保护模式代码段地址区域
-
简单初始化数据段寄存器后,就可以跳转内核代码物理地址处
-
-
编写内核代码kernel.asm
-
定义入口点,配置IDT中断描述符表:定义IDT结构;定义256个IDT表(默认填充即可)和IDT描述符,并把IDT描述符用lidt加载到IDTR寄存器中
-
使用我们自定义的中断编号,找到256个IDT表中的对应地址(因为APIC高级可编程中断控制器的IRQ线是256个【255个,有一个连接次级APIC,传统PIC是15个IRQ中断请求线】),设置特定的我们需要的IDT条目(基址,选择子,中断门,特权),基址换成我们自定义中断处理函数的地址
-
实现
注意点
在具体实现代码之前,先说明几个注意点:
-
GDT表的设置,初始至少设置三个,第一个是空描述符表(防止CPU误操作),用dd 0双字占位8字节,第二个是代码段描述符表,第三个是数据段描述符表。GDT描述符表一共是可以设置8192个的,只不过在纯分页式OS盛行的当下,不会用多少分段,但也不会完全不用,但是会设计一些用来控制一些特定内存段的权限的【8192是CS寄存器的高13位决定的,低3位是请求特权位,CS寄存器内容就是段选择子】。不论设置了多少个GDT表,它们都是连续物理内存排列的(因为在实模式下),GDT的描述符【包括基址和整体的大小】需要加载进GDTR寄存器,通过lgdt指令
-
实模式下打印字符串,可以用bios中断,利用第0x10号中断,而保护模式下则不能使用bios中断,因此只能直接向0xB8000文本模式显存处直接写数据。当然实模式下也可以直接写数据,只是没必要
-
DT表一共有256个,不像GDT有8192个但是初始只需要设置三个就可以(更多当然也可以),IDT表需要全部设置,当然不需要具体有效数字,填充完就行,也就是填充1KB(256*4B)的0,IDT表也是连续排列,虽然是在保护模式下用的虚拟地址,但是只有1K,而且内存对齐,还有特权限制,可以保证不会跨物理页。IDT的描述符包含IDT基址和大小,和GDT一样,需要lidt指令加载进IDTR寄存器中。
-
IDT设置完成前需要cli禁止中断,设置完成后再sti开启中断
-
对于我们自定义的中断处理函数,只要根据自定义的中断号,找到IDT表中对应的项,然后设置基址,GDT选择子,权限和中断门标志就行,只不过基址用我们自定义的函数地址替代
boot.asm
注意点说完,下面就是具体实现,首先是引导文件boot.asm,作用是加载内核代码到内存中,初始化GDT,进入保护模式,并且跳转到内核代码入口点
[BITS 16]
[ORG 0x7C00]
START:
; 初始化段寄存器
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
; 显示启动消息
mov si, MSG_BOOT
call PRINT_STRING
; 重置软盘控制器
xor ah, ah
xor dl, dl
int 0x13
jc DISK_ERROR
; 加载内核到内存
mov si, MSG_LOAD
call PRINT_STRING
; 设置段寄存器,加载内核到0x1000:0
mov ax, 0x1000
mov es, ax
xor bx, bx ; ES:BX = 0x1000:0
; 从软盘读取内核
mov ah, 0x02 ; 读取功能
mov al, 20 ; 读取20个扇区
mov ch, 0 ; 柱面0
mov cl, 2 ; 扇区2(引导扇区之后)
mov dh, 0 ; 磁头0
mov dl, 0 ; 驱动器A(软盘)
int 0x13
jc DISK_ERROR
; 显示成功消息
mov si, MSG_OK
call PRINT_STRING
; 等待一些时间,确保信息能看清
mov cx, 0xFFFF
.delay:
loop .delay
; 显示即将进入保护模式的消息
mov si, MSG_PMODE
call PRINT_STRING
; 等待一些时间
mov cx, 0xFFFF
.delay2:
loop .delay2
; 禁用中断
cli
; 加载GDT
lgdt [GDT_DESCRIPTOR]
; 启用A20线
in al, 0x92
or al, 2
out 0x92, al
; 切换到保护模式
mov eax, cr0
or eax, 1
mov cr0, eax
; 使用长跳转清空流水线
jmp CODE_SEG:PROTECTED_MODE
DISK_ERROR:
mov si, MSG_DISK_ERROR
call PRINT_STRING
jmp $
; 实模式下的字符串打印函数
PRINT_STRING:
mov ah, 0x0E
.next:
lodsb ; 加载DS:SI到AL并递增SI
test al, al ; 检查字符是否为0
jz .done
int 0x10 ; 调用BIOS中断显示字符
jmp .next
.done:
ret
[BITS 32]
PROTECTED_MODE:
; 初始化32位段寄存器
mov ax, DATA_SEG
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, 0x90000 ; 设置新的堆栈
; 使用直接跳转到内核入口点
; 内核被加载到0x10000(线性地址)
jmp dword 0x10000 ; 直接跳到内核的起始位置
; 字符串定义
MSG_BOOT db 'Booting OS...', 13, 10, 0
MSG_LOAD db 'Loading kernel...', 13, 10, 0
MSG_OK db 'Kernel loaded successfully!', 13, 10, 0
MSG_PMODE db 'Entering protected mode...', 13, 10, 0
MSG_DISK_ERROR db 'Error reading disk!', 13, 10, 0
; GDT定义
align 8
GDT_START:
; 空描述符
dd 0, 0
; 代码段描述符
dw 0xFFFF ; 段界限(0-15)
dw 0 ; 基址(0-15)
db 0 ; 基址(16-23)
db 10011010b ; 存在,特权级0,代码段,可读可执行
db 11001111b ; 4K粒度,32位
db 0 ; 基址(24-31)
; 数据段描述符
dw 0xFFFF ; 段界限(0-15)
dw 0 ; 基址(0-15)
db 0 ; 基址(16-23)
db 10010010b ; 存在,特权级0,数据段,可写
db 11001111b ; 4K粒度,32位
db 0 ; 基址(24-31)
GDT_END:
GDT_DESCRIPTOR:
dw GDT_END - GDT_START - 1 ; GDT大小减1
dd GDT_START ; GDT地址
; 段选择子
CODE_SEG equ 0x08 ; 第一个描述符之后的描述符(1<<3)
DATA_SEG equ 0x10 ; 第二个描述符之后的描述符(2<<3)
; 填充到引导扇区大小 (512字节) 并添加引导签名
times 510 - ($ - $$) db 0
dw 0xAA55
我加了非常详细的代码注释,希望读者能够看得更清晰
kernel.asm
然后就是内核代码:
[BITS 32]
[ORG 0x10000] ; 加载地址为0x10000
; 视频内存常量
VIDEO_MEMORY equ 0xB8000
WHITE_ON_BLACK equ 0x0F
GREEN_ON_BLACK equ 0x0A
RED_ON_BLACK equ 0x0C
; IDT描述符结构
struc IDT_ENTRY
.base_low: resw 1
.selector: resw 1
.zero: resb 1
.flags: resb 1
.base_high: resw 1
endstruc
; 入口点
global _start
_start:
; 清屏
mov edi, VIDEO_MEMORY
mov ecx, 80*25
mov ax, 0x0720 ; 空格字符,灰色背景
rep stosw
; 显示内核消息
mov eax, MSG_KERNEL
call display_message
; 设置IDT
call setup_idt
; 显示提示消息
mov eax, MSG_INT_READY
mov ebx, 13 ; 在第13行显示
mov ecx, GREEN_ON_BLACK
call display_at_row
; 触发0x80中断
int 0x80
; 测试0x21中断
int 0x21
; 无限循环
cli
jmp $
; 在屏幕中间显示消息
; eax = 消息指针
display_message:
push esi
push edi
push ecx
push edx
; 复制消息指针到ESI
mov esi, eax
; 计算消息长度
mov ecx, 0
.count_loop:
mov al, [esi+ecx]
test al, al
jz .count_done
inc ecx
jmp .count_loop
.count_done:
; 计算居中位置
mov eax, 80 ; 屏幕宽度
sub eax, ecx
shr eax, 1 ; 除以2
; 计算屏幕中间行
mov edi, VIDEO_MEMORY
add edi, 80*2*12 ; 第12行
; 计算起始位置
shl eax, 1 ; 乘以2(每个屏幕单元格2字节)
add edi, eax
; 显示消息
mov ah, WHITE_ON_BLACK
.disp_loop:
lodsb
test al, al
jz .disp_done
stosw
jmp .disp_loop
.disp_done:
pop edx
pop ecx
pop edi
pop esi
ret
; 在指定行显示消息
; eax = 消息指针
; ebx = 行号
; ecx = 颜色属性
display_at_row:
push esi
push edi
push ecx
push edx
; 保存颜色属性
mov edx, ecx
; 复制消息指针到ESI
mov esi, eax
; 计算消息长度
mov ecx, 0
.count_loop:
mov al, [esi+ecx]
test al, al
jz .count_done
inc ecx
jmp .count_loop
.count_done:
; 计算居中位置
mov eax, 80 ; 屏幕宽度
sub eax, ecx
shr eax, 1 ; 除以2
; 计算指定行的开始位置
mov edi, VIDEO_MEMORY
mov ecx, ebx
shl ecx, 1 ; 乘以2
imul ecx, 80 ; 乘以80
add edi, ecx
; 计算起始位置
shl eax, 1 ; 乘以2(每个屏幕单元格2字节)
add edi, eax
; 显示消息
mov ah, dl ; 恢复颜色属性
.disp_loop:
lodsb
test al, al
jz .disp_done
stosw
jmp .disp_loop
.disp_done:
pop edx
pop ecx
pop edi
pop esi
ret
; ===== IDT 相关代码 =====
; 定义IDT表
align 4
idt:
times 256 * IDT_ENTRY_size db 0
idt_descriptor:
dw 256 * IDT_ENTRY_size - 1
dd idt
; 设置IDT
setup_idt:
; 设置0x80中断处理函数
mov eax, int80_handler
mov ebx, 0x80
call set_idt_entry
; 设置0x21中断处理函数
mov eax, int21_handler
mov ebx, 0x21
call set_idt_entry
; 加载IDT
lidt [idt_descriptor]
; 启用中断
sti
ret
; 设置IDT条目
; eax = 处理函数地址
; ebx = 中断号
set_idt_entry:
push eax
push ebx
push ecx
; 计算IDT条目偏移
mov ecx, ebx
imul ecx, IDT_ENTRY_size
; 设置基地址低16位
mov [idt + ecx + IDT_ENTRY.base_low], ax
; 设置选择子 (代码段选择子)
mov word [idt + ecx + IDT_ENTRY.selector], 0x08
; 设置标志位 (0x8E = 10001110b)
; P=1, DPL=00, S=0, Type=1110 (32位中断门)
mov byte [idt + ecx + IDT_ENTRY.flags], 0x8E
; 设置基地址高16位
shr eax, 16
mov [idt + ecx + IDT_ENTRY.base_high], ax
pop ecx
pop ebx
pop eax
ret
; 0x80中断处理函数
int80_handler:
pusha
; 显示中断触发消息
mov eax, MSG_INT80
mov ebx, 14 ; 第14行
mov ecx, RED_ON_BLACK
call display_at_row
popa
iret
; 0x21中断处理函数
int21_handler:
pusha
; 显示中断触发消息
mov eax, MSG_INT21
mov ebx, 15 ; 第15行
mov ecx, WHITE_ON_BLACK
call display_at_row
popa
iret
section .data
; 字符串数据
MSG_KERNEL db 'Kernel loaded and executed in protected mode!', 0
MSG_INT_READY db 'Ready to test interrupts. Press any key to continue...', 0
MSG_INT80 db 'Interrupt 0x80 handler executed successfully!', 0
MSG_INT21 db 'Interrupt 0x21 handler executed successfully!', 0
代码其实都很基础,主要是思路,按照流程编写即可。
Makefile
为了方便运行,编写一个Makefile文件:
ASM = nasm
QEMU = qemu-system-i386
all: os.img
os.img: boot.bin kernel.bin
copy /b boot.bin+kernel.bin os.img
@echo "OS image created successfully!"
boot.bin: boot.asm
$(ASM) -f bin -o $@ $<
@echo "Boot loader compiled."
kernel.bin: kernel.asm
$(ASM) -f bin -o $@ $<
@echo "Kernel compiled."
run: os.img
$(QEMU) -fda os.img -boot a
debug: os.img
$(QEMU) -fda os.img -boot a -monitor stdio
clean:
del *.o *.bin *.img
需要注意,这是windows下的Makefile文件,使用的shell是windows的cmd命令,如果在Linux下编译,需要修改2处:
-
copy /b boot.bin+kernel.bin os.img
表示二进制合并两个文件,生成镜像文件。而Linux中则使用cat boot.bin kernel.bin > os.img
即可 -
clean标签中的清理文件
del *.o *.bin *.img
,Linux中换成rm *.o *.bin *.img
即可。
然后make
编译后,运行make run
即可使用QEMU加载镜像并运行。
不出意外的话,应该可以看到上面的界面,显示自定义的2个中断0x80
和0x21
都调用成功。