第四章 输出
本章将介绍如何在控制台上显示文本和向串口写入数据。此外,我们将创建第一个驱动,作为在硬件和内核之间,提供一个比直接与硬件通讯更高层次的抽象层。本章的第一部分是说明如何给framebuffer【26】创建一个驱动,使能够在控制台中显示文本。而第二部分则展示如何给串口创建驱动。Bochs能够将来自串口的输出信息存储到文件中,这样我们可以非常高效地为操作系统创建一个日志记录机制。
硬件交互
与硬件交互通常包括两种不同的方式,内存映射I/O(memory-mapped I/O)方式和I/O端口(I/O ports)方式。假若硬件使用的是memory-mapped I/O,你可以向指定的内存地址中写入数据,然后硬件将会使用此新数据来进行更新。framebuffer即为这样的一个例子,我们将在后面讨论更多有关细节。例如,你向地址0x000B8000写入值0x410F,将会看到黑底白体的字母A(请阅读framebuffer小节查找更多的细节)。
若硬件使用的是I/O ports方式,那么必须使用汇编指令代码out和in进行与硬件的通信。out指令接受两个参数:I/O端口的地址和要发送的数据。in指令则仅接受一个I/O端口的地址作为参数,和一个返回值表示从硬件返回的数据。我们可以认为I/O ports通讯和使用sockets的服务端通讯方式是相同的。而framebuffer光标(闪烁的矩形)则是通过I/O端口进行硬件控制的例子之一。
Framebuffer
framebuffer是一个能够在屏幕上显示内存缓冲的硬件设备。framebuffer拥有80列和25行,行列的索引均从0开始(例如可以把行标记为0-24)。
写文本
我们通过memory-mapped I/O来处理framebuffer以完成向控制台写入文本的操作。framebuffer的memory-mapped I/O起始地址是0x000B8000【27】。framebuffer的内存被分成一个个16位的单元,每个单元都单独的决定了所写入的字符是什么,该字符的前景色和背景色。每个单元的高八位存储字符的ASCII值,7-4位为背景色,3-0位为前景色,如下图所示:
下表列出了可用的颜色值:
Color | Value | Color | Value |
Black | 0 | Dark grey | 8 |
Blue | 1 | Light blue | 9 |
Green | 2 | Light green | 10 |
Gyan | 3 | Light cyan | 11 |
Red | 4 | Light red | 12 |
Magenta | 5 | Light magenta | 13 |
Brown | 6 | Light brown | 14 |
Light grey | 7 | White | 15 |
第一个单元对应控制台上的第零行、第零列。使用ASCII表可以看到A对应65或0x41。所以,用下面的汇编指令能够将绿底深灰的字符A写到framebuffer中的第零行、第零列。
mov [0x000B8000], 0x4128
而第二个单元对应第零行、第一列,其地址为:
0x000B8000 + 16 = 0x000B8010;
通过将地址0x000B8000作为一个char类型的指针,我们同样可以用C代码来完成向framebuffer中写入的操作,char * fb = (char*)0x000B8000。在(0,0)位置写入一个绿底深灰的字母A则可像如下代码所示:
fb[0] = ‘A’;
fb[1] = 0x28;
下面的代码显示了如何将以上这些封装到一个函数里面:
/** fb_write_cell:
* Writes a character with the given foreground and background to position i
* in the framebuffer.
*
* @param i The location in the frame buffer
* @param c The character
* @param bg The background color
* @param fg The foreground color
*/
void fb_write_cell(unsigned int i, char c, unsigned char bg, unsigned char fg)
{
fb[i] = c;
fb[i + 1] = ((bg & 0x0F) << 4) | (fg & 0x0F);
}
可以像下面这样使用此函数:
#define FB_GREEN 2
#define FB_DARK_GREY 8
Fb_write_cell(0, ‘A’, FB_GREEN, FB_DARK_GREY);
光标移动
我们可以通过使用两个不同的I/O ports来完成framebuffer光标的移动。光标的位置由一个大小为16比特的整数确定:0表示第零行、第零列;1表示第零行、第一列;80表示第一行、第零列等等。由于位置的大小为16位,而out汇编指令代码的参数大小只有8位,所以位置信息必须分成两回来发送,首先是起始的8位数据然后是下一个8位数据。framebuffer所使用的两个I/O端口,其中一个0x3D5【29】是为了接收数据,另一个0x3D4【29】则是为了描述所接收到的数据。
下面的汇编指令代码可以将光标设置到第一行、第零列的位置(80 = 0x0050):
Out 0x3D4, 14 ; 14 tells the framebuffer to expect the highest 8 bits of the position
Out 0x3D5, 0x00 ; sending the highest 8 bits of 0x0050
Out 0x3D4, 15 ; 15 tells the framebuffer to expect the lowest 8 bits of the position
Out 0x3D5, 0x50 ; sending the lowest 8 bits of 0x0050
我们不能直接在C代码中执行out汇编指令代码。因此一个比较好的方法是用汇编代码将其封装到一个函数中,然后使用符合cdecl调用标准【25】的C代码进行访问:
global outb ;make the label outb visible outside this file
; outb - send a byte to an I/O port
; stack: [esp + 8] the data byte
; [esp + 4] the I/O port
; [esp] return address
outb:
mov al, [esp + 8]
mov dx, [esp + 4]
out dx, al
ret
将此函数保存到名称为io.s的文件中,然后创建一个名称为io.h的头文件,那么C代码即可以很方便的对out汇编指令进行访问:
#ifndef INCLUDE_IO_H
#define INCLUDE_IO_H
/** outb:
* Sends the given data to the given I/O port. Defined in io.s
*
* @param port The I/O port to send the data to
* @param data The data to send to the I/O port
*/
void outb(unsigned short port, unsigned char data);
#endif // INCLUDE_IO_H
现在可以将移动光标的操作封装到C的函数中:
#include “io.h”
/* The I/O ports */
#define FB_COMMAND_PORT 0x3D4
#define FB_DATA_PORT 0x3D5
/* The I/O port commands */
#define FB_HIGH_BYTE_COMMAND 14
#define FB_LOW_BYTE_COMMAND 15
/** fb_move_cursor:
* Moves the cursor of the framebuffer to the given position
*
* @param pos The new position of the cursor
*/
void fb_move_cursor(unsigned short pos)
{
outb(FB_COMMAND_PORT, FB_HIGH_BYTE_COMMAND);
outb(FB_DATA_PORT, ((pos >> 8) & 0x00FF));
outb(FB_COMMAND_PORT, FB_LOW_BYTE_COMMAND);
outb(FB_DATA_PORT, pos & 0x00FF);
}
驱动
驱动应该提供一些让OS中其它部分的代码能够调用之与framebuffer进行交互的接口。虽然并不限制接口应该包括哪些功能,但是我们建议其应包含一个似下面声明的write函数:
int write(char *buf, unsigned int len);
write函数的功能是向屏幕中写入一个长度为len的缓冲区内容buf。它应该在写入一个字符后自动的推进光标并且能够在必要的情况下滚动屏幕。
串口
串口【30】是一个硬件通讯的接口,虽然它在几乎所有的主板上都是可用的,不过目前已经很少以DE-9连接器的形式暴露给用户。串口非常的容易使用,然而更重要的是,在Bochs中,它可以像日志一样输出到文件中。如果一台计算机支持串口通信,那么它通常会开放多个串口,但由于我们只是希望将串口用作日志记录,所以我们只要使用其中的一个串口即可。此外我们只是将串口用作输出。串口完全通过I/O端口进行控制。
串口配置
配置信息是首先第一个要发送到串口的数据。为了两个硬件设备间能够进行正常的对话,它们必须在以下几方面取得一致:
l 发送数据的速率(位或者波特率)
l 对数据使用错误检测(奇偶位,停止位)
l 代表一个数据单元的比特数(数据位)
线路配置
线路配置的意思是配置数据在线路上的发送方式。串口拥有一个名称为line command port的I/O端口,我们使用它来对线路进行配置。
首先需要设置的是发送数据的速度。串口中存在一个运转在115200Hz的内部时钟。速率设置即要向串口发送一个因子,例如发送一个2则导致其速率变化为115200 / 2 = 57600Hz。
该因子的大小16位数,而我们一次只能够发送8位,所以我们必须发送一个指令告诉串口,首先发送的是高8位,然后是低的8位。我们通过向line command port发送0x80来完成此操作。一个完整的例子如下所示:
#include “io.h” // io.h is implement in the section “Moving the cursor”
/* The I/O ports */
/* All the I/O ports are calculated relative to the data port. This is because
* all serial ports (COM1, COM2, COM3, COM4) have their ports in the same
* order, but they start at different values.
*/
#define SERIAL_COM1_BASE 0x3F8 // COM1 base port
#define SERIAL_DATA_PORT(base) (base)
#define SERIAL_FIFO_COMMAND_PORT(base) (base + 2)
#define SERIAL_LINE_COMMAND_PORT(base) (base + 3)
#define SERIAL_MODEM_COMMAND_PORT(base) (base + 4)
#define SERIAL_LINE_STATUS_PORT(base) (base + 5)
/* The I/O port commands */
/* SERIAL_LINE_ENABLE_DLAB:
* Tells the serial port to expect first the highest 8 bits on the data port,
* then the lowest 8 bits will follow
*/
#define SERIAL_LINE_ENABLE_DLAB 0x80
/* serial_configure_baud_rate:
* Sets the speed of the data being sent.. The default speed of a serial
* port is 115200 bits/s. The argument is a divisor of that number, hence
* the resulting speed becomes (115200 / divisor) bits/s.
* @param com The COM port to configure
* @param divisor The divisor
*/
void serial_configure_baud_rate(unsigned short com, unsigned short divisor)
{
outb(SERIAL_LINE_COMMAND_PORT(com), SERIAL_LINE_ENABLE_DLAB);
outb(SERIAL_DATA_PORT(com), (divisor >> 8) & (0x00FF);
outb(SERIAL_DATA_PORT(com), divisor & 0x00FF);
}
数据发送的方式也必须要进行配置,这同样通过向line command port发送一个字节来完成。该字节的8位排列应该像下面这样:
Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
Content: | d | b | prty | s | dl |
每一个名称都可以在下表(和【31】)中找到其描述信息:
Name | Description |
d | Enables(d = 1) or disables (d = 0) DLAB |
b | If break control is enabled (b = 1) or disable (b = 0) |
ptry | The number of parity bits to use |
s | The number of stop bits to use (s = 0 equals 1, s = 1 equals 1.5 or 2) |
dl | Describes the length of the data |
我们将使用一个较为通用的值0x03【31】,它意味着8位的长度,无校验位,一个停止位和禁用中断控制。我们将它发送到line command port,代码如下:
/** serial_configure_line:
* Configures the line of the given serial port. The port is set to have a
* data length of 8 bits, no parity bits, one stop bit and break control disabled.
* @param com The serial port to configure
*/
void serial_configure_line(unsigned short com)
{
/* Bit: | 7 | 6 | 5 4 3 | 2 | 1 0 |
* Content: | d | b | prty | s | dl |
* Value: | 0 | 0 | 0 0 0 | 0 | 1 1 |
*/
outb(SERIAL_LINE_COMMAND_PORT(com), 0x03);
}
在OSDev【31】中有更多针对values解释的文章。
Buffers配置
当通过串口进行数据传输(包括发送和接收)的时候,它会将数据放置到一个buffers中,这样的话,如果你往串口发送数据的速率超过了它能够在电线上传输的速率,即会将其缓存下来。然而,如果你发送的数据太多太快的话,buffer将会出现数据溢出和丢失。也就是说,buffers是一个FIFO队列,它的配置字节如下图所示:
可在下表中找到每个名称的描述:
Name | Description |
lvl | How many bytes should be stored in the FIFO buffers |
bs | If the buffers should be 16 or 64 bytes large |
r | Reserved for future |
dma | How the serial port data should be accessed |
clt | Clear the transmission FIFO buffer |
clr | Clear the receiver FIFO buffer |
e | If the FIFO buffer should be enabled or not |
我们使用值0xC7 = 11000111,该值表示:
* 启用FIFO
* 清除接收和传输FIFO队列
* 队列大小为14字节
WikiBook的串口编程【32】部分有对此值更深入的解释。
配置Modem
我们通过传输就绪(Ready To Transmit, RTS)和数据终端就绪(Data Terminal Ready, DTR)引脚来使用modem control register进行相对较简单的硬件流控制。当配置串口RTS和DTR为1时,即表示我们已经准备好要发送数据了。
modem配置字节排列如下图所示:
可在下表中找到每个名称的描述:
Name | Description |
r | Reserved |
af | Autflow control enabled |
lb | Loopback mode(used for debugging serial ports) |
ao2 | Auxiliary output 2, used for receiving interrupts |
ao1 | Auxiliary output 1 |
rts | Ready To Transmit |
dtr | Data Terminal Ready |
在这里我们不需要启用中断,因为我们不想要处理任何接收到的数据,所以我们使用配置参数值0x03 = 00000011(RTS = 1,DTS = 1)。
写数据到串口
我们通过数据I/O port来完成向串口写数据的操作。但是在写数据之前,传输的FIFO队列必须为空(所有先前的写操作都必须已经完成)。如果line status I/O端口的第五位等于1,则说明FIFO传输队列为空。
阅读通过汇编指令完成I/O port的有关内容,在C中没有办法使用汇编指令,所以我们必须将其封装起来(使用与out汇编指令相同的方式):
global inb
; inb - returns a byte from the given I/O port
; stack: [esp + 4] The address of the I/O port
; [esp] The return address
inb:
mov dx, [esp + 4] ; move the address of the I/O port to the dx register
in al, dx ; read a byte from the I/O port and store it in the al register
ret ; return the read byte
/* in file io.h */
/** inb:
* Read a byte from an I/O port.
*
* @param port The address of the I/O port
* @return The read byte
*/
unsigned char inb(unsigned short port);
检测传输的FIFO是否为空可以使用C代码来完成:
#include “io.h”
/** serial_is_transmit_fifo_empty:
* Checks whether the transmit FIFO queue is empty or not for the given COM
* port
*
* @param com The COM port
* @return 0 If the transmit FIFO queue is not empty
* 1 If the transmit FIFO queue is empty
*/
int serial_is_transmit_fifo_empty(unsigned int com)
{
/* 0x20 = 0010 0000 */
return inb(SERIAL_ILNE_STATUS_PORT(com)) & 0x20;
}
写串口即意味着只要FIFO传输队列(transmit FIFO queue)不为空,就向数据I/O端口中写入数据。
Bochs配置
为了保存第一个串口的输出信息,我们必须更新Bochs的配置文件bochsrc.txt。com1配置参数指示Bochs如何处理第一个串口:
com1: enabled=1, mode=file, dev=com1.out
这样设置后就可以将串口一的输出存储到com1.out文件中。
驱动
我们建议你同样给串口实现一个类似于framebuffer驱动中的write函数,而且为了避免与framebuffer中write函数的名称冲突,可以将它们分别命名为fb_write和serial_write以便区分它们。
我们还建议你写一个像printf一样的函数,请参考7.3节第【8】项。printf函数可以接受一个额外的参数来决定我们要输出的设备(framebuffer或者serial)。
最后,我们希望你可以创建一个能够对日志消息进行严格区分的方法,例如已经预定义的DEBUG,INFO和ERROR消息。
进一步阅读
* 《Serial programming》(可在WikiBooks上获取)中有一些关于串口编程的章节都非常好,http://en.wikibooks.org/wiki/Serial_Programming/8250_UART_Programing#UART_Registers
* OSDev wiki中有一页包含了大量串口编程的信息,http://wiki.osdev.org/Serial_ports