拿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 active
Auto-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 downcounter
reaches zero, an update event is generated and counting restarts
from 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 mode
This 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
属性,编译后可以内联,消除调用开销。