C++ 特性简化STM32 风格固件库的GPIO 操作,使用HK32F030M

所谓的STM32 风格就是指下面这种:

// 开启时钟
RCC_AHBPeriphClockCmd( LED1_GPIO_CLK | LED2_GPIO_CLK, ENABLE);

//定义初始化结构体
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_10MHz;

//初始化引脚
GPIO_InitStructure.GPIO_Pin = LED1_GPIO_PIN;
GPIO_Init(LED1_GPIO_PORT, &GPIO_InitStructure);	

GPIO_InitStructure.GPIO_Pin = LED2_GPIO_PIN;
GPIO_Init(LED2_GPIO_PORT, &GPIO_InitStructure);

//操作引脚
GPIO_SetBits(LED1_GPIO_PORT, LED1_GPIO_PIN);
GPIO_SetBits(LED2_GPIO_PORT, LED2_GPIO_PIN);

已经习惯了的人可能会觉得这样完全没毛病,但是我更习惯8 位单片机和Arduino 那种风格,操作GPIO 这种常用的东西就应该信手拈来才对,而不是还要酝酿一阵子,或者到处复制粘贴代码过来。虽说32 位单片机本身更复杂了,操作上更复杂也情有可原,但是有些地方借助c++ 的“新”特性还是可以做成更舒服的样子的。

引脚定义

首先要处理的就是引脚定义的问题。上面的代码中,想完整的使用一个引脚,要涉及好几个东西:

  • 这个引脚的Port 指针 LED1_GPIO_PORT
  • 引脚的Pin LED1_GPIO_PIN
  • 时钟 LED1_GPIO_CLK
  • GPIO_PinSource0 一类的宏,有几个固件库函数也要用到,

于是常见的写法就是像上面的代码那样,一个引脚要定义好几个宏,东一榔头西一棒槌的,连最简单的给引脚置高电平都得同时引用 LED1_GPIO_PORTLED1_GPIO_PIN 这两个宏,相比之下,曾经我们只需要写:

// 51 单片机
LED1 = 1;

// Arduino (Wiring)
digitalWrite(LED1, 1);

// AVR
LED1_GPIO_PORT |= _BV(LED1_GPIO_PIN);

心智负担明显小多了,时间和精力也是很宝贵的,除了AVR,倒是和STM32 的风格差不多[doge]。希望实现的效果类似下面这样:

// 分配PA0 引脚为LED1
LED1 = PA0;

// 点亮LED1,置高电平
setpin(LED1);

非常的biu 特佛,一眼就能看出LED1 是干嘛的,简洁明快,当然这只是用不了的伪代码,反正意思就是这个意思。思路是用一个结构体把这四项东西都包到一起,之后操作引脚只要把结构体拿过来就全都有了,如下:

// PortType 不能定义为GPIOA 的类型,因为GPIOA 是从GPIOA_BASE 强制转换的一个指针,
// 这种转换和reinterpret_cast 是一样的,不能用在constexpr 的初始化过程中,
// 后面要用GPIOA 指针的地方再手动转换一下。
using PortType = decltype(GPIOA_BASE);
using PortClkEnableType = decltype(RCC_AHBPeriph_GPIOA);
using PinType = decltype(GPIO_Pin_0);
using PinSourceType = decltype(GPIO_PinSource0);

struct PinToken {
    PortType port;
    PortClkEnableType port_clk_en;
    PinType pin;
    PinSourceType pin_source;
};

注意,从这里开始,下面的代码就全是C++ 了,源代码后缀名要写成.cpp 或.cxx,用Keil MDK 的话要指定c++ 标准在c++ 11 以上,我现在用的c++ 14。

代码上半部分的using 是用来获取固件库中这些宏的数据类型,定义结构体的时候要用。具体来说,decltype(GPIO_Pin_0) 就是获取固件库头文件中GPIO_Pin_0 这个常数的数据类型,HK32F030M 的库文件里是uint16_t,用这种写法而不是写死是为了兼容性,万一其他单片机的库里不是uint16_t 也不用改代码。就问C 能做到吗~ [doge]

下面的结构体就没什么好说的,只是不用像C 一样再加上typedef 了,关键是用法。现在要定义一堆结构体常量用来包装每个引脚的四项信息,用宏是做不到的,如果C 来实现,一堆结构体肯定会占用一堆程序空间,好在C++ 11 以后有了constexpr,可以定义这种自定义类型的字面常量,而不付出空间上的代价:

// Port A
// 定义并初始化结构体常量,把每个引脚的相关信息存进去
constexpr PinToken PA0 = {GPIOA_BASE, RCC_AHBPeriph_GPIOA, GPIO_Pin_0, GPIO_PinSource0};
constexpr PinToken PA1 = {GPIOA_BASE, RCC_AHBPeriph_GPIOA, GPIO_Pin_1, GPIO_PinSource1};

// Port D
constexpr PinToken PD0 = {GPIOD_BASE, RCC_AHBPeriph_GPIOD, GPIO_Pin_0, GPIO_PinSource0};

上面这样一个一个写还是太麻烦了,可以写一个辅助宏:

// 用来生成引脚定义的宏,展开形式类似如下:
//   constexpr PinToken PA0 = {GPIOA_BASE, RCC_AHBPeriph_GPIOA, GPIO_Pin_0, GPIO_PinSource0}
#define _GPIO_DEF_PIN_TOKEN(PORT, PIN) \
    constexpr PinToken P##PORT##PIN = {GPIO##PORT##_BASE, RCC_AHBPeriph_GPIO##PORT, GPIO_Pin_##PIN, GPIO_PinSource##PIN}

// 用宏生成PD6 的定义
_GPIO_DEF_PIN_TOKEN(D, 6);

// 就等效于下面这样:
constexpr PinToken PD6 = {GPIOD_BASE, RCC_AHBPeriph_GPIOD, GPIO_Pin_6, GPIO_PinSource6};

想了解这个宏的原理的话,可以去搜## 两个井号的作用,或者看看我写的宏魔法简单介绍:C51 实现Arduino 式的IO 引脚编号映射和统一的IO 操作 - C语言宏魔法的简单实践

按上面的写法,可以继续定义全部引脚。constexpr 的效果这里就简单提一句,这种方法定义的“值” 被称为字面量或者编译器常量,字面量就是代码里手写的一个3 这类东西,而编译器常量的意思是说,编译器可以确定这些值一经定义就不会改变,就像用宏定义的常量一样,编译完就没有了,不会专门留一块空间存储它,随用随扔,和汇编里的立即数也差不多。

不管定义多少个引脚,这些常量本身不占用空间,但是就像代码中手写的一个字面量一样,比如,还是3,虽然没专门存储它,但是3 这个信息肯定会体现在代码中,如果用3 作为参数调用一个函数,那么传递参数的时候,根据3 的类型,可能会占用一个字的栈空间,不过返回之后栈空间就回收了,所以也不用太紧张。当然,还有往栈里压参数的指令,也要占用代码空间。

不能直接使用GPIOA 指针

另外,上面的注释中提到一个小注意事项,“PortType 不能定义为GPIOA 的类型,因为GPIOA 是从GPIOA_BASE 强制转换的一个指针”。GPIOA 是固件库中定义的指向外设寄存器的指针,固件库函数中都要使用它,也就是开头代码中的LED1_GPIO_PORT,但是结构体中却不能直接存放这个指针,原因是固件库中GPIOA 的定义:

#define GPIOA               ((GPIO_TypeDef *) GPIOA_BASE)

GPIOA_BASE 是外设寄存器的基地址,类型是uint32_t,也就是一个整数,而GPIOA 要当指针来用,所以就强制类型转换了一下,转换成GPIO_TypeDef 类型的指针,这种用法是ARM 官方的CMSIS 标准里规定的,但是把整数常量转换成指针后就不能用在constexpr 常量中了,这又是C++ 标准中的要求,详细的可以去搜“reinterpret_cast 为什么不能用在constexpr”。结果只好在结构体里存GPIOA_BASE,后面操作函数时要用GPIOA 指针,就再转换一下。

引脚操作函数

要愉快的使用上面定义好的引脚结构体,先从简单的引脚操作函数开始,也就是读、写、翻转这三种操作,首先改写固件库里的设置高低电平的函数:

// 固件库函数
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
  /* Check the parameters */
  assert_param(IS_GPIO_ALL_PERIPH(GPIOx));
  assert_param(IS_GPIO_PIN(GPIO_Pin));

  GPIOx->BSRR = GPIO_Pin;
}

void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
  /* Check the parameters */
  assert_param(IS_GPIO_ALL_PERIPH(GPIOx));
  assert_param(IS_GPIO_PIN(GPIO_Pin));

  GPIOx->BRR = GPIO_Pin;
}


/* 改写 */
// 置位,也就是写1
inline static void setpin(PinToken pin) {
    _GPIO_PORT_TO_POINTER(pin.port)->BSRR = pin.pin;
}

// 清零,也就是写0
inline static void clrpin(PinToken pin) {
    _GPIO_PORT_TO_POINTER(pin.port)->BRR = pin.pin;
}

改写前后一对比其实差别很小,GPIO_SetBits 对应setpin,给引脚置1,即高电平,GPIO_ResetBits 对应clrpin,给引脚置0,即低电平,clr 是很常见的简写,意思是clear,清除,汇编指令中常见CLB 这种写法,其中的CL 也是clear。改写后的版本去掉了参数检查,因为按设计,形参pin 接受的实参只有上面定义过的那些结构体常量,使用者只管用就行了,如果非要自己定义一个不合法的结构体塞进去,那程序员自然是没有客户那种待遇的,硬要酒吧里吃炒面的后果只能自己承担。

更重要的是,改写后的函数加了inline 修饰,只要不把编译器优化关掉,这两个函数的调用会被编译器优化掉,函数会像宏一样原地展开,又因为传入的参数pin 是结构体常量,编译器能直接看到pin.pinpin.port 的值是什么,所以就把值直接塞进去了。于是编译之后,其实和直接使用寄存器的代码是一样的,没有额外的开销。

里面那个宏_GPIO_PORT_TO_POINTER 用来把结构体中的GPIOA_BASE 转换成GPIOA 指针,内容很简单:

#define _GPIO_PORT_TO_POINTER(port) reinterpret_cast<decltype(GPIOA)>(port)   // 做一次类型转换,把整数port 转换成GPIOA 对应的指针类型。

用相同的思路,接着实现剩下的操作函数:

// 读取一个引脚的值,结果为0 表示低电平,非0 为高电平
// 注意,非0 不一定就等于1
inline static auto test_pin(PinToken pin) {
    return _GPIO_PORT_TO_POINTER(pin.port)->IDR & pin.pin;
}

// 引脚电平翻转
inline static void toggle_pin(PinToken pin) {
    _GPIO_PORT_TO_POINTER(pin.port)->ODR ^= pin.pin;
}

// 有些人可能喜欢这种方法设置电平
// level 为0 则设置低电平,否则高电平。如果level 是手写的字面量,那么函数里的if else 也可能被编译器优化掉
inline static void write_pin(PinToken pin, uint8_t level) {
    if (level == 0) {
        clrpin(pin);
    }
    else {
        setpin(pin);
    }
}

这样就差不多了,常用的操作就这么几个,用法如下:

// 先定义两个LED 的引脚
constexpr auto LED1 = PA0;
constexpr auto LED2 = PD7;

//喜欢的话也可以写成宏
#define LED3  PA2

// 置高电平
setpin(LED1);
setpin(LED2);

// 置低电平
clrpin(LED1);
clrpin(LED2);

美中不足的地方是,库函数GPIO_SetBitsGPIO_ResetBits 可以一次设置多个引脚,只要全在同一个Port,比如:

#define LED1_PORT  GPIOA
#define LED1_PIN GPIO_Pin_0
#define LED2_PORT  GPIOA
#define LED2_PIN GPIO_Pin_2

//因为LED1 和LED2 都在GPIOA,所以可以写在一起,同时置1
GPIO_SetBits(LED1_PORT, LED1_PIN | LED2_PIN);

虽然没什么用,万一以后改了LED2 的引脚,不在GPIOA 了,这么写还很容易导致BUG。不过想要的话,改写后的版本也能实现相同的功能,并且继承相同的BUG [doge],有点复杂,最后再说。

引脚初始化函数

也就是定义一个GPIO_InitTypeDef,然后一个个赋值,再一个一个初始化的部分。我不打算把固件库的代码全部改写掉,因为麻烦,而且换到其他单片机之后万一固件库底层实现不一样了就更麻烦了,不如就套一层皮,还是基于这个结构体赋值,只是优化一下写法,反正引脚初始化的部分整个程序中一般只要写一次,多包一层,付出的代价不会很大。需要实现的效果是,我想尽量不碰这个初始化结构体,怎么方便怎么来,比如下面这样:

// 初始化引脚的函数
// 引脚速度、上拉电阻、施密特这三项参数提供了默认值,分别是高速(10MHz)、带上拉、启用施密特
void init_pin(PinToken pin, pin_mode mode, pull_mode pull = pull_mode::up, speed sp = speed::high, schmit sh = schmit::enable) {
    GPIO_InitTypeDef init_struct = {.GPIO_Pin = pin.pin,
                                    .GPIO_Mode = static_cast<GPIOMode_TypeDef>(_GPIO_ENUM_TO_UNDERLYING(mode) & 0x00ff),
                                    .GPIO_Speed = _GPIO_ENUM_TO_ENUM(sp, GPIOSpeed_TypeDef),
                                    .GPIO_OType = static_cast<GPIOOType_TypeDef>(_GPIO_ENUM_TO_UNDERLYING(mode) >> 8),
                                    .GPIO_PuPd = _GPIO_ENUM_TO_ENUM(pull, GPIOPuPd_TypeDef),
                                    .GPIO_Schmit = _GPIO_ENUM_TO_ENUM(sh, GPIOSchmit_TypeDef)};
    GPIO_Init(_GPIO_PORT_TO_POINTER(pin.port), &init_struct);
}

// 使用方法
init_pin(LED1, pin_mode::out_pp)  // 初始化LED1 为推挽(pp)输出(out)模式,其他参数按默认值设置
init_pin(LED2, pin_mode::out_pp)  // LED2 同理

可见实现方式还是很笨的,主要就是靠函数的默认参数减负,函数内部就是根据参数创建了一个初始化结构体,拿去调用固件库的初始化函数GPIO_Init,之后这个结构体就丢弃掉了。其实效率上还行,因为这么一来,本来手写的创建结构体和赋值的步骤全部放在这个函数里了,可以复用,所以代码空间不会有太多浪费,只是每次初始化都要创建一次结构体,稍微费点时间。函数参数除了引脚结构体,就是pin_mode 等一堆枚举,这就是把固件库里的枚举值也重新封装了一遍,因为固件库里的枚举是C 的枚举,有一些用起来不爽的特性。

枚举

简单说一下C 语言枚举的问题,比如下面这个:

typedef enum
{
  GPIO_Mode_IN   = 0x00, /*!< GPIO Input Mode              */
  GPIO_Mode_OUT  = 0x01, /*!< GPIO Output Mode             */
  GPIO_Mode_AF   = 0x02, /*!< GPIO Alternate function Mode */
  GPIO_Mode_AN   = 0x03  /*!< GPIO Analog In/Out Mode      */
}GPIOMode_TypeDef;

这个枚举的大括号里面包了四个值,即GPIO_Mode_IN 这几个,但是和结构体成员不一样,枚举里的值是可以直接在外面引用的,也就是说:

// 不用先礼貌的通知一下枚举类型
func(GPIOMode_TypeDef.GPIO_Mode_IN)

// 而是直接把值拿出来用
func(GPIO_Mode_IN);

习惯了的人大概就习惯了,但这是存在问题的,就是污染全局作用域,和全局变量一样,一定义出来就到处都是,所以才不得不给每个枚举值都加上前缀,以免名字冲突。另一方面,这样也不方便使用IDE 的智能提示功能,比如结构体变量,打一个点号后面就会出来菜单提示你里面有哪些成员,而枚举值就只能看运气,所有相同前缀的东西都会蹦出来。

所以就拿C++ 的结构体重写了一下,只说pin_mode,这个结构体把固件库中的Mode 和OType 组合起来了,就是本来要分别设置成输出、推挽,改写之后pin_mode 枚举里面专门有一个模式out_pp 就是推挽输出类型,会同时设置Mode 和OType。

// 高8 字节表示输出类型,低8 字节为模式
enum class pin_mode {
    in = GPIO_Mode_IN,
    out_pp = GPIO_Mode_OUT | (GPIO_OType_PP << 8),
    out_od = GPIO_Mode_OUT | (GPIO_OType_OD << 8),
    af_pp = GPIO_Mode_AF | (GPIO_OType_PP << 8),  // AF 模式不需要手动设置,对应的外设函数会自动配置
    af_od = GPIO_Mode_AF | (GPIO_OType_OD << 8),
    an = GPIO_Mode_AN,
};

可见,就是用位运算把Mode 和OType 组合到一起了,然后在函数里再分解开,目的就是方便,少写一个参数。剩下的几个枚举基本是复制粘贴了固件库:

// 上拉/下拉电阻
enum class pull_mode {
    no = GPIO_PuPd_NOPULL,
    up = GPIO_PuPd_UP,
    down = GPIO_PuPd_DOWN
};

// 频率
enum class speed {
    low = GPIO_Speed_2MHz,
    high = GPIO_Speed_10MHz, 
    //high = GPIO_Speed_50MHz  HK32F030M 不支持50Mhz
};

// HK32F030M 可以关闭引脚的施密特触发器功能,不知道是不是Cortex-M0 单片机都有,关掉大概可以省电,默认打开
enum class schmit {
    disable = GPIO_Schmit_Disable,
    enable = GPIO_Schmit_Enable,
};

后面三个枚举对应的参数都有默认值,一般不用修改,所以初始化引脚大部分时候只用设置一个pin_mode 就行。

复用初始化结构体

如果还是想像固件库一样一个初始化结构体重复使用,可以给上面的init_pin 增加两个重载:

/**
 * @brief 按照输入参数初始化空的GPIO_TypeDef 结构体,然后初始化GPIO,传入的GPIO_TypeDef 可以被void init_pin(PinToken pin, GPIO_TypeDef &init_struct)复用。
 *
 * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
 * @param init_struct 初始化结构体的所有数据将被覆盖
 * @param mode 配置输入、输出、复用、模拟输入,以及推挽或开漏输出
 * @param pull 配置上拉、下拉或浮空
 * @param sp 配置速度
 * @param sh 配置输入施密特触发器
 */
void init_pin(PinToken pin, GPIO_InitTypeDef& init_struct, pin_mode mode, pull_mode pull=pull_mode::up, speed sp=speed::high, schmit sh=schmit::enable) {
    init_struct.GPIO_Pin = pin.pin;
    init_struct.GPIO_Mode = static_cast<GPIOMode_TypeDef>(_GPIO_ENUM_TO_UNDERLYING(mode) & 0x00ff);
    init_struct.GPIO_Speed = _GPIO_ENUM_TO_ENUM(sp, GPIOSpeed_TypeDef);
    init_struct.GPIO_OType = static_cast<GPIOOType_TypeDef>(_GPIO_ENUM_TO_UNDERLYING(mode) >> 8);
    init_struct.GPIO_PuPd = _GPIO_ENUM_TO_ENUM(pull, GPIOPuPd_TypeDef);
    init_struct.GPIO_Schmit = _GPIO_ENUM_TO_ENUM(sh, GPIOSchmit_TypeDef);
    GPIO_Init(_GPIO_PORT_TO_POINTER(pin.port), &init_struct);
}


/**
 * @brief 复用已经配置好的GPIO_TypeDef 结构体继续配置引脚
 *
 * 使用方法是先用带配置参数的init_pin 重载把初始化结构体配置好,然后复用该结构体,调用这个函数配置其他参数相同的引脚
 *
 * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
 * @param init_struct 只修改初始化结构体的GPIO_Pin,其他保持原样
 */
void init_pin(PinToken pin, GPIO_InitTypeDef& init_struct) {
    init_struct.GPIO_Pin = pin.pin;
    GPIO_Init(_GPIO_PORT_TO_POINTER(pin.port), &init_struct);
}

这两个函数的第二个参数是初始化结构体的引用,调用函数前先创建一个空白的初始化结构体,然后调用第一个函数,函数里面会根据参数初始化传入的结构体,并配置引脚。第二个函数没有用来配置引脚模式的参数,是用来批量配置多个相同参数的引脚的,直接拿传入的结构体配置传入的引脚,所以必须先调用第一个函数,然后才能使用第二个,如下:

GPIO_InitTypeDef init_struct;
//初始化LED1 为推挽输出,其他参数按默认值
init_pin(LED1, init_struct, pin_mode::out_pp);
//用相同的参数初始化LED2
init_pin(LED2, init_struct);

链式调用

这样可能还嫌不够爽,比如要手动创建一个初始化结构体变量,调用函数的时候每次都要手动传入这个初始化结构体,那么还可以采用所谓的链式调用设计,实现方法是先定义一个类,如下:

class ChainInit {
   private:
    GPIO_InitTypeDef init_struct;
	
   public:
    /**
     * @brief 修改初始化结构体并配置引脚,返回值可以链式调用继续初始化下一个引脚
     *
     * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
     * @param init_struct 初始化结构体的所有数据将被覆盖
     * @param mode 配置输入、输出、复用、模拟输入,以及推挽或开漏输出
     * @param pull 配置上拉、下拉或浮空,默认为上拉
     * @param sp 配置速度,默认为高速/10MHz
     * @param sh 配置输入施密特触发器,默认使能
     * @return ChainInit&
     */
    inline ChainInit& init(PinToken pin, pin_mode mode, pull_mode pull = pull_mode::up, speed sp = speed::high, schmit sh = schmit::enable) {
        init_pin(pin, this->init_struct, mode, pull, sp, sh);
        return *this;
    }


    /**
     * @brief 复用已经配置好的GPIO_TypeDef 结构体继续配置引脚
     *
     * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
     * @return ChainInit&
     */
    inline ChainInit& init(PinToken pin) {
        init_pin(pin, this->init_struct);
        return *this;
    }
};

初始化结构体这次变成了private 成员变量,定义对象时就顺便有了初始化结构体;两个成员函数的用法和前面的init_pin 相似,只是现在它们会返回对象的引用,因此可以链式调用,用法如下:

//设置两个LED 为推挽输出,两个KEY 为输入
ChainInit()
  .init(LED1, pin_mode::out_pp)
  .init(LED2)
  .init(KEY1, pin_mode::in)
  .init(KEY2);

ChainInit() 没有绑定到一个变量,所以是创建了一个临时对象,用这个临时对象内部的初始化结构体依次初始化四个引脚,然后临时对象就被废弃。对象里只有一个初始化结构体,从存储空间的角度看,这个对象和初始化结构体是完全等同的;两个成员函数的内容都很简单,会被内联,所以调用成员函数和直接调用init_pin 没有区别,不会增加开销。

启动时钟函数

最后再来考虑操作顺序上排第一的这个函数。要简化操作,所以启动时钟要和其他GPIO 相关的函数放在一起,而不是像固件库那样放到RCC 那边,从而让整个GPIO 的操作更有整体感,像一个分离的模块。另外提一句,HK32F030M 的GPIO 模块是挂在AHB-Lite 总线上的,而不是像F103 单片机那样挂在APB 上,如下图:

在这里插入图片描述

因此,参考固件库的实现,启动时钟的函数可以写成下面这样:

inline static void enable_clk(PinToken pin) {
    // RCC_AHBPeriphClockCmd(pin.port_clk_en, ENABLE);
    RCC->AHBENR |= pin.port_clk_en;
}

// 用法
enable_clk(LED1);
enable_clk(LED2);

直接用寄存器操作,函数也带有inline 修饰,所以函数调用能被优化。但是用固件库的函数时可以好几个引脚一起当参数传进去,上面这种一个一个调用的写法太低效了。那么,首先就要求这个函数可以接受数量不确定的多个参数,其次,尽量不能损失效率。数组传参和VA_ARGS 这两种方案不能采用,前者又丑又低效,后者会让函数内容变复杂,无法内联优化,损失效率。

C++ 函数可变参数方案

还有两种方案,一种是给函数增加一大堆不同参数个数的重载,到时候要调用,不管有几个参数,都提前准备了对应参数数量的函数重载,比如这样:

// 一个参数
inline static void enable_clk(PinToken pin) {
    RCC->AHBENR |= pin.port_clk_en;
}

// 两个参数
inline static void enable_clk(PinToken pin1, PinToken pin2) {
    RCC->AHBENR |= (pin1.port_clk_en | pin1.port_clk_en);
}

// ...

//很多参数
inline static void enable_clk(PinToken pin1, PinToken pin2, PinToken pin3, /* ... */) {
    RCC->AHBENR |= (pin1.port_clk_en | pin2.port_clk_en | /* ... */);
}

//用法
enable_clk(LED1, LED2, KEY1, KEY2);   // 自动匹配四个参数的函数重载

看着挺傻的,但是这样确实能用,函数重载也可以用脚本自动生成,有些地方其实真的在用这种设计,而且这个简单函数会被内联优化掉,所以定义的重载再多也没事,不会多占用存储空间。第二种方案相当于让编译器自动在调用的时候生成对应参数数量的函数重载,也就是C++ 的模板元编程技术,从C++ 11 开始引入了变参模板,就可以用来“优雅”的实现可变参数,如下:

constexpr PortClkEnableType _calc_port_clk_sum(PinToken pin) {
    return pin.port_clk_en;
}

template <typename... Ts>
constexpr PortClkEnableType _calc_port_clk_sum(PinToken pin, Ts... args) {
    return pin.port_clk_en | _calc_port_clk_sum(args...);
}

template <typename... Ts>
inline static void enable_clk(PinToken pin, Ts... args) {
    RCC->AHBENR |= _calc_port_clk_sum(pin, args...);
}

template <typename... Ts>
inline static void disable_clk(PinToken pin, Ts... args) {
    RCC->AHBENR &= ~_calc_port_clk_sum(pin, args...);
}

//用法
enable_clk(LED1, LED2, KEY1, KEY2);   // 使用上倒是没什么要特别注意的,随便几个参数都行

前面两个函数用来递归的计算出(pin1.port_clk_en | pin2.port_clk_en | /* ... */) 这部分的值,然后enable_clk 再拿计算结果给寄存器赋值,启动GPIO 的时钟,disable_clk 是用来关闭时钟的函数,一般应该不太能用到。整个过程全部在编译期完成,不会占用运行时的资源。如果想让上面的setpinclrpin 函数可以一次设置多个引脚,也可以用相同的模板技巧。具体的细节原理我就不说了,网上一搜资料很多。

总结

最后就再加一个命名空间,把上面的定义的东西都放进命名空间里,完整的代码见[附录 - 1](#附录 - 1)。

虽然说了是用C++,但其实上面这些并没有用到什么面向对象的东西,全都是静态链接的,虚函数、多态、动态内存之类的让人“闻之色变”的东西都没涉及,这种风格就是所谓的Better C,Arduino 的库代码基本上也是这种风格,把C++ 当成更好使的C 来用,而不是把C++ 当作更混沌的Java,言必称对象,随时随地new。

另一方面,其实也能看到,原本的固件库的设计也不是一无是处,高情商叫灵活,低情商叫松散,在那种设计下,如果以后硬件上多加了什么功能,库代码要改动的地方很少,但是官方写库的人轻松了,锅就推给了用库的人。相对而言,C++ 的正经库的设计就有点走向另一个极端了,对写库的人素质要求很高,因为C++ 的哲学就是我全都要.jpg,既想要灵活,兼容性好,又想让用库的人写起来比较舒心,还想尽量不损失效率。于是往往是大神们先提出一些风骚但拧巴的设计,用来实现既要又要还要的理想,然后C++ 语言再把其中一些概念正式化,纳入语言本身,让写库的大神们体验更好。总之,我辈凡人还是要量力而为[doge]。

附录 - 1

#include <type_traits>

#include "hk32f030m.h"

// 用来生成引脚定义的宏,展开形式类似如下:
//   constexpr PinToken PA0 = {GPIOA_BASE, RCC_AHBPeriph_GPIOA, GPIO_Pin_0, GPIO_PinSource0}
#define _GPIO_DEF_PIN_TOKEN(PORT, PIN) \
    constexpr PinToken P##PORT##PIN = {GPIO##PORT##_BASE, RCC_AHBPeriph_GPIO##PORT, GPIO_Pin_##PIN, GPIO_PinSource##PIN}

#define _GPIO_PORT_TO_POINTER(port) reinterpret_cast<decltype(GPIOA)>(port)

#define _GPIO_ENUM_TO_UNDERLYING(e) static_cast<std::underlying_type_t<decltype(e)>>(e)

#define _GPIO_ENUM_TO_ENUM(e1, e2) static_cast<e2>(static_cast<std::underlying_type_t<decltype(e1)>>(e1))


/**
 * @brief 定义了HK32F030M 和0301M 所有GPIO 引脚,特定封装下可能不是全部可用
 *
 * 在F030M 下:
 * PA0 是NRST 引脚的复用功能,F030M 上可能无法启用;PD7 就是VCAP 引脚,可以用作GPIO,但不一定能使用复用功能。
 *
 * 在F0301M 下:
 * 包括PA0 和PD7 在内,共18 个IO 可用。复用NRST 引脚后,编程时只能使用上电复位。
 */
namespace gpio {

    // PortType 不能定义为GPIOA 的类型,因为GPIOA 是从GPIOA_BASE 强制转换的一个指针,
    // 这种转换和reinterpret_cast 是一样的,不能用在constexpr 的初始化过程中,
    // 后面要用GPIOA 指针的地方再手动转换一下。
    using PortType = decltype(GPIOA_BASE);
    using PortClkEnableType = decltype(RCC_AHBPeriph_GPIOA);
    using PinType = decltype(GPIO_Pin_0);
    using PinSourceType = decltype(GPIO_PinSource0);


    struct PinToken {
        PortType port;
        PortClkEnableType port_clk_en;
        PinType pin;
        PinSourceType pin_source;
    };


    // PAx
    _GPIO_DEF_PIN_TOKEN(A, 0);  // NRST 复用功能
    _GPIO_DEF_PIN_TOKEN(A, 1);
    _GPIO_DEF_PIN_TOKEN(A, 2);
    _GPIO_DEF_PIN_TOKEN(A, 3);

    // PBx
    _GPIO_DEF_PIN_TOKEN(B, 4);
    _GPIO_DEF_PIN_TOKEN(B, 5);

    // PCx
    _GPIO_DEF_PIN_TOKEN(C, 3);
    _GPIO_DEF_PIN_TOKEN(C, 4);
    _GPIO_DEF_PIN_TOKEN(C, 5);
    _GPIO_DEF_PIN_TOKEN(C, 6);
    _GPIO_DEF_PIN_TOKEN(C, 7);

    // PDx
    _GPIO_DEF_PIN_TOKEN(D, 1);
    _GPIO_DEF_PIN_TOKEN(D, 2);
    _GPIO_DEF_PIN_TOKEN(D, 3);
    _GPIO_DEF_PIN_TOKEN(D, 4);
    _GPIO_DEF_PIN_TOKEN(D, 5);
    _GPIO_DEF_PIN_TOKEN(D, 6);
    _GPIO_DEF_PIN_TOKEN(D, 7);  // VCAP 引脚


    constexpr PortClkEnableType _calc_port_clk_sum(PinToken pin) {
        return pin.port_clk_en;
    }

    template <typename... Ts>
    constexpr PortClkEnableType _calc_port_clk_sum(PinToken pin, Ts... args) {
        return pin.port_clk_en | _calc_port_clk_sum(args...);
    }

    template <typename... Ts>
    //__attribute__((always_inline)) inline static void enable_clk(const PinToken pin, const Ts... args) {
    inline static void enable_clk(PinToken pin, Ts... args) {
        // RCC_AHBPeriphClockCmd(_calc_port_clk_sum(pin, args...), ENABLE);
        RCC->AHBENR |= _calc_port_clk_sum(pin, args...);
    }

    template <typename... Ts>
    inline static void disable_clk(PinToken pin, Ts... args) {
        // RCC_AHBPeriphClockCmd(_calc_port_clk_sum(pin, args...), DISABLE);
        RCC->AHBENR &= ~_calc_port_clk_sum(pin, args...);
    }


    // 高8 字节表示输出类型,低8 字节为模式
    enum class pin_mode {
        in = GPIO_Mode_IN,
        out_pp = GPIO_Mode_OUT | (GPIO_OType_PP << 8),
        out_od = GPIO_Mode_OUT | (GPIO_OType_OD << 8),
        af_pp = GPIO_Mode_AF | (GPIO_OType_PP << 8),  // AF 模式不需要手动设置,对应的外设函数会自动配置
        af_od = GPIO_Mode_AF | (GPIO_OType_OD << 8),
        an = GPIO_Mode_AN,
    };

    enum class pull_mode {
        no = GPIO_PuPd_NOPULL,
        up = GPIO_PuPd_UP,
        down = GPIO_PuPd_DOWN
    };

    enum class speed {
        low = GPIO_Speed_2MHz,
        high = GPIO_Speed_10MHz,
        // high = GPIO_Speed_50MHz  不支持50Mhz
    };

    enum class schmit {
        disable = GPIO_Schmit_Disable,
        enable = GPIO_Schmit_Enable,
    };

    constexpr GPIO_InitTypeDef make_empty_init() {
        return GPIO_InitTypeDef{};
    }


    /**
     * @brief 按照输入参数初始化空的GPIO_TypeDef 结构体,然后初始化GPIO,传入的GPIO_TypeDef 可以被void init_pin(PinToken pin, GPIO_TypeDef &init_struct)复用。
     *
     * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
     * @param init_struct 初始化结构体的所有数据将被覆盖
     * @param mode 配置输入、输出、复用、模拟输入,以及推挽或开漏输出
     * @param pull 配置上拉、下拉或浮空
     * @param sp 配置速度
     * @param sh 配置输入施密特触发器
     */
    void init_pin(PinToken pin, GPIO_InitTypeDef& init_struct, pin_mode mode, pull_mode pull, speed sp, schmit sh) {
        init_struct.GPIO_Pin = pin.pin;
        init_struct.GPIO_Mode = static_cast<GPIOMode_TypeDef>(_GPIO_ENUM_TO_UNDERLYING(mode) & 0x00ff);
        init_struct.GPIO_Speed = _GPIO_ENUM_TO_ENUM(sp, GPIOSpeed_TypeDef);
        init_struct.GPIO_OType = static_cast<GPIOOType_TypeDef>(_GPIO_ENUM_TO_UNDERLYING(mode) >> 8);
        init_struct.GPIO_PuPd = _GPIO_ENUM_TO_ENUM(pull, GPIOPuPd_TypeDef);
        init_struct.GPIO_Schmit = _GPIO_ENUM_TO_ENUM(sh, GPIOSchmit_TypeDef);
        GPIO_Init(_GPIO_PORT_TO_POINTER(pin.port), &init_struct);
    }


    /**
     * @brief 复用已经配置好的GPIO_TypeDef 结构体继续配置引脚
     *
     * 使用方法是先用带配置参数的init_pin 重载把初始化结构体配置好,然后复用该结构体,调用这个函数配置其他参数相同的引脚
     *
     * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
     * @param init_struct 只修改初始化结构体的GPIO_Pin,其他保持原样
     */
    void init_pin(PinToken pin, GPIO_InitTypeDef& init_struct) {
        init_struct.GPIO_Pin = pin.pin;
        GPIO_Init(_GPIO_PORT_TO_POINTER(pin.port), &init_struct);
    }


    /**
     * @brief 内部创建一个GPIO_TypeDef 结构体,返回后丢弃
     *
     * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
     * @param init_struct 初始化结构体的所有数据将被覆盖
     * @param mode 配置输入、输出、复用、模拟输入,以及推挽或开漏输出
     * @param pull 配置上拉、下拉或浮空,默认为上拉
     * @param sp 配置速度,默认为中速/10MHz
     * @param sh 配置输入施密特触发器,默认使能
     */
    void init_pin(PinToken pin, pin_mode mode, pull_mode pull = pull_mode::up, speed sp = speed::high, schmit sh = schmit::enable) {
        auto init_struct = make_empty_init();
        init_pin(pin, init_struct, mode, pull, sp, sh);
    }


    inline static void set_pin_mode(PinToken pin, pin_mode mode) {
        _GPIO_PORT_TO_POINTER(pin.port)->MODER &= ~(GPIO_MODER_MODER0 << (pin.pin_source * 2));
        _GPIO_PORT_TO_POINTER(pin.port)->MODER |= ((_GPIO_ENUM_TO_UNDERLYING(mode) & 0x00ff) << (pin.pin_source * 2));
        if (mode == pin_mode::out_od || mode == pin_mode::out_pp || mode == pin_mode::af_od || mode == pin_mode::af_pp) {
            _GPIO_PORT_TO_POINTER(pin.port)->OTYPER &= ~((GPIO_OTYPER_OT_0) << pin.pin_source);
            _GPIO_PORT_TO_POINTER(pin.port)->OTYPER |= static_cast<uint16_t>((_GPIO_ENUM_TO_UNDERLYING(mode) >> 8) << (pin.pin_source));
        }
    }


    inline static auto test_pin(PinToken pin) {
        return _GPIO_PORT_TO_POINTER(pin.port)->IDR & pin.pin;
    }

    inline static void setpin(PinToken pin) {
        _GPIO_PORT_TO_POINTER(pin.port)->BSRR = pin.pin;
    }


    inline static void clrpin(PinToken pin) {
        _GPIO_PORT_TO_POINTER(pin.port)->BRR = pin.pin;
    }


    inline static void write_pin(PinToken pin, uint8_t level) {
        if (level == 0) {
            clrpin(pin);
        }
        else {
            setpin(pin);
        }
    }


    inline static void toggle_pin(PinToken pin) {
        _GPIO_PORT_TO_POINTER(pin.port)->ODR ^= pin.pin;
    }

    // TODO: AF config


    class ChainInit {
       private:
        GPIO_InitTypeDef init_struct;

       public:
        /**
         * @brief 修改初始化结构体并配置引脚,返回值可以链式调用继续初始化下一个引脚
         *
         * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
         * @param init_struct 初始化结构体的所有数据将被覆盖
         * @param mode 配置输入、输出、复用、模拟输入,以及推挽或开漏输出
         * @param pull 配置上拉、下拉或浮空,默认为上拉
         * @param sp 配置速度,默认为高速/10MHz
         * @param sh 配置输入施密特触发器,默认使能
         * @return ChainInit&
         */
        inline ChainInit& init(PinToken pin, pin_mode mode, pull_mode pull = pull_mode::up, speed sp = speed::high, schmit sh = schmit::enable) {
            gpio::init_pin(pin, this->init_struct, mode, pull, sp, sh);
            return *this;
        }


        /**
         * @brief 复用已经配置好的GPIO_TypeDef 结构体继续配置引脚
         *
         * @param pin 指向GPIO 引脚的PinToken,应该使用定义好的PAx 等常量
         * @return ChainInit&
         */
        inline ChainInit& init(PinToken pin) {
            gpio::init_pin(pin, this->init_struct);
            return *this;
        }
    };

}  // namespace gpio


  MAIN  ///


void nrst_pin_switch_as_pa0() {
    // 将NRST 复用为PA0
    RCC->APB1ENR |= RCC_APB1ENR_IOMUXEN;
    GPIOMUX->NRST_PIN_KEY = 0x5AE1;
    GPIOMUX->NRST_PA0_SEL = 1;
}


void Delay(uint32_t nCount)  // 简单的延时函数
{
    for (; nCount != 0; nCount--)
        __NOP();
}


constexpr auto LED0 = gpio::PA0;
constexpr auto LED1 = gpio::PC7;
constexpr auto LED2 = gpio::PD7;

int main(void) {
    // 测试PA0 和PD7 能不能用
    nrst_pin_switch_as_pa0();

    // LED 端口初始化
    gpio::enable_clk(LED0, LED1, LED2);

    // 设置三个LED 引脚为推挽输出
    gpio::ChainInit()
        .init(LED0, gpio::pin_mode::out_pp)
        .init(LED1)
        .init(LED2);

    using namespace gpio;

    while (1) {
        clrpin(LED0);  // 亮
        Delay(0x0FFFFF);
        setpin(LED0);  // 灭

        clrpin(LED1);  // 亮
        Delay(0x0FFFFF);
        setpin(LED1);  // 灭

        clrpin(LED2);  // 亮
        Delay(0x0FFFFF);
        setpin(LED2);  // 灭
    }
}

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值