嵌入式程序中,有时一个功能模块的使用会跨越多个物理器件。比如DDS芯片9837至少会用到SPI和GPIO。对于这种情形,最简单的做法是直接将所用到的物理寄存器包含在这个模块内部,写死。这样做没有什么问题,但是这样写,系统本身的逻辑代码和物理寄存器读写代码纠缠在一起,可读性和可移植性都不好。
好的做法是这样的,首先要提炼出接口。对于9837而言,它的底层物理器件接口,就是spi和gpio,接口首先要确定下来。对于spi的接口也许可以写作:
spi_out(int channel, unsigned char *buf, int len);
对于gpio的接口,可以是
gpio_set(int channel);
gpio_clear(int channel);
gpio_toggle(int channel);
这是最基本的接口。但是接下来呢?是否需要内部通过switch()来将具体操作导向各个寄存器读写?这种做法也是最容易想到的办法,但是违背了模块封装原则。它把逻辑上没有关联的一些寄存器读写代码汇聚到了一个函数里,当然,你也可以解释他们都从属于一类物理器件,聚集起来也没有什么大不了的。但是它至少破坏了两个程序开发的原则:第一,可移植性变差,如果其他项目需要9837这个模块,代码无法快速地拆分出去。因为SPI和GPIO的代码和别的模块中所使用的代码是混在一起的。另外,可维护性也差,因为如果增添新的器件,你会修改一个既有的函数,这很可能会影响既有功能。
那么正确的做法是什么呢?我们的工具是函数指针,用它来实现依赖反转。接口是不应该依赖于具体实现的(接口无论.h.c文件中都不应该出现与具体实现相关的代码)。具体实现应该依赖接口。具体的做法类似如下的操作,我们以GPIO为例说明:gpio接口除了上述三个接口函数外,额外增添一个初始化函数:
int gpio_init(FN_STD init, FN_STD set, FN_STD clear, FN_STD toggle);
这个函数的返回值,返回的是在三个接口函数中channel句柄,FN_STD的定义为:
typedef void (*FN_STD)();
经过这样改造后的9837代码,逻辑代码与寄存器操作分离了。逻辑代码可以放在9837.c中,移植时无需更改这个文件,但它已经包含了完整的9837芯片的访问逻辑。寄存器操作部分相对独立,可以整合在一个名为9837_low_level.c的文件中,在移植的时候,逐个文件,修改其中的寄存器访问代码即可。寄存器访问代码因为已经依据不同的功能拆分为一个个具有明确语义的小函数,所以移植工作也可以很快完成。
我们下面以代码实例展示一下:
#ifndef GPIO_H #define GPIO_H #define MAX_CHANNEL 4 //回调函数 typedef void (*FUN_STD_GPIO)(void); typedef int (*FUN_CHK_GPIO)(void); typedef int GPIO_CH; //GPIO子系统初始化 void GPIO_Init(void); //GPIO通道初始化 int GPIO_Register(FUN_STD_GPIO fn_init, FUN_STD_GPIO fn_on, FUN_STD_GPIO fn_off, FUN_CHK_GPIO fn_is_on); //GPIO开启 void GPIO_On(GPIO_CH ch); //GPIO关闭 void GPIO_Off(GPIO_CH ch); //GPIO切换 void GPIO_Toggle(GPIO_CH ch);
上面的代码是gpio.h定义的GPIO器件的注册和访问接口。下面是源代码部分:
#include "gpio.h" typedef struct _GPIO_CH_DESC { FUN_STD_GPIO init; FUN_STD_GPIO on; FUN_STD_GPIO off; FUN_CHK_GPIO is_on; }GPIO_CH_DESC, *PGPIO_CH_DESC; GPIO_CH_DESC gGpioCh[MAX_CHANNEL]; int gGpioNextCh = 0; //GPIO子系统初始化 void GPIO_Init(void) { } //GPIO通道初始化 int GPIO_Register(FUN_STD_GPIO fn_init, FUN_STD_GPIO fn_on, FUN_STD_GPIO fn_off, FUN_CHK_GPIO fn_is_on) { if(gGpioNextCh >= MAX_CHANNEL) return -1; gGpioCh[gGpioNextCh].init = fn_init; gGpioCh[gGpioNextCh].on = fn_on; gGpioCh[gGpioNextCh].off = fn_off; gGpioCh[gGpioNextCh].is_on = fn_is_on; fn_init(); return gGpioNextCh++; } //GPIO开启 void GPIO_On(GPIO_CH ch) { if(ch>=0 && ch<gGpioNextCh) gGpioCh[ch].on(); } //GPIO关闭 void GPIO_Off(GPIO_CH ch) { if(ch>=0 && ch<gGpioNextCh) gGpioCh[ch].off(); } //GPIO切换 void GPIO_Toggle(GPIO_CH ch) { if(ch>=0 && ch<gGpioNextCh) { if(gGpioCh[ch].is_on()) gGpioCh[ch].off(); else gGpioCh[ch].on(); } }
可以看到,上面的代码中未出现任何与寄存器读写相关的内容。因为寄存器读写涉及到具体寄存器操作,与GPIO这个接口层定义是无关的。
下面是一个具体的GPIO接口定义,首先是头文件:
#ifndef GPIO_PIN80_H #define GPIO_PIN80_H //特定通道 extern int GPIO_PIN80; //初始化GPIO_PIN80模块; void GPIO_PIN80_Init(void); #endif
头文件中仅包含一个通道句柄,定义了一个特定管脚PIN80对应的GPIO通道号;以及一个初始化函数,初始化函数中不涉及任何与GPIO接口初始化相关的知识,这些东西仅对制作这个小模块的模块作者是可见的,对用户是隐藏的。最后是这个小模块的实现函数:
#include "LPC17xx.h" #include "gpio.h" #include "gpio_pin80.h" int GPIO_PIN80 = -1; void GPIO_PIN80_Init_Inner(void); void GPIO_PIN80_On(void); void GPIO_PIN80_Off(void); int GPIO_PIN80_IsOn(void); void GPIO_PIN80_Init(void) { GPIO_PIN80 = GPIO_Register(GPIO_PIN80_Init_Inner, GPIO_PIN80_On, GPIO_PIN80_Off, GPIO_PIN80_IsOn); } void GPIO_PIN80_Init_Inner(void) { LPC_PINCON->PINSEL0 &= (~((0x11)<<10)); //pin80. p0.5 gpio LPC_GPIO0->FIODIR |= (1<<5); GPIO_PIN80_On(); } void GPIO_PIN80_On(void) { LPC_GPIO0->FIOCLR |= (1<<5); } void GPIO_PIN80_Off(void) { LPC_GPIO0->FIOSET |= (1<<5); } int GPIO_PIN80_IsOn(void) { return (LPC_GPIO0->FIOPIN & (1<<5)) ? 0 : 1; }
实际使用该模块的代码为:
//... GPIO_PIN80_Init(); //... while(1) { GPIO_Toggle(GPIO_PIN80); //... }
It‘s beautiful, right?