我用 Linux嵌入式 Linux C ARM Linux学习|Gentoo/Arch/FreeBSD

细说嵌入式驱动程序设计

2020-11-29  本文已影响0人  Leon_Geo

嵌入式系统驱动程序的开发有别于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的规定步骤进行开发编写。

(1)收集可有资源

以下是嵌入式驱动开发应该设法取得的资源:

(2)用C语言写驱动程序

驱动程序的开发一般会采用C语言书写,在需要的地方内联汇编即可:

(3)Driver API 设计

除了操作系统外,其它系统程序和应用程序只能通过Driver层提供的API接口间接操作硬件。所以在嵌入式系统项目开发初期,驱动程序设计者就要根据产品规格和硬件架构开始设计Driver API。要将重心放在系统与应用的需求上;对于硬件设计,我们只要确定其能达到这些API 的功能,至于电路细节不会影响API的设计。

实际上,我们可以把 .h 头文件和包含空函数的 .c 源文件先写好,这样就不会影响系统的编译,其它程序可以同步开发。该层也成为硬件抽象层HWL,因为其上层程序都与硬件无关。

二、控制CPU

现在用于日全食系统的CPU一般会整合许多外围设备(LCD、NAND Flash、SDRAM、USB控制器等等)进去以降低成本和设计难度。下图为一个嵌入式下图的CPU内部架构图:

image-20201127130131165

所以,CPU就是驱动工程师首要处理的最重要的设备,下文将一一说明CPU需要设定的功能。

(1)内部寄存器

相同CPU core的内部寄存器和汇编语言规则是一样的,但不同的型号有可能搭配的外围设备会有所区别。下图为某种嵌入式系统CPU寄存器列表:

image-20201127130638352

通常可以将CPU内部寄存器分为可直接赋值的寄存器(如通用寄存器、堆栈指针寄存器、状态寄存器)和只读寄存器(程序计数寄存器Program Counter),它只能通过jump或call来改变执行顺序。

(2)Memory Mapping 寄存器

外围设备分为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系列提供inout指令),从某个特定的port进行数据读写。例如PC上的RS232就是利用Port 0x3F8去读取数据。

(3)中断处理器

驱动程序在控制中断系统时要注意的事项:

(4)Clock

硬件工程师在把板子交给固件工程师之前需要验证各模块的供电电压是否正确,确认输入给CPU 的时钟频率是否正确。这两个条件是CPU运行起来的基本条件。

一般板子上会有两个振荡器电路,它们分别产生一个固定频率的时序输入给CPU,其中频率较慢(一般为32768Hz)用于待机时为CPU提供时序。而频率较快(48MHz左右)的 则会通过CPU内部的PLL电路升频或降频来为其它模块提供时钟。而有些外部芯片的时序是不需要与CPU同步的,此时外部芯片可以有另一个独立的震荡源,此时CPU与该外部芯片可采用序列、并行传输、IIC、IIS等通信协议沟通。

(5)Bus & Chip Select

一般来说Bus分为数据总线和地址总线,所有存储器的相应端口都分别接在这两条总线上。同时CPU会将整个地址空间分为若干个域,不同的域代表着不同的地址范围和所接存储器的类型,同时也分别指定了不同的PIN脚作为存储器的片选信号:下图表示了某CPU的地址空间划分:

地址范围与Chip Select PIN的对应关系

当然,不同的存储器有不同的存储模式,CPU也有一个专门的内部寄存器用来设定外部存储器的行为模式,如下图所示:

用以设定CPU外部存储器行为模式的寄存器实例

理论上CPU在操作某存储器前要插入几个等待周期,且它是可以计算的,在CPU 的datasheet内都有相关信息,只要知道CPU的时序(如48MHz)及存储器的执行速度(如90ns)就可以得出理论的等待周期(waiting cycle)数。但实际上一般开始我们会将此值设大一点(CPU存取存储器的速度会慢一些),等系统稳定下来后在慢慢调整系统各模块的时序。

(6)GPIO Port

在使用CPU各个引脚前,一定要先由硬件工程师给出CPU所有PIN脚的“配置与用途”表格。CPU的各个GPIO可以被用来设定为输入或输出端口,又或者是其它功能的端口。

输入引脚可分为引发中断与不引发中断两种类型。不产生中断的输入引脚比较简单,程序只要通过CPU的寄存器读出该PIN脚目前的状态(高电平还是低电平)即可。如果是可以产生中断的输入引脚,就要查出该PIN脚对应的中断矢量表里的第几个中断,然后要为其调用ISR。

触发中断的属性分为两种:一是边缘触发(Edge Trigger),二是电位触发。

触发中断的时间点

GPIO通过不同的状态和时序组合以达到控制外部IC的目的,以下是某NAND Flash 读操作的时序图:

image-20201128130254639

编写驱动时,就要按照NAND Flash datasheet里的时序图,先依序改变各个PIN脚的点位并对NAND Flash下指令,然后在时序图规定的时间内逐一读取输入引脚的电位,就可以取得NAND Flash的返回值。

以下是IIC接口设备的架构图,因为是序列式通信协议,所以它只需要两根引脚(一个负责时钟。一个负责数据交换)。系统中每个IIC设备都有自己的ID编号,通过这个编号CPU来确定选择通信的设备。

IIC设备架构和时序图

(7)Nop与延迟时段

考虑如下一个简单的时序:

<img src="https://cdn.jsdelivr.net/gh/Leon1023/leon_pics/img/20201128163300.png" alt="时序图" style="zoom:80%;" />

当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执行频率来计算每条指令花费的时间。

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的主频,我们很容易算出执行一次循环需要多少时间。

(8)电源管理

根据产品特性不同可以区分不同耗电等级,一般可以分为以下3种等级:

一般,省电可以从以下几个方面着手:

  1. CPU方面:
    1. 降频;
    2. 降压;
    3. 通过haltsleep指令;
  2. 关闭暂时不使用的设备电源;
  3. 切换内/外部设备的工作模式(Full Run Mode或Standby Mode)或工作电压;
  4. 将CPU各个PIN脚逐一切换为较省电的模式,特别是一些f复合功能引脚。
    1. 通常GPIO会比其它的设定(AD 、data bus等)省电;
    2. 输出引脚比输入省电;
    3. 将输出引脚设定为低电平比保持在高电平省电;
    4. 如果GPIO有被电路拉高或拉低,逆势而为通常会比较耗电;
    5. 以上原则不一定使用全部CPU,以datasheet或sample code中的说明为准。

固件设计人员必须和硬件设计人员沟通电路图的设计,了解电源管理的相关硬件设计是什么:哪些设备具有独立电源、哪些设备的耗电流是否可以单独测量、用哪根PIN脚切换设备的电源开关、CPU各个PIN脚在各个执行状态应如何设定较为省电等。例如:如果硬件设计师将某根控制IC电源开关的PIN设计为低电平关电,但在CPU内部,该PIN脚是被设计为pull-high电阻的,因此要把被拉高的电位维持在低电位以关闭外部IC,反而会耗电更多。

综上:根据软件需求与硬件限制,系统设计者要归纳出这个产品应该包含的所有电源模式,并在系统中设计一个电源管理模块,由其统一管控,解决系统在什么情况下,应该要切换到哪个电源模式,在某个电源模式下,系统中的CPU与各个设备的耗电设定。一般的电源管理框架如下图所示:

Power Manager系统架构图范例

驱动程序提供电源管理接口供电源管理模块调用,系统与应用程序必须根据其需求设定系统的耗电状态,但又不需要知道所有设备的细节。即电源管理模块必须提供已经抽象化的电源模式及接口供其它系统模块或程序调用。同时要注意以下情况:

(9)断电前处理

无论是低电压或突然断电,系统能判断到的硬件事件就是电压已经降到某个临界值(warning level 或 fatal level)。此时电池的驱动程序会发送一个低电压事件给系统(系统可以定时检测电压、利用CPU内外部的硬件功能进行低电压检测Brown-Out),通常低电压事情发生后要做的事情需根据系统用途来确定,但不管什么系统,最后一件事都是执行CPU的复位(reset),要么通过复位引脚或复位指令即可。

(10)充电(Charger)控制

电池的充电管理主要还是避免过充过放的状况发生:为了避免过充,当快充满的时候,必须放慢充电的速度,当电池电压达到某个临界值时就必须停止充电。在电池快没电时,电池电压会突然陡降,此时必须停止系统运行或自动关机,以避免电量被过度消耗,造成永久性的破坏。

正确的充电周期是:

  1. 小电流启动:为了避免电池电压过低时,用大电流充会对电池有影响。
  2. 定电流充电(CC):使用固定电流,将电池快速充到一个特定电压后,此时就应该放慢充电的速度。
  3. 定电压充电(CV):当电池使用定电流充到某个电压值后,必须使用小电流来慢慢充电,否则会因为电池的内阻效应,在大电流时造成一个电压差,进而影响电池的饱和度。通常需要控制充电电压为电池的额定电压正负50mV以内。

<img src="https://cdn.jsdelivr.net/gh/Leon1023/leon_pics/img/20201128215216.png" alt="充电曲线图" style="zoom:80%;" />

为了避免使用者人身意外(电池爆炸),必须尽量在软硬件的设计上把好关,除了基本的CC\CV模式切换之外,还要检测有问题的充电器(输出电压过高、过低、不稳定),必要时停止充电并发出警告!

(11)ADC & DAC

ADC:外部芯片只要能将模拟的输入(温度、速度、磁场强度、压强、音量、湿度、震动强度、亮度等等)转换为不同的电压输出,再连接到CPU的AD口,则程序就可以算得外界模拟的输入。

DAC:系统在使用DAC IC时,只需要注意DAC IC与CPU之间的接口(IIC或IIS)及数据传递的协议即可。一般有些简单的应用(马达控制、亮度调整、语音输出等)可以直接让CPU模拟信号的方法——PWM(脉宽调制)。如果主控IC没有提供PWM输出,也可以利用定时器控制某IO引脚输出周期(Duty Cycle)的方式来模拟。

image-20201129114937511

(12)看门狗

如果系统在硬件设计上没有设置reset按键,那么就需要在软件上养一只看门狗,随时监控系统是否死机了,如果司机就启动一个复位中断,该中断的ISR会主动reset系统。但本质上看门狗是一个定时器Timer,它会从系统设定的某个数值开始开始倒数,数到零时就会产生一个最高优先级的中断或直接让CPU reset。

为了避免看门狗复位系统,我们就必须设定一个喂狗程序,在计数器counter倒数到零之前重新设定它的值。所以必须好好规划这个初始值的大小,太小的话可能造成某些正常运行的但又比较耗时的程序还没运行完,就被看门狗误以为系统死机而被误复位了。

在Linux系统中内置了一个Watchdog的功能,用于监视系统的运行。应用程序一旦调用了dev/Watchdog这个虚拟设备,等同于命令内核启动一个1分钟的定时器,应用程序必须在1分钟内向这个设备写入数据,每次写入数据就会使重新设定counter。若没写入,超时会导致系统reboot。另外,Linux还提供一个称为Watchdog的应用程序,它可以定期对系统以下方面进行检测:

如果某项检测有问题,这个Watchdog应用程序会引发一次软重启(Soft Reboot),它也可以通过dev/Watchdog来触发Kernel Watchdog的运行。

(13)CPU初始化

CPU也是系统的设备之一,当然也需要驱动程序,它必须在其它程序开始执行前就做好CPU的初始化工作。主要包括:

  1. 设定CPU内部寄存器(PSR与SP等);
  2. 设定中断矢量表;
  3. 设定CPU内部各单元(CPU core以及内置设备)的时钟;
  4. BUS设定:设定各个外部存储器的特性,包含要插入几个Waiting cycle、操作该存储器的基本单位宽度(16bit或32bit)等
  5. 设定CPU各PIN脚的用途
  6. 设定CPU的执行模式,刚开始应该是在full run mode
  7. 其它内部设备的初始化。

在驱动程序里必须将这些区分为一个个独立的模块,例如Initialization、Timer、SDRAM_Controller、LCD_Controller等,对上层应用而言,他们应该是一个个独立的模块,只要调用这些模块提供的API即可。

(14)CPU内部还有什么

  1. RTC:Timer定时器一般是用来做精准计时的(毫秒或微秒级),但RTC是一种用来粗粒度计时的定时器(百分之一秒为单位),程序可通过循环将其调整到1秒甚至1分钟才产生中断,以达到时钟或日历的功能。
  2. DMA:运行外部设备(硬盘控制器、显卡、网卡、声卡等)与存储器直接通信,而不需CPU全程介入。用于嵌入式的CPU都会内置DMA控制器,所以都可以通过寄存器去控制它。在进行数据传输前,驱动程序要先设定好源地址、目的地址、数据量大小。
  3. MMU:内存管理单元,实现虚拟存储空间、页映射等存储器管理功能。一般能运行大型操作系统(Linux等)的CPU都需要具备该功能。
  4. 其它内置IP:一些为特定应用邻域设计的CPU都会内置一些需要用到的功能模块,例如MP3的CPU就会整合译码器、LCD控制器、NAND Flash控制器、USB控制器等。此时一般就不需要CPU的IO口来控制了,厂商会在datasheet里指定专门的寄存器来控制这些设备。

三、存储器

在嵌入式设计中,存储器出问题的机会比你想象的多,因为程序都是依赖存储器执行的。一般可以将存储器分为以下几类:

  1. 可用地址读取的只读存储器(EEPROM、Mask ROM):用来存放执行期间不会更改的程序和数据。程序可在其上直接运行,除非有性能要求,否则没必要载入到内存中。
  2. 可用地址读写的RAM:RAM里的内容可在执行期间被改动,分为两类:
    1. Static RAM:需要持续供电,SRAM里存储的数据才能保存,耗电低,连线简单但价格高。
    2. Dynamic RAM:需要持续动态刷新,DRAM里存储的数据才能保存,所以使用时还需要一个额外的DRAM控制器去设定DRAM容量、存取时序、refresh等特性才能正常使用DRAM。一般为了成本考虑,嵌入式系统会选择SDRAM(Synchronous DRAM),CPU只需要提供持续和稳定的时钟给SDRAM就可以了,且它比DRAM更便宜,一般用于系统的缓冲区。
  3. 可用地址读取,但必须下命令写入的 NOR Flash:它里面的数据虽然可以更改,但不像写RAM(通过地址与数据总线就可以)那么简单,需要CPU对其下命令才行。且对NOR Flash写入必须以块(Block)为单元,一般是16KB或8KB大小,还要先对块做擦除。正因如此,NOR Flash的写入性能很差,一般将其作为ROM的替代品,以免除ROM不可更改的缺陷。另外还可以作为存储或备份执行时期的数据,因为它断电后数据不会丢失。例如用户的配置、录音数据等。
  4. 只能用命令读写的NAND Flash:它不能直接读取和写入,必须使用特定的命令。读取是以页(Page)为单位,一般是0.5KB或2KB大小;写入和擦除则是以块(块是多个页的集合)为单位。但NAND Flash有个问题就是,他和硬盘一样会产生坏道(Bad Block),所以可以把其作为较小的硬盘使用,因为它的造价比NOR Flash要小很多。故NOR Flash通常用来执行程序,NAND Flash则用来存放大量数据。

四、控制其它芯片

根据不同的应用类型,有许多控制外部IC 的标准:

五、ISR编写注意事项

  1. 尽量不要在ISR中使用全局变量,如果一定要用得做好保护(关键段/Critical Section),即进入Critical Section前关中断。
  2. ISR的第一个动作一定是将所有的CPU内部寄存器按顺序push进堆栈,ISR的最后一个动作一定是按相反顺序pop出栈。以恢复CPU内部寄存器的值。
  3. 调用外部函数时要确认其是否“可重入”(可以被多个任务同时调用,而不必担心数据被破坏的程序,即可重入的函数在任何时候都可以被中断,而后继续执行,不会因为在函数中断时被其它任务重新调用而影响函数中的数据),如果一定要在ISR内调用非重入的函数,则一般程序在使用同一个模块的函数时,也要加以保护。
  4. ISR的程序越简单越好,执行时间尽量短,尽量不要牵涉到复杂的算法,它的任务只是负责告知(通过message-queue的数据结构,所有与该数据结构相关的函数或操作都被列为critical section)上级应用发生了什么硬件事件,交由上层应用来决定如何处理;
  5. ISR中调用系统功能时必须谨慎,尤其是与task/thread/process切换有关的功能,如果要调用就调用系统提供给ISR的专用系统功能。

六、驱动程序调试

能证明驱动程序正常运行最直接的方法就是能在正确的PIN脚上用示波器量到和时序图一样的信号。另外有条件的话还可以使用逻辑分析仪来一段时间中的多个PIN脚的信号都记录下来。


上一篇下一篇

猜你喜欢

热点阅读