4x4矩阵键盘实拍照如下图。其构成是4行(L1:4)x 4列(R1:4)共16个按键,当第n行、第m列的按钮(n, m)按下时,引脚 Ln 与 Rm 导通:
有一篇文章,对矩阵键盘的接口讲解得很详细。概括起来说,按键检测分为3个阶段。第一个阶段,扫描行。行I/O口设为input模式,使用上拉电阻。列I/O口设为output模式,输出0。逐行扫描,某一行若没有按键按下,则在上拉电阻的作用下pin值读取为1;若该行任一按键按下,则被按键短路到列I/O口,因此pin值读为0。检测到有按键被按下后,进入第二阶段,列扫描,以确定被按下的按键的列。列扫描阶段,行/列的I/O模式互换,即:行I/O口设置为output模式,输出0;列I/O口设为input模式,使用上拉电阻。类似于行扫描,逐列进行扫描,当读取到pin值为0则表明被按下的按键属于该列。通过第一、二阶段,就能确定被按下的按键。第三阶段,监听被按下的按键的列I/O口,直到pin值为1,即表明按键被松开。
关于上拉/下拉电阻,这里有一篇介绍文章。上拉电阻的作用在于,在常态下,按钮开放,IO口被“往上拉”到VDD,读数为1;当按钮闭合,I/O口通过按钮短路到VSS,读数为0;而VDD通过上拉电阻和按钮与VSS连通。若没有上拉电阻的存在,则VDD与VSS短路,会造成灾难性的后果,这显然是必须避免的。使用上拉电阻时,按钮开放时,pin值为1;当按钮闭合时,pin值为0。即,pin值与按钮闭合状态相反,这称为“负逻辑”。
在前述矩阵键盘 的接口算法中,三个阶段都使用了上拉电阻。其检测逻辑为负逻辑。
STM32的I/O口内部电路中包含有上拉电阻和下拉电阻,可以通过程序启用或禁用。
在流水灯实验的硬件基础上,增加矩阵键盘接口。4x4矩阵键盘共有16个按键,4个LED刚好可以显示16个二进制值(0-0x0F)。
矩阵键盘的按键检测是分阶段进行的,因此,程序的主体结构特别适合使用“状态机”设计模式。下列代码中,4个行I/O口的Label依次为R1:4,列I/O口为C1:4。首先定义状态结构体及3个实例:
typedef struct { void (*enter)(); uint8_t (*loop)(); } App_ScanningState; #define App_STAY 0 #define App_LEAVE 1 void rowScanningEnter(); uint8_t rowScanningLoop(); void colScanningEnter(); uint8_t colScanningLoop(); void colScanningPressedEnter(); uint8_t colScanningPressedLoop(); App_ScanningState rowScanning = { rowScanningEnter, rowScanningLoop }; App_ScanningState colScanning = { colScanningEnter, colScanningLoop }; App_ScanningState colScanningPressed = { colScanningPressedEnter, colScanningPressedLoop }; App_ScanningState *currState = &rowScanning;
结构体 App_ScanningState 表示1个状态,当进入该状态时,调用其 (函数指针)成员enter() 。在程序主循环中,则调用其 loop() 成员。loop() 函数返回值为 App_STAY 或 App_LEAVE,若返回前者,则表明应该停留在该状态,下次主循环将再次调用此状态的 loop() 函数;反之,若返回后者,则表明应该切换到下一个状态。
rowScanning, colScanning, colScanningPressed 3个App_ScanningState实例,分别为行扫描阶段、列扫描阶段及第三阶段(检测按键松开)。程序初始时为行扫描状态,例如,使用CubeMX自动生成的初始化代码。程序主循环内的代码为:
if (App_LEAVE != currState->loop()) { return; } // Button released if (currState == &colScanningPressed) { lightLedsUp(key); } // Next state currState = currState == &rowScanning ? &colScanning // : currState == &colScanning ? &colScanningPressed // : &rowScanning; currState->enter();
首先,调用当前状态的 loop() 函数,其返回值表明是否应该切换到下一个状态。如果切换到下一个状态,则调用其 enter() 函数。如果是离开第三阶段,则已检测到一次按键事件(按下并松开),根据按键键值(0-15)点亮LED。点亮LED的函数定义如下,其无外乎按位依次点亮或熄灭每一个LED:
#define BIT_TO_PIN_VALUE(key, bit) ( (1 & (key >> bit)) ? GPIO_PIN_SET : GPIO_PIN_RESET ) void lightLedsUp(uint8_t key) { HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, BIT_TO_PIN_VALUE(key, 3)); HAL_GPIO_WritePin(LED2_GPIO_Port, LED2_Pin, BIT_TO_PIN_VALUE(key, 2)); HAL_GPIO_WritePin(LED3_GPIO_Port, LED3_Pin, BIT_TO_PIN_VALUE(key, 1)); HAL_GPIO_WritePin(LED4_GPIO_Port, LED4_Pin, BIT_TO_PIN_VALUE(key, 0)); }
对于行扫描状态,进入该状态时,应该对行、列的I/O口进行设置。也即,在其enter() 实现中设置行I/O口为input模式,并启用其内部上拉电阻;列I/O为output模式,并输出0。其 loop() 实现则依次检测行I/O口是否读数为0,若读数为0,则表明该行有按键按下,记下行号,并离开本状态:
#define configInputPullUp(port, pin, GPIO_InitStruct) { /* HAL_GPIO_WritePin(port, pin, GPIO_PIN_RESET); */ (GPIO_InitStruct)->Pin = pin ; (GPIO_InitStruct)->Mode = GPIO_MODE_INPUT ; (GPIO_InitStruct)->Pull = GPIO_PULLUP ; (GPIO_InitStruct)->Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(port, GPIO_InitStruct) ; } #define configOutputLow(port, pin, GPIO_InitStruct) { (GPIO_InitStruct)->Pin = pin ; (GPIO_InitStruct)->Mode = GPIO_MODE_OUTPUT_PP ; (GPIO_InitStruct)->Pull = GPIO_NOPULL ; (GPIO_InitStruct)->Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(port, GPIO_InitStruct) ; HAL_GPIO_WritePin(port, pin, GPIO_PIN_RESET); } #define DEBOUNCE_DELAY 5 void rowScanningEnter() { GPIO_InitTypeDef GPIO_InitStruct; // Row pins: input, pull-up enabled configInputPullUp(R1_GPIO_Port, R1_Pin, &GPIO_InitStruct); configInputPullUp(R2_GPIO_Port, R2_Pin, &GPIO_InitStruct); configInputPullUp(R3_GPIO_Port, R3_Pin, &GPIO_InitStruct); configInputPullUp(R4_GPIO_Port, R4_Pin, &GPIO_InitStruct); // Col pins: output 0 configOutputLow(C1_GPIO_Port, C1_Pin, &GPIO_InitStruct); configOutputLow(C2_GPIO_Port, C2_Pin, &GPIO_InitStruct); configOutputLow(C3_GPIO_Port, C3_Pin, &GPIO_InitStruct); configOutputLow(C4_GPIO_Port, C4_Pin, &GPIO_InitStruct); } GPIO_PinState checkPressedLow(GPIO_TypeDef *port, uint16_t pin) { if (GPIO_PIN_RESET == HAL_GPIO_ReadPin(port, pin)) { // Delay & read again HAL_Delay(DEBOUNCE_DELAY); return HAL_GPIO_ReadPin(port, pin); } return GPIO_PIN_SET; } uint8_t rowScanningLoop() { if (GPIO_PIN_RESET == checkPressedLow(R1_GPIO_Port, R1_Pin)) { key = 0; return App_LEAVE; } if (GPIO_PIN_RESET == checkPressedLow(R2_GPIO_Port, R2_Pin)) { key = 1 << 2; return App_LEAVE; } if (GPIO_PIN_RESET == checkPressedLow(R3_GPIO_Port, R3_Pin)) { key = 2 << 2;key return App_LEAVE; } if (GPIO_PIN_RESET == checkPressedLow(R4_GPIO_Port, R4_Pin)) { key = 3 << 2; return App_LEAVE; } return App_STAY; }
注意,在读取pin值时,为了de-bouncing,增加了一个5ms的延时重读。一般,de-bouncing延时取5-10ms。
列扫描状态的实现与行扫描相类似,这里便不再给出代码了。需要说明的是,程序中使用了一个字节型全局变量 key 用来保存键值,其第2-3位为行号(0-3),第0-1位为列号(0-3),因此,key 的值为0-0x0F,依次对应16个按键。
而第三阶段无需改变I/O口设置,只需检测被按下按键所在的列是否读取pin值为1。读取pin值为1表明按键被松开,应该离开此状态,切换回行扫描状态:
uint8_t colScanningPressedLoop() { int col = 3 & key; if (0 == col) { if (GPIO_PIN_SET == HAL_GPIO_ReadPin(C1_GPIO_Port, C1_Pin)) { return App_LEAVE; } } else if (1 == col) { if (GPIO_PIN_SET == HAL_GPIO_ReadPin(C2_GPIO_Port, C2_Pin)) { return App_LEAVE; } } else if (2 == col) { if (GPIO_PIN_SET == HAL_GPIO_ReadPin(C3_GPIO_Port, C3_Pin)) { return App_LEAVE; } } else { // 3== col if (GPIO_PIN_SET == HAL_GPIO_ReadPin(C4_GPIO_Port, C4_Pin)) { return App_LEAVE; } } return App_STAY; }