Policy Information
总结来说,就是插上外设后,cpu就可以检测和连接到外设上的寄存器,把它当成内存来使用,然后就是对这些寄存器进行读写,写控制寄存器来控制外设,读状态寄存器来检测外设状态(外设会把当前状态信息放到指定寄存器上),通过读写数据寄存器来交换数据。
一直以来,发现很多搞上层软件的朋友没有时间了解CPU、编译器、操作系统等底层技术,偶恰好在计算机微体系结构与集成电路实验室,有幸接触到这些底层的东东,所以想写一些自己以前学这些东东的感想,以消除对底层技术不熟悉的朋友对底层技术的神秘感,同时想和搞底层技术的朋友切磋切磋,共同提高。当然偶所谈的内容都不是先进或深奥的,而是最直观和最容易理解的,偶所写的文章不是阐述各个专题的专著,而是入门读物,希望读者读完偶的文章后具有读懂各个专题专著的能力。
闲话少说,让我们切入正题。我们从驱动程序出发,慢慢讲解计算机的各个部分是如何各自为政,而又互相协作,从而完全各种复杂功能的。本文不具体阐述如何编写驱动程序,而是从体系结构的观点着手,力争用通俗易懂的语言阐述各种外设的共同特点,使读者具备举一反三、融会贯通、驾驭各种外设的能力。另外,笔者喜欢从不同的角度分析同一个问题,所以行文中难免出现重复的内容,累赘的阐述,笔者正是希望通过这种重复和累赘来加深读者对所述内容的理解。
计算机发展到今天,其外设早已是五花八门,象硬盘、软盘、光盘、U盘、鼠标、键盘、声卡、网卡、SD卡、手柄等等,真是层出不穷。五花八门的外设给我们带来便利的同时也带来了许多问题,比如:
主板上的接口个数有限,怎样保证各种离奇古怪的外设能连接主板并跟主机通信?
怎样保证CPU能一个不漏地控制外设工作?CPU能够控制什么样的外设?
CPU对外设的控制能达到什么程度?
怎样保证CPU不会误操作外设?
怎样保证外设之间不会“打架”、互相干扰?
外设怎样向CPU报告处理结果?
多个进程怎样共享外设?
高级语言怎样支持驱动程序的编写?
外设怎样给CPU提供配置信息等等,这些问题是否让各位看官头大了?不要紧张,且听我慢慢道来。 首先,讲讲外设的基本构成。每个外设都有一个控制器,这个控制器是数字电路,控制器里有一些叫“寄存器”的存储单元,这些东西的物理结构跟内存单元不一样,但作用跟内存单元一样,都能保存信息。
寄存器各有各的作用,比如:软驱、硬盘上有保存磁头号、磁道号、扇区号等参数的寄存器,这些寄存器的值告诉硬盘这次读磁盘操作要读的是哪个盘面哪个磁道哪个扇区的数据。根据寄存器的作用,可将寄存器分为两类,分别叫控制寄存器和状态寄存器。控制寄存器用来告诉外设: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 unsigned char) 10000)的意思是取指针所指向的存储单元的值,跟我们经常用的p是一个道理。(((volatile unsigned char*) 10000))中最外面的括号是为了保证编译器正确理解我们的宏而添加的。因为C语言的宏只是进行简单的替换,如果不在宏的外面加括号,宏被替换后,其意义可能就变了。请看下面的例子:
define t 20+30
h=t10;
程序员的原意是让t的值为20+30,即50,然后拿50乘以10,结果是500。可惜宏被替换后,h=t10就变成了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,我能写的也就这么多了,写得好累,希望这篇文章能使读者对外设和驱动程序有一个初步的认识,有一些启发作用,那我就心满意足了。
评论