从零开始搭建自制操作系统——中断管理

collectcrop Lv3

这里我们的目标是实现一个32位的操作系统,所以后面介绍相关的内容默认都是32位下的。

基本概念

IDT表

中断描述符表 ( IDT ) 用于告知 CPU中断服务程序(ISR) 的位置(每个中断向量对应一个)。它的结构类似于全局描述符表(GDT)。

IDT的结构是由 CPU 架构规范定义的,所以对于某一类 CPU(比如 x86),结构是固定的。IDT 的结构必须符合 CPU 要求,否则中断会失败。但是处理函数里要做什么,是操作系统自己决定的。

IDT 的位置保存在 IDTRIDT 寄存器)中。它使用 LIDT 汇编指令加载,该指令的参数是指向 IDT 描述符结构的指针,其结构类似于GDT表的指针的limit和base,其中Offset指的是IDT表的起始地址,而Size为IDT表的大小-1:

具体的表结构如下,IDTR Offset即上面我们给IDTR赋值的结构体的Offset域,每个表项占8个字节。

其中每一个entry的具体结构如下,IDT entries 也称为门。它可以包含中断门、任务门和陷阱门。

Interrupt Gate(中断门)

  • 自动清除 IF(中断标志),防止其他中断嵌套进来(常用于硬件中断)

Trap Gate(陷阱门)

  • 不会清除 IF,适合调试、系统调用等
  • Offset:指向了ISR(Interrupt Service Routine)入口的起始地址。

  • Segment Selector:必须指向我们之前定义的GDT表中合法的代码段,在中断发生时 CPU 会使用它来加载代码段(CS寄存器),然后跳到指定的中断处理函数。比如我们之前定义的gdt表中第一个段描述符gdt_code,那么在中断发生时就会把Segment Selector设置为0x8。

  • Gate Type:描述了门的类型

    • 0x5:任务门→ 进行任务切换
    • 0x6:16bit中断门
    • 0x7:16bit陷阱门
    • 0xE:32bit中断门(现代常用)
    • 0xF:32bit陷阱门
  • DPL:指定了特权级别,ring0-3

  • P:Present bit,必须设为 1 才会被认为是一个有效的中断门。

一般系统调用会使用如下type_attr

1
type_attr = 0x8E; // P=1, DPL=00, Type=1110 (32-bit Interrupt Gate)

具体的idt代码定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// idt.h
#pragma once

#include <stdint.h>

#define IDT_ENTRIES 256

struct idt_entry {
uint16_t offset_low; // Offset bits 0-15
uint16_t selector; // Kernel segment selector
uint8_t zero; // Always 0
uint8_t type_attr; // Type and attributes
uint16_t offset_high; // Offset bits 16-31
} __attribute__((packed));

struct idt_ptr {
uint16_t limit; // size
uint32_t base; // offset
} __attribute__((packed));

void idt_set_gate(int num, uint32_t base, uint16_t sel, uint8_t flags);
void idt_install();

中断向量

大多数平台上通常有三类中断:

  • 异常 :这些异常由 CPU 内部生成,用于向正在运行的内核发出需要其注意的事件或情况的警报。在 x86 CPU 上,这些异常包括双重异常(Double Fault)、缺页异常(Page Fault)、通用保护异常(General Protection Fault)等。

  • 中断请求 (IRQ) 或硬件中断 :此类中断由芯片组外部生成,并通过锁存相关 CPU 的 #INTR 引脚或等效信号来发出信号。目前常用的 IRQ 类型有两种。

    • IRQ 线路,或基于引脚的 IRQ :这些 IRQ 通常在芯片组上静态路由。线路从芯片组上的设备连接到 IRQ 控制器,该控制器将设备发送的中断请求序列化,并将它们逐个发送到 CPU,以防止争用。在许多情况下,IRQ 控制器会根据设备的优先级一次向 CPU 发送多个 IRQ。

    • 消息信号中断 :这些中断通过向一个预留的内存位置写入值来发出信号,该位置用于存储中断设备、中断本身以及向量信息。设备会通过固件或内核软件分配一个写入位置。然后,设备会使用特定于设备总线的仲裁协议生成 IRQ。PCI 总线就是一个提供基于消息的中断功能的总线示例。

  • 软件中断 :这是由 CPU 上运行的软件发出的中断信号,表示需要内核的关注。这类中断通常用于系统调用。在 x86 CPU 上,用于启动软件中断的指令是“INT”指令。由于 x86 CPU 可以使用 256 个可用中断向量中的任意一个来处理软件中断,因此内核通常会从中选择一个。例如,许多当代 Unix 系统在基于 x86 的平台上使用向量 0x80。

中断号由8个二进制位表示,范围在0-255,其中前32个中断(异常)是 CPU 固定写死的。比如除以0了,CPU 就会主动触发 中断 0。我们不能更改 CPU 生成的中断号。但我们可以“改响应方式”,比如说你在 IDT 表的第 0 项,挂一个你自己的 isr0 函数,去处理除零异常。但不能让 CPU 改为跳转到 IDT 第 9 项。

ISR(Interrupt Service Routines)

外部事件会触发中断——正常的控制流会被打断,并调用中断服务程序 (ISR)。可以说ISR

ISR 必须以操作码 iret 结尾,所以我们在用idt_set_gate设置某个IDT表项时,需要将base设置为一个以iret结束的函数,所以一般是写汇编来包装ISR,不能直接设置base为回调函数的地址。

IRQ(Interrupt Request)

IRQ 是硬件设备向 CPU 请求服务的信号编号。

每一个外设(例如键盘、鼠标、硬盘)在需要 CPU 服务时,会通过某个 IRQ 线路 向 CPU 发出中断信号,CPU 处理这个中断,称为“中断服务”。IRQ 是“硬件视角”的编号,它最终会被映射成“软件中的中断号”(即 int N)。标准的IRQ如下:

IRQ Description
0 Programmable Interrupt Timer Interrupt
1 Keyboard Interrupt
2 Cascade (used internally by the two PICs. never raised)
3 COM2 (if enabled)
4 COM1 (if enabled)
5 LPT2 (if enabled)
6 Floppy Disk
7 LPT1 / Unreliable "spurious" interrupt (usually)
8 CMOS real-time clock (if enabled)
9 Free for peripherals / legacy SCSI / NIC
10 Free for peripherals / SCSI / NIC
11 Free for peripherals / SCSI / NIC
12 PS2 Mouse
13 FPU / Coprocessor / Inter-processor
14 Primary ATA Hard Disk
15 Secondary ATA Hard Disk

PIC(Programmable Interrupt Controller)

最早的 PC 使用两个 8259A PIC(主从) 来管理 15 个 IRQ 通道(IRQ0 ~ IRQ15)

  • 主 PIC 处理 IRQ0 ~ IRQ7
  • 从 PIC 处理 IRQ8 ~ IRQ15,并通过 IRQ2 接到主 PIC 的 IRQ2 上

异常向量对照表

Int Description
0-31 Protected Mode Exceptions (Reserved by Intel)
8-15 Default mapping of IRQ0-7 by the BIOS at bootstrap
70h-78h Default mapping of IRQ8-15 by the BIOS at bootstrap

PIC 的控制端口(Port)

Port Description
20h & 21h control/mask ports of the master PIC
A0h & A1h control/mask ports of the slave PIC
60h data port from the keyboard controller
64h command port for keyboard controller - use to enable/disable kbd interrupts, etc.

I/O控制指令

outb / inb这两个是 x86 架构提供的底层 I/O 指令,用于与外设通信,比如:可编程中断控制器(PIC)定时器(PIT)键盘控制器CMOS,甚至是串口、VGA 等等

outb(port, value)

  • 作用:把一个字节(value)写到某个 I/O 端口(port
  • 汇编对应outb %al, %dx 这里 %al 是 8 位寄存器,%dx 是 I/O 端口号
1
2
3
static inline void outb(uint16_t port, uint8_t val) {
asm volatile ("outb %0, %1" : : "a"(val), "Nd"(port));
}

解释:

  • asm volatile (...): 告诉编译器这是内联汇编,且不要优化或重排序
  • "a"(val): 把 val 放到 eax/al 寄存器
  • "Nd"(port): 把 port 放到 dx,或立即数(N 表示 0–255 的立即数)

inb(port)

  • 作用:从某个 I/O 端口(port)读取一个字节
  • 汇编对应inb %dx, %al
1
2
3
4
5
static inline uint8_t inb(uint16_t port) {
uint8_t ret;
asm volatile ("inb %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}
  • "=a"(ret): 将 al 中的值存到 ret
  • "Nd"(port): port 放到 dx 或立即数

初始化 PIC 需要发 4 条命令(ICW1 ~ ICW4),ICWInitialization Command Word

控制字 用途 对应 outb() 调用
ICW1 发起初始化 outb(PICx_COMMAND, ICW1_INIT)
ICW2 设置中断向量偏移 outb(PICx_DATA, 0x20 / 0x28)
ICW3 设置主从连接方式 outb(PICx_DATA, 4 / 2)
ICW4 设置 PIC 模式(8086 模式) outb(PICx_DATA, ICW4_8086)

这些 必须按顺序喂给 PIC,否则它无法正确配置。

基本简单实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// idt.h
#pragma once

#include <stdint.h>

#define IDT_ENTRIES 256

struct idt_entry {
uint16_t offset_low; // Offset bits 0-15
uint16_t selector; // Kernel segment selector
uint8_t zero; // Always 0
uint8_t type_attr; // Type and attributes
uint16_t offset_high; // Offset bits 16-31
} __attribute__((packed));

struct idt_ptr {
uint16_t limit; // size
uint32_t base; // offset
} __attribute__((packed));

void idt_set_gate(int num, uint32_t base, uint16_t sel, uint8_t flags);
void idt_install();

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
// idt.c
#include "idt.h"
#include "isr.h"

struct idt_entry idt[IDT_ENTRIES];
struct idt_ptr idtp;

extern void idt_flush(uint32_t); // 来自汇编

void idt_set_gate(int num, uint32_t base, uint16_t sel, uint8_t flags) {
idt[num].offset_low = base & 0xFFFF;
idt[num].selector = sel;
idt[num].zero = 0;
idt[num].type_attr = flags;
idt[num].offset_high = (base >> 16) & 0xFFFF;
}

void idt_install() {
idtp.limit = sizeof(struct idt_entry) * IDT_ENTRIES - 1;
idtp.base = (uint32_t)&idt;

for (int i = 0; i < IDT_ENTRIES; i++) {
// 清空,初始化时所有参数为 0,但是由于flags位的P也为0,所以CPU会忽略该中断,访问未定义的中断时不会导致系统重启
idt_set_gate(i, 0, 0, 0);
}

isr_install(); // 安装 ISR 0-31

idt_flush((uint32_t)&idtp); // 刷新 IDT
}

1
2
3
4
5
6
7
8
9
10
11
// isr.h
#pragma once
#include <stdint.h>

typedef struct registers {
uint32_t ds, edi, esi, ebp, esp, ebx, edx, ecx, eax;
uint32_t int_no, err_code;
uint32_t eip, cs, eflags, useresp, ss;
} registers_t;

void isr_install();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// isr.c
#include "idt.h"
#include "isr.h"
#include "screen.h"

extern void isr0(); // 在汇编中定义的 isr 函数

void isr_install() {
idt_set_gate(0, (uint32_t)isr0, 0x08, 0x8E); // 设置除 0 异常处理函数
// 后续你可以添加 isr1-isr31
}

void isr_handler(registers_t *regs) {
print_string("Received interrupt: ");
print_hex(regs->int_no); // 自己写的打印 hex 函数
print_newline();
print_string("Counter: ");
print_dec(12345);
}

1
2
3
4
5
6
7
8
9
10
// screen.h
#pragma once

void clear_screen();
void print_char(char c);
void print_string(const char *str);
void print_newline();
void print_dec(int num);
void print_hex(unsigned int num);

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
// screen.c

#define VIDEO_MEMORY (unsigned short *)0xB8000
#define MAX_COLS 80
#define MAX_ROWS 25

static int cursor_x = 0;
static int cursor_y = 0;

static void update_cursor_pos() {
if (cursor_x >= MAX_COLS) {
cursor_x = 0;
cursor_y++;
}

if (cursor_y >= MAX_ROWS) {
// 暂时不做滚动,直接从顶部开始
cursor_x = 0;
cursor_y = 0;
}
}

void clear_screen() {
unsigned short *video = VIDEO_MEMORY;
for (int i = 0; i < 80 * 25; i++) {
video[i] = 0x0F20; // 清屏,0x0F 是前景色,0x20 是空格
}
cursor_x = 0;
cursor_y = 0;
}

void print_char(char c) { // 打印一个字符
unsigned short *video = VIDEO_MEMORY;
if (c == '\n') {
cursor_x = 0;
cursor_y++;
} else {
int pos = cursor_y * MAX_COLS + cursor_x;
video[pos] = (0x0F << 8) | c;
cursor_x++;
}
update_cursor_pos();
}

void print_string(const char *str) {
while (*str) {
print_char(*str++);
}
}

void print_newline() {
print_char('\n');
}

void print_dec(int num) {
if (num == 0) {
print_char('0');
return;
}

char buf[16];
int i = 0;
if (num < 0) {
print_char('-');
num = -num;
}

while (num > 0) {
buf[i++] = '0' + (num % 10);
num /= 10;
}

while (--i >= 0)
print_char(buf[i]);
}

void print_hex(unsigned int num) {
print_string("0x");
char hex_chars[] = "0123456789ABCDEF";
for (int i = 28; i >= 0; i -= 4) {
print_char(hex_chars[(num >> i) & 0xF]);
}
}
1
2
3
4
5
6
7
; idt_flush.asm
global idt_flush
idt_flush:
mov eax, [esp+4] ; 参数:&idtp
lidt [eax]
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
; isr_stub.asm
global isr0
extern isr_handler

isr0:
cli
push 0 ; error code
push 0 ; int number
jmp isr_common_stub

isr_common_stub:
pusha
mov ax, ds
push eax
mov ax, 0x10 ; data segment selector
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax

push esp
call isr_handler
add esp, 4

pop eax
popa
add esp, 8
sti
iret

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
# Makefile
DIR := $(shell pwd)
BOOT_DIR := $(DIR)/boot
KERNEL_DIR := $(DIR)/kernel
DRIVER_DIR := $(DIR)/drivers
ARCH_DIR := $(DIR)/arch/x86
LINK_LD := $(DIR)/linker.ld
TARGET := $(DIR)/focus-os.img

# 中间产物
BOOT_BIN := boot.bin
STAGE2_BIN := stage2.bin
KERNEL_ELF := kernel.elf
KERNEL_BIN := kernel.bin

# 自动获取所有 .asm 文件
ASM_SRCS := $(wildcard $(ARCH_DIR)/*.asm)
ASM_OBJS := $(patsubst %.asm,%.o,$(ASM_SRCS))

# 自动获取所有 .c 和 .h 文件
KERNEL_SRC := $(wildcard $(KERNEL_DIR)/*.c) $(wildcard $(DRIVER_DIR)/*.c)
# 必须让kernel在第一个加载,从而可以直接跳转到kernel_main
KERNEL_OBJ := $(KERNEL_DIR)/kernel.o $(filter-out $(KERNEL_DIR)/kernel.o, $(KERNEL_SRC:.c=.o)) $(ASM_OBJS)


# 编译器设置
CC := gcc
CFLAGS := -m32 -g -ffreestanding -fno-pie -fno-stack-protector -nostdlib -nostartfiles -I$(KERNEL_DIR) -I$(DRIVER_DIR)

.PHONY: all clean run

all: $(TARGET)

$(BOOT_BIN): $(BOOT_DIR)/boot.asm
nasm -f bin $< -o $@

$(STAGE2_BIN): $(BOOT_DIR)/stage2.asm
nasm -f bin $< -o $@

# 编译内核每个 .c 文件
$(KERNEL_DIR)/%.o: $(KERNEL_DIR)/%.c
$(CC) $(CFLAGS) -c $< -o $@
# 编译驱动每个 .c 文件
$(DRIVER_DIR)/%.o: $(DRIVER_DIR)/%.c
$(CC) $(CFLAGS) -c $< -o $@
# 编译每个 .asm 文件
$(ARCH_DIR)/%.o: $(ARCH_DIR)/%.asm
nasm -f elf32 $< -o $@

$(KERNEL_BIN): $(KERNEL_OBJ) $(LINK_LD)
ld -m elf_i386 -g -T $(LINK_LD) -o $(KERNEL_ELF) $(KERNEL_OBJ)
objcopy -O binary $(KERNEL_ELF) $(KERNEL_BIN)


$(TARGET): $(BOOT_BIN) $(STAGE2_BIN) $(KERNEL_BIN)
@echo "Creating floppy image..."
dd if=/dev/zero of=$(TARGET) bs=512 count=2880
dd if=$(BOOT_BIN) of=$(TARGET) bs=512 count=1 conv=notrunc
dd if=$(STAGE2_BIN) of=$(TARGET) bs=512 count=2 seek=1 conv=notrunc
@KERNEL_SIZE=$$(stat -c %s $(KERNEL_BIN)); \
KERNEL_SECTORS=$$((($$KERNEL_SIZE + 511) / 512)); \
echo "Kernel size: $$KERNEL_SIZE bytes ($$KERNEL_SECTORS sectors)"; \
dd if=$(KERNEL_BIN) of=$(TARGET) bs=512 count=$$KERNEL_SECTORS seek=3 conv=notrunc
# dd if=$(KERNEL_BIN) of=$(TARGET) bs=512 count=${KERNEL_SECTORS} seek=3 conv=notrunc
run:
qemu-system-i386 -fda $(TARGET) -s


clean:
rm -f *.bin *.o *.elf $(TARGET)
rm -f $(KERNEL_DIR)/*.o *.bin *.elf
rm -f $(DRIVER_DIR)/*.o *.bin *.elf
rm -f $(ARCH_DIR)/*.o *.bin *.elf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// kernel.c
#include "idt.h"
#include "isr.h"
#include "irq.h"
#include "timer.h"
#include "screen.h"
// #include "keyboard.h"


void kernel_main(void) {
clear_screen(); // 清屏
print_string("Hello, Kernel!"); // 打印一行字符
idt_install();
isr_install();
int a = 1 / 0; // 触发一个除零错误
while (1) { }
}

最后我们跑起来时就可以看到我们之前在isr.c中实现的isr_handler被调用了。首先是触发除零错误时跳转到isr0处处理,然后进入isr_handler进行打印调试信息。

这里已经成功调用了我们写的回调函数,但是由于并没有处理除零后的行为,所以从回调函数返回时还会执行除零的这个操作,所以导致一直进行0号中断。

我们可以把实际的处理逻辑放在c语言中实现,那么就可以在汇编里统一将32个中断统一跳转到isr_common_stub中处理,最后设置好寄存器参数后,跳转到isr_handler进行分类处理。

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
void isr_install() {
idt_set_gate(0, (uint32_t)isr0, 0x08, 0x8E); // div 0 fault
idt_set_gate(6, (uint32_t)isr6, 0x08, 0x8E); // invalid opcode fault
idt_set_gate(13, (uint32_t)isr13, 0x08, 0x8E); // General Protection Fault
}

void isr_handler(registers_t *regs) {
clear_screen();
print_string("Received interrupt: ");
print_dec(regs->int_no);
print_newline();
switch (regs->int_no) {
case 0:
print_string("Divide by zero\n");
while (1); // Halt
case 6:
print_string("Invalid opcode\n");
while (1);
case 13:
print_string("General Protection Fault\n");
while (1);
default:
print_string("Unknown interrupt!\n");
while (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
; isr_stub.asm
extern isr_handler

; 无错误码的宏
%macro ISR_NOERR 1
global isr%1
isr%1:
cli
push 0 ; error code
push %1
jmp isr_common_stub
%endmacro

; 有错误码的宏(不再 push 错误码)
%macro ISR_ERR 1
global isr%1
isr%1:
cli
push %1
jmp isr_common_stub
%endmacro

; 统一调用展开

; 无错误码中断
%assign i 0
%rep 32
%if i=8 || i=10 || i=11 || i=12 || i=13 || i=14 || i=17
; 有错误码的中断
ISR_ERR i
%else
; 没有错误码的
ISR_NOERR i
%endif
%assign i i+1
%endrep

isr_common_stub:
pusha
mov ax, ds
push eax
mov ax, 0x10 ; data segment selector
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax

push esp
call isr_handler
add esp, 4

pop eax
popa
add esp, 8
sti
iret

之后我们可以分别调用如下代码进行测试中断是否正常工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
void trigger_invalid_opcode() {
asm volatile ("ud2");
}

void trigger_gpf() {
asm volatile (
"mov $0x23, %%ax \n"
"mov %%ax, %%ds \n"
:
:
: "ax"
);
}

IRQ 重映射

在 x86 架构中,前 32 个中断号(0~31)保留给 CPU 异常,而 PIC(Programmable Interrupt Controller,8259A)默认会将:

  • IRQ0 映射到中断号 0x08(8)
  • IRQ1 → 0x09(9)
  • ...
  • IRQ15 → 0x0F(15)

这就与 CPU 异常冲突了!所以必须把 IRQ 中断重新映射32~47(0x20~0x2F)或别的中断号,这样才可以自己处理它们,不会和异常冲突。这里可以通过4阶段的ICW来对PIC进行配置。

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
// irq.c
#include <stdint.h>

#define PIC1_COMMAND 0x20 // 主 PIC 的命令端口
#define PIC1_DATA 0x21 // 主 PIC 的数据端口
#define PIC2_COMMAND 0xA0 // 从 PIC 的命令端口
#define PIC2_DATA 0xA1 // 从 PIC 的数据端口

#define ICW1_INIT 0x11 // ICW1:初始化标志 + 需要 ICW4
#define ICW4_8086 0x01 // ICW4:设置为 8086/88 模式(非特殊的 8080 模式)

static inline void outb(uint16_t port, uint8_t val) {
asm volatile ("outb %0, %1" : : "a"(val), "Nd"(port));
}
static inline uint8_t inb(uint16_t port) {
uint8_t ret;
asm volatile ("inb %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}
void irq_remap() {
// 保存原始掩码
uint8_t a1 = inb(PIC1_DATA);
uint8_t a2 = inb(PIC2_DATA);

// 开始初始化
outb(PIC1_COMMAND, ICW1_INIT);
outb(PIC2_COMMAND, ICW1_INIT);

// 设置 IRQ 映射偏移量
outb(PIC1_DATA, 0x20); // 主 PIC 从中断向量号 0x20 开始(32)
outb(PIC2_DATA, 0x28); // 从 PIC 从中断向量号 0x28 开始(40)

// 设置主从关系
outb(PIC1_DATA, 4); // 通知主 PIC 有从 PIC 接在 IRQ2 上
outb(PIC2_DATA, 2); // 通知从 PIC 它连接到主 PIC 的 IRQ2 上

// 设置 PIC 工作模式
outb(PIC1_DATA, ICW4_8086);
outb(PIC2_DATA, ICW4_8086);

// 恢复掩码
outb(PIC1_DATA, a1);
outb(PIC2_DATA, a2);
}

IRQ实现时钟与键盘处理

改进isr回调函数逻辑

之前我们采用硬编码的方式来注册与处理isr,这里我们想要扩展到IRQ的实现,并为了之后能够方便添加中断,这里对原来的isr.c进行一定修改来实现动态注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// kernel/isr.h
#pragma once
#include <stdint.h>

typedef struct registers {
uint32_t ds, edi, esi, ebp, esp, ebx, edx, ecx, eax;
uint32_t int_no, err_code;
uint32_t eip, cs, eflags, useresp, ss;
} registers_t;

// 函数指针类型
typedef void (*isr_t)(registers_t *);

void isr_install();
void register_interrupt_handler(uint8_t n, isr_t handler);
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
// kernel/isr.c
#include "idt.h"
#include "isr.h"
#include "screen.h" // 你之前写的 print_string
#include "string.h"
isr_t interrupt_handlers[256]; // 保存最多256个中断处理函数


extern void isr0(); // 在汇编中定义的 isr 函数
extern void isr6();
extern void isr13();
void isr_install() {
memset(interrupt_handlers, 0, sizeof(isr_t) * 256);
idt_set_gate(0, (uint32_t)isr0, 0x08, 0x8E); // div 0 fault
idt_set_gate(6, (uint32_t)isr6, 0x08, 0x8E); // invalid opcode fault
idt_set_gate(13, (uint32_t)isr13, 0x08, 0x8E); // General Protection Fault
}

void isr_handler(registers_t *regs) {
clear_screen();
print_string("Received interrupt: ");
print_dec(regs->int_no);
print_newline();
if (interrupt_handlers[regs->int_no]) {
isr_t handler = interrupt_handlers[regs->int_no];
handler(regs); // 调用回调函数
} else {
clear_screen();
print_string("Unhandled interrupt: ");
print_dec(regs->int_no);
print_newline();

while (1); // halt
}
}

void register_interrupt_handler(uint8_t n, isr_t handler) {
interrupt_handlers[n] = handler;
}

这里我把前面用到的outb以及inb单独提取出来放到common.h中声明实现,更加模块化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// include/common.h
#pragma once

#include <stdint.h>
#define IRQ0 32
#define IRQ1 33
static inline void outb(uint16_t port, uint8_t val) {
asm volatile ("outb %0, %1" : : "a"(val), "Nd"(port));
}

static inline uint8_t inb(uint16_t port) {
uint8_t ret;
asm volatile ("inb %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}

时钟实现

1
2
3
4
5
6
7
8
// drivers/timer.h
#pragma once

#include <stdint.h>
#include "isr.h" // 引入 struct regs

void init_timer(uint32_t freq);

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
// drivers/timer.c
#include "timer.h"
#include "isr.h"
#include "common.h"
#include "screen.h"

uint32_t tick = 0;

static void timer_callback(struct regs* r) {
tick++;
if (tick % 100 == 0) {
print_string("Tick: ");
print_dec(tick);
print_newline();
}
}

void init_timer(uint32_t freq) {
register_interrupt_handler(IRQ0, timer_callback);

uint32_t divisor = 1193180 / freq;
outb(0x43, 0x36); // PIT control
outb(0x40, divisor & 0xFF);
outb(0x40, (divisor >> 8) & 0xFF);
}

我们来详细看一下定时器具体如何设置:

1
uint32_t divisor = 1193180 / freq;
  • 8253/8254 的输入时钟频率是 1.193180 MHz

  • divisor = 1193180 / freq 是设置定时器的分频器,比如你想让定时器每 freq = 100 Hz 触发一次中断,就要分频成:divisor = 1193180 / 100 ≈ 11931

1
outb(0x43, 0x36); // PIT control
  • 0x43PIT 控制寄存器端口号
  • 0x36 是控制字,表示:
    • 通道 0(timer channel 0)
    • 使用 先低后高字节模式写入分频值
    • 模式 3:方波生成模式
    • 二进制计数模式
1
2
outb(0x40, divisor & 0xFF);         // 写入低 8 位
outb(0x40, (divisor >> 8) & 0xFF); // 写入高 8 位
  • 0x40定时器通道 0 的数据端口
  • 由于定时器的计数器是 16 位,我们需要把 divisor 拆成 低字节 + 高字节 写进去;
  • 写入顺序:先低 8 位,再高 8 位(之前通过 0x36 控制字设置的)。

作用流程:

  1. kernel_main可以初始化阶段调用 init_timer(100) 设置频率为 100Hz(即每10ms触发一次);
  2. 设置好 PIT 芯片的工作模式和分频值;
  3. 注册了中断回调函数 timer_callback,绑定到了中断号 32(IRQ0);
  4. 每10ms CPU 就会被硬件中断打断,自动跳转到 timer_callback 执行,tick 变量就会递增;
  5. 每 100 次(1 秒)打印一次信息,说明定时器在工作。

然后在isr_install中加一条idt_set_gate(32, (uint32_t)irq0, 0x08, 0x8E);来注册这个中断,并在isr_stub.asm中加入以下代码来处理IRQ,同样也是跳转到isr_common_stub进行进一步处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
%macro IRQ 1
global irq%1
irq%1:
cli
push 0 ; dummy error code for compatibility
push %1 + 32 ; 中断号(重映射后的IRQ)
jmp isr_common_stub
%endmacro

; 定义 IRQ0~IRQ15
%assign i 0
%rep 16
IRQ i
%assign i i+1
%endrep

最后可以在irq_install中初始化时钟,并在kernel_main里调用irq_install

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// kernel/irq.c
#include "timer.h"
...
void irq_install() {
init_timer(100); // 设置定时器
}

void irq_handler(registers_t* regs) {
// 专门处理irq中断
if (interrupt_handlers[regs->int_no] != 0) {
isr_t handler = interrupt_handlers[regs->int_no];
handler(regs);
}

// 发送 EOI
if (regs->int_no >= 40) {
// IRQ8-IRQ15 来自从 PIC
outb(PIC2_COMMAND, PIC_EOI);
}
outb(PIC1_COMMAND, PIC_EOI);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// kernel/kernel.c
#include "idt.h"
#include "isr.h"
#include "irq.h"
#include "screen.h"
// #include "keyboard.h"

void kernel_main(void) {
clear_screen(); // 清屏
print_string("Hello, Kernel!\n"); // 打印一行字符
idt_install();
irq_remap();
isr_install();
irq_install();

asm volatile("sti"); // 开中断
while (1) {
asm volatile("hlt"); // 节省CPU
}
}

这里我们还定义了一个irq_handler,这个是为了区别cpu异常和硬件中断,因为硬件中断最后需要特殊处理,比如像要在处理完成过后向主从PIC发送PIC_EOI以表示中断处理结束,从而可以进行下一次处理。那么isr_handler也需要进行一定的更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// isr.c
void isr_handler(registers_t *regs) {
if (regs->int_no>=32&&regs->int_no<=47){
irq_handler(regs);
}
else{
clear_screen();
print_string("Received interrupt: ");
print_dec(regs->int_no);
print_newline();
if (interrupt_handlers[regs->int_no]) {
isr_t handler = interrupt_handlers[regs->int_no];
handler(regs); // 调用回调函数
} else {
clear_screen();
print_string("Unhandled interrupt: ");
print_dec(regs->int_no);
print_newline();

while (1); // halt
}
}
}

键盘输入实现

1
2
3
4
5
// drivers/keyboard.h
#pragma once

void init_keyboard();

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
// drivers/keyboard.c
#include "keyboard.h"
#include "isr.h"
#include "screen.h"
#include "common.h"

#define KEYBOARD_DATA_PORT 0x60

unsigned char scancode_to_ascii[128] = {
0, 27, '1','2','3','4','5','6','7','8','9','0','-','=', '\b', // 0x00 - 0x0E
'\t','q','w','e','r','t','y','u','i','o','p','[',']','\n', 0, // 0x0F - 0x1D (Enter, Control)
'a','s','d','f','g','h','j','k','l',';','\'','`', 0, '\\', // 0x1E - 0x2B (Shift, \)
'z','x','c','v','b','n','m',',','.','/', 0, '*', 0, ' ', // 0x2C - 0x39 (Space, Alt, etc)
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x3A - 0x45 (F1-F10, etc)
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x46 - 0x50
0, 0, 0, 0, 0, 0, 0, 0 // 0x51 - 0x58 (F11, F12...)
};

static void keyboard_callback(registers_t regs){
// 1. 从端口 0x60 读取扫描码(键盘输入缓冲区)
unsigned char scancode = inb(KEYBOARD_DATA_PORT);
// 2. 过滤掉 key-release 事件(第8位为1,表示抬起)
if (scancode & 0x80) {
// 是 key-up,不处理
} else {
// 3. 查表转成 ASCII 字符
char c = scancode_to_ascii[scancode];
if (c) {
print_char(c); // 4. 显示字符
}
}
}

void init_keyboard(){
register_interrupt_handler(IRQ1, keyboard_callback);
}
1
2
3
4
5
// kernel/irq.c
void irq_install() {
init_timer(100); // 设置定时器
init_keyboard();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// kernel/isr.c
void isr_install() {
memset(interrupt_handlers, 0, sizeof(isr_t) * 256);
idt_set_gate(0, (uint32_t)isr0, 0x08, 0x8E); // div 0 fault
idt_set_gate(6, (uint32_t)isr6, 0x08, 0x8E); // invalid opcode fault
idt_set_gate(13, (uint32_t)isr13, 0x08, 0x8E); // General Protection Fault
idt_set_gate(0x20, (uint32_t)irq0, 0x08, 0x8E); // IRQ0,timer
idt_set_gate(0x21, (unsigned)irq1, 0x08, 0x8E); /// IRQ1,keyboard

register_interrupt_handler(0, divide_by_zero_handler);
register_interrupt_handler(6, invalid_opcode_handler);
register_interrupt_handler(13, general_protection_fault_handler);
}

之后加入别的IRQ也是这样一个修改流程,先写好对应的回调函数,然后在irq_install中调用注册回调函数的接口,最后在isr_install里用idt_set_gate来设置好IDT表项。

键盘底层是通过「扫描码(scancode)」和 CPU 交互的。

当你在键盘上按下一个键,比如 A,键盘控制器发送的是一个字节(称为 扫描码),而不是 ASCII 字符。比如:

  • A 键按下时,对应的扫描码是 0x1E
  • B0x30
  • Enter0x1C
  • Space0x39

所以scancode_to_ascii[] 数组的作用是 把扫描码转换为可打印的 ASCII 字符

键盘按下和抬起,都会触发中断。比如按下 A

  • A 按下:0x1E
  • A 抬起:0x9E(0x1E + 0x80)

我们通常只关心「按下时的动作」,所以用:

1
if (scancode & 0x80)

判断是否是抬起,如果是就跳过。

然后就可以正常从键盘读取输入,不过目前的版本并不能用shift切换大小写显示。

进行一下修改就可以实现显示shift转换后的内容

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
unsigned char scancode_to_ascii_shifted[128] = {
0, 27, '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '\b', // 0x00 - 0x0E
'\t','Q','W','E','R','T','Y','U','I','O','P','{','}','\n', 0, // 0x0F - 0x1D
'A','S','D','F','G','H','J','K','L',':','"','~', 0, '|', // 0x1E - 0x2B
'Z','X','C','V','B','N','M','<','>','?', 0, '*', 0, ' ', // 0x2C - 0x39
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x3A+
0, 0, 0, 0, 0, 0, 0, 0 // 0x50+
};
static void keyboard_callback(registers_t regs){
// 1. 从端口 0x60 读取扫描码(键盘输入缓冲区)
unsigned char scancode = inb(KEYBOARD_DATA_PORT);

// 按下Shift
if (scancode == 0x2A || scancode == 0x36) {
shift_pressed = true;
return;
}

// 松开Shift
if (scancode == 0xAA || scancode == 0xB6) {
shift_pressed = false;
return;
}

// 2. 过滤掉 key-release 事件(第8位为1,表示抬起)
if (scancode & 0x80) {
// 是 key-up,不处理
} else {
// 3. 查表转成 ASCII 字符
char c;
if (shift_pressed) {
c = scancode_to_ascii_shifted[scancode];
} else {
c = scancode_to_ascii[scancode];
}

if (c) {
print_char(c); // 4. 显示字符
}
}
}

debug

加载顺序问题

一开始我的makefile如下,结果每次跳转到0x9000位置,执行的好像都是别的代码,而没有进行call其它函数。

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
DIR := $(shell pwd)
BOOT_DIR := $(DIR)/boot
KERNEL_DIR := $(DIR)/kernel
DRIVER_DIR := $(DIR)/drivers
ARCH_DIR := $(DIR)/arch/x86
LINK_LD := $(DIR)/linker.ld
TARGET := $(DIR)/focus-os.img

# 中间产物
BOOT_BIN := boot.bin
STAGE2_BIN := stage2.bin
KERNEL_ELF := kernel.elf
KERNEL_BIN := kernel.bin

# 自动获取所有 .asm 文件
ASM_SRCS := $(wildcard $(ARCH_DIR)/*.asm)
ASM_OBJS := $(patsubst %.asm,%.o,$(ASM_SRCS))

# 自动获取所有 .c 和 .h 文件
KERNEL_SRC := $(wildcard $(KERNEL_DIR)/*.c) $(wildcard $(DRIVER_DIR)/*.c)
KERNEL_OBJ := $(KERNEL_SRC:.c=.o) $(ASM_OBJS)



# 编译器设置
CC := gcc
CFLAGS := -m32 -g -ffreestanding -fno-pie -fno-stack-protector -nostdlib -nostartfiles -I$(KERNEL_DIR) -I$(DRIVER_DIR)

.PHONY: all clean run

all: $(TARGET)

$(BOOT_BIN): $(BOOT_DIR)/boot.asm
nasm -f bin $< -o $@

$(STAGE2_BIN): $(BOOT_DIR)/stage2.asm
nasm -f bin $< -o $@

# 编译内核每个 .c 文件
$(KERNEL_DIR)/%.o: $(KERNEL_DIR)/%.c
$(CC) $(CFLAGS) -c $< -o $@
# 编译驱动每个 .c 文件
$(DRIVER_DIR)/%.o: $(DRIVER_DIR)/%.c
$(CC) $(CFLAGS) -c $< -o $@
# 编译每个 .asm 文件
$(ARCH_DIR)/%.o: $(ARCH_DIR)/%.asm
nasm -f elf32 $< -o $@

$(KERNEL_BIN): $(KERNEL_OBJ) $(LINK_LD)
ld -m elf_i386 -g -T $(LINK_LD) -o $(KERNEL_ELF) $(KERNEL_OBJ)
objcopy -O binary $(KERNEL_ELF) $(KERNEL_BIN)

$(TARGET): $(BOOT_BIN) $(STAGE2_BIN) $(KERNEL_BIN)
dd if=/dev/zero of=$(TARGET) bs=512 count=2880
dd if=$(BOOT_BIN) of=$(TARGET) bs=512 count=1 conv=notrunc
dd if=$(STAGE2_BIN) of=$(TARGET) bs=512 count=2 seek=1 conv=notrunc
dd if=$(KERNEL_BIN) of=$(TARGET) bs=512 count=5 seek=3 conv=notrunc
run:
qemu-system-i386 -fda $(TARGET) -s -S


clean:
rm -f *.bin *.o *.elf $(TARGET)
rm -f $(KERNEL_DIR)/*.o *.bin *.elf
rm -f $(DRIVER_DIR)/*.o *.bin *.elf
rm -f $(ARCH_DIR)/*.o *.bin *.elf

然后用IDA看kernel.elf文件,才发现我们的kernel_main并没有正常的加载到0x9000的起始位置,所以我们前面都是跳转到idt_set_gate函数进行执行。所以会出现问题。

问题在于我们在指定目标KERNEL_OBJ时的顺序没有指定,这里的 $(KERNEL_SRC) 是由 wildcard 自动搜集 .c 文件,而 wildcard 的顺序是按文件名 ASCII 排序来的,不保证 kernel.c(含有 kernel_main())是第一个。而ld只会按参数顺序进行加载。

1
2
KERNEL_SRC := $(wildcard $(KERNEL_DIR)/*.c) $(wildcard $(DRIVER_DIR)/*.c)
KERNEL_OBJ := $(KERNEL_SRC:.c=.o) $(ASM_OBJS)

简单的更改方式是把kernel.o单独拿出来:

1
KERNEL_OBJ := $(KERNEL_DIR)/kernel.o $(filter-out $(KERNEL_DIR)/kernel.o, $(KERNEL_SRC:.c=.o)) $(ASM_OBJS)

磁盘预留空间问题

当我们的kernel.bin比较大时,我们可能之前手动指定的5个扇区长度就不够用了,会导致部分函数没有加载到内存。解决方式有两种,手动的方式是用 size kernel.elf 看总共占的大小,然后再算出需要的扇区数。

自动计算扇区数并加入软盘方式如下,需要更改makefile最后生成TARGET软盘文件的逻辑:

1
2
3
4
5
6
7
8
9
$(TARGET): $(BOOT_BIN) $(STAGE2_BIN) $(KERNEL_BIN)
@echo "Creating floppy image..."
dd if=/dev/zero of=$(TARGET) bs=512 count=2880
dd if=$(BOOT_BIN) of=$(TARGET) bs=512 count=1 conv=notrunc
dd if=$(STAGE2_BIN) of=$(TARGET) bs=512 count=2 seek=1 conv=notrunc
@KERNEL_SIZE=$$(stat -c %s $(KERNEL_BIN)); \
KERNEL_SECTORS=$$((($$KERNEL_SIZE + 511) / 512)); \
echo "Kernel size: $$KERNEL_SIZE bytes ($$KERNEL_SECTORS sectors)"; \
dd if=$(KERNEL_BIN) of=$(TARGET) bs=512 count=$$KERNEL_SECTORS seek=3 conv=notrunc

timer无响应

由于我的isr_handler设计上是只要接受到中断就先打印中断号,所以如果屏幕上没有打印任何内容,说明并没有接受到外部中断。

首先是需要在加载完irq以及isr后手动用sti开启中断,sti 是 x86 指令,用于设置 中断标志位 IF(Interrupt Flag),允许 CPU 响应硬件中断(IRQ)。

其次我们需要单独为irq实现一个handler,因为在 IRQ 的中断服务程序(IRQ handler) 中,必须手动发送 EOI 给主/从 8259A PIC,否则 PIC 会认为该中断尚未处理完成,从而 不会发出新的中断请求

之后遇到的问题是跳转后好像指令没有加载出来,发现问题可能在于我们之前boot.asm中映射空间大小写死为读取5个扇区,而这里我们的kernel已经超出了5个扇区的大小了,所以最后只加载了一半。那么我们在boot.asm中多申请点读取扇区大小即可。

  • 标题: 从零开始搭建自制操作系统——中断管理
  • 作者: collectcrop
  • 创建于 : 2025-05-08 10:54:55
  • 更新于 : 2025-05-08 10:55:45
  • 链接: https://collectcrop.github.io/2025/05/08/从零开始搭建自制操作系统——中断管理/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。