numworks移植记录:8.按键扫描——用74HC595和74HC165扩展GPIO实现矩阵键盘

按键扫描------用74HC595和74HC165扩展GPIO实现矩阵键盘

在上一篇文章中,我们成功地将LCD驱动集成到了NumWorks的Ion层,实现了显示输出。接下来,我们需要解决输入问题------如何让ESP32-S3读取NumWorks的按键。

NumWorks原版采用按键矩阵 设计,直接使用MCU的GPIO连接行线和列线。对于一个典型的8行×8列的矩阵,需要16个GPIO引脚。ESP32-S3虽然GPIO数量不少,但考虑到其他外设(如PSRAM、LCD、USB、SD卡等)的占用,直接使用16个引脚会显得捉襟见肘。为了节省宝贵的GPIO,我们引入串行扩展芯片 :用74HC595 作为列驱动(输出),用74HC165 作为行读取(输入),并且让它们共用时钟引脚,只需少量GPIO即可驱动整个矩阵。

本文将详细介绍这种硬件设计方案、电路连接、以及如何在ESP32-S3上实现按键扫描,并最终将其适配到NumWorks的Ion::Keyboard接口中。


1. 为什么需要串行扩展?

1.1 原版NumWorks的按键矩阵

NumWorks的键盘是一个典型的矩阵结构,例如:

Col0 Col1 Col2 ...
Row0 A B C ...
Row1 D E F ...
... ... ... ... ...

扫描原理:MCU将某一列拉低(输出低电平),然后读取所有行的电平。如果某行被拉低,说明该行与当前列的交点处有按键按下。如此循环扫描所有列,即可检测所有按键。

原版设计中,列线和行线都直接连接到MCU的GPIO。对于8×8的矩阵,需要16个GPIO。

1.2 ESP32-S3的引脚压力

ESP32-S3虽然有40多个GPIO,但许多引脚有特殊功能或已被占用:

  • LCD I8080并口:至少占用8条数据线 + WR、DC、CS、RST等,约12~15个引脚。
  • PSRAM:通常占用专用引脚(如GPIO 26-32)。
  • USB、SD卡、调试串口等也会占用一些引脚。

如果直接使用16个GPIO做键盘,几乎不可能。因此,我们需要使用串行扩展芯片来大幅减少引脚占用。


2. 硬件设计方案:74HC595 + 74HC165

2.1 芯片简介

芯片 功能 特点
74HC595 8位串行输入/并行输出移位寄存器 用于输出,可级联,带有输出锁存器
74HC165 8位并行输入/串行输出移位寄存器 用于输入,可级联

2.2 工作原理

我们设计一个共用时钟、独立数据线的方案:

  • 时钟线(SCK):74HC595和74HC165共用同一个时钟引脚(例如GPIO 4)。
  • 数据线
    • 74HC595的数据输入(DS):用于向595写入列扫描数据(哪个列被拉低)。
    • 74HC165的串行输出(Q7):用于从165读取当前行的状态。
  • 锁存/加载引脚
    • 74HC595的锁存(RCLK):当一帧数据移位完成后,拉高锁存引脚,将数据并行输出到列引脚。
    • 74HC165的并行加载(PL):低电平时将当前行电平锁存到内部寄存器,然后可以通过时钟移位读出。

这样,总共只需要4个GPIO(SCK、595_DS、165_Q7、595_RCLK/165_PL,若共用则5个,但通常将RCLK和PL分开,以便独立控制)。

2.3 电路连接示意图

假设我们使用4个74HC595级联(用于16列)和4个74HC165级联(用于16行),但NumWorks通常是8列×8行,所以我们各用1片即可(可扩展性依然保留)。下面以8×8矩阵为例:

text

scss 复制代码
ESP32-S3           74HC595 (列驱动)                 74HC165 (行读取)
GPIO4  (SCK)  --->  SCK (pin 11)               ---> SCK (pin 2)  [共用时钟]
GPIO5  (DS)   --->  DS (pin 14)                   - [不连接]
GPIO6  (Q7)   ---  [不连接]                    <--- Q7 (pin 9)   (串行输出)
GPIO7  (RCLK) --->  RCLK (pin 12)                 - [不连接]
GPIO8  (PL)   ---  [不连接]                    ---> PL (pin 1)   (并行加载)

矩阵连接:
595并行输出 QA~QH (pin15,1-7) 接矩阵的列线0~7
165并行输入 A~H (pin11-4,3,10) 接矩阵的行线0~7

:如果595和165的时钟极性要求相同(通常都是上升沿移位),共用时钟没有问题。但需注意595的锁存(RCLK)和165的加载(PL)是独立控制的,因此需要两个额外的GPIO,总共4+2=6个。如果你希望进一步节省引脚,可以将RCLK和PL也共用一个引脚,但时序上要小心处理(先完成移位,然后同时锁存和加载,可能会互相干扰)。建议保留独立控制以确保稳定性。

2.4 节省引脚的效果

使用上述方案,驱动8×8键盘仅需:

  • SCK : 1
  • 595_DS : 1
  • 165_Q7 : 1
  • 595_RCLK: 1
  • 165_PL : 1 总共5个GPIO(如果RCLK和PL可合并则只需4个),相比16个GPIO节省了11个!

3. 按键扫描软件实现

3.1 扫描原理

扫描过程分为两步:

  1. 输出列扫描数据:通过74HC595将某一列拉低(其余列高电平)。由于595是并行输出,我们只需在软件中生成一个8位数据,其中只有一位为0(表示拉低),其余为1。
  2. 读取行状态:通过74HC165读取8行的电平。如果某行被拉低,说明该行对应的按键被按下。

为了提高抗干扰能力,通常会在每个扫描周期内读取多次并去抖动。

3.2 ESP-IDF中的GPIO控制

我们使用ESP-IDF的GPIO驱动来控制上述引脚。需要将SCK、DS、RCLK、PL配置为推挽输出,Q7配置为输入(可能带上拉)。

3.3 关键代码片段

以下是一个简化的按键扫描函数示例(假设有5个控制引脚):

c

ini 复制代码
#include "driver/gpio.h"

// 引脚定义(需与硬件一致)
#define PIN_SCK     4
#define PIN_595_DS  5
#define PIN_165_Q7  6
#define PIN_595_RCLK 7
#define PIN_165_PL   8

// 初始化GPIO
void keypad_init(void) {
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL<<PIN_SCK) | (1ULL<<PIN_595_DS) | 
                        (1ULL<<PIN_595_RCLK) | (1ULL<<PIN_165_PL),
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    gpio_config(&io_conf);
    
    io_conf.pin_bit_mask = (1ULL<<PIN_165_Q7);
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pull_up_en = GPIO_PULLUP_ENABLE;  // 内部上拉,确保未按下时高电平
    gpio_config(&io_conf);
}

// 向595写入一个字节(列数据)
void shift_out_595(uint8_t col_data) {
    for (int i = 0; i < 8; i++) {
        gpio_set_level(PIN_SCK, 0);
        gpio_set_level(PIN_595_DS, (col_data >> (7-i)) & 1);  // 高位先出
        gpio_set_level(PIN_SCK, 1);  // 上升沿移位
    }
}

// 从165读取一个字节(行状态)
uint8_t shift_in_165(void) {
    uint8_t row_data = 0;
    for (int i = 0; i < 8; i++) {
        gpio_set_level(PIN_SCK, 0);
        row_data = (row_data << 1) | gpio_get_level(PIN_165_Q7);  // 读取Q7(高位先出)
        gpio_set_level(PIN_SCK, 1);  // 上升沿移位
    }
    return row_data;
}

// 按键扫描:获取当前所有按键状态(矩阵形式)
void keypad_scan(uint8_t key_state[8][8]) {
    // 先拉低PL,锁存当前行电平到165内部
    gpio_set_level(PIN_165_PL, 0);
    gpio_set_level(PIN_165_PL, 1);  // 上升沿锁存(实际是低有效,拉高后保持)
    
    for (int col = 0; col < 8; col++) {
        // 准备列数据:只有当前列为0,其余为1
        uint8_t col_data = ~(1 << col);  // 注意:595输出高电平有效?需要看实际电路。通常595输出高电平驱动列线,所以拉低一列应输出0,其余输出1。
        shift_out_595(col_data);
        
        // 锁存到595输出(RCLK上升沿)
        gpio_set_level(PIN_595_RCLK, 0);
        gpio_set_level(PIN_595_RCLK, 1);
        
        // 延时一小段时间,等待信号稳定(可选)
        esp_rom_delay_us(5);
        
        // 读取当前行的状态
        uint8_t row_data = shift_in_165();
        
        // 将行数据存入状态矩阵(row_data的每一位对应一行)
        for (int row = 0; row < 8; row++) {
            key_state[row][col] = (row_data >> row) & 1 ? 0 : 1;  // 假设低电平表示按下
        }
    }
}

注意:实际电平极性取决于硬件连接。如果595输出高电平导通列线,那么拉低一列应输出0;如果行线上拉了电阻,按下时行线被拉低,那么读取到的电平0表示按下。你需要根据实际电路调整极性。

3.4 去抖动处理

机械按键存在抖动,通常需要在扫描中加入去抖动逻辑。简单的方法是:每隔10~20ms扫描一次,并记录前后两次扫描结果,只有当某按键连续两次检测到相同状态时才认为状态变化。NumWorks本身也有一套去抖机制,我们只需要提供原始的按键状态矩阵即可。

3.5 集成到Ion::Keyboard

NumWorks的键盘抽象层位于ion/include/ion/keyboard.hion/src/device/shared/drivers/keyboard.h。我们需要为ESP32-S3实现以下函数:

  • Ion::Keyboard::State:获取当前按键状态。通常返回一个Keyboard::State对象,其中包含所有按键的按下状态。
  • Ion::Keyboard::scan:执行一次扫描,更新内部状态。

在ESP32-S3的实现中,我们将上面的扫描函数封装起来,并按照NumWorks的按键映射(每个按键对应一个矩阵坐标)将原始矩阵转换为Keyboard::State

例如,在ion/src/esp32s3/keyboard.cpp中:

cpp

ini 复制代码
#include <ion/keyboard.h>
#include "keypad_hardware.h"  // 上面的硬件驱动函数

namespace Ion {
namespace Keyboard {

State scan() {
    uint8_t matrix[8][8];
    keypad_scan(matrix);  // 从硬件读取原始矩阵
    
    State state;
    // 清空state
    for (int i = 0; i < sizeof(state)/sizeof(state.uint8); i++) {
        ((uint8_t*)&state)[i] = 0;
    }
    
    // 将matrix映射到state的各个位
    // 假设按键映射表 keyMap[row][col] 对应 Keyboard::Key 枚举
    for (int row = 0; row < 8; row++) {
        for (int col = 0; col < 8; col++) {
            if (matrix[row][col]) {
                Key k = keyMap[row][col];  // 你需要定义一个映射表
                if (k != Key::None) {
                    state.setKey(k, true);
                }
            }
        }
    }
    return state;
}

State* state() {
    static State state;
    return &state;
}

}
}

其中keyMap需要根据NumWorks的键盘布局定义,可以在原版keyboard.h中找到。


4. 电路设计注意事项

  1. 电源去耦:每个74HC595和74HC165的VCC和GND之间应放置0.1μF陶瓷电容,靠近芯片引脚。
  2. 上拉电阻 :行线一般需接上拉电阻(10kΩ左右)至VCC,确保未按下时输入为高电平。如果使用了165的内部上拉(如通过gpio_pullup_en),也可以不接外部电阻,但165本身没有内部上拉,需要在ESP32的输入端启用上拉,或者外部上拉。
  3. 限流电阻:如果列线直接驱动LED(一般键盘没有),不需要;但为了防止短路,可在595输出和列线之间串联100Ω电阻。
  4. 级联扩展:如果将来需要更多按键(如16×8),可以将多个595和165级联,只需将前一级的Q7'(595的串行输出)连接到后一级的DS,以及将前一级的Q7(165的串行输出)连接到后一级的SER(串行输入),时钟和锁存/加载共用。软件上相应地增加移位字节数即可。

5. 总结

通过使用74HC595和74HC165串行扩展芯片,我们成功地将ESP32-S3的键盘接口从16个GPIO减少到5个GPIO,为其他外设留出了充足的空间。硬件设计简单可靠,软件扫描算法清晰,易于集成到NumWorks的Ion层中。

接下来,我们将进入更复杂的模块------存储模拟(Ion::Storage),即如何用ESP32-S3的NVS分区或SPIFFS来模拟NumWorks的内部闪存存储,以便保存用户数据和应用。敬请期待!

相关推荐
你家人养牛2 小时前
numworks移植记录:7.移植LCD驱动——添加到numworks中
嵌入式
你家人养牛2 小时前
numworks移植记录:10.编译问题汇总与解决方案
嵌入式
你家人养牛2 小时前
numworks移植记录:11.编译问题汇总与解决方案(二)
嵌入式
序安InToo7 天前
第6课|注释与代码风格
后端·操作系统·嵌入式
济61713 天前
FreeRTOS基础--堆栈概念与汇编指令实战解析
汇编·嵌入式·freertos
嵌入小生00713 天前
线程间通信---嵌入式(Linux)
linux·c语言·vscode·嵌入式·互斥锁·线程间通信·信号量
济61713 天前
ARM Linux 驱动开发篇---GPIO子系统详解-- Ubuntu20.04
linux·嵌入式·嵌入式linux驱动开发
charlie11451419113 天前
嵌入式C++教程——Lambda捕获与性能影响
开发语言·c++·笔记·嵌入式·现代c++·工程实践
嵌入小生00714 天前
线程(2)/ 线程属性 /相关函数接口--- 嵌入式(Linux)
linux·嵌入式·线程·软件编程·僵尸线程·马年开工第一学·线程属性