HAL 库设置回调成员函数的一种方法
HAL 库都是拿C 写的,想注册回调函数的话,也只能是C 的函数,不能用成员函数作为回调。现在有个需求,要给一个I2C 设备写个驱动,希望把驱动整体封装成个类,这样比较灵活。要是还得把I2C 的回调函数放在类外面,那就太不“整洁”了。此外,放在外面的回调函数没办法直接引用到设备驱动对象,必须有个全局的指针变量,让它指向驱动对象,然后回调函数里再使用这个全局的指针去找对象。STM32DUINO 框架里就是这么设计的,只是它们稍微取了点巧,大致原理如下:
// 驱动类,或者驱动结构体
struct Driver{
I2C_HandleTypeDef handle;
// ... 其他一堆成员变量
// ... 其他一堆成员函数
};
// 驱动对象
Driver driver_instance;
// 调用HAL 库函数
HAL_I2C_Master_Transmit_DMA(&driver_instance.handle, addr, buf_ptr, count);
// HAL 库的回调函数。想在回调里使用C++ 代码,必须把回调放在CPP 文件里,所以要加上extern "C"
extern "C" {
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *I2cHandle) {
// 根据handle 结构体在Driver 类中的偏移量,拿到指向driver_instance 对象的指针
auto offset = offsetof(Driver, handle);
Driver *driver_ptr = reinterpret_cast<Driver*>(I2cHandle - offset);
// 拿到了驱动对象,之后就随便弄了
// driver_ptr->.......
}
}
把HAL 库的Handle 结构体嵌进驱动对象里,再用这个handle
成员变量去调用各种HAL 函数,那么回调函数传进来的*I2cHandle
当然就是指向这个成员变量的指针。有了成员变量的地址,就可以根据成员变量在驱动对象中的偏移量,拿到驱动对象的地址。也是挺巧妙的,优点是不用单独定义个隐藏的全局变量,用户使用的时候会自己定义Driver 对象。
不过这种做法不适合我。我习惯把Cube 生成的代码简单改改就直接用,而且还是拿脚本自动改。Cube 会在它生成的main.c
里定义好各种handle 结构,我就在它的main
函数里调用我自己的入口函数app()
。这样做的好处是可以分离Cube 自动生成的代码,我可以把我的代码放在app.cpp
文件里,以后如果改了配置,只要把Cube 新生成的代码复制过来改一下就好了,我可不想在它生成的代码里照着它规定好的格式填空。
于是,既然handle 都已经在main.c
里定义好了,我肯定不想一个一个自己定义Driver 对象,再去改生成好的配置代码。
所以我用的是另一种比较脏的方法,就是直接修改HAL 库的头文件,在I2C_HandleTypeDef
结构体定义里加一个成员void *PtrToCallbackObject
,用来指向我的驱动对象。
typedef struct __I2C_HandleTypeDef
#else
typedef struct
#endif /* USE_HAL_I2C_REGISTER_CALLBACKS */
{
I2C_TypeDef *Instance; /*!< I2C registers base address */
// ...
#if (USE_HAL_I2C_REGISTER_CALLBACKS == 1)
void *PtrToCallbackObject; // 指向回调函数关联的对象
// ...
void (* AddrCallback)(struct __I2C_HandleTypeDef *hi2c, uint8_t TransferDirection, uint16_t AddrMatchCode); /*!< I2C Slave Address Match callback */
void (* MspInitCallback)(struct __I2C_HandleTypeDef *hi2c); /*!< I2C Msp Init callback */
void (* MspDeInitCallback)(struct __I2C_HandleTypeDef *hi2c); /*!< I2C Msp DeInit callback */
#endif /* USE_HAL_I2C_REGISTER_CALLBACKS */
} I2C_HandleTypeDef;
// 回调
void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *I2cHandle) {
// 直接拿到驱动对象的指针
Driver *driver_ptr = reinterpret_cast<Driver*>(I2cHandle.PtrToCallbackObject);
// driver_ptr->.......
}
这样只是改了头文件的一行代码,几乎不会引起任何兼容性问题。虽然如果HAL 库要更新,那就得重新改,但是相信大伙很多人用的库都是复制粘贴祖传下来的,根本不知道版本是什么。而且就算更新以后忘记改了,代码直接不能编译,会提示你缺个PtrToCallbackObject
成员,所以不会出错。最后,这个Handle 结构体里面已经定义了这么多不知道有没有用的变量,我再加一个指针,不会造成什么负担。此外,就算不用C++,在C 里面往往也会用结构体模拟对象,增加这个成员也是有用的。
有了这个方便的修改以后,回到开头,现在可以把回调函数放在驱动类里面了:
class Driver {
private:
I2C_HandleTypeDef _handle; // 指向设备要使用的I2C handle
public:
Driver(I2C_HandleTypeDef h) : _handle(h) {}
void init() {
// 注册回调函数,先把对象的指针放进去
_handle->PtrToCallbackObject = reinterpret_cast<void *>(this);
// 用这些lambda 作为回调函数。捕获列表必须为空,否则不能作为普通函数指针使用,
// 所以lambda 里不能直接操作this 指针,必须从handle 里获取。
// WARNING: 注意,这些回调是从I2C 中断调用的
// TX 完成回调
auto master_tx_complete_callback = [](I2C_HandleTypeDef *h) {
// 获取this 指针
auto t = reinterpret_cast<Driver *>(h->PtrToCallbackObject);
// 发送完成时,调用成员函数
t->on_tx_complete();
};
// 错误回调
auto error_callback = [](I2C_HandleTypeDef *h) {
auto t = reinterpret_cast<Driver *>(h->PtrToCallbackObject);
// 发生错误时
t->on_error();
};
// 注册回调函数
_handle.MasterTxCpltCallback = master_tx_complete_callback;
_handle.ErrorCallback = error_callback;
}
};
这样有什么优点?可以把驱动代码全部放在头文件里,用的时候include 一下就行了,多方便。