宏被称为黑魔法不是没有原因的——做个轻量级信号与槽机制 be like

一、引言

image.png

信号与槽机制源自 Qt 框架,它是一种强大而优雅的事件驱动模型,旨在促进组件之间的松散耦合通信。该模式允许对象之间无需直接相互引用即可响应彼此的状态变更,从而极大地提高了系统的可扩展性和可维护性。

1.1 传统事件处理的局限性

在早期 C++ 程序中,事件处理通常通过回调函数或观察者模式来实现,但这些方法存在一些问题:

  • 强耦合:回调函数需要提前注册到特定对象中,导致调用方与被调用方紧密耦合,不利于模块化和重用。
  • 代码复杂度高:实现和维护复杂的事件处理逻辑较为困难,尤其是在多个对象之间需要进行交互时。
  • 类型检查不足:回调函数的接口不够灵活,容易引发编译期或运行时错误。
  • this 指针的绑定问题:C++ 成员函数需要显式绑定到对象实例(this),导致回调机制实现复杂且易出错。

1.2 信号与槽机制的优势

信号与槽机制提供了一种声明式的方式来描述组件之间的关系,代码更加直观、易读和易维护,降低了程序的复杂度。例如:

connect(sender, &Sender::signal, receiver, &Receiver::slot);  

这一语句清晰地表明了信号与槽之间的关系。

信号与槽机制通过 connect 函数将事件的发出者和处理者连接起来,实现了组件间的解耦,简化了事件驱动编程。

1.3 嵌入式 C 编程中的信号与槽的优势

虽然信号与槽机制主要源于面向对象语言(如 C++ 和 Qt 框架),但它的思想同样可以借鉴到嵌入式 C 编程中,用于解决某些事件通信和模块解耦问题。

  • 模块解耦:信号与槽机制允许模块之间通过事件(信号)进行通信,而无需直接调用对方的接口,降低模块间的耦合度。
  • 简化事件处理:使用信号与槽,可以轻松管理事件的分发和处理逻辑,而不需要手动维护复杂的回调函数或状态机。
  • 动态性和灵活性:可以在运行时动态注册或解除信号与槽的绑定,支持灵活的模块间通信。
  • 易于扩展:新模块可以通过注册信号与槽轻松加入系统,无需修改现有模块的实现。
  • 提升代码可读性与可维护性:信号与槽机制的代码逻辑清晰,描述事件触发和响应关系的方式更直观。
  • 线程安全:如果设计得当,信号与槽机制可以用于不同任务(或线程)之间的安全通信,避免复杂的同步机制。

既然信号槽有如此多的优点,嵌入式 C 开发也想拥有,下边就开始用 C 语言一步步实现它吧...

二、用宏打造一个轻量级信号与槽机制

2.1 前景回顾

如果说上一篇文章通过队列的实现只是对宏的“浅尝辄止”,让我们领略了宏在代码简化和重用上的基本功,那么这篇文章,我们可以更进一步,将目光投向更复杂、更具挑战性的场景。通过深入挖掘宏的强大能力,我们不仅要展示它的“锦上添花”,更要看到它在解决复杂问题时“化繁为简”的真正价值。

2.2 基本功能封装

信号与槽机制的核心在于信号与槽的动态连接与断开,以及通过信号调用槽函数完成事件通知

为此,我们先无脑定义一些与 Qt 中接口相同的宏,为后续的功能实现奠定基础:

#define signals //定义信号

#define emit   //发射信号

#define slots  //定义槽

#define connect //链接信号与槽

#define disconnect //断开信号与槽

信号槽机制的本质是发射信号时,通过信号与槽的对应关系找到槽函数并调用它。我们需要实现的,正是这个机制。

2.3 实现声明信号的宏

在 C 语言中,信号本质上是一个函数,其触发的行为是调用与之绑定的槽函数。因此,我们需要通过 signals 宏声明信号的函数类型。

实现逻辑

  • signals 宏通过 typedef 声明信号函数类型。
  • 发射信号时,会通过调用该函数类型的函数指针,执行槽函数。

实现代码

#define signals(__NAME,...)               \  
          typedef void __NAME( __VA_ARGS__);  

示例

signals(my_signal, int value, const char *message);  

上述代码将定义一个信号 my_signal,它可以传递一个 int 和一个 const char * 参数。

2.4 实现发射信号的宏

发射信号的本质是通过函数指针调用槽函数。在实现发射信号的宏时,我们需要处理以下情况:

  • 动态调用与信号绑定的槽函数。
  • 支持不定长参数传递。

实现步骤

  1. 通过信号的函数指针调用槽函数。
  2. 利用参数宏解析 ... 的参数数量,从而正确调用不同参数数量的槽函数。

实现代码

#define **emit(**OBJ,...) \  
           **PLOOC_EVAL(**RecFun_,##**VA_ARGS**) ((**OBJ)->ptRecObj,##**VA_ARGS__);

#define emit(**NAME,**OBJ,...)                     \  
           do {                                      \  
               sig_slot_t *ptObj = &((**OBJ)->tObject); \  
               **NAME ***RecFun = ptObj->ptRecFun;     \  
               **emit(ptObj, **VA_ARGS**)              \  
           } while(0)  

在这里:

  • __PLOOC_EVAL 是一个辅助宏,根据参数数量自动选择对应的 __RecFun_N 宏。
  • __RecFun_N 定义了多种参数调用的槽函数适配逻辑。
#define **RecFun_0(**OBJ)                                     \  
            **RecFun((**OBJ))

#define **RecFun_1(**OBJ, **ARG1)                         \  
            **RecFun((**OBJ),(**ARG1))

#define **RecFun_2(**OBJ, **ARG1, **ARG2)                         \  
            **RecFun((**OBJ),(**ARG1), (**ARG2))

#define **RecFun_3(**OBJ, **ARG1, **ARG2, **ARG3)                 \  
            **RecFun((**OBJ),(**ARG1), (**ARG2), (**ARG3))

#define **RecFun_4(**OBJ, **ARG1, **ARG2, **ARG3, **ARG4)                 \  
            **RecFun((**OBJ),(**ARG1), (**ARG2), (**ARG3), (**ARG4))

  
#define **RecFun_5(**OBJ, **ARG1, **ARG2, **ARG3, **ARG4, **ARG5)                 \  
            **RecFun((**OBJ),(**ARG1), (**ARG2), (**ARG3), (**ARG4), (**ARG5))

#define **RecFun_6(**OBJ, **ARG1, **ARG2, **ARG3, **ARG4, **ARG5, **ARG6)                 \  
            **RecFun((**OBJ),(**ARG1), (**ARG2), (**ARG3), (**ARG4), (**ARG5), (**ARG6))

#define **RecFun_7(**OBJ, **ARG1, **ARG2, **ARG3, **ARG4, **ARG5, **ARG6, **ARG7)                 \  
            **RecFun((**OBJ),(**ARG1), (**ARG2), (**ARG3), (**ARG4), (**ARG5), (**ARG6), (**ARG7))

#define **RecFun_8(**OBJ, **ARG1, **ARG2, **ARG3, **ARG4, **ARG5, **ARG6, **ARG7, **ARG8)                 \  
            **RecFun((**OBJ),(**ARG1), (**ARG2), (**ARG3), (**ARG4), (**ARG5), (**ARG6), (**ARG7), (**ARG8))


2.5 取代 QObject 类

为了在结构体中存储信号与槽的元信息,我们定义一个 sig_slot_t 结构体,用于管理信号与槽的动态连接。

typedef struct sig_slot_t sig_slot_t;  
typedef struct sig_slot_t {  
    char   chSenderName[SIG_NAME_MAX]; //  信号名称  
    void  *ptSenderObj;               //  信号所在对象地址  
    void  *ptRecObj;                  //  槽所在对象地址  
    void  *ptRecFun;                  //  槽函数的地址  
} sig_slot_t;

#define SIG_SLOT_OBJ  sig_slot_t tObject;

在需要信号支持的结构体中,添加 SIG_SLOT_OBJ 即可。

示例

typedef struct {  
    SIG_SLOT_OBJ  
} my_signal_object_t;  

2.6 实现 connect 函数

connect 宏用于动态建立信号与槽的连接关系。核心逻辑包括:

  • 检查参数有效性。
  • 将信号的函数地址与槽函数地址绑定。
void connect(void *SenderObj, const char *ptSender, void *RecObj, void *RecFun) {  
    if (SenderObj == NULL || ptSender == NULL || RecObj == NULL || RecFun == NULL) {  
        return;  
    }  
    sig_slot_t *ptMetaObj = SenderObj;  
    ptMetaObj->ptRecFun = RecFun;  
    ptMetaObj->ptRecObj = RecObj;  
    memcpy(ptMetaObj->chSenderName, ptSender, strlen(ptSender));  
}  

2.7 可选的 slots 宏

虽然槽函数在本质上是普通函数,但为了和 signals 宏对应,我们可以通过 slots 宏为槽函数提供一个语法糖,方便统一管理。

实现代码

#define **slots(**NAME,...)             \  
            void **NAME(**VA_ARGS__);

  
#define slots(**NAME,**OBJ,...)  \  
            **slots(**NAME,_args(**OBJ,##**VA_ARGS__))  

示例

slots(my_slot, void *obj, int value, const char *message);  

2.8 设计总结

到此为止,我们实现了信号与槽的以下核心功能:

  • 信号声明:通过 signals 宏定义信号函数类型。
  • 信号发射:通过 emit 宏调用信号对应的槽函数。
  • 动态连接:通过 connect 宏在运行时动态绑定信号与槽。
  • 统一语法:使用 slots 宏声明槽函数,与信号配合使用。

在这一机制下,C 语言通过宏实现了类似 Qt 的信号与槽机制,为模块化和解耦设计提供了强大的工具支持。

三、完整的代码实现

以上代码只是展示核心部分,并且仅实现了一个信号对应一个槽,不能一个信号对应多个信号和槽,还有诸多类型检查,空指针检查等需要优化的地方,完整的代码开源链接:https://github.com/Aladdin-Wa...

完整代码信号与槽具备以下核心特征:

  • 不定长参数支持:信号和槽函数可携带任意参数;
  • 多路复用能力:支持一对多、多对多的灵活连接;
  • 连接与断开机制:运行时动态连接和断开信号与槽;
  • 类型安全与检查:防止重复连接或非法操作;
  • 链表结构:高效管理信号与槽的关系;
  • 宏封装:简化信号与槽的定义和操作,提升代码可读性和可维护性。

四、使用方法与 QT 中的区别

1. SIG_SLOT_OBJ 取代 QObject

SIG_SLOT_OBJ 取代 QObject,且只需要在信号所在的类中定义。

2. 定义信号不同

QT 在类里面声明信号,signals 宏是在结构体外声明信号,并且要指定信号名称,信号所在的对象地址,和一些自定义的参数:

  signals(__NAME,__OBJ,...)   
  example:  
  signals(send_sig,can_data_msg_t *ptThis,  
      args(                
            uint8_t *pchByte,  
            uint16_t hwLen  
          ));  

3. 发射信号不同

emit 宏的括号内需要指定信号名称,信号所在的对象地址,和自定义的参数的数据:

   emit(__NAME,__OBJ,...)   
   example:    
   emit(send_sig,&tCanMsgObj,  
      args(  
            tCanMsgObj.CanDATA.B,  
            tCanMsgObj.CanDLC  
         ));  

4. 定义槽不同

与定义信号语法类似

   slots(__NAME,__OBJ,...)   
   example:  
   slots(enqueue_bytes,byte_queue_t *ptObj,  
      args(  
            void *pchByte,  
            uint16_t hwLength  
         ));  

5. 连接信号与槽

与 QT 一样一个信号可以连接多个信号或者槽

#define connect(**SIG_OBJ,**SIG_NAME,**SLOT_OBJ,**SLOT_FUN)    \  
            direct_connect(**SIG_OBJ.tObject,**SIG_NAME,**SLOT_OBJ,**SLOT_FUN)

example:  
  connect(&tCanMsgObj,SIGNAL(send_sig),&s_tFIFOin,SLOT(enqueue_bytes));

#define disconnect(**SIG_OBJ,**SIG_NAME)    \  
            auto_disconnect(**SIG_OBJ.tObject,**SIG_NAME)  
  example:  
  connect(&tCanMsgObj,SIGNAL(send_sig));  

信号与槽的链接关系是同步调用的关系,不同于发布订阅系统的异步调用。

同步调用的特点:

  • 当一个信号发出时,如果有多个槽连接到该信号,所有槽函数会按顺序被调用。
  • 槽函数在信号发出时会立即执行,且不会返回控制权给发出信号的对象,直到所有槽函数执行完毕才会返回。
  • 这种调用是同步的,因为发出信号时会等待槽函数的执行完成后才继续进行其他操作。

与发布-订阅模式的区别:

发布-订阅模式通常是基于异步通信的,其中发布者发送消息后,不会阻塞等待订阅者的处理结果。订阅者会异步地处理这些消息,并可能在处理完后通过回调等机制通知发布者。

下一篇将结合队列,实现一个异步调用的发布-订阅模式,敬请期待。

五、信号与槽使用示例

信号与槽模块作为我开源代码MicroBoot的核心机制,不仅提升了代码的可维护性和灵活性,还带来了更好的开发体验。

接下来实现一个将 CAN 接收的数据,存储到环形队列 ringbuf 的例子:

can.h 文件

#include "signals_slots.h"  
typedef struct  
{  
    SIG_SLOT_OBJ;  
    uint8_t  CanDLC;  
    union {  
        uint8_t B[8];  
        uint16_t H[4];  
        uint32_t W[2];  
    } CanDATA;  
}can_data_msg_t;

signals(send_sig,can_data_msg_t *ptThis,  
      args(                
            uint8_t *pchByte,  
            uint16_t hwLen  
          ));

  
extern can_data_msg_t tCanMsgObj;            

can.c 文件

#include "can.h"  
can_data_msg_t tCanMsgObj;  
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)  
{  
 HAL_StatusTypeDef status;  
 CAN_RxHeaderTypeDef rxheader = {0};

status = HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rxheader, (uint8_t *)tCanMsgObj.CanDATA.B);  
 if (HAL_OK != status)  
   return ;  
 /* get id */  
 if (CAN_ID_STD == rxheader.IDE)  
 {  
  tCanMsgObj.CanID_t.CanID = rxheader.StdId;  
 }  
 else  
 {  
  tCanMsgObj.CanID_t.CanID = rxheader.ExtId;  
 }  
 /* get len */  
 tCanMsgObj.CanDLC = rxheader.DLC;

emit(send_sig,&tCanMsgObj,  
        args(  
            tCanMsgObj.CanDATA.B,  
            tCanMsgObj.CanDLC  
         ));  
}  

main.c 文件

#include "signals_slots.h"  
#include "can.h"

static uint8_t s_cFIFOinBuffer[1024];  
static byte_queue_t s_tFIFOin;

MX_CAN1_Init();  
HAL_CAN_Start(&hcan1);  
HAL_CAN_ActivateNotification(&hcan1, CAN_IT_RX_FIFO0_MSG_PENDING);  
QUEUE_INIT(&s_tFIFOin, s_cFIFOinBuffer, sizeof(s_cFIFOinBuffer));  
int main(void)  
{  
    connect(&tCanMsgObj,SIGNAL(send_sig),&s_tFIFOin,SLOT(enqueue_bytes));  
    while(1){  
     //do something  
    }  
}  

END

作者:kk
原文:裸机思维

专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
1492
内容数
127
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息