跳转到内容

串口编程/DOS 编程

维基教科书,开放书籍,开放世界

现在是时候建立我们在前面已经建立的一切了。虽然您不太可能将 MS-DOS 用于大型应用程序,但它是一个很好的操作系统,可以演示与 8250 UART 的软件访问和驱动程序开发相关的许多想法。与 Linux、OS-X 或 Windows 等现代操作系统相比,MS-DOS 几乎不能称之为操作系统。它真正提供的只是对硬盘的基本访问和一些小的实用程序。这对于我们在这里处理的内容来说并不重要,这是一个很好的机会来了解我们如何直接操作 UART 以获得计算机所有方面的全部功能。我正在使用的工具都是免费的(如啤酒),可以在模拟软件(如 VMware 或 Bochs)中使用,也可以尝试这些想法。串行设备的模拟通常是这些程序的弱点,因此如果您从 DOS 的软盘引导或在另一台旧的计算机上工作,它可能更容易,否则该计算机将被丢弃,因为它已经过时了。

对于 Pascal,您可以查看这里

  • Turbo Pascal [1] 版本 5.5 - 这是我实际用于这些示例的软件,也是大多数网络上较旧文档支持的编译器(通常)。
  • Free Pascal [2] - *注意* 这是一个 32 位版本,尽管有一个用于 DOS 开发的移植版本。与 Turbo Pascal 不同,它还在不断开发中,对于在 DOS 中运行的严肃项目更有价值。

对于 MS-DOS 替代(如果您碰巧没有 MS-DOS 6.22)

  • FreeDOS [3] 项目 - 现在微软已经放弃了 DOS 的开发,这几乎是唯一剩下的纯粹命令行驱动的并遵循 DOS 架构的操作系统。

Hello World,串行数据版本

[编辑 | 编辑源代码]

简介 中,我提到编写实现 RS-232 串行通信的计算机软件非常困难。一个非常简短的程序表明,至少一个基本的程序并不难。事实上,比典型的“Hello World”程序只多三行。

 program HelloSerial;
 var
   DataFile: Text;
 begin
   Assign(DataFile,'COM1');
   Rewrite(DataFile);
   Writeln(DataFile,'Hello World');
   Close(DataFile);
 end.

所有这些都行得通,因为在 DOS(以及所有版本的 Windows 也是如此……在这点上)有一个名为 COM1 的“保留”文件名,它是操作系统连接到串行通信端口的。虽然这看起来很简单,但它具有欺骗性地简单。您仍然无法访问能够控制波特率或调制解调器的任何其他设置。然而,使用上一章中讨论的有关 UART 的知识 编程 8250 UART,这是一个相当简单的事情。

为了尝试更简单的事情,您甚至不需要编译器。这利用了 DOS 中的保留“设备名”,并且可以在命令提示符下完成。

C:\>COPY CON COM1

您在这里所做的是从 CON(控制台或您在计算机上使用的标准键盘)获取输入,并将其“复制”到 COM1。您还可以使用此方法的变体来完成一些有趣的文件传输,但它有一些重要的限制。最重要的是,您无法访问 UART 设置,这只是使用 UART 的任何默认设置,或者您上次使用串行终端程序更改设置时使用的设置。

查找 UART 的端口 I/O 地址

[编辑 | 编辑源代码]

我们必须处理的下一个重大任务是尝试找到端口 I/O 的基本“地址”,以便我们可以直接与 UART 芯片通信(请参阅 典型的 RS232 硬件配置 模块中关于接口逻辑的部分,了解有关此内容的信息)。对于“典型的”PC 系统,以下通常是您需要处理的地址

串行端口名称 基本 I/O 端口地址 IRQ(中断)号
COM1 3F8 4
COM2 2F8 3
COM3 3E8 4
COM4 2E8 3

在 RAM 中查找 UART 基地址

[编辑 | 编辑源代码]

我们将在稍后回到 IRQ 号的问题,但现在我们需要知道从哪里开始访问有关每个 UART 的信息。如前所述,DOS 还跟踪串行 I/O 端口的位置,以便自身使用,因此您可以尝试在 DOS 使用的内存表中“查找”,以尝试找到正确的地址。这并不总是有效,因为我们超出了正常的 DOS API 结构。其他与 MS-DOS 兼容的操作系统(FreeDOS 在这里工作正常)可能不会以这种方式工作,因此请注意,这可能只是完全给您错误的结果。

串行 I/O 端口的地址可以在 RAM 中的以下位置找到


端口 偏移量
COM1 $0040 $0000
COM2 $0040 $0002
COM3 $0040 $0004
COM4 $0040 $0006


这些地址是由 BIOS 在启动时写入内存的。如果其中一个端口不存在,BIOS 会将零写入相应的地址。请注意,地址以段:偏移量格式给出,您需要将段地址乘以 16 并加上偏移量才能得到内存中的物理地址。这就是 DOS “查找”端口地址的地方,这样您就可以运行本章中的第一个示例程序。

在汇编器中,您可以这样获取地址

; Data Segment
.data
Port  dw 0
...

; Code Segment
.code
mov ax,40h
mov es,ax
mov si,0
mov bx,Port ; 0 - COM1 , 1 - COM2 ...
shl bx,1
mov Port, es:[si+bx]

在 Turbo Pascal 中,您可以几乎以相同的方式获取这些地址,而且在某些方面甚至更容易,因为它是一种“高级语言”。您所要做的就是添加以下行以访问 COM 端口位置作为简单数组

 var
   ComPort: array [1..4] of Word absolute $0040:$0000;

保留的、非标准的词语 absolute 是一个用于编译器的标记,它指示编译器不要“分配”内存,而是使用您已经指定的内存位置。除非您访问诸如始终存储在该内存位置的这些 I/O 端口地址之类的元素,否则程序员很少会这样做。

对于一个简单的程序,它只是打印出所有四个标准 COM 端口的 I/O 端口地址表,您可以使用这个简单的程序

 program UARTLook;
 const
   HexDigits: array [$0..$F] of Char = '0123456789ABCDEF';
 var
   ComPort: array [1..4] of Word absolute $0040:$0000;
   Index: Integer;
 function HexWord(Number:Word):String;
 begin
   HexWord := '$' + HexDigits[Hi(Number) shr 4] +
                    HexDigits[Hi(Number) and $F] +
                    HexDigits[Lo(Number) shr 4] +
                    HexDigits[Lo(Number) and $F];
 end;
 begin
   writeln('Serial COMport I/O Port addresses:');
   for Index := 1 to 4 do begin
     writeln('COM',Index,' is located at ',HexWord(ComPort[Index]));
   end;
 end.

搜索 BIOS 设置

[编辑 | 编辑源代码]

假设标准 I/O 地址似乎不适用于您的计算机,并且您也无法通过搜索 RAM 找到正确的 I/O 端口偏移量地址,那么仍然没有绝望。假设您之前没有意外更改这些设置,您也可以尝试在计算机的 BIOS 设置页面中查找这些数字。您可能需要进行一些操作才能找到这些信息,但如果您在计算机上拥有传统的串行数据端口,它将存在。

如果您使用的是通过 USB 连接的串行数据端口(在较新的计算机上很常见),那么您就无法(轻松地)在 DOS 中进行直接的串行数据通信。相反,您需要使用更高级的操作系统,例如 Windows 或 Linux,这超出了本章的范围。我们将在后续章节中介绍如何在这些操作系统中访问串行通信例程。我们在此讨论的基本原理仍然值得回顾,因为它涉及基本的 UART 结构。

虽然尝试使 IRQ 可选并且不假设上述信息在所有情况下都是正确的可能很有用,但需要注意的是,大多数与 PC 兼容的计算机设备通常使用这种方式使用这些 IRQ 和 I/O 端口地址,因为存在遗留支持。令人惊讶的是,随着计算机变得越来越复杂,即使是配备了 USB 设备等更先进设备,这些遗留连接仍然适用于大多数设备。

修改 UART 寄存器

[编辑 | 编辑源代码]

现在我们已经知道在哪里内存中查找以修改 UART 寄存器,让我们将这些知识付诸实践。我们现在还将对本章前面列出的表格进行一些实际应用 8250 UART 编程

首先,让我们重新执行之前的“Hello World”应用程序,但这次我们将 RS-232 传输参数设置为 1200 波特率、7 个数据位、偶校验和 2 个停止位。我选择这个设置参数是因为它不符合大多数调制解调器应用程序的标准,作为演示。如果您能够更改这些设置,那么其他传输设置将变得微不足道。

首先,我们需要设置一些软件常量来跟踪内存中的位置。这主要是为了让将来尝试更改我们软件的人员清楚地了解情况,而不是因为编译器需要它。

 const
   LCR = 3;
   Latch_Low = $00;
   Latch_High = $01;

接下来,我们需要将 DLAB 设置为逻辑“1”,以便我们可以设置波特率

 Port[ComPort[1] + LCR] := $80;

在这种情况下,我们忽略了线路控制寄存器 (LCR) 的其余设置,因为我们将在稍后进行设置。请记住,这仅仅是一种“快速简便”的方法,现在就完成了。稍后我们将使用此模块演示设置波特率等内容的更“正式”方法。

接下来,我们需要输入调制解调器的波特率。在 除数锁存字节表 中查找 1200 波特率,得到以下值

 Port[ComPort[1] + Latch_High] := $00;
 Port[ComPort[1] + Latch_Low] := $60;

现在,我们需要根据我们所需的 7-2-E 通信设置来设置 LCR 的值。我们还需要“清除”DLAB,我们也可以同时进行。

 Clearing DLAB = 0 * 128
 Clearing "Set Break" flag = 0 * 64
 Even Parity = 2 * 8
 Two Stop bits = 1 * 4
 7 Data bits = 2 * 1

 Port[ComPort[1] + LCR] := $16  {8*2 + 4 + 2 = 22 or $16 in hex}

到目前为止,事情都清楚了吗?我们刚刚做了一些位运算,我试图在这里保持事情非常简单,并试图详细解释每个步骤。让我们把所有这些都整合在一起,作为快速简便的“Hello World”,但也要调整传输设置

 program HelloSerial;
 const
   LCR = 3;
   Latch_Low = $00;
   Latch_High = $01;
 var
   ComPort: array [1..4] of Word absolute $0040:$0000;
   DataFile: Text;
 begin
   Assign(DataFile,'COM1');
   Rewrite(DataFile);
   {Change UART Settings}
   Port[ComPort[1] + LCR] := $80;
   Port[ComPort[1] + Latch_High] := $00;
   Port[ComPort[1] + Latch_Low] := $60;
   Port[ComPort[1] + LCR] := $16
   Writeln(DataFile,'Hello World');
   Close(DataFile);
 end.

这正在变得越来越复杂,但还不算太复杂。不过,到目前为止,我们只做了将数据写入串行端口。从串行数据端口读取数据会更加棘手。

基本串行输入

[编辑 | 编辑源代码]

理论上,您可以使用标准 I/O 库,就像从硬盘上的文件读取数据一样,从 COM 端口读取数据。就像这样

 Readln(DataFile,SomeSerialData);

但是,大多数软件在执行此操作时都会遇到一些问题。要记住的一点是,使用标准输入例程将停止您的软件,直到输入完成,以“Enter”字符(ASCII 码 13 或十六进制 $0D)结束。

通常,您希望使用接收串行数据的程序来允许用户在软件等待数据输入时执行其他操作。在多任务操作系统中,这将简单地放在另一个“线程”上,但由于这是 DOS,我们通常没有线程功能,也没有必要。为了将串行数据输入到您的软件中,我们还有其他一些替代方法。

轮询 UART

[编辑 | 编辑源代码]

除了简单地让标准 I/O 例程获取输入之外,最容易的做法可能是对 UART 进行软件轮询。这样做有效的原因之一是,串行通信通常比 CPU 速度慢得多,因此您可以在每个字符传输到计算机之间执行许多任务。此外,我们正在尝试使用 UART 芯片进行实际应用,因此这是一个很好的方法来演示芯片的功能,而不仅仅是简单地输出数据。

串行回显程序

[编辑 | 编辑源代码]

查看线路状态寄存器 (LSR),有一个名为 Data Ready 的位,它指示 UART 中有一些数据可供您的软件使用。我们将利用该位,并开始直接从 UART 进行数据访问,而不是依赖标准 I/O 库。我们将在此处演示的程序将被称为 Echo,因为它只做一件事,就是接收通过串行数据端口发送到计算机的任何数据,并将其显示在您的屏幕上。我们还将配置 RS-232 设置为更常见的 9600 波特率、8 个数据位、1 个停止位和无校验。要退出程序,您所要做的就是按下键盘上的任何键。

 program SerialEcho;
 uses
   Crt;
 const
   RBR = 0;
   LCR = 3;
   LSR = 5;
   Latch_Low = $00;
   Latch_High = $01;
 var
   ComPort: array [1..4] of Word absolute $0040:$0000;
   InputLetter: Char;
 begin
   Writeln('Serial Data Terminal Character Echo Program.  Press any key on the keyboard to quit.');
   {Change UART Settings}
   Port[ComPort[1] + LCR] := $80;
   Port[ComPort[1] + Latch_High] := $00;
   Port[ComPort[1] + Latch_Low] := $0C;
   Port[ComPort[1] + LCR] := $03;
   {Scan for serial data}
   while not KeyPressed do begin
     if (Port[ComPort[1] + LSR] and $01) > 0 then begin
       InputLetter := Chr(Port[ComPort[1] + RBR]);
       Write(InputLetter);
     end; {if}
   end; {while}
 end.

简单终端

[编辑 | 编辑源代码]

这个程序并不复杂。实际上,可以从此程序中改编一个非常简单的“终端”程序,以允许发送和接收字符。在这种情况下,将使用 Escape 键退出程序,实际上,程序的大部分更改都将发生在此处。我们还首次引入了直接输出到 UART,而不是通过标准 I/O 库,使用以下代码行

 Port[ComPort[1] + THR] := Ord(OutputLetter);

传输保持寄存器 (THR) 是您想要传输的数据首先进入 UART 的方式。DOS 之前已经处理了详细信息,因此现在我们不需要打开“文件”来发送数据。为了保持事情非常简单,我们将假设您无法以 9600 波特率输入,或者大约每分钟 11000 个字。只有当您处理非常慢的波特率(例如 110 波特率)时,这才会成为问题(仍然超过每分钟 130 个字的打字速度……这确实是一位非常快的打字员)。

 program SimpleTerminal;
 uses
   Crt;
 const
   THR = 0;
   RBR = 0;
   LCR = 3;
   LSR = 5;
   Latch_Low = $00;
   Latch_High = $01;
   {Character Constants}
   NullLetter = #0;
   EscapeKey = #27;
 var
   ComPort: array [1..4] of Word absolute $0040:$0000;
   InputLetter: Char;
   OutputLetter: Char;
 begin
   Writeln('Simple Serial Data Terminal Program.  Press "Esc" to quit.');
   {Change UART Settings}
   Port[ComPort[1] + LCR] := $80;
   Port[ComPort[1] + Latch_High] := $00;
   Port[ComPort[1] + Latch_Low] := $0C;
   Port[ComPort[1] + LCR] := $03;
   {Scan for serial data}
   OutputLetter := NullLetter;
   repeat
     if (Port[ComPort[1] + LSR] and $01) > 0 then begin
       InputLetter := Chr(Port[ComPort[1] + RBR]);
       Write(InputLetter);
     end; {if}
     if KeyPressed then begin
       OutputLetter := ReadKey;
       Port[ComPort[1] + THR] := Ord(OutputLetter); 
     end; {if}
   until OutputLetter = EscapeKey;
 end.

DOS 中的中断驱动程序

[编辑 | 编辑源代码]

软件轮询方法可能足以满足大多数简单任务,如果您想测试一些串行数据概念,而无需编写大量软件,它可能就足够了。只使用这种数据输入方法就可以完成很多事情。

但是,当您编写更完整的软件时,重要的是要考虑软件的效率。虽然计算机正在“轮询”UART 以查看是否已通过串行通信端口发送了字符,但它花费了相当多的 CPU 周期来完全不做任何事情。将像上面演示的那个程序扩展为非常大型程序的一小部分也会变得非常困难。如果您想从软件中获得最后一点 CPU 性能,我们需要转向中断驱动程序以及如何编写它们。

我坦率地说,从上面列出的简单轮询应用程序过渡到这种复杂程度,是一个很大的飞跃,但总的来说,这是一个重要的编程主题。我们还将揭示 8086 芯片系列的低级行为,这些知识也可以应用于更新的操作系统,至少可以作为背景信息。

回到之前关于 8259 可编程中断控制器 (PIC) 芯片的讨论,像 UART 这样的外部设备可以向 8086 信号,告知需要发生一个重要任务,该任务会**中断**当前在计算机上运行的软件的流程。然而,并非所有计算机都这样做,有时软件轮询设备是获取来自其他设备的数据输入的唯一方法。中断事件的真正优势在于,你可以非常快地处理来自 UART 等设备的数据采集,而本来用来测试是否有数据可用的 CPU 时间可以用于其他任务。在设计**事件驱动**的操作系统时,它也非常有用。

中断请求 (IRQ) 被标记为 IRQ0 到 IRQ15。UART 芯片通常使用 IRQ 3 或 IRQ 4。当 PIC 向 CPU 信号中断已发生时,CPU 会自动开始运行一个非常小的子程序,该子程序之前已在 RAM 中的**中断表**中设置。启动的具体程序取决于哪个 IRQ 被触发。我们将在本文中演示编写自己的软件的能力,该软件可以从操作系统中“接管”中断发生时应该发生的事情。实际上,至少对于我们正在重写的那些部分,我们正在编写自己的“操作系统”。

事实上,这就是操作系统作者在尝试制作新操作系统时所做的事情……处理中断并编写控制连接到计算机的设备所需的子程序。

以下是捕获键盘中断并在每次按下按键时在扬声器中产生“咔嗒”声的非常简单的程序。关于整个部分的一个有趣的事情是,虽然它稍微偏离了主题,但这正是在与串行设备进行通信。典型 PC 上的键盘通过 RS-232 串行协议传输关于您按下的每个键的信息,该协议通常以 300 到 1200 波特率运行,并且具有自己的自定义 UART 芯片。通常情况下,这不是您需要处理的事情,而且很少会有其他类型的设备连接到键盘端口,但有趣的是,您可以通过了解串行数据编程来“入侵”键盘的功能。

 program KeyboardDemo;
 uses
   Dos, Crt;
 const
   EscapeKey = #27;
 var
   OldKeybrdVector: Procedure;
   OutputLetter: Char;
 {$F+}
 procedure Keyclick; interrupt;
 begin
   if Port[$60] < $80 then begin
     Sound(5000);
     Delay(1);
     Nosound;
   end;
   inline($9C) { PUSHF - Push the flags onto the stack }
   OldKeybrdVector;
 end;
 {$F-}
 begin
   GetIntVec($9,@OldKeybrdVector);
   SetIntVec($9,Addr(Keyclick));
   repeat
    if KeyPressed then begin
      OutputLetter := ReadKey;
      Write(OutputLetter);
    end; {if}
   until OutputLetter = EscapeKey;
   SetIntVec($9,@OldKeybrdVector);
 end.

这个程序做了很多事情,我们还需要探索 16 位 DOS 软件的领域。为了与当时设计时可用的计算机技术合作,8086 芯片的设计者不得不做出很多妥协。与计算机的总体成本相比,计算机内存非常昂贵。大多数 IBM-PC 竞争的早期微型计算机只有 64K 或 128K 的主 CPU RAM,因此庞大的程序并不重要。事实上,最初的 IBM-PC 旨在仅在 128K 的 RAM 上运行,尽管它确实成为标准,通常最多使用 640K 的主 RAM,特别是在 IBM PC-XT 发布以及 PC“克隆”市场推出被普遍认为是“标准 PC”的计算机之后。

设计提出了一种称为**分段内存**的东西,其中 CPU 地址由一个内存“段”指针和一个 64K 内存块组成。这就是为什么这些计算机上的一些早期软件只能在 64K 的内存中运行,并且为 8086 上的编译器作者带来了噩梦。奔腾计算机通常没有这个问题,因为“保护模式”下的内存模型不使用这种分段设计方法。

远过程调用

[edit | edit source]
 
{$F+}
{$F-}

此程序有两个“编译器开关”,它们通知编译器需要使用所谓的远过程调用。通常情况下,对于小型程序和简单子程序,您可以使用所谓的相对索引,这样软件就可以让 CPU 通过一些简单的数学运算并“添加”一个数字到当前 CPU 地址来“跳转”到带有该程序的 RAM 部分,从而找到正确的指令。这样做尤其是因为它使用相当少的内存来存储所有这些指令。

但是,有时必须从与当前 CPU 内存地址“指令指针”完全不同的 RAM 中访问程序。中断程序就是其中之一,因为它甚至不必与存储在中断向量表中的相同程序相同。这引出了接下来要讨论的部分

中断程序

[edit | edit source]
procedure Keyclick; interrupt;

此过程名称后面的“中断”一词在这里是一个关键项。这告诉编译器在组织此函数时,它必须做一些与正常函数调用行为略有不同的事情。通常情况下,对于计算机上的大多数软件,您都有很多简单指令,然后是(在汇编程序中)一个称为

RET

这是从过程调用返回的助记符汇编指令。中断的处理方式略有不同,通常应以不同的 CPU 指令结尾,该指令在汇编中称为

IRET

或者简称为中断返回。任何中断服务例程都应该发生的一件事是,在执行任何其他操作之前“保留”CPU 信息。您在软件中编写的每个“命令”都会修改 CPU 的内部寄存器。请记住,中断可能发生在执行另一个程序的一些计算的中间,例如渲染图形图像或进行工资计算。我们需要保留这些信息并在子程序结束时“恢复”所有 CPU 寄存器的值。这通常通过将所有寄存器值“压入”CPU 堆栈、执行 ISR,然后恢复 CPU 寄存器来完成。

在这种情况下,Turbo Pascal(以及其他具有这种编译器标志的编写良好的编译器)会使用此简单标志为您处理这些底层细节。如果您使用的编译器没有此功能,则必须“手动”添加这些功能并将其显式地放入您的软件中。这并不意味着编译器会为您做所有事情来创建一个中断程序。还有更多步骤才能使它正常工作。

过程变量

[edit | edit source]
var
   OldKeybrdVector: Procedure;

这些指令使用的是所谓的过程变量。请记住,所有软件都位于与变量和您的软件使用的其他信息相同的内存中。本质上,一个变量程序,您不必担心它做什么,直到软件运行,而且您可以在程序运行时更改此变量。这是一个强大的概念,不常使用,但它可以用于许多不同的用途。在本例中,我们跟踪了之前的中断服务例程,并将这些例程“链接”在一起。

有一些程序称为终止并驻留 (TSR),它们被加载到您的计算机中。其中一些被称为驱动程序,操作系统本身也会插入子程序来执行基本功能。如果您想与所有这些其他软件“友好相处”,确保每个人都有机会查看中断中的数据的既定协议是将每个新的中断子程序链接到以前存储的中断向量。当我们完成了我们想用中断做的事情后,我们就会让所有其他程序也有机会使用中断。还有一种可能是我们刚刚编写的中断服务例程 (ISR) 不是链中的第一个,而是一个被另一个 ISR 调用的 ISR。

获取/设置中断向量

[edit | edit source]
  GetIntVec($9,@OldKeybrdVector);
  SetIntVec($9,Addr(Keyclick));
  SetIntVec($9,@OldKeybrdVector);

再说一次,这是 Turbo Pascal 以一种方便的方式“隐藏”细节。您可以直接访问一个“向量表”,但此向量表并不总是位于 RAM 中的相同位置。相反,如果您通过 BIOS 使用软件中断,则可以“保证”中断向量会被正确替换。

硬件中断表

[edit | edit source]
中断 硬件 IRQ 用途
$00 CPU 除零
$01 CPU 单步指令处理
$02 CPU 不可屏蔽中断
$03 CPU 断点指令
$04 CPU 溢出指令
$05 CPU 边界异常
$06 CPU 无效操作码
$07 CPU 未找到数学协处理器
$08 IRQ0 系统计时器
$09 IRQ1 键盘
$0A IRQ2 来自 IRQ8 - IRQ15 的级联
$0B IRQ3 串行端口 (COM2)
$0C IRQ4 串行端口 (COM1)
$0D IRQ5 声卡
$0E IRQ6 软盘控制器
$0F IRQ7 并行端口 (LPT1)
$10 - $6F 软件中断
$70 IRQ8 实时时钟
$71 IRQ9 传统 IRQ2 设备
$72 IRQ10 保留(通常是 PCI 设备)
$73 IRQ11 保留(通常是 PCI 设备)
$74 IRQ12 PS/2 鼠标
$75 IRQ13 数学协处理器结果
$76 IRQ14 硬盘驱动器
$77 IRQ15 保留
$78 - $FF 软件中断

此表简要概述了中断的一些用途及其相关的中断号。请记住,IRQ 号主要是参考号,CPU 使用一组不同的编号。例如,键盘 IRQ 是 IRQ1,但在 CPU 内部编号为中断 $09。

CPU 本身也会“生成”一些中断。虽然从技术上讲是硬件中断,但这些中断是由 CPU 内部 的条件生成的,有时基于软件或操作系统设置的条件。当我们开始编写串行通信端口的中断服务例程时,将使用中断 11 和 12(十六进制为 $0B 和 $0C)。正如您所见,大多数中断都分配给特定任务。我省略了软件中断,主要是为了专注于串行编程和硬件中断。

其他功能

[编辑 | 编辑源代码]

这个程序还有其他几个部分,不需要过多解释。请记住,我们正在讨论串行编程,而不是中断驱动程序。I/O 端口 $60 很有趣,因为它是键盘 UART 的接收缓冲区 (RBR)。它返回键盘“扫描码”,而不是实际按下的字符。事实上,当您在 PC 上使用键盘时,键盘实际上会为每个您使用的键传输两个字符。一个字符是在您按下键时传输的,另一个字符是在“释放”键向上移动时传输的。在这种情况下,DOS 中的中断服务例程通常会将扫描码转换为软件可以使用的 ASCII 码。实际上,像 Shift 键这样的简单键也被视为另一个扫描码。

声音例程访问的是 PC 内部扬声器,而不是声卡上的扬声器。现在唯一使用此扬声器的可能是 BIOS“哔声代码”,您只会在计算机出现硬件故障时听到这些代码,或者在启动或重启计算机时听到短暂的“哔”声。它从未设计用于语音合成或音乐播放等用途,驱动程序尝试将其用于这些用途的声音非常糟糕。尽管如此,它仍然是一个值得尝试的东西,也是一个遗留的计算机部件,令人惊讶地,它仍然被许多当前的计算机使用。

终端程序再探

[编辑 | 编辑源代码]

我将回到串行终端程序,这次将使用中断服务例程重新编写应用程序。我还想介绍一些其他概念,所以我会在这个示例程序中尝试加入它们。从用户的角度来看,我想添加从命令行更改终端特性的功能,并允许“最终用户”更改波特率、停止位和奇偶校验检查等内容,并允许这些内容成为变量,而不是硬编码的常量。我会解释每个部分,然后在完成后将它们全部整合在一起。

串行 ISR

[编辑 | 编辑源代码]

这是一个我们可以使用的串行 ISR 示例

 {$F+}
 procedure SerialDataIn; interrupt;
 var
  InputLetter: Char;
 begin     
   if (Port[ComPort[1] + LSR] and $01) > 0 then begin
     InputLetter := Chr(Port[ComPort[1] + RBR]);
  end; {if}
 end;
 {$F-}

这与我们之前使用的轮询方法并没有太大区别,但请记住,通过将检查放入 ISR 中,CPU 仅在有数据可用时才进行检查。为什么还要检查 LSR 以查看是否有数据字节可用?读取发送到 UART 的数据不是 UART 触发中断的唯一原因。我们将在后面的部分详细介绍这一点,但现在这是一个良好的编程实践,可以确认数据是否在那里。

通过将此检查移到 ISR 中,CPU 可以有更多时间执行其他任务。我们甚至可以将键盘轮询也放入 ISR 中,但现在我们先保持简单。

禁用 FIFO

[编辑 | 编辑源代码]

我们编写这个 ISR 的方式存在一个小的问题。我们假设 UART 中没有 FIFO。这个 ISR 当前编写方式可能出现的“错误”是 FIFO 缓冲区中可能存在多个字符。通常,当这种情况发生时,UART 只发送单个中断,由 ISR 负责完全“清空”FIFO 缓冲区。

相反,我们只需完全禁用 FIFO。这可以使用 FCR(FIFO 控制寄存器)完成,并明确禁用 FIFO。作为额外的预防措施,我们还将在程序的初始化部分“清除”UART 中的 FIFO 缓冲区。清除 FIFO 看起来像这样

  Port[ComPort[1] + FCR] := $07; {clearing the FIFOs}

禁用 FIFO 看起来像这样

  Port[ComPort[1] + FCR] := $00; {disabling FIFOs}

我们将在下一节使用 FIFO,所以到目前为止,这只是一个关于该寄存器的简要介绍。

使用 PIC

[编辑 | 编辑源代码]

到目前为止,我们不必担心使用可编程中断控制器 (PIC)。现在我们必须使用它。不需要执行 PIC 的所有潜在指令,但我们需要启用和禁用 UART 使用的中断。每个 PC 上通常有两个 PIC,但由于典型的 UART IRQ 向量,我们实际上只需要处理主 PIC。

PIC 函数 I/O 端口地址
PIC 命令 0x20
中断标志 0x21

这将以下两个常量添加到软件中

 {PIC Constants}
 MasterPIC = $20;
 MasterOCW1 = $21;

参考PIC IRQ 表,我们需要在软件中添加以下行才能启用 IRQ4(通常用于 COM1)

 Port[MasterOCW1] := Port[MasterOCW1] and $EF;

当我们完成程序时进行“清理”时,我们还需要使用以下软件行禁用此 IRQ

 Port[MasterOCW1] := Port[MasterOCW1] or $10;

请记住,COM2 位于另一个 IRQ 向量上,因此您必须使用不同的常量才能使用该 IRQ。稍后将进行演示。我们使用逻辑与或对该 PIC 寄存器中的现有值进行运算,因为我们不想更改 PC 上其他软件和驱动程序可能使用的其他中断向量的值。

我们还需要修改中断服务例程 (ISR),使其与 PIC 协同工作。您可以发送给 PIC 的一个命令称为中断结束 (EOI)。这向 PIC 发出信号,表明它可以清除此中断信号并处理较低优先级的中断。如果您没有清除 PIC,中断信号将保留,并且 CPU 无法处理任何其他“较低优先级”的中断。这是 CPU 如何与 PIC 通信以结束中断周期的。

以下行添加到 ISR 中以实现这一点

 Port[MasterPIC] := EOI;

调制解调器控制寄存器

[编辑 | 编辑源代码]

这可能是您在尝试获取 UART 中断时可能犯的最不明显的错误。调制解调器控制寄存器实际上是 UART 与 PC 其余部分通信的方式。由于大多数计算机主板上的电路设计方式,您通常必须打开辅助输出 2 信号才能使中断“连接”到 CPU。此外,在这里我们将打开 串行数据线 上的 RTS 和 DTS 信号以确保设备能够传输。我们将在后面的部分介绍软件和硬件流控制。

要在 MCR 中打开这些值,我们需要在软件中添加以下行

 Port[ComPort[1] + MCR] := $0B;

中断使能寄存器

[编辑 | 编辑源代码]

我们还没有完全完成。我们还需要启用 UART 本身的中断。这很简单,目前我们只希望 UART 在接收到数据时触发中断。这只是一行简单的代码需要添加

 Port[ComPort[1] + IER] := $01;

到目前为止,将这些内容整合在一起

[编辑 | 编辑源代码]

以下是使用 ISR 输入的完整程序

 program ISRTerminal;
 uses
   Crt, Dos;
 const
   {UART Constants}
   THR = 0;
   RBR = 0;
   IER = 1;
   FCR = 2;
   LCR = 3;
   MCR = 4;
   LSR = 5;
   Latch_Low = $00;
   Latch_High = $01;
   {PIC Constants}
   MasterPIC = $20;
   MasterOCW1 = $21;
   {Character Constants}
   NullLetter = #0;
   EscapeKey = #27;
 var
   ComPort: array [1..4] of Word absolute $0040:$0000;
   OldSerialVector: procedure;
   OutputLetter: Char;
 {$F+}
 procedure SerialDataIn; interrupt;
 var
   InputLetter: Char;
 begin     
   if (Port[ComPort[1] + LSR] and $01) > 0 then begin
     InputLetter := Chr(Port[ComPort[1] + RBR]);
     Write(InputLetter);
   end; {if}
   Port[MasterPIC] := EOI;
 end;
 {$F-}
 begin
   Writeln('Simple Serial ISR Data Terminal Program.  Press "Esc" to quit.');
   {Change UART Settings}
   Port[ComPort[1] + LCR] := $80;
   Port[ComPort[1] + Latch_High] := $00;
   Port[ComPort[1] + Latch_Low] := $0C;
   Port[ComPort[1] + LCR] := $03;
   Port[ComPort[1] + FCR] := $07; {clearing the FIFOs}
   Port[ComPort[1] + FCR] := $00; {disabling FIFOs}
   Port[ComPort[1] + MCR] := $0B;
   {Setup ISR vectors}
   GetIntVec($0C,@OldSerialVector);
   SetIntVec($0C,Addr(SerialDataIn));
   Port[MasterOCW1] := Port[MasterOCW1] and $EF;
   Port[ComPort[1] + IER] := $01;
   {Scan for keyboard data}
   OutputLetter := NullLetter;
   repeat
     if KeyPressed then begin
       OutputLetter := ReadKey;
       Port[ComPort[1] + THR] := Ord(OutputLetter); 
     end; {if}
   until OutputLetter = EscapeKey;
   {Put the old ISR vector back in}
   SetIntVec($0C,@OldSerialVector);
   Port[MasterOCW1] := Port[MasterOCW1] or $10;
 end.

此时,您开始理解串行数据编程的复杂性。我们还没有完成,但如果您已经走到了这一步,希望您已经理解了上面列出的程序的每个部分。我们将尝试一步一步地进行,但此时,您应该能够编写一些使用串行 I/O 的简单自定义软件。

命令行输入

[编辑 | 编辑源代码]

您可以用多种不同的方式“扫描”启动程序的参数。例如,如果您在 DOS 中启动一个简单的终端程序,可以使用以下命令开始

C:> terminal COM1 9600 8 1 None

或者可能是

C:> terminal COM4 1200 7 2 Even

显然,如果用户想要更改诸如波特率之类的简单内容,就不需要重新编译软件。我们试图在这里完成的是获取用于启动程序的其他项目。在 Turbo Pascal 中,有一个函数可以返回一个字符串

 ParamStr(index)

它包含命令行中的每个项目。这些项目通过字符串传递给程序。有关如何提取这些参数的快速示例程序,请参见此处

 program ParamTst;
 var
   Index: Integer;
 begin
   writeln('Parameter Test -- displays all command line parameters of this program');
   writeln('Parameter Count = ',ParamCount);
   for Index := 0 to ParamCount do begin
     writeln('Param # ',Index,' - ',ParamStr(Index));
   end;
 end.

一个有趣的“参数”是参数编号 0,它是正在处理命令的程序的名称。我们不会使用此参数,但它在许多其他编程情况下很有用。

获取终端参数

[编辑 | 编辑源代码]

为了简单起见,我们将要求所有参数都以波特率、位大小、停止位、奇偶校验的格式存在;或者根本没有参数。此示例主要用于演示如何使用变量通过软件用户而不是程序员来更改 UART 设置。由于添加的部分不言自明,我将直接提供完整的程序。这里将进行一些字符串操作,超出了本书的范围,但仅用于解析命令。为了保持用户界面简单,我们仅使用命令行参数来更改 UART 参数。我们可以构建一个花哨的界面,允许在程序运行时更改这些设置,但这留给读者作为练习。

华夏公益教科书