本文为学习操作系统源码
(低并发编程) 所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。
第十四回 |
你的键盘是什么时候生效的
你的键盘是什么时候生效的?
(qq.com)
我们以 Linux 0.11 源码为例,发现进入内核的 main
函数后不久,有这样一行代码。
1 2 3 4 5 void main (void ) { ... trap_init(); ... }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void trap_init (void ) { int i; set_trap_gate(0 ,÷_error); set_trap_gate(1 ,&debug); set_trap_gate(2 ,&nmi); set_system_gate(3 ,&int3); set_system_gate(4 ,&overflow); set_system_gate(5 ,&bounds); set_trap_gate(6 ,&invalid_op); set_trap_gate(7 ,&device_not_available); set_trap_gate(8 ,&double_fault); set_trap_gate(9 ,&coprocessor_segment_overrun); set_trap_gate(10 ,&invalid_TSS); set_trap_gate(11 ,&segment_not_present); set_trap_gate(12 ,&stack_segment); set_trap_gate(13 ,&general_protection); set_trap_gate(14 ,&page_fault); set_trap_gate(15 ,&reserved); set_trap_gate(16 ,&coprocessor_error); for (i=17 ;i<48 ;i++) set_trap_gate(i,&reserved); set_trap_gate(45 ,&irq13); set_trap_gate(39 ,¶llel_interrupt); }
1 2 3 4 5 6 7 8 9 10 11 12 13 void trap_init (void ) { int i; set_trap_gate(0 , ÷_error); ... set_system_gate(45 , &bounds); ... for (i=17 ;i<48 ;i++) set_trap_gate(i, &reserved); ... }
首先我们看 set_trap_gate 和
set_system_gate 这俩货,发现了这么几个宏定义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #define _set_gate(gate_addr,type,dpl,addr) \ __asm__ ("movw %%dx,%%ax\n\t" \ "movw %0,%%dx\n\t" \ "movl %%eax,%1\n\t" \ "movl %%edx,%2" \ : \ : "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \ "o" (*((char *) (gate_addr))), \ "o" (*(4+(char *) (gate_addr))), \ "d" ((char *) (addr)),"a" (0x00080000)) #define set_trap_gate(n,addr) \ _set_gate(&idt[n],15,0,addr) #define set_system_gate(n,addr) \ _set_gate(&idt[n],15,3,addr)
不过这俩都是最终指向了相同的另一个宏定义
**_set_gate**,说明是有共性的。
最终的效果就是在中断描述符表中插入了一个中断描述符 。
这段代码就是往这个 idt
表里一项一项地写东西,其对应的中断号就是第一个参数,中断处理程序就是第二个参数。
产生的效果就是,之后如果来一个中断后,CPU
根据其中断号,就可以到这个中断描述符表 idt
中找到对应的中断处理程序了。
比如这个。
1 set_trap_gate(0 ,÷_error);
就是设置 0 号中断 ,对应的中断处理程序是
divide_error 。
等 CPU 执行了一条除零指令的时候,会从硬件层面发起一个 0
号异常中断,然后执行由我们操作系统定义的 divide_error
也就是除法异常处理程序,执行完之后再返回。
再比如这个。
1 set_system_gate(5 ,&overflow);
就是设置 5 号中断,对应的中断处理程序是
overflow,是边界出错中断。
TIPS:这个 trap 与 system
的区别仅仅在于,设置的中断描述符的特权级不同,前者是 0(内核态),后者是
3(用户态),这块展开将会是非常严谨的、绕口的、复杂的特权级相关的知识,不明白的话先不用管,就理解为都是设置一个中断号和中断处理程序的对应关系就好了。
再往后看,批量操作这里。
1 2 3 4 5 6 void trap_init (void ) { ... for (i=17 ;i<48 ;i++) set_trap_gate(i,&reserved); ... }
17 到 48 号中断都批量设置为了 reserved
函数,这是暂时的,后面各个硬件初始化时要重新设置好这些中断,把暂时的这个给覆盖掉,此时你留个印象。
键盘产生的中断的中断号是
0x21 ,此时这个中断号还仅仅对应着一个临时的中断处理程序
&reserved,我们接着往后看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void main (void ) { ... trap_init(); ... tty_init(); ... }void tty_init (void ) { rs_init(); con_init(); }void con_init (void ) { ... set_trap_gate(0x21 ,&keyboard_interrupt); ... }
注意到 trap_init 后有个
tty_init ,最后根据调用链,会调用到一行添加 0x21
号中断处理程序的代码,就是刚刚熟悉的
set_trap_gate 。
而后面的 keyboard_interrupt
根据名字也可以猜出,就是键盘的中断处理程序嘛!就是从这一行代码开始,我们的键盘生效了!
没错,不过还有点小问题,不过不重要,就是我们现在的中断处于禁用状态 ,不论是键盘中断还是其他中断,通通都不好使。
1 2 3 4 5 6 7 8 9 void main (void ) { ... trap_init(); ... tty_init(); ... sti(); ... }
sti 最终会对应一个同名的汇编指令
sti,表示允许中断 。所以这行代码之后,键盘才真正开始生效!
第十五回 |
读取硬盘前的准备工作有哪些?
读取硬盘前的准备工作有哪些?
(qq.com)
我们就讲讲,读取块设备与内存缓冲区之间的桥梁,块设备请求项 的初始化工作。
我们以 Linux 0.11 源码为例,发现进入内核的 main
函数后不久,有这样一行代码
1 2 3 4 5 void main (void ) { ... blk_dev_init(); ... }
1 2 3 4 5 6 7 void blk_dev_init (void ) { int i; for (i=0 ; i<32 ; i++) { request[i].dev = -1 ; request[i].next = NULL ; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 struct request { int dev; int cmd; int errors; unsigned long sector; unsigned long nr_sectors; char * buffer; struct task_struct * waiting ; struct buffer_head * bh ; struct request * next ; };
这也侧面说明了,学习操作系统,其实把遇到的重要数据结构牢记心中,就已经成功一半了。比如主内存管理结构
mem_map,知道它的数据结构是什么样子,其功能也基本就懂了。
dev 表示设备号,-1 就表示空闲。
cmd 表示命令,其实就是 READ 还是
WRITE,也就表示本次操作是读还是写。
errors 表示操作时产生的错误次数。
sector 表示起始扇区。
nr_sectors 表示扇区数。
buffer
表示数据缓冲区,也就是读盘之后的数据放在内存中的什么位置。
waiting 是个 task_struct
结构,这可以表示一个进程,也就表示是哪个进程发起了这个请求。
bh
是缓冲区头指针,这个后面讲完缓冲区就懂了,因为这个 request
是需要与缓冲区挂钩的。
next 指向了下一个请求项
读请求时,cmd 就是 READ,sector 和
nr_sectors
这俩就定位了所要读取的块设备(可以简单先理解为硬盘)的哪几个扇区,buffer
就定位了这些数据读完之后放在内存的什么位置。
而其他的参数,肯定是为了更好地配合操作系统进行读写块设备操作嘛,为了把多个读写块设备请求很好地组织起来。这个组织不但要有这个数据结构中
hb 和 next
等变量的配合,还要有后面的电梯调度算法的配合,仅此而已,先点到为止。
总之,我们这里就先明白,这个 request
结构可以完整描述一个读盘操作。然后那个 request
数组就是把它们都放在一起,并且它们又通过 next 指针串成链表。
读操作的系统调用函数是
sys_read ,源代码很长,我给简化一下,仅仅保留读取普通文件的分支,就是如下的样子。
1 2 3 4 5 6 7 8 int sys_read (unsigned int fd,char * buf,int count) { struct file * file = current->filp[fd]; struct m_inode * inode = file->f_inode; verify_area(buf,count); return file_read(inode,file,buf,count); }
入参 fd 是文件描述符,通过它可以找到一个文件的
inode,进而找到这个文件在硬盘中的位置。
另两个入参 buf
就是要复制到的内存中的位置,count
就是要复制多少个字节,很好理解。
file_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 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; }
整体看,就是一个 while
循环,每次读入一个块的数据,直到入参所要求的大小全部读完为止。
1 2 3 4 5 6 7 int file_read (struct m_inode * inode, struct file * filp, char * buf, int count) { ... while (left) { ... if (!(bh=bread(inode->i_dev,nr))) } }
bread() function
1 2 3 4 5 6 7 8 9 10 11 struct buffer_head * bread (int dev,int block) { struct buffer_head * bh = getblk(dev,block); if (bh->b_uptodate) return bh; ll_rw_block(READ,bh); wait_on_buffer(bh); if (bh->b_uptodate) return bh; brelse(bh); return NULL ; }
其中 getblk 先申请了一个内存中的缓冲块,然后 ll_rw_block
负责把数据读入这个缓冲块,进去继续看。
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 void ll_rw_block (int rw, struct buffer_head * bh) { ... make_request(major,rw,bh); }static void make_request (int major,int rw, struct buffer_head * bh) { ...if (rw == READ) req = request+NR_REQUEST; else req = request+((NR_REQUEST*2 )/3 ); while (--req >= request) if (req->dev<0 ) break ; ... req->dev = bh->b_dev; req->cmd = rw; req->errors=0 ; req->sector = bh->b_blocknr<<1 ; req->nr_sectors = 2 ; req->buffer = bh->b_data; req->waiting = NULL ; req->bh = bh; req->next = NULL ; add_request(major+blk_dev,req); }
具体说来,就是该函数会往刚刚的设备的请求项链表 request[32]
中添加一个请求项,只要 request[32]
中有未处理的请求项存在,都会陆续地被处理,直到设备的请求项链表是空为止。
第十六回 |
按下键盘后为什么屏幕上就会有输出
第16回
| 按下键盘后为什么屏幕上就会有输出 (qq.com)
tty_init() function
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void main (void ) { ... 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(); }
这个方法执行完成之后,我们将会具备键盘输入到显示器输出字符这个最常用的功能。
1 2 3 4 5 void tty_init (void ) { rs_init(); con_init(); }
rs_init() function
1 2 3 4 5 6 7 8 void rs_init (void ) { set_intr_gate(0x24 ,rs1_interrupt); set_intr_gate(0x23 ,rs2_interrupt); init(tty_table[1 ].read_q.data); init(tty_table[2 ].read_q.data); outb(inb_p(0x21 )&0xE7 ,0x21 ); }
这个方法是串口中断的开启,以及设置对应的中断处理程序,串口在我们现在的
PC 机上已经很少用到了,所以这个直接忽略。
con_init() function
1 2 3 4 5 6 7 8 9 10 11 12 13 void con_init (void ) { ... if (ORIG_VIDEO_MODE == 7 ) { ... if ((ORIG_VIDEO_EGA_BX & 0xff ) != 0x10 ) {...} else {...} } else { ... if ((ORIG_VIDEO_EGA_BX & 0xff ) != 0x10 ) {...} else {...} } ... }
这是为了应对不同的显示模式,来分配不同的变量值,那如果我们仅仅找出一个显示模式,这些分支就可以只看一个了。
啥是显示模式呢?那我们得简单说说显示,一个字符是如何显示在屏幕上的呢 ?换句话说,如果你可以随意操作内存和
CPU 等设备,你如何操作才能使得你的显示器上,显示一个字符‘a’呢?
内存中有这样一部分区域,是和显存映射的。啥意思,就是你往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。
其实就是往内存中 0xB8000
这个位置写了一个值,只要一写,屏幕上就会是这样。
具体说来,这片内存是每两个字节表示一个显示在屏幕上的字符,第一个是字符的编码,第二个是字符的颜色 ,那我们先不管颜色,如果多写几个字符就像这样。
1 2 3 4 5 mov [0xB8000],'h' mov [0xB8002],'e' mov [0xB8004],'l' mov [0xB8006],'l' mov [0xB8008],'o'
我们就假设显示模式是我们现在的这种文本模式,那条件分支就可以去掉好多。
代码可以简化成这个样子。
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 #define ORIG_X (*(unsigned char *)0x90000) #define ORIG_Y (*(unsigned char *)0x90001) void con_init (void ) { register unsigned char a; video_num_columns = (((*(unsigned short *)0x90006 ) & 0xff00 ) >> 8 ); video_size_row = video_num_columns * 2 ; video_num_lines = 25 ; video_page = (*(unsigned short *)0x90004 ); video_erase_char = 0x0720 ; video_mem_start = 0xb8000 ; video_port_reg = 0x3d4 ; video_port_val = 0x3d5 ; video_mem_end = 0xba000 ; origin = video_mem_start; scr_end = video_mem_start + video_num_lines * video_size_row; top = 0 ; bottom = video_num_lines; gotoxy(ORIG_X, ORIG_Y); set_trap_gate(0x21 ,&keyboard_interrupt); outb_p(inb_p(0x21 )&0xfd ,0x21 ); a=inb_p(0x61 ); outb_p(a|0x80 ,0x61 ); outb(a,0x61 ); }
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
根设备号
第一部分 获取 0x90006
地址处的数据,就是获取显示模式等相关信息。
第二部分 就是显存映射的内存地址范围,我们现在假设是
CGA 类型的文本模式,所以映射的内存是从 0xB8000 到 0xBA000。
第三部分 是设置一些滚动屏幕时需要的参数,定义顶行和底行是哪里,这里顶行就是第一行,底行就是最后一行,很合理。
第四部分 是把光标定位到之前保存的光标位置处(取内存地址
0x90000 处的数据),然后设置并开启键盘中断。
开启键盘中断后,键盘上敲击一个按键后就会触发中断,中断程序就会读键盘码转换成
ASCII
码,然后写到光标处的内存地址,也就相当于往显存写,于是这个键盘敲击的字符就显示在了屏幕上。
1.
我们现在根据已有信息已经可以实现往屏幕上的任意位置写字符了,而且还能指定颜色。
2.
并且,我们也能接受键盘中断,根据键盘码中断处理程序就可以得知哪个键按下了。
1 2 3 4 5 6 7 8 #define ORIG_X (*(unsigned char *)0x90000) #define ORIG_Y (*(unsigned char *)0x90001) void con_init (void ) { ... gotoxy(ORIG_X, ORIG_Y); ... }
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 ); }
其中 x 表示光标在哪一列,y
表示光标在哪一行,pos
表示根据列号和行号计算出来的内存指针,也就是往这个 pos
指向的地址处写数据,就相当于往控制台的 x 列 y
行处写入字符了,简单吧?
然后,当你按下键盘后,触发键盘中断,之后的程序调用链是这样的。
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); ... }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++; ... }
asm 内联汇编,就是把键盘输入的字符
c 写入 pos
指针指向的内存,相当于往屏幕输出了。
之后两行 pos+=2 和 x++,就是调整所谓的光标 。
你看,写入一个字符,最底层,其实就是往内存的某处写个数据,然后顺便调整一下光标 。
由此我们也可以看出,光标的本质,其实就是这里的 x y pos
这仨变量而已。
我们还可以做换行效果 ,当发现光标位置处于某一行的结尾时(这个应该很好算吧,我们都知道屏幕上一共有几行几列了),就把光标计算出一个新值,让其处于下一行的开头。
就一个小计算公式即可搞定,仍然在 con_write 源码处有体现,就是判断列号
x 是否大于了总列数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void con_write (struct tty_struct * tty) { ... if (x>=video_num_columns) { x -= video_num_columns; pos -= video_size_row; lf(); } ... }static void lf (void ) { if (y+1 <bottom) { y++; pos += video_size_row; return ; } ... }
相似的,我们还可以实现滚屏 的效果,无非就是当检测到光标已经出现在最后一行最后一列了,那就把每一行的字符,都复制到它上一行,其实就是算好哪些内存地址上的值,拷贝到哪些内存地址,就好了。
所以,有了这个初始化工作,我们就可以利用这些信息,弄几个小算法,实现各种我们常见控制台的操作。
所以 console.c
中的其他方法就是做这个事的,我们就不展开每一个功能的方法体了,简单看看有哪些方法。
1 2 3 4 5 6 7 8 9 10 11 static inline void gotoxy (unsigned int new_x, unsigned int new_y) {}static void scrup (void ) {}static void lf (int currcons) {}static void cr (void ) {} ...static void delete_line (void ) {}
第十七回 |
原来操作系统获取时间的方式也这么low
第17回
| 原来操作系统获取时间的方式也这么 low (qq.com)
time_init
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 #define CMOS_READ(addr) ({ \ outb_p(0x80|addr,0x70); \ inb_p(0x71); \ }) #define BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10) static void time_init (void ) { struct tm time ; do { time.tm_sec = CMOS_READ(0 ); time.tm_min = CMOS_READ(2 ); time.tm_hour = CMOS_READ(4 ); time.tm_mday = CMOS_READ(7 ); time.tm_mon = CMOS_READ(8 ); time.tm_year = CMOS_READ(9 ); } while (time.tm_sec != CMOS_READ(0 )); BCD_TO_BIN(time.tm_sec); BCD_TO_BIN(time.tm_min); BCD_TO_BIN(time.tm_hour); BCD_TO_BIN(time.tm_mday); BCD_TO_BIN(time.tm_mon); BCD_TO_BIN(time.tm_year); time.tm_mon--; startup_time = kernel_mktime(&time); }
那主要就是对 CMOS_READ 和
BCD_TO_BIN 都是啥意思展开讲一下就明白了了。
首先是 CMOS_READ
1 2 3 4 #define CMOS_READ(addr ) ({ \ outb_p(0x80|addr ,0x70) ; \ inb_p(0x71) ; \ })
就是对一个端口先 out 写一下,再 in
读一下。
这是 CPU 与外设交互的一个基本玩法,CPU
与外设打交道基本是通过端口,往某些端口写值来表示要这个外设干嘛,然后从另一些端口读值来接受外设的反馈。
至于这个外设内部是怎么实现的,对使用它的操作系统而言,是个黑盒,无需关心。那对于我们程序员来说,就更不用关心了。
对 CMOS 这个外设的交互讲起来可能没感觉,我们看看与硬盘的交互。
最常见的就是读硬盘了,我们看硬盘的端口表。
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
命令寄存器或状态寄存器
命令寄存器
在 0x1F2 写入要读取的扇区数
在 0x1F3 ~ 0x1F6 这四个端口写入计算好的起始 LBA 地址
在 0x1F7 处写入读命令的指令号
不断检测 0x1F7 (此时已成为状态寄存器的含义)的忙位
如果第四步骤为不忙,则开始不断从 0x1F0
处读取数据到内存指定位置,直到读完
CMOS 。它是主板上的一个可读写的 RAM
芯片,你在开机时长按某个键就可以进入设置它的页面。
最后一步 kernel_mktime
也很简单,就是根据刚刚的那些时分秒数据,计算从 1970 年 1 月 1 日
0 时 起到开机当时经过的秒数,作为开机时间,存储在
startup_time 这个变量里。
想研究可以仔细看看这段代码,不过我觉得这种细节不必看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 startup_time = kernel_mktime(&time);long kernel_mktime (struct tm * tm) { long res; int year; year = tm->tm_year - 70 ; res = YEAR*year + DAY*((year+1 )/4 ); res += month[tm->tm_mon]; if (tm->tm_mon>1 && ((year+2 )%4 )) res -= DAY; res += DAY*(tm->tm_mday-1 ); res += HOUR*tm->tm_hour; res += MINUTE*tm->tm_min; res += tm->tm_sec; return res; }
所以今天其实就是,计算出了一个 startup_time
变量而已,至于这个变量今后会被谁用,怎么用,那就是后话了。