从零编写linux0.11 - 第三章 printk函数

编程环境:Ubuntu Kylin 16.04、gcc-5.4.0

代码仓库:https://gitee.com/AprilSloan/linux0.11-project

linux0.11源码下载(不能直接编译,需进行修改)

本章目标

编写printk函数,实现它的功能。

printk函数的功能与printf相同,区别在于printk在内核态被使用,printf在用户态被使用。首先printk要把格式化字符串转换成普通字符串,如"%s"转换成它指向的字符串,把"%d"转换成数字字符串等等。接着要把字符串显示在屏幕上,这里采用写显存的方式完成。显存是内存中的一块地址空间,在这里写入可打印字符会在屏幕上显示相应的字符。

1.获取机器系统参数

在写显存之前,我们要获取一些相关参数,如显存起始地址,显存大小,显存状态(彩色/单色),特性参数等等,在setup.s中完成这个操作。一些其他的参数以后可能会用到,为了避免反复修改setup.s,也就一起获取吧。

下面是setup.s的部分代码:

INITSEG     equ 0x9000
SYSSEG      equ 0x1000
SETUPSEG    equ 0x9020

start:
    mov ax, INITSEG
    mov ds, ax
    mov ah, 0x03
    xor bh, bh
    int 0x10
    mov [0], dx         ; 光标位置

    mov ah, 0x88
    int 0x15
    mov [2], ax         ; 扩展内存数

    mov ah, 0x0f
    int 0x10
    mov [4], bx         ; 显示页面
    mov [6], ax         ; 显示模式、字符列数

    mov ah, 0x12
    mov bl, 0x10
    int 0x10
    mov [8], ax         ; ??
    mov [10], bx        ; 显示内存、显示状态
    mov [12], cx        ; 特性参数

    mov ax, 0x0000
    mov ds, ax
    lds si, [4 * 0x41]  ; 取中断向量0x41的值,即hd0参数表的地址->ds:si
    mov ax, INITSEG
    mov es, ax
    mov di, 0x0080      ; 传输的目的地址es:di(0x9000:0x0080)
    mov cx, 0x10        ; 共传输0x10字节
    rep
    movsb

    mov ax, 0x0000
    mov ds, ax
    lds si, [4 * 0x46]  ; 取中断向量0x46的值,即hd1参数表的地址->ds:si
    mov ax, INITSEG
    mov es, ax
    mov di, 0x0090      ; 传输的目的地址es:di(0x9000:0x0090)
    mov cx, 0x10
    rep
    movsb

; 检查是否有第二个硬盘,不存在就把第二个表清零
    mov ax, 0x1500
    mov dl, 0x81
    int 0x13
    jc  no_disk1
    cmp ah, 3
    je  is_disk1
no_disk1:
    mov ax, INITSEG
    mov es, ax
    mov di, 0x0090
    mov cx, 0x10
    mov ax, 0x00
    rep
    stosb
is_disk1:
    cli                 ; 保护模式下中断机制尚未建立,应禁止中断
    mov ax, 0x00
    cld
do_move:                ; 将内核从0x10000移动到0x00

获得光标位置等BIOS中断可以看BIOS接口技术参考手册,这段程序首先改变了数据段寄存器的值,获取的参数保存在0x90000开始的地址。这里着重讲一下检查硬盘的操作。

int_0x13_ah_0x15

BIOS中断后,如果CF=1(读取出错)或AH!=3(不是硬盘),则清零第二张硬盘表。BIOS接口技术参考手册中有两个INT 0x13的章节,一个是软盘的,一个是硬盘的,上图是硬盘的内容,请不要搞错了。

读取的参数和保留的内存位置如下图。

参数及其内存位置

之后我们会用C语言指针访问这些地址,使用这些参数。

2.初始化屏幕相关变量

获得了显存起始地址其实就可以开始写字符了,但这样处理还是太粗糙了,无法完成字符打印时光标的移动处理,屏幕滚动,字符显示模式变更等操作。为了实现上述功能,这节就来使用上节的参数完成屏幕的初始化。

以下是console.c的内容(这里面定义的全局变量确实有点多啊)。

#include <linux/tty.h>

#define ORIG_X			    (*(unsigned char *)0x90000)     // 光标列号 
#define ORIG_Y			    (*(unsigned char *)0x90001)     // 光标行号
#define ORIG_VIDEO_PAGE		(*(unsigned short *)0x90004)    // 当前页号
#define ORIG_VIDEO_MODE		(*(unsigned char *)0x90006)     // 显示模式
#define ORIG_VIDEO_COLS 	(*(unsigned char *)0x90007)     // 显示列数
#define ORIG_VIDEO_LINES	(25)                            // 显示行数
#define ORIG_VIDEO_EGA_AX	(*(unsigned short *)0x90008)    // [??]
#define ORIG_VIDEO_EGA_BX	(*(unsigned short *)0x9000a)    // 显示内存和色彩模式
#define ORIG_VIDEO_EGA_CX	(*(unsigned short *)0x9000c)    // 显示卡特性参数

// 定义显示器单色/彩色显示模式类型符号常数
#define VIDEO_TYPE_MDA		0x10	// 单色文本显示
#define VIDEO_TYPE_CGA		0x11	// CGA显示
#define VIDEO_TYPE_EGAM		0x20	// EGA/VGA单色模式
#define VIDEO_TYPE_EGAC		0x21	// EGA/VGA彩色模式

static unsigned char	video_type;		    // 显示模式
static unsigned long	video_num_columns;	// 显示列数
static unsigned long	video_size_row;		// 每行字节数
static unsigned long	video_num_lines;	// 显示行数
static unsigned char	video_page;		    // 初始页面
static unsigned long	video_mem_start;	// 显存起始地址
static unsigned long	video_mem_end;		// 显存结束地址
static unsigned short	video_port_reg;		// 显示控制器索引寄存器端口
static unsigned short	video_port_val;		// 显示控制器数据寄存器端口
static unsigned short	video_erase_char;	// 擦除字符属性与字符(0x0720)

// 以下这些变量用于屏幕卷屏操作
static unsigned long	origin;		// 用于EGA/VGA快速滚屏
static unsigned long	scr_end;	// 用于EGA/VGA快速滚屏
static unsigned long	pos;        // 当前光标对应的显存地址
static unsigned long	x, y;       // 当前光标位置
static unsigned long	top, bottom;// 滚动时顶行行号,底行行号

static unsigned char    attr = 0x07;// 字符属性(黑底白字)

static inline void gotoxy(unsigned int new_x, unsigned int new_y)
{
	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);
}

void con_init(void)
{
	register unsigned char a;
	char *display_desc = "????";
	char *display_ptr;

	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是属性)
	
	if (ORIG_VIDEO_MODE == 7)			    // 如果为单色显示
	{
		video_mem_start = 0xb0000;
		video_port_reg = 0x3b4;
		video_port_val = 0x3b5;
		if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10)
		{
			video_type = VIDEO_TYPE_EGAM;
			video_mem_end = 0xb8000;
			display_desc = "EGAm";
		}
		else
		{
			video_type = VIDEO_TYPE_MDA;
			video_mem_end	= 0xb2000;
			display_desc = "*MDA";
		}
	}
	else								    // 如果不是,就是彩色显示
	{
		video_mem_start = 0xb8000;
		video_port_reg	= 0x3d4;
		video_port_val	= 0x3d5;
		if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10)
		{
			video_type = VIDEO_TYPE_EGAC;
			video_mem_end = 0xbc000;
			display_desc = "EGAc";
		}
		else
		{
			video_type = VIDEO_TYPE_CGA;
			video_mem_end = 0xba000;
			display_desc = "*CGA";
		}
	}

	// 打印正在使用的显示驱动程序
	display_ptr = ((char *)video_mem_start) + video_size_row - 8;
	while (*display_desc)
	{
		*display_ptr++ = *display_desc++;
		display_ptr++;
	}
	
	// 初始化用于滚屏的变量
	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);
}

第3-11行用将机器系统参数地址设置为宏定义,方便阅读和修改。

第19-28行定义了一些与屏幕显示相关的参数,在初始化完成后就不再修改。

第31-35行定义了一些卷屏相关的参数。虽然屏幕只显示25行的字符,但有时我们会在显存中保存超过25行的屏幕字符信息(当top!=0或bottom!=video_num_lines时就不保存这些信息)。

为了更好地解释之后的内容,以我的模拟器得到的参数为例进行讲解。

机器系统参数

光标列号为0,行号为20(0x14),当前页面为0,列数80(0x50,一行显示80个字符),显示内存256k,彩色显示(对照上一节图表)。

第55行video_size_row = video_num_columns * 2;的意思是显存每行有160个字符,不是每行只显示80个字符吗?这160个字符有一半是要显示的字符,另一半是字符的显示模式。比如,想在第1行第1列黑底白字显示字符A,需要在0xb8000地址写数据0x41,在0xb8001地址写数据0x07,0xb8001地址的数据就是字符A的显示模式,0x07代表黑底白字,可以通过更改这个值设置白底红字、红底蓝字等。所以,虽然每行显存中每行有160个字符,但只显示80个字符。

那第58行video_erase_char = 0x0720;这个擦除字符是什么意思?0x20是要显示的字符,0x20对应的字符是空格,0x07对应黑底白字显示方式,以黑底白色的方式显示空格那就是一个黑色块,当它覆盖其他字符时看起来就像是被擦除了一样。

第60-95行初始化相关变量。已知机器采用彩色显示,则显存起始地址为0xb8000,已知0x9000B地址的值为0,则显存结束地址为0xbc000,显存大小为16k而不是256k,这是为什么?

第98-103行会在屏幕打印EGAc,简单粗暴地直接写显存,猜一猜打印的位置。

第106-109行初始化卷屏相关变量,我们会在本章第4节用到这些变量。

最后我们使用gotoxy函数更新当前光标对应的显存地址。首先要检查光标的位置是否合法,通过计算得到位置并存储在pos变量中。

接着来完善其他文件。首先是tty_io.c。

#include <linux/tty.h>

void tty_init(void)
{
	con_init();
}

目前,这个文件用处不大,但如果把用处不大的文件删掉,将它们的内容移动到其他文件中,之后为了和原版的linux0.11保持一致,需要不断修改整理代码,这很麻烦,不如就这样了。

添加一个叫tty.h的库文件,在里面放入函数声明。

#ifndef _TTY_H_
#define _TTY_H_

void con_init(void);
void tty_init(void);

#endif

这个库文件之后还会添加关于串口的内容。

稍稍修改一下main.c。

#include <linux/tty.h>

#define PAGE_SIZE   4096

long user_stack[PAGE_SIZE >> 2];

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

void main(void)
{
	tty_init();
	while (1);
}

在main.c中包含tty.h,因为会在main函数中使用tty_init()。

还需要再kernel/chr_drv目录下创建一个Makefile文件,将这个目录下的目标文件打包成静态库。

AR	=ar
AS	=as --32
LD	=ld
LDFLAGS	=-s -x
CC	=gcc
CFLAGS	=-Wall -O -m32 -fstrength-reduce -fno-stack-protector -fomit-frame-pointer -finline-functions -nostdinc -I../../include
CPP	=gcc -E -nostdinc -I../../include

.c.s:
	$(CC) $(CFLAGS) \
	-S -o $*.s $<
.s.o:
	$(AS) -c -o $*.o $<
.c.o:
	$(CC) $(CFLAGS) \
	-c -o $*.o $<

OBJS  = tty_io.o console.o

chr_drv.a: $(OBJS)
	$(AR) rcs chr_drv.a $(OBJS)
	sync

clean:
	rm -f core *.o *.a tmp_make keyboard.s
	for i in *.c;do rm -f `basename $$i .c`.s;done

console.s console.o : console.c
tty_io.s tty_io.o : tty_io.c

kernel目录下也需要一个Makefile,现在这个目录下没有文件,但需要添加clean规则,执行chr_drv内的clean规则。

AR	=ar
AS	=as --32
LD	=ld -m elf_i386
LDFLAGS	=-s -x
CC	=gcc -march=i386
CFLAGS	=-Wall -O -m32 -fstrength-reduce -fno-stack-protector -fomit-frame-pointer -finline-functions -nostdinc -I../include
CPP	=gcc -E -nostdinc -I../include

.c.s:
	$(CC) $(CFLAGS) \
	-S -o $*.s $<
.s.o:
	$(AS) -o $*.o $<
.c.o:
	$(CC) $(CFLAGS) \
	-c -o $*.o $<

OBJS  =

clean:
	rm -f *.o *.a
	for i in *.c;do rm -f `basename $$i .c`.s;done
	(cd chr_drv; make clean)

最后的最后,我们还得改主目录下的Makefile,将kernel/chr_drv中的内容编译到system中。

AS	=as --32
LD	=ld
LDFLAGS	=-m elf_i386
CC	=gcc -march=i386
CFLAGS	=-Wall -O2 -m32 -fomit-frame-pointer -fno-stack-protector

default: all

DRIVERS =kernel/chr_drv/chr_drv.a

.c.s:
	$(CC) $(CFLAGS) \
	-nostdinc -Iinclude -S -o $*.s $<
.s.o:
	$(AS) -c -o $*.o $<
.c.o:
	$(CC) $(CFLAGS) \
	-nostdinc -Iinclude -c -o $*.o $<

all: Image

Image: clean mkimg boot/bootsect.bin boot/setup.bin system
	objcopy -O binary -R .note -R .comment system kernel.bin
	dd if=boot/bootsect.bin of=kernel.img bs=512 count=1 conv=notrunc
	dd if=boot/setup.bin of=kernel.img bs=512 count=4 seek=1 conv=notrunc
	dd if=kernel.bin of=kernel.img bs=512 count=384 seek=5 conv=notrunc
	rm kernel.bin -f
	bochs -qf bochsrc

boot/head.o: boot/head.s
	gcc -m32 -traditional -c boot/head.s -o boot/head.o

system:	boot/head.o init/main.o $(DRIVERS)
	$(LD) $(LDFLAGS) boot/head.o init/main.o \
	$(DRIVERS)	\
	-o system -T kernel.lds
	nm system | grep -v '\(compiled\)\|\(\.o$$\)\|\( [aU] \)\|\(\.\.ng$$\)\|\(LASH[RL]DI\)'| sort > System.map 

kernel/chr_drv/chr_drv.a:
	(cd kernel/chr_drv; make)

boot/bootsect.bin: boot/bootsect.s
	nasm boot/bootsect.s -o boot/bootsect.bin

boot/setup.bin: boot/setup.s
	nasm boot/setup.s -o boot/setup.bin

mkimg:
	./mkimg.sh

clean:
	rm -rf boot/*.bin boot/*.o init/*.o System.map system kernel.img
	(cd kernel;make clean)

init/main.o: init/main.c

好了,来运行一下程序吧。

3.2运行结果

可以看到EGAc被打印在右上角。

3.简单的printk函数1

想要通过显存在屏幕上打印字符串,我们需要知道几个信息:打印的位置,显示模式,字符串地址和长度。当然,这些信息并不难获取。上一节中,我们通过gotoxy函数将光标当前位置保存在pos变量中,显示模式就直接指定为黑底白字,字符串地址可以当作参数传入函数,长度也很好计算。

以下是添加到console.c的代码,con_write的参数buf是字符串地址,nr是字符串长度。

void con_write(char *buf, int nr)
{
	int i;
	char c;

	for (i = 0; i < nr; i++) {
		c = buf[i];
		if (c > 31 && c < 127) {
			__asm__(
				"movb %1, %%ah\n\t"
				"movw %%ax, %2\n\t"
				::"a"(c), "m"(attr), "m"(*(short *)pos));
			pos += 2;
			x++;
		}
	}
}

这段代码会将可打印字符(数字、字母和符号)打印在屏幕上,对于不可打印字符(换行、退格等)之后再进行处理。这里使用内联汇编将字符和显示模式写入显存中(内联汇编相关的知识可以看这篇博客:https://www.cnblogs.com/taek/archive/2012/02/05/2338838.html),这段代码把字符写入al寄存器中,把显示模式attr写入ah寄存器中,再把ax写入pos指向的地址中。最后更新pos和x(光标列号)的值。

现在,你就可以用con_write("Hello World!", 12);这么一行代码打印字符串。但是,你肯定不想每一次打印都要自己数一次字符串长度吧。另外,这个函数也不能格式化输出,比如printf就可以通过%d打印数字,%s打印字符串。那就让我们再添加一些代码吧。

首先是在tty_io.c添加一个函数。

int tty_write(char *buf, int nr)
{
	char c;

	if (nr < 0)
		return -1;

	con_write(buf, nr);
}

这个函数目前没有多大的意义,只是为了和原版的linux0.11保持一致。

接着是printk.c。

#include <stdarg.h>

#include <linux/kernel.h>

static char buf[1024];

extern int vsprintf(char * buf, const char * fmt, va_list args);

int printk(const char *fmt, ...)
{
	va_list args;
	int i;

	va_start(args, fmt);
	i = vsprintf(buf, fmt, args);
	va_end(args);
	return tty_write(buf, i);
}

函数参数使用了可变参数,即参数的数量和类型不是固定的,更多的内容还请自行百度。重中之重是vsprintf函数,举个例子理解vsprintf的用法,我有printk("%s World!", "Hello");这么一段代码,此时,fmt内的字符串是"%s World!",args内的参数是"Hello",经过vsprintf处理后,buf内的字符串会变成"Hello World!",并返回字符串的长度。printk的返回值是打印字符串的长度。

vsprintf函数只是单纯的字符串处理函数,与操作系统没有多大的关系,这里就不展开说明,具体的代码放在vsprintf.c中,有兴趣的话可以自行阅读代码。

printk.c中要使用stdarg.h,vsprintf中要使用string.h,需要在include目录下添加这些文件(具体内容请看我的码云仓库)。本来是可以直接复制linux0.11的string.h的,但是可能是因为gcc版本不同,我的代码使用linux0.11的strlen函数会出现一些错误,所以就自己写了一个简单的strlen函数。

还要在tty.h中添加函数声明。

#ifndef _TTY_H_
#define _TTY_H_

void con_init(void);
void tty_init(void);

int tty_write(char *buf, int nr);

void con_write(char *buf, int nr);

#endif

另外再创建一个新文件kernel.h,这个文件只是存放一些函数声明,也是为了与linux0.11保持一致而添加的文件。

int printk(const char * fmt, ...);
int tty_write(char *buf, int nr);

虽然我也想快点改main.c,然后运行程序,但我们还是得改改Makefile,将添加的文件编译到内核中。

先修改kernel目录下的Makefile。

AR	=ar
AS	=as --32
LD	=ld -m elf_i386
LDFLAGS	=-s -x
CC	=gcc -march=i386
CFLAGS	=-Wall -O -m32 -fstrength-reduce -fno-stack-protector -fomit-frame-pointer -finline-functions -nostdinc -I../include
CPP	=gcc -E -nostdinc -I../include

.c.s:
	$(CC) $(CFLAGS) \
	-S -o $*.s $<
.s.o:
	$(AS) -o $*.o $<
.c.o:
	$(CC) $(CFLAGS) \
	-c -o $*.o $<

OBJS  =printk.o vsprintf.o

kernel.o: $(OBJS)
	$(LD) -r -o kernel.o $(OBJS)
	sync

clean:
	rm -f *.o *.a
	for i in *.c;do rm -f `basename $$i .c`.s;done
	(cd chr_drv; make clean)

printk.s printk.o : printk.c
vsprintf.s vsprintf.o : vsprintf.c

再修改主目录下的Makefile。

ARCHIVES=kernel/kernel.o

system:	boot/head.o init/main.o $(ARCHIVES) $(DRIVERS)
	$(LD) $(LDFLAGS) boot/head.o init/main.o \
	$(ARCHIVES)	\
	$(DRIVERS)	\
	-o system -T kernel.lds
	nm system | grep -v '\(compiled\)\|\(\.o$$\)\|\( [aU] \)\|\(\.\.ng$$\)\|\(LASH[RL]DI\)'| sort > System.map 

kernel/kernel.o:
	(cd kernel; make)

主目录的Makefile内容过多,这里只给出与kernel目录相关的部分。

最后,修改main.c,然后终于可以编译了。

#include <linux/tty.h>

extern int printk(const char *fmt, ...);

#define PAGE_SIZE   4096

long user_stack[PAGE_SIZE >> 2];

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

void main(void)
{
	tty_init();
	printk("%s World!", "Hello");
	while (1);
}

printk要放在tty_init函数之后。预期的结果就是打印出Hello World。事不宜迟,马上编译让程序跑起来。

3.3运行结果

运行成功!可是,从图片中就可以看出,光标并没有随字符打印而移动,另外,如果字符数量过多也会出现错误,比如,我要打印下面这段文字。

For hardware functions such as input and output and memory allocation, the operating system acts as an intermediary between programs and the computer hardware,[1][2] although the application code is usually executed directly by the hardware and frequently makes system calls to an OS function or is interrupted by it. Operating systems are found on many devices that contain a computer – from cellular phones and video game consoles to web servers and supercomputers.

运行结果如下:

3.3运行结果2

上面的字符串共470个字符,屏幕每行显示80个字符,所以应该会显示6行,而这里只显示了5行,有一部分内容并没有显示出来,这也需要改进。

不过,改进的内容都放在下一节进行吧。

4.简单的printk函数2

这一节要改进上一节的printk函数,为其添加光标移动和滚屏的功能。

先修改console.c的con_write函数,给它添加换行和光标随字符串移动的功能。

void con_write(char *buf, int nr)
{
	int i;
	char c;

	for (i = 0; i < nr; i++) {
		c = buf[i];
		if (c > 31 && c < 127) {
			if (x >= video_num_columns) {	// 打印完一行后换行
				x -= video_num_columns;
				pos -= video_size_row;
				lf();
			}
			__asm__(
				"movb %1,%%ah\n\t"
				"movw %%ax,%2\n\t"
				::"a" (c),"m"(attr),"m" (*(short *)pos));
			pos += 2;
			x++;
		}
	}
	set_cursor();	// 打印完字符串后,重新设置光标
}

之前打印字符串时,我们都是让x不断自增,这显然是不合理的,现在打印完一行后,让x归零,让pos回到行首位置,然后调用lf函数进行换行,当打印完屏幕的最后一行后还需要进行滚屏操作,腾出空行。

打印完字符串之后,需要重新设置光标,调用set_cursor函数。

static inline void set_cursor(void)
{
	cli();
	outb_p(14, video_port_reg);
	outb_p(0xff&((pos-video_mem_start)>>9), video_port_val);
	outb_p(15, video_port_reg);
	outb_p(0xff&((pos-video_mem_start)>>1), video_port_val);
	sti();
}

set_cursor函数较为简单,就先讲它吧。首先关中断,避免下面的操作被中断打断(虽然我们还没初始化中断),然后下面4行是干什么的?

在我的机器上,经过con_init,video_port_reg的值为0x3d4,video_port_val的值为0x3d5。让我们看看这两个端口有什么用。

0x3d4_0x3d5

如图所示,我们可以通过向0x3d4端口输入不同的值,访问0x3d5下不同的寄存器。向0x3d4写入0xE和0xF分别可以读写光标地址的高低字节。如,想将光标移动到第2行第2列,此时的光标位置为1*80+1=81,第一个1是行号,第二个1是列号,80是每行可显示的字符数。将81的高8位写入MSB中,低八位写入LSB中,光标就移动到指定位置。

其中outb_p函数定义在io.h中,用于向端口输出数据。

#define outb_p(value,port) \
__asm__ ("outb %%al,%%dx\n" \
		"\tjmp 1f\n" \
		"1:\tjmp 1f\n" \
		"1:"::"a" (value),"d" (port))

第一个参数是输入端口的值,第二个参数是端口号,把数据放入eax中,端口号放入edx中,输出完成后,jmp 1f并无实际意义,只是用来延时。

设置完光标位置后,开启中断。开关中断的函数定义在system.h中,具体定义如下所示:

#define sti() __asm__ ("sti"::)
#define cli() __asm__ ("cli"::)
#define nop() __asm__ ("nop"::)

#define iret() __asm__ ("iret"::)

这里顺便也定义了nop和iret的函数。

接着讲讲lf函数。

static void lf(void)
{
	if (y + 1 < bottom) {
		y++;
		pos += video_size_row;
		return;
	}
	scrup();
}

bottom代表底行行号。如果不是在最后一行换行,让光标行号加1,将pos设置为下一行行首位置即可。如果是在最后一行换行,将显示下一个字符的位置改为最后一行的第一列,然后调用scrup函数滚动屏幕。

static void scrup(void)
{
	if (video_type == VIDEO_TYPE_EGAC || video_type == VIDEO_TYPE_EGAM)
	{
		if (!top && bottom == video_num_lines) {
			origin += video_size_row;
			pos += video_size_row;
			scr_end += video_size_row;
			if (scr_end <= video_mem_end) {
				__asm__("cld\n\t"
					"rep\n\t"
					"stosw"
					::"a"(video_erase_char),
					"c"(video_num_columns),
					"D"(scr_end - video_size_row)
					);
			}
			else {
				__asm__("cld\n\t"
					"rep\n\t"
					"movsd\n\t"
					"movl %2, %%ecx\n\t"
					"rep\n\t"
					"stosw"
					::"a"(video_erase_char),
					"c"((video_num_lines - 1) * video_num_columns >> 1),
                    "m"(video_num_columns),
					"D"(video_mem_start),
					"S"(origin)
					);
				scr_end -= origin - video_mem_start;
				pos -= origin - video_mem_start;
				origin = video_mem_start;
			}
			set_origin();
		}
		else {
			__asm__("cld\n\t"
				"rep\n\t"
				"movsd\n\t"
				"movl %2, %%ecx\n\t"
				"rep\n\t"
				"stosw"
				::"a"(video_erase_char),
				"c"((bottom - top - 1) * video_num_columns >> 1),
                "m"(video_num_columns),
				"D"(origin + video_size_row * top),
				"S"(origin + video_size_row * (top + 1))
				);
		}
	}
	else		/* Not EGA/VGA */
	{
		__asm__("cld\n\t"
			"rep\n\t"
			"movsd\n\t"
			"movl %2, %%ecx\n\t"
			"rep\n\t"
			"stosw"
			::"a"(video_erase_char),
			"c"((bottom - top - 1) * video_num_columns >> 1),
            "m"(video_num_columns),
			"D"(video_mem_start + video_size_row * top),
			"S"(video_mem_start + video_size_row * (top + 1))
			);
	}
}

这个函数存在多个if分支,不好讲解,我们就从最简单的地方开始着手吧。

首先讲解第52-66行,如果显示模式不是VIDEO_TYPE_EGAC或VIDEO_TYPE_EGAM,就执行这段代码。我们之前的代码已经将显示模式打印在屏幕右上角,如果你是一步步按照我之前的操作做的话,你的显示模式应该是VIDEO_TYPE_EGAC,但是还是请看看下面的内容,因为各种滚屏操作有相似之处。

这个模式的滚屏操作很简单,将后24行的内容移动到前24行中,然后把第25行的内容擦掉。

cld rep movsd中的movsd可以扩展为movsd ds:[esi], es:[edi],将ds:[esi]处4个字节移动到es:[edi]处,cld会让esi、edi在每次移动后递增,rep会让移动操作进行ecx次。ecx中的值为(bottom - top - 1) * video_num_columns >> 1,即前24行显存总字节数除以4(因为每次移动4字节,所以要除以4),edi的值为video_mem_start + video_size_row * top(屏幕第零行在显存中的位置),esi的值为video_mem_start + video_size_row * (top + 1)(屏幕第一行在显存中的位置)。这里就完成了把后24行的内容移动到前24行的功能。

rep stosw中的stosw是将ax的内容放入es:[edi]中,ax的内容为video_erase_char(0x0720),此时edi的值为屏幕最后一行的显存的位置,第57行代码将video_num_columns的值放入ecx中,rep会让移动操作执行ecx次。也就是说,会将0x0720放入显存最后一行,0x07代表黑底白字,0x20代表空格,所以看起来就是黑色块,相当于清空了最后一行的内容。

现在来看看第3-51行的代码。如果显示模式是VIDEO_TYPE_EGAC或VIDEO_TYPE_EGAM,就执行这段代码。在这种显示模式下,滚屏时会保存之前的屏幕内容,要比上面的显示模式更高级,但代码也更多。

先看看当top=0而且bottom=25的if分支,即第5-36行代码。首先更新origin、pos和scr_end的值,origin代表屏幕在显存的起始地址,在我的机器上一开始是0xb8000,在滚屏后变成0xb80a0。scr_end代表屏幕在显存的结束地址,在我的机器上一开始是0xb8fa0,在滚屏后变成0xb9040。origin到scr_end是屏幕在显存上的地址范围。pos代表光标在显存中的位置。如果scr_end还在显存的范围内(第9-17行代码),我们只需将屏幕的最后一行清空,然后重新设置屏幕在显存的地址即可。如果scr_end超出显存范围(第18-34行),会将屏幕内容拷贝到显存起始地址,并将最后一行清空,将scr_end、pos和origin重新设置为显存起始地址的相应位置。再看看set_origin函数。

static inline void set_origin(void)
{
	cli();
	outb_p(12, video_port_reg);
	outb_p(0xff&((origin-video_mem_start)>>9), video_port_val);
	outb_p(13, video_port_reg);
	outb_p(0xff&((origin-video_mem_start)>>1), video_port_val);
	sti();
}

set_origin函数与set_cursor函数相似,都是对0x3d4和0x3d5寄存器进行操作。

设置屏幕地址

如果要将第2行第1列设置为屏幕的起始地址,将Start address设置为1*80+0=80,1代表行号,0代表列号,80代表一行的字符数,将高8位写入0xC寄存器,将低8位写入0xD寄存器。

最后看看37-50行的代码,当top!=0或bottom!=25时,执行此段代码。这段代码的操作与第52-66行的代码类似,就不多做解释。这段代码与第5-36行的代码相比,它不保存之前的屏幕显示内容。

终于讲完了显示的流程,最后修改一下main.c就开始运行吧。

void main(void)
{
	char buf[] = "For hardware functions such as input and output and memory allocation, the operating system acts as an intermediary between programs and the computer hardware,[1][2] although the application code is usually executed directly by the hardware and frequently makes system calls to an OS function or is interrupted by it. Operating systems are found on many devices that contain a computer – from cellular phones and video game consoles to web servers and supercomputers.";
	tty_init();
	printk("%s", buf);
	cli();
	while (1);
}

请注意,我在调用printk函数之后使用cli关闭了中断。这节的代码在printk之后的死循环中会触发中断,但我们还没有初始化中断,导致双重错误(Double Fault)。当初我调试的时候,模拟器总是莫名其妙地挂了,在虚拟机上一直不断关机重启,真的是一把辛酸一把泪。至于到底触发了什么中断我也不太清楚,在之后章节的代码中又会莫名其妙好了。。。
3.4运行结果

可以看到,所有地字符都完整地显示了,光标也确实是随着字符移动了。OHHHHHHHHH!!!

我们的printk函数还是有点小问题,它没法打印’\n’、’\t’这种特殊字符,当然,我们会在下一节解决这个问题。

5.简单的printk函数3

这一节主要是让printk能够处理特殊字符。主要修改的文件还是console.c。

为了能够识别不可打印字符,让我们在con_write中添加一些判断。

static void cr(void)
{
	pos -= x << 1;
	x = 0;
}

static void del(void)
{
	if (x) {
		pos -= 2;
		x--;
		*(unsigned short *)pos = video_erase_char;
	}
}

void con_write(char *buf, int nr)
{
	int i;
	char c;

	for (i = 0; i < nr; i++) {
		c = buf[i];
		if (c > 31 && c < 127) {
			if (x >= video_num_columns) {	// 打印完一行后换行
				x -= video_num_columns;
				lf();
			}
			__asm__(
				"movb %1, %%ah\n\t"
				"movw %%ax, %2\n\t"
				::"a" (c), "m"(attr), "m" (*(short *)pos));
			pos += 2;
			x++;
		}
		else if (c == 10 || c == 11 || c == 12)	// '\n',换行,使光标下移一格
			lf();
		else if (c == 13)	// '\r',回车,使光标移至行首
			cr();
		else if (c == '\177')	// 八进制数,等于十进制127,删除
			del();
		else if (c == 8) {	// '\b',退格
			if (x) {
				x--;
				pos -= 2;
			}
		}
		else if (c == 9) {	// '\t',水平制表符
			c = 8 - (x & 7);
			x += c;
			pos += c << 1;
			if (x > video_num_columns) {
				x -= video_num_columns;
				pos -= video_size_row;
				lf();
			}
			c = 9;
		}
	}
	set_cursor();	// 打印完字符串后,重新设置光标
}

换行操作直接调用lf函数,这个函数我们在上一节就已经讲过了。

回车操作就是把x和pos移动到行首的位置,之后set_cursor会将光标也移动到行首位置。

删除操作会删掉一个字符,并将光标左移一位。

退格操作只会把光标左移一位,不会删除字符。

水平制表符会将光标移动到8的倍数的位置上。

这一节的内容很简单,或许我应该把c=7(响铃)的操作也加进来。算了,修改一下main.c运行吧。

void main(void)
{
	char buf[] = "For hardware functions such as input and output and memory allocation, the operating system acts as an intermediary between programs and the computer hardware,[1][2] although the application code is usually executed directly by the hardware and frequently makes system calls to an OS function or is interrupted by it. \n\rOperating systems are found on many devices that contain a computer – from cellular phones and video game consoles to web servers and supercomputers.";
	tty_init();
	printk("%s\n\r", buf);
	cli();
	while (1);
}

3.5运行结果

可以正确换行,另外的特殊字符就让大家自己测试吧。

你可能会疑问,为什么换行是用"\n\r"而不是printf常用的"\n"呢?这个涉及到一些状态的处理。虽然看起来printk函数已经足够完整了,但是我们还是有不少功能没有添加,比如ANSI转义字符序列的处理,当然这些功能在之后的章节中会慢慢补全。

printk的内容就告一段落,下一章会讲讲部分系统的初始化操作。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值