初探优雅地解耦代码(修改移植)

初探优雅地解耦代码

首先声明:本篇文章的初始版源代码是Clone自GitHub上 NevermindZZT 大佬,本人只是在这个基础上根据自身理解加以改动和移植。

仅用作学习交流,未用作任何商业用途,如有版权问题请联系更改


前言

相信经常和单片机打交道的同学肯定看见过类似下面main函数初始化的画面

在这里插入图片描述

同时,在处理中断函数的时候,经常设置一个标志位来标志状态的改变,控制main函数中某些函数的执行与否。

用裸机进行单片机开发的过程中,曾经我也认为这是理所应当的,直到我偶然间看见了NevermindZZT写的一篇博客,顿时觉得十分神奇,因为这样优雅利索的代码正是我十分向往的。于是我下载了源代码,根据博客和注释像读一本好书一样慢慢品味。

接下来我会简要分析一下CPostCEvent的使用和实现机制,以及如何将这个使用在新版编译器上的代码,移植到不完全支持C99的KEIL C51和KEIL C251 编译器上。

CPost

CPost使用

CPost使用方式比较简单,使用支持C99的编译器几乎可以不对源码做任何修改就能够使用。只需设置好本系统的时钟获取函数,在中断函数中用cpost()函数添加handler抛出任务,然后在主函数中用cpostProcess()处理相关的回调函数即可

CPost实现

核心有两点,一是弄懂两个结构体的作用,二是明白如何添加handler

typedef struct
{
    size_t startTime;
    size_t delay;
    void (*handler)(void *);            //定义了一个返回值为void的函数指针,接受参数为一个任意类型的指针
    void *param;                        //定义了一个任意类型的指向函数参数的指针                      
} CpostHandler;

typedef struct
{
    void *handler;
    void *param;
    size_t delay;
    struct {
        unsigned char flag : 2;         //结构体位段定义两个位存状态
        unsigned char paramDiff: 1;
    } attrs;
} CpostParam;

CpostParam结构体是用户自己定义的,包括指向函数的指针handler,指向参数的指针param,以及抛出任务后delay多久会被响应。

CpostHandler结构体才是CPost真正抛出的任务,相当于封装了一层,和用户使用分隔开便于维护和更新,handler被强制类型转化成了一个真正的函数指针param依旧是参数指针,剩下两个是用于计算延时时间的,使用频率相对不高。

#define cpost(...) \
        cpostAddHandler(&((CpostParam){__VA_ARGS__}))   //宏定义传递可变参数
// #define cpost(arg...) \
//         cpostAddHandler(&((CpostParam){arg}))   //等价用arg代替

在C99标准中引入了新的...__VA_ARGS__宏定义支持可变参数的传递,同时也用到了复合字面量生成一个匿名变量仅用来传递参数初始化。在初始化的过程中也可以用到非常方便的指定成员初始化,由于是全局变量,所以没有指定默认是0,相当于默认赋初值了,比如cpost(handler, .delay=5000);

以上全部是C99的新标准,技巧十分丰富。至于cpostAddHandler中的具体实现如何,大家有兴趣可以在 NevermindZZT的GitHub上自行查看,相对容易理解就不赘述了。

CPost移植

C99的新标准让编程变得更加优美简洁,但是如果条件要求只能用C51和C251编译器所支持的ANSI C标准,就不得不对原本简洁的代码做出一些妥协了。

主要需要更改的还是cpost的宏定义,虽然随着版本更新,以上两款老编译器已经支持__VA_ARGS__宏定义,但是想要做到上面的效果还需要支持复合字面量的。无奈之下,只能含泪再定义一个函数,充当那个复合字面量的生成,具体代码如下:

#define cpost(handler,param,delay,flag,paramdiff) \
        cpostAddHandler(cpostparaminit(handler,param,delay,flag,paramdiff)) 
/*
*   @brief cpostparam 结构体生成
*	只支持用地址传递一个参数
*/
CpostParam * cpostparaminit(void * handler,void * param,size_t delay,unsigned char flag,unsigned char paramDiff)
{
	CpostParam cpostparam;                 //定义了一个参数结构体
    size_t i;
    for ( i = 0; i < CPOST_MAX_HANDLER_SIZE; i++)
    {
        if (cposhHandlers[i].handler == NULL)
        {
            cpostparam.handler = handler;
            cpostparam.param = param;
            cpostparam.delay = delay;
            cpostparam.attrs.flag = flag;
            cpostparam.attrs.paramDiff = paramDiff;
            return &cpostparam;
        }
    }
    return 0;
}

也就是说,在函数中定义的cpostparam通过值传递给了cpostAddHandler()函数,最终被销毁。这也导致使用宏定义的时候必须要给出全部参数,并且还要按照固定的顺序😢.显得臃肿了很多,但是为了使用也是无奈之举。

CEvent

CEvent使用

CEvent的作用也十分巧妙,核心思想是把事件的导出和事件的执行分离开。这样的机理对于减少代码之间的耦合十分有帮助,添加需要执行的函数只需导出事件,减少执行的函数只需不导出事件即可。

有同学这时候可能要发问了,这样处理和我用一个大的函数比如大的init_all()函数把所有初始化函数全部包括起来有什么区别呢?

其实区别是很大的。首先用一个大的 init_all()函数并没有切断主函数和其他模块的联系,需要一个个头文件include所有需要初始化的函数所在的位置,模块中的任何修改都会反映到主函数中函数执行中,这就是耦合。

其次,在事件执行中,自身根本不知道到底有多少事件需要执行,这完全取决于用户导出的事件数。意思是说:无论有多少函数需要执行,只要导出事件,执行函数就像一台无情的机器全部执行。(当然可以自行添加一些状态表示是否重复执行之类的信息)


举个实际例子:

当我需要一个GPIO初始化函数和一个EXTI初始化函数的时候,分别在GPIO和EXTI的源文件中用CEVENT_EXPORT(event——num,func,...)导出事件,然后再在main函数中 ceventInit()ceventPost(event_num)即可完成事件的处理。
event_num是一个标号,用于事件的分类,把需要一起调用、相互之间没有先后关系的事件放在一起。具体数值可由用户设置,更好的方式是定义一个枚举变量,这样对事件的分类更加直观。


CEvent实现

CEvent的实现主要是通过一个宏定义:__attribute__((section(x))),它的详细用法可以参考博客.
简单来说就是把变量、函数在编译的过程中给编译到固定的一个段内(段的名称可以由x设定),而且数据在段中连续排列。再配合对应的宏定义获取段的初始地址和结束地址,通过运算得到事件的个数。

typedef struct 
{
    const void **param;                         /**< 参数(包括函数)指向一个数组,数组中存放了指向函数地址的指针和指向参数的指针 */
    const unsigned char paramNum;               /**< 参数数量 */
    const unsigned short event;                 /**< 监听事件 */
} CEvent;

/**
 * @brief 导出事件
 * 
 * @param _event 事件
 * @param _func 注册函数
 * @param ... 参数,参数传递的时候要以地址的形式传递
 * @note cEventParam##_event##_func和cEvent##_event##_func是定义的两个变量,一个是存放指针的数组,一个是类型为Cevent的变量
 */
#define CEVENT_EXPORT(_event, _func, ...) \
        const void *cEventParam##_event##_func[] = {(void *)_func, ##__VA_ARGS__}; \
        const CEvent SECTION("cEvent") cEvent##_event##_func = \
        { \
            .param = cEventParam##_event##_func, \
            .paramNum = sizeof(cEventParam##_event##_func) / sizeof(void *), \
            .event = _event, \
        }

可以得知,事件的导出就是定义了存放在指定的段(cEvent)中的变量,变量中有一个指向数组的指针,该数组又是一个指针数组,里面包含了函数地址,参数地址。多重指针的结构使得参数传递速度很快。剩下的paramNumevent分别是数组中的指针个数和事件标号了。

导出事件之后,应该获取段的初始地址和CEvent数据个数,否则怎么得知应该从哪里开始执行事件。

  • 简单的方法就是直接获取段的初始地址和数据个数,在需要执行的时候直接从头开始,遍历每一个CEvent校验它的事件号和需要完成的事件号是否相同,如果相同就执行,不同就下一个。这个方法对每一个事件都遍历,效率不是很高,但是优点是不需要额外的内存,而且操作简单。

  • 更加高效的方式就是在初始化的时候建立一个索引表,在索引表内将每个CEvent按照事件的编号排序,需要哪一个编号,直接就能通过数组的首地址加偏移量访问到,速度对于大量数据而言较快。下面是实现的源码:

        size_t maxEvent = 0;
        for (size_t i = 0; i < count; i++)
        {
            if (base[i].event > maxEvent) {
                maxEvent = base[i].event;
            }
        }
        maxEvent += 1;
    
        ceventTable.eventBase = (size_t **) ceventBuffer;
        size_t *cur = ceventBuffer + maxEvent;
        for (size_t i = 0; i < maxEvent; i++)
        {
            ceventTable.eventBase[i] = cur;
            for (size_t j = 0; j < count; j++)
            {
                if (base[j].event == i) {
                    *cur++ = (size_t) &base[j];
                }
            }
            *cur++ = NULL;
        }
    

    第一步找出最大的事件标号,得让那个索引表能够得到最远的位置才行嘛。

    第二步就是对CEvent数据的重新排列,将排列好的数据存放在ceventBuffer中。这个ceventBuffer相当于分成了前半部分和后半部分。前半部分就是索引表,每个数据类型都是一个地址,指向后面的部分。后面的部分中存放着在事件导出过程中CEvent数据的实际地址。这样通过两次解引用操作,就能够快速访问CEvent数据了。


    同样举个例子:

​ 我定义了两个标号为2的事件和一个标号为3的事件,然后经过初始化排列后,memory中就会变成这样:

在这里插入图片描述

​ 其中,0x455是ceventTable.eventBase的地址,地址中存放了ceventBuffer的地址,然后再ceventBuffer中的指针指向关系已经画出。那个0x800、0x807、0x80E就是CEvent的实际地址了。注意0x807和0x80E之前存在一个0000间隙,这个间隙是通过上面的函数很巧妙插入的,目的是为了将这个作为一个事件标号的结束,不会在执行事件2的时候把事件3的函数也给执行了。


剩下的ceventPost(event_num)函数就是执行某一个标号的所有事件了。这个过程比较明了也容易理解,主要是函数指针的使用,此处就不赘述了,可以在NevermindZZT上下载源码查看。

CEvent移植

CEvent的在C51/C251编译器上的移植就比较困难了,网络上找遍几乎所有资料都没有,当初我还一度认为不可能。

首先,最关键的宏定义:__attribute__((section(x))),在以上两个编译器中不支持,所以问题的核心就是如何用已有的条件替代宏定义:__attribute__((section(x)))

最初我想到的方法是定义一个最够大的数组来代替段,这样既能保证数据之间是连续的,又能获取数据的长度和首地址。写完还没等我编译我就知道这个方法肯定行不通。因为__attribute__((section(x)))的关键特点就是在编译的过程中就已经让数据刻在内存里面了,而我这样定义一个数组,在程序运行的时候再来写入肯定是不行的,就算能做到也失去了最初降低代码耦合性的目的。

于是我仔细查阅C251的使用手册,发现在使用C251.exe编译器的时候可以加入一些指令,来指导它的编译

在这里插入图片描述

可以通过#pragma SYMBOLS类似的预编译语句在源代码中对编译器提出指令。

别无选择,只能期望这些指令列表中有能实现相关要求的指令了。对于我这种英语一般般的人来说,就算真的有相应的功能,我也不一定能在众多指令中找出我需要的指令。经过反复挣扎,我总算是发现了相应的指令(不然真的得考虑用汇编了😿)

在这里插入图片描述

大概意思就是,可以使用类似下面的语句,将变量定义在一个指定的段内

#pragma userclass (near = app1)
int    x1;
int    x2;
#pragma userclass (near = default)

app1是自己定义的段名称,default是编译器默认的段,查看生成的map文件可以发现,确实做到了在一个特定段内数据连续排列。

在这里插入图片描述

有了这个编译指令,问题就解决了一大半,剩下的问题下面逐一解决

  1. 段的首地址如何获取?C99有专门获取段首地址的宏,但是显然目前所用的编译器不可能支持。
  2. 在不同源文件中定义的段虽然可以命名,但是最后在链接的时候的的最终段名却不同。比如在main.c中定义的叫?ED?APP1?MAIN,在gpio.c中定义的叫?ED?APP1?GPIO,它们之间的顺序却又是链接器随机链接的。
  3. CEVENT_EXPORT()的时候,只能有CEvent结构体存入cEvent段,那个存放参数的数组不能一并存入。
  4. 段内数据的个数如何获取?
  • 要解决前两个问题,就不得不查阅L251.exe链接器的相关手册了。其实链接器在使用的时候也可以输入相关的指令

    在这里插入图片描述

​ 其中正巧又有一条我需要的

在这里插入图片描述

找到keil的链接器设置界面,通过这个指令,在User Segments中按照手册的说明配置。段的首地址就可以自己配置,作为一个常量了,问题1解决。按照顺序给出segments的指令,段名也会按照给定的顺序依次排列,所以能够做到连续了,问题2解决

  • 要解决第三个问题就必须给参数数组重新指定内存位置,但这样意味着要把原来的宏定义给拆散,再分别定义,这样异常麻烦。既然对参数数组的存储位置没有要求,那么就把参数数组给扔到外置数据RAM中,加入xdata限定符,再配置xdata的有效范围后。问题3就基本解决了

  • 最后一个问题就是如何确定事件的个数呢?原来的代码的方案是通过宏定义找到段的起始位置和终止位置,然后再除以CEvent结构体的数据长度得到事件的个数。但是目前所用的编译器没有这个宏,也不能这样做了。我想到的方案是用函数来确定事件的个数,因为事件的格式是用于初始化函数中,并不要求在编译的时候确定,所以这样方案是可行的,代码如下所示。

    /**
     * @brief 获取数组中最后连续存放的cevent序号
     */
    unsigned char get_lastcevent(CEvent* event_list)
    {
        unsigned char i;
        for(i=0;(i<(CEVENT_BUFFER_SIZE/2));i++)
        {
            if(*((*(event_list+i)).param)==NULL)			//param是一个类型为void* 的数组,应该再次取地址获得第一个参数:函数指针
                break;
        }
        return i;                 //返回下一个空余位置序号
    }
    

    只是如果采用这样的方案,就必须确保最后一个的CEvent为空,所以要在最后添加一个空CEvent变量,理论上在哪添加都行,但是我选择在main.c中添加,这样不容易忘记。

    #pragma userclass (near=CEVENT)	
    	CEVENT_EXPORT(0,NULL,NULL);
    #pragma userclass (near=default)
    

    综上所述,想要在C51/C251正常使用CEvent模块,就必须做一些额外的工作了。除了在导出事件的时候应该像上面所示多添加两条编译指令,还应该在L251链接器设置界面配置如下:

    在这里插入图片描述

xdata可以根据实际情况调整,User Segments中设置好相应的段,段名可以在生成的.map文件中查看,都是有规律的。然后在指定初始地址后按照顺序写在后面即可,注意别忘了最后是一个空的CEvent哦。

可以在map文件中看到实际的段空间分配内存情况,和预期一样是连续分布的。

在这里插入图片描述

至此CEvent就移植成功了😆


写在最后:感谢能读到这里,希望没有浪费您宝贵的时间,如果觉得还不错,不要吝啬手中的赞呦~。

加以修改后的源代码在了sycamoremoon’s github,需要的同学可以自取。

之后还会有其他的内容上传,希望我的小小努力能够给大家带来帮助

这是我的网站,内容会随着时间逐渐丰富的…

  • 21
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不努力就会变成沸羊羊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值