你管这叫操作系统源码(五)

你管这叫操作系统源码之五

控制台初始化tty_init

按下键盘后为什么屏幕上就会有输出?

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();
}

执行了tty_init()后,我们将会具备键盘输入到显示器输出字符这个最常用的功能。打开这个函数:

void tty_init(void)
{
    rs_init();
    con_init();
}

看来这个方法已经多到需要拆成两个子方法了。

打开第一个方法:

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 机上已经很少用到了。

看第二个方法,这是重点。代码非常长,有点吓人,我先把大体框架写出:

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 {...}
    }
    ...
}

非常多的 if else。这是为了应对不同的显示模式,来分配不同的变量值,那如果我们仅仅找出一个显示模式,这些分支就可以只看一个了

啥是显示模式呢?那我们得简单说说显示,一个字符是如何显示在屏幕上的呢?换句话说,如果你可以随意操作内存和 CPU 等设备,你如何操作才能使得你的显示器上,显示一个字符‘a’呢?

我们先看一张图:
ch12-1
内存中有这样一部分区域,是和显存映射的。啥意思,就是你往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。如果我们写一行汇编语句:

mov [0xB8000],'h'

后面那个 h 相当于汇编编辑器帮我们转换成 ASCII 码的二进制数值,当然我们也可以直接写:

mov [0xB8000],0x68

其实就是往内存中 0xB8000 这个位置写了一个值,只要一写,屏幕上就会是这样
ch12-2
具体说来,这片内存是每两个字节表示一个显示在屏幕上的字符,第一个是字符的编码,第二个是字符的颜色,那我们先不管颜色,如果多写几个字符就像这样

mov [0xB8000],'h'
mov [0xB8002],'e'
mov [0xB8004],'l'
mov [0xB8006],'l'
mov [0xB8008],'o'

此时屏幕上就会是这样
ch12-3
回过头看刚刚的代码,我们就假设显示模式是我们现在的这种文本模式,那条件分支就可以去掉好多。代码可以简化成这个样子

#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);
}

之前汇编语言的时候做的工作,存了好多以后要用的数据在内存中,就在系列之二中保护模式前的最后一次折腾内存

内存地址长度(字节)名称
0x900002光标位置
0x900022扩展内存数
0x900042显示页面
0x900061显示模式
0x900071字符列数
0x900082未知
0x9000A1显示内存
0x9000B1显示状态
0x9000C2显卡特性参数
0x9000E1屏幕行数
0x9000F1屏幕列数
0x9008016硬盘1参数表
0x9009016硬盘2参数表
0x901FC2根设备号

第一部分获取 0x90006 地址处的数据,就是获取显示模式等相关信息。
第二部分就是显存映射的内存地址范围,我们现在假设是 CGA 类型的文本模式,所以映射的内存是从 0xB8000 到 0xBA000。
第三部分是设置一些滚动屏幕时需要的参数,定义顶行和底行是哪里,这里顶行就是第一行,底行就是最后一行,很合理。
第四部分是把光标定位到之前保存的光标位置处(取内存地址 0x90000 处的数据),然后设置并开启键盘中断。

开启键盘中断后,键盘上敲击一个按键后就会触发中断,中断程序就会读键盘码转换成 ASCII 码,然后写到光标处的内存地址,也就相当于往显存写,于是这个键盘敲击的字符就显示在了屏幕上
ch12-1.gif
这一切具体是怎么做到的呢?我们先看看我们干了什么。

1. 我们现在根据已有信息已经可以实现往屏幕上的任意位置写字符了,而且还能指定颜色。

2. 并且,我们也能接受键盘中断,根据键盘码中断处理程序就可以得知哪个键按下了。

好,接下来我们看看代码是怎么处理的,很简单。一切的起点,就是第四步的 gotoxy 函数,定位当前光标。

#define ORIG_X          (*(unsigned char *)0x90000)
#define ORIG_Y          (*(unsigned char *)0x90001)
void con_init(void) {
    ...
    // 第四部分 定位光标并开启键盘中断
    gotoxy(ORIG_X, ORIG_Y);
    ...
}

这里面干嘛了呢?

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 这三个参数附上了值。其中 x 表示光标在哪一列,y 表示光标在哪一行,pos 表示根据列号和行号计算出来的内存指针,也就是往这个 pos 指向的地址处写数据,就相当于往控制台的 x 列 y 行处写入字符了?

然后,当你按下键盘后,触发键盘中断,之后的程序调用链是这样的

_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++;
    ...
}

前面的过程不用管,我们看最后一个函数 con_write 中的关键代码。

__asm__ 内联汇编,就是把键盘输入的字符 c 写入 pos 指针指向的内存,相当于往屏幕输出了。之后两行 pos+=2x++,就是调整所谓的光标

你看,写入一个字符,最底层,其实就是往内存的某处写个数据,然后顺便调整一下光标。由此我们也可以看出,光标的本质,其实就是这里的 x y pos 这仨变量而已。

我们还可以做换行效果,当发现光标位置处于某一行的结尾时(这个应该很好算吧,我们都知道屏幕上一共有几行几列了),就把光标计算出一个新值,让其处于下一行的开头
ch12-2.gif
就一个小计算公式即可搞定,仍然在 con_write 源码处有体现,就是判断列号 x 是否大于了总列数

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 中的其他方法就是做这个事的,我们就不展开每一个功能的方法体了,简单看看有哪些方法

// 定位光标的
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){}

OK,整个 console.c就讲完了,要知道这个文件可是整个内核中代码量最大的文件,可是功能特别单一,也都很简单,主要是处理键盘各种不同的按键,需要写好多 switch case 等语句,十分麻烦,我们这里就完全没必要去展开了,就是个苦力活。

到这里,我们就正式讲完了 tty_init 的作用。在此之后,内核代码就可以用它来方便地在控制台输出字符啦!这在之后内核想要在启动过程中告诉用户一些信息,以及后面内核完全建立起来之后,由用户用 shell 进行操作时手动输入命令,都是可以用到这里的代码的!

时间初始化time_init

操作系统是怎么获取到当前时间的?当然,现在都联网了,可以从网络上实时同步。那当没有网络时,为什么操作系统在启动之后,可以显示出当前时间呢?难道操作系统在电脑关机后,依然不停地在某处运行着,勤勤恳恳数着秒表么?

当然不是。继续往下看下一个初始化:time_init

#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_READBCD_TO_BIN

首先,CMOS_READ

#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命令寄存器或状态寄存器命令寄存器寄存器

那读硬盘就是,往除了第一个以外的后面几个端口写数据,告诉要读硬盘的哪个扇区,读多少。然后再从 0x1F0 端口一个字节一个字节的读数据。这就完成了一次硬盘读操作.

具体的:

  1. 在 0x1F2 写入要读取的扇区数
  2. 在 0x1F3 ~ 0x1F6 这四个端口写入计算好的起始 LBA 地址
  3. 在 0x1F7 处写入读命令的指令号
  4. 不断检测 0x1F7 (此时已成为状态寄存器的含义)的忙位
  5. 如果第四步骤为不忙,则开始不断从 0x1F0 处读取数据到内存指定位置,直到读完

看,是不是对 CPU 最底层是如何与外设打交道有点感觉了?是不是也不难?就是按照人家的操作手册,然后无脑按照要求读写端口就行了。

当然,读取硬盘的这个无脑循环,可以 CPU 直接读取并做写入内存的操作,这样就会占用 CPU 的计算资源。也可以交给 DMA 设备去读,解放 CPU,但和硬盘的交互,通通都是按照硬件手册上的端口说明来操作的,实际上也是做了一层封装。

好了,我们已经学会了和一个外设打交道的基本玩法了。那我们代码中要打交道的是哪个外设呢?就是 CMOS。它是主板上的一个可读写的 RAM 芯片,你在开机时长按某个键就可以进入设置它的页面
ch12-4
那我们的代码,其实就是与它打交道,获取它的一些数据而已。我们回过头看代码

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 就是通过读写 CMOS 上的指定端口,依次获取年月日时分秒等信息。具体咋操作代码上也写了,也是按照 CMOS 手册要求的读写指定端口就行了,我们就不展开了。

所以你看,其实操作系统程序,也是要依靠与一个外部设备打交道,来获取这些信息的,并不是它自己有什么魔力。操作系统最大的魅力,就在于它借力完成了一项伟大的事,借 CPU 的力,借硬盘的力,借内存的力,以及现在借 CMOS 的力。至于 CMOS 又是如何知道时间的,这个就不在我们讨论范围了。

接下来 BCD_TO_BIN 就是 BCD 转换成 BIN,因为从 CMOS 上获取的这些年月日都是 BCD 码值,需要转换成存储在我们变量上的二进制数值,所以需要一个小算法来转换一下,没什么意思。

最后一步 kernel_mktime 也很简单,就是根据刚刚的那些时分秒数据,计算从 1970 年 1 月 1 日 0 时起到开机当时经过的秒数,作为开机时间,存储在 startup_time 这个变量里。

想研究可以仔细看看这段代码,不过我觉得这种细节不必看

startup_time = kernel_mktime(&time);

// kernel/mktime.c
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 变量而已,至于这个变量今后会被谁用,怎么用,那就是后话了。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值