自定义x86中断

思路

对于装有操作系统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处:

  1. copy /b boot.bin+kernel.bin os.img 表示二进制合并两个文件,生成镜像文件。而Linux中则使用cat  boot.bin kernel.bin > os.img即可

  2. clean标签中的清理文件del *.o *.bin *.img,Linux中换成rm *.o *.bin *.img即可。

然后make编译后,运行make run即可使用QEMU加载镜像并运行。

不出意外的话,应该可以看到上面的界面,显示自定义的2个中断0x800x21都调用成功。