第一部分:进入内核前的苦力活

第二部分:大战前期的初始化工作

第三部分:一个新进程的诞生

第四部分:shell 程序的到来

第五部分:从一个命令的执行看操作系统各模块的运作

第六部分:操作系统哲学与思想

开机

主板上写死的BIOS程序将启动区(1扇区)的前512字节放到内存0x7c00中

0盘0道1扇区的最后两个字节为0x55和0xaa,就是启动区

这个启动区存储的就是用汇编写的bootsect.s通过编译的bootsect二进制文件

BIOS干两件事:1、加载内存;2、跳转到该内存地址

bootsect

把内核搬到内存

1
2
mov ax,0x07c0
mov ds,ax

设置段基地址,早期8086总线20位,物理地址有20位,但是寄存器只有16位,为了使用完物理地址,使用分段存储

物理地址 = 段基址 << 4 + 偏移地址,这样就能表示所有内存了

1
2
3
4
5
6
mov ax,0x9000
mov es,ax
mov cx,#256
sub si,si
sub di,di
rep movw

从ds:si复制256字到es:di,现在512字节的启动区被复制到0x90000地址

1
2
3
4
5
go: mov ax,cs
    mov ds,ax
    mov es,ax
    mov ss,ax
    mov sp,#0xFF00

然后将go标签所在的地址给ds数据段,es扩展段、ss栈段,同时设置sp栈顶,初步规划号内存地址

紧接着到load_setup标签处,发起0x13中断,将第二个扇区开始的4个扇区加载到0x90200,内容是setup程序

接着后把第6个扇区开始的240个扇区加载到0x10000,内容是操作系统其他代码system

setup

CPU跳转到0x90200来执行setup

1
2
3
4
5
6
7
start:
    mov ax,#0x9000  ; this is done in bootsect already, but...
    mov ds,ax
    mov ah,#0x03    ; read cursor pos
    xor bh,bh
    int 0x10        ; save it in known place, con_init fetches
    mov [0],dx      ; it from 0x90000.

然后设置ah寄存器为0x03并触发int 0x10中断,这两步的作用来读取光标位置,并将光标位置存在dx中(dh存行号,dl存列号)

除此之外,以相同的步骤读取内存信息,显卡信息,磁盘信息等,

约定用0x90000起的内存来存取这些信息,这样就方便了双方对数据的存取,而不需要变量

内存地址 长度(字节) 名称
0x90000 2 光标位置
0x90002 2 扩展内存数
0x90004 2 显示页面
0x90006 1 显示模式
0x90007 1 字符列数
0x90008 2 未知
0x9000A 1 显示内存
0x9000B 1 显示状态
0x9000C 2 显卡特性参数
0x9000E 1 屏幕行数
0x9000F 1 屏幕列数
0x90080 16 硬盘1参数表
0x90090 16 硬盘2参数表
0x901FC 2 根设备号
1
cli         ; no interrupts allowed ;

此时关闭中断,把BIOS写的中断向量给覆盖掉

接着把system部分内存复制到0地址,这样0-0x80000这些内存就是操作系统剩下的所有代码了,而0x90000的bootsect也被覆盖了一些变量信息


保护模式——16位实模式到32位保护模式转换

区别:实模式下段寄存器(ds、cs、es)存放基地址,保护模式下段寄存器存放段选择子,通过gdtr去找到gdt,段选择子包含段描述符索引,找到段描述符才找到基地址,用这个基地址+偏移地址=物理地址(仅段机制没开启分页机制)

image-20250807233210491
1
2
3
4
5
6
7
8
9
10
lidt  idt_48      ; load idt with 0,0
lgdt gdt_48 ; load gdt with whatever appropriate

idt_48:
.word 0 ; idt limit=0
.word 0,0 ; idt base=0L

gdt_48:
.word 0x800 ; gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ; gdt base = 0X9xxxx

从这里看到idtr和gdtr存放这48位,看gdt低16位是gdt的大小,限制2048字节,每个描述符8个字节,有256个描述符;高32位是gdt的偏移地址+0x90200(setup的地址)

1
2
3
4
5
6
7
8
9
10
11
12
gdt:
.word 0,0,0,0 ; dummy

.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9A00 ; code read/exec
.word 0x00C0 ; granularity=4096, 386

.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9200 ; data read/write
.word 0x00C0 ; granularity=4096, 386

这段代码描述了gdt的具体内容,第0个描述符位为空;第1个第2个为代码段数据段,他们的基地址都为0,这样逻辑地址=物理地址

1
2
3
mov ax,#0x0001  ; protected mode (PE) bit
lmsw ax ; This is it;
jmpi 0,8 ; jmp offset 0 of segment 8 (cs)

前面省略打开A20地址线,重写中断号的代码,这里lmsw把cr0寄存器位0(就是PE)置1,这样就进入保护模式了,同时这里的jmpi跳转指cs+ip,0是偏移地址,8是cs的值1000(因为段寄存器是16位,段选择子高13位为描述符索引+TI+两位RPL),这样去gdt找到第一个描述符即代码段,基地址为0,CPU跳转到0物理地址,刚好是system程序,执行内核代码

cr0寄存器控制着CPU的一些重要特性,主要位PE,PG允许分页,WP写保护

cr2页故障线性地址寄存器,存放最后一次出现页故障的线性地址

cr3页目录基地址寄存器,存放页目录表的物理地址,按照页大小4k对齐,因此低12位位0

image-20250808000307855

system

head.s———分页

1
2
3
4
5
6
7
8
_pg_dir:
_startup_32:
mov eax,0x10
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start

_pg_dir这个标签就是实际页目录,也就是开启分页机制后,页目录覆盖这里;通上面cs为8一样,这些段寄存器都设置为10000,会去找gdt第二个段描述符,也就是数据段,同样是0的物理地址

1
2
3
4
5
6
7
8
long user_stack[4096 >> 2];

struct
{
  long *a;
  short b;
}
stack_start = {&user_stack[4096 >> 2], 0x10};

这个是stack_start的结构,lss esp将ss:esp指向stack_start,栈段寄存器依然是0x10,栈顶指针指向user_stack的最后一个元素,也就是这个栈应该是内核栈,并且大小应该有4096字节

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
call setup_idt ;设置中断描述符表
call setup_gdt ;设置全局描述符表
mov eax,10h
mov ds,ax
mov es,ax
mov fs,ax
mov gs,ax
lss esp,_stack_start
......

setup_idt:
lea edx,ignore_int
mov eax,00080000h
mov ax,dx
mov dx,8E00h
lea edi,_idt
mov ecx,256
rp_sidt:
mov [edi],eax
mov [edi+4],edx
add edi,8
dec ecx
jne rp_sidt
lidt fword ptr idt_descr
ret

idt_descr:
dw 256*8-1
dd _idt

_idt:
DQ 256 dup(0)

接着又做了两件事:1、重新设置idt和gdt,call两个函数,然后又刷新了段寄存器;2、重定向idtr和gdtr

这里相当于初始化中断描述符表,使其每个中断描述符的函数地址都指向ignore_int 这个默认函数,相当于你点击鼠标键盘不响应;重定向是这段代码在是system的head.s,_idt是中断描述符的内存地址,这个标签是在head.s设置的,这之前的地址在0x90200中的setup。

内核程序都在0地址开始的system中,后面0x90200的setup程序没用了就给覆盖了

1
2
3
4
5
6
7
8
9
10
11
jmp after_page_tables
...
after_page_tables:
push 0
push 0
push 0
push L6
push _main
jmp setup_paging
L6:
jmp L6

这一步就开启分页机制,并且将将下一条命令地址也就是main函数压栈了,这样子当setup_paging函数调用后ret后就会去找栈顶元素,于是进入main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
setup_paging:
mov ecx,1024*5
xor eax,eax
xor edi,edi
pushf
cld
rep stosd
mov eax,_pg_dir
mov [eax],pg0+7
mov [eax+4],pg1+7
mov [eax+8],pg2+7
mov [eax+12],pg3+7
mov edi,pg3+4092 ; EDI = 0x4000 + 4092 = 0x4FFC (pg3的末尾)
mov eax,00FFF007h ; 初始值:物理页帧0xFFF000,属性=7
std ; 设置方向标志(递减)
L3:
stosd ; [EDI] = EAX, EDI -= 4
sub eax,00001000h ; 页帧号减1 (0x1000 = 4096)
jge L3 ; 如果EAX>=0则继续
popf
xor eax,eax
mov cr3,eax
mov eax,cr0
or eax,80000000h
mov cr0,eax
ret

在setup_paging这个标签中,为分页机制做了这些事:

1、将页目录表放在0地址,这样推测之前_pg_dir标签指向的地址就是0地址,因为这里被页目录覆盖了;

2、设置4页页表放在页目录表后面,内存布局就变成这样了:

| 地址范围 | 内容 | 大小 |

|————–|————–|——–|

| 0x0000 | 页目录表 | 4KB |

| 0x1000 | pg0 (页表0)| 4KB |

| 0x2000 | pg1 (页表1)| 4KB |

| 0x3000 | pg2 (页表2)| 4KB |

| 0x4000 | pg3 (页表3)| 4KB |

| 0x5000 | 其他数据 | N/A |

3、清零页目录和页表,这里rep stosd将五个页表全部清零了

4、初始化页目录项PDE,只有四个页表,设置属性0111

  • P=1:存在 (Present)
  • RW=1:可读写 (Read/Write)
  • US=1:用户/超级用户 (User/Supervisor)
image-20250810171916951

5、L3标签初始化页表项PTE,建立恒等式:从0x4FFC也就是最后一个PTE开始,往前循环每一个PTE,并且进行填充,以建立恒等式:

最开始0x4FFC这个PTE填充为00FFF007h,也就是1111 1111 1111 0000 0000 0111,11找到pg3,10个1找到第1023个PTE,此时取出来的基地址也是1111 1111 1111 0000 0000 0000,这样就建立关系:线性地址==转换后的物理地址;接着PTE减4变成0x4FF8,中间10位减1变成1111 1111 1110 0000 0000 0111,继续填充,直到0x1000填充了0000 0000 0000 0000 0000 0111

目的:因为一旦mov cr3,eax马上就开启分页转换,为了当前地址仍然能正确运行,于是建立恒等关系。这样cr3,jmp和ret返回main就是正确有效的地址

映射关系

  • 线性地址 0x00000000 → 物理地址 0x00000000
  • 线性地址 0x00001000 → 物理地址 0x00001000
  • 线性地址 0x00FFFFFF → 物理地址 0x00FFFFFF

6、CPU开启分页机制,之前设置cr0时为了开启保护模式,现在设置cr0最高位开启分页机制,这样子之前经过分段转换的地址变成线性地址,CPU看到这个地址后拆分为 ——高10位:中间10位,低12位,由CPU里的MMU来将地址转换:因为一个页表1024个项,刚好需要10位,从PDE找到PTE,再从PTE找到基地址,最后加上12位偏移地址构成物理地址;这样一种方案叫二级页表方案;

7、将页目录表地址放到cr3中

为什么是4个页表?

0.11时认为内存16MB,最大地址空间也就是0xFFFFFF;一个页大小4k(12),一个页表项为4字节,这样一个页最多放1024个页表项,即一个页表存放1024*4k=4MB,16MB需要4个页表


main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void main(void) {
    ROOT_DEV = ORIG_ROOT_DEV;
    drive_info = DRIVE_INFO;
    memory_end = (1<<20) + (EXT_MEM_K<<10);
    memory_end &= 0xfffff000;
    if (memory_end > 16*1024*1024)
        memory_end = 16*1024*1024;
    if (memory_end > 12*1024*1024) 
        buffer_memory_end = 4*1024*1024;
    else if (memory_end > 6*1024*1024)
        buffer_memory_end = 2*1024*1024;
    else
        buffer_memory_end = 1*1024*1024;
    main_memory_start = buffer_memory_end;

    mem_init(main_memory_start,memory_end);
    trap_init();
    blk_dev_init();
    chr_dev_init();
    tty_init();
    time_init();
    sched_init();
    buffer_init(buffer_memory_end);
    hd_init();
    floppy_init();

    sti();
    move_to_user_mode();
    if (!fork()) {
        init();
    }

    for(;;) pause();
}

main函数做了这些事:

1、保存设备信息

这些信息在setup程序保存在0x90000地址;

2、主存初始化

之前说linux0.11内存为16MB,1MB用来放内核代码,剩下15MB分成多个4k页,用mem_map这张表来管理这15MB

| 地址范围 | 内容 | 值 |

|————–|————–|——–|

| 2MB |memory | 0 |

| 1MB | buffer | USED |

|0 | 内核 | |

当我们要申请内存时,比如fork进程,就需要**get_free_page()**来申请一页内存来存储task_struct,这张表对应的值变为100(USED)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define LOW_MEM 0x100000
#define PAGING_MEMORY (15*1024*1024)
#define PAGING_PAGES (PAGING_MEMORY>>12)
#define MAP_NR(addr) (((addr)-LOW_MEM)>>12)
#define USED 100

static long HIGH_MEMORY = 0;
static unsigned char mem_map[PAGING_PAGES] = { 0, };

// start_mem = 2 * 1024 * 1024
// end_mem = 8 * 1024 * 1024
void mem_init(long start_mem, long end_mem)
{
    int i;
    HIGH_MEMORY = end_mem;
    for (i=0 ; i<PAGING_PAGES ; i++)
        mem_map[i] = USED;
    i = MAP_NR(start_mem);
    end_mem -= start_mem;
    end_mem >>= 12;
    while (end_mem-->0)
        mem_map[i++]=0;
}

3、各个模块的初始化:

中断初始化:

之前中断描述符都初始化为ignore_int函数,现在设置具体正确的函数,设置许trap_gate和system_gate,这样我们的鼠标键盘就有中断函数了。他们两者区别是DPL(描述特权级)不一样,trap明显进入内核。键盘的中断函数其实路径是这样的:tty_init()——>con_init()——>set_trap_gate(0x21,&keyboard_interrupt);最后**sti()**开启中断键盘鼠标就有响应了


块设备初始化——读硬盘
1
2
3
4
5
6
7
8
9
10
11
struct request {
    int dev;        /* -1 if no request */
    int cmd;        /* READ or WRITE */
    int errors;
    unsigned long sector;
    unsigned long nr_sectors;
    char * buffer;
    struct task_struct * waiting;
    struct buffer_head * bh;
    struct request * next;
};

先给块设备请求项数组的前32个dev=-1, next=NULL;这样一个请求项就能描述一次读取硬盘的操作了:从哪块扇区读到哪块缓存

多个请求项通过next连成链表,内核逐个处理

  • 具体例子:sys_read
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
int sys_read(unsigned int fd,char * buf,int count) {
    struct file * file = current->filp[fd];
    struct m_inode * inode = file->f_inode;
    // 校验 buf 区域的内存限制
    verify_area(buf,count);
    // 仅关注目录文件或普通文件
    return file_read(inode,file,buf,count);
}

int file_read(struct m_inode * inode, struct file * filp, char * buf, int count) {
    int left,chars,nr;
    struct buffer_head * bh;
    left = count;
    while (left) {
        if (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) {
            if (!(bh=bread(inode->i_dev,nr)))
                break;
        } else
            bh = NULL;
        nr = filp->f_pos % BLOCK_SIZE;
        chars = MIN( BLOCK_SIZE-nr , left );
        filp->f_pos += chars;
        left -= chars;
        if (bh) {
            char * p = nr + bh->b_data;
            while (chars-->0)
                put_fs_byte(*(p++),buf++);
            brelse(bh);
        } else {
            while (chars-->0)
                put_fs_byte(0,buf++);
        }
    }
    inode->i_atime = CURRENT_TIME;
    return (count-left)?(count-left):-ERROR;

读取一个文件描述符,最终落实到这个文件inode去读取磁盘块,函数调用链:bread——>ll_rw_block——>make_request——>add_request

image-20250813232455669
控制台初始化——键盘输出

在内存有一段区域——跟显存映射的:这块区域用两个字节表示一个显示在屏幕上的字符(编码和颜色);

tty_init()包含rs_init()和con_init()这两个函数:第一个函数开启串口中断、设置中断函数;第二个函数做了几件事:

1、获取显存相关信息:之前在0x90000地址存放了信息,包含了屏幕显示(显示模式、字符列数、行数、列数等),获取 0x90006 地址处的数据

2、显存映射:比如在CGA文本模式,映射的内存是从 0xB8000 到 0xBA000。例如以下这段代码在左上角打印了hello

1
2
3
4
5
mov [0xB8000],'h'
mov [0xB8002],'e'
mov [0xB8004],'l'
mov [0xB8006],'l'
mov [0xB8008],'o'

3、设置滚动屏幕时的参数,定义首行和末行,这样滚动的时候就可以上移

4、把光标定位到之前保存的光标位置处(取内存地址 0x90000 处的数据),然后设置并开启键盘中断。然后通过x列,y行,向pos指向的地址写入数据

1
2
3
4
5
6
static inline void gotoxy(unsigned int new_x,unsigned int new_y) {
  ...
  x = new_x;
  y = new_y;
  pos = origin + y*video_size_row + (x<<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
_keyboard_interrupt:
    ...
    call _do_tty_interrupt
    ...
    
void do_tty_interrupt(int tty) {
   copy_to_cooked(tty_table+tty);
}

void copy_to_cooked(struct tty_struct * tty) {
    ...
    tty->write(tty);
    ...
}

// 控制台时 tty 的 write 为 con_write 函数
void con_write(struct tty_struct * tty) {
    ...
    __asm__("movb _attr,%%ah\n\t"
      "movw %%ax,%1\n\t"
      ::"a" (c),"m" (*(short *)pos)
      :"ax");
     pos += 2;
     x++;
    ...
}
时间初始化——获取当前时间

如何获取时间,主要做两件事:1、从CMOS这个设备读取;2、将读取的BCD编码转换为二进制存储

与CMOS打交道则是通过端口的进行in,out的,这些端口号大部分连接寄存器,如数据寄存器、命令寄存器等

进程调度初始化

shed_init用到两个关键的结构:TSSLDT

在gdt中,已经存储了四个段描述符了:0、code、data、0。接下来又往后加了两项,TSS和LDT

TSS:任务状态段,用来保存和恢复进程的上下文

LDT:局部描述符表,用来保存每个进程的代码段、数据段

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
struct tss_struct{
long back_link;
long esp0;
long ss0;
long esp1;
long ss1;
long esp2;
long ss2;
long cr3;
long eip;
long eflags;
long eax, ecx, edx, ebx;
long esp;
long ebp;
long esi;
long edi;
long es;
long cs;
long ss;
long ds;
long fs;
long gs;
long ldt;
long trace_bitmap;
struct i387_struct i387;
};

1、给task(64个task_struct结构)缺省值的,并且给gdt剩余的位置填充为0,往后每添加一个进程就添加一组TSS和LDT

当前task[0]不是init进程,而是idle进程:main函数这些初始化的指令流可以说是内核镜像的一部分,此时没有进程上下文,而后调度器初始化并且启动后执行的第一个任务就是最后一段代码的for循环,进行空闲循环,这个代码才是进程0的,可以说tss0为内核执行提供了环境,派生了后续的init和其他内核线程

2、0TSS和0LDT是为当前运行的代码的一个指令流,当调度机制建立后就会成为进程0;除此之外gdt有gdtr,代码段数据段都有段寄存器保存地址,TSS和LDT也有tr寄存器和ldt寄存器用来保存这两个段描述符的地址,当进程切换时,这些寄存器也要保存

3、真正的进程调度主要靠时钟中断和调度算法来实现,后面的代码则是通过outb_p与可编程定时器芯片的端口交互,开启定时器,设置中断函数,允许中断,开启了定时中断,在这里设置0x21和0x80两个非常重要的中断,时钟中断和系统调用中断

outb_p(0x36,0x43);      /* binary, mode 3, LSB/MSB, ch 0 */
outb_p(LATCH & 0xff , 0x40);    /* LSB */
outb(LATCH >> 8 , 0x40);    /* MSB */
set_intr_gate(0x20,&timer_interrupt);
outb(inb_p(0x21)&~0x01,0x21);
set_system_gate(0x80,&system_call);

task_struct:进程结构的具体信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct task_struct {
/* these are hardcoded - don't touch */
    long state; /* -1 unrunnable, 0 runnable, >0 stopped */
    long counter;
    long priority;
    long signal;
    struct sigaction sigaction[32];
    long blocked; /* bitmap of masked signals */
  /* various fields */
    int exit_code;
    unsigned long start_code,end_code,end_data,brk,start_stack;
    long pid,father,pgrp,session,leader;
    unsigned short uid,euid,suid;
    unsigned short gid,egid,sgid;
    long alarm;
    long utime,stime,cutime,cstime,start_time;
    unsigned short used_math;
  /* file system info */
    int tty;  /* -1 if no tty, so it must be signed */
    unsigned short umask;
    struct m_inode * pwd;
    struct m_inode * root;
    struct m_inode * executable;
    unsigned long close_on_exec;
    struct file * filp[NR_OPEN];
  /* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
    struct desc_struct ldt[3];
  /* tss for this task */
    struct tss_struct tss;
};

进程创建

进程 0 创建进程 1 时,复制了 160 个页表项。进程 1 创建进程 2 时,复制了 1024 个页表项。之后进程 2 创建进程 3,进程 3 创建进程 4,通通都是复制 1024 个页表项。

1、为进程设置页表和页表目录通过copy_mem函数来准备工作,根据父进程代码段数据段,设置子进程代码段数据段基地址

2.设置idt的代码段数据段信息

3、copy_page_tables复制页表,申请空间

4、设置子进程页目录表,刷新页变换高速缓存

image-20250817155949113
缓存区初始化
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
extern int end;
struct buffer_head * start_buffer = (struct buffer_head *) &end;

void buffer_init(long buffer_end) {
    struct buffer_head * h = start_buffer;
    void * b = (void *) buffer_end;
    while ( (b -= 1024) >= ((void *) (h+1)) ) {
        h->b_dev = 0;
        h->b_dirt = 0;
        h->b_count = 0;
        h->b_lock = 0;
        h->b_uptodate = 0;
        h->b_wait = NULL;
        h->b_next = NULL;
        h->b_prev = NULL;
        h->b_data = (char *) b;
        h->b_prev_free = h-1;
        h->b_next_free = h+1;
        h++;
    }
    h--;
    free_list = start_buffer;
    free_list->b_prev_free = h;
    h->b_next_free = free_list;
    for (int i=0;i<307;i++)
        hash_table[i]=NULL;
}

end是链接器 ld 在链接整个程序时设置的一个外部变量,帮我们计算好了整个内核代码的末尾地址

这段代码以内存为8M为例子,在2M缓存区上方存放缓存块,下方存放缓存信息结构体,并且h->b_data = (char*)b;最后组成双向链表free_list;

后面hash_table进行块设备缓冲区的映射,读取块设备时需要读到缓冲区,如果已经读到了就进行映射,这样就不需要去遍历链表了,映射规则:(dev^block) mod 307

硬盘初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//struct blk_dev_struct {
//    void (*request_fn)(void);
//    struct request * current_request;
//};
//extern struct blk_dev_struct blk_dev[NR_BLK_DEV];

void hd_init(void) {
    blk_dev[3].request_fn = do_hd_request;
    set_intr_gate(0x2E,&hd_interrupt);
    outb_p(inb_p(0x21)&0xfb,0x21);
    outb(inb_p(0xA1)&0xbf,0xA1); 
}

struct blk_dev_struct blk_dev[NR_BLK_DEV] = {
    { NULL, NULL },     /* no_dev */
    { NULL, NULL },     /* dev mem */
    { NULL, NULL },     /* dev fd */
    { NULL, NULL },     /* dev hd */
    { NULL, NULL },     /* dev ttyx */
    { NULL, NULL },     /* dev tty */
    { NULL, NULL }      /* dev lp */
};

因为有很多块设备,所以 Linux 0.11 内核用了一个 blk_dev[] 来进行管理,每一个索引表示一个块设备。

这里函数指针进行统一接口,多态

硬盘的端口表。

端口
0x1F0 数据寄存器 数据寄存器
0x1F1 错误寄存器 特征寄存器
0x1F2 扇区计数寄存器 扇区计数寄存器
0x1F3 扇区号寄存器或 LBA 块地址 0~7 扇区号或 LBA 块地址 0~7
0x1F4 磁道数低 8 位或 LBA 块地址 8~15 磁道数低 8 位或 LBA 块地址 8~15
0x1F5 磁道数高 8 位或 LBA 块地址 16~23 磁道数高 8 位或 LBA 块地址 16~23
0x1F6 驱动器/磁头或 LBA 块地址 24~27 驱动器/磁头或 LBA 块地址 24~27
0x1F7 命令寄存器或状态寄存器 命令寄存器

那读硬盘就是,往除了第一个以外的后面几个端口写数据,告诉要读硬盘的哪个扇

4、切换用户模式

内核态用户态本质是特权级的变化——属于段保护模式的一种

我们执行的代码地址是通过CPU中的cs:eip指向的,cs存放段选自的CPL是当前特权级,当要跳转到其他段时,yyy地址的RPL是请求特权级,找到段描述符后,只有DPL目标特权级和CPL一致时,才不会报错

代码跳转只能同特权级,数据访问只能高特权级访问低特权级

image-20250818002905006

跳转到用户态的操作:

代码模拟中断返回iret过程实现跳转,发生中断时,CPU会压栈保存上下文,发生特权级变换时压栈顺序:ss、esp、eflags、cs、eip、erro_code

ss段选择子为1001,则说明会去gdt的数据段(因为TI为0),并且特权级为1(内核态);

cs为1111,则说明会去ldt的代码段(因为TI为1),并且特权级为3(内用户态),eip指向l1这个标签地址,即下一条执行地址

所以iretd执行后,依然在内核栈,但接下来的代码指令已经到0LDT去执行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define move_to_user_mode() \
_asm { \
    _asm mov eax,esp \
    _asm push 00000017h \
    _asm push eax \
    _asm pushfd \
    _asm push 0000000fh \
    _asm push offset l1 \
    _asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
    _asm mov ds,ax \
    _asm mov es,ax \
    _asm mov fs,ax \
    _asm mov gs,ax \
}

5、init进程

准备工作:

1、每次进程切换通过将上下文保存到tss中

2、每次时钟中断将时间片减1,counter为0时执行schedule

3、优先级

4、进程状态

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
struct task_struct {
    long state; /* -1 unrunnable, 0 runnable, >0 stopped */
    long counter;
    long priority;
    ...
    struct tss_struct tss;
}

#define TASK_RUNNING          0
#define TASK_INTERRUPTIBLE    1
#define TASK_UNINTERRUPTIBLE  2
#define TASK_ZOMBIE           3
#define TASK_STOPPED          4

struct tss_struct {
    long    back_link;  /* 16 high bits zero */
    long    esp0;
    long    ss0;        /* 16 high bits zero */
    long    esp1;
    long    ss1;        /* 16 high bits zero */
    long    esp2;
    long    ss2;        /* 16 high bits zero */
    long    cr3;
    long    eip;
    long    eflags;
    long    eax,ecx,edx,ebx;
    long    esp;
    long    ebp;
    long    esi;
    long    edi;
    long    es;     /* 16 high bits zero */
    long    cs;     /* 16 high bits zero */
    long    ss;     /* 16 high bits zero */
    long    ds;     /* 16 high bits zero */
    long    fs;     /* 16 high bits zero */
    long    gs;     /* 16 high bits zero */
    long    ldt;        /* 16 high bits zero */
    long    trace_bitmap;   /* bits: trace 0, bitmap 16-31 */
    struct i387_struct i387;
};

进程的调度:

1. 拿到剩余时间片(counter的值)最大且在 runnable 状态(state = 0)的进程号 next。

2. 如果所有 runnable 进程时间片都为 0,则将所有进程(注意不仅仅是 runnable 的进程)的 counter 重新赋值(counter = counter/2 + priority),然后再次执行步骤 1。

3. 最后拿到了一个进程号 next,调用了 switch_to(next) 这个方法,就切换到了这个进程去执行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sched.h

#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
    "je 1f\n\t" \
    "movw %%dx,%1\n\t" \
    "xchgl %%ecx,_current\n\t" \
    "ljmp %0\n\t" \
    "cmpl %%ecx,_last_task_used_math\n\t" \
    "jne 1f\n\t" \
    "clts\n" \
    "1:" \
    ::"m" (*&__tmp.a),"m" (*&__tmp.b), \
    "d" (_TSS(n)),"c" ((long) task[n])); \
}

fork进程创建:

#define __NR_fork 2,_到sys_call_table+8个字节的地方,即下标为2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static _inline _syscall0(int,fork)

#define _syscall0(type,name) \
type name(void) \
{ \
long __res; \

__asm__ volatile ("int $0x80" \
    : "=a" (__res) \
    : "0" (__NR_##name)); \
if (__res >= 0) \
    return (type) __res; \
errno = -__res; \
return -1; \
}

CPU 中断压入的 5 个值,加上 system_call 手动压入的 7 个值。具体说来有 ds、es、fs、edx、ecx、ebx、eax

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
_system_call:
    cmpl $nr_system_calls-1,%eax
    ja bad_sys_call
    push %ds
    push %es
    push %fs
    pushl %edx
    pushl %ecx      # push %ebx,%ecx,%edx as parameters
    pushl %ebx      # to the system call
    movl $0x10,%edx     # set up ds,es to kernel space
    mov %dx,%ds
    mov %dx,%es
    movl $0x17,%edx     # fs points to local data space
    mov %dx,%fs
    call _sys_call_table(,%eax,4)
    pushl %eax
    movl _current,%eax
    cmpl $0,state(%eax)     # state
    jne reschedule
    cmpl $0,counter(%eax)       # counter
    je reschedule
ret_from_sys_call:
    movl _current,%eax      # task[0] cannot have signals
    cmpl _task,%eax
    je 3f
    cmpw $0x0f,CS(%esp)     # was old code segment supervisor ?
    jne 3f
    cmpw $0x17,OLDSS(%esp)      # was stack segment = 0x17 ?
    jne 3f
    movl signal(%eax),%ebx
    movl blocked(%eax),%ecx
    notl %ecx
    andl %ebx,%ecx
    bsfl %ecx,%ecx
    je 3f
    btrl %ecx,%ebx
    movl %ebx,signal(%eax)
    incl %ecx
    pushl %ecx
    call _do_signal
    popl %eax
3:  popl %eax
    popl %ebx
    popl %ecx
    popl %edx
    pop %fs
    pop %es
    pop %ds
    iret

sys_fork先是找到了空闲的task,然后进行copy,copy的方法比较长,基本上就是元信息(状态、id、counter)和tss的复制;首先需要为这个task_struct分配内存get_free_page,即在mem_map映射表中找到一页空闲的;

原来,为每一个进程分配一个4k页,进程的堆栈栈顶指针指向页顶端

原来mem_map映射的物理页,内核和用户态可以随机分布,但是通过段描述符来识别这个页的特权级

栈指针指向顶端这样就可以向下增加

copy进程函数还copy_mem,设置了LDT在线性空间的地址和大小:nr * 0x4000000(64MB)

然后copy_page,这个函数比较重要:因为虽然进程0占640K,线性地址从0开始,但是进程1的线性地址从64MB开始,这里需要将他们的线性地址最终都映射到物理地址0-64MB,才能正确运行

最后还将页表项变成只读,并且直接赋值给新进程——写时复制,进程0到进程1只复制160项,后面的都复制1024项,也就是

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
_sys_fork:
    call _find_empty_process
    testl %eax,%eax
    js 1f
    push %gs
    pushl %esi
    pushl %edi
    pushl %ebp
    pushl %eax
    call _copy_process
    addl $20,%esp
1:  ret


fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
  sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
  sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
  sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
  sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
  sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
  sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
  sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
  sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
  sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
  sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
  sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
  sys_setreuid, sys_setregid
};

在 unistd.h 头文件里,还定义了 syscall0 ~ syscall3 一共四个宏。数字代码参数个数

1
2
3
4
#define _syscall0(type,name)
#define _syscall1(type,name,atype,a)
#define _syscall2(type,name,atype,a,btype,b)
#define _syscall3(type,name,atype,a,btype,b,ctype,c)

中断

整个操作系统就是一个死循环,其他所有事情都是由操作系统提前注册的中断机制和其对应的中断处理函数完成

根据intel手册CPU提供两种中断机制,中断和异常

  • 中断:异步事件,通常由IO设备触发(点击鼠标)
  • 异常:同步时间,是 CPU 在执行指令时检测到的反常条件(故障、陷阱、中止)
  • INT:通过这条指令直接给CPU发送中断号

我们常把上面两个叫做硬中断,INT叫做软中断

本质:

都是传递给CPU中断信息和中断号

例如,可编程中断控制器,多个IRQ引脚线连着各种设备(鼠标键盘等),每个IRQ对应一个中断号,当设备发送信号时,就给CPU的INTR引脚传递中断号

CPU如何处理:

CPU收到中断号后根据idtr寄存器去中断描述符表找到中断描述符,其中包含了段选择子和偏移地址,段选择子根据gdtr寄存器去全局描述符表找到段描述符,其中包含基地址。

基地址+偏移地址 = 程序入口地址


中断描述符表:

1
2
3
struct desc_struct {
    unsigned long a,b;
};

一个描述符就是64位,分为三类:

Task Gate:任务门描述符

Interrupt Gate:中断门描述符————无法嵌套中断

Trap Gate:陷阱门描述符————可以嵌套中断

image-20250805233912735
1
2
3
4
5
idt_descr:
    .word 256 * 8 - 1
    .long idt_table
    
lidt idt_descr

idtr寄存器结构为16为位的长度+32位的地址,lidt将这个结构存入寄存器中


找到中断描述符后进行压栈操作:

image-20250805234729648

最后IRET或者IRETD指令返回,做了两件事:1、出栈;2、赋值给eip、cs、eflags

outb_p

定义

outb_p 是 x86 架构下的 带延迟的 I/O 端口输出函数,用于向硬件设备的寄存器写入一个字节(8 位)数据,并在写入后插入一个短暂的延迟。其典型实现如下(以 Linux 内核为例):

1
2
#define outb_p(value, port) \
do { outb(value, port); slow_down_io(); } while (0)

其中:

  • outb(value, port):直接向指定端口写入数据。
  • slow_down_io():插入延迟(通常通过 nop 指令或 jmp 到下一个语句实现)。x86 的早期硬件设备(如 PIC、PATA、定时器)响应速度较慢,outb_p 的延迟能防止 CPU 写入过快导致设备未及时处理指令。
1
x86:通常为 asm volatile ("jmp 1f\n\t1: jmp 1f\n\t1:" : : );(两条跳转指令)

使用场景

outb_p 主要用于与 Legacy x86 硬件 交互,典型场景包括:

硬件设备 用途 示例代码
8259A PIC 配置中断屏蔽字(IMR)或初始化中断控制器 outb_p(inb_p(0x21) & 0xfb, 0x21); // 允许IRQ2
IDE 硬盘控制器 (PATA) 设置硬盘参数(扇区、柱面、磁头)或发送命令 outb_p(nsect, 0x1f2); // 写入扇区数
8253/8254 PIT (定时器) 设置定时器频率(如系统时钟)或工作模式 outb_p(0x36, 0x43); // 模式3,方波发生器
串口/UART 配置波特率或控制寄存器 outb_p(0x80, 0x3F8 + 3); // 设置DLAB位

现代系统的演进

  • 硬件抽象层
    现代操作系统(如 Linux)通过 io.h 或设备驱动框架封装端口操作,开发者无需直接调用 outb_p
  • 替代技术
    • 中断控制:APIC/MSI 取代 8259A PIC。
    • 存储接口:AHCI/NVMe 取代 PATA,使用内存映射寄存器(MMIO)。
    • 定时器:HPET 或 ACPI 电源管理定时器取代 PIT。
  • 保留场景
    仍用于 引导加载程序Legacy 设备驱动嵌入式开发