程序框架设计示例1_裸机
1.前言
报名参加了七天物联网训练营,收获甚多,根据韦东山老师的资料,将一些学到的知识总结下来用于后期的学习。
2. 框架设计
在《代码大全》第5章中,把程序设计分为这几个层次:
-
第1层:软件系统,就是整个系统、整个程序
-
第2层:分解为子系统或包。比如我们可以拆分为:输入子系统、显示子系统、业务系统
-
第3层:分解为类。在C语言里没有类,可以使用结构体来描述子系统。
-
第4层:分解成子程序:实现那些结构体(结构体中有函数指针)。
使用按键控制 LED例子来详细介绍一下代码思路:告诉我们为什么要引入结构体。
3. 按键控制LED
使用按键控制K1控制LED,功能很简单,但是可以探讨很多东西。
提出一些问题:
2.1 耦合太严重
下列代码有很多缺点:
- 暴露了太多细节:需要结合原理图、需要有硬件知识、需要有HAL库知识,才能理解这个程序
- 代码无法复用:换个芯片、换个引脚,代码要全部修改
- 无法扩展:比如想实现长按点灯,"长按"这个动作就不容易扩展
void main(void)
{
GPIO_PinState key; //读取按键
while (1)
{
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6); //读F组引脚
if (key == GPIO_PIN_RESET) //如果等于零的话
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_5, GPIO_PIN_RESET);
//设置LED引脚输出低电平
else
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_5, GPIO_PIN_SET);
//设置LED引脚输出高电平
}
}
这个程序很容易理解,因为你会看原理图,但是有些人不会看原理图,不同部门的人可能会看不明白。这相当于你把硬件锁死了,你换个引脚换个灯程序全部要改。这会造成业务层代码与板级代码严重耦合,对后续的软件功能扩展、硬件升级及代码复用都会产生不便,同时也会对不懂硬件的业务层开发人员造成障碍。
因此,需要将程序结构进行分层,将业务逻辑代码与硬件驱动代码分离:这就提出将一些功能提取出来写一个子函数,这样子更改板子或者业务可以直接调用。
2.2 使用子函数
// main.c
void main(void)
{
int key;
while (1)
{
key = read_key();
if (key == UP)
led_on(); //打开led
else
led_off(); //关闭led
}
}
// key.c 读引脚
int read_key(void)
{
GPIO_PinState key;
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);
if (key == GPIO_PIN_RESET)
return 0;
else
return 1;
}
// led.c 写操作
void led_on(void)
{
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_5, GPIO_PIN_RESET);
}
void led_off(void)
{
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_5, GPIO_PIN_SET);
}
写成子函数解决了耦合问题,但是还有如下缺点:
-
按键无法扩展:想支持多个按键怎么办?想支持长按、短按、双击等等,怎么办?
-
LED无法扩展:想支持多个LED怎么办?
引出面向对象的程序设计方法:
2.3 面向对象
2.3.1 函数指针的引入
假设你们公司产品升级了,按键发生了变化:
换了个引脚设计led,怎么写出一个支持多个版本的read_key函数?
- 方法1:宏开关,在代码里指定使用哪套代码
- 缺点:只能支持一个版本,如果宏开关过多,无法进行维护。当你版本过多就要写多个分支。
#define HARDWARE_VER 1 //定义宏等于1.
//若是你想支持版本2,写成 #define HARDWARE_VER 2
//但是它没法及支持版本1又支持版本2.
// key.c
// 返回值: 0表示被按下, 1表示被松开
int read_key(void)
{
GPIO_PinState key;
#if (HARDWARE_VER == 1)
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);//版本1
#else
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_7);//版本2
#endif
if (key == GPIO_PIN_RESET)
return 0;
else
return 1;
}
-
方法2:读取硬件版本,根据硬件版本调用对应代码,可以同时支持多个版本
-
缺点:如果版本很多,效率很低,太多仍然不好维护。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kl3YPzx3-1648727365352)(程序框架设计示例1_裸机.assets/2.png)]
使用E2PROM来存储引脚信息,比如外接有三引脚,000代表版本1,001代表版本2,…总共有2三次方个版本信息。你可以写一个函数read_hardware_ver()来实现。
-
// key.c
// 返回值: 0表示被按下, 1表示被松开
int read_key(void)
{
GPIO_PinState key;
int ver = read_hardware_ver();//读硬件版本的函数。
if (ver == 1)
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);
else (ver == 2)
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_7);
..... ///假如有无数个。
else (ver == n)
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_n);
if (key == GPIO_PIN_RESET)
return 0;
else
return 1;
}
-
方法3:使用函数指针
什么是函数指针,
int a; //整数 int *a; //指针 void read_key(); //函数 void (*read_key)(); //指针,但是要注意,*是描述返回值,还是描述函数指针,因此加一个括号
int (*read_key)(void); //函数指针,到底指向那个函数需要判断。
//版本1
// 返回值: 0表示被按下, 1表示被松开
int read_key_ver1(void)
{
GPIO_PinState key;
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);
if (key == GPIO_PIN_RESET)
return 0;
else
return 1;
}
//版本2
// 返回值: 0表示被按下, 1表示被松开
int read_key_ver2(void)
{
GPIO_PinState key;
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);
if (key == GPIO_PIN_RESET)
return 0;
else
return 1;
}
//初始化
void key_init()
{
int ver = read_hardware_ver();
if (ver == 1)
read_key = read_key_ver1; //函数指read_key针指向read_key_ver1
else
read_key = read_key_ver2; //函数指read_key针指向read_key_ver2
}
// main.c
void main(void)
{
int key;
key_init(); //初始化按键
while (1)
{
key = read_key(); //不用像以前一样每次调用KEY都要判断版本
if (key == UP)
led_on();
else
led_off();
}
}
需要将硬件版本号写入EEPROM中,在软件中进行判断,但是区别在于引入函数指针后,只需要在上电初始化过程中根据版本号判断一次,将版本对应接口赋值给指针,不需要在后续的代码中进行大量的判断调用。
第二个问题: 如何解决软件扩展性问题?
设计模式中有一个设计原则:OCP,开闭原则,大致意思是好的设计需要对扩展开放,对修改关闭。用人话说就是做功能扩展时只新增代码,不对已有代码做修改。
2.3.2 怎么处理多个按键(解决扩展性问题)
假设有两个按键,它们的操作完全不同,如何实现两个按键同时按下。
你需要实现两个读函数:
// key.c
// 返回值: 0表示被按下, 1表示被松开
int read_key1(void)
{
GPIO_PinState key;
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);
if (key == GPIO_PIN_RESET) //读到零返回零
return 0;
else
return 1;
}
// 返回值: 0表示被按下, 1表示被松开
int read_key2(void)
{
GPIO_PinState key;
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_7);
if (key == GPIO_PIN_RESET)//读到零返回1
return 1;
else
return 0;
}
能否用一个函数,既可以读按键1,也可以读按键2呢?
我们当然可以改进:
// key.c
// 返回值: 0表示被按下, 1表示被松开
//读哪个按键必须传入一个参数。
//在读引脚时要包含一堆的头文件。看着很别扭很麻烦。
#include <hal...h>
#include <wifi...h>
int read_key(int which)
{
GPIO_PinState key;
switch (which)
{
//传入0表示想读按键0,走这个分支。
case 0:
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);
if (key == GPIO_PIN_RESET)
return 0;
else
return 1;
break;
//传入1表示想读按键1,走这个分支。
case 1:
key = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_7);
if (key == GPIO_PIN_RESET)
return 1;
else
return 0;
break;
//假设是网络数据,虚拟成按键。
case 2:
read_net_data();
if (data==xxx)
return xxx;
}
}
上述函数中,K1、K2的代码掺和在一起,不好扩展。比如我们想增加K3时,可能并不是用按键触发的,只是一个虚拟的按键,例如是一种网络,并不想、也不需要顾虑K1、K2的存在。怎么办?我想要让K1独立,K2独立,K3独立,我们可以写一个k1.c、k2.c、k3.c—>>可以使用结构体。
缺点:
2.3.3 结构体的引入
定义一个key结构体:
//定义一个结构体
typedef struct key {
char *name;
void (*init)(struct key *k);
int (*read)(struct key *k);
}key, *p_key;
每个按键都实现一个key结构体:
// key1.c
key k1 = {"k1", NULL, read_key1}; //read_key1只读这个函数。
// key2.c
key k2 = {"k2", NULL, read_key2};
// key_net.c
key k_net = {"net", NULL, read_key_net};
那么怎么用起来呢?引入概念,分层。
2.3.4 管理层/接口层的引入(分层)
怎么管理这些按键?
需要增加一个管理层:.比如说注册:底层的代码放在上面的数组key *key[32];(比如说有32个按键)/列表中,把底层的key1、key2、key3放在数组中来。面向对象的思想,按键1抽象出一个结构体,注册到某一个数组/列表。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-88WoHSAw-1648727365354)(程序框架设计示例1_裸机.assets/4.png)]
怎么写程序?我们先将程序分层,main
函数属于应用层或业务逻辑层,key_manager
属于中间层,最下面属于硬件驱动层,通过中间层来实现对按键的管理,同时将业务逻辑层与驱动层解耦。
2.4 整体改造
2.3.1 系统划分
划分为按键系统、LED系统、业务系统:
// key_manager.h
typedef struct key {
char *name; //按键的名字
void (*init)(struct key *k); //按键的初始化函数
int (*read)(void); //按键的读函数
}key, *p_key;
// 所有按键的初始化
void key_init(void);
// 根据按键name获取按键
key *get_key(char *name);
// key_manager.c
int key_cnt = 0;
key *keys[32]; //32位的数组
//注册按键,结构体k放在数组里。
void register_key(key *k)
{
keys[key_cnt] = k;
key_cnt++;
}
//按键初始化。
void key_init(void)
{
k1_init();
k2_init();
}
//取出某一个按键。传一个char形的名字
key *get_key(char *name)
{
int i = 0;
for (i = 0; i < key_cnt; i++)
if (strcmp(name, keys[i]->name) == 0)
return keys[i];
return NULL;
}
// key1.c
//我们构造了一个结构体。这个结构体是什么呢,可以看key_manager.h里
// 返回值: 0表示被按下, 1表示被松开
//按键的读函数
static int read_key1(void)
{
GPIO_PinState key_status;
key_status = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);
if (key_status == GPIO_PIN_RESET)
return 0;
else
return 1;
}
//构造出的结构:key k1,。内容是 {"k1", 0, read_key1}
//要告诉中间层,你以后可以使用我了,怎么告诉呢有个初始化函数k1_init(void),有个注册函数register_key(&k1)——>注册按键是什么意思呢,在中间层有个按键指针数组来注册。
static key k1 = {"k1", 0, read_key1};
//
void k1_init(void)
{
register_key(&k1);
}
// key2.c
// 返回值: 0表示被按下, 1表示被松开
static int read_key2(void)
{
GPIO_PinState key_status;
key_status = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_7);
if (key_status == GPIO_PIN_RESET)
return 1;
else
return 0;
}
static key k2 = {"k2", NULL, read_key2};
void k2_init(void)
{
register_key(&k2);
}
// main.c
void main(void)
{
key *k;
key_init();
/* 使用某个按键 */
k = get_key("k1");
if (k == NULL)
return;
while (1)
{
if (k->read(k) == 0)//如果读取的按键等于零,表示已经按下。
led_on();
else
led_off();
}
}
目前的代码还有一些问题,没有将业务层与驱动层解耦,在main
函数中还有具体按键read
函数的调用及状态判断。同时,作为业务层期望中间层可以同时读取所有按键的状态。
再对中间层实现进行优化:(读取多个按键。有轮询方式,)
// key_manager.h
typedef struct key {
char *name;
unsigned char id;
void (*init)(struct key *k);
int (*read)(void);
}key, *p_key;
#define KEY_UP 0xA
#define KEY_DOWN 0xB
// 所有按键的初始化
void key_init(void);
// 读取所有按键的状态
int read_key(void);
// key_manager.c
int key_cnt = 0;
key *keys[32];
void register_key(key *k)
{
keys[key_cnt] = k;
key_cnt++;
}
void key_init(void)
{
k1_init();
k2_init();
}
int read_key(void)
{
int val;
for (int i = 0; i < key_cnt; i++)
{
val = keys[i]->read();
if (val == -1)
continue;
else
return val;
}
return -1;
}
// key1.c
// 返回值: 0表示被按下, 1表示被松开
#define KEY1_ID 1
static int read_key1(void)
{
static GPIO_PinState pre_key_status;
GPIO_PinState key_status;
key_status = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_6);
if (key_status == pre_key_status)
return -1;
pre_key_status = key_status;
if (key_status == GPIO_PIN_RESET)
return KEY_DOWN | (KEY1_ID << 8);
else
return KEY_UP | (KEY1_ID << 8);
}
static key k1 = {"k1", KEY1_ID, NULL, read_key1};
void k1_init(void)
{
register_key(&k1);
}
// key2.c
// 返回值: 0表示被按下, 1表示被松开
#define KEY2_ID 2
static int read_key1(void)
{
static GPIO_PinState pre_key_status;
GPIO_PinState key_status;
key_status = HAL_GPIO_ReadPin(GPIOF, GPIO_PIN_7);
if (key_status == pre_key_status)
return -1;
pre_key_status = key_status;
if (key_status == GPIO_PIN_RESET)
return KEY_UP | (KEY2_ID << 8);
else
return KEY_DOWN | (KEY2_ID << 8);
}
static key k2 = {"k2", KEY2_ID, NULL, read_key2};
void k2_init(void)
{
register_key(&k2);
}
// main.c
void main(void)
{
int val;
key_init();
while (1)
{
val = read_key();
if (val == -1)
{
/* 没有按键 */
}
else
{
key_status = val & 0xFF;
key_id = (val>>8) & 0xFF:
switch (key_status)
{
case KEY_UP:
/* key_id 松开 */
break;
case KEY_DOWN:
/* key_id 按下 */
break;
default:
break;
}
}
}
}
4.一些提问的问题
HAL库是不是经常使用开关宏来做? (对得)不需要支持M3或者M4。
哪些函数放在驱动层,哪些函数放在应用层:APP(不用懂硬件。) 和驱动程序(硬件操作给封装好函数,让别人不懂硬件也可以使用。)
_key(&k2);
}
```c
// main.c
void main(void)
{
int val;
key_init();
while (1)
{
val = read_key();
if (val == -1)
{
/* 没有按键 */
}
else
{
key_status = val & 0xFF;
key_id = (val>>8) & 0xFF:
switch (key_status)
{
case KEY_UP:
/* key_id 松开 */
break;
case KEY_DOWN:
/* key_id 按下 */
break;
default:
break;
}
}
}
}
4.一些提问的问题
HAL库是不是经常使用开关宏来做? (对得)不需要支持M3或者M4。
哪些函数放在驱动层,哪些函数放在应用层:APP(不用懂硬件。) 和驱动程序(硬件操作给封装好函数,让别人不懂硬件也可以使用。)
RAM资源比较紧张的话,不用这样子写,这样子写会浪费资源。