拿C++ 在固件库上套娃一层有几点原因:
- 固件库都是用C 写的,而我平时都用C++,虽然是兼容的,但C 的一些特性我不喜欢;
- 我不喜欢官方库的函数命名风格;
- 各个厂家的固件库大同小异,但是“小异”的那一部分很烦人,比如几个函数和常量命名不同,包装一层可以隔离这些差异;
- 我不喜欢固件库里惯用的初始化结构体,用着麻烦;
以下就简单的介绍一下思路,以定时器TIM 操作函数为例。
链式调用初始化
首先考虑初始化结构体的改进方案,比如时基初始化结构体:
typedef struct
{uint16_t TIM_Prescaler; /*!< Specifies the prescaler value used to divide the TIM clock.This parameter can be a number between 0x0000 and 0xFFFF */uint16_t TIM_CounterMode; /*!< Specifies the counter mode.This parameter can be a value of @ref TIM_Counter_Mode */uint16_t TIM_Period; /*!< Specifies the period value to be loaded into the activeAuto-Reload Register at the next update event.This parameter must be a number between 0x0000 and 0xFFFF. */ uint16_t TIM_ClockDivision; /*!< Specifies the clock division.This parameter can be a value of @ref TIM_Clock_Division_CKD */uint8_t TIM_RepetitionCounter; /*!< Specifies the repetition counter value. Each time the RCR downcounterreaches zero, an update event is generated and counting restartsfrom the RCR value (N).This means in PWM mode that (N+1) corresponds to:- the number of PWM periods in edge-aligned mode- the number of half PWM period in center-aligned modeThis parameter must be a number between 0x00 and 0xFF. @note This parameter is valid only for TIM1 and TIM8. */
} TIM_TimeBaseInitTypeDef;
参数挺多的,而且都是整数类型,如果简单的把这些参数都当作函数参数传进一个初始化函数里,可读性会很差,可能类似下面这样:
init_time_base(1000, TIM_CounterMode_Up, 5000, 1, 0);
阅读者无法一眼看出哪个参数对应哪个功能,必须熟悉函数的参数顺序。这时候就会羡慕python 里有命名参数,调用函数时必须手写上参数名,参数功能当然一看便知。而C++ 里处理这个问题,除了用结构体传参数,还有两、三种方法,参考:Design Patterns With C++(九)命名参数与方法链。给函数加默认参数只能少写一两个参数,解决不了可读性问题,所以决定采用链式调用风格。
先看一下包装之后的用法:
void main() {// ...using namespace timxx;// 初始化TIM2 TIM3 时基TimeBaseInit().division(clock_div::div1).mode(counter_mode::up).prescaler(1000).period(5000).init(TIM2) // 这一步才实际调用库函数执行初始化,其他几个函数都是在给内部结构体赋值.prescaler(2000).period(2000).init(TIM3);// ...
}
对比原来的库代码,书写简洁程度上见仁见智吧,反正不用手动创建个结构体,解决了我的核心痛点,变量和函数命名比库代码简洁,因为不用担心重名。原理很简单,就是写一个名叫TimeBaseInit
的类,把初始化结构体藏在里面,使用时不必把对象赋值给一个变量,随用随创建,用完就把对象销毁了。这个类的所有成员函数都会返回该对象的引用,所以函数调用后还可以继续接着链式调用,每次调用的都是那个匿名对象。类里面只有init
函数实际去执行初始化步骤,其他函数都只是给内部结构体赋值。
在继续之前,先说明一下,当然,这么包一层肯定会付出一些代价,包括运行时间和空间占用,后面会有编译结果比较。有官方库“珠玉在前”,我觉得相比之下,这层包装付出的代价并不显著,有兴趣可以看看常用的GPIO 初始化函数在固件库里是怎么实现的,可以说是资源浪费的典范[doge]。另一方面,固件库里都是C 函数,函数实现都分开放在.c 文件里,编译器内联的可能性应该不大,因此就算是简单的给引脚设置个电平,用固件库也会产生额外的函数调用,所以很多人是在宏里自己写寄存器操作的。用C++ 的话,这种简单的函数放在头文件里,编译后就内联了,资源使用上和宏没区别。
初始化实现
上面的代码中用到了两个枚举: clock_div
和counter_mode
,先把这俩写出来:
/*** @brief 计数模式**/
enum class counter_mode : decltype(TIM_CounterMode_Up) {up = TIM_CounterMode_Up, // 递增后归零。基本定时器只支持向上模式,所以初始化默认值为向上down = TIM_CounterMode_Down,center_1 = TIM_CounterMode_CenterAligned1,center_2 = TIM_CounterMode_CenterAligned2,center_3 = TIM_CounterMode_CenterAligned3,
};enum class clock_div: decltype(TIM_CKD_DIV1) {div1 = TIM_CKD_DIV1,div2 = TIM_CKD_DIV2,div4 = TIM_CKD_DIV4,
};
就是用枚举把固件库里常量值包装了一下,优点是这样一来就不用在函数里做参数检查了,只有这些值能传进去。decltype(TIM_CounterMode_Up)
用来获取常量值的数据类型,让枚举得底层类型和这些常量一致。然后是那个类的代码:
class TimeBaseInit {private:// TODO: 默认的初始化参数为:向上计数、TIM_TimeBaseInitTypeDef _init_struct = {.TIM_Prescaler = 0,.TIM_CounterMode = _ENUM_TO_UNDERLYING(counter_mode::up),.TIM_Period = 0,.TIM_ClockDivision = TIM_CKD_DIV1,.TIM_RepetitionCounter = 0};public:TimeBaseInit& division(clock_div clkdiv) {_init_struct.TIM_ClockDivision = _ENUM_TO_UNDERLYING(clkdiv);return *this;}TimeBaseInit& prescaler(uint16_t prs) {_init_struct.TIM_Prescaler = prs;return *this;}TimeBaseInit& period(uint16_t prd) {_init_struct.TIM_Period = prd;return *this;}TimeBaseInit& mode(counter_mode cm) {_init_struct.TIM_CounterMode = _ENUM_TO_UNDERLYING(cm);return *this;}TimeBaseInit& repetition_counter(uint8_t c) {_init_struct.TIM_RepetitionCounter = c;return *this;}TimeBaseInit& init(TIM_TypeDef* tim_x) {TIM_TimeBaseInit(tim_x, &_init_struct);return *this;}
};
就是像上面说的,类里有个私有的成员变量,即初始化结构体。对象创建时,会先用默认参数初始化这个结构体。默认参数是比较常用的值,没必要不用改,之后调用初始化的时候可以少写一两行代码。顺便一说,这样初始化应该不会有额外的代价,按我的理解,结构体创建出来后,内部成员本来就要初始化为0,这样写只是把默认的0 改成了别的值,不会重复生成初始化赋值的代码。
除了最后的init
,其他函数都只是给结构体赋一个值,编译器应该可以内联掉,不会付出调用函数的开销。赋值时调用了一个宏,作用是把枚举转换成对应的底层类型,在这里就是转换成了uint16_t
,然后才能送进结构体里。宏的内容如下:
#include <type_traits>#define _ENUM_TO_UNDERLYING(e) static_cast<std::underlying_type_t<decltype(e)>>(e)
用到了C++ 标准库的<type_traits>
。
编译结果对比
总之也没什么好说的,对比一下执行同样功能时,直接用固件库和用包装的存储占用,比较的代码如下:
// C++ 包装TimeBaseInit().division(timxx::clock_div::div1).mode(timxx::counter_mode::up).prescaler(1000).period(1000).repetition_counter(0).init(TIM1).prescaler(2000).period(1000).init(TIM2);// 直接用固件库TIM_TimeBaseInitTypeDef init_struct;init_struct.TIM_ClockDivision = TIM_CKD_DIV1;init_struct.TIM_CounterMode = TIM_CounterMode_Up;init_struct.TIM_Period = 1000;init_struct.TIM_Prescaler = 1000;init_struct.TIM_RepetitionCounter = 0;TIM_TimeBaseInit(TIM1, &init_struct);init_struct.TIM_Period = 2000;init_struct.TIM_Prescaler = 2000;TIM_TimeBaseInit(TIM12, &init_struct);
环境是PlatformIO,编译器arm-none-eabi-gcc-7.2.1,优化等级Os。先是用C++ 包装的编译结果:
Flash 总占用6508 字节,包括了项目里别的代码,不是只有上面那段。然后是用固件库的编译结果:
占用6500 字节,只少了8 个字节。STM32 是32 位架构,所以8 个字节只是两个字。显然,C++ 包装代码里那些函数调用大部分应该是被优化掉了,调用函数给结构体赋值和直接赋值差不多。顺便再对比一下没有这些代码时的体积:
可见,哪怕是直接用固件库,也产生了196 字节的占用,C++ 版本多占了4%,就是个零头。
其他函数
解决了初始化函数这个重难点,其他固件库函数就很简单了,遇到常量参数就全写进一个枚举里面,然后写一层简单的C++ 函数把原来的库函数包起来,或者就不用库了,把库代码复制粘贴过来,直接操作寄存器。简单的封装函数和寄存器操作函数就放在头文件里,加上inline
属性,编译后可以内联,消除调用开销。