首先,讲讲外设的基本构成。每个外设都有一个控制器,这个控制器是数字电路,控制器里有一些叫 “寄存器” 的存储单元,这些东东的物理结构跟内存单元不一样,但作用跟内存单元一样,都能保存信息。寄存器各有各的作用,比如:软驱、硬盘上有保存磁头号、磁道号、扇区号等参数的寄存器,这些寄存器的值告诉硬盘这次读磁盘操作要读的是哪个盘面哪个磁道哪个扇区的数据。根据寄存器的作用,可将寄存器分为两类,分别叫控制寄存器和状态寄存器。控制寄存器用来告诉外设: CPU要求它干什么活以及它干活时需要的参数;状态寄存器用于外设向 CPU报告外设目前的状态,比如,外设目前在干什么活,在干活的过程中是否发生了错误,外设是否还有能力接受新任务等等,状态寄存器没有能力主动告诉 CPU外设当前的状态,而是被动地等待着 CPU来取状态信息,CPU把状态寄存器的值读出来就能知道外设当前的工作状态。当然,外设也有主动报告 CPU的能力—— 中断。寄存器有的是只写的,有的是只读的,还有的是可读可写的。一般而言,控制寄存器是只写或可读可写的,状态寄存器是只读的。除了控制器外,大多数外设还有一个用来具体干活的模拟电路,如硬盘有控制磁头移动、盘片转动的模拟电路,打印机有控制打印纸滚动,控制喷墨或打印针击打打印纸的模拟电路, MP3有数模转换器和功率放大器等等。控制器和模拟电路通常是集成在一块芯片里,这种集成电路叫数模混合电路。数模混合电路是目前 IT领域颇具挑战性的技术之一,如果某天你能设计数模混合电路了,那么恭喜你,这辈子你再也不用愁吃穿住行了!当然,也有纯数字电路的外设,如 DMA控制器。以前的外设由于技术不成熟,其控制器、模拟电路、电机等部件是分离的,现在大多数外设把控制器、模拟电路及电机、盘片(如果有的话)等等各个部件集成在一起,如硬盘。有的外设只是把控制器、模拟电路及电机集成在一起,盘片是可移动的,如光驱、软驱。这种把控制器、模拟电路及电机等部件集成在一起的外设称为智能外设。
那么,怎样保证CPU能一个不漏地控制多个外设呢?原来,多个外设和 CPU都挂在一组总线上,硬件工程师给外设的每个寄存器都分配一个地址, CPU拿一个地址去访问某个寄存器时,只有该寄存器发生动作,或接收数据总线上的数据(对应于写操作),或把自己的数据送到数据总线上(对应于读操作),同一个外设的其他寄存器和其他外设的寄存器都不会动作。这样, CPU用不同的地址就可以访问不同的寄存器,也就可以一个不漏地控制多个外设了。 CPU访问某个寄存器时,别的寄存器不会发生动作,所以,外设之间不会 “打架” 、不会互相干扰。同样地, CPU访问内存时,其地址不是外设的寄存器的地址,所有的外设都不会动作,所以 CPU不会误操作外设。
根据外设的基本结构,你是否已经猜到 CPU控制外设的能力了?显然, CPU控制外设的方法和能力无非就是读写寄存器。比如, CPU要从硬盘读文件,那么CPU只需要把磁头号、磁道号、扇区号、要读的数据量等参数填入硬盘控制器的对应寄存器,然后向硬盘控制器的对应寄存器填一个开始命令,硬盘控制器就命令接在其后面的模拟电路开始工作 ——如:控制电机移动磁头到对应的磁道、对准扇区,读数据等等。至于磁头目前在什么位置,怎样移动到对应的磁道,顺指针移动还是逆时针移动,以多快的速度移动,磁头移动到对应磁道后以多大的加速度减速等等,这些事情不是 CPU所能控制的,而是由硬盘控制器和接在硬盘控制器后面的模拟电路共同控制的。遗憾的是,集成电路和印制电路板( PCB板)的技术已经很成熟,硬盘控制器、接在硬盘控制器后面的模拟电路以及磁头、盘片、控制磁头移动的电机等部件早已集成在一个小小的长方体盒子里,我们已经没有机会一睹各个部件的芳容了。总之, CPU只能控制外设中数字部分的程序员可见的寄存器,无法控制程序员不可见的寄存器,更加无法控制模拟电路、电机等部件,也就是说 CPU只能告诉外设要干什么活以及干活过程中需要的参数,至于外设是怎么干活,如:硬盘怎么移动磁头、音频芯片怎么把数字信号转成模拟信号,怎么把模拟信号放大等等,这些事情是 CPU无法控制的。
外设一般有两种方式报告 CPU外设的工作状态——程序查询方式和中断方式。程序查询方式就是利用状态寄存器报告 CPU外设的工作状态,外设只需要把其工作状态的信息填到状态寄存器里,可惜的是状态寄存器没有能力主动告诉 CPU它里面的值是多少,而只能被动地等待着 CPU读取它的值。所以,CPU需要不断地读取状态寄存器,来判断外设是否已经干完活。显然,这种方法的效率很低,程序每让外设干一次活就得不断查询状态寄存器,一直在做无用功,无法把 CPU时间让给别的进程,直到外设干完活后,程序才能往下执行。中断方式要求外设具有向 CPU发送中断请求的能力,外设每次干完活后就主动向 CPU发中断请求,注意是主动发中断请求,可惜的是,中断请求只能告诉 CPU外设已经干完活,至于在干活的过程中外设是否发生错误,外设的空闲缓冲区还剩多少等其他信息无法在中断请求中表达,所以中断方式也离不开状态寄存器, CPU响应中断后,可以读一下状态寄存器,以了解外设的更多更详细的信息。由于中断方式是主动方式,所以进程让外设干活后就可以把 CPU时间让给别的进程,外设干完活后,中断处理程序会唤醒该进程,这就是中断方式比程序查询方式高效的原因。
下面,讲讲多个进程怎样共享外设。从共享的角度划分,外设分为共享设备和独占设备。共享设备就是在某个活没干完时,别的进程可以让该设备干别的活,如进程 A要从硬盘读10MB 的数据,读完 8MB数据时,进程B要求硬盘读 5MB数据给它,这时磁盘调度算法可能让硬盘先把 B需要的5MB 数据读给 B,回头再给A 读最后的 2MB数据,具有硬盘这种特点的设备就叫共享设备。独占设备就是外设在干某个活时,一定要先干完这个活才能干别的活,如打印机正在打印进程 A的文档,那么在打印A的文档的过程中,打印机不能给其他进程打印东西,否则,打印出来的东西就面目全非了,具有打印机这种特点的设备就叫独占设备。下面,我们以打印机为例来说明多个进程怎样共享 “独占设备” 的。操作系统可以设置一个打印队列,准备一个打印机的驱动程序 C,打印机每打印完一个作业时,给 CPU发中断,CPU 响应中断,转入内核态,并跳到 C执行,C 把该作业对应的进程唤醒,从打印队列里取出一项新作业,把相关参数如待打印数据的开始地址、数据量等,填到打印机的对应寄存器里,然后发一个 “开始” 命令,打印机开始打印新的作业,打印完后再给 CPU发中断,如此周而复始地工作。某个进程想打印数据是,调用相应的 API函数D ,D把待打印的数据组织成一个打印作业,插入到打印队列的末尾,把进程状态设为挂起状态,然后调用进程调度函数切换别的进程执行,在以后的某个时刻,该进程的作业被打印完, C随即把该进程唤醒,将进程状态设为就绪状态,该进程就能往下执行了。 OK,独占设备到此结束,下面以硬盘为例讲讲多个进程是怎样共享 “共享设备” 的。硬盘在其控制器上设置有一个缓冲区用来暂时保存从盘片读来的数据或从内存写过来的将要写到盘片去的数据。缓冲区的大小有限,如 8MB,而读写的文件可能很大,如一个视频文件可能有几百 MB大,所以,一个读写作业可能需要读写多次才能完成。同样地,操作系统需要设置一个类似于刚才所说的 “打印队列” 的数据结构用来记录各个进程待读写的数据,需要准备一个硬盘中断处理程序 E。硬盘完成一次读写后给 CPU发中断,CPU 转入内核态并跳到 E执行,如果是写操作,E把硬盘缓冲区里的数据搬到内存,然后根据某种磁盘调度算法,如:先来先服务、电梯算法、最短寻道优先等算法从各个读写作业中调一个它认为最好的作业出来,并命令硬盘处理该作业。如果在某次中断处理过程中发现某个进程的待读写数据的剩余数据量为0 ,则表明该进程的读写作业已经完成, E把该进程唤醒,并把进程状态设为就绪状态,该进程就能往下执行了。
主板上的接口个数有限,怎样保证各种离奇古怪的外设能连接主板并跟主机通信呢?答案是标准接口。主板上只设置了所谓的标准接口,如 IDE接口、串口、并口、PS/2接口、 USB接口、PCI 接口等等,至于你拿 USB口接打印机还是游戏手柄还是数码相机还是别的什么东东,主板就管不了了。如果你想做一个新外设,那么首先要考虑好用什么接口跟主板连接,当然只能从标准接口里选择,然后还要写一个驱动程序,把外设连同驱动程序一起给用户,用户就能使用该外设了。当然,操作系统自带了常用外设的驱动程序,据说 windowXP自带了2000 多个驱动程序,晕,怪不得弄得 windows越来越大,有些驱动程序可能我们一辈子也用不上,可它偏偏躺在那占用我们的硬盘空间!
我们经常说,电脑开机时 BIOS首先要进行自检,即检查电脑连着什么外设,这些外设是否能正常工作,如果某个外设出现故障, BIOS还能根据不同的故障发出不同的报警声。 BIOS也是一段程序,它凭什么能做到上面所说的事情呢?我们自己写一段程序,是不是也能做到上面所说的事情呢?不要急,请听我慢慢道来。原来,人们在设计外设时就考虑了自检功能,如鼠标设置了一个查询 /应答命令,BIOS 检查电脑是否连着鼠标时只需要向鼠标对应的寄存器发一个查询命令,如 0xaa。如果电脑连着鼠标,鼠标就把此查询命令原封不动地送到另一个寄存器 F,然后,BIOS 再读F的值,如果 F的值是0xaa ,则表明鼠标存在,否则,读进来的值就是 0xff或0x00 ,这表明鼠标不存在。如果你熟悉数字电路,你一定知道为什么此时读进来的值会是 0xff或0x00 。现在,你清楚 BIOS怎样检查外设是否存在了吧。那么, BIOS怎样检查存储体如内存、硬盘的大小呢?对于内存, BIOS从0 地址开始,每隔 1KB的间隔写一个数(如0xaa)到内存,然后再从这个地址读数,如果读出来的数跟写进去的数相等,则表明这 1KB的内存是存在的,据此把内存容量增加 1KB,如果你的电脑比较慢,你可以在电脑开机时看到屏幕上显示的检测到的内存容量是以 1KB的步长不断增大的。对于 32位CPU 而言,只要在 0~4GB的地址范围检查一遍,就能知道内存的大小。 BIOS怎么检查硬盘的大小呢?不会也象检查内存一样写一遍硬盘吧?如果写一遍硬盘岂不是把硬盘原来的数据给擦了???当然不会写一遍硬盘!还记得上面提到的智能外设吗?原来,智能外设里一般有一些只读的寄存器保存着这个外设的配置信息,硬盘里就要这样的寄存器保存着该硬盘的大小,BIOS只需要读一下该寄存器就知道硬盘的大小了。由于硬盘的盘片是固定的,一旦出厂,硬盘的容量是不变的,所以 BIOS读到的硬盘大小是不会错的。那么,光盘和软盘呢?它们可不是固定的?我拿来一张光盘,你怎么知道光盘的容量?答案是工业标准。虽然从理论上说,一张光盘的容量可以是任意值,如 1.23MB,可惜工业标准规定了这种容量是非法的,工业标准只允许光盘的容量是少数几个值,如 VCD的容量是700 多MB, DVD的容量是4000 多MB,把一张光盘插入光驱后,光驱先检测该光盘是 VCD格式还是DVD 格式(这可以从数据密度不同检查出来),并据此判断该光盘的容量。如果你有能力制作光盘,你当然可以制作一张容量只有 1.23MB的光盘,只可惜这张光盘违反了标准的规定,别人都不懂怎么使用这张光盘罢了。说了这么多,你清楚 BIOS怎样检测外设了吗?你能自己写一段程序,象 BIOS那样检测外设了吗?我想这两个问题已经难不倒聪明的你了,但你是否看到了 BIOS自检的一些缺陷呢?比如,我的内存的地址为 1500的存储单元坏了,BIOS能检测到吗?又如,鼠标虽然能应答查询命令,但保存鼠标移动量的寄存器坏了, BIOS能检测到吗?答案当然是不能。所以,如果 BIOS发出报警声,电脑一定有问题; BIOS没发出报警声,电脑也有可能有问题,这种问题更让你郁闷,因为你根本不知道哪出了问题。偶的同学就遇到过装系统时,装了一半就莫名其妙地不动了,检来检去原来是内存坏了一个单元,狂晕!
最后,我们以C语言为例,讲讲高级语言怎样支持驱动程序的编写,使程序员的开发效率更高。编写驱动程序无非就是读写外设的寄存器,那么在 C语言里怎样读写外设的寄存器呢?在内存空间和 I/O空间统一编址的CPU中(如采用 ARM、MIPS 架构的CPU),只要定义一个指针就能象访问普通变量一样访问寄存器,如某个寄存器是 8位宽,地址为10000,则在 C语言中,你可以象下面这样访问这个寄存器:
#define (*((volatile unsigned char*) 10000)) a
a=100; //写寄存器
b=a; //读寄存器
对于上面的例子, (volatile unsigned char*) 10000)表示定义一个值为10000的指针,这个指针的类型是 unsigned char型,也就是8 位宽,如果你想访问的寄存器是 16为宽,那类型可以定义为 unsigned short int,如果是32 位宽,类型可以定义为 unsigned int。volatile的意思是告诉编译器这个指针所指向的值可以不由 CPU赋值就能改变,编译器不能优化与此值有关的代码。有关 volatile的详细用法及编译优化的内容请看我的另一篇文章(还没写,所以没法在此定题目,呵呵)。 *((volatile unsigned char*) 10000)的意思是取指针所指向的存储单元的值,跟我们经常用的 *p是一个道理。(*((volatile unsigned char*) 10000))中最外面的括号是为了保证编译器正确理解我们的宏而添加的。因为 C语言的宏只是进行简单的替换,如果不在宏的外面加括号,宏被替换后,其意义可能就变了。请看下面的例子:
#define t 20+30
h=t*10;
程序员的原意是让 t的值为20+30 ,即50,然后拿 50乘以10 ,结果是 500。可惜宏被替换后,h=t*10就变成了 h=20+30*10,执行完这个语句后,h的值是 320,而不是500 !!!现在,你体会到在宏定义的最外层加括号的重要意义了吗?
现在,我们清楚了在内存空间和 I/O空间统一编址的CPU中怎样访问寄存器了,可惜我们最常用的 intelCPU却是把内存空间和I/O空间分别编址的,其实 “最常用” 这个词很不准确, ARM、MIPS 等嵌入式 CPU比intel 的CPU用得更广泛,只不过不搞嵌入式的朋友对这些真正最常用的 CPU不熟悉罢了。嘿嘿,又扯远了,还是说说 intelCPU怎样访问外设的寄存器吧,很遗憾目前我只知道用内嵌汇编在 intelCPU中访问外设的寄存器,但我想 C语言编译器可以增加一个关键词,用来指示某个变量或指针是位于 I/O空间的,这样就可以在C语言中象访问普通变量一样访问外设的寄存器了。
外设的一个寄存器可能用来表示多种意义,如:某个 8位宽的寄存器表示的意义可能是这样的:权值最高的 3位表示外设的工作模式,次高的 3位表示工作速度,最低两位表示传输方式。现在你想让这个外设用某种工作模式、工作速度和传输方式工作,你怎样填写这个寄存器呢?一种直观的方法就是用移位、与、或等位操作的方法拼凑好这个命令,然后一次性地把命令填到寄存器中。显然,拼凑的方法比较繁琐,容易出错,并且寄存器各位表示的意义在源代码中体现不出来。幸运的是, C语言对这种操作进行了支持,你可以象下面这个例子这样快速、高效地组织一个命令:
struct command
{
unsigned char work_mode : 3;
unsigned char work_speed : 3;
unsigned char transfer_mode : 2;
};
在上面的结构体定义中,冒号后面的数字表示该域所占的二进制位,我们暂且称之为位段,各个位段是挨在一起的。定义一个类型为 command的结构体A 后,我们就能象访问一个普通结构体那样去访问各个位段了。下面我们组织一个命令:
A.work_mode=3; // 填好工作模式
A.work_speed=2; // 填好工作速度
A.transfer_mode=3; // 填好传输模式
我们用3句话就组织好了一个命令,这显然比拼凑的方法高效,更加重要的是,这种方法在源代码中体现了各个位段表示的意义,也就是增加了源代码的可读性,不要小看这点哦,它能大大减少程序员由于疏忽所犯的错误!!!我认为大名鼎鼎的 C++的最大功绩就是强迫程序员增加源代码的可读性,从而大大减少程序员犯错误的概率。
OK,我能写的也就这么多了,写得好累,希望这篇文章能使读者对外设和驱动程序有一个初步的认识,有一些启发作用,那我就心满意足了。