干货 | 怎样让 I/O 口配置代码更简洁

我DIY用了好几款STM32了(碰巧都是F0和F4系列的,没有用F1系列。F1系列的GPIO寄存器表有所不同,不能直接用本文的代码),每新做一块PCB,或者在Nucleo上试不同的应用,差不多都要把I/O口配置的代码重新写一次。或者不完全重写,拿已有的程序来改,核对每一个用到的管脚的连接,还是会消耗工夫。引脚最重要的属性是作为输入还是输出用,还是作为复用功能,这通常在画电路图的时候就安排好了,写程序只是对照设计文档来做。

STM32的GPIO模块由MODER寄存器决定引脚的功能,即四种选择:输出/输入/复用功能/模拟。16个引脚用1个32-bit的寄存器定义,每个引脚占2 bits, 默认00是输入功能。我常常是类似这么写的:

GPIOA->MODER = GPIO_MODER_MODER14_1|GPIO_MODER_MODER13_1   // PA14, PA13 AF (SWD), other input
                   |GPIO_MODER_MODER10_1|GPIO_MODER_MODER9_1           // PA10, PA9 AF (UART)
                   |GPIO_MODER_MODER8_0;                     // PA8 (LED)

对 MODER 寄存器初始化针对非数字输入用途的引脚(默认输入就不用配了),如果设成输出就要把对应2 bits设成01, 复用功能设成 10, 模拟用途则设为 11, 可以分别用 stm32fxxxx.h 头文件里面的 GPIO_MODER_MODERy_1, GPIO_MODER_MODERy_0, 以及 GPIO_MODER_MODERy 宏定义来书写。不用宏定义,上面这段也可以写成

GPIOA->MODER = 2<<28|2<<26|2<<20|2<<18|1<<16;

简洁了不少,但是可读性下降,因为 28, 26, 20, 18, 16 这几个数字没有直接对应端口号,需要大脑换算。不过嘛,这总比写成

GPIOA->MODER = 0x28290000;

的可读性强多了,直接写十六进值数的代码是很难排错和重用的(当然,要写成十进制的话……

)

类似 MODER 寄存器的还有设置上拉下拉的 PUPDR 寄存器,设置输出翻转速度的 OSPEEDR 寄存器。不过,重要性仅次于 MODER 寄存器的是设置引脚复用的具体功能。因为在 STM32 上,每个引脚最多可能有 16 种特殊硬件功能的复用选择,这在手册上会以表格列出,如下图这样。AF (Alternate Function)的编号从0到15, 在 AFRH 和 AFRL 寄存器中每4 bits用来指定一个引脚的复用功能选择,如果在配置 MODER 该引脚为复用功能。

单独阅读代码,是不能从 AFRx 寄存器的值反推出复用功能是哪个的。MCU上的硬件模块太多了。于是我在写程序的时候特意填加了注释。关于 AFRx 寄存器, stm32fxxxx.h 头文件里面的宏起不到什么帮助,反而是直接写十六进制数最直观:因为十六进制一位数就是4比特,例如

GPIOA->AFR[1]=0x000AA000       // PA14,13 as SWD, PA12,11 as USB
                 |0x00000770                        // PA10,9 as USART1
                 |0x0000000C;                      // PA8 as SDIO

我把不同组的功能分散在几行来写,以便于添加注释,以后删改也容易一些。但是若写错了AF号,不对照手册也是不能发现的。

从网上找来的例子中,GPIO配置部分可能这么来写:
GPIO_InitTypeDef GPIO_InitStructure;

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);

GPIO_PinAFConfig(GPIOB,GPIO_PinSource6,GPIO_AF_USART1);
GPIO_PinAFConfig(GPIOB,GPIO_PinSource7,GPIO_AF_USART1);

我自己不会喜欢这样的代码,第一是绕了圈子,把简单的东西复杂化了,做了太多不必要的寄存器和内存操作;第二是源代码长度也增加了,需要多敲键盘,虽然读起来知道每一行写的要干什么。对于 AF 功能选择,用库函数也没有提供任何帮助,像上面 GPIO_AF_USART1 这个宏定义,如果用错了Pin位置也依然无法查错。

我想很少有人像我这样手动写寄存器来配置 GPIO 吧

…… 我猜想大多数人用的是图形化的工具来配的,然后,就由软件直接生成代码了…… 根本不是自己敲进去的

但是我还要坚持,也许是我难接受新生事物,呃——我从Visual C++开始就不喜欢IDE环境,偏好命令行操作和Makefile, 坚持把源代码和其它数据分开。我希望代码就是书写出来的,具有可读性的,容易维护的。

今天整了一天,算是有所改进了。这个办法是针对 STM32 的,这个思想也可以移用在其它 MCU 平台。我的设想是:用 #define 定义宏来指定复用功能,以及引脚的功能选择和其它属性。

例如,想把 PA9 设置为 USART1_TX 这个复用功能,就定义
#define ASSIGN_USART1_TX_PA9
当然,PA9 必须要具有这个选项才可以用,否则定义了也无效。类似的,定义
#define ASSIGN_SPI1_MOSI_PA7
来打开 PA7 的复用功能,设为 SPI1_MOSI.  对一般的输出引脚设定,支持如下的宏
#define USE_PB1_OUTPUT
#define USE_PA0_INPUT
#define USE_PC0_ANALOG
#define USE_PA0_PULLUP
#define USE_PB1_OPENDRAIN
分别设置输出模式、输入模式、模拟功能,还可以设置上拉、设置开漏输出。注意,仅仅是宏定义,不需要写任何操作寄存器的代码。只需调用一次 gpio_config() 函数即可完成所有 GPIO 口的初始设置。这个函数也是写好的,针对一个器件源程序是固定不变的(当然编译结果因配置而变)。

按这个起初的想法实践了,我发现一些问题:一旦在使用的时候拼写出错,那么定义就无效,期望的设置没有达到,然而编译器不会有任何错误或警告——因为定义一个不被用到的宏和没有定义是一样的。

于是为了防止手误,我要求在使用复用功能的时候,除了使用上面 #define ASSIGN_SPI1_MOSI_PA7 这样的宏之外,还必须再定义 #define USE_PA7_ALTFUNC 指定功能,一旦缺其一就会有错,算是保险一些了。副作用是又不那么简洁了。
不过终归是语句越长越容易拼写错,我偶然把下划线漏了敲了空格都没有一下子发现。后来,又把上面那段定义方式修改为这样:
#define USE_PB1  PIN_OUT | PIN_OD
#define USE_PA0  PIN_IN | PIN_PULLUP
#define USE_PC0  PIN_ANA
因为 USE_PB1 这样短的标识符拼错的概率就大大降低了。再用逻辑或组合预定义的值来实现选择功能,更紧凑一些。不过,设置复用功能仍然需要两个 #define ,占用两行代码。

下面是调试过的一个例子程序,关于 GPIO 配置的部分:

  1. // file:  gpio_config.c

  2. #include "stm32f0xx.h"

  3. #include "gpiodef.h"

  4. #define USE_PA3  PIN_OUT

  5. #define USE_PA6  PIN_OUT

  6. #define USE_PA13 PIN_AF

  7. #define ASSIGN_SWDIO_PA13

  8. #define USE_PA14 PIN_AF

  9. #define ASSIGN_SWCLK_PA14

  10. #define USE_PA9  PIN_AF

  11. #define ASSIGN_USART1_TX_PA9

  12. #define USE_PA10 PIN_AF|PIN_PULLUP

  13. #define ASSIGN_USART1_RX_PA10

  14. #define USE_PA7  PIN_AF

  15. #define ASSIGN_SPI1_MOSI_PA7

  16. #define USE_PA5  PIN_AF

  17. #define ASSIGN_SPI1_SCK_PA5

  18. #define USE_PA4  PIN_AF

  19. #define ASSIGN_SPI1_NSS_PA4

  20. #include "gpiodef.c"

这个 C 文件包含了一个 .h 文件,其中定义了 PIN_OUT,  PIN_AF, PIN_PULLUP 这样的宏;然后用 #define 来书写需要用到的I/O引脚,没有写的会默认成模拟功能。最后一行 #include 的文件里面,才包含产生机器代码的地方。这段代码编译之后,产生一个函数 gpio_config(), 机器代码如下:

  1. 00000000 <gpio_config>:

  2. 0:   4b0c            ldr     r3, [pc, #48]   ; (34 <gpio_config+0x34>)

  3. 2:   229c            movs    r2, #156        ; 0x9c

  4. 4:   6959            ldr     r1, [r3, #20]

  5. 6:   03d2            lsls    r2, r2, #15

  6. 8:   430a            orrs    r2, r1

  7. a:   615a            str     r2, [r3, #20]

  8. c:   4a0a            ldr     r2, [pc, #40]   ; (38 <gpio_config+0x38>)

  9. e:   2301            movs    r3, #1

  10. 10:   425b            negs    r3, r3

  11. 12:   6013            str     r3, [r2, #0]

  12. 14:   4909            ldr     r1, [pc, #36]   ; (3c <gpio_config+0x3c>)

  13. 16:   2290            movs    r2, #144        ; 0x90

  14. 18:   05d2            lsls    r2, r2, #23

  15. 1a:   6011            str     r1, [r2, #0]

  16. 1c:   2188            movs    r1, #136        ; 0x88

  17. 1e:   0049            lsls    r1, r1, #1

  18. 20:   6251            str     r1, [r2, #36]   ; 0x24

  19. 22:   2180            movs    r1, #128        ; 0x80

  20. 24:   0349            lsls    r1, r1, #13

  21. 26:   60d1            str     r1, [r2, #12]

  22. 28:   4a05            ldr     r2, [pc, #20]   ; (40 <gpio_config+0x40>)

  23. 2a:   6013            str     r3, [r2, #0]

  24. 2c:   4a05            ldr     r2, [pc, #20]   ; (44 <gpio_config+0x44>)

  25. 2e:   6013            str     r3, [r2, #0]

  26. 30:   4770            bx      lr

  27. 32:   46c0            nop                     ; (mov r8, r8)

  28. 34:   40021000        .word   0x40021000

  29. 38:   48001400        .word   0x48001400

  30. 3c:   ebeb9a7f        .word   0xebeb9a7f

  31. 40:   48000400        .word   0x48000400

  32. 44:   48000800        .word   0x48000800

因为 gpiodef.c 这个文件很冗长(当然了,不是手写出来的),通篇是条件编译命令,这里只能看编译结果了。代码还是很短的,其实就是直接操作 MODER, AFRH, AFRL, PUPDR 这些寄存器。

gpiodef.c 这个文件是个模板,把所有可能用到的AF设置都要包含进去。具体在编译的时候,根据用到的引脚来确定寄存器的值。在 gpio_config() 函数里面,可以这么写:

GPIOA->MODER = GPIOA_MODER15|GPIOA_MODER14|......GPIOA_MODER0
GPIOA->AFRL = GPIOA_AFR7|GPIOA_AFR6|......GPIOA_AFR0
每个引脚的值再用一个宏定义。这里的宏定义不是写程序的时候直接写的,而是由条件编译确定。例如,想配置 PA4 为 SPI1_NSS 功能,在主程序中写定义
#define ASSIGN_SPI1_NSS_PA4
而在后面处理这个宏(gpiodef.c里面)的时候可以这么来做:
#ifdef ASSIGN_SPI1_NSS_PA4
        #define GPIOA_AFR4 0<<16
#endif
条件编译指令判断到 ASSIGN_SPI1_NSS_PA4 被定义了,于是定义 GPIOA_AFR4 这个宏,值是 0. 这样 GPIOA->AFRL 的相关位就得到了定义。类似的,如果想配置 PA4 为 USART2_CK 功能,在就主程序中定义
#define ASSIGN_USART2_CK_PA4
而 gpiodef.c 里面因为还有
#ifdef ASSIGN_USART2_CK_PA4
        #define GPIOA_AFR4 1<<16
#endif
这段,就会把 GPIOA_AFR4 宏定义,值为1。

注意,如果 PA4 引脚没有定义 AF 功能,那么 GPIOA_AFR4 这个宏就不会被定义,这样后面编译不能通过。所以还需要补充一个默认值:
#if !defined GPIOA_AFR4
     #define GPIOA_AFR4 0
#endif
这样如果发现没有定义必要的宏,就定义一个默认值。

前帖中已说过,这样简单处理的问题是如果不小心把宏定义拼错了,就不会被条件编译指定判断到,导致不希望的结果但是没有错误和警告。所以还需要加一重保险,在我现在的代码里面,实际是这样的:
#ifdef ASSIGN_USART2_CK_PA4
        #ifdef ENABLE_PA4_AF
                #error "PA4: multiple AF assignments"
        #else
                #define ENABLE_PA4_AF 1
        #endif
#endif
这里不直接定义 GPIOA_AFR4 这个宏,而先定义 ENABLE_PA4_AF,并判断是否已定义(避免写了两个复用功能分配到同一个引脚)。然后,在处理 USE_PA4 宏的时候,和 PIN_AF 宏做双重检查:
#if defined USE_PA4
        #ifdef ENABLE_PA4_AF
                #if !((USE_PA4) & PIN_AF)
                        #error "PA4 use: PIN_AF not set"
                #endif
                #define GPIOA_MODER4 GPIO_MODER_BF(MODE_AF,4)
                #define GPIOA_AFR4 GPIO_AFR_BF(ENABLE_PA4_AF,4)
        #else
                #define GPIOA_AFR4 0
                #if (USE_PA4) & PIN_OUT
                        #define GPIOA_MODER4 GPIO_MODER_BF(MODE_OUTPUT,4)
                #elif (USE_PA4) & PIN_IN
                        #define GPIOA_MODER4 GPIO_MODER_BF(MODE_INPUT,4)
                #elif (USE_PA4) & PIN_ANALOG
                        #define GPIOA_MODER4 GPIO_MODER_BF(MODE_ANALOG,4)
                #else
                        #error "PA4 use: mode undefined"
                #endif
        #endif
        #if (USE_PA4) & PIN_PULLUP
                #define GPIOA_PUPDR4 GPIO_PUPDR_BF(PULL_UP,4)
        #elif (USE_PA4) & PIN_PULLDOWN
                #define GPIOA_PUPDR4 GPIO_PUPDR_BF(PULL_DOWN,4)
        #else
                #define GPIOA_PUPDR4 0
        #endif
        #if (USE_PA4) & PIN_OPENDRAIN
                #define GPIOA_OTYPER4 1<<4
        #else
                #define GPIOA_OTYPER4 0
        #endif
#else
        #define GPIOA_MODER4 GPIO_MODER_BF(MODE_ANALOG,4)
        #define GPIOA_AFR4 0
        #define GPIOA_PUPDR4 0
        #define GPIOA_OTYPER4 0
#endif
这也是条件编译最关键的一段。
为了能工作,在 gpiodef.h 中事先定义一些必要的宏:
#define PIN_AF 0x8000
#define PIN_OUT 0x4000
#define PIN_IN 0x2000
#define PIN_ANALOG 0x1000
#define PIN_PULLUP 0x0200
#define PIN_PULLDOWN 0x0100
#define PIN_OPENDRAIN 0x0080
用它们来书写 USE_Pxy 宏的值,再到条件编译指令中用逻辑与去判断。    一旦定义了 USE_PA4, 但是后面的值没有 PIN_AF, PIN_OUT, PIN_IN, PIN_ANALOG 当中的一个(比如因为拼写错误的原因),就会发生错误。而且,如果使用了 PIN_AF, 但没有定义某个有效的 ASSIGN_xxx_yyy_PA4 这样的宏(比如因为拼写错误,比如写成不存在的功能),也会检测到错误。以及如果定义了有效的 ASSIGN_xxx_yyy_PA4 ,却没有定义 USE_PA4 为 PIN_AF,也会报错。这样可以减少大部分的程序书写错误。

(0)

相关推荐