操作系统源码学习笔记(六) 字符设备初始化/tty_init

/ 默认分类 / 0 条评论 / 493浏览

上一节我们学习了块设备的初始化工作流程,按照linus的设计,main函数接下来要执行的操作如下:

blk_dev_init();            
chr_dev_init();           
tty_init();

按照方法的定义,很明显chr_dev_init函数就是要来初始化字符设备了。但是我们发现chr_dev_init函数内部没有任何逻辑:

void chr_dev_init(void)
{
}

实际上,真正来初始化字符设备的函数是下面的tty_init。至于为什么这个就只能问linus了,但是如果你自己也是一名程序员,你就会明白,这种问题是可以理解的。

一. tty_init

我们来看下tty_init函数的源码:

void tty_init(void)
{
	rs_init(); //初始化串口,并为串口设置中断程序
	con_init(); //初始化控制台显示,并开启键盘中断     
}

在 Linux 中,TTY(Teletypewriter)是一种用于与用户交互的设备,例如终端窗口或串口终端等。TTY 初始化函数负责初始化系统中的终端设备,以确保它们能够正常工作。

二.rs_init 串口初始化

在现代计算机中,特别是个人计算机(PC)领域,串口(RS-232)的使用已经相对较少了。然而,在某些特定的应用场景中,串口仍然具有一定的重要性,尤其是在嵌入式系统、网络设备、工业控制和通信领域。 尽管大多数个人计算机不再使用串口进行连接外部设备,如打印机或调制解调器,但串口仍然被用于以下一些情况:

  1. 嵌入式系统和嵌入式开发:在嵌入式系统中,串口通常用于与外部设备进行通信,例如与传感器、执行器或其他嵌入式设备进行通信。在嵌入式开发中,串口通常用作调试和通信接口。
  2. 网络设备:一些网络设备,如路由器、交换机和网络服务器,仍然可能使用串口作为控制台接口进行管理和配置。
  3. 工业控制和自动化:在工业控制和自动化领域,串口通常用于连接和控制各种设备,例如传感器、执行器、PLC(可编程逻辑控制器)等。

我们本次源码解析针对用户计算机来看,所以rs_init就先跳过,里面的串口逻辑还是要深入学习嵌入式相关知识才能正确解读,如果大家想了解,可以参考相关书籍。

三.con_init 显示设备初始化/键盘中断开启

这个函数的功能十分重要,先贴一下具体的源码逻辑.大家可以先不用细读,我们下面详细分析下。

/*
 *  void con_init(void);
 *
 * This routine initalizes console interrupts, and does nothing
 * else. If you want the screen to clear, call tty_write with
 * the appropriate escape-sequece.
 *
 * Reads the information preserved by setup.s to determine the current display
 * type and sets everything accordingly.
 */
// 控制台初始化程序。在init/main.c中被调用
// 该函数首先根据setup.s程序取得的系统硬件参数初始化设置几个本函数专用的静态
// 全局变量。然后根据显示卡模式(单色还是彩色显示)和显卡类型(EGA/VGA还是CGA)
// 分别设置显示内存起始位置以及显示索引寄存器和显示数值寄存器端口号。最后设置
// 键盘中断陷阱描述符并复位对键盘中断的屏蔽位,以允许键盘开始工作。
void con_init(void)
{
    // 寄存器变量a为了高效的访问和操作。
    // 若想指定存放的寄存器(如eax),则可以写成:
    // register unsigned char a asm("ax");。
	register unsigned char a;
	char *display_desc = "????";
	char *display_ptr;

    // 首先根据setup.s程序取得系统硬件参数初始化几个本函数专用的静态全局变量。
	video_num_columns = ORIG_VIDEO_COLS;    // 显示器显示字符列数
	video_size_row = video_num_columns * 2; // 每行字符需要使用的字节数
	video_num_lines = ORIG_VIDEO_LINES;     // 显示器显示字符行数
	video_page = ORIG_VIDEO_PAGE;           // 当前显示页面
	video_erase_char = 0x0720;              // 擦除字符(0x20是字符,0x07属性)
	
    // 根据显示模式是单色还是彩色分别设置所使用的显示内存起始位置以及显示寄存器
    // 索引端口号和显示寄存器数据端口号。如果原始显示模式等于7,则表示是单色显示器。
	if (ORIG_VIDEO_MODE == 7)			/* Is this a monochrome display? */
	{
		video_mem_start = 0xb0000;      // 设置单显映象内存起始地址
		video_port_reg = 0x3b4;         // 设置单显索引寄存器端口
		video_port_val = 0x3b5;         // 设置单显数据寄存器端口
        // 接着我们根据BIOS中断int 0x10 功能0x12获得的显示模式信息,判断显示卡是
        // 单色显示卡还是彩色显示卡。若使用上述中断功能所得到的BX寄存器返回值不等于
        // 0x10,则说明是EGA卡。因此初始显示类型为EGA单色。虽然EGA卡上有较多显示内存,
        // 但在单色方式下最多只能利用地址范围在 0xb0000-0xb8000 之间的显示内存。
        // 然后置显示器描述字符串为 'EGAm'. 并会在系统初始化期间显示器描述字符串将
        // 显示在屏幕的右上角。
        // 注意,这里使用了 bx 在调用中断 int 0x10 前后是否被改变的方法来判断卡的类型。
        // 若BL在中断调用后值被改变,表示显示卡支持 Ah=12h 功能调用,是EGA或后推出来的
        // VGA等类型的显示卡。若中断调用返回值未变,表示显示卡不支持这个功能,则说明
        // 是一般单色显示卡。
		if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10)
		{
			video_type = VIDEO_TYPE_EGAM;       // 设置显示类型(EGA单色)
			video_mem_end = 0xb8000;            // 设置显示内存末端地址
			display_desc = "EGAm";              // 设置显示描述字符串
		}
		else    // 如果 BX 寄存器的值等于 0x10,则说明是单色显示卡MDA。
		{
			video_type = VIDEO_TYPE_MDA;        // 设置显示类型(MDA单色)
			video_mem_end	= 0xb2000;          // 设置显示内存末端地址
			display_desc = "*MDA";              // 设置显示描述字符串
		}
	}
    // 如果显示模式不为7,说明是彩色显示卡。此时文本方式下所用的显示内存起始地址为0xb8000;
    // 显示控制索引寄存器端口地址为 0x3d4;数据寄存器端口地址为 0x3d5。
	else								/* If not, it is color. */
	{
		video_mem_start = 0xb8000;              // 显示内存起始地址
		video_port_reg	= 0x3d4;                // 设置彩色显示索引寄存器端口
		video_port_val	= 0x3d5;                // 设置彩色显示数据寄存器端口
        // 再判断显示卡类别。如果 BX 不等于 0x10,则说明是EGA/VGA 显示卡。此时实际上我们
        // 可以使用32KB显示内存(0xb8000 -- 0xc0000),但该程序只使用了其中16KB显示内存。
		if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10)
		{
			video_type = VIDEO_TYPE_EGAC;       // 设置显示类型(EGA彩色)
			video_mem_end = 0xbc000;            // 设置显示内存末端地址
			display_desc = "EGAc";              // 设置显示描述字符串
		}
		else    // 如果 BX 寄存器的值等于 0x10,则说明是CGA显示卡,只使用8KB显示内存
		{
			video_type = VIDEO_TYPE_CGA;        // 设置显示类型(CGA彩色)
			video_mem_end = 0xba000;            // 设置显示内存末端地址
			display_desc = "*CGA";              // 设置显示描述字符串
		}
	}

	/* Let the user known what kind of display driver we are using */

    // 然后我们在屏幕的右上角显示描述字符串。采用的方法是直接将字符串写到显示内存
    // 相应位置处。首先将显示指针display_ptr 指到屏幕第1行右端差4个字符处(每个字符
    // 需2个字节,因此减8),然后循环复制字符串的字符,并且每复制1个字符都空开1个属性字节。
	display_ptr = ((char *)video_mem_start) + video_size_row - 8;
	while (*display_desc)
	{
		*display_ptr++ = *display_desc++;
		display_ptr++;                      // 空开属性字节
	}
	
	/* Initialize the variables used for scrolling (mostly EGA/VGA)	*/
	
	origin	= video_mem_start;              // 滚屏起始显示内存地址
	scr_end	= video_mem_start + video_num_lines * video_size_row;   // 结束地址
	top	= 0;                                // 最顶行号
	bottom	= video_num_lines;              // 最底行号

    // 最后初始化当前光标所在位置和光标对应的内存位置pos,并设置键盘中断0x21陷阱门
    // 描述符,&keyboard_interrupt是键盘中断处理过程地址。取消8259A中对键盘中断的
    // 屏蔽,允许响应键盘发出的IRQ1请求信号。最后复位键盘控制器以允许键盘开始正常工作。
	gotoxy(ORIG_X,ORIG_Y);
	set_trap_gate(0x21,&keyboard_interrupt);
	outb_p(inb_p(0x21)&0xfd,0x21);          // 取消对键盘中断的屏蔽,允许IRQ1。
	a=inb_p(0x61);                          // 读取键盘端口0x61(8255A端口PB)
	outb_p(a|0x80,0x61);                    // 设置禁止键盘工作(位7置位)
	outb(a,0x61);                           // 再允许键盘工作,用以复位键盘
}

这段程序前面有很多ifelse分之,之所以是这样,是因为在初始化显示设备的时候,因为显卡有不同的属性,比如单色和彩色。所以需要针对不同的设备情况进行不一样的初始化工作,也是为了应对不同的显示模式,来分配不同的变量值。 但是函数整体的函数功能是清晰的,我们先来简化一下整个函数的逻辑,写成伪代码的逻辑:

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

小小知识点: 这里我们先来看一个小问题,一个字符是如何显示在屏幕上的呢?换句话说,如果你可以随意操作内存和 CPU 等设备,你如何操作才能使得你的显示器上,显示一个字符‘a’呢? image.png 内存中有这样一部分区域,是和显存映射的。如果往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。 如果我们写这一行汇编语句。 mov [0xB8000],'h' 编译器拿到的就是ascll码 mov [0xB8000],0x68 那么屏幕就会显示h image.png 如果我们写多个 mov [0xB8000],'h' mov [0xB8002],'e' mov [0xB8004],'l' mov [0xB8006],'l' mov [0xB8008],'o' 那么屏幕就会显示: image.png

所以前面代码的第一部分和第二部分,显然就是先获取当前显卡的显示模式等信息,然后就可以知道对应的显存映射的内存区域。第三部分是设置一些滚动屏幕时需要的参数,定义屏幕最上面的行和最底下的行是哪里。第四部分是把光标定位到之前保存的光标位置处(取内存地址 0x90000 处的数据),然后设置并开启键盘中断。

这样一来,开启键盘中断后,键盘上敲击一个按键后就会触发中断,中断程序就会读键盘码转换成 ASCII 码,然后写到光标处的内存地址,也就相当于往显存写,于是这个键盘敲击的字符就显示在了屏幕上。,cpu就可以处理键盘的中断请求了,比如我们输入一些数据,或者上下左右移动光标等等。

四.gotoxy/控制台界面到底是如何展示数据和移动光标的?

经过上面的处理之后,我们有没有想过在代码层面,到底是如何处理数据展示和光标定位和移动的呢?

在con_init中的第四部分,其实就定义了到底是如何操作的。下面我们来详细看下。 先来看下源码:

/* NOTE! gotoxy thinks x==video_num_columns is ok */
// 跟踪光标当前位置。
// 参数:new_x - 光标所在列号;new_y - 光标所在行号。
// 更新当前光标位置变量x,y,并修正光标在显示内存中的对应位置pos.
static inline void gotoxy(unsigned int new_x,unsigned int new_y)
{
    // 首先检查参数的有效性。如果给定的光标列号超出显示器列数,
    // 或者光标行号不低于显示的最大行数,则退出。否则就更新当前
    // 光标变量和新光标位置对应在显示内存中位置pos.
	if (new_x > video_num_columns || new_y >= video_num_lines)
		return;
	x=new_x;
	y=new_y;
	pos=origin + y*video_size_row + (x<<1);     // 1列用2个字节表示,x<<1.
}

上面其实就是给 x y pos 这三个参数附上了值。其中 x 表示光标在哪一列,y 表示光标在哪一行,pos 表示根据列号和行号计算出来的内存指针,也就是说往这个 pos 指向的地址处写数据,就相当于往显示器展示的控制台的 x 列 y 行处写入字符了(这内部就是显卡自己控制的像素展示了)。

当我们按下键盘的一个字母c后,触发键盘中断,之后cpu执行的程序调用链是这样的:

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

主要看最后执行的con_write,asm 内联汇编,就是把键盘输入的字符 c 写入 pos 指针指向的内存,相当于往屏幕输出展示了c。 之后两行 pos+=2 和 x++,就是调整所谓的光标。pos指向的地址加2,因为我们写了一个c,占用了内存,内存需要移动。x表示列,c占了一列,所以也需要移动光标。

所以写入一个字符,最底层,其实就是往内存的某处写个数据,然后顺便调整一下光标(x,y,pos)。由此我们也可以看出,光标的本质,其实就是这里的 x,y,pos 这三个变量而已。我们还可以做换行效果,当发现光标位置处于某一行的结尾时(这个很好计算,已经知道屏幕上一共有几行几列了),就把光标计算出一个新值,让其处于下一行的开头。 实现滚屏效果的核心思想是,当光标移动到最后一行最后一列时,需要将每一行的字符复制到其上一行,从而实现文字向上滚动的效果。实际上,这就是在计算哪些内存地址上的值需要被复制到哪些内存地址上,然后执行复制操作即可。

五.总结

我们平时习以为常的控制台,回车换行、删除、滚屏、清屏等操作,其实底层都要实现相应的代码的。而操作系统中console.c 中的一些函数就是显现上述功能的。