干货 | 怎样让 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 配置的部分:
// file: gpio_config.c
#include "stm32f0xx.h"
#include "gpiodef.h"
#define USE_PA3 PIN_OUT
#define USE_PA6 PIN_OUT
#define USE_PA13 PIN_AF
#define ASSIGN_SWDIO_PA13
#define USE_PA14 PIN_AF
#define ASSIGN_SWCLK_PA14
#define USE_PA9 PIN_AF
#define ASSIGN_USART1_TX_PA9
#define USE_PA10 PIN_AF|PIN_PULLUP
#define ASSIGN_USART1_RX_PA10
#define USE_PA7 PIN_AF
#define ASSIGN_SPI1_MOSI_PA7
#define USE_PA5 PIN_AF
#define ASSIGN_SPI1_SCK_PA5
#define USE_PA4 PIN_AF
#define ASSIGN_SPI1_NSS_PA4
#include "gpiodef.c"
这个 C 文件包含了一个 .h 文件,其中定义了 PIN_OUT, PIN_AF, PIN_PULLUP 这样的宏;然后用 #define 来书写需要用到的I/O引脚,没有写的会默认成模拟功能。最后一行 #include 的文件里面,才包含产生机器代码的地方。这段代码编译之后,产生一个函数 gpio_config(), 机器代码如下:
00000000 <gpio_config>:
0: 4b0c ldr r3, [pc, #48] ; (34 <gpio_config+0x34>)
2: 229c movs r2, #156 ; 0x9c
4: 6959 ldr r1, [r3, #20]
6: 03d2 lsls r2, r2, #15
8: 430a orrs r2, r1
a: 615a str r2, [r3, #20]
c: 4a0a ldr r2, [pc, #40] ; (38 <gpio_config+0x38>)
e: 2301 movs r3, #1
10: 425b negs r3, r3
12: 6013 str r3, [r2, #0]
14: 4909 ldr r1, [pc, #36] ; (3c <gpio_config+0x3c>)
16: 2290 movs r2, #144 ; 0x90
18: 05d2 lsls r2, r2, #23
1a: 6011 str r1, [r2, #0]
1c: 2188 movs r1, #136 ; 0x88
1e: 0049 lsls r1, r1, #1
20: 6251 str r1, [r2, #36] ; 0x24
22: 2180 movs r1, #128 ; 0x80
24: 0349 lsls r1, r1, #13
26: 60d1 str r1, [r2, #12]
28: 4a05 ldr r2, [pc, #20] ; (40 <gpio_config+0x40>)
2a: 6013 str r3, [r2, #0]
2c: 4a05 ldr r2, [pc, #20] ; (44 <gpio_config+0x44>)
2e: 6013 str r3, [r2, #0]
30: 4770 bx lr
32: 46c0 nop ; (mov r8, r8)
34: 40021000 .word 0x40021000
38: 48001400 .word 0x48001400
3c: ebeb9a7f .word 0xebeb9a7f
40: 48000400 .word 0x48000400
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,也会报错。这样可以减少大部分的程序书写错误。