深入理解驱动程序

深入理解驱动程序一直以来,发现很多搞上层软件的朋友没有时间了解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随即把该进程唤 醒,将进程状态设为就绪状态,该进程就能往下执行了0K,独占设 备到此结束,下面以硬盘为例讲讲多个进程是怎样共享“共享设备” 的硬盘在其控制器上设置有一个缓冲区用来暂时保存从盘片读来的 数据或从内存写过来的将要写到盘片去的数据缓冲区的大小有限, 如8MB,而读写的文件可能很大,如一个视频文件可能有几百MB 大,所以,一个读写作业可能需要读写多次才能完成同样地,操作 系统需要设置一个类似于刚才所说的“打印队列”的数据结构用来记 录各个进程待读写的数据,需要准备一个硬盘中断处理程序E硬盘 完成一次读写后给CPU发中断,CPU转入内核态并跳到E执行,如 果是写操作,E把硬盘缓冲区里的数据搬到内存,然后根据某种磁盘 调度算法,如:先来先服务、电梯算法、最短寻道优先等算法从各个 读写作业中调一个它认为最好的作业出来,并命令硬盘处理该作业 如果在某次中断处理过程中发现某个进程的待读写数据的剩余数据 量为0则表明该进程的读写作业已经完成,E把该进程唤醒,并把 进程状态设为就绪状态,该进程就能往下执行了。
主板上的接口个数有限,怎样保证各种离奇古怪的外设能连接主 板并跟主机通信呢?答案是标准接口主板上只设置了所谓的标准接 口,如 IDE 接口、串口、并口、PS/2 接口、USB 接口、PCI 接口等 等,至于你拿USB 口接打印机还是游戏手柄还是数码相机还是别的 什么东东,主板就管不了了如果你想做一个新外设,那么首先要考 虑好用什么接口跟主板连接,当然只能从标准接口里选择,然后还要 写一个驱动程序,把外设连同驱动程序一起给用户,用户就能使用该 外设了当然,操作系统自带了常用外设的驱动程序,据说windowXP 自带了 2000多个驱动程序,晕,怪不得弄得windows越来越大,有 些驱动程序可能我们一辈子也用不上,可它偏偏躺在那占用我们的硬 盘空间!我们经常说,电脑开机时BIOS首先要进行自检,即检查电脑连 着什么外设,这些外设是否能正常工作,如果某个外设出现故障, BIOS还能根据不同的故障发出不同的报警声BIOS也是一段程序, 它凭什么能做到上面所说的事情呢?我们自己写一段程序,是不是也 能做到上面所说的事情呢?不要急,请听我慢慢道来原来,人们在 设计外设时就考虑了自检功能,如鼠标设置了一个查询/应答命令, BIOS检查电脑是否连着鼠标时只需要向鼠标对应的寄存器发一个查 询命令,如Oxaa。
如果电脑连着鼠标,鼠标就把此查询命令原圭寸不 动地送到另一个寄存器F,然后,BIOS再读F的值,如果F的值是 Oxaa,则表明鼠标存在,否则,读进来的值就是Oxff或0x00,这表 明鼠标不存在如果你熟悉数字电路,你一定知道为什么此时读进来 的值会是Oxff或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)) aa=100; 〃写寄存器b=a; 〃读寄存器对于上面的例子,(volatile unsigned char*) 10000)表示定义一个 值为10000的指针,这个指针的类型是unsigned char型,也就是8 位宽,如果你想访问的寄存器是16为宽,那类型可以定义为unsigned short int,如果是32位宽,类型可以定义为unsigned into volatile 的意思是告诉编译器这个指针所指向的值可以不由CPU赋值就能改 变,编译器不能优化与此值有关的代码。
有关volatile的详细用法及 编译优化的内容请看我的另一篇文章(还没写,所以没法在此定题目, 呵呵)volatile unsigned char*) 10000)的意思是取指针所指向的 存储单元的值,跟我们经常用的*p是一个道理volatile unsigned char*) 10000))中最外面的括号是为了保证编译器正确理解我们的宏 而添加的因为C语言的宏只是进行简单的替换,如果不在宏的外 面加括号,宏被替换后,其意义可能就变了请看下面的例子:#define t 20+30h=t*10;程序员的原意是让t的值为20+30,即50,然后拿50乘以10, 结果是500o可惜宏被替换后,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 ,我能写的也就这么多了,写得好累,希望这篇文章能使 读者对外设和驱动程序有一个初步的认识,有一些启发作用,那我就 心满意足了。