嵌入式系统驱动程序的开发有别于WIndows或Linux。后者除了必须了解新设备的硬件特性,把控制硬件的程序尽快完成之外,还需要设法让驱动程序符合Windows或Linux的规定(大且复杂的架构)。但在嵌入式系统中,往往是先设计驱动程序再有系统,所以只需要致力于驱动各个外围设备,而且嵌入式产品一般没有后期新增硬件的需求,所以不要求驱动程序编写的有扩展性,只需要逻辑清晰,简单明了就可以。
往往驱动程序分为两层:Driver层和API层。前者是真正驱动硬件设备的程序,后者是负责与系统或应用程序交互的接口,对外隐藏硬件的特性和细节。以后若要更换硬件,只需要修改驱动程序的Driver层,API层不用动,进而整个应用程序和系统程序也不必修改。
以下是一个简单的驱动程序示例,用于为上层应用提供关中断的功能。
// 驱动程序内部函数(Driver层)
void hw_clear_interrupt_flag(void)
{
//设定CPU状态寄存器内的某位
//
asm("pushn %r0");
asm("ld.w %r0, %psr");
asm("xand %r0, 0xffffffef");
asm("ld.w %psr, %r0");
asm("popn %r0");
}
// 驱动程序对外接口(API层)
void drv_clear_interrupt_flag(void)
{
hw_clear_interrupt_flag();
}
驱动程序的API是由固件组根据硬件特性与最终产品的需求,先行定义驱动程序的API的初稿,然后交由系统组参考,再根据系统组的反馈修改增删API。
驱动程序再整个系统开发中是属于没机会发挥个人创意的工作,必须按照CPU或外围IC的规定步骤进行开发编写。
以下是嵌入式驱动开发应该设法取得的资源:
驱动程序的开发一般会采用C语言书写,在需要的地方内联汇编即可:
volatile 变量:使用C语言的指针,并设定为volatile类型。
内联汇编:对CPU内部寄存器无法用C语言语句对其操作,故使用asm("...");
语句。
ISR的写法:开始要先将CPU所有寄存器值压栈,结束前要恢复这些寄存器的值;且返回被中断程序中要使用interrupt return指令reti
。但C编译器会为所有用__interrupt__
修饰的函数自动加上存储/回存寄存器的指令,并且用reti
返回。
/* 使用了“__interrupt__”描述的函数经C编译器处理后,产生的汇编语言指令
会自动加上存储/回存寄存器的指令,并且用`reti`返回。*/
__interrupt__ void ISR(void)
{
INT_handler();
}
不同CPU类型的汇编语法不同。
//X86家族CPU对寄存器AX赋值9的语法
mov AX, 9
//其它一些CPU对寄存器r1赋值9语法
ldw 9, %r1
大部分程序效率不佳的原因多与算法或数据存取的性能问题,而不是语言的选择问题(采用汇编或C语言);
非要用汇编语言去改写某程序模块时(一是CPU寄存器无法用C语言表示时,二是局部性能调优时),通常是对性能要求相当严格的应用。
当真的需要你用汇编语言编写某个功能时,可以先用C语言实现该功能,然后通过添加-S选项进行编译,得到代码的汇编版本,然后再仔细查看该段汇编语言性能可调整的地方。
除了操作系统外,其它系统程序和应用程序只能通过Driver层提供的API接口间接操作硬件。所以在嵌入式系统项目开发初期,驱动程序设计者就要根据产品规格和硬件架构开始设计Driver API。要将重心放在系统与应用的需求上;对于硬件设计,我们只要确定其能达到这些API 的功能,至于电路细节不会影响API的设计。
实际上,我们可以把 .h 头文件和包含空函数的 .c 源文件先写好,这样就不会影响系统的编译,其它程序可以同步开发。该层也成为硬件抽象层HWL,因为其上层程序都与硬件无关。
现在用于日全食系统的CPU一般会整合许多外围设备(LCD、NAND Flash、SDRAM、USB控制器等等)进去以降低成本和设计难度。下图为一个嵌入式下图的CPU内部架构图:
所以,CPU就是驱动工程师首要处理的最重要的设备,下文将一一说明CPU需要设定的功能。
相同CPU core的内部寄存器和汇编语言规则是一样的,但不同的型号有可能搭配的外围设备会有所区别。下图为某种嵌入式系统CPU寄存器列表:
通常可以将CPU内部寄存器分为可直接赋值的寄存器(如通用寄存器、堆栈指针寄存器、状态寄存器)和只读寄存器(程序计数寄存器Program Counter),它只能通过jump或call来改变执行顺序。
外围设备分为CPU外部的分立设备和整合进CPU内部的 In-Chip 外围设备。前者通过CPU的Pin脚来相互间传递控制或数据信息;而后者则需要通过CPU内部的特定寄存器(Memory Mapping 寄存器)来传递控制和数据信息,此处的寄存器与CPU内部寄存器是完全不同的性质,CPU内部寄存器有自己的名字,而内部外围设备的寄存器只有一个地址,并将这种控制内置chip的方式称为meory mapping I/O!例如某内置外围设备寄存器的地址为0x402CF,则赋值给该寄存器的语句可写为:(volatile unsigned char *)0x402CF = 0x0;
CPU内部的Memory Mapping Register除了控制内置外围设备的寄存器、操作CPU Core的基本功能外,还可以控制CPU的Pin引脚(I/O端口)。一般为了便于阅读和编写,会在头文件中将这些特殊地址定义宏常量:
// ALE与CLE是CPU与NAND Flash chip连接的两根PIN脚
// 在控制NAND Flash时要频繁地将其拉高或拉低。
//
#define SET_ALE_H *(volatile unsigned char*)0x402d9 |=0x20
#define SET_ALE_L *(volatile unsigned char*)0x402d9 &=0x20
#define SET_CLE_H *(volatile unsigned char*)0x402d9 |=0x10
#define SET_CLE_L *(volatile unsigned char*)0x402d9 &=0xef
但也有争议之处,有些书建议直接使用地址加备注的方式去写代码,这些仁者见仁智者见智。因为有些内置IC的寄存器实在太多,一个个去编名字很难,且别人阅读的时候不见得就能理解你名字的含义,还是要通过名字查看头文件得到地址,再通过地址查看datasheet后明白寄存器的意义,所以不如索性在代码中直接使用地址。
除了Memory Mapping 寄存器,在一些高级CPU中(例如X86系列),会使用Port I/O的方式。所谓Port I/O也是一段连续的地址空间,但是它与CPU 的内存空间是相互独立编址的,CPU不能以地址或指针的方式对其进行存取,而必须通过特殊的指令(X86系列提供in
和out
指令),从某个特定的port进行数据读写。例如PC上的RS232就是利用Port 0x3F8去读取数据。
驱动程序在控制中断系统时要注意的事项:
dummy()
表示),如果系统中有空的ISR被执行了,说明系统中发生了不该发生中断,表示硬件设计有错误或静电产生误动作,必须通知硬件人员处理。硬件工程师在把板子交给固件工程师之前需要验证各模块的供电电压是否正确,确认输入给CPU 的时钟频率是否正确。这两个条件是CPU运行起来的基本条件。
一般板子上会有两个振荡器电路,它们分别产生一个固定频率的时序输入给CPU,其中频率较慢(一般为32768Hz)用于待机时为CPU提供时序。而频率较快(48MHz左右)的 则会通过CPU内部的PLL电路升频或降频来为其它模块提供时钟。而有些外部芯片的时序是不需要与CPU同步的,此时外部芯片可以有另一个独立的震荡源,此时CPU与该外部芯片可采用序列、并行传输、IIC、IIS等通信协议沟通。
一般来说Bus分为数据总线和地址总线,所有存储器的相应端口都分别接在这两条总线上。同时CPU会将整个地址空间分为若干个域,不同的域代表着不同的地址范围和所接存储器的类型,同时也分别指定了不同的PIN脚作为存储器的片选信号:下图表示了某CPU的地址空间划分:
当然,不同的存储器有不同的存储模式,CPU也有一个专门的内部寄存器用来设定外部存储器的行为模式,如下图所示:
理论上CPU在操作某存储器前要插入几个等待周期,且它是可以计算的,在CPU 的datasheet内都有相关信息,只要知道CPU的时序(如48MHz)及存储器的执行速度(如90ns)就可以得出理论的等待周期(waiting cycle)数。但实际上一般开始我们会将此值设大一点(CPU存取存储器的速度会慢一些),等系统稳定下来后在慢慢调整系统各模块的时序。
在使用CPU各个引脚前,一定要先由硬件工程师给出CPU所有PIN脚的“配置与用途”表格。CPU的各个GPIO可以被用来设定为输入或输出端口,又或者是其它功能的端口。
输入引脚可分为引发中断与不引发中断两种类型。不产生中断的输入引脚比较简单,程序只要通过CPU的寄存器读出该PIN脚目前的状态(高电平还是低电平)即可。如果是可以产生中断的输入引脚,就要查出该PIN脚对应的中断矢量表里的第几个中断,然后要为其调用ISR。
触发中断的属性分为两种:一是边缘触发(Edge Trigger),二是电位触发。
GPIO通过不同的状态和时序组合以达到控制外部IC的目的,以下是某NAND Flash 读操作的时序图:
编写驱动时,就要按照NAND Flash datasheet里的时序图,先依序改变各个PIN脚的点位并对NAND Flash下指令,然后在时序图规定的时间内逐一读取输入引脚的电位,就可以取得NAND Flash的返回值。
以下是IIC接口设备的架构图,因为是序列式通信协议,所以它只需要两根引脚(一个负责时钟。一个负责数据交换)。系统中每个IIC设备都有自己的ID编号,通过这个编号CPU来确定选择通信的设备。
考虑如下一个简单的时序:
当CPU把CS由高电平拉到低电平时,该IC开始动作,经过T1时间后,IC会把数据放到P#1端口上,维持的时间为T2。在这种情况下,程序如何在正确的时间里到P#1上获取正确的数据?
通常执行取数据的时间很短,不会超过T2,假设T1=10ms,那该如果编写相应的驱动程序及如何确定10ms这个时间段呢?
/****************************************************
Function: drv_read_XXXIC_status
return 0 if P#1 is low,1 if P#1 is high
*****************************************************/
int drv_read_XXXIC_status(void)
{
int i;
int P1_status;
//将CS PIN设为低电平
//
drv_set_XXXIC_CS_Low();
//等待10ms
//
for(i=0; i<10, i++)
drv_wait_1ms();
// 读出P#1 PIN的状态
//
P1_status = drv_get_P1_status();
//根据时序图,将CS PIN恢复为高电平
//
drv_set_XXXIC_CS_High();
return P1_status;
}
延时1ms的程序该怎么写?每种CPU均有NOP指令,执行该指令时CPU什么都不做,单纯就是耗掉一个或多个时钟。我来可以循环执行该指令来达到短暂延时的目的,而且不会影响任何其它模块。一般CPU都会有一个类似下图的表,它会告诉你CPU执行各种指令所需的时钟数,而后根据CPU执行频率来计算每条指令花费的时间。
所以一般会编写如下延时函数:
#define COUNTER_PER_1MS 25000
void drv_wait_1ms(void)
{
int counter;
//执行该函数首先要确保不会产生中断
for(counter=0; counter<COUNTER_PER_1MS; counter++)
{
asm("nop");
}
}
上述程序中的COUNTER_PER_1MS的值又该如何确定呢?
我们首先将上述C语言编写的程序转换为汇编代码(可以利用加-S的编译器实现):
;不同的CPU有不同的汇编指令集
;以下程序旨在表达流程
R0 = COUNTER_PER_1MS ; 2个时钟周期
loop_start:
nop ; 1个时钟周期
R0 = R0 -1 ; 1个时钟周期
jump to "loop_start" if R0 != 0 ;4个时钟周期
由上述汇编代码可知,每次循环需要6个时钟周期,所以根据CPU的主频,我们很容易算出执行一次循环需要多少时间。
根据产品特性不同可以区分不同耗电等级,一般可以分为以下3种等级:
一般,省电可以从以下几个方面着手:
halt
或sleep
指令;固件设计人员必须和硬件设计人员沟通电路图的设计,了解电源管理的相关硬件设计是什么:哪些设备具有独立电源、哪些设备的耗电流是否可以单独测量、用哪根PIN脚切换设备的电源开关、CPU各个PIN脚在各个执行状态应如何设定较为省电等。例如:如果硬件设计师将某根控制IC电源开关的PIN设计为低电平关电,但在CPU内部,该PIN脚是被设计为pull-high电阻的,因此要把被拉高的电位维持在低电位以关闭外部IC,反而会耗电更多。
综上:根据软件需求与硬件限制,系统设计者要归纳出这个产品应该包含的所有电源模式,并在系统中设计一个电源管理模块,由其统一管控,解决系统在什么情况下,应该要切换到哪个电源模式,在某个电源模式下,系统中的CPU与各个设备的耗电设定。一般的电源管理框架如下图所示:
驱动程序提供电源管理接口供电源管理模块调用,系统与应用程序必须根据其需求设定系统的耗电状态,但又不需要知道所有设备的细节。即电源管理模块必须提供已经抽象化的电源模式及接口供其它系统模块或程序调用。同时要注意以下情况:
无论是低电压或突然断电,系统能判断到的硬件事件就是电压已经降到某个临界值(warning level 或 fatal level)。此时电池的驱动程序会发送一个低电压事件给系统(系统可以定时检测电压、利用CPU内外部的硬件功能进行低电压检测Brown-Out),通常低电压事情发生后要做的事情需根据系统用途来确定,但不管什么系统,最后一件事都是执行CPU的复位(reset),要么通过复位引脚或复位指令即可。
电池的充电管理主要还是避免过充和过放的状况发生:为了避免过充,当快充满的时候,必须放慢充电的速度,当电池电压达到某个临界值时就必须停止充电。在电池快没电时,电池电压会突然陡降,此时必须停止系统运行或自动关机,以避免电量被过度消耗,造成永久性的破坏。
正确的充电周期是:
为了避免使用者人身意外(电池爆炸),必须尽量在软硬件的设计上把好关,除了基本的CC\CV模式切换之外,还要检测有问题的充电器(输出电压过高、过低、不稳定),必要时停止充电并发出警告!
ADC:外部芯片只要能将模拟的输入(温度、速度、磁场强度、压强、音量、湿度、震动强度、亮度等等)转换为不同的电压输出,再连接到CPU的AD口,则程序就可以算得外界模拟的输入。
DAC:系统在使用DAC IC时,只需要注意DAC IC与CPU之间的接口(IIC或IIS)及数据传递的协议即可。一般有些简单的应用(马达控制、亮度调整、语音输出等)可以直接让CPU模拟信号的方法——PWM(脉宽调制)。如果主控IC没有提供PWM输出,也可以利用定时器控制某IO引脚输出周期(Duty Cycle)的方式来模拟。
如果系统在硬件设计上没有设置reset按键,那么就需要在软件上养一只看门狗,随时监控系统是否死机了,如果司机就启动一个复位中断,该中断的ISR会主动reset系统。但本质上看门狗是一个定时器Timer,它会从系统设定的某个数值开始开始倒数,数到零时就会产生一个最高优先级的中断或直接让CPU reset。
为了避免看门狗复位系统,我们就必须设定一个喂狗程序,在计数器counter倒数到零之前重新设定它的值。所以必须好好规划这个初始值的大小,太小的话可能造成某些正常运行的但又比较耗时的程序还没运行完,就被看门狗误以为系统死机而被误复位了。
在Linux系统中内置了一个Watchdog的功能,用于监视系统的运行。应用程序一旦调用了dev/Watchdog
这个虚拟设备,等同于命令内核启动一个1分钟的定时器,应用程序必须在1分钟内向这个设备写入数据,每次写入数据就会使重新设定counter。若没写入,超时会导致系统reboot。另外,Linux还提供一个称为Watchdog的应用程序,它可以定期对系统以下方面进行检测:
如果某项检测有问题,这个Watchdog应用程序会引发一次软重启(Soft Reboot),它也可以通过dev/Watchdog
来触发Kernel Watchdog的运行。
CPU也是系统的设备之一,当然也需要驱动程序,它必须在其它程序开始执行前就做好CPU的初始化工作。主要包括:
在驱动程序里必须将这些区分为一个个独立的模块,例如Initialization、Timer、SDRAM_Controller、LCD_Controller等,对上层应用而言,他们应该是一个个独立的模块,只要调用这些模块提供的API即可。
在嵌入式设计中,存储器出问题的机会比你想象的多,因为程序都是依赖存储器执行的。一般可以将存储器分为以下几类:
根据不同的应用类型,有许多控制外部IC 的标准:
能证明驱动程序正常运行最直接的方法就是能在正确的PIN脚上用示波器量到和时序图一样的信号。另外有条件的话还可以使用逻辑分析仪来一段时间中的多个PIN脚的信号都记录下来。
能证明驱动程序正常运行最直接的方法就是能在正确的PIN脚上用示波器量到和时序图一样的信号。另外有条件的话还可以使用逻辑分析仪来一段时间中的多个PIN脚的信号都记录下来。